Files
transcience/src/net/client_runtime.gd
T
claude 7af439341d
ci / verify (push) Successful in 46s
Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
Replaces the fixed centred arena with per-world tile geometry, which is the
foundation the remaining features sit on.

- MapGrid: tile grid with three independent flags -- blocks movement, bullets,
  sight -- so a pit stops feet but not bullets or eyes, and a barricade stops
  feet and bullets but not eyes. Circle collision with per-axis sliding, and
  Bresenham line of sight shared by fog and aggro.
- MapGen: rooms and corridors generated from (seed, depth), with hand-authored
  boss arenas from Rooms stamped in first so a corridor can never carve through
  a designed fight. Reachability from spawn to boss is asserted over 40 seeds --
  "usually connected" is the failure mode that ruins one run in twenty.
- Dungeons are populated at creation, per room, instead of gating on waves. You
  explore and choose your fights; the run ends when the boss dies, not when the
  map is swept. Boss rooms have no lock, so walking out is always available.
- Aggro: enemies need range AND line of sight, so a dungeon stays quiet until
  engaged and cover actually protects.
- Hard fog, scrolling camera, and terrain rendering.

Maps are streamed per peer in chunks around that peer's player, and the seed is
deliberately NOT sent -- a client holding it could regenerate the whole dungeon,
which is a map hack for free. Stream radius (900u) is wider than view radius
(460u) because the client predicts movement against walls and simulates bullets
that die on them; the accepted cost is a cheater seeing a little further than
the fog, never the floor plan.

Partial map knowledge means wall deaths must be announced rather than derived.
A test caught the subtle half of that: out-of-bounds tiles read as WALL by
design, so checking geometry before bounds reported every bullet leaving the map
as a wall kill.

128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4
players and a boss, ~65x headroom.
2026-09-03 20:49:27 +02:00

375 lines
13 KiB
GDScript

class_name ClientRuntime
extends Node
## The client half: sample input, predict the local player, interpolate everyone
## else, and replay the bullets the server told us about.
##
## The client owns exactly one thing -- where it *thinks* the local player is,
## so the stick feels instant. Every consequence (damage, death, escape, loot)
## comes back from the server and overwrites whatever the client believed.
signal instance_changed
signal hud_dirty
signal local_hit(damage: int)
var my_peer: int = 0
var instance_id: int = 0
var instance_kind: Protocol.InstanceKind = Protocol.InstanceKind.LOBBY
var boss_def: BossDef = null
## Replica world. [member SimWorld.authoritative] is false, so it integrates
## bullets and nothing else.
var world := SimWorld.new()
## Local estimate of the server's tick, used to age incoming bullets.
var server_tick_est: int = 0
var input_tick: int = 0
var predicted_pos := Vector2.ZERO
var aim: float = 0.0
var pending: Array[InputFrame] = []
# Authoritative mirror of the local player.
var my_hp: int = SimConfig.PLAYER_MAX_HP
var my_alive: bool = true
var my_escape: float = 0.0
var my_escaping: bool = false
## Arrival protection: invulnerable and unable to shoot.
var my_spawn_grace: bool = false
## Seconds left before the HUD's return-to-hub button becomes available. The
## server enforces the same lockout; this only drives the button's look.
var my_respawn_wait: float = 0.0
## Set by the HUD button, not by a key. Death is deliberately exited through a
## deliberate click rather than whatever the player happened to be holding.
var request_respawn: bool = false
## Set by the in-game menu's "return to hub" -- synthesises a held escape
## button, so the menu route runs the identical server-side channel as the key.
## Cleared when the channel resolves or the player cancels.
var request_escape: bool = false
## Who is online and where, for the hub's player list. Server-pushed.
var roster: Array[Dictionary] = []
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
## Where this world's dungeon portal is. Per-map now, so it has to be told.
var portal_pos := Vector2.ZERO
## Backstop for input-numbering drift: if the server stops acknowledging new
## inputs, our tick numbering has fallen outside its acceptance window and no
## amount of waiting fixes it. Counted in snapshots, not ticks.
var _last_acked: int = -1
var _ack_stall: int = 0
var snap_prev: Dictionary = {}
var snap_curr: Dictionary = {}
var _interp: float = 0.0
var _bot_tick: int = 0
var _dungeon_ticks: int = 0
func _ready() -> void:
world.authoritative = false
# Sample and send input BEFORE ServerRuntime ticks. On a listen server both
# live in one process, and default tree order put the server first -- so the
# input sampled on frame N was not consumed until the server's frame N+1,
# leaving the drawn ship a permanent one-tick (4px at 240 u/s) ahead of the
# authoritative one. Bullets, spawned at the authoritative position, visibly
# trailed the ship. Going first closes the gap to zero on a listen server
# and costs a remote client nothing.
process_physics_priority = -10
set_physics_process(true)
func _physics_process(delta: float) -> void:
server_tick_est += 1
input_tick += 1
var frame := _sample_input()
pending.append(frame)
# Only enough history to cover the worst reconciliation window.
while pending.size() > SimConfig.INPUT_MAX_AGE:
pending.pop_front()
if my_alive:
predicted_pos = Movement.step_player(predicted_pos, frame.move,
SimConfig.PLAYER_SPEED, world.map)
# Send the last few frames every tick. Inputs are unreliable-ordered, so the
# redundancy is what covers a dropped packet without a retransmit stall.
var redundant: Array[InputFrame] = []
var from := maxi(pending.size() - 3, 0)
for i in range(from, pending.size()):
redundant.append(pending[i])
Net.send_input(NetCodec.encode_inputs(redundant))
world.step()
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
_maybe_bot_leave()
## Bot harness: quit cleanly mid-run so the smoke test proves a polite
## disconnect is caught by the same channel a SIGKILL is. Deferred because
## Net.shutdown() frees this node.
func _maybe_bot_leave() -> void:
if GameOpts.bot_leave_after <= 0 or instance_kind != Protocol.InstanceKind.DUNGEON:
return
_dungeon_ticks += 1
if _dungeon_ticks == GameOpts.bot_leave_after:
GameLog.info("client", "BOT_GRACEFUL_LEAVE")
Net.shutdown.call_deferred()
# --- Input ------------------------------------------------------------------
func _sample_input() -> InputFrame:
if GameOpts.bot_client:
return _bot_input()
if not my_alive:
# Downed: no movement, no fire, and the interact key does nothing. The
# only way out is the HUD button, which sets request_respawn.
var dead_buttons := InputFrame.BTN_INTERACT if request_respawn else 0
return InputFrame.make(input_tick, Vector2.ZERO, aim, dead_buttons)
var move := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5
var to_mouse := mouse - predicted_pos
if to_mouse.length_squared() > 1.0:
aim = to_mouse.angle()
var buttons := 0
if Input.is_action_pressed("fire"):
buttons |= InputFrame.BTN_FIRE
if Input.is_action_pressed("emergency_escape") or request_escape:
buttons |= InputFrame.BTN_ESCAPE
if Input.is_action_pressed("interact"):
buttons |= InputFrame.BTN_INTERACT
return InputFrame.make(input_tick, move, aim, buttons)
## Scripted input so `tools/smoke.sh` can play the game with no display: orbit
## the arena, fire constantly, take the portal, then punch out with the escape.
func _bot_input() -> InputFrame:
_bot_tick += 1
var t := float(_bot_tick) * SimConfig.TICK_DELTA
var move := Vector2(cos(t * 0.9), sin(t * 1.3))
aim = t * 2.1
var buttons := InputFrame.BTN_FIRE
if not my_alive:
# Downed bots ask for the hub, same as a player would -- otherwise a bot
# that dies mid-run just lies there and the smoke test stalls.
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands.
move = (portal_pos - predicted_pos).normalized()
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
buttons |= InputFrame.BTN_ESCAPE
return InputFrame.make(input_tick, move, aim, buttons)
# --- Server messages --------------------------------------------------------
func on_welcome(peer_id: int) -> void:
my_peer = peer_id
set_physics_process(true)
GameLog.info("client", "welcome, peer id %d" % peer_id)
## Re-number our input stream relative to the server and drop the history that
## was numbered under the old scheme -- replaying it would apply inputs the
## server never accepted.
func _resync_input_tick(server_tick: int, why: String) -> void:
input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD
pending.clear()
_ack_stall = 0
_last_acked = -1
GameLog.warn("client", "input re-sync: %s" % why)
func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
# A chunk still in flight when we changed instances describes the wrong map.
if from_instance != instance_id or world.map == null:
return
NetCodec.decode_map_chunks_into(world.map, data)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
instance_id = id
instance_kind = kind as Protocol.InstanceKind
portal_pos = portal
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
# We are told how big the map is and nothing else. Every tile starts UNKNOWN
# and is filled in by streaming as the player moves, so the client never
# holds terrain it has not been near -- there is no seed here to regenerate
# from. Origin matches the server's centre_on_origin().
var blank := MapGrid.new(maxi(map_w, 1), maxi(map_h, 1), MapGrid.Kind.UNKNOWN)
blank.centre_on_origin()
world.set_map(blank)
world.pool.clear()
snap_prev = {}
snap_curr = {}
predicted_pos = spawn
pending.clear()
server_tick_est = server_tick
input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD
_last_acked = -1
_ack_stall = 0
my_alive = true
my_hp = SimConfig.PLAYER_MAX_HP
my_escape = 0.0
my_escaping = false
my_spawn_grace = false
# Arriving somewhere is the natural end of an escape request, however it
# was triggered -- otherwise the synthesised button would keep firing and
# bounce the player straight back out of the hub.
request_escape = false
request_respawn = false
my_respawn_wait = 0.0
cleared_countdown = Protocol.COUNTDOWN_NONE
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit()
hud_dirty.emit()
func on_snapshot(data: PackedByteArray) -> void:
var snap := NetCodec.decode_snapshot(data)
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
return # stale or duplicate; unreliable channel, newest wins
cleared_countdown = int(snap["cleared_countdown"])
snap_prev = snap_curr
snap_curr = snap
_interp = 0.0
var tick := int(snap["tick"])
if server_tick_est < tick or server_tick_est > tick + 12:
server_tick_est = tick
# Keep the client roughly one buffer ahead of the server so inputs arrive
# just before they are needed rather than late. The band is deliberately
# narrower than the server's acceptance window (SimConfig.INPUT_MAX_LEAD),
# so we always correct before the server starts dropping anything.
var lead := input_tick - tick
if lead < SimConfig.INPUT_LEAD_MIN or lead > SimConfig.INPUT_LEAD_MAX:
_resync_input_tick(tick, "lead %d out of band" % lead)
for rec: Dictionary in snap["players"]:
if int(rec["peer"]) == my_peer:
_reconcile(rec)
break
## Rewind to the server's position, replay every input it has not seen yet, and
## land where the client should actually be right now.
func _reconcile(rec: Dictionary) -> void:
my_hp = int(rec["hp"])
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
my_escape = float(rec["escape"])
my_respawn_wait = float(rec["respawn_wait"])
if my_alive:
request_respawn = false
hud_dirty.emit()
var acked := int(rec["last_input_tick"])
# If the server is not consuming anything we send, our numbering is outside
# its window; lead alone cannot detect that, because a wrong lead looks
# perfectly normal from here. This is what makes the failure self-healing.
if acked == _last_acked:
_ack_stall += 1
if _ack_stall >= SimConfig.INPUT_ACK_STALL_LIMIT:
_resync_input_tick(int(snap_curr["tick"]),
"server stopped acknowledging input")
return
else:
_last_acked = acked
_ack_stall = 0
while not pending.is_empty() and pending[0].tick <= acked:
pending.pop_front()
var p: Vector2 = rec["pos"]
if my_alive:
for f in pending:
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, world.map)
var error := predicted_pos.distance_to(p)
if error > 24.0:
predicted_pos = p # real divergence: take the server's word
elif error > 0.5:
predicted_pos = predicted_pos.lerp(p, 0.3) # smooth out jitter
func on_events(data: PackedByteArray) -> void:
var packet := NetCodec.decode_events(data)
var catchup := clampi(server_tick_est - int(packet["tick"]), 0, 30)
for ev: Dictionary in packet["events"]:
match int(ev["t"]):
SimEvent.Type.BULLET_SPAWN:
var slot := world.pool.spawn(ev["pos"], ev["vel"], ev["r"], ev["life"], 0,
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
if slot >= 0 and catchup > 0:
world.pool.advance_slot(slot, catchup)
SimEvent.Type.BULLET_DESPAWN:
world.apply_event(ev)
SimEvent.Type.PLAYER_HIT:
if int(ev["peer"]) == my_peer:
my_hp = int(ev["hp"])
local_hit.emit(int(ev["dmg"]))
hud_dirty.emit()
SimEvent.Type.PLAYER_DIED:
if int(ev["peer"]) == my_peer:
my_alive = false
hud_dirty.emit()
SimEvent.Type.PLAYER_RESPAWNED:
if int(ev["peer"]) == my_peer:
my_alive = true
predicted_pos = ev["pos"]
pending.clear()
hud_dirty.emit()
_:
pass
# --- View queries -----------------------------------------------------------
## Remote players, interpolated between the last two snapshots. The local player
## is excluded -- the view draws [member predicted_pos] for that one.
func remote_players() -> Array[Dictionary]:
return _interpolated("players", "peer", [my_peer])
func enemies() -> Array[Dictionary]:
return _interpolated("enemies", "id", [])
func boss_state() -> Dictionary:
if snap_curr.is_empty() or snap_curr.get("boss") == null:
return {}
return snap_curr["boss"]
func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]:
var out: Array[Dictionary] = []
if snap_curr.is_empty():
return out
var prev_by_id := {}
if not snap_prev.is_empty():
for r: Dictionary in snap_prev[list_key]:
prev_by_id[r[id_key]] = r
for r: Dictionary in snap_curr[list_key]:
if exclude.has(r[id_key]):
continue
var rec: Dictionary = r.duplicate()
var old: Variant = prev_by_id.get(r[id_key])
if old != null:
rec["pos"] = (old["pos"] as Vector2).lerp(r["pos"], _interp)
out.append(rec)
return out