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
+9
View File
@@ -110,6 +110,15 @@ ticks in milliseconds with no SceneTree.
- **`PLAYER_RADIUS` (hitbox) < `PLAYER_VISUAL_RADIUS` (sprite), and
`PLAYER_MUZZLE_OFFSET` derives from the visual one.** Prefer a visible
near-miss over an invisible hit; keep the muzzle clear of the sprite.
- **`INPUT_MAX_LEAD` must stay well above `INPUT_LEAD_MAX`.** The server's
input acceptance window has to be wider than the band in which the client
re-syncs its own numbering. Violate it and drifting clocks land in a silent
dead zone where the server rejects everything and the client never notices —
the ship and the authoritative position separate permanently. Pinned by
`tests/unit/test_input_lead.gd`.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
dense patterns safer than sparse ones.
- **A disconnect is not an exit.** Dropping in a dungeon keeps the player in the
world as `linkdead`, channelling out over the same second the escape costs.
Damage must never cancel the escape channel, or quitting beats the button.
+49
View File
@@ -71,6 +71,50 @@ slow relative to bullet speed, so this reads as latency rather than unfairness.
If it becomes a complaint, the fix is to lag-compensate **player bullets against
enemies only**, never enemy bullets against players.
## Input timing, and a bug worth remembering
The client numbers its input frames `INPUT_TARGET_LEAD` ticks ahead of the
server's tick; the server accepts anything within `INPUT_MAX_LEAD` and re-syncs
nothing itself. **These two numbers must be chosen together.**
They originally were not — the server dropped inputs past a lead of 12, while
the client only corrected itself past 16. A client whose lead drifted into 1316
therefore had *every* input silently rejected, while believing its own timing
was fine. The server, receiving nothing, coasted on the last input it had
(`held_input`) and then stopped; the client kept predicting forward. The ship
and the authoritative position separated permanently, the reconciler fought it
every snapshot, and the player got shoved around. It needed two independent
clocks to drift apart, so it appeared only after minutes of play — and never
recovered, because nothing in the loop could notice.
Two defences now:
1. `INPUT_MAX_LEAD` (40) is far wider than the client's correction band
(`INPUT_LEAD_MIN` 3 … `INPUT_LEAD_MAX` 20), so the client always re-syncs
long before the server starts refusing anything. `test_input_lead.gd`
asserts that ordering directly, so narrowing the window fails a test rather
than shipping.
2. An **ack-stall detector**: if `last_input_tick` does not advance across
`INPUT_ACK_STALL_LIMIT` snapshots, the client concludes its numbering is
outside the window and hard re-syncs. Lead alone cannot detect this, because
a wrong lead looks perfectly normal from the client's side. This is the
backstop that makes the whole class of failure self-healing regardless of
cause.
`tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap
returns to normal; it exits non-zero if it does not.
## No invulnerability frames
A hit grants no immunity — every bullet that touches you deals its damage. In a
bullet hell the wall *is* the threat, and i-frames invert that: you are punished
for the first bullet of a pattern and gifted the next thirty, which makes dense
patterns *safer* than sparse ones. Measured cost: a stationary player survives
~13.6s of the Warden's opening phase, ~17.5s while drifting aimlessly.
Arrival protection (`SPAWN_GRACE_TICKS`) is the sole exception, and it is a
transition, not a combat mechanic.
## Input validation
`SimWorld.queue_input()` is the single audit point. It drops:
@@ -81,6 +125,11 @@ enemies only**, never enemy bullets against players.
| Stale input | `f.tick < tick - INPUT_MAX_AGE` |
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side |
The respawn lockout is worth calling out: the HUD disables its button for the
same three seconds, but that is presentation. A client that ignores its own UI
and holds the bit down still waits, because the check lives in `SimWorld`.
Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
+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()
-1
View File
@@ -81,7 +81,6 @@ func test_bullets_killed_by_a_hit_are_removed_from_the_replica() -> void:
server.add_player(1, "tester")
# Just below the boss, inside the ring pattern rather than at its centre.
server.players[1].pos = Vector2(0.0, 20.0)
server.players[1].iframes = 0
server.spawn_boss(Content.warden())
var hits := 0
for _i in 900:
-3
View File
@@ -9,7 +9,6 @@ const PEER := 3
func before_each() -> void:
world = SimWorld.new(1)
world.add_player(PEER, "tester")
world.players[PEER].iframes = 0
func _hold_escape(ticks: int) -> void:
@@ -65,7 +64,6 @@ func test_dying_ends_the_channel() -> void:
_hold_escape(20)
var p: SimPlayer = world.players[PEER]
p.hp = 1
p.iframes = 0
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
@@ -91,7 +89,6 @@ func test_a_linkdead_player_is_still_killable_while_channelling() -> void:
var p: SimPlayer = world.players[PEER]
p.linkdead = true
p.hp = 1
p.iframes = 0
world.step()
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
+70
View File
@@ -0,0 +1,70 @@
extends GutTest
## The client numbers its input frames a fixed "lead" ahead of the server's
## tick, and the server accepts a window around its own tick. These two numbers
## have to be chosen together.
##
## They were not, and the result was the worst kind of bug: the server silently
## dropped every input from a client whose lead had drifted into the gap between
## "server stops accepting" and "client notices", the client never corrected
## because by its own reckoning nothing was wrong, and the server coasted on a
## stale input forever. It needed two independent clocks to drift apart, so it
## only appeared after a few minutes -- and then never went away.
var world: SimWorld
const PEER := 5
func before_each() -> void:
world = SimWorld.new(1)
world.add_player(PEER, "tester")
world.tick = 1000
func _queue_at_lead(lead: int) -> void:
var frames: Array[InputFrame] = [
InputFrame.make(world.tick + lead, Vector2.RIGHT, 0.0, 0)]
world.queue_input(PEER, frames)
## The invariant that was violated. The client corrects itself at
## INPUT_LEAD_MAX; the server must keep accepting well past that, or there is a
## band where the server refuses input the client still thinks is fine.
func test_the_server_accepts_every_lead_the_client_tolerates() -> void:
assert_gt(SimConfig.INPUT_MAX_LEAD, SimConfig.INPUT_LEAD_MAX,
"the server's acceptance window must be wider than the client's " +
"correction band, or drift lands in a silent dead zone")
func test_inputs_across_the_whole_client_band_are_accepted() -> void:
for lead in range(SimConfig.INPUT_LEAD_MIN, SimConfig.INPUT_LEAD_MAX + 1):
world.players[PEER].input_queue.clear()
world.players[PEER].last_input_tick = 0
_queue_at_lead(lead)
assert_eq(world.players[PEER].input_queue.size(), 1,
"lead %d is inside the band the client will not correct, so the " % lead +
"server has to accept it")
func test_absurd_lead_is_still_rejected() -> void:
_queue_at_lead(SimConfig.INPUT_MAX_LEAD + 1)
assert_eq(world.players[PEER].input_queue.size(), 0,
"the window is wider, not gone -- a client claiming a far-future tick " +
"still buys nothing")
## What the drift actually looked like from the server's side: nothing arrives,
## so it coasts on the last input it saw. That is correct behaviour for a brief
## hiccup and catastrophic as a permanent state, which is why the client now
## has a stall detector rather than relying on its lead estimate alone.
func test_a_starved_server_coasts_then_stops() -> void:
var p: SimPlayer = world.players[PEER]
_queue_at_lead(SimConfig.INPUT_TARGET_LEAD)
world.step()
var moved_once: Vector2 = p.pos
assert_ne(moved_once, Vector2.ZERO, "setup: the input should have moved us")
for _i in SimConfig.INPUT_MAX_AGE + 5:
world.step()
var coasted: Vector2 = p.pos
for _i in 60:
world.step()
assert_eq(p.pos, coasted, "coasting has to end, or a silent client drifts forever")
+1
View File
@@ -0,0 +1 @@
uid://xemxybh1gvvo
+43 -8
View File
@@ -91,7 +91,6 @@ func test_a_starved_player_eventually_stops_moving() -> void:
func test_enemy_bullets_damage_the_player_and_are_consumed() -> void:
var p: SimPlayer = world.players[PEER]
p.pos = Vector2.ZERO
p.iframes = 0
world.pool.spawn(Vector2(-1.0, 0.0), Vector2.ZERO, 6.0, 60, 25,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
@@ -99,22 +98,37 @@ func test_enemy_bullets_damage_the_player_and_are_consumed() -> void:
assert_eq(world.pool.live_count, 0, "a bullet that hits must be consumed")
func test_invulnerability_frames_stop_a_second_hit() -> void:
## The inverse of what this asserted before. There are no i-frames: in a bullet
## hell the wall is the threat, and letting one hit grant immunity to the rest
## of the wall inverts the whole risk model.
func test_every_bullet_in_a_wall_lands() -> void:
var p: SimPlayer = world.players[PEER]
p.pos = Vector2.ZERO
p.iframes = 0
for _i in 2:
for _i in 3:
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 25, "two bullets in one tick is still one hit")
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 75,
"three bullets is three hits, not one")
func test_consecutive_ticks_both_deal_damage() -> void:
var p: SimPlayer = world.players[PEER]
p.pos = Vector2.ZERO
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 20,
"there is no window of immunity after a hit")
func test_a_replica_world_never_resolves_a_hit() -> void:
world.authoritative = false
var p: SimPlayer = world.players[PEER]
p.pos = Vector2.ZERO
p.iframes = 0
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
@@ -126,7 +140,6 @@ func _kill_player() -> SimPlayer:
var p: SimPlayer = world.players[PEER]
p.pos = Vector2.ZERO
p.hp = 5
p.iframes = 0
p.spawn_grace = 0
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
@@ -150,6 +163,7 @@ func test_a_downed_player_stays_down_without_input() -> void:
func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void:
_kill_player()
_drive(SimConfig.RESPAWN_LOCKOUT_TICKS)
world.drain_events()
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 1,
@@ -159,6 +173,28 @@ func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void:
assert_false(world.players[PEER].alive)
## The HUD disables its button for the same duration, but that is presentation.
## A client that ignores its own UI and spams the bit still has to wait.
func test_the_respawn_lockout_is_enforced_by_the_server() -> void:
_kill_player()
world.drain_events()
_drive(SimConfig.RESPAWN_LOCKOUT_TICKS - 2, Vector2.ZERO, InputFrame.BTN_INTERACT)
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0,
"holding the request down must not shorten the lockout")
_drive(4, Vector2.ZERO, InputFrame.BTN_INTERACT)
assert_gt(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0,
"and it must actually open once the lockout expires")
func test_the_lockout_is_reported_to_the_client() -> void:
_kill_player()
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
var wait: float = snap["players"][0]["respawn_wait"]
assert_almost_eq(wait,
float(SimConfig.RESPAWN_LOCKOUT_TICKS) / float(SimConfig.TICK_RATE), 0.3,
"the HUD needs the countdown to render its disabled button")
func test_respawn_request_never_reaches_the_client() -> void:
_kill_player()
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
@@ -171,7 +207,6 @@ func test_respawn_request_never_reaches_the_client() -> void:
func test_spawn_grace_blocks_damage_and_firing() -> void:
var p: SimPlayer = world.players[PEER]
p.pos = Vector2.ZERO
p.iframes = 0
p.spawn_grace = 60
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
+28 -1
View File
@@ -8,10 +8,19 @@ extends Node
## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual
## Net loopback), not a mock, so node process order is exactly production's.
## Tick at which to shove the client's input numbering out of sync, simulating
## the clock drift between two independent processes that produced the original
## "ship and bullets separate after a few minutes, permanently" bug. 0 disables.
const DRIFT_AT := 120
const DRIFT_BY := 14
var _ticks: int = 0
var _worst: float = 0.0
var _sum: float = 0.0
var _samples: int = 0
## Measured only after the injected drift, which is the interesting window.
var _worst_after_drift: float = 0.0
var _drifted: bool = false
func _ready() -> void:
@@ -42,6 +51,17 @@ func _physics_process(_delta: float) -> void:
_sum += delta_px
_samples += 1
if DRIFT_AT > 0 and _ticks == DRIFT_AT:
# Exactly what two drifting clocks do, only instantly. With the old
# constants this landed in the gap between "server stops accepting" and
# "client notices", and the two never re-converged.
cli.input_tick += DRIFT_BY
_drifted = true
print("--- injected +%d tick input drift ---" % DRIFT_BY)
# Give the client a moment to notice before judging it.
if _drifted and _ticks > DRIFT_AT + 90:
_worst_after_drift = maxf(_worst_after_drift, delta_px)
if _ticks % 20 == 0:
print("tick=%4d srv_tick=%4d srv_pos=%-22s pred=%-22s gap=%6.2fpx acked=%4d cli_tick=%4d queue=%d" % [
_ticks, inst.world.tick, str(sp.pos.round()), str(cli.predicted_pos.round()),
@@ -55,5 +75,12 @@ func _physics_process(_delta: float) -> void:
print("muzzle offset = %.1fpx, drawn ship radius = %.1fpx, hitbox = %.1fpx" % [
SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS,
SimConfig.PLAYER_RADIUS])
var ok := true
if _drifted:
print("RECOVERED gap after drift: worst=%.2fpx" % _worst_after_drift)
# One tick of movement is 4px; anything near that means the client
# re-synced. Tens of px means it is stuck, which is the bug.
ok = _worst_after_drift < 12.0
print("DRIFT_RECOVERY_" + ("PASS" if ok else "FAIL"))
Net.shutdown()
get_tree().quit(0)
get_tree().quit(0 if ok else 1)