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.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
+193 -123
View File
@@ -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 `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.
# 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 19002999. |
| `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 `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 | 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.