Files
the-living-world/README.md
T

263 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 14). 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.