Commit
Theria
feat: pack snapshots into a compact binary wire format
modified CHANGELOG.md
@@ -24,6 +24,15 @@ protocol version.
## [Unreleased]
### Changed
- Snapshots are now packed into a compact binary wire format — a short header plus one
fixed byte record per entity, with floats narrowed to 32 bits — instead of a Variant
container. A full opening creep wave drops from roughly 3 KB to under 1 KB, so the
whole world now fits in a single unreliable datagram rather than fragmenting above the
transport's packet-size limit. Rendering is unchanged; positions round-trip exactly.
The netcode protocol version advances to 3 (the snapshot wire shape changed).
### Added
- Networked multiplayer over a listen-server: one player hosts the authoritative
modified src/net/net_protocol.gd
@@ -2,11 +2,14 @@ class_name NetProtocol
extends RefCounted
## The wire contract between the authoritative server and its clients.
##
## Pure, engine-free serialization: it turns an InputCommand or a whole SimState
## into plain Variant data (Arrays / Dictionaries the high-level multiplayer layer
## encodes for us) and back, with no transport or rendering coupling. Keeping it
## here — separate from the socket layer in NetSession — is what lets the round
## trip be unit-tested headlessly, exactly like the simulation core.
## Pure, engine-free serialization: it turns an InputCommand into a small Array, and
## a whole SimState into a compact, fixed-layout binary record (a PackedByteArray),
## and back, with no transport or rendering coupling. The snapshot is packed tight — a
## short header plus one fixed byte record per entity, floats narrowed to 32 bits — so
## a full world stays inside a single unreliable datagram rather than fragmenting above
## the transport MTU. Keeping the shaping here — separate from the socket layer in
## NetSession — is what lets the round trip be unit-tested headlessly, exactly like the
## simulation core.
##
## The server is authoritative, but a client predicts its own hero locally and
## reconciles against each snapshot. The wire carries two sequence markers for
@@ -18,7 +21,7 @@ extends RefCounted
## connect and a mismatch is refused, so an old client cannot desync against a
## newer server. Bump it on any wire-shape change here.
const PROTOCOL_VERSION := 2
const PROTOCOL_VERSION := 3
## Bit positions for the packed entity-flags field (slot 11 of an entity row).
const _FLAG_STRUCTURE := 1
@@ -46,36 +49,59 @@ static func decode_input(data: Array) -> InputCommand:
return command
## Encodes the full authoritative world into a snapshot: the tick, the winner,
## `ack` (the last client input sequence the server has applied — `-1` when no
## remote input has been processed), and every entity as a fixed-order row.
## Insertion order is preserved so the decoded state iterates identically to the
## server's — deterministic rendering. The client reads `ack` to prune and replay
## its pending inputs; `decode_snapshot` ignores it (it is a transport marker, not
## world state), so it is read straight off the raw dict.
static func encode_snapshot(state: SimState, ack: int = -1) -> Dictionary:
var rows: Array = []
## Encodes the full authoritative world into a snapshot byte record: an 11-byte
## header — tick (u32), `ack` (i32), winner (i8), entity count (u16) — followed by one
## fixed entity record per entity in insertion order. `ack` is the last client input
## sequence the server has applied (`-1` when none); the client reads it to prune and
## replay its pending inputs, and `decode_snapshot` ignores it (a transport marker, not
## world state) — `decode_snapshot_ack` reads it alone, straight from the header,
## without decoding the entities. Insertion order is preserved so the decoded world
## iterates identically to the server's — deterministic rendering. Packing the world
## this tight keeps a full creep wave inside one unreliable datagram.
static func encode_snapshot(state: SimState, ack: int = -1) -> PackedByteArray:
var buf := StreamPeerBuffer.new()
buf.put_u32(state.tick)
buf.put_32(ack)
buf.put_8(state.winner)
buf.put_u16(state.entities.size())
for id in state.entities:
rows.append(_encode_entity(state.entities[id]))
return {"tick": state.tick, "winner": state.winner, "ack": ack, "entities": rows}
_encode_entity(buf, state.entities[id])
return buf.data_array
## Rebuilds a SimState from a snapshot. The result is a render target, not a
## simulation: it carries no id allocator and is never stepped on the client.
static func decode_snapshot(data: Dictionary) -> SimState:
## Reads the input ack out of a snapshot's header without decoding its entities — the
## client needs it every tick to reconcile, but not the whole world. The ack is the
## second header field: a signed 32-bit int at byte offset 4.
static func decode_snapshot_ack(bytes: PackedByteArray) -> int:
return bytes.decode_s32(4)
## Rebuilds a SimState from a snapshot byte record. The result is a render target, not
## a simulation: it carries no id allocator and is never stepped on the client.
static func decode_snapshot(bytes: PackedByteArray) -> SimState:
var buf := StreamPeerBuffer.new()
buf.data_array = bytes
buf.seek(0)
var state := SimState.new()
state.tick = data["tick"]
state.winner = data["winner"]
for row in data["entities"]:
state.add_entity(_decode_entity(row))
state.tick = buf.get_u32()
buf.get_32() # ack — a transport marker, read via decode_snapshot_ack, not world state
state.winner = buf.get_8()
var count := buf.get_u16()
for _i in count:
state.add_entity(_decode_entity(buf))
return state
## Fixed entity row, by slot:
## 0 id 1 team 2 pos.x 3 pos.y 4 move_speed 5 hp 6 max_hp
## 7 attack_damage 8 attack_range 9 attack_cooldown_ticks 10 cooldown
## 11 flags (structure|nexus|creep bitmask) 12 lane 13 waypoint_index
static func _encode_entity(entity: SimEntity) -> Array:
## Fixed entity byte record, by field (little-endian, 35 bytes):
## id u32 team u8 pos.x f32 pos.y f32 move_speed f32 hp i16 max_hp i16
## attack_damage i16 attack_range f32 attack_cooldown_ticks u16 cooldown u16
## flags u8 (structure|nexus|creep bitmask) lane u8 waypoint_index u16
## Floats are narrowed to 32 bits: positions are Vector2 (already 32-bit) so they round
## trip exactly, and the round-number tunings are exact in 32 bits too. The integer
## widths cover the v0.1 tuning with headroom (hp and damage sit well inside a signed
## 16-bit range); a tuning that outgrows a field must widen it here in lockstep with a
## PROTOCOL_VERSION bump.
static func _encode_entity(buf: StreamPeerBuffer, entity: SimEntity) -> void:
var flags := 0
if entity.is_structure:
flags |= _FLAG_STRUCTURE
@@ -83,36 +109,38 @@ static func _encode_entity(entity: SimEntity) -> Array:
flags |= _FLAG_NEXUS
if entity.is_creep:
flags |= _FLAG_CREEP
return [
entity.id,
entity.team,
entity.position.x,
entity.position.y,
entity.move_speed,
entity.hp,
entity.max_hp,
entity.attack_damage,
entity.attack_range,
entity.attack_cooldown_ticks,
entity.cooldown,
flags,
entity.lane,
entity.waypoint_index,
]
static func _decode_entity(row: Array) -> SimEntity:
var entity := SimEntity.new(row[0], row[1], Vector2(row[2], row[3]), row[4])
entity.hp = row[5]
entity.max_hp = row[6]
entity.attack_damage = row[7]
entity.attack_range = row[8]
entity.attack_cooldown_ticks = row[9]
entity.cooldown = row[10]
var flags: int = row[11]
buf.put_u32(entity.id)
buf.put_u8(entity.team)
buf.put_float(entity.position.x)
buf.put_float(entity.position.y)
buf.put_float(entity.move_speed)
buf.put_16(entity.hp)
buf.put_16(entity.max_hp)
buf.put_16(entity.attack_damage)
buf.put_float(entity.attack_range)
buf.put_u16(entity.attack_cooldown_ticks)
buf.put_u16(entity.cooldown)
buf.put_u8(flags)
buf.put_u8(entity.lane)
buf.put_u16(entity.waypoint_index)
static func _decode_entity(buf: StreamPeerBuffer) -> SimEntity:
var id := buf.get_u32()
var team := buf.get_u8()
var pos := Vector2(buf.get_float(), buf.get_float())
var move_speed := buf.get_float()
var entity := SimEntity.new(id, team, pos, move_speed)
entity.hp = buf.get_16()
entity.max_hp = buf.get_16()
entity.attack_damage = buf.get_16()
entity.attack_range = buf.get_float()
entity.attack_cooldown_ticks = buf.get_u16()
entity.cooldown = buf.get_u16()
var flags := buf.get_u8()
entity.is_structure = (flags & _FLAG_STRUCTURE) != 0
entity.is_nexus = (flags & _FLAG_NEXUS) != 0
entity.is_creep = (flags & _FLAG_CREEP) != 0
entity.lane = row[12]
entity.waypoint_index = row[13]
entity.lane = buf.get_u8()
entity.waypoint_index = buf.get_u16()
return entity
modified src/net/net_session.gd
@@ -42,11 +42,11 @@ var _latest_inputs: Dictionary = {}
## already applied and replay only the rest.
var _latest_input_seqs: Dictionary = {}
## Client: the most recent snapshot Dictionary, or empty until the first arrives.
## Client: the most recent snapshot as packed bytes, or empty until the first arrives.
## Updated as snapshots are drained from the conditioner, so it reflects the shaped
## stream — both prediction (which reads it) and interpolation see the same delayed,
## lossy arrivals.
var _latest_snapshot: Dictionary = {}
var _latest_snapshot: PackedByteArray = PackedByteArray()
## Client: optional network-condition simulator on the snapshot intake. When set,
## every received snapshot passes through it (delayed, jittered, or dropped) and is
@@ -88,7 +88,7 @@ func close() -> void:
multiplayer.multiplayer_peer = null
_latest_inputs.clear()
_latest_input_seqs.clear()
_latest_snapshot = {}
_latest_snapshot = PackedByteArray()
# --- Server side ------------------------------------------------------------
@@ -174,9 +174,12 @@ func latest_state() -> SimState:
## The sequence number of the last input the server had applied in the latest
## snapshot, or -1 if none. The client prunes inputs at or below this and replays
## the rest onto the snapshot to predict its hero.
## the rest onto the snapshot to predict its hero. Read from the snapshot header
## alone, without decoding its entities.
func latest_ack() -> int:
return _latest_snapshot.get("ack", -1)
if _latest_snapshot.is_empty():
return -1
return NetProtocol.decode_snapshot_ack(_latest_snapshot)
## Releases the snapshots whose conditioner hold has elapsed by `now_msec` and
@@ -212,7 +215,7 @@ func _reject(reason: String) -> void:
@rpc("authority", "call_remote", "unreliable_ordered")
func _push_snapshot(data: Dictionary) -> void:
func _push_snapshot(data: PackedByteArray) -> void:
if _netsim != null:
# Hold the snapshot in the conditioner; `drain_snapshots` delivers it later.
_netsim.receive(data, Time.get_ticks_msec())
modified test/unit/test_net_protocol.gd
@@ -9,7 +9,7 @@ extends GutTest
func test_protocol_version_is_pinned() -> void:
# The netcode compatibility axis. A wire-shape change must bump this in the
# same commit; this guard makes an accidental drift fail the suite.
assert_eq(NetProtocol.PROTOCOL_VERSION, 2)
assert_eq(NetProtocol.PROTOCOL_VERSION, 3)
func test_input_round_trips_with_its_sequence_number() -> void:
@@ -22,11 +22,30 @@ func test_input_round_trips_with_its_sequence_number() -> void:
func test_snapshot_carries_the_input_ack() -> void:
# The ack lets the client prune the inputs the server has applied and replay
# only the rest. It rides the snapshot dict; decode_snapshot ignores it.
# The ack lets the client prune the inputs the server has applied and replay only
# the rest. It rides the snapshot header and is read without decoding the world.
var snapshot := NetProtocol.encode_snapshot(SimState.new(), 7)
assert_eq(snapshot["ack"], 7, "the last applied input sequence is carried")
assert_eq(NetProtocol.encode_snapshot(SimState.new())["ack"], -1, "no input applied -> -1")
assert_eq(NetProtocol.decode_snapshot_ack(snapshot), 7, "the last applied input seq is carried")
var no_input := NetProtocol.encode_snapshot(SimState.new())
assert_eq(NetProtocol.decode_snapshot_ack(no_input), -1, "no input applied -> -1")
func test_an_empty_snapshot_is_just_the_header() -> void:
# Header only: tick u32, ack i32, winner i8, entity count u16 = 11 bytes. The
# snapshot is packed bytes, not a Variant container.
var bytes := NetProtocol.encode_snapshot(SimState.new())
assert_true(bytes is PackedByteArray, "the snapshot is a packed byte record")
assert_eq(bytes.size(), 11, "an empty world encodes to the 11-byte header alone")
func test_a_full_snapshot_fits_in_one_unreliable_datagram() -> void:
# The opening creep wave is the heaviest world the walking skeleton sends. Packed,
# it must fit one datagram so the snapshot is not fragmented above the transport
# MTU (~1392 bytes) — the regression guard for the binary wire format.
var state := _opening_wave_state()
assert_gt(state.entities.size(), 20, "the opening wave is a heavy world")
var bytes := NetProtocol.encode_snapshot(state)
assert_lt(bytes.size(), 1392, "the packed snapshot fits one datagram, below the MTU")
func test_a_populated_snapshot_round_trips_every_field() -> void:
@@ -85,3 +104,15 @@ func _populated_state() -> SimState:
for _i in 5:
sim.step({})
return sim.state
## The heaviest world the walking skeleton broadcasts: both teams' structures, both
## heroes, and a full creep wave on every lane. The first wave spawns on tick 0, so a
## single step seeds it.
func _opening_wave_state() -> SimState:
var sim := SimCore.new()
sim.spawn_structures()
sim.add_hero(0, MapData.spawn_for_team(0), 320.0)
sim.add_hero(1, MapData.spawn_for_team(1), 300.0)
sim.step({})
return sim.state