TL;DR. TapMap has two marketplaces. In the first one people publish maps: anyone collects their own set of places, and others switch it on as a layer over the main map. In the second one people publish styles: anyone (or their AI agent) repaints the map, adds their own icons and animations, and publishes the result. Below is how we got there, the mistakes we made and the decisions we took.
Try the style editor: styles.tapmap.net
The project in short
TapMap is a social network built on maps. The question it answers is not "what's nearby" but "where should I go right now, and with whom". We started with Thailand.
The client is Flutter: Android, iOS and web from one codebase, with Riverpod for state. The map is Mapbox. The backend is Postgres with PostGIS.
Our main map is curated: places are collected and tagged by our own pipeline. The marketplaces are what people put on top of it.
Part 1. The map marketplace
From moderation to personal maps
At first we did what everyone does: a person adds a point, taps "Send for review", and a moderator decides whether it goes into the shared database. It scaled badly and it confused people: "I added my favourite coffee shop, where is it?"
So we flipped the model. A person's point belongs to their map and doesn't appear anywhere else. The shared database stays curated, and everyone gets their own maps:
- a default "My map" is created automatically on first sign-in;
- you can create as many maps as you like, each with its own name, icon, colour and description;
- you can invite friends as admins, and they add points too;
- any map can be published to the marketplace;
- someone else "downloads" it and turns it on as a layer, and there is no limit on how many layers are on at once.
The data model turned out simple: a map, map members, map subscriptions, and a map reference on each point. No reference means the point is from the shared database; a reference means it's someone's collection. That gives one rule for all analytics: count the shared database separately, or other people's collections leak into the numbers.
Permissions live in one place
Access rules here are more than "this row is mine". They are a small piece of business logic: "only a friend can be made an admin", "you can only subscribe to a published map", "the default map can't be deleted". We put it into server-side functions instead of spreading it across declarative per-table policies. That way it's easier to read and harder to weaken by accident in the next change.
Ordering in the marketplace is simple for now: popular maps go first, and empty maps aren't shown at all. A shop window full of empty boxes is worse than an empty window.
How a store chain directory nearly took the client down
The client was designed for personal maps: a dozen or two favourite places. Then a map of a convenience store chain appeared in the marketplace, with more than sixteen thousand points. Three problems showed up at once.
- The whole layer was downloaded on every sync, even though the directory hadn't changed in weeks.
- The big JSON was parsed on the main isolate. The stutter was visible.
- The nastiest one: eviction. The main map lives entirely on the phone: the client downloads all points once and then asks the server only for changes. That cache has a ceiling, and map layers went into the same cache. The chain directory took up space, eviction threw out the oldest entries, which were points from the shared database. A subscriber to the directory silently lost thousands of cafés from the map, and sync didn't bring them back: from the server's point of view they hadn't changed.
What we did.
A version on every map. Any edit that changes what the client sees bumps the version number. The client first asks a cheap question, "what are the current versions of my maps?", and re-downloads only the maps whose number changed. A directory that hasn't changed isn't downloaded, isn't parsed and isn't written to the cache again.
The subtle part is deciding what counts as a change. Nightly enrichment runs touch points all the time: photos, ratings, attributes. If any touch bumped the version, the client would re-download the directory every night because of data that isn't even in the layer. So the version only grows when fields that actually end up in the layer change.
The version is a counter, not a "last modified" timestamp. Finding the maximum over sixteen thousand rows on every poll from every subscriber means paying CPU for the question "did anything change?". A counter answers it with a single read.
Parsing in the background. Large responses are decoded in a separate isolate.
Each kind of data gets its own budget. Map layers no longer share cache space with the main database. We bumped the snapshot format along the way, so everyone re-downloaded the old one in full once: phones already held snapshots "poisoned" by the directory.
A big map is a directory, not a set of pins. A person's own pins are always visible: they're their places. But if sixteen thousand stores were shown the same way, they'd take every free spot before the cafés did. So a big map is treated as a directory: its points join the shared importance ranking and appear as you zoom in, and chain locations only get the space left over by regular places.
The first frame comes from disk
Each layer is stored on disk as a separate file together with its version. On a cold start, layers of enabled maps are loaded from disk before the network, so your own pins are there in the first frame and don't disappear offline.
Two more details that saved us debugging time:
- Syncs run strictly one at a time. Otherwise a retry after a failure and a scheduled pass would download the same layer twice and write the same file concurrently.
- Account switches have a "generation". A response requested for the previous account no longer lands in the new account's cache after sign-out.
final generation = _generation;
bool stale() => generation != _generation || !(alive?.call() ?? true);
final run = _tail.then((_) async {
if (stale()) return;
// ...
});
_tail = run.then<void>((_) {}, onError: (Object _) {});
Your choice survives a reinstall
Which maps are on and which style is selected used to be remembered only by the device. A different phone, a messenger's in-app browser or a reinstall, and everything reset to defaults. Now the choice is stored in the account, and the newer record wins. Only the person's own actions are sent to the server, not settings restored at startup. Otherwise an old phone opened a week later would overwrite yesterday's choice made on the new one.
Search finds maps too
If you've downloaded a chain directory, searching "7 eleven" should find the map, not three hundred addresses. Your own maps and the most popular marketplace maps are matched by name right on the phone: from the start of any word, ignoring case, punctuation and spaces ("7 eleven" = "7-Eleven"). Picking a map in search turns it on and downloads it if needed.
Part 2. The style marketplace
Before: four styles hard-coded in the app
We had themed styles, Western, Neon, Terminal and Comic, each with its own place icons and animations. The app knew each of them by name in code. A fifth style meant a new app build.
We wanted anyone to be able to make a style, by hand in an editor or by handing the job to an AI agent. That meant a style had to become data, not code.
A style pack: base style + manifest
A style has two parts. The first is a regular Mapbox GL style: water, roads, parks, labels. The second is a manifest describing everything the app paints on top of that base:
{
"name": {"ru": "Неоновый город", "en": "Neon city"},
"base": "dark",
"buildings": {
"low": "#06131d", "mid": "#0a2734", "high": "#0f3d4d",
"opacity": 0.8, "windows": "windows.png"
},
"icons": {
"food": {"image": "icons/food.png"},
"clubs": {"frames": ["icons/clubs/f00.png", "icons/clubs/f01.png"], "fps": 12}
}
}
-
baseis a light or dark base. It decides how point labels are drawn. -
buildingsare 3D buildings: wall colour by building height and a window texture. -
iconscovers any icon the app draws: category pins, favourites, people's homes, event badges. Either a picture or a frame-by-frame animation. Anything missing from the manifest falls back to the app's own icon.
A style is code, even if it's JSON
A Mapbox style contains URLs: where to get tiles, fonts and sprites. If a style pointing at someone else's server got into the marketplace, every phone using that style would start calling that server. From tile requests, that server would learn the approximate location of everyone looking at the map. For an app where people share their location with friends, that's unacceptable.
So marketplace styles are checked on the server, and third-party URLs aren't allowed. Besides security, the server makes sure a style isn't too heavy for a phone.
Full validation of the style itself (are the expressions valid, do the layers exist) happens in the browser editor. It renders the style with the same engine as the app and won't let you publish with errors. The split works out: the server is responsible for safety, the editor for correctness.
An editor and an API for AI agents
The editor lives at styles.tapmap.net: repaint water and roads, change the label font, tune 3D buildings, replace icons with a picture or an animation.
The API does all of the same, and we designed it with AI agents in mind. A person gives an agent a key, and the agent works on their behalf. We learned a few things by watching agents make mistakes.
Targeted edits instead of rewrites. It's awkward for an agent to rewrite a whole Mapbox style just to change the water colour. So there are operations: "paint layers matching a mask", "replace a colour everywhere", "remove a layer", "add a layer before a given one".
An edit that touched nothing is an error, not silence. If an agent's layer mask misses, it finds out right away, not three steps later when the style "looks somehow wrong". Every response says how many layers were affected.
A catalogue instead of guessing. A separate request returns every icon id with its size, the available fonts and the templates. The agent doesn't have to guess what exists.
In the end, "make it cyberpunk" turns into a few requests: create a draft from a template, repaint, upload icons, publish. The docs are open at styles.tapmap.net/docs.
Publishing: versions are immutable
Every publish creates a new version, and old ones are never touched. A phone that's in the middle of downloading version three will finish downloading it even if the author has already shipped version four. A new version is built on the side and becomes available all at once or not at all: there are no half-published versions.
All of a style's files are packed into one archive. An animated style is hundreds of small images, and a hundred requests over a mobile network are slower and flakier than one. The phone checks the archive checksum and won't install a pack that arrived corrupted. The format is the same as our built-in themed styles, so the app downloads both with the same loader.
People's styles live in a separate namespace in the app, so their names never clash with built-in packs and the loader knows right away where to fetch them from.
If a style is taken down from the marketplace, people who already installed it keep it. Taking someone's map away in the middle of a walk is bad UX.
Why animation only plays on the selected pin
We honestly wanted to animate every icon right on the map and tried the two obvious approaches. Both were bad:
- a separate layer per frame means copying the heaviest layer of the map (selection, collisions, labels) for every animation frame;
- swapping the image on every frame flickers visibly and churns textures on the GPU.
So on the map an animated icon shows its first frame. The animation plays on the selected pin, where the icon is drawn by a Flutter widget on top of the map. All frames are decoded in advance, otherwise the first loop of the animation flashes blank.
Moderation without a queue
Styles, like maps, are published immediately with no pre-moderation. Protection works after the fact: anyone can report a style right from the app, the team takes down violations, and the author can't republish a style that was taken down. The rules are in the terms of use, and the editor shows them as a checklist before publishing. The main one: no other people's logos or trademarks.
Remixes
Any published style can be copied and reworked, and the copy keeps a note of which style it came from. "Like theirs, only pink" takes one button, and the origin isn't lost.
What we'd do differently
- Layer versions should have been there from day one. "Personal maps are small" holds right up to the first directory. A version counter and a file per map cost next to nothing; the rework with a migration of client snapshots cost noticeably more.
- Limits shouldn't be shared. A shared cache ceiling silently evicted points from the main database. We didn't notice anything until cafés disappeared from the map. Separate budgets for different kinds of data are much easier to debug.
- Anything that comes from users and runs on other people's devices is code. Even if it's "just a JSON style". Treat it accordingly.
What's next
- sending changes to map layers instead of whole layers, once directories start changing often;
- vector tiles instead of a list of points for the main map;
- recommendations in the marketplaces: maps are sorted by popularity now, and we'd like to sort them by what fits a particular person too.
You can try the style editor at styles.tapmap.net (sign in with a TapMap account). The app itself is at tapmap.net.
If you've built something similar with custom Mapbox or MapLibre styles, especially icon animation without flicker, tell us in the comments how you solved it.
Top comments (0)