Files
h-school/docs/architecture.md
T

8.1 KiB
Raw Blame History

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/<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.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.

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, pause, speed change, shutdown, and on a rare clock snapshot (SaveIntervalSeconds, 30 by default) — never on every tick. 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.