Fix bullet/ship desync; rework death, escape, arrival and hub awareness

(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
  - ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
    not consumed until frame N+1, leaving the drawn ship a constant one tick
    (4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
    ClientRuntime now sets process_physics_priority = -10. Gap on a listen
    server: 4.00px -> 0.10px mean, 0.30px worst.
  - PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
    ship, so bullets were born inside the sprite. Regression from the previous
    commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.

(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.

(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.

(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.

(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.

(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.

(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.

Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
This commit is contained in:
2026-09-03 18:43:19 +02:00
parent d9a59fff03
commit 005679f1b5
29 changed files with 903 additions and 79 deletions
+3
View File
@@ -11,6 +11,9 @@ enum Type {
PLAYER_HIT, ## peer, damage, hp
PLAYER_DIED, ## peer
PLAYER_RESPAWNED, ## peer, pos
## A downed player asked to be returned to the hub. Server-only: the
## instance layer performs the transfer, the client just sees it arrive.
RESPAWN_REQUESTED, ## peer
ENEMY_HIT, ## id, damage, hp
ENEMY_DIED, ## id
BOSS_PHASE, ## phase index
+24 -4
View File
@@ -11,7 +11,14 @@ var hp: int = SimConfig.PLAYER_MAX_HP
var alive: bool = true
var iframes: int = 0
var fire_cooldown: int = 0
var respawn_timer: int = 0
## Ticks of arrival protection left: invulnerable, and unable to shoot.
var spawn_grace: 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
## a dangerous moment: a linkdead player keeps channelling the escape (and stays
## killable) for the same second everyone else would. See ServerRuntime.
var linkdead: bool = false
## Ticks the emergency escape has been held. 0 means not channelling.
var escape_ticks: int = 0
@@ -30,12 +37,25 @@ func escape_progress() -> float:
return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0)
func reset_for_instance(spawn: Vector2) -> void:
## Invulnerable while arriving, or in the usual post-hit window.
func invulnerable() -> bool:
return spawn_grace > 0 or iframes > 0
## Arrival protection also locks the gun, so the spawn point is not a free
## firing position.
func can_fire() -> bool:
return alive and spawn_grace <= 0 and fire_cooldown == 0
## [param grace] is 0 for the hub (nothing there can shoot) and
## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
pos = spawn
hp = SimConfig.PLAYER_MAX_HP
alive = true
iframes = SimConfig.PLAYER_IFRAMES
iframes = 0
spawn_grace = grace
fire_cooldown = 0
respawn_timer = 0
escape_ticks = 0
input_queue.clear()
+27 -18
View File
@@ -28,6 +28,9 @@ var events: Array[Dictionary] = []
## Set on a lobby world so the interact button can open a dungeon.
var portal_enabled: bool = false
var spawn_point := Vector2(0.0, 240.0)
## Arrival protection granted to players entering this world. 0 in the hub,
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
var spawn_grace_ticks: int = 0
var _next_actor_id: int = 1
var _ctx := EmitContext.new()
@@ -50,7 +53,7 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer:
var p := SimPlayer.new()
p.peer_id = peer_id
p.display_name = display_name
p.reset_for_instance(spawn_point)
p.reset_for_instance(spawn_point, spawn_grace_ticks)
players[peer_id] = p
return p
@@ -148,24 +151,25 @@ 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:
p.fire_cooldown -= 1
var frame := _take_input(p)
if not p.alive:
p.respawn_timer -= 1
if p.respawn_timer <= 0:
p.alive = true
p.hp = SimConfig.PLAYER_MAX_HP
p.pos = spawn_point
p.iframes = SimConfig.PLAYER_IFRAMES
events.append({"t": SimEvent.Type.PLAYER_RESPAWNED, "peer": p.peer_id, "pos": p.pos})
# 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:
events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
continue
var frame := _take_input(p)
p.aim = frame.aim
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
if frame.pressed(InputFrame.BTN_FIRE) and p.fire_cooldown == 0:
if frame.pressed(InputFrame.BTN_FIRE) and p.can_fire():
_fire_player_shot(p)
_step_escape(p, frame)
@@ -196,7 +200,7 @@ func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
var dir := Vector2.RIGHT.rotated(p.aim)
pool.spawn(
p.pos + dir * (SimConfig.PLAYER_RADIUS + 6.0),
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
dir * SimConfig.PLAYER_BULLET_SPEED,
SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME,
@@ -206,7 +210,10 @@ func _fire_player_shot(p: SimPlayer) -> void:
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
if frame.pressed(InputFrame.BTN_ESCAPE):
# A dropped connection is treated as holding the button down. Pulling the
# plug then costs exactly what pressing escape costs -- one second of
# standing there, still killable -- instead of an instant, safe exit.
if p.linkdead or frame.pressed(InputFrame.BTN_ESCAPE):
if p.escape_ticks == 0:
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
p.escape_ticks += 1
@@ -317,7 +324,10 @@ func _resolve_bullet_hits() -> void:
var br: float = pool.radius[i]
if pool.team[i] == SimConfig.TEAM_ENEMY:
for p in players.values():
if not p.alive or p.iframes > 0:
# invulnerable() covers arrival grace as well as post-hit
# i-frames -- a player who just walked into a live bullet field
# must not be hit by what was already in the air.
if not p.alive or p.invulnerable():
continue
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, pool.damage[i])
@@ -346,7 +356,7 @@ func _resolve_contact_damage() -> void:
if not e.alive or e.def.contact_damage <= 0:
continue
for p in players.values():
if not p.alive or p.iframes > 0:
if not p.alive or p.invulnerable():
continue
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, e.def.contact_damage)
@@ -358,16 +368,15 @@ func _kill_bullet(slot: int) -> void:
pool.despawn(slot)
## Damage deliberately does NOT interrupt an escape channel -- see the note on
## 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
if SimConfig.ESCAPE_BREAK_ON_DAMAGE and p.escape_ticks > 0:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
if p.hp <= 0:
p.alive = false
p.respawn_timer = SimConfig.PLAYER_RESPAWN_DELAY
p.escape_ticks = 0
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})