Death still ends the run, but the coins you grabbed stay banked. Spend them on hearts and speed between runs, and watch each life get measurably further.
Loading the game engine. This takes a moment the first time.
Break it on purpose
Run at what 120 speed upgrades would buy, without the ten runs it would take to earn them, then hold right. Both versions of the movement code are already in the game; the second switch picks which one runs.
200 px a second ÷ 60 checks a second = 3 px between checks. Nothing in the level is small enough to fall into a gap that size.
velocity = direction * speed
var hops: int = 1
velocity /= float(hops)
for i: int in hops:
move_and_slide()This is a sampler, not a build lesson: play it before you read it. You WILL die, fast, and that is the design. A roguelite keeps the roguelike's rule that death ends the run, then adds one twist: something you earned is waiting for you on the other side of it.
The five variables below say "static". A normal "var" belongs to one copy of a scene, so reload_current_scene() wipes it: that is why your run coins reset. A STATIC variable belongs to the SCRIPT, and the script survives scene reloads, so the bank rides through every death. It does NOT survive closing the game. A real roguelite writes its bank to a save file, and that is the one piece this sampler skips.
Hades, The Binding of Isaac, Dead Cells and Spelunky. Every one of them kills you often, rebuilds the map, and then hands you something you get to keep: a permanent upgrade, an unlocked weapon, a shortcut. That is the line between these and the roguelike in the last lesson, where the only thing you carry out of a run is what you learned.
Buy speed long enough and this game falls apart: you pass straight through spikes without losing a heart, and you cross coins without picking them up. That is a real bug, and it is left in on purpose, because meeting one is worth more than being protected from it.
The physics engine looks at the world 60 times a second, and a coin only notices you if you are touching it during one of those looks. At the starting speed of 200 you cover about 3 pixels between looks, so you cannot miss. Buy 45 boosts and you cover 33 pixels a look, while the coin is only 24 pixels wide: one look has you in front of it, the next has you past it, and no look ever had you on it. The name for that is tunneling.
The walls never let you through no matter how fast you go, and that is the tell. A wall is a StaticBody2D, and move_and_slide() checks the whole path you travelled before it puts you down. Spikes and coins are Area2D, which only ever checks where you ARE. One scene, two different rules, and the speed upgrade found the seam between them.
The switch under the game on the website runs it both ways. "Maxed out" runs you at what 120 upgrades would buy, which is 5000 pixels a second, or 83 pixels between two looks: wider than a coin and wider than a spike. "Checks" picks which version of the movement code runs, and the single line it swaps is in player.gd, above _physics_process(). Both versions are in the game already. A web page cannot hand Godot new code, because these scripts were compiled into the build; all it can do is flip a switch the code is watching.
Hops fix the SYMPTOM. FIX ONE, written out next to SPEED_PER_BOOST below and deliberately left switched off, fixes the CAUSE: stop selling an upgrade with no ceiling. Turn the checks on at full speed and you will see why it is the better fix, because you still cannot steer.
None of the earlier lessons use these. Each one shows the line where it appears here.
A variable that belongs to the script, not to one node, so it keeps its value even when the scene reloads.
static var banked_coins: int = 0True when a node is drawn. Setting it to false is the same as hide().
best_flag.visible = best_distance > 0.0Greys a button out so it cannot be clicked.
buy_heart_button.disabled = bank < heart_priceRounds a number up to the next whole number.
hops = maxi(1, ceili(speed * delta / MAX_STEP))Divides what a variable already holds.
velocity /= float(hops)■ UI node · ■ 2D node · % unique name · .tscn instanced scene
game_events.gd
Switch it on once, under Project > Project Settings, on the Globals tab. Not made yet? Press Create Script to make it right there. Already made it? Press Select Script/Scene, pick it, and press Add. Its Name column should say GameEvents, which Godot fills in for you. That's the name your code uses.
extends Node2D
# THE BANK. Static, so it belongs to this script rather than this scene,
# and death (which reloads the scene) cannot touch it. See the header.
static var banked_coins: int = 0
static var best_distance: float = 0.0
static var extra_hearts: int = 0
static var speed_boosts: int = 0
static var runs: int = 0
const HEART_PRICE: int = 5
const SPEED_PRICE: int = 3
# How much faster each speed upgrade makes the player.
const SPEED_PER_BOOST: float = 40.0
# FIX ONE for the speed bug in the header, written out but NOT applied.
# The rule every engine obeys: the distance you cover between two physics
# looks has to stay smaller than the smallest thing you need to touch. A
# coin is 24 pixels wide and physics looks 60 times a second, so anything
# under about 1000 pixels a second can never tunnel:
#
# const MAX_BOOSTS: int = 20 # 200 + 20 * 40 = 1000 a second, 17 a look
#
# and then the shop refuses the sale once the player owns that many:
#
# "speed":
# if banked_coins >= SPEED_PRICE and speed_boosts < MAX_BOOSTS:
# banked_coins -= SPEED_PRICE
# speed_boosts += 1
#
# Capping an upgrade is not giving up. Every shipped game does it, because
# a number that climbs forever eventually walks off the edge of something
# another system assumed, and this one walks off the edge of the physics.
# How long the corridor is. Matches the walls in this scene and the camera
# limits in player.tscn.
const CORRIDOR_LENGTH: float = 3456.0
# What the website's "Maxed out" switch is worth, in boosts. Buying this many
# honestly would take about ten clean runs, which is too long to wait to see a
# bug. 120 boosts is 5000 pixels a second, or 83 pixels between two physics
# looks: wider than a coin (24) and wider than a spike (40), so both stop
# working. See the header.
const DEMO_BOOSTS: int = 120
var run_coins: int = 0
var run_over: bool = false
@onready var player: Player = $Player
@onready var hud: GauntletHUD = $HUD
@onready var best_flag: Polygon2D = $BestFlag
@onready var goal: Area2D = $Goal
func _ready() -> void:
runs += 1
# Everything bought in past runs is applied at the start of this one,
# read straight out of the statics.
player.health = 1 + extra_hearts
_apply_speed()
GameEvents.site_flag_changed.connect(_on_site_flag_changed)
GameEvents.coin_collected.connect(_on_coin_collected)
GameEvents.player_died.connect(_on_player_died)
goal.body_entered.connect(_on_goal_entered)
# The HUD's shop buttons only report. This file decides what they do.
hud.shop_pressed.connect(_on_shop_pressed)
best_flag.visible = best_distance > 0.0
best_flag.position.x = best_distance
hud.set_hearts(player.health)
hud.set_bank(banked_coins)
hud.set_best(_percent(best_distance))
## The run's speed, from the boosts bought plus whatever the website's switch
## is asking for. Its own function because the page can flip that switch in the
## middle of a run and expects to feel it immediately.
func _apply_speed() -> void:
var boosts := speed_boosts
if GameEvents.site_flag("fast"):
boosts = maxi(boosts, DEMO_BOOSTS)
player.speed = player.base_speed + boosts * SPEED_PER_BOOST
func _on_site_flag_changed(_flag: String, _on: bool) -> void:
_apply_speed()
func _on_coin_collected(value: int) -> void:
run_coins += value
func _on_player_died() -> void:
_end_run(false)
func _on_goal_entered(body: Node2D) -> void:
if body is Player:
_end_run(true)
# The one moment that makes this a roguelite: the run's coins move into
# the bank BEFORE the scene reloads, and the bank is static, so they
# survive the reload that erases everything else.
func _end_run(reached_goal: bool) -> void:
if run_over:
return
run_over = true
player.set_physics_process(false)
var distance := CORRIDOR_LENGTH if reached_goal else player.position.x
best_distance = maxf(best_distance, distance)
banked_coins += run_coins
hud.set_best(_percent(best_distance))
var result := "Run %d over at %d%%. %d coins banked." % [runs, _percent(distance), run_coins]
if reached_goal:
result = "Run %d beat the whole gauntlet! %d coins banked." % [runs, run_coins]
hud.show_shop(result)
_refresh_shop()
func _on_shop_pressed(item: String) -> void:
match item:
"heart":
if banked_coins >= HEART_PRICE:
banked_coins -= HEART_PRICE
extra_hearts += 1
"speed":
if banked_coins >= SPEED_PRICE:
banked_coins -= SPEED_PRICE
speed_boosts += 1
"retry":
# Reloading wipes every normal var in the scene. _ready() then
# rebuilds the run from whatever the statics say.
get_tree().reload_current_scene()
return
_refresh_shop()
func _refresh_shop() -> void:
hud.update_shop(banked_coins, HEART_PRICE, SPEED_PRICE, extra_hearts, speed_boosts)
func _percent(distance: float) -> int:
return int(100.0 * distance / CORRIDOR_LENGTH)extends Area2D
## How many coins this pickup is worth.
@export var value: int = 1
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
# "Player" is the class_name at the top of player.gd.
if body is Player:
# Emit first, erase second: erased nodes cannot send messages.
GameEvents.coin_collected.emit(value)
queue_free()extends Area2D
## How much health the player loses on a touch.
@export var damage: int = 1
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
# "Player" is the class_name at the top of player.gd. The
# collision handed us the player, so we just call its function.
if body is Player:
body.take_damage(damage)class_name GauntletHUD
extends CanvasLayer
# Our own signal. "item" is which shop button: "heart", "speed" or "retry".
signal shop_pressed(item: String)
var _run_coins: int = 0
@onready var hearts_label: Label = %HeartsLabel
@onready var coins_label: Label = %CoinsLabel
@onready var bank_label: Label = %BankLabel
@onready var best_label: Label = %BestLabel
@onready var shop_panel: PanelContainer = %ShopPanel
@onready var result_label: Label = %ResultLabel
@onready var buy_heart_button: Button = %BuyHeartButton
@onready var buy_speed_button: Button = %BuySpeedButton
@onready var retry_button: Button = %RetryButton
@onready var home_button: Button = %HomeButton
func _ready() -> void:
GameEvents.health_changed.connect(_on_health_changed)
GameEvents.coin_collected.connect(_on_coin_collected)
# "bind" glues the item name onto the press, so all three buttons can
# share one handler and the level hears which one it was.
buy_heart_button.pressed.connect(_on_shop_button.bind("heart"))
buy_speed_button.pressed.connect(_on_shop_button.bind("speed"))
retry_button.pressed.connect(_on_shop_button.bind("retry"))
home_button.pressed.connect(_on_home_pressed)
func _on_health_changed(new_health: int) -> void:
set_hearts(new_health)
func _on_coin_collected(value: int) -> void:
_run_coins += value
coins_label.text = "Coins: %d" % _run_coins
func _on_shop_button(item: String) -> void:
shop_pressed.emit(item)
## Called BY game.gd when a run starts.
func set_hearts(hearts: int) -> void:
hearts_label.text = "Hearts: %d" % hearts
func set_bank(bank: int) -> void:
bank_label.text = "Bank: %d" % bank
func set_best(percent: int) -> void:
best_label.text = "Best: %d%%" % percent
## Called BY game.gd when the run ends, win or lose.
func show_shop(result: String) -> void:
result_label.text = result
shop_panel.visible = true
## Called BY game.gd after every purchase. The HUD never
## decides a price or checks the bank: it only shows what it is told.
func update_shop(
bank: int, heart_price: int, speed_price: int, hearts_owned: int, boosts_owned: int
) -> void:
set_bank(bank)
buy_heart_button.text = "Buy a heart for %d coins (own %d)" % [heart_price, hearts_owned]
buy_speed_button.text = "Buy speed for %d coins (own %d)" % [speed_price, boosts_owned]
buy_heart_button.disabled = bank < heart_price
buy_speed_button.disabled = bank < speed_price
# Loads the lesson list screen, which replaces this whole scene.
func _on_home_pressed() -> void:
get_tree().change_scene_to_file("res://shared/ui/lesson_select.tscn")class_name Player
extends CharacterBody2D
## The speed before any shop upgrades. The level adds the boosts on top.
@export var base_speed: float = 200.0
## The longest jump the player may take without the world getting a look at
## them. Under the width of a coin (24 px) on purpose. See _physics_process().
const MAX_STEP: float = 20.0
# Both are filled in by game.gd at the start of each run,
# from the statics the shop feeds.
var speed: float = 200.0
var health: int = 1
# True for a moment after each hit. See take_damage().
var _invulnerable: bool = false
@onready var sprite: Sprite2D = $Sprite2D
# THE BUG AND ITS FIX, ON ONE SWITCH.
#
# The switch under the game on the website sets the "hops" flag, and everything
# it changes is the one line marked below. It cannot hand Godot new source --
# these scripts were compiled into the build -- so both versions live here and
# the flag picks one.
#
# hops == 1 is the original: one move a frame, so one look a frame. At 5000
# pixels a second that move is 83 pixels and a coin is 24 wide, so a whole coin
# fits between two looks and nothing ever notices it. Splitting the same
# distance into 20-pixel hops puts the player inside every coin and spike on
# the way. Same speed, same landing spot, same frame: the only difference is
# how often the world got to check.
#
# Opened in the Godot editor there is no website and no switch, so the flag is
# always false and this runs the original. To try the fix there, change the
# "if" below to "if true:".
func _physics_process(delta: float) -> void:
var direction: Vector2 = Input.get_vector(
"move_left", "move_right", "move_up", "move_down"
)
velocity = direction * speed
# THE LINE THE SWITCH CHANGES.
var hops: int = 1
if GameEvents.site_flag("hops"):
hops = maxi(1, ceili(speed * delta / MAX_STEP))
velocity /= float(hops)
for i: int in hops:
move_and_slide()
# The warrior picture only faces right. Walking left mirrors it.
if direction.x != 0.0:
sprite.flip_h = direction.x < 0.0
## Called BY hazard.gd when a spike touches us.
func take_damage(amount: int) -> void:
if _invulnerable:
return
health -= amount
if health <= 0:
health = 0
# game.gd connects to this and ends the run.
GameEvents.player_died.emit()
# Emit LAST, so the HUD draws the corrected 0 instead of -1.
GameEvents.health_changed.emit(health)
# The mercy window: hurt, then briefly untouchable.
_invulnerable = true
var timer: SceneTreeTimer = get_tree().create_timer(0.8)
timer.timeout.connect(func() -> void: _invulnerable = false)extends Node
# All of the game's signals are declared below, grouped by the lesson
# that uses them. A signal with brackets, like coin_collected(value: int),
# passes that value to every function connected to it.
# This shows only the signals this lesson uses. The full file declares
# every lesson's signals.
# Coin Pickup lesson
signal coin_collected(value: int)
# Health and Damage lesson
signal health_changed(new_health: int)
signal player_died