# Architecture The server owns the world; the browser draws it. There is no game logic on the client, and there is no rendering on the server. ``` ┌───────────────────────────── Aspire AppHost ─────────────────────────────┐ │ │ │ ┌────────────────────────┐ WebSocket /ws/game ┌──────────────────┐ │ │ │ HSchool.Server │ ◄────── binary ──────► │ HSchool.Client │ │ │ │ │ │ (Vite + Pixi) │ │ │ │ GameLoopService 20 Hz │ HTTP /api, /health └──────────────────┘ │ │ │ ├── GameCommandQueue│ │ │ │ ├── GameWorld (Arch)│ │ │ │ └── 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 network. | | `src/HSchool.Simulation` | Arch ECS world, components, systems, fixed-step pipeline. No ASP.NET, no sockets — this is what unit tests exercise. | | `src/HSchool.Server` | ASP.NET Core host: WebSocket endpoint, connection lifetime, the loop that drives the simulation. | | `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 + PixiJS renderer. | Dependency direction is one-way: `Protocol ← Simulation ← Server ← AppHost`. Nothing in `Simulation` knows about HTTP, and nothing in `Protocol` knows about ECS. ## 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.** Join, leave and input all arrive from connection threads as `GameCommand` records. This is the only way anything mutates the world. 2. **Steps the simulation** with a fixed delta (`1 / TickRate`), catching up at most 5 steps if the host stalled; a longer backlog is dropped with a warning rather than simulated in a burst. 3. **Captures and broadcasts a snapshot.** One immutable buffer is shared by every connection. `GameWorld` is single-threaded on purpose: only the loop thread touches the Arch `World`. Everything else communicates through `GameCommandQueue` (inbound) and per-client outboxes (outbound). That is the whole concurrency model — if you find yourself wanting a lock, you are probably about to break it. ## ECS layout Components are plain mutable structs in `HSchool.Simulation/Components`: - `Position`, `Velocity` — movement state. - `PlayerControl` — the latest input mask plus its sequence number and the owner's player id. - `Renderable` — kind, radius and colour; replicated verbatim to the client. - `NetworkId` — stable replication id, because Arch recycles entity ids. Systems implement `ISimulationSystem` and run in registration order: `PlayerInputSystem` (intent → velocity) → `MovementSystem` (velocity → position) → `WorldBoundsSystem` (clamp to the field). Adding a system means adding it to the array in `GameWorld`'s constructor — order is explicit, not discovered. ## Connection lifetime 1. The browser opens `/ws/game`; `ClientRegistry` assigns a player id. 2. The client sends `Hello`; a version mismatch closes the socket. 3. The handler enqueues a `Join` command and waits for the loop thread to spawn the avatar. 4. The `Welcome` frame goes out, the client is marked ready, and only then does it start receiving snapshots — so world state never arrives before the client knows its own entity id. 5. The receive loop turns `Input` into commands and answers `Ping` directly. 6. On disconnect the client is removed from the registry and a `Leave` command despawns the avatar. Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client that cannot keep up loses intermediate snapshots instead of stalling the loop. ## Rendering The client buffers snapshots and renders ~100 ms in the past (`SnapshotBuffer`), interpolating between the two frames that straddle the render time. That is what turns 20 discrete server ticks into smooth motion at display refresh rate, at the cost of a fixed visual delay. `WorldRenderer` keeps one PixiJS `Graphics` per replication id, creates it on first sight and destroys it when the id disappears from a snapshot. The field is scaled to fit the viewport with letterboxing, so every player sees the same area regardless of window size. ## Where to add things next - **New replicated component**: add the struct, extend `GameWorld.CaptureSnapshot`, extend the snapshot layout in [`protocol.md`](protocol.md) and both codecs, bump the protocol version. - **New system**: implement `ISimulationSystem`, register it in `GameWorld`, unit-test it against `GameWorld` directly — no server needed. - **Client-side prediction**: the input `sequence` already travels to the server; echo the last processed sequence back in snapshots, then replay unacknowledged inputs on the client.