ajhahn.de
← Theria commits

Commit

Theria

feat: add a playtest network-condition simulator for the client

ajhahnde · Jun 2026 · 32e854154e2680a529c9c6d17e45e03a01f4f2f7 · parent: 47db6f1 · view on GitHub →

modified CHANGELOG.md
@@ -49,6 +49,13 @@ protocol version.
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.
- Simulated link conditions for playtesting: a joining player can add `--netsim
<latency>,<jitter>,<loss>` to shape their incoming snapshot stream as if it had
crossed a worse network — delaying, jittering, and dropping snapshots — so the
remote-unit smoothing and its adaptive delay can be seen working on a local
machine or LAN, which otherwise deliver almost perfectly. A client-side debug aid
only: it changes nothing the host sends and no wire bytes, so the protocol version
is unaffected.
- 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
@@ -67,7 +67,7 @@ delay adapts to the connection's measured jitter rather than being fixed.
| :------------- | :---------------------------------------------------- |
| `src/sim` | The authoritative simulation core and its data types. |
| `src/bot` | Bot input derived from the world state. |
| `src/net` | Listen-server transport, the client/server wire protocol, and remote-entity interpolation. |
| `src/net` | Listen-server transport, the client/server wire protocol, remote-entity interpolation, and the playtest link-condition simulator. |
| `src/client` | Local input sampling and rendering. |
| `test/unit` | Headless tests of the simulation and the wire protocol. |
| `scenes` | Godot scenes. |
@@ -95,6 +95,19 @@ 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 joining
player's hero is predicted locally, so it responds without waiting on the host.
A local machine and a clean LAN deliver snapshots almost perfectly, so the smoothing
that exists to ride out a bad connection is never really exercised. To see it work, a
joining player can simulate a worse link on their incoming snapshot stream:
```sh
# join with 150 ms latency, 50 ms of jitter, and 10% packet loss
godot --path . -- --join 127.0.0.1 --netsim 150,50,0.1
```
This shapes only what the client receives — it changes nothing the host sends and no
wire bytes — and makes the remote units visibly buffer further behind and the
interpolation cover the dropped snapshots. It is a debug aid, not a gameplay option.
## Testing
Tests run headless with [GUT](https://github.com/bitwes/Gut):
modified src/client/main.gd
@@ -15,6 +15,11 @@ extends Node2D
## interpolated between buffered snapshots, so they move smoothly through
## network jitter and dropped packets.
##
## A CLIENT may add `--netsim <latency>,<jitter>,<loss>` to shape its incoming
## snapshot stream as if it had crossed a worse link — a debug aid for watching the
## adaptive interpolation delay grow and the interpolation cover dropped snapshots,
## since the local machine and LAN deliver almost perfectly.
##
## All authority stays in SimCore; the transport lives in NetSession; the wire
## shaping lives in NetProtocol; remote-entity smoothing lives in
## SnapshotInterpolator. This node samples input, routes it, predicts the client's
@@ -30,6 +35,10 @@ const BOT_TEAM := 1
const DEFAULT_JOIN_ADDRESS := "127.0.0.1"
## Fixed seed for the optional `--netsim` conditioner, so a shaped playtest replays
## the same drop and jitter pattern run to run.
const NETSIM_SEED := 1
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
@@ -63,6 +72,12 @@ const HP_BAR_FG := Color(0.4, 0.85, 0.4)
var _mode: int = Mode.LOCAL
var _join_address := DEFAULT_JOIN_ADDRESS
## CLIENT: optional simulated link conditions parsed from `--netsim
## <latency>,<jitter>,<loss>`, as `[latency_ms, jitter_ms, loss]`, or empty to take
## snapshots as they arrive. A debug aid for exercising the smoothing under a worse
## link than the local machine provides.
var _netsim_params: Array = []
## The authoritative simulation. Present in LOCAL and HOST; null on a pure CLIENT,
## which renders snapshots instead of simulating.
var _sim: SimCore = null
@@ -129,9 +144,32 @@ func _configure_from_cmdline() -> void:
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_join_address = args[i + 1]
i += 1
elif arg == "--netsim":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_netsim_params = _parse_netsim(args[i + 1])
i += 1
i += 1
## Parses a `--netsim` value of `latency,jitter,loss` (milliseconds, milliseconds,
## a 0..1 fraction) into `[latency_ms, jitter_ms, loss]`. Missing trailing fields
## default to zero; a malformed value yields an empty array (the conditioner is left
## off) with a warning, so a typo degrades to a normal join rather than a crash.
func _parse_netsim(value: String) -> Array:
var fields := value.split(",")
var nums: Array = []
for field in fields:
if not field.is_valid_float():
push_warning("ignoring malformed --netsim value %s (want latency,jitter,loss)" % value)
return []
nums.append(field.to_float())
return [
maxf(0.0, (nums[0] if nums.size() > 0 else 0.0)),
maxf(0.0, (nums[1] if nums.size() > 1 else 0.0)),
clampf((nums[2] if nums.size() > 2 else 0.0), 0.0, 1.0),
]
func _start_local() -> void:
_sim = SimCore.new()
_sim.spawn_structures()
@@ -160,6 +198,12 @@ func _start_client() -> void:
_net.joined_server.connect(_on_joined_server)
_net.rejected.connect(_on_rejected)
_net.server_left.connect(_on_server_left)
if not _netsim_params.is_empty():
_net.configure_netsim(_netsim_params[0], _netsim_params[1], _netsim_params[2], NETSIM_SEED)
print(
"simulating link: %d ms latency, %d ms jitter, %d%% loss"
% [_netsim_params[0], _netsim_params[1], roundi(_netsim_params[2] * 100.0)]
)
print("joining %s:%d" % [_join_address, NetSession.DEFAULT_PORT])
@@ -200,18 +244,26 @@ func _tick_client() -> void:
var command := _sample_player_input()
_net.send_input(_input_seq, command)
_pending_inputs.append({"seq": _input_seq, "input": command})
_buffer_latest_snapshot()
_buffer_snapshots()
_client_state = _render_state()
## 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())
## Feeds freshly arrived authoritative snapshots into the interpolation buffer. With
## a `--netsim` conditioner the session releases the snapshots whose simulated delay
## has elapsed, each stamped with its release time so the injected latency and jitter
## read as real arrival timing; otherwise the freshest snapshot is buffered as it
## stands. Either way the interpolator ignores ticks it already holds, so each
## distinct snapshot is buffered once. This decodes its own copy; prediction decodes
## a separate one, so neither mutates the buffer.
func _buffer_snapshots() -> void:
var now := float(Time.get_ticks_msec())
if _net.is_conditioned():
for delivered in _net.drain_snapshots(now):
_interp.push(delivered["state"], delivered["time"])
else:
var state := _net.latest_state()
if state != null:
_interp.push(state, now)
## The world to draw: remote entities interpolated in the past (smoothing jitter and
modified src/net/net_session.gd
@@ -43,8 +43,17 @@ var _latest_inputs: Dictionary = {}
var _latest_input_seqs: Dictionary = {}
## Client: the most recent snapshot Dictionary, or empty until the first arrives.
## Updated as snapshots are drained from the conditioner, so it reflects the shaped
## stream — both prediction (which reads it) and interpolation see the same delayed,
## lossy arrivals.
var _latest_snapshot: Dictionary = {}
## Client: optional network-condition simulator on the snapshot intake. When set,
## every received snapshot passes through it (delayed, jittered, or dropped) and is
## delivered by `drain_snapshots` rather than landing immediately. Null on the server
## and on a client running without `--netsim`, in which case snapshots arrive raw.
var _netsim: NetSim = null
## Starts hosting on `port`. Returns OK or an ENet error; on success this peer is
## the multiplayer authority and runs the authoritative simulation.
@@ -130,6 +139,22 @@ func _push_input(data: Array) -> void:
# --- Client side ------------------------------------------------------------
## Installs a network-condition simulator on the snapshot intake (a debug aid for
## exercising the smoothing under a worse link than the local machine provides).
## Once set, received snapshots are held for `latency_ms` plus up to `jitter_ms`,
## and a `loss` fraction is dropped, with `drain_snapshots` releasing the rest when
## due. A no-op when left unset: snapshots arrive raw. Seeded for a reproducible run.
func configure_netsim(latency_ms: float, jitter_ms: float, loss: float, rng_seed: int) -> void:
_netsim = NetSim.new(latency_ms, jitter_ms, loss, rng_seed)
## Whether a network-condition simulator is installed. When true the client must
## deliver snapshots through `drain_snapshots`; when false it buffers the latest
## snapshot directly.
func is_conditioned() -> bool:
return _netsim != null
## 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:
@@ -154,6 +179,24 @@ func latest_ack() -> int:
return _latest_snapshot.get("ack", -1)
## Releases the snapshots whose conditioner hold has elapsed by `now_msec` and
## returns them oldest first, each `{time: float, state: SimState}` where `time` is
## the release time the caller stamps onto the interpolation buffer (so injected
## latency and jitter read as real arrival timing). `_latest_snapshot` advances to
## the newest released, so prediction reconciles against the same shaped stream.
## Returns an empty array when no conditioner is installed — a raw client buffers
## the latest snapshot directly and does not drain.
func drain_snapshots(now_msec: float) -> Array:
var delivered: Array = []
if _netsim == null:
return delivered
for packet in _netsim.drain(now_msec):
_latest_snapshot = packet["data"]
var state := NetProtocol.decode_snapshot(packet["data"])
delivered.append({"time": packet["release"], "state": state})
return delivered
func _on_connected_to_server() -> void:
_submit_hello.rpc_id(1, NetProtocol.PROTOCOL_VERSION)
@@ -170,4 +213,8 @@ func _reject(reason: String) -> void:
@rpc("authority", "call_remote", "unreliable_ordered")
func _push_snapshot(data: Dictionary) -> void:
_latest_snapshot = data
if _netsim != null:
# Hold the snapshot in the conditioner; `drain_snapshots` delivers it later.
_netsim.receive(data, Time.get_ticks_msec())
else:
_latest_snapshot = data
added src/net/net_sim.gd
@@ -0,0 +1,90 @@
class_name NetSim
extends RefCounted
## A client-side network-condition simulator for the snapshot stream.
##
## The listen-server's snapshots reach a client over the local machine or a clean
## LAN with almost no latency, jitter, or loss — so the smoothing that exists to
## ride out a bad connection (interpolation and its adaptive delay) is never
## actually exercised in a playtest. This shapes the received snapshot stream as if
## it had crossed a worse link: it holds each snapshot for a base `latency` plus a
## random `jitter`, and drops a `loss` fraction outright. The result is irregular
## arrival gaps and holes the rest of the netcode then has to absorb, so the
## adaptive interpolation delay can be *seen* growing and the interpolation can be
## seen covering dropped snapshots.
##
## It conditions opaque payloads (it never inspects the snapshot) and takes arrival
## and release times as plain milliseconds rather than reading a clock, so — like
## the simulation, protocol, and interpolation cores — it is pure and unit-tested
## headlessly. Its randomness is seeded, so a given seed yields the same drop and
## jitter pattern every run, which keeps the tests deterministic and a playtest
## reproducible.
##
## It lives on the client's snapshot intake only: it shapes nothing the server
## sends out and changes no wire bytes, so PROTOCOL_VERSION is unaffected. The
## whole stream — what interpolation buffers and what prediction reconciles against
## alike — passes through it, so a simulated bad link degrades the client honestly
## rather than only cosmetically.
## Base hold applied to every accepted snapshot, in milliseconds: its release time
## is at least this far past its arrival.
var _latency_ms: float
## Extra random hold on top of `_latency_ms`, in milliseconds: each snapshot is
## held an additional uniform `[0, _jitter_ms)`. This is what makes consecutive
## arrival gaps irregular, which is what the adaptive delay responds to.
var _jitter_ms: float
## Fraction of snapshots dropped on arrival, in `[0, 1]` — a dropped snapshot is
## never queued and never released, leaving a hole the interpolation must cover.
var _loss: float
## Seeded so the drop and jitter pattern is reproducible: deterministic for the
## tests and repeatable in a playtest.
var _rng := RandomNumberGenerator.new()
## Snapshots accepted but not yet due, each `{release: float, data: Variant}` where
## `release` is the arrival time plus the hold. Not kept sorted; `drain` orders the
## packets it releases.
var _pending: Array[Dictionary] = []
func _init(latency_ms: float, jitter_ms: float, loss: float, rng_seed: int) -> void:
_latency_ms = maxf(0.0, latency_ms)
_jitter_ms = maxf(0.0, jitter_ms)
_loss = clampf(loss, 0.0, 1.0)
_rng.seed = rng_seed
## Offers a freshly received snapshot, stamped with its arrival time in
## milliseconds. Returns false if the loss roll drops it (it is discarded), or true
## if it was queued to be released later at `arrival + latency + random jitter`.
func receive(data: Variant, recv_msec: float) -> bool:
if _rng.randf() < _loss:
return false
var release := recv_msec + _latency_ms + _rng.randf() * _jitter_ms
_pending.append({"release": release, "data": data})
return true
## Releases every queued snapshot whose hold has elapsed by `now_msec`, oldest
## release first, and removes them from the queue. Each returned entry is
## `{release: float, data: Variant}`; the caller stamps the downstream buffer with
## `release` so the injected latency and jitter show up as real arrival timing.
## Jitter can reorder releases relative to arrival; the snapshot buffer downstream
## already drops a stale tick, so an overtaken snapshot is handled there.
func drain(now_msec: float) -> Array:
var due: Array = []
var kept: Array[Dictionary] = []
for packet in _pending:
if packet["release"] <= now_msec:
due.append(packet)
else:
kept.append(packet)
_pending = kept
due.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["release"] < b["release"])
return due
## Whether any snapshot is held back waiting for its release time.
func has_pending() -> bool:
return not _pending.is_empty()
added src/net/net_sim.gd.uid
@@ -0,0 +1 @@
uid://cec16trn0wggh
added test/unit/test_net_sim.gd
@@ -0,0 +1,85 @@
extends GutTest
## The N5 network-condition simulator, exercised without any networking.
##
## NetSim shapes the client's incoming snapshot stream — holding each snapshot a
## base latency plus random jitter, dropping a loss fraction — so the smoothing can
## be exercised under a worse link than the local machine provides. These tests pin
## its contract: identity pass-through, the latency hold, the seeded loss rate,
## the jitter band, release ordering, and that a not-yet-due snapshot stays queued.
## It conditions opaque payloads and takes plain millisecond times, so the whole
## round trip is checked headlessly like the protocol, sim, and interpolation cores.
func test_identity_releases_immediately() -> void:
# No latency, jitter, or loss: a snapshot is due the instant it arrives.
var sim := NetSim.new(0.0, 0.0, 0.0, 1)
assert_true(sim.receive("a", 100.0), "an unconditioned snapshot is always accepted")
var due := sim.drain(100.0)
assert_eq(due.size(), 1, "the snapshot is released at its arrival time")
assert_eq(due[0]["data"], "a")
assert_eq(due[0]["release"], 100.0)
assert_false(sim.has_pending(), "nothing is left queued")
func test_latency_holds_until_due() -> void:
var sim := NetSim.new(50.0, 0.0, 0.0, 1)
sim.receive("a", 100.0)
assert_eq(sim.drain(149.0).size(), 0, "before arrival + latency the snapshot is held")
assert_true(sim.has_pending(), "and stays queued")
var due := sim.drain(150.0)
assert_eq(due.size(), 1, "at arrival + latency it is released")
assert_eq(due[0]["release"], 150.0)
func test_loss_drops_at_its_rate_and_drops_stay_gone() -> void:
# Seeded, so the drop pattern is fixed: at 50% loss over 1000 arrivals the
# delivered count sits near 500, and a dropped snapshot is never released later.
var sim := NetSim.new(0.0, 0.0, 0.5, 12345)
var accepted := 0
for i in range(1000):
if sim.receive(i, float(i)):
accepted += 1
assert_between(accepted, 440, 560, "roughly half the snapshots survive the loss roll")
assert_eq(sim.drain(2000.0).size(), accepted, "only the accepted snapshots are ever released")
func test_jitter_spreads_release_within_the_band() -> void:
# Every snapshot shares one arrival time, so their spread comes purely from jitter:
# each is held the base latency plus a random [0, jitter), never outside that band.
var sim := NetSim.new(100.0, 40.0, 0.0, 7)
for i in range(50):
sim.receive(i, 0.0)
var due := sim.drain(1000.0)
assert_eq(due.size(), 50, "no loss, so all are released once due")
var lowest := INF
var highest := -INF
for packet in due:
lowest = minf(lowest, packet["release"])
highest = maxf(highest, packet["release"])
assert_gte(lowest, 100.0, "never released before the base latency")
assert_lt(highest, 140.0, "never held beyond latency + jitter")
assert_gt(highest - lowest, 0.0, "jitter actually spreads the release times")
func test_drain_returns_due_in_release_order() -> void:
# Released oldest-first regardless of the order they were offered, so the
# downstream buffer sees them in arrival order.
var sim := NetSim.new(0.0, 0.0, 0.0, 1)
sim.receive("c", 30.0)
sim.receive("a", 10.0)
sim.receive("b", 20.0)
var order: Array = []
for packet in sim.drain(100.0):
order.append(packet["data"])
assert_eq(order, ["a", "b", "c"], "drained in ascending release time, not offer order")
func test_a_not_yet_due_snapshot_waits_for_a_later_drain() -> void:
var sim := NetSim.new(0.0, 0.0, 0.0, 1)
sim.receive("a", 10.0)
sim.receive("b", 30.0)
assert_eq(sim.drain(20.0).size(), 1, "only the snapshot already due is released")
assert_eq(sim.drain(20.0).size(), 0, "the released one is not handed out twice")
var later := sim.drain(40.0)
assert_eq(later.size(), 1, "the held snapshot is released once its time comes")
assert_eq(later[0]["data"], "b")
added test/unit/test_net_sim.gd.uid
@@ -0,0 +1 @@
uid://t1wsvandhttb