# 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 20 Hz │ WebSocket /ws/game │ │ │ │ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │ │ │ ├── SchoolRegistry │ └──────────────────┘ │ │ │ │ └── School │ │ │ │ │ ├─ Clock│ │ │ │ │ └─ World│ (Arch ECS, empty for now) │ │ │ └── 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.Simulation` | Schools, the game clock, the Arch ECS world. No ASP.NET, no sockets — this is what unit tests exercise. | | `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, the loop that drives the schools. | | `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`. Nothing in `Simulation` 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 tick `GameLoopService` wakes on a `PeriodicTimer` at the configured rate (20 Hz by default) and, for each wake-up: 1. **Drains the command queue.** Create, delete, open, close and clock changes all arrive from request or connection threads as `GameCommand` records. This is the only way anything mutates a school. 2. **Advances every running school** by a fixed delta (`1 / TickRate`), catching up at most 5 steps if the host stalled; a longer backlog is dropped with a warning. 3. **Publishes the menu state** — an immutable `SchoolsState` the HTTP handlers read without blocking — and **pushes a clock frame** to every connection that has a school open. The registry is single-threaded on purpose: only the loop thread touches `SchoolRegistry` or any `School`. Everything else communicates through `GameCommandQueue` (inbound), the published state (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 loop thread 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 nothing more. 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. ## Connection lifetime 1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id. 2. The client sends `Hello`; 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`; from the next tick on, clock frames arrive. 5. `SetRunning` and `SetSpeed` drive the calendar; `CloseSchool` goes back to the menu. 6. On disconnect the client is removed; the school it was watching keeps running. Outbound 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 the loop. ## 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. - **Saving schools**: `SchoolRegistry` is the single owner of every school, so persistence hooks into create/delete plus a periodic snapshot from the loop thread.