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