# Architecture The server owns the schools; the browser draws them. There is no game logic on the client, and there is no UI on the server. ``` ┌───────────────────────────── Aspire AppHost ─────────────────────────────┐ │ │ │ ┌────────────────────────┐ HTTP /api/schools ┌──────────────────┐ │ │ │ HSchool.Server │ ◄────── JSON ────────► │ HSchool.Client │ │ │ │ │ │ (Vite + DOM) │ │ │ │ GameLoopService │ WebSocket /ws/game │ │ │ │ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │ │ │ ├── SchoolWorker ×N │ └──────────────────┘ │ │ │ │ └── School │ │ │ │ │ ├─ Clock│ │ │ │ │ ├─ Catalog (frozen Content) │ │ │ │ ├─ Map │ │ │ │ └─ World│ (Arch ECS, empty for now) │ │ │ ├── SchoolStore │ saves/{id}.json │ │ │ ├── ModContent │ mods// │ │ │ └── ClientRegistry │ │ │ └────────────────────────┘ │ │ │ OTLP logs / traces / metrics │ │ ▼ │ │ Aspire dashboard │ └──────────────────────────────────────────────────────────────────────────┘ ``` ## Projects | Project | Role | | --- | --- | | `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. | | `src/HSchool.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. | | `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. Holds a frozen catalog and map; no HTTP. | | `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, `mods/` and `saves/`. | | `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. | | `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. | | `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. | Dependency direction is one-way: `Protocol ← Server → Simulation → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in `Protocol` knows about schools. ## Two channels, on purpose The menu is request/response — you list, create and delete saves — so it is plain REST over JSON. The school calendar changes twenty times a second, so it rides the binary WebSocket instead. Both are described in [`protocol.md`](protocol.md). ## The supervisor and the workers `GameLoopService` is a thin supervisor. It does not tick calendars and it does not touch a `World`. It: 1. **Drains `GameCommandQueue`.** Create, delete and name suggestions stay here. Open, close, running and speed are forwarded to that school's mailbox. 2. **Owns the table of workers.** Each school is a `SchoolWorker` on a dedicated `LongRunning` thread with its own `PeriodicTimer`, its own `School` (clock + world) and its own save file. 3. **Exposes menu state.** Each worker publishes an immutable `SchoolState`; HTTP handlers read those snapshots without blocking the worker. The worker advances its school by a fixed delta (`1 / TickRate`), catching up at most 5 steps if it stalled; a longer backlog is dropped with a warning. Clock frames go from that worker into the outboxes of connections that have this school open. Only that worker thread touches its `School` or `World`. Everything else communicates through mailboxes (inbound), published snapshots (menu reads) and per-client outboxes (outbound). If you find yourself wanting a lock, you are probably about to break it. Commands that a request must wait for — create, delete, name suggestion — carry a `TaskCompletionSource` the supervisor completes. That is how a POST gets its answer without ever touching a school itself. ## Schools A `School` is one save: an id, a name, a `GameClock` and an Arch `World`. The world is empty today — pupils, rooms and staff land in it as the game grows — but it is created and destroyed with the school so ownership is never in question. `GameClock` moves while it is running, in fixed steps: `realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes per real second at ×1, with ×½, ×2, ×3 and ×4 as the other stops. The same number of ticks always produces the same date. **Every school runs on its own.** A new school starts living immediately and keeps going whether or not anybody is looking at it; only the player's pause button stops one, and that pause sticks until they press play again. Opening a school subscribes the connection to its clock frames and sends one map snapshot labelled in the Hello locale. One school's pause cannot stall another's calendar, because they do not share a thread. The main menu therefore re-reads `GET /api/schools` once a second while it is on screen — that is how the cards tick. It patches the cards it already has instead of rebuilding them, so a refresh cannot land between a mouse-down and a click. ## Saves Each school is a JSON file under `Simulation:SavesDirectory` (`saves/{id}.json` plus `index.json` for the next id). The worker writes on create, on shutdown, and on a rare clock snapshot (`SaveIntervalSeconds`, 30 by default) — never on every tick. Pause and speed changes are written too, but coalesced to at most one write per `MinSaveIntervalMilliseconds`: a client can send those as fast as the socket allows, and each one is a file write on the school's own thread. Shutdown always flushes, so a pause is never lost. The file also stores the mod pack ids and the map layout; the catalog is loaded again from `mods/` on start. A missing mod folder or a map that no longer validates leaves the file in place and that school unstarted. ## Connection lifetime 1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id. 2. The client sends `Hello` (version + UI locale); a version mismatch closes the socket. 3. `Welcome` goes out with the tick rate and the school limit, and the client is marked ready. 4. Opening a school enqueues `OpenSchool`; the worker sends a map snapshot then clock frames. 5. `SetRunning` and `SetSpeed` go to that school's mailbox; `CloseSchool` goes back to the menu. 6. On disconnect the client is removed; the school it was watching keeps running. Clock frames go through a bounded channel per connection (32 frames, drop-oldest). A client that cannot keep up loses intermediate clock frames instead of stalling a worker. The map snapshot uses a separate reliable queue so it cannot be dropped for a newer tick. ## Where to add things next - **Something inside a school**: add components and systems around `School.World`, run them from `School.Tick`, and unit-test them against `School` directly — no server needed. - **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from the server after every change, so nothing else has to know. - **Create editor and the map snapshot**: done in this slice. Next game verbs (Sit) and the event log are out of scope here.