Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
ci / server (push) Failing after 4m10s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 11:11:48 +03:00
parent 84aafb0b69
commit e6739e7912
84 changed files with 5698 additions and 2 deletions
+97
View File
@@ -0,0 +1,97 @@
# 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.
+123
View File
@@ -0,0 +1,123 @@
# Wire protocol v1
Binary frames over a single WebSocket at `/ws/game`. One protocol message per frame, no
framing header beyond the message id. **All multi-byte numbers are little-endian.**
Three files must stay in sync — change them in the same commit:
| Where | File |
| --- | --- |
| Server codec | [`src/HSchool.Protocol/ProtocolCodec.cs`](../src/HSchool.Protocol/ProtocolCodec.cs) |
| Client codec | [`src/HSchool.Client/src/net/protocol.ts`](../src/HSchool.Client/src/net/protocol.ts) |
| This document | `docs/protocol.md` |
Any change to a layout below bumps `ProtocolConstants.Version` / `PROTOCOL_VERSION`. The server
closes connections whose hello carries a different version with `1002 ProtocolError`.
## Message ids
Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted
frame is obvious at a glance.
| Id | Direction | Message |
| --- | --- | --- |
| `0x01` | C → S | Hello |
| `0x02` | C → S | Input |
| `0x03` | C → S | Ping |
| `0x81` | S → C | Welcome |
| `0x82` | S → C | Snapshot |
| `0x83` | S → C | Pong |
## Client → server
### `0x01` Hello
Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x01` |
| 1 | `u8` | protocol version |
| 2 | `u8` | name length in bytes (≤ 32) |
| 3 | `u8[]` | UTF-8 name |
### `0x02` Input
Sent at ~30 Hz whether or not the mask changed.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x02` |
| 1 | `u32` | sequence number, monotonically increasing |
| 5 | `u8` | button mask |
Button mask: `1` up, `2` down, `4` left, `8` right. Frames with a sequence lower than the last
accepted one are ignored, so a late packet cannot undo a newer intent.
### `0x03` Ping
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x03` |
| 1 | `i64` | client clock in milliseconds |
## Server → client
### `0x81` Welcome — 15 bytes
The first frame the client receives; no snapshot is queued before it.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x81` |
| 1 | `u8` | protocol version |
| 2 | `u32` | replication id of this client's own avatar |
| 6 | `u8` | tick rate in Hz |
| 7 | `f32` | world width |
| 11 | `f32` | world height |
### `0x82` Snapshot — 7 + 21·N bytes
Full state, no delta compression yet. **Entities missing from a snapshot are despawned by the
client**, which is why every visible entity is present in every frame.
Header:
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x82` |
| 1 | `u32` | tick |
| 5 | `u16` | entity count |
Then, per entity (21 bytes):
| Offset | Type | Field |
| --- | --- | --- |
| +0 | `u32` | replication id (never reused within a session) |
| +4 | `u8` | kind: `0` unknown, `1` player, `2` obstacle |
| +5 | `f32` | x |
| +9 | `f32` | y |
| +13 | `f32` | radius |
| +17 | `u32` | colour, packed `0x00RRGGBB` |
### `0x83` Pong — 13 bytes
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x83` |
| 1 | `i64` | client clock, echoed unchanged |
| 9 | `u32` | server tick when the ping was handled |
## Guarantees and limits
- Frames larger than 64 KiB are refused with close status `1009 MessageTooBig`.
- A malformed frame closes the connection with `1007 InvalidPayloadData`.
- Unknown message ids are ignored rather than fatal, so new ids can be added without breaking
older clients within the same protocol version.
- Snapshot delivery is lossy under back pressure: each connection buffers 32 frames and drops the
oldest, because a stale snapshot is worthless once a newer one exists.
## Not in v1 yet
Client-side prediction and reconciliation (the `sequence` field exists for it but is never echoed
back), delta compression, interest management, and any form of authentication.