9.0 KiB
Wire protocol v4
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/gamefor 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 |
| Client codec | 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.
{
"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.
{ "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.
lang is the same value Hello carries — not Accept-Language. Anything other than en is
Russian.
POST /api/schools
Body:
{
"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 |
u8 |
item count, then that many strings |
u8 |
position count, then that many strings |
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. AMapSnapshotof 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 v4 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.