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.