Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s

The real cause of the ship/bullet separation, which the previous commit only
half-addressed. The server dropped inputs past a lead of 12 while the client
only re-synced past 16, so a client whose lead drifted into 13-16 had every
input silently rejected while believing its timing was fine. The server coasted
on held_input and then stopped; the client kept predicting. The two separated
permanently and the reconciler fought it every snapshot -- "shoved around".
It needed two independent clocks to drift, hence "only after some time", and
nothing in the loop could notice, hence "then persists". The listen-server
diagnostic could never reproduce it: one process, one physics tick, lead
constant by construction.

Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction
band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a
test; and an ack-stall detector re-syncs when last_input_tick stops advancing,
which catches the whole class regardless of cause -- lead alone cannot, because
a wrong lead looks normal from the client. diag_prediction.gd now injects a +14
tick drift and exits non-zero unless the gap recovers.

Also:
- No invulnerability frames. Every bullet that touches a player lands; i-frames
  made dense patterns safer than sparse ones, which inverts the genre. Measured:
  a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s
  drifting. spawn_grace remains the only invulnerable state.
- Death is exited with a HUD button, disabled for the first 3s. The lockout is
  enforced in SimWorld, not just by graying the button -- a client that ignores
  its own UI still waits. The interact key no longer respawns.
- Joining a server that is not there no longer drops the player into an empty
  lobby they cannot act in. Net.join() only creates an ENet object; the game
  scene now waits for the server to actually place us in an instance, with an
  8s timeout, and headless runs exit non-zero instead of idling.

Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass.
This commit is contained in:
2026-09-03 19:19:33 +02:00
parent 005679f1b5
commit f70de1b825
18 changed files with 423 additions and 51 deletions
+33 -6
View File
@@ -42,19 +42,46 @@ const PLAYER_BULLET_SPEED := 620.0
const PLAYER_BULLET_RADIUS := 4.0
const PLAYER_BULLET_LIFETIME := 90 # ticks
const PLAYER_BULLET_DAMAGE := 6
const PLAYER_IFRAMES := 36 # ticks of invulnerability after a hit
## There are deliberately NO invulnerability frames after a hit. In a bullet
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
## into a single cheap hit -- you get punished for the first bullet and gifted
## the next thirty. Every bullet that touches you lands. Arrival protection
## below is the one exception, and it is a transition, not a combat mechanic.
## Invulnerable *and* unable to shoot on entering a dungeon, so arriving into a
## live bullet field is survivable. Both halves matter: invulnerability alone
## would make the spawn point a free firing position.
const SPAWN_GRACE_TICKS := 120 # 2 seconds
## A downed player cannot leave for the hub until this has elapsed. Enforced
## here rather than only by grey-ing out the button, because a disabled button
## is a suggestion -- the server is the only thing a modified client cannot
## argue with.
const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds
# --- Anti-cheat guards ------------------------------------------------------
# --- Input timing -----------------------------------------------------------
# The client numbers its input frames INPUT_TARGET_LEAD ahead of the server's
# tick and re-syncs whenever its lead leaves [INPUT_LEAD_MIN, INPUT_LEAD_MAX].
# The server accepts anything within INPUT_MAX_LEAD.
#
# INPUT_MAX_LEAD *must* stay comfortably above INPUT_LEAD_MAX. When it did not
# (12 vs 16), a client whose lead drifted into the gap had every input silently
# rejected while believing its timing was fine -- so the server coasted on a
# stale input and the player's ship and the authoritative position separated
# permanently. Two independent clocks have to drift for it to happen, which is
# why it only showed up minutes in, and never recovered.
## Inputs older than this (relative to the newest accepted) are discarded.
const INPUT_MAX_AGE := 30
## Inputs claiming to be further ahead than this of the server tick are clamped.
const INPUT_MAX_LEAD := 12
## Hard ceiling on inputs consumed from one peer in a single tick.
const INPUT_MAX_PER_TICK := 4
## Where the client aims to sit.
const INPUT_TARGET_LEAD := 8
## The client re-syncs its numbering outside this band.
const INPUT_LEAD_MIN := 3
const INPUT_LEAD_MAX := 20
## Inputs claiming to be further ahead than this of the server tick are dropped.
const INPUT_MAX_LEAD := 40
## Snapshots with no forward progress on last_input_tick before the client
## assumes its numbering is out of the server's window and hard re-syncs. The
## backstop that makes the failure above self-healing whatever its cause.
const INPUT_ACK_STALL_LIMIT := 8
# --- Emergency escape -------------------------------------------------------
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
+50
View File
@@ -5,9 +5,15 @@ extends Node
const GAME_SCENE := preload("res://src/game.tscn")
## How long to wait for a server to answer before giving up. ENet's own
## connection_failed can take considerably longer than a player's patience.
const CONNECT_TIMEOUT_SEC := 8.0
var _menu: Control = null
var _game: Node = null
var _ticks: int = 0
var _connecting: bool = false
var _connect_deadline: float = 0.0
func _ready() -> void:
@@ -30,6 +36,8 @@ func _ready() -> void:
func _physics_process(_delta: float) -> void:
if _connecting and _now() > _connect_deadline:
_abort_connect("no answer from %s:%d" % [GameOpts.host_address, GameOpts.port])
_ticks += 1
if GameOpts.autoquit_ticks > 0 and _ticks >= GameOpts.autoquit_ticks:
GameLog.info("main", "autoquit after %d ticks" % _ticks)
@@ -59,15 +67,49 @@ func _show_menu() -> void:
add_child(_menu)
## Note what this deliberately does NOT do: enter the game. Net.join() only
## means "an ENet client object was created" -- it says nothing about anything
## being at the other end. Showing the world at that point dropped the player
## into an empty lobby they could not act in, because no server had ever
## acknowledged them. The game scene now waits for the server to actually place
## us in an instance.
func _join(address: String, port: int) -> void:
GameOpts.host_address = address
GameOpts.port = port
if Net.join(address, port) != OK:
_menu_status(Net.last_error)
return
_connecting = true
_connect_deadline = _now() + CONNECT_TIMEOUT_SEC
_menu_busy(true)
_menu_status("connecting to %s:%d ..." % [address, port])
Net.client.instance_changed.connect(_on_session_ready, CONNECT_ONE_SHOT)
func _now() -> float:
return float(Time.get_ticks_msec()) / 1000.0
## The server has accepted the handshake and placed us somewhere. Only now is
## there a world worth drawing.
func _on_session_ready() -> void:
_connecting = false
_enter_game()
func _abort_connect(reason: String) -> void:
_connecting = false
GameLog.error("main", "connection failed: %s" % reason)
if GameOpts.autojoin:
# Headless/scripted run: there is no menu to fall back to, and silently
# idling would make a broken smoke test look like a passing one.
get_tree().quit(1)
return
Net.shutdown()
_show_menu()
_menu_status(reason)
## Listen server: one process runs the authoritative world and a local client
## connected over loopback. The client takes no shortcuts because of it -- it
## still only sends input and still only learns outcomes from snapshots.
@@ -100,9 +142,17 @@ func _menu_status(text: String) -> void:
_menu.set_status(text)
func _menu_busy(busy: bool) -> void:
if _menu != null:
_menu.set_busy(busy)
func _on_net_state(state: Net.State) -> void:
match state:
Net.State.FAILED:
if _connecting:
_abort_connect(Net.last_error)
return
_show_menu()
_menu_status(Net.last_error)
Net.State.OFFLINE:
+56 -4
View File
@@ -35,6 +35,13 @@ 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.
@@ -48,6 +55,12 @@ var roster: Array[Dictionary] = []
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
## 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
@@ -98,6 +111,11 @@ func _physics_process(delta: float) -> void:
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
@@ -142,6 +160,17 @@ func on_welcome(peer_id: int) -> void:
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_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
@@ -158,7 +187,9 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
predicted_pos = spawn
pending.clear()
server_tick_est = server_tick
input_tick = server_tick + 8
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
@@ -168,6 +199,8 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
# 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()
@@ -187,10 +220,12 @@ func on_snapshot(data: PackedByteArray) -> void:
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.
# 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 < 2 or lead > 16:
input_tick = tick + 8
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:
@@ -206,9 +241,26 @@ func _reconcile(rec: Dictionary) -> void:
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()
+4 -2
View File
@@ -27,8 +27,6 @@ static func encode_snapshot(world: SimWorld,
var flags := 0
if p.alive:
flags |= Protocol.F_ALIVE
if p.iframes > 0:
flags |= Protocol.F_INVULN
if p.escape_ticks > 0:
flags |= Protocol.F_ESCAPING
if p.spawn_grace > 0:
@@ -37,6 +35,9 @@ static func encode_snapshot(world: SimWorld,
flags |= Protocol.F_LINKDEAD
b.put_u8(flags)
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
# Whole ticks are more than the HUD needs; a byte of tenths of a second
# covers the lockout with room to spare.
b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255))
# Echoed so the owning client knows how far to rewind when reconciling.
b.put_u32(p.last_input_tick)
@@ -85,6 +86,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"hp": b.get_u16(),
"flags": b.get_u8(),
"escape": float(b.get_u8()) / 255.0,
"respawn_wait": float(b.get_u8()) / 10.0,
"last_input_tick": b.get_u32(),
})
+6 -5
View File
@@ -4,8 +4,10 @@ extends RefCounted
## server refuses mismatched clients at handshake rather than desyncing later.
## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
## roster message.
const VERSION := 2
## roster message.
## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte
## to each player record.
const VERSION := 3
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
@@ -22,10 +24,9 @@ enum InstanceKind { LOBBY, DUNGEON }
## Player flags packed into the snapshot's per-player byte.
const F_ALIVE := 1
const F_INVULN := 2
const F_ESCAPING := 4
## Arrival protection: invulnerable and unable to shoot. Distinct from F_INVULN
## so the client can label the status rather than just tint the ship.
## Arrival protection: invulnerable and unable to shoot. The only invulnerable
## state there is -- see the i-frames note in SimConfig.
const F_SPAWN_GRACE := 8
## The peer behind this player has dropped and is being channelled out.
const F_LINKDEAD := 16
+11 -4
View File
@@ -9,10 +9,11 @@ var pos := Vector2.ZERO
var aim: float = 0.0
var hp: int = SimConfig.PLAYER_MAX_HP
var alive: bool = true
var iframes: int = 0
var fire_cooldown: int = 0
## Ticks of arrival protection left: invulnerable, and unable to shoot.
var spawn_grace: int = 0
## Ticks before a downed player may ask to return to the hub.
var respawn_lockout: int = 0
## The peer's connection dropped, but the player is deliberately still in the
## world. Held here rather than deleted so a disconnect cannot be used to dodge
@@ -37,9 +38,10 @@ func escape_progress() -> float:
return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0)
## Invulnerable while arriving, or in the usual post-hit window.
## Arrival protection only -- there is no post-hit invulnerability. See the
## note on SimConfig.SPAWN_GRACE_TICKS.
func invulnerable() -> bool:
return spawn_grace > 0 or iframes > 0
return spawn_grace > 0
## Arrival protection also locks the gun, so the spawn point is not a free
@@ -54,8 +56,13 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
pos = spawn
hp = SimConfig.PLAYER_MAX_HP
alive = true
iframes = 0
spawn_grace = grace
respawn_lockout = 0
fire_cooldown = 0
escape_ticks = 0
input_queue.clear()
## True once the lockout has run out and the hub is available again.
func can_request_respawn() -> bool:
return not alive and respawn_lockout <= 0
+6 -5
View File
@@ -149,8 +149,6 @@ func step() -> void:
func _step_players() -> void:
for p in players.values():
if p.iframes > 0:
p.iframes -= 1
if p.spawn_grace > 0:
p.spawn_grace -= 1
if p.fire_cooldown > 0:
@@ -159,10 +157,13 @@ func _step_players() -> void:
var frame := _take_input(p)
if not p.alive:
if p.respawn_lockout > 0:
p.respawn_lockout -= 1
# No timed respawn: a downed player waits for the hub. Asking to go
# is an input like any other, so a dead client cannot be revived by
# anything except its own request reaching the server.
if frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead:
# anything except its own request reaching the server -- and not
# before the lockout expires, however early its UI lets it ask.
if p.can_request_respawn() and (frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead):
events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
continue
@@ -372,11 +373,11 @@ func _kill_bullet(slot: int) -> void:
## SimConfig.ESCAPE_CHANNEL_TICKS for why that would reward pulling the plug.
func _damage_player(p: SimPlayer, amount: int) -> void:
p.hp = maxi(p.hp - amount, 0)
p.iframes = SimConfig.PLAYER_IFRAMES
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
if p.hp <= 0:
p.alive = false
p.escape_ticks = 0
p.respawn_lockout = SimConfig.RESPAWN_LOCKOUT_TICKS
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
+33 -4
View File
@@ -6,9 +6,12 @@ const MARGIN := 24.0
const BAR_W := 260.0
const BAR_H := 16.0
signal respawn_pressed
var _canvas: Control
var _status: Label
var _hint: Label
var _respawn_button: Button
var _hit_flash: float = 0.0
var client: ClientRuntime = null
@@ -38,6 +41,20 @@ func _ready() -> void:
_hint.offset_left = MARGIN
_hint.offset_top = -56.0
# A real Button rather than something drawn in _draw_hud: this is the one
# HUD element the player has to actually click, and it starts disabled so
# death is not exited by whatever key happened to be under a finger.
_respawn_button = Button.new()
_respawn_button.custom_minimum_size = Vector2(240.0, 40.0)
_respawn_button.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
_respawn_button.offset_left = -120.0
_respawn_button.offset_right = 120.0
_respawn_button.offset_top = 40.0
_respawn_button.offset_bottom = 80.0
_respawn_button.pressed.connect(func() -> void: respawn_pressed.emit())
_respawn_button.visible = false
_canvas.add_child(_respawn_button)
func _make_label(pos: Vector2) -> Label:
var l := Label.new()
@@ -53,9 +70,23 @@ func _process(delta: float) -> void:
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
_status.text = _status_text()
_hint.text = _hint_text()
_update_respawn_button()
_canvas.queue_redraw()
func _update_respawn_button() -> void:
var downed := client != null and not client.my_alive
_respawn_button.visible = downed
if not downed:
return
# Mirrors SimConfig.RESPAWN_LOCKOUT_TICKS, which the server enforces
# independently -- this is presentation, not the rule.
var ready := client.my_respawn_wait <= 0.0
_respawn_button.disabled = not ready
_respawn_button.text = "Return to hub" if ready \
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
func _status_text() -> String:
if client == null:
return "connecting..."
@@ -69,7 +100,7 @@ func _hint_text() -> String:
if client == null:
return ""
if not client.my_alive:
return "DOWN -- press E to return to the hub"
return "DOWN"
if client.instance_kind == Protocol.InstanceKind.LOBBY:
return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu"
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu"
@@ -106,9 +137,7 @@ func _draw_hud() -> void:
var centre := _canvas.size * 0.5
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0),
"DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4))
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(118.0, -18.0),
"press E to return to the hub", HORIZONTAL_ALIGNMENT_LEFT, -1, 16,
Color(1.0, 0.75, 0.75))
if _hit_flash > 0.0:
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
+17 -8
View File
@@ -9,6 +9,8 @@ var _address: LineEdit
var _port: LineEdit
var _name: LineEdit
var _status: Label
var _join_button: Button
var _host_button: Button
func _ready() -> void:
@@ -43,15 +45,15 @@ func _ready() -> void:
_address = _field(panel, "server address", GameOpts.host_address)
_port = _field(panel, "port", str(GameOpts.port))
var join := Button.new()
join.text = "Join"
join.pressed.connect(_on_join)
panel.add_child(join)
_join_button = Button.new()
_join_button.text = "Join"
_join_button.pressed.connect(_on_join)
panel.add_child(_join_button)
var host := Button.new()
host.text = "Host and play (listen server)"
host.pressed.connect(_on_host)
panel.add_child(host)
_host_button = Button.new()
_host_button.text = "Host and play (listen server)"
_host_button.pressed.connect(_on_host)
panel.add_child(_host_button)
var quit := Button.new()
quit.text = "Quit"
@@ -78,6 +80,13 @@ func set_status(text: String) -> void:
_status.text = text
## Locks the connect buttons while an attempt is in flight, so a second click
## cannot start a competing connection over the top of the first.
func set_busy(busy: bool) -> void:
_join_button.disabled = busy
_host_button.disabled = busy
func _commit_name() -> void:
var clean := _name.text.strip_edges()
if not clean.is_empty():
+7
View File
@@ -15,6 +15,7 @@ func _ready() -> void:
get_viewport().size_changed.connect(_recentre)
menu.return_to_hub_requested.connect(_on_return_to_hub)
menu.disconnect_requested.connect(_on_disconnect)
hud.respawn_pressed.connect(_on_respawn_pressed)
func _recentre() -> void:
@@ -42,5 +43,11 @@ func _on_disconnect() -> void:
Net.shutdown()
## Sets the intent bit; the server decides whether the lockout has expired.
func _on_respawn_pressed() -> void:
if _bound != null:
_bound.request_respawn = true
func _on_local_hit(_damage: int) -> void:
hud.flash_hit()