# 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 │ │ │ │ ├─ Roster │ │ │ │ └─ World│ (Arch ECS: people, classes) │ │ │ ├── SchoolStore │ saves/{id}.json + {id}.people.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`, a frozen catalog, a map, a roster and an Arch `World`. Pupils, staff and parents live in that world as entities; they do not walk yet. `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, and `saves/{id}.people.json` for the roster). The worker writes the clock file on create, on shutdown, and on a rare clock snapshot (`SaveIntervalSeconds`, 30 by default) — never on every tick. The people file is written only when composition changes (create, and later yearly intake). 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 clock file also stores the mod pack ids and the map layout; the catalog is loaded again from `mods/` on start. A missing mod folder, a map that no longer validates, or a roster that no longer fits the map leaves the files 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.