263 lines
16 KiB
Markdown
263 lines
16 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: the main menu lists existing worlds and lets you create a new
|
||
one. Enter coordinates and press **Generate world**, then open a ready world to explore the map.
|
||
|
||
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 — the backend under xUnit, the client under Vitest:
|
||
|
||
```bash
|
||
dotnet test
|
||
```
|
||
|
||
```bash
|
||
npm --prefix src/TheLivingWorld.Web test
|
||
```
|
||
|
||
The client tests cover the pure half of the renderer: geometry helpers, camera maths, layer ordering and the
|
||
palettes. Modules that hold PixiJS values are deliberately kept out of them, which is why `layers.ts` imports
|
||
`Container` as a type only and the container construction lives in `mapView.ts` — the ordering rules stay
|
||
testable without a browser.
|
||
|
||
## 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.
|
||
Lines are then cut again at chunk boundaries — a road that spans the map becomes one entity per chunk it
|
||
crosses, which is what keeps a chunk's extent close to its own square. Neighbouring pieces overlap by a
|
||
metre and a half so the seam is covered rather than left as a hairline gap.
|
||
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"`; `409` when the slot budget is full |
|
||
| `GET /api/worlds` | `{ worlds, maxConcurrentWorlds }` — list plus the server slot budget, 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 |
|
||
| `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 1–4). Body: `{ paused?, timeScale? }` |
|
||
| `DELETE /api/worlds/{id}` | Remove a world and its chunks |
|
||
| `GET /api/climates` | The climate catalogue for the create form, with the latitude band each preset is the default for |
|
||
|
||
`state.json` and the API do not share a type. `StoredWorldDto` holds what the simulation needs to resume — the
|
||
last tick stamp and the drifting pressure systems — and `WorldSummaryDto` holds what clients see. The only way
|
||
from one to the other is `ToSummary()`, so a new endpoint cannot publish the internals by forgetting to strip
|
||
them; the wire type has no field that could carry them.
|
||
|
||
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.
|
||
The number of worlds that may exist at once is capped by `WorldStorage:MaxConcurrentWorlds` (today that means
|
||
folders on disk; later the same budget will limit concurrent simulation).
|
||
|
||
### Climate and weather
|
||
|
||
Each world picks one of twelve Köppen-lite climates at creation. Leave it out and the server guesses from the
|
||
latitude; the create form previews that guess using the band limits `GET /api/climates` returns, so the rule
|
||
lives in exactly one place. Three presets — tropical monsoon, cold steppe and highland — depend on
|
||
continentality or altitude rather than latitude, so they are never guessed and have to be chosen.
|
||
|
||
Weather is a hybrid: the climate gives a deterministic baseline (seasonal curve, daily curve, wet season),
|
||
and a handful of pressure systems drift across the map on top of it as ECS entities, fading in and out. Cloud,
|
||
rain, wind and the apparent temperature all fall out of that field, sampled at the middle of the map — one
|
||
reading is the world's weather. Systems drift at a fixed rate in normalised world space rather than a real
|
||
one: a genuine front crosses ten kilometres in minutes, which at five game minutes per real second would be a
|
||
flicker, whereas this gives a sky that turns over across a game day.
|
||
|
||
The drifting systems are persisted in `state.json` so a restart resumes the sky it had. Come back after more
|
||
than a game day away and the model rolls a fresh sky for the season instead — stepping days of drift in one
|
||
jump is not a simulation, it is a teleport.
|
||
|
||
Time does not run without limit while nobody is here. A world banks at most `Simulation:MaxCatchUpGameHours`
|
||
of in-world time per step, so a host that was down for a week wakes its worlds a day older rather than years.
|
||
Worlds nobody is looking at also tick lazily, on `Simulation:IdleTickSeconds` instead of every pass. That
|
||
costs no accuracy — a step is driven by the wall time since *that* world last ticked, so one long step and
|
||
fifty short ones land on the same game time — and reading a world brings it current before answering, which
|
||
keeps the work proportional to how much anyone is actually watching.
|
||
|
||
Snow is the one part of the weather with memory. Everything else is a function of the current instant, but
|
||
you cannot tell how deep the snow lies without knowing what the sky did for the last few days, so it is
|
||
integrated as the world ticks and stored alongside the pressure systems. A world created in a Siberian
|
||
January starts under snow rather than waiting for the first fall.
|
||
|
||
`GET /api/worlds/{id}` carries the weather along with the clock, so the client needs no second poll for it.
|
||
|
||
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
|
||
|
||
The app opens on a full-screen main menu: a list of worlds with a slot counter, the create form, and theme
|
||
controls. Opening a ready world switches to the map screen (back button returns to the menu). PixiJS is
|
||
initialised on first open and kept alive across visits.
|
||
|
||
`MapView` owns one scaled container holding the layer stack from `layers.ts`, plus a screen-space layer for
|
||
place names above it. `Camera` is the only place the Y flip lives; everything else thinks in map coordinates.
|
||
|
||
**Layers are global, not per chunk.** Every chunk paints into the same ordered set of containers rather than
|
||
into a container of its own. That is what makes junctions correct: with per-chunk containers the ordering
|
||
would only hold inside a chunk, so a side street loaded after a trunk road would paint straight over it
|
||
wherever the two meet. Roads are sorted into importance bands — tunnel, minor, local, secondary, major,
|
||
bridge — and within each band every casing goes down before any fill, so the fills merge into one continuous
|
||
surface. Land cover gets the same treatment in three bands: zoning blocks, natural cover, then parks and
|
||
pitches.
|
||
|
||
`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. When a chunk is redrawn — a zoom step or a
|
||
theme switch — the new set fades in over the old one rather than replacing it outright.
|
||
|
||
Detail thins out as you zoom away: footpaths disappear first, then small buildings, and stroke widths gain a
|
||
floor so hairlines stay visible. At street level the map picks up the things that only read close up:
|
||
|
||
- buildings extrude, with walls drawn down from every footprint edge to a roof lifted by the building's height
|
||
- footways, paths, steps and cycleways switch to dashed lines so they never read as pale streets
|
||
- railways become a dark bed with light sleepers dashed over it
|
||
- one-way streets grow chevrons pointing the way traffic runs
|
||
- gentle bends in roads and watercourses are rounded off by Chaikin corner cutting; corners sharper than 50°
|
||
are left alone, because a gridded town is full of genuine right angles
|
||
|
||
`WeatherLayer` sits over the map in screen space. One reading covers the whole world — a generated world is
|
||
a town, not a continent, and a shower does not fall on half of one — so the wash covers the view evenly.
|
||
Below the place names goes a colour for the time of day, interpolated from the sun's elevation through golden
|
||
hour, dusk and night and greyed down by cloud while the sun is up; then white for lying snow; then a pale haze
|
||
for fog, blizzards and sandstorms. Above the names falls the precipitation — slanted streaks for rain,
|
||
drifting dots for snow, blown dust for a sandstorm — leaning downwind at a slant taken from the wind and
|
||
capped so a gale still looks like weather rather than a barcode. Thunderstorms flash.
|
||
|
||
The whole overlay can be switched off from the game bar; the choice is remembered like the theme. The weather
|
||
still happens either way — the button only decides whether it is drawn.
|
||
|
||
The maths lives in `sky.ts`, which imports no PixiJS and is unit-tested; `weatherLayer.ts` only knows how to
|
||
paint the result. A dark theme pulls the night wash back rather than switching it off, because the map is
|
||
already drawn dark and dusk still has to feel like dusk.
|
||
|
||
### Picking a building
|
||
|
||
Clicking a footprint selects it and opens a panel with its name, type, height, floors, footprint area and a
|
||
link to the original OpenStreetMap element. The hit test runs against the chunk data the client already has
|
||
cached, so it costs no request: `picking.ts` ray-casts the point against every loaded footprint and takes the
|
||
**smallest** match, because where footprints overlap the big one is nearly always the container and the small
|
||
one is what the player can see the edges of. A courtyard punched out of a block counts as outside it.
|
||
|
||
Panning always nudges the pointer, so a press only counts as a click if it moved less than a few pixels —
|
||
otherwise dragging the map would reselect on every release. A second finger cancels the click outright, since
|
||
that is a pinch. Clicking bare ground, pressing Escape or the panel's close button clears the selection.
|
||
|
||
The highlight is redrawn whenever the camera moves: the container is scaled, so a stroke of fixed world width
|
||
would thin to nothing as you zoom out.
|
||
|
||
Place names are drawn in screen space so text keeps a constant size at every zoom, and the work is split in
|
||
two. `labelPlacement.ts` decides *which* names to show: candidates are ranked — water bodies first, then
|
||
arterials, then land cover, then side streets — and placed greedily, dropping anything that would overlap a
|
||
label already placed, or any street name too long for the road it belongs to. Because a road is split across
|
||
chunks, the pieces are folded back together by OSM id so a street gets one label rather than one per chunk.
|
||
That pass runs on the same slow timer as chunk bookkeeping. `LabelLayer` then moves the chosen labels to
|
||
follow the camera every frame, which is nearly free — without that split they lag a fast pan by up to a tenth
|
||
of a second and snap back when the next placement lands.
|
||
|
||
Both palettes live in `theme.ts` and nothing else in the renderer names a colour. Switching theme changes the
|
||
render profile key, which is the same signal a zoom change uses, so every loaded chunk redraws through the
|
||
usual dissolve instead of a special case. The page chrome follows via a `data-theme` attribute.
|
||
|
||
## Configuration
|
||
|
||
`src/TheLivingWorld.Api/appsettings.json`:
|
||
|
||
- `WorldStorage:RootDirectory` — where generated worlds go (default `data/worlds`)
|
||
- `WorldStorage:MaxConcurrentWorlds` — how many worlds may exist at once (default `8`)
|
||
- `Simulation:MaxCatchUpGameHours` — in-world time a world may bank per step, so downtime does not cost years
|
||
(default `24`; `0` removes the limit)
|
||
- `Simulation:IdleAfterSeconds` — how long after the last request a world stops counting as watched (default `20`)
|
||
- `Simulation:IdleTickSeconds` — tick spacing for unwatched worlds (default `5`)
|
||
- `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.
|
||
- Lines are split at chunk boundaries, but polygons are not: a large forest or landuse block still belongs
|
||
whole to the chunk holding the centre of its extent and overhangs its neighbours. The exported chunk bounds
|
||
are widened to match and the client culls against those. Splitting polygons too would risk hairline seams
|
||
between the filled pieces.
|
||
- Building extrusion is a flat fake — walls swept in one fixed direction, no perspective and no roof shapes.
|
||
- Labels are placed along a straight line at the middle of a road, not curved along its path, so a name on a
|
||
sharply bending street sits at the average angle rather than following it.
|