Files
h-school/docs/architecture.md
T

6.1 KiB

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