Map and markers#
Every route stop, find and bot on the map is drawn by our own code rather than by the map provider's default pin, which is why the look is consistent across the app and the website — and why the cost of drawing it is something we can measure.
One marker layer#
Markers are not map annotations: there are no pins, circles or emoji floating over the figure. That was an earlier version and it was replaced on purpose.
- In the app one GL layer draws all the markers; on the website the same shapes come from sprites generated from the same palette. The result is full control over the look and zero style sources and zero style layers taken from the map style.
- All figures on the map — you, other players, lobby members and bots — go into one batched layer, instead of a dozen separate ones. Three bots cost about one extra draw call per frame.
- One frame writes the camera once. The follow-the-player loop, gestures and animations do not compete for the camera, and the pitch changes while you are pinching rather than snapping after you lift your fingers.
- The draw plan is recomputed about four times per second, and only when the camera has actually moved. The same code path that mounts the layer is the one that pushes the plan, so coming back from the settings screen does not lose the markers.
- Marker sizes are computed in screen pixels, then converted to metres — that is what prevents a marker from turning into a building-sized flag as you zoom out.
- Marker clusters (circles with a number) were removed completely. Markers are visible at every zoom; from far away they are simply smaller.
Types and colours#
| Object | Look | When it is visible |
|---|---|---|
| Find — not collected | a block: dark navy base, gold box and a cream spark above it | at every zoom; 14–30 px on screen, with the floor of 14 px so it cannot shrink to nothing |
| Find — collected | the same block at 55 % size, greyed out, no spark | from zoom 17 only, so collected ones do not clutter the view from a distance |
| Route start | a pennant on a post with a gold cap (height 1.00 m) | when the route is opened |
| Next stop | a taller post with a gold ring and a spark, pulsing (0.96 m) | always kept in view — it is never dropped by the drawing limit |
| First stop | a post with a gold ring (0.56 m) | when the route is opened |
| Ordinary stop | a plain post (0.52 m) | when the route is opened |
| Completed stop | low, greyed out, no gold (0.24 m) | when the route is opened |
| Bot | a ring under the feet in the accent colour plus a name pill | while on duty, according to the bot's schedule |
| Other players | plain figures, without a ring | only when both sides have sharing enabled |
Colour is meaningful, not decorative: the route family uses turquoise with gold highlights, finds use the gold box, and a completed element loses the gold entirely. The ring under a bot is the same accent colour as the app (#2A9D8F during the day, #6FD3C2 at dusk and at night, when the map is dark).
Marker size on screen is a ramp: about 14 px far away, growing to roughly 34 px at walk-in zoom for stops, with a ceiling of 40 px to accommodate the pulse. A bot's ring has a hard floor of 26 px in diameter and grows to 52 px at walking zoom — that is why you can find a bot even when the figure itself is only a few pixels tall.
Icons from one source#
The same shapes are used by two different renderers, so the numbers that describe them must live in one place:
| Element | Where it comes from |
|---|---|
| Marker sprites on the website | one shared module that also owns the palette and the zoom thresholds |
| Marker models in the app | vertex buffers built in the app's own geometry code, drawn by a single custom layer |
| Icon definitions | tools/marker-icons/ — the source for the icon generator and its test |
| Labels | finds get names from zoom 16.5, bot names from zoom 14.4, and only the three nearest bots show a name |
| Parity | an automated test compares the JavaScript and Kotlin tables — the same sizes, thresholds, colours and state order on both sides |
The state order matters as much as the colours: a stop is drawn as next/focused → completed → first → ordinary. That is the same priority in the app and on the website, so “the highlighted stop” means the same thing everywhere. On the website a name is placed below the marker so that it never covers the model.
Map performance#
Performance is measured with the same scenario ladder every time, before and after each optimisation: one pawn, then finds, then a route, then other players, then a lobby, then bots.
| Measurement | Value |
|---|---|
| Cost of three bots | +1 draw call per frame |
| Marker layers in the style | 0 sources, 0 style layers |
| Draw calls of the find layer at close zoom | 52 (26 blocks plus 26 shadows) |
| Draw calls of the find layer when zoomed out | 26 — the shadow is skipped, because at 14 px it is a two-pixel smudge |
| Repaints per frame with markers in view | 1 up close, 0 when they are static and far away |
| Hard limit of drawn 3D markers | 40 — beyond that, the ones nearest the centre of the set are drawn, chosen deterministically |
| Marker contrast against the background (colour difference, ±8 px window) | find 143.5 → 173.6, route start 74.6 → 120.3, route stop 71.2 → 122.3 |
The last row is the night-map work: the readability of markers on the dark style was improved by changing the shared sprite plan — the same drawings in the app and on the website — rather than by pasting an outline in one place. Because the thresholds and palettes are shared, a change made for the website cannot silently miss the app: the parity test fails instead.
Documented for app version 0.120.1 (updated 2026-09-26).
Machine translation — the Polish version prevails.