Condition drops from catalog hours only while the person is at school; skip through the night issues the same fresh set as a lived night so the school does not walk in in rags. Co-authored-by: Cursor <cursoragent@cursor.com>
30 KiB
Wire protocol v7
The client talks to the server two ways:
- HTTP/JSON for the main menu and the in-school people browser — listing, creating and deleting schools, listing mods, loading a catalog for the create editor, reading a school's roster (filtered list + one-person card), a short id→name directory, staffing, and the timetable. Those are request/response by nature, so they are plain REST.
- A binary WebSocket at
/ws/gamefor the school calendar (20 Hz), a static map snapshot sent once when a school is opened, and a presence stream (~2 Hz) of who is where.
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. schoolWeekDays is 5–7 working days counted
from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a catalog def.
seed is the roster generator seed: it is not the school id. Share it to recreate the same
people; it does not change on a living school.
{
"maxSchools": 6,
"defaultStartDate": "2012-03-31T06:00:00Z",
"gameMinutesPerRealSecond": 5,
"schoolWeekDays": 5,
"schools": [
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"], "seed": 1847291 }
]
}
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?lang=ru|en
Folders under the server's mods/ directory. core is always first and required: true; other
packs can be switched off in the create dialog. lang is the same value Hello carries — not
Accept-Language. Anything other than en is Russian.
Each pack carries a human label, a version string and the ids it requires. The label is the pack
id looked up in that pack's own localizations/<lang>.jsonc. A folder without pack.jsonc is
still a pack: the id stands in for the name, version is empty, requires is empty.
{ "mods": [{ "id": "core", "required": true, "label": "Базовая игра", "version": "1.0", "requires": [] }] }
The repo also ships example next to core — a sample pack, not required. It appears in this
list with required: false and requires: ["core"]. Vanilla create omits it.
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 and then reorders extras so
requires load first, same as create. mods is a comma-separated list of extra pack ids; omit
it for vanilla. Unknown extras return 400 unknown-mod. A selected pack whose dependency was
not listed returns 400 missing-mod; a cycle returns 400 mod-cycle.
Room defs that are homerooms carry homeroom, seatThing and defaultSeats instead of a
slot table. A map classroom stores seats — how many of that thing occupy the room. Capacity is
still ThingDef.pupilSlots × seats. Named slots remain on rooms whose furnishing actually
varies (cafeteria, medical office, principal's office, library). The create editor shows a
single seats field on a homeroom and the old slot rows on everything else.
Things carry pupilSlots — how many pupils that thing hosts. The snapshot's per-node pupil-slot
total is computed on the server.
countries is the list of placeable countries (defName + label + nativeLanguages +
climatePresets). Each nativeLanguages entry is a skill (defName + label). The create
dialog picks a country and, when that country lists more than one tongue, a native language;
the language list comes from that country (vanilla Russia: Russian, Belarusian, Ukrainian).
It is independent of the UI language. Everyone in the school is generated with that native.
Other languages of the same country often appear at a low skill — a Russian speaker who
understands Belarusian. Those rolls live on the nested names of the CountryDef
(relatedLanguageChance and neighbours), not on this catalog payload. climatePresets are
ids; the school rolls one at create from its seed and keeps it.
subjects is the list of placeable subjects (defName, label, gradeMin/gradeMax,
hoursPerWeek, skills with shares, and optional room). room is the RoomDef the lesson
needs — PE uses a gym, informatics a computer lab; omit it and the class homeroom is used.
dayFrame is the one concrete bell schedule (firstLesson as HH:mm, lesson count and
lengths, which break is the long one). holidays are month-day ranges that repeat every
academic year; a range whose start is after its end wraps across 1 January.
The assignment form in a later phase reads subjects; the timetable grid reads the day frame. The create editor does not.
lang is the same value Hello carries — not Accept-Language. Anything other than en is
Russian.
POST /api/schools
Body:
{
"name": "Гимназия №14",
"startDate": "2012-03-31T06:00:00Z",
"modIds": [],
"map": null,
"countryId": "Russia",
"nativeLanguage": null,
"seed": null
}
modIds are extras; the server always prepends core, then reorders the selection so each
pack's requires load first (stable topological sort over the player's order). The resolved
order is returned as modIds on the created school and written to the save, so a restart loads
the same catalog. 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.
countryId is a CountryDef; omit it to use the first placeable country in the catalog (vanilla:
Russia). Unknown ids return 400 unknown-country. The old nameSetId field is not accepted.
nativeLanguage is a skill from that country's nativeLanguages. Omit it (or send null) to pick
one from the school seed. An id that is not in the country returns 400 unknown-native-language.
seed is an optional integer. Send it to reproduce a known school; omit it (or send null) and
the server rolls one. Existing saves keep the seed already stored in the people file.
| 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 missing-mod |
A selected pack requires an id that was not selected. missing is that id. |
400 mod-cycle |
Selected packs require each other in a cycle. |
400 invalid-catalog |
The selected packs could not be loaded. |
400 unknown-country |
countryId is not a placeable CountryDef in those packs. |
400 unknown-native-language |
nativeLanguage is not in that country's nativeLanguages. |
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.
GET /api/schools/{id}/people
The in-school people list. Reads the published roster snapshot and that school's last clock;
it does not post to the worker. Unknown {id} is 404 unknown-school. Bad query parameters
are 400 invalid-query. lang is ru or en, same as the catalog — anything other than
en is Russian.
| Query | Meaning |
|---|---|
role |
student, staff or parent. A staff parent matches parent. |
year |
Class parallel (5 for fifth year). |
letter |
Class letter as stored on the roster (А, not a room number). |
position |
Staff PositionDef name (Teacher). |
sex |
male or female. |
ageMin / ageMax |
Inclusive age in full years at the school's current game time. |
sort |
surname (default), age, year, position. |
dir |
asc (default) or desc. |
page |
1-based. Default 1. 0 is invalid. A page past the end is empty and still reports total. |
pageSize |
Default 50, max 100. |
lang |
Label language for positions and filter options. |
{
"total": 512,
"page": 1,
"pageSize": 50,
"people": [
{
"id": "f0.c0",
"fullName": "Иванова Мария Петровна",
"surname": "Иванова",
"given": "Мария",
"patronymic": "Петровна",
"female": true,
"age": 12,
"roles": ["student"],
"classYear": 5,
"classLetter": "А",
"position": null,
"positionLabel": null
}
],
"filters": {
"years": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
"letters": ["А"],
"positions": [{ "defName": "Teacher", "label": "Учитель" }]
}
}
The list row is identity only — skills, traits, needs, clothes and what they carry stay on the
card. Age is computed from
birthDate and the published game time, not from the World.
Applicants live in saves/{id}.people.json next to the roster and are not in this list.
They are not school staff until hired. A parent who is also looking for work keeps the same id
in both places.
GET /api/schools/{id}/directory
Short id→name directory for the presence stream. Reads the published roster snapshot; it
does not post to the worker. Unknown {id} is 404 unknown-school. ?lang=ru|en is accepted
for symmetry with the other people endpoints — names are stored as written and not translated.
The client fetches this once on OpenSchool and again when a presence frame carries an unknown id. Names do not ride the WebSocket.
{
"people": [
{ "id": "f0.c0", "fullName": "Иванова Мария Петровна" }
]
}
Applicants are not in this list.
GET /api/schools/{id}/people/{personId}
One person's card. Goes through the school's mailbox because need values live on entities and
tick with the clock. Unknown school is 404 unknown-school; unknown person is 404
unknown-person. ?lang=ru|en labels body, skills, traits, needs and the position.
Family links are other members of the same family: parents and siblings for a child, children
and partners for a parent. The client opens another card by id; this response does not nest
cards. classId is the homeroom class for a pupil and null otherwise — the personal timetable
grid fetches GET .../timetable?classId= with it.
activity is the ActionDef name currently in progress, or null when idle. activityLabel
is that def in the request locale. HTTP JSON is additive — no protocol version bump.
skills lists only keys the person has, not every SkillDef in the catalog. A first-year has
no Chemistry; a related tongue from the name set may sit beside the native at a low value.
worn is what is on the body right now: def, colour, the layers it occupies, condition
(0–1) and conditionLabel from the catalog bands (целая / поношенная / порванная /
висит лохмотьями). The client draws the bar and the caption; it does not compute thresholds.
carried is the bag — textbooks include subject. carryMass / carryCapacity are kilograms;
overload does not slow walking. Home and locker stay in people.json and are not on this card.
The people list does not include any of these fields.
{
"id": "f0.c0",
"fullName": "Иванова Мария Петровна",
"female": true,
"age": 12,
"birthDate": "2000-03-14T00:00:00Z",
"roles": ["student"],
"classYear": 5,
"classLetter": "А",
"classId": "class-classroom-105",
"body": [{ "id": "Height", "label": "Рост", "value": "164" }],
"skills": [{ "id": "Math", "label": "Математика", "value": "62" }],
"traits": [{ "defName": "Diligent", "label": "Усидчивый" }],
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
"activity": null,
"activityLabel": null,
"family": {
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
"children": [],
"siblings": [{ "id": "f0.c1", "fullName": "Иванов Кирилл Петрович", "female": false }],
"partners": []
},
"worn": [
{
"defName": "Shirt",
"label": "Рубашка",
"color": "White",
"colorLabel": "Белый",
"layers": [{ "defName": "Top", "label": "Верх" }],
"condition": 1,
"conditionLabel": "целая"
}
],
"carried": [
{
"defName": "Textbook",
"label": "Учебник",
"color": null,
"colorLabel": null,
"subject": "Mathematics",
"subjectLabel": "Математика",
"mass": 0.4
}
],
"carryMass": 1.2,
"carryCapacity": 14
}
GET /api/schools/{id}/staffing
Money, uncovered subjects, the applicant pool and current staff. Reads the published
roster, applicant snapshot and catalog — it does not post to the worker. Unknown {id} is
404 unknown-school. ?lang=ru|en labels subjects and positions.
allocated is Simulation:MonthlyPayrollCap. A staff member is paid
hourlyWageAsk × weeklyHours × weeksPerMonth, never less than one full rate
(baseWeeklyHours), and payroll is the sum over staff. The cap is checked when hiring or
assigning, not at month end; money itself does not move.
weeklyHours is not stored: it is the curriculum. For every subject assigned to a person,
hoursPerWeek of that subject across the classes that study it, divided between everyone
teaching it. So a second teacher of a subject halves what the first one carries — and costs.
A subject appears in uncovered when nobody teaches it or when the people who do cannot
between them carry its hours (maxWeeklyHours each). Both mean the same thing to a player:
those lessons will not happen. teachersShort is how many more people that subject still
needs — primary school on a vanilla map is three, and hiring the second teacher leaves it at
one.
{
"allocated": 100000,
"payroll": 5000,
"remaining": 95000,
"uncovered": [
{
"defName": "Mathematics",
"label": "Математика",
"gradeMin": 5,
"gradeMax": 11,
"hoursPerWeek": 5,
"teachersShort": 1
}
],
"applicants": [
{
"id": "a0.p0",
"fullName": "Соколов Иван Петрович",
"female": false,
"age": 34,
"isParent": false,
"hourlyWageAsk": 50,
"monthlyBase": 4000,
"skills": [{ "id": "Mathematics", "label": "Математика", "value": "72" }]
}
],
"staff": [
{
"id": "f3.p1",
"fullName": "Иванова Ольга Михайловна",
"female": true,
"age": 41,
"isParent": true,
"position": "Teacher",
"positionLabel": "Учитель",
"hourlyWageAsk": 50,
"weeklyHours": 35,
"monthlyPay": 7000,
"subjects": [{ "defName": "Mathematics", "label": "Математика" }]
}
],
"positions": [{ "defName": "Teacher", "label": "Учитель" }],
"subjects": [
{
"defName": "Mathematics",
"label": "Математика",
"gradeMin": 5,
"gradeMax": 11,
"hoursPerWeek": 5
}
]
}
Applicants here are the same people as in saves/{id}.people.json. A parent keeps the same
id on the roster; hiring them sets isStaff on that person and does not create a second
entity. Generated candidates (aN.p0) join the roster only when hired. skills on an
applicant are the three strongest, for the management list; the card lists every skill they
have, not the three strongest. positions and subjects are the school's catalog, so the hire and assign pickers
do not need a second request.
GET /api/schools/{id}/people/{personId} also opens a card for someone who is only in the
applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown
ids that are in neither place stay 404 unknown-person.
POST /api/schools/{id}/staff/hire
Body: { "personId": "a0.p0", "position": "Teacher" }. Goes through the school's mailbox.
On success returns the same payload as GET .../staffing, so it takes the same ?lang=ru|en.
Teacher needs no room opening; other positions fill the first free RoomDef.positions slot
of that kind.
| Status | code |
When |
|---|---|---|
404 |
unknown-school |
No school with that id. |
404 |
unknown-applicant |
personId is not in the pool. |
409 |
already-hired |
That person is already staff. |
400 |
unknown-position |
Not a concrete PositionDef. |
409 |
no-opening |
Every opening of that position is filled. |
409 |
payroll-exceeded |
Hire would take payroll past allocated. |
payroll-exceeded includes allocated, payroll (current), remaining and attempted
(what payroll would become). Same RFC 7807 code field as the other errors.
POST /api/schools/{id}/staff/{personId}/subjects
Body: { "subject": "Mathematics" }. Teachers only. Same success payload as GET staffing,
and the same ?lang=ru|en.
| Status | code |
When |
|---|---|---|
400 |
not-staff |
Person is not staff. |
400 |
not-teacher |
Position is not Teacher. |
400 |
unknown-subject |
Not a concrete SubjectDef. |
409 |
already-assigned |
Already on this person. |
409 |
payroll-exceeded |
Extra subject would exceed the cap. |
DELETE /api/schools/{id}/staff/{personId}/subjects/{subject}
Removes one assignment. Payroll drops when the subject was not the only one. Same success
payload and ?lang=ru|en as the other two. Unknown assignment is 404 unknown-assignment.
GET /api/schools/{id}/timetable
The published lesson table and uncovered hours. Optional ?classId= or ?personId= filter
the lessons. ?lang=ru|en labels subjects and rooms. Reads the snapshot — it does not post to the
worker. Unknown {id} is 404 unknown-school. classes and rooms are the full school lists
so a picker does not need a second request.
day is 0 = Monday. period is the 1-based lesson number from the day frame.
{
"weekDays": 5,
"lessonCount": 7,
"lessons": [
{
"classId": "c5A",
"classYear": 5,
"classLetter": "A",
"subject": "Mathematics",
"subjectLabel": "Математика",
"teacherId": "f3.p1",
"teacherName": "Иванова Ольга Михайловна",
"roomId": "classroom-204",
"roomLabel": "Класс 204",
"day": 1,
"period": 3,
"locked": false
}
],
"uncovered": [
{
"classId": "c5A",
"classYear": 5,
"classLetter": "A",
"subject": "Informatics",
"subjectLabel": "Информатика",
"hours": 1
}
],
"classes": [{ "id": "c5A", "year": 5, "letter": "A" }],
"rooms": [{ "id": "classroom-204", "label": "Класс 204" }]
}
POST /api/schools/{id}/timetable/pin
Body: { "classId", "subject", "roomId", "day", "period" }. Pins a locked lesson there and
rebuilds the rest around it. Same success payload as GET timetable.
| Status | code |
When |
|---|---|---|
400 |
unknown-class / unknown-subject / unknown-room |
Not in this school. |
409 |
no-teacher |
Nobody is assigned that subject. |
409 |
pin-rejected |
The slot or room violates the four constraints. |
DELETE /api/schools/{id}/timetable/pin
Query: classId, subject, day, period. Drops that lock and rebuilds. Unknown lock is
404 unknown-lesson.
Dev endpoints
These exist only when HSchool:AllowSaveReload is true (headless AppHost tests). They are never
mapped in production by default. Same switch as POST /api/dev/reload-schools.
GET /api/dev/schools/{id}/dump
Roster, live presence, live needs and the timetable as one JSON. The roster and lesson table come
from the published snapshots; nodes and needs go through that school's mailbox — HTTP does not
read the World. Unknown {id} is 404 unknown-school.
{
"id": 1,
"name": "Гимназия №14",
"gameTime": "2012-04-03T10:20:00Z",
"running": true,
"people": [
{
"id": "f3.p1",
"fullName": "Иванова Ольга Михайловна",
"nodeId": "classroom-101",
"needs": { "Hunger": 0.92, "Toilet": 1, "Social": 0.8, "Sleep": 1 }
}
],
"now": [
{
"classId": "c5A",
"subject": "Mathematics",
"teacherId": "f3.p1",
"roomId": "classroom-101",
"day": 1,
"period": 3
}
],
"lessons": [
{
"classId": "c5A",
"subject": "Mathematics",
"teacherId": "f3.p1",
"roomId": "classroom-101",
"day": 1,
"period": 3
}
]
}
nodeId is null when the person is off campus. now is the lessons occurring at gameTime
(empty on a break, night, weekend or holiday). lessons is the published table.
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 |
0x07 |
C → S | SkipEmpty |
0x81 |
S → C | Welcome |
0x82 |
S → C | Pong |
0x83 |
S → C | Clock |
0x84 |
S → C | SchoolGone |
0x85 |
S → C | MapSnapshot |
0x86 |
S → C | Presence |
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 and presence 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.
0x07 SkipEmpty — 1 byte
Jump empty nights, weekends and holidays. The server re-checks both conditions (campus empty and outside the day-frame work window) — a frame from the browser is untrusted. Ignored when the skip is not legal; the calendar does not move.
Running, speed and skip are separate messages on purpose. A button that also resent a neighbouring field would clobber it with a stale client copy.
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 — 24 bytes
Sent every tick to every connection that has a school open, and only to those.
skipAllowed is the server's verdict; the client must not recompute it.
skipTargetUnixMs is 0 when skip is refused.
| 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 |
| 15 | u8 |
1 skip allowed, 0 refused |
| 16 | i64 |
skip target, milliseconds since the Unix epoch, UTC; 0 if refused |
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 when a school is opened, and on reconnect OpenSchool. Structure only — people and the current lesson ride the presence stream. Labels are in the Hello locale.
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.
0x86 Presence — variable
Live occupancy of an open school, about twice a second, and also once on OpenSchool and after a successful empty-time skip. Covers the whole map; the client filters to the selected tree node. Off-campus people are omitted — a missing id means they are away. Walking people occupy their current node. Names are resolved over HTTP, not on this frame.
Nodes listed are occupied or currently taught (count may be 0). Sorted by id. Labels are in the Hello locale, encoded per client.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x86 |
| 1 | i32 |
school id |
| 5 | u16 |
node count |
| 7… | nodes, then u16 person count, then people |
Each node:
| Type | Field |
|---|---|
| string | instance id |
u16 |
headcount in this node |
u8 |
1 if a lesson is in this room right now, then subject label + class label strings; 0 if free |
Each person:
| Type | Field |
|---|---|
| string | person id |
| string | node id they occupy |
u8 |
1 here, 2 walking |
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. AMapSnapshotorPresenceframe of a large school legitimately exceeds it, and the server sizes those frames 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 and presence use a separate reliable queue so ticks cannot crowd them out.
Not in v7 yet
Authentication, Sit orders, an event log, walk animation, and OpenLocation on the server —
the tree and the location panel are filtered on the client from the snapshot plus presence.