3.7 KiB
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 |
| 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.
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.