Files
the-living-world/README.md
T

5.8 KiB

The Living World

A web game whose map is a real place. The backend pulls a square of OpenStreetMap data once, turns it into an ECS world, and serves it to a PixiJS client that renders it as a vector map.

This is the first iteration: world generation and rendering only, no gameplay yet.

Stack

Piece Choice
Backend .NET 10, ASP.NET Core minimal APIs
World model Arch ECS
Map data OpenStreetMap via the Overpass API
Frontend PixiJS 8 + TypeScript + Vite
Orchestration .NET Aspire 13
Storage Plain files under data/

Running it

On Windows, double-click run.cmd or run it from a terminal:

run.cmd

Anywhere else, or if you prefer the CLI directly:

dotnet run --project src/TheLivingWorld.AppHost

Aspire starts the API, runs npm install for the client, launches the Vite dev server, and prints a dashboard URL. Open the web endpoint from the dashboard, enter coordinates, and press Generate world.

The default coordinates are Robert Lee, Texas (31.8966010, -100.4858591) — a small town that generates in a few seconds.

To run the two halves separately instead:

dotnet run --project src/TheLivingWorld.Api
npm --prefix src/TheLivingWorld.Web run dev

Tests:

dotnet test

How a world is made

  1. Fetch. OverpassClient posts one bounding-box query to Overpass and streams the response into data/osm-cache/<hash>.json. The hash covers the query text, so the same box is never downloaded twice and editing the query invalidates the cache. Public mirrors are tried in order, with retries.
  2. Project. LocalProjection flattens WGS84 onto a metric plane centred on the requested point: X east, Y north, both in metres. Over a 20 km square the error stays under a metre, and distances are directly usable as game units — which Web Mercator would not give.
  3. Import. OsmWorldBuilder reads each element's tags, decides what it is, and creates one ECS entity per feature. Multipolygon relations are stitched into rings by RingAssembler; everything is clipped to the world square by GeometryClipper, so a highway crossing town does not drag geometry 40 km off the map.
  4. Systems. ComputeBoundsSystem fills each entity's extent, AssignChunksSystem buckets it into the chunk grid.
  5. Export. ChunkExporter walks the ECS world and writes one JSON file per chunk, plus an index.

The ECS shape

Geometry does not live in components. ShapeStore holds the vertex arrays and components carry an integer handle, which keeps component data blittable and archetype chunks dense.

Component Meaning
OsmSource Which OSM element this came from
Outline / Holes Closed ring and the rings cut out of it
Polyline Open centreline, for roads and streams
Bounds / InChunk Cached extent and spatial bucket, filled in by systems
Building Kind, height, levels
Road Class, width, lanes, bridge/tunnel/oneway flags
AreaFeature / Water Land cover and water classification
DisplayName The name tag

There are no simulation systems yet — the pipeline is the two passes above. Gameplay systems slot in beside them without reworking the data model.

HTTP API

Endpoint Purpose
POST /api/worlds Start generating a world. Returns immediately with status: "pending"
GET /api/worlds List worlds, with live status for anything still generating
GET /api/worlds/{id} Status of one world
GET /api/worlds/{id}/map Metadata plus the chunk index
GET /api/worlds/{id}/chunks/{x}/{y} One chunk of geometry
DELETE /api/worlds/{id} Remove a world and its chunks

Generation takes tens of seconds — mostly waiting on Overpass — so POST returns straight away and the client polls for status. Only one generation runs at a time, to stay a good citizen on the shared Overpass mirrors.

Geometry travels as flat [x0, y0, x1, y1, …] arrays of world metres, which is exactly what PixiJS Graphics.poly() accepts, so the client never reshapes it. Responses are compressed; chunk files are written in wire format and streamed straight from disk.

The client

MapView owns one scaled container holding five layers — land cover, water, road casings, road fills, buildings — so a building in one chunk never ends up under a park from the next. Camera is the only place the Y flip lives; everything else thinks in map coordinates.

ChunkManager fetches chunks as the camera reaches them and drops their graphics once they are well out of view, keeping the parsed data cached so panning back is instant. Detail thins out as you zoom away: footpaths disappear first, then small buildings, and stroke widths gain a floor so hairlines stay visible.

Configuration

src/TheLivingWorld.Api/appsettings.json:

  • WorldStorage:RootDirectory — where generated worlds go (default data/worlds)
  • Osm:Endpoints — Overpass mirrors, tried in order
  • Osm:CacheDirectory — raw Overpass responses (default data/osm-cache)
  • Osm:QueryTimeoutSeconds / Osm:RequestTimeoutSeconds — server-side and client-side budgets

Relative paths resolve against the API's content root. Everything under data/ is reproducible from coordinates and is not committed.

Known limits

  • The Overpass response is parsed in one pass rather than streamed. Fine for the small towns this targets; a dense 20 km city would want a streaming reader.
  • Chunk assignment buckets a feature by the centre of its extent and never splits geometry, so a long road overhangs its chunk. The exported chunk bounds are widened to match and the client culls against those.
  • Buildings are flat footprints shaded by height. No 3D, no roofs.