# Protocol Wire format for client-server communication. ## Transport - UDP (`std::net::UdpSocket`, non-blocking) - Hard limit: **1200 bytes per datagram** (safe internet MTU; avoids IP fragmentation) - All integers: little-endian - Server streams `StatePackets` continuously; client sends `ActionPackets` only when needed --- ## Header Every datagram begins with a 6-byte universal header. | Offset | Size | Type | Field | Notes | |--------|------|------|---------------|-----------------------------------------| | 0 | 2 | u16 | `magic` | Fixed: `0x524C` ("RL") — rejects noise | | 2 | 1 | u8 | `version` | Protocol version — server rejects mismatch | | 3 | 1 | u8 | `packet_type` | See packet type table below | | 4 | 2 | u16 | `client_type` | 0 = official client; others registered | `client_type` allows the server to distinguish official clients, registered bots, and forks for analytics and access control, without affecting the protocol logic. --- ## Packet types | Packet type | id | Flow | Size | |---------------|----|------------------|------------------| | State | 0 | server → client | 72 bytes | | Action | 1 | client → server | min. 74 bytes | | Chunk Data | 2 | server → client | max. 909 bytes | | Entity | 3 | server → client | max. 1200 bytes | | Entity Query | 4 | client → server | 18 bytes | | Entity Detail | 5 | server → client | max. 1200 bytes | | Ping | 6 | client → server | 14 bytes | | Pong | 7 | server → client | 14 bytes | --- ### StatePacket — server → client Sent by the server at 10 Hz regardless of client activity. Currently carries the chunk manifest for the 3×3 neighbourhood around the player. | Offset | Size | Type | Field | Notes | |--------|------|------------|---------------------|-------------------------------------------------| | 0 | 6 | Header | `header` | packet_type = 0 | | 6 | 4 | u32 | `tick` | Server tick counter | | 10 | 54 | ChunkEntry | `chunks[9]` | 9 chunk entries | | 64 | 4 | u32 | `player_entity_id` | Global entity ID of the receiving client | | 68 | 4 | u32 | `entity_checksum` | FNV-1a over all EntityPacket payloads this tick | **Total: 72 bytes** The `entity_checksum` lets the client detect a lost `EntityPacket` without a dedicated ACK: if the checksum differs from the one computed over the last received entity update, the client knows to retransmit an `ActionPacket` (`target_tick = 0`, no-op action) to prompt the server to re-send the current entity state. --- ### ActionPacket — client → server Sent by the client on player action or on a chunk cache miss. | Offset | Size | Type | Field | Notes | |--------|------|--------------|-----------------|--------------------------------| | 0 | 6 | Header | `header` | packet_type = 1 | | 6 | 8 | u64 | `auth_token` | Token of the current session | | 14 | 4 | u32 | `target_tick` | Tick the action is scheduled for (see below); 0 = keep-alive/ack only | | 18 | 54 | ChunkEntry | `cache[9]` | Versions client currently holds | | 72 | 2 | PlayerAction | `player_action` | Derived from user input | | 74 | ? | ActionData | `action_data` | Dependent on PlayerAction | **Minimum: 74 bytes** (no ActionData) **Tick-addressed scheduling.** Actions are scheduled onto the server's tick timeline instead of being consumed in arrival order. `target_tick` selects the movement window (`target_tick / TICKS_PER_MOVE`, rounded up) the action executes in: - A second action addressed to the same window **replaces** the first — this is how the client retracts (NOOP) or changes a scheduled step until its window executes, and how retransmits dedupe for free. - A **late** action (window already passed on arrival) moves to the next window, but only if that slot is empty: late actions fill gaps, they never override newer intent. - Only the next `ACTION_WINDOW_HORIZON` windows are addressable; anything beyond is dropped. Combined with one-action-per-window execution this bounds server memory and movement speed regardless of client behavior. - `target_tick = 0` carries no scheduling intent (keep-alive / cache-ack packets). **ChunkEntry (6 bytes)** | Offset | Size | Type | Field | |--------|------|------|------------| | 0 | 4 | u32 | `chunk_id` | | 4 | 2 | u16 | `version` | `chunk_id` encodes the chunk grid position: `(x as u16) | ((y as u16) << 16)`, where x and y are signed chunk coordinates (i16 each). **PlayerAction (u16)** | Value | Action | |-------|---------| | 0 | No-Op (cache update only) | | 1 | North | | 2 | East | | 3 | South | | 4 | West | $TODO — additional actions (interact, etc.) --- ### ChunkPacket — server → client Sent by the server for each chunk the client is missing or has stale. One chunk per datagram. The world is divided into **32×32 tile chunks**. - At most **4 chunks (2×2)** are visible at once. - The 3×3 neighbourhood (9 chunks) covers all prefetch needs. - Each chunk carries a **local palette** of up to 64 tile types. - Tiles are encoded as 6-bit palette indices (4 tiles per 3 bytes, no padding). | Offset | Size | Type | Field | Notes | |-------------------|----------|--------------|-------------|---------------------------------| | 0 | 6 | Header | `header` | packet_type = 2 | | 6 | 6 | ChunkEntry | `chunk` | ID and version of this chunk | | 12 | 1 | u8 | `pal_count` | Number of palette entries (≤64) | | 13 | max. 128 | u16[] | `palette` | Global tile IDs, pal_count × 2 | | 13 + pal_count×2 | 768 | packed u6[] | `tiles` | 1024 tiles, 6-bit indices | **Maximum: 6 + 6 + 1 + 128 + 768 = 909 bytes** **Palette** — up to 64 entries, each a global tile ID (u16) mapping local 6-bit index → world tile type. **Tiles** — 1024 tiles packed as 256 groups of 3 bytes (4 tiles × 6 bits = 24 bits per group). --- ### EntityPacket — server → client Sent by the server each tick, immediately after the `StatePacket`. Contains the bulk entity update for all entities within the visible **30×30 tile viewport**. Positions are **absolute world tile coordinates**. If the entity count exceeds what fits in one datagram, the server sends multiple `EntityPacket`s on the same tick; `packet_flags` bit 0 signals that more follow. | Offset | Size | Type | Field | Notes | |--------|----------------|-------------|----------------|--------------------------------| | 0 | 6 | Header | `header` | packet_type = 3 | | 6 | 4 | u32 | `tick` | Matches the `StatePacket` tick | | 10 | 1 | u8 | `entity_count` | Entities in this datagram | | 11 | 1 | u8 | `packet_flags` | bit 0: more packets follow | | 12 | entity_count×… | EntityEntry | `entities` | Variable-length entries | **Maximum: 1200 bytes** — without metadata: ⌊(1200 − 12) / 18⌋ = **66 entities per datagram**. --- #### EntityEntry | Offset | Size | Type | Field | Notes | |--------|------------|------|-----------------|-----------------------------------------| | 0 | 4 | u32 | `id` | Server-assigned entity ID (persistent) | | 4 | 2 | u16 | `type_id` | Entity type; 0 = player | | 6 | 2 | i16 | `pos_x` | Absolute world tile X | | 8 | 2 | i16 | `pos_y` | Absolute world tile Y | | 10 | 2 | u16 | `hp` | Current HP | | 12 | 2 | u16 | `hp_max` | Max HP | | 14 | 2 | u16 | `elo` | Elo rating (acts as level) | | 16 | 1 | u8 | `entity_flags` | See flag table below | | 17 | 1 | u8 | `meta_len` | Byte length of the TLV metadata block | | 18 | `meta_len` | u8[] | `meta` | TLV metadata (0 bytes if none) | **Base size: 18 bytes** (plus `meta_len` bytes of metadata). **entity_flags:** | Bit | Meaning | |-----|-------------------------------------------------------| | 0 | `is_static` — does not move (chest, sign, item stack) | | 1 | `is_hostile` | | 2 | `is_interactable` — show interaction prompt | | 3–7 | reserved | --- #### Inline metadata — TLV format Inline metadata is a sequence of TLV (type–length–value) fields appended directly after the fixed `EntityEntry` fields. `meta_len = 0` means no metadata is present. Each TLV field: | Offset | Size | Type | Field | |--------|------|------|---------| | 0 | 1 | u8 | `tag` | | 1 | 1 | u8 | `len` | | 2 | len | u8[] | `value` | **Defined tags:** | Tag | Name | Value format | Notes | |------|------------------|--------------|--------------------------------| | 0x01 | `status_effects` | u16 bitmask | Active status effects | | 0x02 | `weapon` | u16 type_id | Currently equipped weapon | | 0x03 | `armor` | u16 type_id | Currently equipped armor | Large metadata (chest inventory, sign text, dialogue trees) is not included inline. Request it via `EntityQueryPacket` (type 4) on player interaction. --- ### EntityQueryPacket — client → server Sent when the client needs detailed metadata for a specific entity (on interaction, hover, or similar trigger). The server responds with an `EntityDetailPacket`. | Offset | Size | Type | Field | Notes | |--------|------|--------|--------------|------------------------------| | 0 | 6 | Header | `header` | packet_type = 4 | | 6 | 8 | u64 | `auth_token` | Token of the current session | | 14 | 4 | u32 | `entity_id` | Entity to query | **Total: 18 bytes** --- ### EntityDetailPacket — server → client Full metadata response for a queried entity. Sent in reply to an `EntityQueryPacket`. | Offset | Size | Type | Field | Notes | |--------|------------|--------|--------------|------------------------------| | 0 | 6 | Header | `header` | packet_type = 5 | | 6 | 4 | u32 | `tick` | Server tick at time of query | | 10 | 4 | u32 | `entity_id` | Entity being described | | 14 | 2 | u16 | `meta_len` | Byte length of metadata | | 16 | `meta_len` | u8[] | `meta` | Full TLV metadata block | **Maximum: 1200 bytes** (up to 1184 bytes of metadata). --- ### PingPacket / PongPacket — round-trip measurement Liveness and RTT probe. The client sends a `PingPacket`; the server echoes the payload back unchanged as a `PongPacket`. Both share the same layout. | Offset | Size | Type | Field | Notes | |--------|------|--------|----------------|--------------------------------------| | 0 | 6 | Header | `header` | packet_type = 6 (Ping) / 7 (Pong) | | 6 | 8 | u64 | `timestamp_ms` | Echoed verbatim by the server | **Total: 14 bytes** The client currently measures RTT locally via the elapsed time since the ping was sent, so `timestamp_ms` is sent as 0; the field is reserved for server-stamped timing if needed later. --- ## Protocol flow ``` ── every tick (10 Hz) ──────────────────────────────────────────────────────── server → client StatePacket (tick N, chunk manifest, entity_checksum) server → client EntityPacket (tick N, entities 0–73) server → client EntityPacket (tick N, entities 74–N, more=0) ← if needed ↓ client verifies entity_checksum ↓ checksum mismatch → retransmit ActionPacket (no-op) ↓ chunk cache miss on chunk X → client → server ActionPacket (auth_token, seq, cache state, player_action) server → client ChunkPacket (chunk X, full data) server → client ChunkPacket (chunk Y, full data) ← if multiple misses ── on player interaction ───────────────────────────────────────────────────── client → server EntityQueryPacket (auth_token, entity_id) server → client EntityDetailPacket (entity_id, full TLV metadata) ``` The server reads the `ActionPacket`'s cache list, computes the diff against current chunk versions, and sends one `ChunkPacket` per missing or stale chunk. --- ## Reliability UDP is unreliable. The protocol handles loss without a dedicated ACK mechanism: **Lost ActionPacket** — the server never learns of the cache miss and keeps streaming StatePackets. The client retransmits the ActionPacket after **200–300 ms** if no ChunkPacket has arrived. **Lost ChunkPacket** — the server has already processed the ActionPacket and will not retransmit spontaneously. The client's timeout fires (200–300 ms), it resends the ActionPacket with its updated cache state (listing only still-missing chunks), and the server sends the missing chunks again. **Duplicate ActionPackets** — the server handles these idempotently. The cache list is self-describing state; the server reads it, diffs against current versions, and sends whatever is still missing. No deduplication logic is required. **Lost StatePacket** — the next tick delivers the same chunk manifest. No retry needed; the client simply waits one tick (~100 ms). **Lost EntityPacket** — detected via the `entity_checksum` in the next `StatePacket`. The client retransmits a no-op `ActionPacket` (`target_tick = 0`, no-op action, current cache state); the server treats this as a normal diff request and re-sends the full entity update for the tick. **Lost EntityQueryPacket / EntityDetailPacket** — the client retransmits the query after a 200–300 ms timeout if no `EntityDetailPacket` has arrived.