ajhahn.de
← Theria
GDScript 164 lines
class_name NetProtocol
extends RefCounted
## The wire contract between the authoritative server and its clients.
##
## 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
## that loop: every input is stamped with a client sequence number, and every
## snapshot echoes back the last input sequence the server has applied (its `ack`),
## so the client knows which of its pending inputs to replay.
##
## PROTOCOL_VERSION is the netcode compatibility axis — peers exchange it on
## 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 := 4

## Bit positions for the packed entity-flags field (slot 11 of an entity row).
const _FLAG_STRUCTURE := 1
const _FLAG_NEXUS := 2
const _FLAG_CREEP := 4


## Encodes one tick of intent for a single entity, stamped with the client's
## monotonic input sequence number so the server can acknowledge it and the client
## can match the ack back to a pending input. Only the move direction is carried as
## intent today; richer intent (abilities) extends this row without a reshape.
static func encode_input(seq: int, command: InputCommand) -> Array:
	return [seq, command.move_dir.x, command.move_dir.y]


## The sequence number stamped on an encoded input, read without rebuilding the
## command — the server stores it as the per-peer ack.
static func decode_input_seq(data: Array) -> int:
	return data[0]


static func decode_input(data: Array) -> InputCommand:
	var command := InputCommand.new()
	command.move_dir = Vector2(data[1], data[2])
	return command


## Encodes the 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 encoded 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.
##
## `visible_ids` is the fog-of-war filter: when non-empty, only entities whose id is in it are
## written (and the count reflects that), so an enemy a team cannot see never crosses the wire —
## the fog is authoritative, not a client dim. Empty (the default) writes the whole world, the
## pre-fog behaviour every other caller and the round-trip tests rely on. The wire shape is
## unchanged — a filtered snapshot is just a smaller entity count — so PROTOCOL_VERSION is not
## affected: a filtered server and an unfiltered one differ only in how many rows they send.
static func encode_snapshot(
	state: SimState, ack: int = -1, visible_ids: Dictionary = {}
) -> PackedByteArray:
	var ids: Array = []
	for id in state.entities:
		if visible_ids.is_empty() or visible_ids.has(id):
			ids.append(id)
	var buf := StreamPeerBuffer.new()
	buf.put_u32(state.tick)
	buf.put_32(ack)
	buf.put_8(state.winner)
	buf.put_u16(ids.size())
	for id in ids:
		_encode_entity(buf, state.entities[id])
	return buf.data_array


## 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 = 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 byte record, by field (little-endian, 37 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
##   respawn_ticks u16 (0 for a living unit; a downed hero's countdown, so the client raises
##   its death screen and ticks the timer straight off the snapshot)
## 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
	if entity.is_nexus:
		flags |= _FLAG_NEXUS
	if entity.is_creep:
		flags |= _FLAG_CREEP
	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)
	buf.put_u16(entity.respawn_ticks)


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 = buf.get_u8()
	entity.waypoint_index = buf.get_u16()
	entity.respawn_ticks = buf.get_u16()
	return entity