ajhahn.de
← Theria commits

Commit

Theria

feat: adapt the interpolation delay to connection jitter

ajhahnde · Jun 2026 · 47db6f1ba89d85cfc7b70b0b4e5395021cbd5009 · parent: aa84ff8 · view on GitHub →

modified CHANGELOG.md
@@ -45,6 +45,10 @@ protocol version.
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.
- Adaptive interpolation delay: the delay remote units are rendered behind now
tracks the connection's measured jitter instead of a fixed value — a clean
connection pays little added latency, while a jittery one automatically buffers
enough to ride out its worst gap, within a bounded range.
- 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
@@ -58,7 +58,8 @@ locally so input feels instant, reconciling against every snapshot: it rolls bac
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 — 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.
snapshots, so they move smoothly through network jitter and dropped packets; that
delay adapts to the connection's measured jitter rather than being fixed.
## Layout
modified src/client/main.gd
@@ -30,14 +30,6 @@ 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
@@ -222,13 +214,13 @@ func _buffer_latest_snapshot() -> void:
_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.
## The world to draw: remote entities interpolated in the past (smoothing jitter and
## absorbing dropped snapshots), with our own hero overlaid at its predicted,
## present-time position. The interpolation delay adapts to the live connection's
## jitter. 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)
var state := _interp.sample(Time.get_ticks_msec() - _interp.target_delay_ms())
if state == null:
return null
_overlay_predicted_hero(state)
modified src/net/snapshot_interpolator.gd
@@ -16,19 +16,53 @@ extends RefCounted
## (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.
##
## How far in the past to render is **adaptive** (`target_delay_ms`): it tracks the
## worst recent gap between snapshot arrivals, so a clean connection pays little
## latency while a jittery one buffers enough to ride out its hiccups. The delay
## snaps up to cover a new worst gap and eases back down slowly, and is clamped to a
## sane band, so it tracks the live connection without warping remote-unit motion.
##
## 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
## ~530 ms — comfortably more than MAX_DELAY_MS, so even at the largest adaptive
## delay the render time falls between two buffered snapshots rather than clamping
## to the oldest one.
const BUFFER_LIMIT := 32
## Adaptive render-delay bounds, in milliseconds. The delay tracks the observed
## snapshot jitter so a clean connection pays little latency while a jittery one
## buffers enough to ride out its worst gap. Floored so even a perfect line keeps a
## small cushion (≈3 snapshots), capped so a pathological connection never adds
## unbounded latency.
const MIN_DELAY_MS := 50.0
const MAX_DELAY_MS := 250.0
## Headroom added over the worst recent arrival gap — ≈2 snapshots at 60 Hz — so the
## render time stays behind the newest snapshot through one further late packet.
const GAP_MARGIN_MS := 33.0
## How many of the most recent arrival gaps the delay is sized against. A bad gap is
## remembered for this many snapshots and then forgotten, decoupling how long jitter
## influences the delay from how much history the buffer keeps for sampling.
const GAP_WINDOW := 8
## How fast the delay relaxes once jitter subsides. It snaps up at once to cover a
## new worst gap (fast attack) but eases down by this fraction per snapshot (slow
## release), so a single hiccup does not make the delay itself jitter and warp the
## apparent speed of remote units.
const DELAY_RELEASE := 0.05
## 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] = []
## The current adaptive render delay in milliseconds, updated as snapshots arrive.
var _delay_estimate_ms := MIN_DELAY_MS
## Records a freshly received snapshot, stamped with its arrival time in
## milliseconds. Snapshots whose tick is not newer than the latest buffered one are
@@ -42,12 +76,47 @@ func push(state: SimState, recv_msec: float) -> void:
_buffer.append({"time": recv_msec, "state": state})
if _buffer.size() > BUFFER_LIMIT:
_buffer.pop_front()
_update_delay_estimate()
func has_data() -> bool:
return not _buffer.is_empty()
## The render delay the client should currently apply: remote entities are drawn
## this many milliseconds in the past. It adapts to observed snapshot jitter
## (clamped to [MIN_DELAY_MS, MAX_DELAY_MS]) so the smoothing tracks the live
## connection instead of a fixed guess.
func target_delay_ms() -> float:
return _delay_estimate_ms
## Resizes the adaptive delay to cover the worst arrival gap over the recent window,
## plus a margin, clamped to the delay band. Snaps up at once to cover a worse gap
## (so the next late packet is already absorbed) and eases down slowly otherwise (so
## the delay itself stays steady). A no-op until two snapshots give a first gap.
func _update_delay_estimate() -> void:
if _buffer.size() < 2:
return
var target := clampf(_max_recent_gap() + GAP_MARGIN_MS, MIN_DELAY_MS, MAX_DELAY_MS)
if target >= _delay_estimate_ms:
_delay_estimate_ms = target
else:
_delay_estimate_ms += (target - _delay_estimate_ms) * DELAY_RELEASE
## The widest interval between consecutive arrivals over the last GAP_WINDOW gaps —
## the worst recent hiccup the delay must stay ahead of.
func _max_recent_gap() -> float:
var widest := 0.0
var start: int = maxi(1, _buffer.size() - GAP_WINDOW)
for i in range(start, _buffer.size()):
var gap: float = _buffer[i]["time"] - _buffer[i - 1]["time"]
if gap > widest:
widest = gap
return widest
## 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
modified test/unit/test_snapshot_interpolator.gd
@@ -106,9 +106,73 @@ func test_the_buffer_is_capped_to_its_limit() -> void:
assert_eq(interp.sample(0.0).get_entity(7).position, Vector2(oldest_kept, 0.0))
# --- adaptive render delay --------------------------------------------------
func test_delay_floors_at_min_on_even_arrivals() -> void:
var interp := SnapshotInterpolator.new()
# Snapshots arriving evenly at the 60 Hz interval carry almost no jitter, so the
# delay sits on its floor rather than buffering latency it does not need.
_push_arrivals(interp, _repeat(16.0, 10))
assert_almost_eq(interp.target_delay_ms(), SnapshotInterpolator.MIN_DELAY_MS, 0.001)
func test_delay_attacks_to_cover_a_large_gap() -> void:
var interp := SnapshotInterpolator.new()
# A single late snapshot opens a 200 ms gap; the delay snaps up at once to cover
# it (worst gap + margin), so the next late packet is already absorbed.
_push_arrivals(interp, [16.0, 16.0, 16.0, 200.0])
var expected := 200.0 + SnapshotInterpolator.GAP_MARGIN_MS
assert_almost_eq(interp.target_delay_ms(), expected, 0.001)
func test_delay_caps_at_max() -> void:
var interp := SnapshotInterpolator.new()
# A pathological gap must not buy unbounded latency — the delay clamps to the cap.
_push_arrivals(interp, [16.0, 16.0, 1000.0])
assert_almost_eq(interp.target_delay_ms(), SnapshotInterpolator.MAX_DELAY_MS, 0.001)
func test_delay_releases_slowly_after_jitter_subsides() -> void:
var interp := SnapshotInterpolator.new()
# A 200 ms gap drives the delay to its peak; once even arrivals resume the gap
# leaves the recent window and the delay eases back down — gradually, not snapping
# to the floor (which would warp remote-unit motion), but well below the peak.
var peak := 200.0 + SnapshotInterpolator.GAP_MARGIN_MS # the delay right after the gap
var intervals := [16.0, 16.0, 200.0]
intervals.append_array(_repeat(16.0, 20))
_push_arrivals(interp, intervals)
assert_lt(interp.target_delay_ms(), peak, "the delay relaxes once jitter subsides")
assert_gt(
interp.target_delay_ms(),
SnapshotInterpolator.MIN_DELAY_MS,
"but eases down slowly rather than snapping to the floor",
)
# --- helpers ----------------------------------------------------------------
## Pushes a stream of snapshots whose inter-arrival times are `intervals`
## (milliseconds). The first arrives at time 0; time and tick advance per interval.
func _push_arrivals(interp: SnapshotInterpolator, intervals: Array) -> void:
var time := 0.0
var tick := 1
interp.push(_state(tick, {7: Vector2(float(tick), 0.0)}), time)
for dt: float in intervals:
time += dt
tick += 1
interp.push(_state(tick, {7: Vector2(float(tick), 0.0)}), time)
## An array of `value` repeated `count` times.
func _repeat(value: float, count: int) -> Array:
var out: Array = []
for _i in count:
out.append(value)
return out
## A snapshot at `tick` whose entities are the given `id -> position` pairs.
func _state(tick: int, positions: Dictionary) -> SimState:
var state := SimState.new()