ajhahn.de
← Theria commits

Commit

Theria

feat: interpolate remote entities from buffered snapshots

ajhahnde · Jun 2026 · aa84ff8de38b8b7a9ed0bf3672508c904c10a90e · parent: 24c5ac1 · view on GitHub →

modified CHANGELOG.md
@@ -40,6 +40,11 @@ protocol version.
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).
- Remote-entity interpolation: enemy heroes, creeps, and structures are now
rendered a short delay in the past, interpolated between buffered snapshots, so
they move smoothly instead of stuttering or snapping when packets arrive with
jitter or are dropped. The delay applies only to remote units; the joined
player's own hero is still predicted to the present and feels no added latency.
- 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
@@ -56,7 +56,9 @@ another driver — a listen-server — added without rewriting gameplay. The hos
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.
the same movement code the server runs. Remote units — the enemy hero, creeps, and
structures — are rendered a short delay in the past, interpolated between buffered
snapshots, so they move smoothly through network jitter and dropped packets.
## Layout
@@ -64,7 +66,7 @@ the same movement code the server runs. Remote units are drawn from the snapshot
| :------------- | :---------------------------------------------------- |
| `src/sim` | The authoritative simulation core and its data types. |
| `src/bot` | Bot input derived from the world state. |
| `src/net` | Listen-server transport and the client/server wire protocol. |
| `src/net` | Listen-server transport, the client/server wire protocol, and remote-entity interpolation. |
| `src/client` | Local input sampling and rendering. |
| `test/unit` | Headless tests of the simulation and the wire protocol. |
| `scenes` | Godot scenes. |
modified src/client/main.gd
@@ -11,12 +11,15 @@ extends Node2D
## 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.
## snapshot. Remote entities are rendered a short delay in the past,
## interpolated between buffered snapshots, so they move smoothly through
## network jitter and dropped packets.
##
## All authority stays in SimCore; the transport lives in NetSession; the wire
## 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.
## shaping lives in NetProtocol; remote-entity smoothing lives in
## SnapshotInterpolator. This node samples input, routes it, predicts the client's
## own hero, interpolates the rest, and draws the resulting state — the same `_draw`
## serves every mode.
enum Mode { LOCAL, HOST, CLIENT }
@@ -27,6 +30,14 @@ const BOT_TEAM := 1
const DEFAULT_JOIN_ADDRESS := "127.0.0.1"
## CLIENT: how far in the past (milliseconds) remote entities are rendered. The
## interpolator draws them between the snapshots bracketing this delayed time, so
## the delay is the jitter/loss budget — at the 60 Hz snapshot rate it spans ~6
## snapshots, enough that a late or dropped one is covered by its neighbours rather
## than stalling the unit. Only remote entities pay it; our own hero is predicted to
## the present, so the local player feels no added latency.
const INTERPOLATION_DELAY_MS := 100.0
const HERO_COLOR := Color(0.36, 0.66, 1.0)
const BOT_COLOR := Color(1.0, 0.42, 0.38)
const ENTITY_RADIUS := 44.0
@@ -74,9 +85,12 @@ var _team1_peer: int = 0
var _joined: bool = false
## 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.
## CLIENT: the world to draw — remote entities interpolated a short delay in the
## past, with our own hero overlaid at its predicted (present) position.
var _client_state: SimState = null
## CLIENT: buffers recent snapshots and renders remote entities interpolated
## between them, smoothing network jitter and dropped packets.
var _interp := SnapshotInterpolator.new()
## 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
@@ -185,35 +199,72 @@ func _tick_host() -> void:
## 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.
## pending, feeds the latest snapshot to the interpolator, then rebuilds the world
## to draw. Prediction makes the local hero respond immediately instead of waiting a
## round-trip; interpolation makes the remote entities move smoothly despite jitter.
func _tick_client() -> void:
if _joined:
_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()
_buffer_latest_snapshot()
_client_state = _render_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:
## Feeds the freshest authoritative snapshot into the interpolation buffer. The
## interpolator ignores ticks it already holds, so polling the latest every frame is
## safe — each distinct snapshot is buffered once with its arrival time. This decodes
## its own copy; prediction decodes a separate one, so neither mutates the buffer.
func _buffer_latest_snapshot() -> void:
var state := _net.latest_state()
if state != null:
_interp.push(state, Time.get_ticks_msec())
## The world to draw: remote entities interpolated INTERPOLATION_DELAY_MS in the
## past (smoothing jitter and absorbing dropped snapshots), with our own hero
## overlaid at its predicted, present-time position. Authority is never forked —
## both halves derive only from the server's snapshots. Null until the first
## snapshot arrives.
func _render_state() -> SimState:
var state := _interp.sample(Time.get_ticks_msec() - INTERPOLATION_DELAY_MS)
if state == null:
return null
_overlay_predicted_hero(state)
return state
## Replaces our hero's interpolated (past) position in `state` with its predicted
## present-time position, so only our hero escapes the interpolation delay while
## every other entity stays smoothed.
func _overlay_predicted_hero(state: SimState) -> void:
var predicted := _predicted_hero()
if predicted == null:
return
var hero := _local_hero(state)
if hero != null:
hero.position = predicted.position
## Our hero reconciled against the latest snapshot: take its authoritative position,
## drop the inputs the server has already applied, and replay the rest with the same
## movement math the server runs. Authority is never forked — the snapshot rolls our
## hero back to the server's truth before the replay, so a misprediction self-corrects
## within a tick. Returns null before the first snapshot or if our hero is not in it.
func _predicted_hero() -> SimEntity:
var state := _net.latest_state()
if state == null:
return null
var hero := _local_hero(state)
if hero == 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
for entry in _pending_inputs:
SimCore.apply_movement(hero, entry["input"])
return hero
## Our hero in `state`: the one mobile, non-creep unit on our team. The walking
@@ -259,8 +310,8 @@ func _on_server_left() -> void:
# --- Rendering --------------------------------------------------------------
## The world this client should draw: the predicted state on a pure CLIENT, the
## authoritative simulation otherwise.
## The world this client should draw: the predicted + interpolated render state on a
## pure CLIENT, the authoritative simulation otherwise.
func _active_state() -> SimState:
return _client_state if _mode == Mode.CLIENT else _sim.state
@@ -280,8 +331,9 @@ func _draw_map() -> void:
## Draws the live world: towers and nexuses as squares, mobile units as circles,
## each with an HP bar. Structures and units share one entity list, so they all
## come straight from the authoritative state (local or received).
## each with an HP bar. Structures and units share one entity list, so they all come
## from one state — the authoritative simulation in LOCAL/HOST, the predicted +
## interpolated render state on a CLIENT.
func _draw_entities() -> void:
var state := _active_state()
if state == null:
added src/net/snapshot_interpolator.gd
@@ -0,0 +1,92 @@
class_name SnapshotInterpolator
extends RefCounted
## Client-side smoothing for remote entities.
##
## A client receives the authoritative world as a stream of snapshots that arrive
## with jitter and the occasional loss (snapshots travel unreliably). Drawing the
## newest one each frame makes remote units stutter and snap. This buffers the last
## few snapshots and renders remote entities a fixed delay in the *past*,
## interpolating between the two snapshots that bracket the render time — so a late
## or dropped snapshot is covered by the ones around it instead of stalling a unit.
##
## It never extrapolates: a render time past the newest snapshot clamps to it
## rather than guessing ahead. Remote entities carry no local input, so guessing
## their future would only snap back when the truth arrives — interpolation in the
## past is the right tool, and prediction is reserved for the client's own hero
## (which `main.gd` overlays on top of the interpolated world). Authority is never
## forked: every position here is derived from the server's snapshots alone.
##
## Pure and engine-free — it takes arrival and render times as plain milliseconds
## rather than reading a clock, so the whole buffer/sample round trip is unit-tested
## headlessly, like the simulation and protocol cores.
## How many recent snapshots to retain. At the 60 Hz snapshot rate this spans
## ~200 ms, comfortably more than any sane interpolation delay needs to bracket.
const BUFFER_LIMIT := 12
## Buffered snapshots, oldest first, each `{time: float, state: SimState}` where
## `time` is the arrival time in milliseconds. Kept ascending in both arrival time
## and tick by `push`.
var _buffer: Array[Dictionary] = []
## Records a freshly received snapshot, stamped with its arrival time in
## milliseconds. Snapshots whose tick is not newer than the latest buffered one are
## ignored, so feeding the most recent snapshot every frame is safe — each distinct
## snapshot is buffered exactly once, and a reordered or duplicate one is dropped.
func push(state: SimState, recv_msec: float) -> void:
if state == null:
return
if not _buffer.is_empty() and state.tick <= _buffer[-1]["state"].tick:
return
_buffer.append({"time": recv_msec, "state": state})
if _buffer.size() > BUFFER_LIMIT:
_buffer.pop_front()
func has_data() -> bool:
return not _buffer.is_empty()
## The interpolated world at `render_msec`: the two buffered snapshots that bracket
## that time, with each entity present in both lerped between them. Returns null
## before any snapshot arrives, and the lone snapshot when only one is held. A
## render time outside the buffered span clamps to the nearest end — it never
## extrapolates past the newest snapshot.
func sample(render_msec: float) -> SimState:
if _buffer.is_empty():
return null
if _buffer.size() == 1:
return _buffer[0]["state"]
if render_msec <= _buffer[0]["time"]:
return _buffer[0]["state"]
if render_msec >= _buffer[-1]["time"]:
return _buffer[-1]["state"]
var i := 0
while i < _buffer.size() - 1 and _buffer[i + 1]["time"] <= render_msec:
i += 1
var before: Dictionary = _buffer[i]
var after: Dictionary = _buffer[i + 1]
var span: float = after["time"] - before["time"]
var alpha := 0.0 if span <= 0.0 else clampf((render_msec - before["time"]) / span, 0.0, 1.0)
return _interpolate(before["state"], after["state"], alpha)
## Builds a render state between two snapshots. The newer snapshot `after` is the
## target: iterating its entities makes a unit that has spawned appear and one that
## has died drop at the newer snapshot's moment. An entity also present in `before`
## has its position lerped; every other field, and any entity new in `after`, comes
## straight from `after` (the fresher truth). The buffered snapshots are never
## mutated — each render entity is a clone.
static func _interpolate(before: SimState, after: SimState, alpha: float) -> SimState:
var out := SimState.new()
out.tick = after.tick
out.winner = after.winner
for id in after.entities:
var target: SimEntity = after.entities[id]
var entity := target.clone()
var prior: SimEntity = before.entities.get(id, null)
if prior != null:
entity.position = prior.position.lerp(target.position, alpha)
out.add_entity(entity)
return out
added src/net/snapshot_interpolator.gd.uid
@@ -0,0 +1 @@
uid://cu04c3567xlq2
modified src/sim/sim_entity.gd
@@ -50,3 +50,22 @@ func _init(
team = p_team
position = p_pos
move_speed = p_speed
## Returns a field-for-field copy of this entity. The client's snapshot
## interpolation uses it to build a render entity at an in-between position without
## mutating the buffered authoritative snapshots it derives from.
func clone() -> SimEntity:
var copy := SimEntity.new(id, team, position, move_speed)
copy.hp = hp
copy.max_hp = max_hp
copy.attack_damage = attack_damage
copy.attack_range = attack_range
copy.attack_cooldown_ticks = attack_cooldown_ticks
copy.cooldown = cooldown
copy.is_structure = is_structure
copy.is_nexus = is_nexus
copy.is_creep = is_creep
copy.lane = lane
copy.waypoint_index = waypoint_index
return copy
added test/unit/test_snapshot_interpolator.gd
@@ -0,0 +1,118 @@
extends GutTest
## The N3 remote-entity interpolation invariants, exercised without any networking.
##
## SnapshotInterpolator buffers authoritative snapshots stamped with their arrival
## time and renders remote entities a delay in the past, interpolating between the
## two snapshots that bracket the render time. These tests pin the buffer and the
## sampling math: the linear blend at the midpoint, the no-extrapolation clamps at
## both ends, spawn/death handling, duplicate-tick rejection, and the buffer cap —
## all pure, so the round trip is checked headlessly like the protocol and sim cores.
func test_an_empty_buffer_samples_to_null() -> void:
var interp := SnapshotInterpolator.new()
assert_false(interp.has_data(), "a fresh interpolator holds nothing")
assert_null(interp.sample(0.0), "with no snapshots there is nothing to render")
func test_a_lone_snapshot_is_returned_as_is() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(100.0, 200.0)}), 0.0)
# With only one snapshot there is no pair to blend, so any render time yields it.
var sampled := interp.sample(-50.0)
assert_eq(sampled.get_entity(7).position, Vector2(100.0, 200.0))
func test_midpoint_render_time_lerps_position_halfway() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(100.0, 40.0)}), 100.0)
# Render exactly between the two arrival times -> alpha 0.5 -> the midpoint.
var sampled := interp.sample(50.0)
assert_eq(sampled.get_entity(7).position, Vector2(50.0, 20.0))
func test_a_render_time_before_the_oldest_clamps_to_it() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 100.0)
interp.push(_state(2, {7: Vector2(100.0, 0.0)}), 200.0)
# Earlier than anything buffered: clamp to the oldest, never extrapolate backwards.
var sampled := interp.sample(0.0)
assert_eq(sampled.get_entity(7).position, Vector2(0.0, 0.0))
func test_a_render_time_past_the_newest_clamps_to_it() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(100.0, 0.0)}), 100.0)
# Later than anything buffered: clamp to the newest. Remote entities are never
# extrapolated ahead — guessing their future only snaps back when truth arrives.
var sampled := interp.sample(500.0)
assert_eq(sampled.get_entity(7).position, Vector2(100.0, 0.0))
func test_a_repeated_or_stale_tick_is_ignored() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(2, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(999.0, 0.0)}), 100.0) # same tick -> dropped
interp.push(_state(1, {7: Vector2(888.0, 0.0)}), 200.0) # older tick -> dropped
# Only the first snapshot was kept, so sampling still returns its position.
assert_eq(interp.sample(150.0).get_entity(7).position, Vector2(0.0, 0.0))
func test_an_entity_that_spawned_after_appears_at_its_own_position() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(10.0, 0.0), 9: Vector2(60.0, 0.0)}), 100.0)
# Entity 9 exists only in the newer snapshot -> nothing to lerp from, so it
# renders at its newer position rather than being blended toward the origin.
var sampled := interp.sample(50.0)
assert_not_null(sampled.get_entity(9), "the newly spawned entity is present")
assert_eq(sampled.get_entity(9).position, Vector2(60.0, 0.0))
func test_an_entity_that_died_before_the_newer_snapshot_is_dropped() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0), 9: Vector2(60.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(10.0, 0.0)}), 100.0)
# Entity 9 is gone in the newer snapshot, so the render (targeting the newer
# one) omits it — a dead unit disappears, it does not linger interpolating.
var sampled := interp.sample(50.0)
assert_null(sampled.get_entity(9), "the entity absent from the newer snapshot is dropped")
func test_non_position_fields_come_from_the_newer_snapshot() -> void:
var interp := SnapshotInterpolator.new()
var older := _state(1, {7: Vector2(0.0, 0.0)})
older.get_entity(7).hp = 100
var newer := _state(2, {7: Vector2(100.0, 0.0)})
newer.get_entity(7).hp = 40
interp.push(older, 0.0)
interp.push(newer, 100.0)
# Position blends, but hp (and every other field) is the fresher truth, not a blend.
var sampled := interp.sample(50.0)
assert_eq(sampled.get_entity(7).position, Vector2(50.0, 0.0), "position is blended")
assert_eq(sampled.get_entity(7).hp, 40, "hp is taken whole from the newer snapshot")
func test_the_buffer_is_capped_to_its_limit() -> void:
var interp := SnapshotInterpolator.new()
var count := SnapshotInterpolator.BUFFER_LIMIT + 5
for tick in range(1, count + 1):
interp.push(_state(tick, {7: Vector2(float(tick), 0.0)}), float(tick))
# The oldest snapshots are evicted, so a render time before the surviving window
# clamps to the oldest retained snapshot, not the long-discarded tick 1.
var oldest_kept := float(count - SnapshotInterpolator.BUFFER_LIMIT + 1)
assert_eq(interp.sample(0.0).get_entity(7).position, Vector2(oldest_kept, 0.0))
# --- helpers ----------------------------------------------------------------
## A snapshot at `tick` whose entities are the given `id -> position` pairs.
func _state(tick: int, positions: Dictionary) -> SimState:
var state := SimState.new()
state.tick = tick
for id in positions:
state.add_entity(SimEntity.new(id, 1, positions[id], 0.0))
return state
added test/unit/test_snapshot_interpolator.gd.uid
@@ -0,0 +1 @@
uid://bcxjj6opxv18k