- Introduced a new project for timetable planning, dependent on HSchool.Content. - Updated documentation to reflect the addition of timetable planning and its dependencies. - Added tests for timetable planning to ensure deterministic behavior with the same staff and map. - Revised architecture documentation to include the new HSchool.Schedule component and its interactions.
8.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 │ 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/<id>/ │
│ │ └── 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.People |
Roster generation from catalog, map and seed. No Arch, no ASP.NET. |
src/HSchool.Schedule |
Timetable from curriculum, assignments and map. 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 → People → Content, and
Schedule → 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.
The supervisor and the workers
GameLoopService is a thin supervisor. It does not tick calendars and it does not touch a
World. It:
- Drains
GameCommandQueue. Create, delete and name suggestions stay here. Open, close, running and speed are forwarded to that school's mailbox. - Owns the table of workers. Each school is a
SchoolWorkeron a dedicatedLongRunningthread with its ownPeriodicTimer, its ownSchool(clock + world) and its own save file. - 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
- The browser opens
/ws/game;ClientRegistryassigns a client id. - The client sends
Hello(version + UI locale); 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; the worker sends a map snapshot then clock frames. SetRunningandSetSpeedgo to that school's mailbox;CloseSchoolgoes back to the menu.- 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 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. - Create editor and the map snapshot: done in this slice. Next game verbs (Sit) and the event log are out of scope here.