ajhahn.de
← Theria commits

Commit

Theria

feat: add hero respawn and a death-screen countdown

ajhahnde · Jun 2026 · 2ded0d5a4787850a2c7463a02692252b07af5cda · parent: e36051a · view on GitHub →

modified CHANGELOG.md
@@ -68,6 +68,14 @@ protocol version.
### Added
- Heroes now respawn instead of dying for good. A slain hero is no longer erased — it falls,
goes inert (it cannot move, fight, cast, or be targeted), and a respawn clock counts it back,
returning it at full health at its spawn point a few seconds later; only creeps and structures
stay dead. While the player's own hero is down a death screen dims the match and shows the
respawn countdown, and any standing move order is dropped so the hero comes back idle at base
rather than walking off toward a pre-death click. The respawn timer rides the snapshot, so a
networked client raises its own death screen straight from the wire — the netcode protocol
version moves to **4** for the added field.
- Press **S** to stop the hero where it stands, clearing the current move or attack order (the
MOBA-standard hold-position): tap it to cancel a path, hold it to stay planted while a fresh
right-click is held. Client-side input only; the simulation and the netcode protocol are
added src/client/death_overlay.gd
@@ -0,0 +1,70 @@
class_name DeathOverlay
extends Control
## The full-screen death screen shown while the player's own hero is down, counting it
## back to respawn. Pure presentation: each tick the driver hands it the hero's remaining
## respawn ticks and it renders the dim and the countdown, or hides when the hero is alive.
## It owns no simulation and never decides when the hero is dead — `main.gd` reads that off
## the hero's `respawn_ticks` (sim-side in LOCAL/HOST, straight from the snapshot on a CLIENT)
## and drives this, exactly as the connect menu stays a pure entry point over the `_start_*`
## paths. A headless run never builds it (no display to draw to).
## A dim wash over the live world rather than an opaque cover: the match plays on behind the
## death screen — squadmates fight, the timer ticks — so the player watches it while waiting.
const DIM_COLOR := Color(0.0, 0.0, 0.0, 0.6)
const TITLE_TEXT := "YOU DIED"
const TITLE_COLOR := Color(0.85, 0.24, 0.22)
const TITLE_FONT_SIZE := 96
const TIMER_COLOR := Color(0.92, 0.93, 0.96)
const TIMER_FONT_SIZE := 52
var _timer_label: Label
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
# The hero is down but the world plays on under the dim — never eat a click, so panning or
# any other still-live input passes straight through to the game below.
mouse_filter = Control.MOUSE_FILTER_IGNORE
var dim := ColorRect.new()
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.color = DIM_COLOR
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 28)
center.add_child(box)
var title := Label.new()
title.text = TITLE_TEXT
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", TITLE_FONT_SIZE)
title.add_theme_color_override("font_color", TITLE_COLOR)
box.add_child(title)
_timer_label = Label.new()
_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timer_label.add_theme_font_size_override("font_size", TIMER_FONT_SIZE)
_timer_label.add_theme_color_override("font_color", TIMER_COLOR)
box.add_child(_timer_label)
hide()
## Shows the death screen with the respawn countdown, or hides it when the hero is alive
## (0 ticks). The remaining ticks are rounded up to whole seconds so the timer counts the way
## a player reads it — "3… 2… 1…" — and reaches 0 on the tick the hero actually respawns.
func set_respawn(remaining_ticks: int, tick_rate: int) -> void:
if remaining_ticks <= 0:
hide()
return
var seconds := ceili(float(remaining_ticks) / float(tick_rate))
_timer_label.text = "Respawning in %d" % seconds
show()
added src/client/death_overlay.gd.uid
@@ -0,0 +1 @@
uid://ctf5wavrb4kj
modified src/client/main.gd
@@ -195,6 +195,9 @@ var _cam_target: Vector2 = Vector2.ZERO
var _cam_ready: bool = false
var _ground: MeshInstance3D = null
var _views: Dictionary = {}
## The death screen, on its own screen-space layer over the 3D world. Built once, hidden, and
## shown by `_update_death_overlay` while the player's hero is down. Null on a headless run.
var _death_overlay: DeathOverlay = null
func _ready() -> void:
@@ -634,6 +637,13 @@ func _build_world() -> void:
_player_input = PlayerInput.new(_camera)
_move_marker = MoveMarker.new()
add_child(_move_marker)
# The death screen rides its own CanvasLayer so it draws in screen space over the zoomed
# game camera, exactly like the connect menu. A headless smoke has no display to raise it on.
if not _is_headless():
_death_overlay = DeathOverlay.new()
var death_layer := CanvasLayer.new()
death_layer.add_child(_death_overlay)
add_child(death_layer)
## Reconciles the view pool against the live state, then trails the camera. Called each
@@ -663,6 +673,7 @@ func _sync_world() -> void:
else:
_move_marker.clear()
_follow_camera(state)
_update_death_overlay(state)
## Trails the camera on the player's hero — a fixed height and pitch, eased toward it each
@@ -697,6 +708,18 @@ func _camera_focus(state: SimState) -> SimEntity:
return null
## Raises or hides the death screen for the player's own hero. While the hero is down its
## respawn timer drives the on-screen countdown; alive — or not yet spawned — the screen stays
## hidden. The hero is the camera's focus, sim-driven in LOCAL/HOST and read out of the snapshot
## on a CLIENT, so the countdown ticks down straight off the same entity the world draws.
func _update_death_overlay(state: SimState) -> void:
if _death_overlay == null:
return
var hero := _camera_focus(state)
var ticks := hero.respawn_ticks if hero != null else 0
_death_overlay.set_respawn(ticks, SimCore.TICK_RATE)
## Builds an entity's pooled view: a primitive body (capsule unit, box structure), a
## flat ground ring for heroes, and a billboarded overlay carrying the HP bar, the
## resource bar (heroes), and the status label (heroes). Returns the node refs the
@@ -768,6 +791,7 @@ func _update_view(view: Dictionary, entity: SimEntity) -> void:
var root := view["root"] as Node3D
var moved := _world(entity.position) - root.position
root.position = _world(entity.position)
root.visible = not entity.is_dead() # a downed hero's body vanishes behind the death screen
if view.has("yaw"):
HeroModelLibrary.drive_facing(view, view["body"], Vector2(moved.x, moved.z))
if view.has("ring"):
modified src/client/player_input.gd
@@ -42,6 +42,11 @@ func _init(camera: Camera3D) -> void:
## movement alone.
func sample(state: SimState, hero: SimEntity, team: int, cast_abilities: bool) -> InputCommand:
var command := InputCommand.new()
if hero != null and hero.is_dead():
# Down and behind the death screen: ignore input and drop any standing order, so the
# hero respawns idle at base rather than marching off toward a pre-death click.
_halt()
return command
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
_issue_order(state, team, _mouse_world_point())
if Input.is_physical_key_pressed(STOP_KEY):
modified src/net/net_protocol.gd
@@ -21,7 +21,7 @@ extends RefCounted
## connect and a mismatch is refused, so an old client cannot desync against a
## newer server. Bump it on any wire-shape change here.
const PROTOCOL_VERSION := 3
const PROTOCOL_VERSION := 4
## Bit positions for the packed entity-flags field (slot 11 of an entity row).
const _FLAG_STRUCTURE := 1
@@ -92,10 +92,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> SimState:
return state
## Fixed entity byte record, by field (little-endian, 35 bytes):
## Fixed entity byte record, by field (little-endian, 37 bytes):
## id u32 team u8 pos.x f32 pos.y f32 move_speed f32 hp i16 max_hp i16
## attack_damage i16 attack_range f32 attack_cooldown_ticks u16 cooldown u16
## flags u8 (structure|nexus|creep bitmask) lane u8 waypoint_index u16
## respawn_ticks u16 (0 for a living unit; a downed hero's countdown, so the client raises
## its death screen and ticks the timer straight off the snapshot)
## Floats are narrowed to 32 bits: positions are Vector2 (already 32-bit) so they round
## trip exactly, and the round-number tunings are exact in 32 bits too. The integer
## widths cover the v0.1 tuning with headroom (hp and damage sit well inside a signed
@@ -123,6 +125,7 @@ static func _encode_entity(buf: StreamPeerBuffer, entity: SimEntity) -> void:
buf.put_u8(flags)
buf.put_u8(entity.lane)
buf.put_u16(entity.waypoint_index)
buf.put_u16(entity.respawn_ticks)
static func _decode_entity(buf: StreamPeerBuffer) -> SimEntity:
@@ -143,4 +146,5 @@ static func _decode_entity(buf: StreamPeerBuffer) -> SimEntity:
entity.is_creep = (flags & _FLAG_CREEP) != 0
entity.lane = buf.get_u8()
entity.waypoint_index = buf.get_u16()
entity.respawn_ticks = buf.get_u16()
return entity
modified src/sim/ability_executor.gd
@@ -128,6 +128,7 @@ static func _targets(
if (
t != null
and t.max_hp > 0
and not t.is_dead()
and t.team != caster.team
and caster.position.distance_to(t.position) <= spec.range
):
@@ -162,7 +163,7 @@ static func pick_unit_target(state: SimState, caster_team: int, point: Vector2)
var best_dist := INF
for id in state.entities:
var e: SimEntity = state.entities[id]
if e.team == caster_team or e.max_hp <= 0:
if e.team == caster_team or e.max_hp <= 0 or e.is_dead():
continue
var d := point.distance_to(e.position)
if d < best_dist:
@@ -179,7 +180,7 @@ static func _enemies_in_area(
var hits: Array[SimEntity] = []
for id in state.entities:
var e: SimEntity = state.entities[id]
if e.team == caster.team or e.max_hp <= 0:
if e.team == caster.team or e.max_hp <= 0 or e.is_dead():
continue
if center.distance_to(e.position) <= radius:
hits.append(e)
modified src/sim/sim_core.gd
@@ -48,6 +48,12 @@ const HERO_DAMAGE := 60
const HERO_RANGE := 250.0
const HERO_COOLDOWN_TICKS := 36
## How long a slain hero stays down before respawning at its spawn point, full health. A flat
## timer for now (8 s at the tick rate) — short enough that a death is a setback, not a sit-out;
## scaling it with match time is a later tuning pass. A dead hero is kept in the world (not
## erased like a creep) so its id, and this countdown, persist for the client's death screen.
const HERO_RESPAWN_TICKS := 8 * TICK_RATE
var state: SimState = SimState.new()
## Whether `step` spawns creep waves on its own clock. On for live play and the
@@ -104,8 +110,10 @@ func spawn_structures() -> void:
## returns its id. `move_speed` is set by the driver; combat is fixed tuning.
func add_hero(team: int, position: Vector2, move_speed: float) -> int:
var entity := SimEntity.new(_next_id, team, position, move_speed)
entity.is_hero = true # a hero from birth, so death downs-and-respawns it even before a kit
entity.max_hp = HERO_HP
entity.hp = HERO_HP
entity.spawn_position = position # where it returns after a death
entity.attack_damage = HERO_DAMAGE
entity.attack_range = HERO_RANGE
entity.attack_cooldown_ticks = HERO_COOLDOWN_TICKS
@@ -170,6 +178,7 @@ func step(inputs: Dictionary) -> void:
if state.is_match_over():
return
_step_spawning()
_step_respawns()
_step_movement(inputs)
_step_creeps()
_step_statuses()
@@ -199,6 +208,8 @@ func _step_movement(inputs: Dictionary) -> void:
## server and a predicting client move a unit by byte-identical math, which is what
## lets client-side reconciliation land exactly on the authoritative position.
static func apply_movement(entity: SimEntity, command: InputCommand) -> void:
if entity.is_dead():
return # a downed hero holds where it fell — server and the client's prediction alike
var move_dir := Vector2.ZERO
if command != null:
move_dir = command.move_dir
@@ -303,8 +314,8 @@ func _step_statuses() -> void:
func _step_abilities(inputs: Dictionary) -> void:
for id in state.entities:
var hero: SimEntity = state.entities[id]
if not hero.is_hero:
continue
if not hero.is_hero or hero.is_dead():
continue # a dead hero neither regens nor decays cooldowns until it respawns
_regen_resource(hero)
_tick_cooldowns(hero)
for id in inputs:
@@ -312,7 +323,7 @@ func _step_abilities(inputs: Dictionary) -> void:
if command == null or command.ability_slot < 0:
continue
var hero: SimEntity = state.get_entity(id)
if hero != null and hero.is_hero:
if hero != null and hero.is_hero and not hero.is_dead():
_try_cast(hero, command)
@@ -358,6 +369,8 @@ func _step_combat() -> void:
var attacker: SimEntity = state.entities[id]
if attacker.attack_damage <= 0:
continue
if attacker.is_dead():
continue # a downed hero stops fighting until it respawns
if attacker.is_stunned():
continue # a locked unit neither strikes nor ticks its cooldown down
if attacker.cooldown > 0:
@@ -400,8 +413,8 @@ func _nearest_enemy_in_range(attacker: SimEntity) -> SimEntity:
var other: SimEntity = state.entities[id]
if other.team == attacker.team:
continue
if other.max_hp <= 0:
continue
if other.max_hp <= 0 or other.is_dead():
continue # non-combat entities and downed heroes are not valid targets
var dist := attacker.position.distance_to(other.position)
if dist <= attacker.attack_range and dist < nearest_dist:
nearest_dist = dist
@@ -409,14 +422,64 @@ func _nearest_enemy_in_range(attacker: SimEntity) -> SimEntity:
return nearest
## Reconciles every unit brought to 0 hp this tick. A creep or a structure is erased (a
## felled nexus first deciding the match); a hero is kept in the world but downed —
## marked dead and put on the respawn clock — so its id, position, and countdown persist
## for the client's death screen and `_step_respawns` can revive it in place. A hero
## already counting down is skipped, so it is downed once, not re-killed every tick.
func _resolve_deaths() -> void:
var dead: Array[int] = []
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.max_hp > 0 and entity.hp <= 0:
if entity.max_hp > 0 and entity.hp <= 0 and not entity.is_dead():
dead.append(id)
for id in dead:
var entity: SimEntity = state.entities[id]
if entity.is_hero:
_down_hero(entity)
continue
if entity.is_nexus and not state.is_match_over():
state.winner = 1 - entity.team
state.entities.erase(id)
## Puts a slain hero on the respawn clock instead of erasing it: hp pinned to 0, the
## respawn timer started, and any lingering statuses and auto-attack cooldown cleared so
## nothing carries over the death. `is_dead` now reads true, which makes every acting and
## targeting step skip it until `_step_respawns` revives it.
func _down_hero(hero: SimEntity) -> void:
hero.hp = 0
hero.respawn_ticks = HERO_RESPAWN_TICKS
hero.statuses.clear()
hero.cooldown = 0
## Counts every downed hero's respawn timer down by one tick and revives the hero the tick
## it elapses. Runs near the top of the step so a hero that comes back this tick is alive for
## the rest of it. Pure and insertion-ordered like every other step.
func _step_respawns() -> void:
for id in state.entities:
var hero: SimEntity = state.entities[id]
if not hero.is_dead():
continue
hero.respawn_ticks -= 1
if hero.respawn_ticks <= 0:
_respawn_hero(hero)
## Revives a hero at its spawn point with a full health bar, back in human form with a full
## resource pool and every cooldown cleared — a clean slate, as if freshly seated. `respawn_ticks`
## lands at 0, so `is_dead` reads false and the hero acts again from this tick. A hero with no kit
## (the bare walking skeleton) has empty resource tuning, so the pool simply stays 0.
func _respawn_hero(hero: SimEntity) -> void:
hero.respawn_ticks = 0
hero.position = hero.spawn_position
hero.hp = hero.max_hp
hero.cooldown = 0
hero.statuses.clear()
hero.ability_cooldowns.clear()
hero.form = AbilitySpec.FORM_HUMAN
hero.resource_max = hero.form_resource_max[AbilitySpec.FORM_HUMAN]
hero.resource_regen_ticks = hero.form_resource_regen[AbilitySpec.FORM_HUMAN]
hero.resource = hero.resource_max
hero.resource_regen_counter = 0
modified src/sim/sim_entity.gd
@@ -44,6 +44,17 @@ var waypoint_index: int = 0
## (see SimCore.equip_kit); a hero with no kit just auto-attacks like before.
var is_hero: bool = false
## Where this hero respawns after dying — its spawn point, set once at creation. Sim-only
## (never serialized): respawn is resolved server-side, so a client never needs it.
var spawn_position: Vector2 = Vector2.ZERO
## Ticks until this hero respawns, counted down each tick. 0 for a living unit; set to
## SimCore.HERO_RESPAWN_TICKS the tick the hero dies, the hero blinking back at full HP when it
## reaches 0. Only a hero ever carries it — creeps and structures are erased on death, a hero is
## kept and revived — so `is_dead` reads it as the dead/alive flag. Serialized so a client can
## raise its own death screen and tick down the respawn countdown from its hero's snapshot.
var respawn_ticks: int = 0
## The active shapeshifter form (AbilitySpec.FORM_HUMAN / FORM_ANIMAL). Only the
## abilities of the active form are castable; a TRANSFORM ability flips it.
var form: int = 0
@@ -123,6 +134,8 @@ func clone() -> SimEntity:
copy.lane = lane
copy.waypoint_index = waypoint_index
copy.is_hero = is_hero
copy.spawn_position = spawn_position
copy.respawn_ticks = respawn_ticks
copy.form = form
copy.stance = stance
copy.kit_id = kit_id
@@ -138,6 +151,14 @@ func clone() -> SimEntity:
return copy
## Whether this hero is dead and counting down to respawn. A dead hero is inert — it cannot
## move, attack, cast, or be targeted (every acting and targeting step skips it) — and the
## client hides its body behind the death screen until the timer elapses. Reads off
## `respawn_ticks` so death and its countdown are one piece of state, alive again at 0.
func is_dead() -> bool:
return respawn_ticks > 0
## This entity's move speed after any active lock or slow. A STUN freezes it outright
## (speed 0); otherwise a SLOW status scales the base speed by (100 - its percent), and
## with neither the base speed is returned unchanged — so a status-free entity (every
modified test/unit/test_ability.gd
@@ -232,7 +232,7 @@ func test_an_unequipped_hero_ignores_ability_intent() -> void:
cast.ability_slot = 0
cast.target_point = Vector2(100.0, 0.0)
sim.step({id: cast}) # must not cast or crash
assert_false(sim.state.get_entity(id).is_hero, "a bare hero is not an ability caster")
assert_true(sim.state.get_entity(id).kit.is_empty(), "a bare hero carries no kit to cast from")
assert_eq(sim.state.get_entity(enemy).hp, 600, "and its ability intent does nothing")
modified test/unit/test_net_protocol.gd
@@ -9,7 +9,7 @@ extends GutTest
func test_protocol_version_is_pinned() -> void:
# The netcode compatibility axis. A wire-shape change must bump this in the
# same commit; this guard makes an accidental drift fail the suite.
assert_eq(NetProtocol.PROTOCOL_VERSION, 3)
assert_eq(NetProtocol.PROTOCOL_VERSION, 4)
func test_input_round_trips_with_its_sequence_number() -> void:
@@ -75,6 +75,26 @@ func test_a_populated_snapshot_round_trips_every_field() -> void:
assert_eq(copy.is_creep, original.is_creep)
assert_eq(copy.lane, original.lane)
assert_eq(copy.waypoint_index, original.waypoint_index)
assert_eq(copy.respawn_ticks, original.respawn_ticks)
func test_snapshot_carries_a_downed_heros_respawn_timer() -> void:
# The respawn countdown rides the snapshot so a pure CLIENT can raise its own death screen
# and tick the timer down without simulating. Kill team 0's hero outright, let the death pass
# down it, then prove the timer survives the trip.
var sim := SimCore.new()
sim.spawn_creeps = false
var hero := sim.add_hero(0, MapData.spawn_for_team(0), 320.0)
sim.state.get_entity(hero).hp = 0
sim.step({}) # the death pass downs the hero and starts its respawn clock
var original := sim.state.get_entity(hero)
assert_true(original.is_dead(), "the slain hero is downed, not erased")
var restored := NetProtocol.decode_snapshot(NetProtocol.encode_snapshot(sim.state))
assert_eq(
restored.get_entity(hero).respawn_ticks,
original.respawn_ticks,
"the respawn countdown survives the trip so the client can show it",
)
func test_snapshot_preserves_entity_order() -> void:
modified test/unit/test_sim_core.gd
@@ -108,6 +108,64 @@ func test_an_entity_dies_when_its_hp_reaches_zero() -> void:
assert_null(sim.state.get_entity(enemy), "an entity at 0 hp is removed from the world")
# --- Hero death & respawn ---------------------------------------------------
func test_a_slain_hero_is_downed_not_erased() -> void:
# Unlike a creep, a dead hero is kept in the world and put on the respawn clock, so its id and
# countdown persist for the client's death screen and the revive step.
var sim := SimCore.new()
sim.spawn_creeps = false
var hero := sim.add_hero(0, MapData.spawn_for_team(0), 320.0)
sim.state.get_entity(hero).hp = 0
sim.step({})
var downed := sim.state.get_entity(hero)
assert_not_null(downed, "a slain hero stays in the world rather than being erased")
assert_true(downed.is_dead(), "the slain hero is marked dead")
assert_eq(downed.respawn_ticks, SimCore.HERO_RESPAWN_TICKS, "its respawn clock is started")
assert_eq(downed.hp, 0, "a downed hero sits at 0 hp")
func test_a_downed_hero_respawns_full_at_its_spawn_point() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var spawn := MapData.spawn_for_team(0)
var hero := sim.add_hero(0, spawn, 320.0)
# Walk the hero off its spawn so the respawn-in-place is observable, then kill it.
sim.state.get_entity(hero).position = spawn + Vector2(500.0, 0.0)
sim.state.get_entity(hero).hp = 0
sim.step({}) # downs the hero, starting the HERO_RESPAWN_TICKS countdown
for _i in SimCore.HERO_RESPAWN_TICKS - 1:
sim.step({})
assert_true(sim.state.get_entity(hero).is_dead(), "the hero stays down until the timer elapses")
sim.step({}) # the tick the timer reaches 0
var revived := sim.state.get_entity(hero)
assert_false(revived.is_dead(), "the hero is alive once the timer elapses")
assert_eq(revived.hp, SimCore.HERO_HP, "it returns at full health")
assert_eq(revived.position, spawn, "it returns at its spawn point")
func test_a_downed_hero_is_inert_and_untargetable() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false
var tower := sim.add_structure(1, Vector2.ZERO, 1000, 100, 300.0, 60)
var hero := sim.add_hero(0, Vector2(100.0, 0.0), 320.0)
sim.state.get_entity(hero).hp = 0
sim.step({}) # downs the hero
assert_true(sim.state.get_entity(hero).is_dead())
var down_pos := sim.state.get_entity(hero).position
# Untargetable: the only enemy in the tower's range is the corpse, so it finds nothing to hit.
assert_null(
sim._nearest_enemy_in_range(sim.state.get_entity(tower)),
"a downed hero is not a valid attack target",
)
# Inert: a move command on a downed hero is ignored — it holds where it fell.
var command := InputCommand.new()
command.move_dir = Vector2.RIGHT
sim.step({hero: command})
assert_eq(sim.state.get_entity(hero).position, down_pos, "a downed hero does not move")
func test_nexus_destruction_sets_the_winner_and_freezes_the_match() -> void:
var sim := SimCore.new()
sim.spawn_creeps = false