doc 11 of 11

Stage 09 — Ship it: death, restart, and proof

You will build: the survival timer, a game-over screen on the pause pattern you already know, a restart that actually works, and the self-review that decides whether the game is your game or a transcription. You'll learn: reload_current_scene and what survives it · score as a design surface · the rebuild-from-memory drill · what "done" means for a learning project.

Why this exists#

A game without a death screen is a demo with extra steps. More importantly, this stage is where the almanac's method closes the loop: you wrote down what the game is (stage 00's checklist, the architecture doc), you built it one sitting at a time, and now you prove — file by file — that you can stand it back up from the diagram in your head. If you can't explain a file, that file isn't yours yet; find it before you ship.

Build it#

A — The clock#

In PlayerUI, a Label for the score and, in the player's PlayerStats:

var time_alive := 0.0

func _physics_process(delta: float) -> void:
	time_alive += delta

One label updated once a second (not every frame — a clock that redraws 60 times a second is the frame-skip habit in reverse):

func _on_score_timer_timeout() -> void:   # a 1s timer on the UI
	score_label.text = "⏱ %d s" % int(stats.time_alive)

The score is time, not kills — the survivors convention, for a reason: kills can be farmed by a wall, but time survived is the only number the crowd itself is adding. Every mechanic you ship later (faster spawns, tougher species) makes the clock mean more, without the clock's code changing. A score that gets richer as the game gets harder is a design surface, not a HUD.

B — Game over, on the pattern you know#

GameOver overlay: same shape as the level-up menu — full-rect Control, hidden, Process Mode: When Paused — with the final time, a Restart button, and (later, stretch) a best-time line.

# GameOver.gd
extends Control

func _ready() -> void:
	var stats: PlayerStats = Globals.player.player_stats
	stats.health_depleted.connect(_on_dead)

func _on_dead() -> void:
	final_label.text = "You survived %d seconds." % int(Globals.player.player_stats.time_alive)
	visible = true
	get_tree().paused = true

func _on_restart_pressed() -> void:
	get_tree().paused = false
	get_tree().reload_current_scene()

And in PlayerStats, death becomes real — replace the stage-04 print:

func _on_health_depleted() -> void:
	health_depleted.emit()   # the overlay listens; the player itself does nothing

The player's death is announced, not handled: the stats emit, the overlay listens, the world keeps existing while paused. Same upward flow as every signal in this project.

C — What reload_current_scene does, precisely#

It frees the entire main scene and re-instantiates it from disk. Consequences, in order:

  • Everything under Entities is gone and rebuilt — 300 golems, the pool, every drop. No cleanup code runs; there is no cleanup to run. (This is why the pool's containment argument from stage 08 matters: the tweens die with their targets, for free.)
  • Autoloads survive. Globals is still running — and its player property re-queries the group, so the first frame after reload may briefly see no player. Every consumer already guards if player == null (stages 02–08 built the habit); the reload exercises it in the one place you never tested it: the transition itself.
  • The new scene's CanvasLayer overlays start hidden, because they're new. Your game-over screen is gone the instant you restart — correct.
  • Nothing is saved, because you haven't built saving. The best-time stretch is where save & load comes in — one FileAccess to user://, and the clock from part A suddenly has a history.

The alternative — manually resetting every system in place — is how a restart becomes a project: the spawner's timer, the pool's bookkeeping, the weapon's upgraded stats (do the upgrades carry over? that's now a design question you have to answer for each), every _ready re-run in the wrong order. Reload answers all of them with one verb and one frame. When you outgrow it (per-run upgrades that should persist, a mid-level restart), you'll know exactly which of the four bullets above forced the change.

D — The self-review, file by file#

No new code. For each script in the project — Globals, Player, PlayerStats, Weapon, Enemy, EnemyStats, Exp, Hitbox, Hurtbox, FloatText, FloatTextPool, PlayerUI, LevelUp, GameOver — answer in a comment at the top of the file (or in your commit message, or out loud to a person):

  1. What is this file's one job?
  2. What does it know about the rest of the game, and through which doors (groups, signals, autoload)?
  3. What would break if this file were deleted?
  4. What would break if it were duplicated?

Question 4 is the honest one: a file that breaks when duplicated holds a singleton in disguise (the pool, Globals), and you should be able to say which of its members is the singleton and which are just state. If you can't answer all four for a file, that file is the next thing you're going to rewrite — with the answer in the top comment.

E — Commit#

git add . && git commit -m "stage 09: survival clock, game over, restart, self-review"

Checkpoint — definition of done#

  • The clock runs, updates once a second, and is the time survived — kills are not in it
  • Death pauses the world on the familiar pattern; the final time is correct (check it against the clock you were watching, not against the label)
  • Restart works from game over, and works with 300 enemies and a full pool alive (the stage-08 guard earns its keep)
  • Restart works with a drop mid-arc and the level-up menu's unpaused state — kill yourself in the frame the menu closes, if you can
  • The four questions answered for all fourteen files, and at least one file you rewrote because the answers exposed it
  • You played the game for ten minutes and the thing you'd change first is a design complaint (enemy variety, upgrade depth, difficulty), not a bug report
  • Zero warnings; committed

The stretch list, in the order the itch usually strikes#

  1. A ranged weapon — the RANGE enum from stage 05 finally fires: a projectile scene, spawned per swing, pooled per stage 08. This is where object pooling stops being insurance and becomes the architecture — a crossbow at 0.5s cadence mints a projectile fifty times a minute and each one lives until it hits.
  2. A second species as a pure data change — the stage-03 Brute, finished: slower, tougher, 4 exp. If any code changed, find the species assumption and move it to EnemyStats.
  3. Audio — the audio shelf, in order: the hit thock (stage 04's path now has a sound), the level-up sting (it should duck the score — the pause from part B makes this free), the ambient bed. A survivors game with no sound is a spreadsheet with sprites; the shelf's first part takes an hour.
  4. A feel passgame feel: hit-stop on player damage (one frame of pause reads as impact), screen shake on the kill of a big enemy, the weapon's knockback stat from stage 07's stretch finally selling itself.
  5. A minute-ten wall — the spawn rate doubles at 600 seconds and a new species appears. The clock from part A is why this works: the score's meaning changed without the score changing.

If you get stuck#

  • Restart works but the next death never shows game over → the overlay's health_depleted connection died with the old player; the new overlay (new scene) connected in its _ready — verify the new overlay is the one listening, not a zombie. reload_current_scene frees the old CanvasLayer too; the pattern survives only because it's re-built, not because it's re-used.
  • The final time is always 0 → the overlay reads time_alive from the stats node that was just freed, or from a different one. Globals.player after death still resolves (the player scene exists until reload) — print get_instance_id() from both the clock's writer and the overlay's reader once.
  • Restart leaves a ghost pool: floating text from the previous run still drifting → a text outlived Entities, which means its parent chain broke somewhere (a set_as_top_level you added in a hurry doesn't do this — reparenting does). Find the add_child that put a text under root instead of the pool's Entities.
  • You can't answer question 3 for Hurtbox — good. The honest answer is "nothing visible, and that's the point: it's the contract, not the feature." Files whose deletion is invisible are the ones carrying the load; name them in your commit message.