Refactor project structure and update documentation. Replace PixiJS with plain DOM for UI rendering, enhance README with game features, and revise protocol documentation for HTTP API. Remove unused files and streamline client code for better maintainability.
This commit is contained in:
+107
-97
@@ -1,97 +1,107 @@
|
||||
# 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.
|
||||
# Architecture
|
||||
|
||||
The server owns the schools; the browser draws them. There is no game logic on the client, and
|
||||
there is no UI on the server.
|
||||
|
||||
```
|
||||
┌───────────────────────────── Aspire AppHost ─────────────────────────────┐
|
||||
│ │
|
||||
│ ┌────────────────────────┐ HTTP /api/schools ┌──────────────────┐ │
|
||||
│ │ HSchool.Server │ ◄────── JSON ────────► │ HSchool.Client │ │
|
||||
│ │ │ │ (Vite + DOM) │ │
|
||||
│ │ GameLoopService 20 Hz │ WebSocket /ws/game │ │ │
|
||||
│ │ ├── GameCommandQueue│ ◄────── binary ──────► │ │ │
|
||||
│ │ ├── SchoolRegistry │ └──────────────────┘ │
|
||||
│ │ │ └── School │ │
|
||||
│ │ │ ├─ Clock│ │
|
||||
│ │ │ └─ World│ (Arch ECS, empty for now) │
|
||||
│ │ └── 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 socket. |
|
||||
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. No ASP.NET, no sockets — this is what unit tests exercise. |
|
||||
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, the loop that drives the schools. |
|
||||
| `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 UI: main menu, creation form, the school screen. |
|
||||
|
||||
Dependency direction is one-way: `Protocol ← Server → Simulation`. Nothing in `Simulation` knows
|
||||
about HTTP, and nothing in `Protocol` knows about schools.
|
||||
|
||||
## Two channels, on purpose
|
||||
|
||||
The menu is request/response — you list, create and delete saves — so it is plain REST over JSON.
|
||||
The school calendar changes twenty times a second, so it rides the binary WebSocket instead. Both
|
||||
are described in [`protocol.md`](protocol.md).
|
||||
|
||||
## 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.** Create, delete, open, close and clock changes all arrive from
|
||||
request or connection threads as `GameCommand` records. This is the only way anything mutates a
|
||||
school.
|
||||
2. **Advances every running school** by a fixed delta (`1 / TickRate`), catching up at most 5 steps
|
||||
if the host stalled; a longer backlog is dropped with a warning.
|
||||
3. **Publishes the menu state** — an immutable `SchoolsState` the HTTP handlers read without
|
||||
blocking — and **pushes a clock frame** to every connection that has a school open.
|
||||
|
||||
The registry is single-threaded on purpose: only the loop thread touches `SchoolRegistry` or any
|
||||
`School`. Everything else communicates through `GameCommandQueue` (inbound), the published state
|
||||
(menu reads) and per-client outboxes (outbound). If you find yourself wanting a lock, you are
|
||||
probably about to break it.
|
||||
|
||||
Commands that a request must wait for — create, delete, name suggestion — carry a
|
||||
`TaskCompletionSource` the loop thread completes. That is how a POST gets its answer without ever
|
||||
touching a school itself.
|
||||
|
||||
## Schools
|
||||
|
||||
A `School` is one save: an id, a name, a `GameClock` and an Arch `World`. The world is empty
|
||||
today — pupils, rooms and staff land in it as the game grows — but it is created and destroyed
|
||||
with the school so ownership is never in question.
|
||||
|
||||
`GameClock` moves while it is running, in fixed steps:
|
||||
`realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes
|
||||
per real second at ×1, with ×½, ×2, ×3 and ×4 as the other stops. The same number of ticks always
|
||||
produces the same date.
|
||||
|
||||
**Every school runs on its own.** A new school starts living immediately and keeps going whether
|
||||
or not anybody is looking at it; only the player's pause button stops one, and that pause sticks
|
||||
until they press play again. Opening a school subscribes the connection to its clock frames and
|
||||
nothing more.
|
||||
|
||||
The main menu therefore re-reads `GET /api/schools` once a second while it is on screen — that is
|
||||
how the cards tick. It patches the cards it already has instead of rebuilding them, so a refresh
|
||||
cannot land between a mouse-down and a click.
|
||||
|
||||
## Connection lifetime
|
||||
|
||||
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id.
|
||||
2. The client sends `Hello`; a version mismatch closes the socket.
|
||||
3. `Welcome` goes out with the tick rate and the school limit, and the client is marked ready.
|
||||
4. Opening a school enqueues `OpenSchool`; from the next tick on, clock frames arrive.
|
||||
5. `SetRunning` and `SetSpeed` drive the calendar; `CloseSchool` goes back to the menu.
|
||||
6. On disconnect the client is removed; the school it was watching keeps running.
|
||||
|
||||
Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client
|
||||
that cannot keep up loses intermediate clock frames instead of stalling the loop.
|
||||
|
||||
## Where to add things next
|
||||
|
||||
- **Something inside a school**: add components and systems around `School.World`, run them from
|
||||
`School.Tick`, and unit-test them against `School` directly — no server needed.
|
||||
- **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from
|
||||
the server after every change, so nothing else has to know.
|
||||
- **Saving schools**: `SchoolRegistry` is the single owner of every school, so persistence hooks
|
||||
into create/delete plus a periodic snapshot from the loop thread.
|
||||
|
||||
+193
-123
@@ -1,123 +1,193 @@
|
||||
# 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 `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, 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.
|
||||
# Wire protocol v3
|
||||
|
||||
The client talks to the server two ways:
|
||||
|
||||
- **HTTP/JSON** for the main menu — listing, creating and deleting schools. Those are
|
||||
request/response by nature, so they are plain REST.
|
||||
- **A binary WebSocket at `/ws/game`** for the school calendar, which changes 20 times a second.
|
||||
|
||||
This document covers both. One protocol message per WebSocket 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`.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Game dates are ISO-8601 UTC instants. The in-game calendar has no time zone — UTC is only used so
|
||||
the wire format is unambiguous, and the client formats it back in UTC.
|
||||
|
||||
### `GET /api/schools`
|
||||
|
||||
Everything the main menu needs in one request.
|
||||
|
||||
```json
|
||||
{
|
||||
"maxSchools": 6,
|
||||
"defaultStartDate": "2012-04-03T06:00:00Z",
|
||||
"gameMinutesPerRealSecond": 5,
|
||||
"schools": [
|
||||
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-04-03T07:35:00Z", "running": false, "speedIndex": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/schools/random-name`
|
||||
|
||||
`{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken.
|
||||
|
||||
### `POST /api/schools`
|
||||
|
||||
Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }`
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `201` | Created; body is the school. |
|
||||
| `400` `invalid-name` | Blank, or longer than 40 characters. |
|
||||
| `400` `invalid-start-date` | Outside 1900–2999. |
|
||||
| `409` `school-limit-reached` | `maxSchools` schools already exist. |
|
||||
|
||||
Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on.
|
||||
|
||||
### `DELETE /api/schools/{id}`
|
||||
|
||||
`204` when deleted, `404` when the id is unknown. Anyone watching that school over a WebSocket
|
||||
gets a `SchoolGone` frame.
|
||||
|
||||
## WebSocket message ids
|
||||
|
||||
Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
|
||||
frame is obvious at a glance.
|
||||
|
||||
| Id | Direction | Message |
|
||||
| --- | --- | --- |
|
||||
| `0x01` | C → S | Hello |
|
||||
| `0x02` | C → S | Ping |
|
||||
| `0x03` | C → S | OpenSchool |
|
||||
| `0x04` | C → S | CloseSchool |
|
||||
| `0x05` | C → S | SetRunning |
|
||||
| `0x06` | C → S | SetSpeed |
|
||||
| `0x81` | S → C | Welcome |
|
||||
| `0x82` | S → C | Pong |
|
||||
| `0x83` | S → C | Clock |
|
||||
| `0x84` | S → C | SchoolGone |
|
||||
|
||||
## Client → server
|
||||
|
||||
### `0x01` Hello — 2 bytes
|
||||
|
||||
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 |
|
||||
|
||||
### `0x02` Ping — 9 bytes
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x02` |
|
||||
| 1 | `i64` | client clock in milliseconds |
|
||||
|
||||
### `0x03` OpenSchool — 5 bytes
|
||||
|
||||
Starts watching a school: clock frames for it begin to arrive. It does not start the calendar —
|
||||
every school runs on its own from the moment it is created.
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x03` |
|
||||
| 1 | `i32` | school id |
|
||||
|
||||
### `0x04` CloseSchool — 1 byte
|
||||
|
||||
Back to the menu: the clock frames stop. The school keeps running — only `SetRunning` pauses it,
|
||||
and that pause survives leaving and reconnecting.
|
||||
|
||||
### `0x05` SetRunning — 2 bytes
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x05` |
|
||||
| 1 | `u8` | `1` running, `0` paused |
|
||||
|
||||
### `0x06` SetSpeed — 2 bytes
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x06` |
|
||||
| 1 | `u8` | speed index |
|
||||
|
||||
Running and speed are **separate messages on purpose**. A single "set clock" message forces each
|
||||
button to resend the other field from the client's own copy of the state, which is always at least
|
||||
one tick stale — pressing play and then a speed button would pause the school again.
|
||||
|
||||
Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×3`, `4 = ×4`; out-of-range values are
|
||||
ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (5), so ×1 is five game
|
||||
minutes per real second.
|
||||
|
||||
## Server → client
|
||||
|
||||
### `0x81` Welcome — 4 bytes
|
||||
|
||||
The first frame the client receives.
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x81` |
|
||||
| 1 | `u8` | protocol version |
|
||||
| 2 | `u8` | tick rate in Hz |
|
||||
| 3 | `u8` | maximum number of schools |
|
||||
|
||||
### `0x82` Pong — 13 bytes
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x82` |
|
||||
| 1 | `i64` | client clock, echoed unchanged |
|
||||
| 9 | `u32` | server tick when the ping was handled |
|
||||
|
||||
### `0x83` Clock — 15 bytes
|
||||
|
||||
Sent every tick to every connection that has a school open, and only to those.
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x83` |
|
||||
| 1 | `i32` | school id |
|
||||
| 5 | `i64` | in-game date, milliseconds since the Unix epoch, read as UTC |
|
||||
| 13 | `u8` | `1` running, `0` paused |
|
||||
| 14 | `u8` | speed index |
|
||||
|
||||
### `0x84` SchoolGone — 5 bytes
|
||||
|
||||
The open school no longer exists — deleted from the menu in another tab, or never existed. The
|
||||
client returns to the menu.
|
||||
|
||||
| Offset | Type | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `u8` | `0x84` |
|
||||
| 1 | `i32` | school id |
|
||||
|
||||
## Guarantees and limits
|
||||
|
||||
- Frames larger than 8 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.
|
||||
- Clock delivery is lossy under back pressure: each connection buffers 32 frames and drops the
|
||||
oldest, because a stale clock is worthless once a newer one exists.
|
||||
|
||||
## Not in v3 yet
|
||||
|
||||
Saving schools to disk (they live in server memory), authentication, and any game state beyond the
|
||||
calendar — the school's ECS world is created but still empty.
|
||||
|
||||
Reference in New Issue
Block a user