Files
the-living-world/README.md
T

136 lines
5.8 KiB
Markdown

# 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](https://github.com/genaray/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:
```bash
run.cmd
```
Anywhere else, or if you prefer the CLI directly:
```bash
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:
```bash
dotnet run --project src/TheLivingWorld.Api
```
```bash
npm --prefix src/TheLivingWorld.Web run dev
```
Tests:
```bash
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.