# Wire protocol v5 The client talks to the server two ways: - **HTTP/JSON** for the main menu — listing, creating and deleting schools, listing mods and loading a catalog for the create editor. Those are request/response by nature, so they are plain REST. - **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the one-shot map snapshot sent when a school is opened. 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. Optional `?lang=en` draws from the English word list (`Northern Academy`); any other value, or none, stays Russian. The client sends the active UI language. Names the player types are not translated — they are saved as written. ### `GET /api/mods` Folders under the server's `mods/` directory. `core` is always first and `required: true`; other packs can be switched off in the create dialog. ```json { "mods": [{ "id": "core", "required": true }] } ``` ### `GET /api/catalog?lang=ru|en&mods=addon1,addon2` Placeable (non-abstract) types plus labels in `lang`, and the last-wins `maps/default.jsonc` for `core` plus the listed extras. The server always prepends `core`. `mods` is a comma-separated list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`. Room slots include a `count` (how many of the default thing the def places). Things carry `pupilSlots` — how many pupils that thing hosts for lessons. The create editor copies both onto the map instance; the snapshot's per-node pupil-slot total is computed on the server. `lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is Russian. ### `POST /api/schools` Body: ```json { "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z", "modIds": [], "map": null } ``` `modIds` are extras; the server always prepends `core`. Omit `map` (or send `null`) to use that pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph. | Status | Meaning | | --- | --- | | `201` | Created; body is the school. | | `400` `invalid-name` | Blank, or longer than 40 characters. | | `400` `invalid-start-date` | Outside 1900–2999. | | `400` `invalid-map` | Missing yard, no rooms, unknown def, or a disconnected graph. | | `400` `unknown-mod` | An extra pack id is missing under `mods/`. | | `400` `invalid-catalog` | The selected packs could not be loaded. | | `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 | | `0x85` | S → C | MapSnapshot | ## Client → server ### `0x01` Hello — 3 bytes Must be the first frame; the server drops the connection if it does not arrive within 5 seconds. The locale byte is the same language the catalog HTTP API takes as `?lang=`. | Offset | Type | Field | | --- | --- | --- | | 0 | `u8` | `0x01` | | 1 | `u8` | protocol version | | 2 | `u8` | locale: `0` Russian, `1` English; any other value is treated as Russian | ### `0x02` Ping — 9 bytes | Offset | Type | Field | | --- | --- | --- | | 0 | `u8` | `0x02` | | 1 | `i64` | client clock in milliseconds | ### `0x03` OpenSchool — 5 bytes Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames. 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 | ### `0x85` MapSnapshot — variable Sent once when a school is opened (and again on reconnect OpenSchool). Not every tick, not on tree clicks. Labels are in the Hello locale. People and in-place activities are omitted — the client keeps those sections empty. Strings are `u16` byte length + UTF-8. Empty string is a zero length. | Offset | Type | Field | | --- | --- | --- | | 0 | `u8` | `0x85` | | 1 | `i32` | school id | | 5 | `u16` | node count | | 7… | | nodes | Each node: | Type | Field | | --- | --- | | `u8` | kind: `0` territory, `1` building, `2` floor, `3` room | | string | instance id | | string | parent id (empty for the yard) | | string | display name | | `u16` | pupil slots — how many pupils can take a lesson here. Summed from things on the server. | | `u8` | item count, then that many records of: string name + `u8` count | | `u8` | position count, then that many strings | Item `count` is how many of that thing stand in the room (`Парта ×16` is one record, not sixteen). The client must not recompute pupil slots from items. ## Guarantees and limits - **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That limit is about what the server reads; it does not bound what the server sends. A `MapSnapshot` of a large school legitimately exceeds it, and the server sizes that frame from the message. - 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 clock frames and drops the oldest, because a stale clock is worthless once a newer one exists. - The map snapshot uses a separate reliable queue so ticks cannot crowd it out. ## Not in v5 yet Authentication, Sit orders, an event log, and `OpenLocation` on the server — the tree is filtered on the client from the snapshot. The school's ECS world is created but still empty.