98 lines
6.1 KiB
Markdown
98 lines
6.1 KiB
Markdown
# 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.
|