Naming your city without telling anyone where you are
Citylap shows friends which city you're in. Turning coordinates into a city name happens entirely on your phone, using a 24.6 MB index of the world cut into geohash cells.
448,033 cells · 260 country datasets · zero network calls↓ or Space to advance
The server never sees your coordinates
Citylap is end-to-end encrypted, so there's no server that could look up the city for you. Your phone has to work it out before encrypting. Friends are grouped by comparing city keys exactly, so a wrong answer means two people in the same city don't show up together.
The world's borders don't fit in an app
The obvious approach is to ship every city boundary and test which one contains you. The Who's On First admin data behind it is 5.2 GB of GeoJSON even compressed: too big to bundle and too slow to scan on a phone.
Instead, every answer is computed in advance. The app ships only a lookup table from
geohash → place, with no geometry at all.
Each character zooms in 32×
A geohash cuts the world into 32 cells, then cuts each of those into 32 more, and so on. Each character picks one cell inside the previous one, so a longer hash is a smaller box nested inside the shorter one.
Each panel zooms into the highlighted cell of the one before it. Green is Guatemala City; lines are department and municipal boundaries.
Guatemala from the shipped index, with neighbouring countries faded. Each square is one stored cell.
Fine where places are small, coarse where they're not
Smaller cells are more accurate but make the file larger. The index stores fine cells only where they change the answer, such as boundaries between dense municipalities. Elsewhere it keeps a single coarse cell.
Places whose bounding box covers no more than about 1,000 km² are allowed down to precision 5. Large, sparse ones such as the jungle of Petén stay at precision 4.
Click or tap anywhere to look up that point.
A lookup is a longest-prefix match
Hash the point at the finest precision, then drop a character at a time until a stored cell matches. Whichever place owns that cell is the answer. In practice all the prefixes go into a single indexed query.
Cells ignore national borders
A cell that straddles a border can belong to only one country. Three build rules keep the damage small:
Home-cell ownership
A place owns the cell containing its own centre, even when a larger neighbour would outrank it.
Country-scoped rollups
A metro area can absorb its own suburbs, but never a town across the border.
Deterministic merit
Contested cells are decided by a fixed total order, so batch order can't change the result.
San Diego / Tijuana, with the real city and national boundaries behind the cells. A cell on the border can still belong to only one side, which is the error a 5 km grid can't avoid.
A cabin in the Adirondacks should read “New York”, not “New York City”.
When a point can only be resolved coarsely, the honest answer is a region or country. A confident city name hundreds of kilometres away is wrong in a way users can't detect.
Moving out from Lake Placid, each label sits inside the one below it:
Tested against 40,523 towns worldwide
For every town in the index, does a point at its own centre come back as that town? All of them are checked, not a sample.
The known limits, such as twin towns facing each other across a border and places whose published centre lies outside their own boundary, are tracked as open issues.
Guatemala City. People along the road out to Fraijanes think of themselves as living in the city, so
curation/gt.json relabels those four cells. Mixco and Villa Nueva keep their own names.
Where the rules stop, local knowledge takes over
No generic rule knows how residents name their own neighbourhood, or that the source data filed two Maltese towns under Mali. Those judgment calls live in small per-country overlay files, applied after the build.
Only fine cells
A relabel starts at a minimum precision, so coarse fallback cells keep their honest regional names.
Proven by probes
Each entry lists points that must get the new label and guard points that must not change. If any probe fails, the whole overlay is rolled back.
Fixes upstream data too
Records misfiled under the wrong country can be pulled back where they belong.
Know a place better than the data? Send a pull request
Edit one country's file
curation/<country>.json. Validation rejects any id from another country, so a change can only affect the country it's named for.
Explain the call
Write a rationale that a reviewer without local knowledge can evaluate.
Pin it with probes
Add at least one positive probe and one guard. Once merged, probes become permanent regression tests.
Dry-run and review
Conflicting or chained merges are rejected before anything is written.
{
"country": "GT",
"entries": [{
"op": "merge",
"into": 421169087,
"absorb": [1108695621, 421185999],
"minPrecision": 5,
"rationale": "The corridor functions as part of the city…",
"probes": [
{ "lat": 14.4801, "lon": -90.4175, "expect": "Guatemala City" },
{ "lat": 14.6339, "lon": -90.6064, "expect": "Mixco",
"note": "guard: Mixco stays itself" },
{ "lat": 14.5567, "lon": -90.7337, "expect": "Antigua Guatemala",
"note": "guard: must not leak beyond the metro" }
]
}]
}
Simplified from curation/gt.json.
Use it in your own app
offline-geocoder runs in Node and in Expo / React Native via expo-sqlite.
It ships the build scripts, a lockfile that pins each country's data snapshot, and curation files
for the cases the rules can't handle.
MIT licensed, building on Luca Spiller's original offline-geocoder. Boundary data from Who's On First. The cells on every map are drawn directly from the index that ships in the Citylap app; the boundaries behind them are Who's On First polygons.
// Node (Expo: see the README) const geocoder = require('offline-geocoder')({ database: 'geocoder-boundary.db', reverseMode: 'boundary', }) const place = await geocoder.reverse(14.6007, -90.5133) // → { id: 421169087, name: 'Guatemala City', … }