Commit
Theria
feat: add client-side hero prediction with snapshot reconciliation
modified CHANGELOG.md
@@ -33,6 +33,13 @@ protocol version.
a bot. Launch with `-- --host` or `-- --join <address>`; the default remains a
single-machine game. This activates the netcode protocol version as a
compatibility axis.
- Client-side prediction and reconciliation: a joined player's hero now responds
to their input immediately rather than after a network round trip. The client
predicts its own hero locally and, on every authoritative snapshot, rolls back
to the server's truth and replays the inputs the server has not yet applied — so
the prediction stays exactly on the authoritative path and self-corrects. Remote
units are still drawn straight from the snapshot. The netcode protocol version
advances to 2 (inputs now carry a sequence number and snapshots an acknowledgement).
- Combat heroes: the player's hero and the bot now auto-attack the nearest enemy
in range, so a hero can clear creep waves, pressure towers, duel the enemy hero,
and push a lane toward the nexus. Heroes spawn at their base fountain.
modified README.md
@@ -53,7 +53,10 @@ core is driven by:
Because authority lives entirely in the simulation, networked play is just
another driver — a listen-server — added without rewriting gameplay. The host is
the sole authority; a client never simulates, it only renders what it is sent.
the sole authority. A client never owns authority, but it predicts its own hero
locally so input feels instant, reconciling against every snapshot: it rolls back
to the server's state and replays the inputs the server has not yet applied, using
the same movement code the server runs. Remote units are drawn from the snapshot.
## Layout
@@ -86,7 +89,8 @@ godot --path . -- --host # host the match (you are team 0)
godot --path . -- --join 127.0.0.1 # join a host at an address (you are team 1)
```
The host is authoritative and fills any empty player slot with a bot.
The host is authoritative and fills any empty player slot with a bot. The joining
player's hero is predicted locally, so it responds without waiting on the host.
## Testing
modified src/client/main.gd
@@ -8,12 +8,15 @@ extends Node2D
## HOST — the listen-server: owns the authoritative SimCore, drives team 0 from
## local input, hands team 1 to a remote client when one connects (a bot
## until then), and broadcasts a snapshot every tick.
## CLIENT — owns no simulation: it samples local input and sends it up, and draws
## whatever authoritative snapshot the server last sent down.
## CLIENT — owns no authority: it samples local input, sends it up, and draws the
## server's snapshots — but predicts its own hero locally from that input
## so the hero responds without a round-trip, reconciling against every
## snapshot. Remote entities are drawn straight from the snapshot.
##
## All authority stays in SimCore; the transport lives in NetSession; the wire
## shaping lives in NetProtocol. This node only samples input, routes it, and
## draws the resulting state — the same `_draw` serves every mode.
## shaping lives in NetProtocol. This node samples input, routes it, predicts the
## client's own hero, and draws the resulting state — the same `_draw` serves every
## mode.
enum Mode { LOCAL, HOST, CLIENT }
@@ -69,8 +72,17 @@ var _net: NetSession = null
var _team1_peer: int = 0
## CLIENT: set once the server has accepted our handshake.
var _joined: bool = false
## CLIENT: the latest authoritative world decoded from a snapshot, or null.
## CLIENT: the team the server assigned us; identifies our hero in a snapshot.
var _my_team: int = BOT_TEAM
## CLIENT: the predicted world to draw — the latest authoritative snapshot with
## our own hero advanced by the inputs the server has not yet acknowledged.
var _client_state: SimState = null
## CLIENT: monotonic input sequence number, stamped on each input we send so the
## server can acknowledge it and we can match the ack back to a pending input.
var _input_seq: int = 0
## CLIENT: inputs sent but not yet acknowledged, oldest first, each `{seq, input}`.
## Replayed onto every snapshot to predict our hero; pruned as acks arrive.
var _pending_inputs: Array[Dictionary] = []
func _ready() -> void:
@@ -161,19 +173,57 @@ func _tick_local() -> void:
func _tick_host() -> void:
var team1_command: InputCommand
var ack := -1
if _team1_peer != 0:
var remote := _net.input_for(_team1_peer)
team1_command = remote if remote != null else InputCommand.new()
ack = _net.input_seq_for(_team1_peer)
else:
team1_command = _bot.decide(_sim.state, _bot_id)
_sim.step({_hero_id: _sample_player_input(), _bot_id: team1_command})
_net.broadcast_snapshot(_sim.state)
_net.broadcast_snapshot(_sim.state, ack)
## Samples local input, sends it up stamped with a sequence number, buffers it as
## pending, then redraws the predicted world. Prediction makes the local hero
## respond immediately instead of waiting a round-trip for the server to echo it.
func _tick_client() -> void:
if _joined:
_net.send_input(_sample_player_input())
_client_state = _net.latest_state()
_input_seq += 1
var command := _sample_player_input()
_net.send_input(_input_seq, command)
_pending_inputs.append({"seq": _input_seq, "input": command})
_client_state = _predicted_state()
## Reconciles against the latest snapshot and predicts our hero: take the
## authoritative world, drop the inputs the server has already applied, and replay
## the rest onto our hero with the same movement math the server runs. Authority is
## never forked — every snapshot rolls our hero back to the server's truth before
## the replay, so a misprediction self-corrects within a tick. Remote entities are
## drawn straight from the snapshot (interpolation is a later slice).
func _predicted_state() -> SimState:
var state := _net.latest_state()
if state == null:
return null
var ack := _net.latest_ack()
while not _pending_inputs.is_empty() and _pending_inputs[0]["seq"] <= ack:
_pending_inputs.pop_front()
var hero := _local_hero(state)
if hero != null:
for entry in _pending_inputs:
SimCore.apply_movement(hero, entry["input"])
return state
## Our hero in `state`: the one mobile, non-creep unit on our team. The walking
## skeleton seats exactly one hero per team, so the first match is ours.
func _local_hero(state: SimState) -> SimEntity:
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.team == _my_team and not entity.is_structure and not entity.is_creep:
return entity
return null
# --- Network event handlers -------------------------------------------------
@@ -192,6 +242,7 @@ func _on_client_left(peer_id: int) -> void:
func _on_joined_server(team: int) -> void:
_joined = true
_my_team = team
print("joined the server as team %d" % team)
@@ -208,7 +259,7 @@ func _on_server_left() -> void:
# --- Rendering --------------------------------------------------------------
## The world this client should draw: the decoded snapshot on a pure CLIENT, the
## The world this client should draw: the predicted state on a pure CLIENT, the
## authoritative simulation otherwise.
func _active_state() -> SimState:
return _client_state if _mode == Mode.CLIENT else _sim.state
modified src/net/net_protocol.gd
@@ -8,12 +8,17 @@ extends RefCounted
## 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: a client only ever *renders* a decoded snapshot,
## it never trusts its own copy. 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.
## 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 := 1
const PROTOCOL_VERSION := 2
## Bit positions for the packed entity-flags field (slot 11 of an entity row).
const _FLAG_STRUCTURE := 1
@@ -21,26 +26,38 @@ const _FLAG_NEXUS := 2
const _FLAG_CREEP := 4
## Encodes one tick of intent for a single entity. Only the move direction is
## carried today; richer intent (abilities) extends this row without a reshape.
static func encode_input(command: InputCommand) -> Array:
return [command.move_dir.x, command.move_dir.y]
## 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[0], data[1])
command.move_dir = Vector2(data[1], data[2])
return command
## Encodes the full authoritative world into a snapshot: the tick, the winner,
## and every entity as a fixed-order row. Insertion order is preserved so the
## decoded state iterates identically to the server's — deterministic rendering.
static func encode_snapshot(state: SimState) -> Dictionary:
## `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 = []
for id in state.entities:
rows.append(_encode_entity(state.entities[id]))
return {"tick": state.tick, "winner": state.winner, "entities": rows}
return {"tick": state.tick, "winner": state.winner, "ack": ack, "entities": rows}
## Rebuilds a SimState from a snapshot. The result is a render target, not a
modified src/net/net_session.gd
@@ -37,6 +37,11 @@ var is_server: bool = false
## than snapping the unit to a halt on a single lost frame.
var _latest_inputs: Dictionary = {}
## Server: the sequence number of each peer's latest input. Echoed back in the
## snapshot as the peer's `ack` so the client can prune the inputs the server has
## already applied and replay only the rest.
var _latest_input_seqs: Dictionary = {}
## Client: the most recent snapshot Dictionary, or empty until the first arrives.
var _latest_snapshot: Dictionary = {}
@@ -73,16 +78,18 @@ func close() -> void:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
_latest_inputs.clear()
_latest_input_seqs.clear()
_latest_snapshot = {}
# --- Server side ------------------------------------------------------------
## Broadcasts the authoritative world to every client. Called once per tick by
## the host driver, after the simulation has stepped.
func broadcast_snapshot(state: SimState) -> void:
_push_snapshot.rpc(NetProtocol.encode_snapshot(state))
## Broadcasts the authoritative world to every client. Called once per tick by the
## host driver, after the simulation has stepped. `ack` is the sequence number of
## the remote input applied this tick, so the client can reconcile against it.
func broadcast_snapshot(state: SimState, ack: int = -1) -> void:
_push_snapshot.rpc(NetProtocol.encode_snapshot(state, ack))
## The last input received from `peer_id`, or null if none has arrived yet.
@@ -90,8 +97,15 @@ func input_for(peer_id: int) -> InputCommand:
return _latest_inputs.get(peer_id, null)
## The sequence number of `peer_id`'s last input, or -1 if none has arrived. The
## host passes this to `broadcast_snapshot` as the tick's ack.
func input_seq_for(peer_id: int) -> int:
return _latest_input_seqs.get(peer_id, -1)
func _on_server_peer_disconnected(peer_id: int) -> void:
_latest_inputs.erase(peer_id)
_latest_input_seqs.erase(peer_id)
client_left.emit(peer_id)
@@ -108,15 +122,18 @@ func _submit_hello(protocol_version: int) -> void:
@rpc("any_peer", "call_remote", "unreliable_ordered")
func _push_input(data: Array) -> void:
_latest_inputs[multiplayer.get_remote_sender_id()] = NetProtocol.decode_input(data)
var peer_id := multiplayer.get_remote_sender_id()
_latest_inputs[peer_id] = NetProtocol.decode_input(data)
_latest_input_seqs[peer_id] = NetProtocol.decode_input_seq(data)
# --- Client side ------------------------------------------------------------
## Sends this tick's intent up to the server. A no-op before the handshake.
func send_input(command: InputCommand) -> void:
_push_input.rpc_id(1, NetProtocol.encode_input(command))
## Sends this tick's intent up to the server, stamped with `seq` so the server can
## acknowledge it. A no-op before the handshake.
func send_input(seq: int, command: InputCommand) -> void:
_push_input.rpc_id(1, NetProtocol.encode_input(seq, command))
func has_snapshot() -> bool:
@@ -130,6 +147,13 @@ func latest_state() -> SimState:
return NetProtocol.decode_snapshot(_latest_snapshot)
## 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.
func latest_ack() -> int:
return _latest_snapshot.get("ack", -1)
func _on_connected_to_server() -> void:
_submit_hello.rpc_id(1, NetProtocol.PROTOCOL_VERSION)
modified src/sim/sim_core.gd
@@ -153,14 +153,23 @@ func _register(entity: SimEntity) -> int:
func _step_movement(inputs: Dictionary) -> void:
for id in state.entities:
var entity: SimEntity = state.entities[id]
var command: InputCommand = inputs.get(id, null)
var move_dir := Vector2.ZERO
if command != null:
move_dir = command.move_dir
if move_dir.length() > 1.0:
move_dir = move_dir.normalized()
entity.position += move_dir * entity.move_speed * TICK_DELTA
entity.position = MapData.clamp_to_bounds(entity.position)
apply_movement(entity, inputs.get(id, null))
## Advances one entity by a single tick of movement intent: the pure movement
## sub-step, with the diagonal-speed clamp and the bounds clamp. A `null` command
## holds the entity still. The authoritative `_step_movement` runs it over every
## entity; the client's prediction/replay runs it over its own hero alone — so the
## server and a predicting client move a unit by byte-identical math, which is what
## lets client-side reconciliation land exactly on the authoritative position.
static func apply_movement(entity: SimEntity, command: InputCommand) -> void:
var move_dir := Vector2.ZERO
if command != null:
move_dir = command.move_dir
if move_dir.length() > 1.0:
move_dir = move_dir.normalized()
entity.position += move_dir * entity.move_speed * TICK_DELTA
entity.position = MapData.clamp_to_bounds(entity.position)
## On a wave tick, spawns one creep wave per team per lane. Driven off
modified test/unit/test_net_protocol.gd
@@ -9,16 +9,26 @@ 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, 1)
assert_eq(NetProtocol.PROTOCOL_VERSION, 2)
func test_input_round_trips() -> void:
func test_input_round_trips_with_its_sequence_number() -> void:
var command := InputCommand.new()
command.move_dir = Vector2(-1.0, 0.5)
var restored := NetProtocol.decode_input(NetProtocol.encode_input(command))
var data := NetProtocol.encode_input(42, command)
assert_eq(NetProtocol.decode_input_seq(data), 42, "the sequence number survives the trip")
var restored := NetProtocol.decode_input(data)
assert_eq(restored.move_dir, command.move_dir, "the move direction survives the trip")
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.
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")
func test_a_populated_snapshot_round_trips_every_field() -> void:
var state := _populated_state()
var restored := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(state))
added test/unit/test_prediction.gd
@@ -0,0 +1,112 @@
extends GutTest
## The N2 client-prediction invariant, exercised without any networking: a client
## that replays its un-acknowledged inputs onto the latest authoritative snapshot
## must land its hero exactly where the server's own simulation will — prediction
## and reconciliation never diverge from authority.
##
## The reconcile loop here mirrors `main.gd`'s `_predicted_state`: decode the
## snapshot, drop the inputs at or below the server's ack, replay the rest through
## the shared `SimCore.apply_movement`. Keeping it pure lets the round trip be
## checked headlessly, exactly like the protocol and simulation cores.
const HERO_SPEED := 320.0
func test_replayed_prediction_matches_the_authoritative_position() -> void:
var inputs := [
_command(Vector2.RIGHT),
_command(Vector2.RIGHT),
_command(Vector2.UP),
_command(Vector2(1.0, 1.0)),
_command(Vector2.LEFT),
]
# The server has applied the first three inputs (acked up to seq 3); the client
# still holds all five as pending until it reconciles.
var acked := 3
var server := SimCore.new()
server.spawn_creeps = false
var hero_id := server.add_hero(1, Vector2(500.0, 500.0), HERO_SPEED)
for i in acked:
server.step({hero_id: inputs[i]})
# The client reconciles against the snapshot taken at this ack: prune the
# applied inputs, then replay the remainder onto its predicted hero.
var pending := _all_pending(inputs)
var snapshot := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(server.state, acked))
var predicted := snapshot.get_entity(hero_id)
while not pending.is_empty() and pending[0]["seq"] <= acked:
pending.pop_front()
for entry in pending:
SimCore.apply_movement(predicted, entry["input"])
# Meanwhile the server applies the remaining inputs for real.
for i in range(acked, inputs.size()):
server.step({hero_id: inputs[i]})
assert_eq(
predicted.position,
server.state.get_entity(hero_id).position,
"the replayed prediction lands on the authoritative position",
)
func test_reconciliation_prunes_acknowledged_inputs() -> void:
# Five pending inputs (seq 1..5); an ack of 3 must drop seqs 1..3 and keep 4
# and 5 — only the inputs the server has not yet applied are replayed.
var pending: Array[Dictionary] = []
for seq in range(1, 6):
pending.append({"seq": seq, "input": _command(Vector2.RIGHT)})
var ack := 3
while not pending.is_empty() and pending[0]["seq"] <= ack:
pending.pop_front()
assert_eq(pending.size(), 2, "the two un-acked inputs remain")
assert_eq(pending[0]["seq"], 4, "pruning stops at the first un-acked input")
assert_eq(pending[1]["seq"], 5)
func test_a_fully_acked_buffer_predicts_nothing() -> void:
# When the server has applied every input, replay is empty and the prediction
# is exactly the snapshot — the client and server agree with no extrapolation.
var pending: Array[Dictionary] = []
for seq in range(1, 4):
pending.append({"seq": seq, "input": _command(Vector2.RIGHT)})
var ack := 3
while not pending.is_empty() and pending[0]["seq"] <= ack:
pending.pop_front()
assert_eq(pending.size(), 0, "an ack covering every input leaves nothing to replay")
# --- apply_movement: the shared movement sub-step the prediction replays --------
func test_apply_movement_advances_one_tick() -> void:
var entity := SimEntity.new(1, 0, Vector2.ZERO, 300.0)
SimCore.apply_movement(entity, _command(Vector2.RIGHT))
assert_almost_eq(entity.position.x, 300.0 * SimCore.TICK_DELTA, 0.0001)
assert_almost_eq(entity.position.y, 0.0, 0.0001)
func test_apply_movement_clamps_diagonals() -> void:
var entity := SimEntity.new(1, 0, Vector2.ZERO, 300.0)
SimCore.apply_movement(entity, _command(Vector2.ONE)) # length sqrt(2) -> clamps to 1
assert_almost_eq(entity.position.length(), 300.0 * SimCore.TICK_DELTA, 0.0001)
func test_apply_movement_holds_still_on_null_command() -> void:
var entity := SimEntity.new(1, 0, Vector2(10.0, -5.0), 300.0)
SimCore.apply_movement(entity, null)
assert_eq(entity.position, Vector2(10.0, -5.0), "a null command moves nothing")
func _command(dir: Vector2) -> InputCommand:
var command := InputCommand.new()
command.move_dir = dir
return command
## Every input as a pending entry, seq stamped 1-based in send order.
func _all_pending(inputs: Array) -> Array[Dictionary]:
var pending: Array[Dictionary] = []
for i in inputs.size():
pending.append({"seq": i + 1, "input": inputs[i]})
return pending
added test/unit/test_prediction.gd.uid
@@ -0,0 +1 @@
uid://dabfvbb42oxst