325 lines
15 KiB
Markdown
Executable File
325 lines
15 KiB
Markdown
Executable File
# 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.
|