Files
forgotten_caves/notes/protocol.md
T
2026-06-17 08:23:13 +02:00

14 KiB
Executable File
Raw Blame History

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 (sequence preserved, 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 sequence Monotonically increasing
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)

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 EntityPackets 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 (same sequence number, 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.