Transmission #004: Chunk Buffer, Location Map, Overpass Retries, and Collision
Today’s focus was turning the map from a single fixed grid into a streaming 3×3 chunk system, making Overpass more resilient, and giving the world proper collision so the player can’t fall through roads or walk through buildings and water.
Where Things Stood
The map was one OSMMapDisplay building a fixed tile grid (e.g. 2×2) around a center set once at start (GPS or inspector). No streaming, no follow-the-player. Overpass did one request per fetch with no retries. Terrain had a collider, but roads and water were mesh-only; buildings from cubes had BoxCollider; prefabs and decor depended on whatever the prefab had—so plenty of holes to fall through or walk through.
Target Behavior
Chunk grid: A 3×3 set of chunks centered on the tile under the player. One chunk = one OSM tile. When the player moves to another tile, we unload chunks that leave the 3×3 and load the new ones (e.g. moving east: unload a,d,g; load j,k,l). Unload when a chunk is 2+ tiles away from center. Same (tileX, tileY) always produces the same OSM/elevation data, so coming back to a,d,g reloads them identically—and later we can cache by that key.
Single world space: A fixed world origin (e.g. center of the tile the player started on). All chunk GameObjects are positioned in that world so coordinates are stable for future harvest/mine and gameplay.
Location-based load: Initial center from GPS (mobile) or a default/inspector fallback, so the first 3×3 is around the player’s location.
Overpass: Retries (e.g. 3 attempts with backoff) in the existing Overpass request path.
Collision: Roads, water, buildings, and decor get colliders so the player can’t fall through roads, walk through houses or trees, or pass through water.
How tiles load and unload as you move
The coordinator always keeps a 3×3 window of chunks centered on the tile under the player. Each cell below is one OSM tile (one chunk). The player is in the center tile.
Initial state — player in center tile e:
North
a b c
d [ e ] f ← center tile (player here)
g h i
West East
South
Player moves east into tile f. The center tile becomes f, so the 3×3 window shifts east. Chunks more than 1 tile away from the new center are unloaded; chunks that are now within the 3×3 are loaded:
Unload: a, d, g (west column, now 2 tiles left of center)
Load: j, k, l (new column to the east of c, f, i)
b c j
e [ f ] k ← new center (player here)
h i l
Same idea for any direction: move north and the window shifts north (unload g,h,i; load the row above a,b,c); move diagonally and you unload/load the corner chunks. The world origin stays fixed, so chunk positions in world space are stable and returning to a previous tile reloads the same data.
1. Coordinate and Tile Helpers (MapCoordinateHelper)
We need two helpers that stay stateless and reusable (and cache-friendly later):
- TileToLatLon(tileX, tileY, zoom, out lat, out lon) — inverse of the existing lat/lon → tile math (OSM slippy map). So a chunk at
(tileX, tileY)can get its OSM center. - WorldToTile(worldX, worldZ, originTileX, originTileY, zoom, worldScalePerTile, out tileX, out tileY) — so the coordinator can compute the current center tile from the follow target’s world position: continuous tile = origin + (worldX / worldScalePerTile) and origin − (worldZ / worldScalePerTile), then round to integer.
2. MapChunkCoordinator (3×3 Load/Unload)
New script: MapChunkCoordinator. It holds zoom, world scale per tile, and tile grid size. It has a follow target (e.g. player or flying beast). Origin tile (originTileX, originTileY) is set once at first run (from first GPS fix or initial lat/lon); world (0,0,0) is the center of that tile. Each frame (or at interval) we take the target’s world XZ, run WorldToTile, and get the current center tile. We keep a 3×3 set of chunk keys around that center.
Loaded chunks live in a dictionary keyed by (tileX, tileY). Each chunk GameObject is a child of the coordinator and is positioned so that +X = East, +Z = North, and the origin chunk sits at (0,0). When a (tileX, tileY) enters the 3×3, we instantiate a chunk (prefab or built in code), set its position, set its center lat/lon via TileToLatLon, and let it build (raster + terrain + Overpass + 3D). When a chunk leaves the 3×3 (Chebyshev distance > 1), we unload it and remove it from bookkeeping.
The coordinator also exposes the map API for the player and GPS: IsMapReady, HasTerrainForHeight, SampleHeightAtWorld, LatLonToWorld. So PlayerController and GPSFlyingController can talk to the coordinator instead of a single OSMMapDisplay when we’re in chunked mode. Height sampling: figure out which loaded chunk contains the world XZ (from chunk bounds), then sample that chunk’s terrain and add the chunk’s world Y if needed. LatLonToWorld uses the origin tile and world scale to convert lat/lon to world XZ in one consistent formula for the whole world.
Initial center: on start, if using device location we wait for GPS (same pattern as OSMMapDisplay’s WaitForLocationAndSetCenter), set origin tile from that, then create the initial 3×3; otherwise we use inspector lat/lon.
3. MapChunk (Per-Chunk Setup)
New script: MapChunk. It holds tileX, tileY (set by the coordinator). In Awake, it finds OSMMapDisplay in self or children, sets centerLatitude and centerLongitude from TileToLatLon, and sets useDeviceLocationForCenter = false, tileGridSize = 1. So when that map’s Start runs, it builds one tile at the right place. Optionally it can expose “is this chunk ready” or its Terrain for height sampling.
4. OSMMapDisplay
When used as a chunk, we don’t override center with device location—MapChunk sets center and disables useDeviceLocationForCenter, so no change to OSMMapDisplay logic beyond that. We can optionally expose center tile as read-only for height lookup; the “set in Awake, build in Start” flow is enough for this plan.
5. DEMTerrainBuilder / Overpass / OSM3DBuilder Per Chunk
They already read mapDisplay (and terrain, etc.) from the same GameObject or parent. So each chunk has its own child with OSMMapDisplay + DEMTerrainBuilder + OSMOverpassClient + OSM3DBuilder; they run in Start for that chunk with that chunk’s center and tileGridSize = 1. No code changes required for per-chunk operation. For height, the coordinator doesn’t use a single global mapDisplay; it uses SampleHeightAtWorld to find the right chunk and sample its terrain. OSM3DBuilder and DEMTerrainBuilder on each chunk stay as-is for local use.
6. Overpass Retries (OSMOverpassClient)
In FetchOSMDataCoroutine, wrap the single Overpass request in a retry loop: e.g. maxRetries = 3, retryDelaySeconds = 2f (configurable). On failure (request error or response with “error”/“remark”), if attempt < maxRetries, wait then retry with the same bbox/query. On success or after all retries exhausted, call onComplete (with data or null). Same query and bbox every attempt so behavior is deterministic.
7. Collision for Map Features (OSM3DBuilder)
- Roads: After CreateMeshGo in BuildRoadMesh, add a MeshCollider to the same GameObject and assign the same mesh. Put roads on a layer (e.g. “Ground” or “Map”) and ensure PlayerController’s groundMask includes it so the player stands on roads.
- Water: After CreateMeshGo in BuildWaterMesh, add a MeshCollider (or a simple box) so the player can’t walk through water; solid collider is enough for this task.
- Buildings: Cubes already have BoxCollider. For house prefabs, ensure prefabs include colliders; if not, add a BoxCollider from the building footprint when instantiating.
- Decoration / nature: In PlaceRoadSideDecoration, PlaceBuildingScatter, and PlaceNature, after Instantiate, if the instance has no collider, add a simple CapsuleCollider or BoxCollider so trees and props are solid. Use a consistent layer (e.g. “Map”) so they block movement.
No new physics layers are strictly required if we use existing ones; if we add “Map”, we assign it in the builder and include it in PlayerController’s groundMask.
8. Player and GPS Using the Coordinator
Introduce a small IMapProvider (or equivalent) that exposes IsMapReady, HasTerrainForHeight, SampleHeightAtWorld, LatLonToWorld. MapChunkCoordinator implements it. OSMMapDisplay implements it too so we can keep one field. In the scene, when using chunked map, we assign the coordinator to PlayerController and GPSFlyingController as the map provider. So: IMapProvider with those four members; both OSMMapDisplay and MapChunkCoordinator implement it; change the player/GPS field to IMapProvider (e.g. mapProvider or keep mapDisplay as the name).
9. Scene and File Checklist
| Item | Action |
|---|---|
| MapCoordinateHelper | Add TileToLatLon, WorldToTile |
| MapChunkCoordinator | New: 3×3 logic, follow target, origin, load/unload, map API |
| MapChunk | New: set chunk center from (tileX, tileY) in Awake |
| OSMOverpassClient | Add retry loop in FetchOSMDataCoroutine |
| OSM3DBuilder | Add MeshCollider to roads and water; ensure/add colliders for buildings and decor |
| OSMMapDisplay | Optional: expose center tile; MapChunk sets center in Awake |
| PlayerController / GPSFlyingController | Use coordinator as map source (IMapProvider); assign in scene |
| Scene | Add MapChunkCoordinator; use chunk prefab or template; set follow target; remove or disable single full-map build |
Future-Proofing
Chunk key is (tileX, tileY, zoom). Load/unload and coordinate logic can later call a cache service (e.g. Redis/S3) that returns or stores tile/Overpass/DEM data for that key; coordinator and MapChunk stay the same. Harvest/mine and other gameplay can key off world position or (tileX, tileY) and store state per chunk or per world cell—chunk identity and world positions are stable thanks to the fixed origin.
That’s the plan for the chunk buffer, location-based map, Overpass retries, and collision. More soon as we implement it.