Files
h-school/docs/architecture.md
T

6.8 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 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:

  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.