6.8 KiB
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.
The tick
GameLoopService wakes on a PeriodicTimer at the configured rate (20 Hz by default) and, for
each wake-up:
- Drains the command queue. Create, delete, open, close and clock changes all arrive from
request or connection threads as
GameCommandrecords. This is the only way anything mutates a school. - 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. - Publishes the menu state — an immutable
SchoolsStatethe 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
- The browser opens
/ws/game;ClientRegistryassigns a client id. - The client sends
Hello; a version mismatch closes the socket. Welcomegoes out with the tick rate and the school limit, and the client is marked ready.- Opening a school enqueues
OpenSchool; from the next tick on, clock frames arrive. SetRunningandSetSpeeddrive the calendar;CloseSchoolgoes back to the menu.- 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 fromSchool.Tick, and unit-test them againstSchooldirectly — no server needed. - More state on the cards: extend
SchoolStateand the JSON response; the menu reloads from the server after every change, so nothing else has to know. - Saving schools:
SchoolRegistryis the single owner of every school, so persistence hooks into create/delete plus a periodic snapshot from the loop thread.