Stage 06 — The drop falls: experience finds you
You will build: enemy death leaving an experience drop behind, a two-phase magnet (a
little arc away, then a chase home), the player's pickup area, and the crediting seam — where
you'll make design decision 3 and meet an underscore method you're tempted to call.
You'll learn: drops as scenes with a job · add_child ordering and why deferred · a
signal emitted by the receiver of the pickup · public doors vs private corridors.
Why this exists#
A survivors game is an economy: kills are the mint, the drop is the coin, the magnet is the coin's gravity, and the level-up (stage 07) is the shop. The economy only feels good if the coin moves like a coin — and that movement is this stage's real subject. The code is short; the design decisions in it are not.
Build it#
A — The drop scene#
entities/drops/exp.tscn: Area2D named Exp, group drops, layer 22 (Coin),
mask 0, monitoring off — another target, like the hurtboxes. One CollisionShape2D child,
circle radius 4.
class_name Exp
extends Area2D
signal picked_up
var pick := false
var exp_value := 1.0
@export var push_duration := 0.35
@export var push_distance := 20.0
@export var chase_speed := 150.0
@export var stop_distance := 10.0
var _tween: Tween
func _physics_process(delta: float) -> void:
if not pick:
return
if _tween != null and _tween.is_running():
return
var player := Globals.player
if player == null:
return
var to_player := player.global_position - global_position
if to_player.length() > stop_distance:
global_position += to_player.normalized() * chase_speed * delta
else:
player.player_stats.gain_exp(exp_value)
queue_free()
func _on_picked_up() -> void:
if pick:
return
pick = true
var away := (global_position - Globals.player.global_position)
if away.length() > 0.01:
away = away.normalized() * push_distance
else:
away = Vector2.UP * push_distance
_tween = create_tween()
_tween.tween_property(self, "global_position", global_position + away, push_duration) \
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
Read the two phases as feel, not physics:
- Phase one — the push. When your pickup area touches a drop, it hops away from you for a third of a second before it comes home. A drop that snaps straight to your face reads as "the game took it"; a drop that arcs and returns reads as "I grabbed it and it's flying here." Same total travel, opposite meaning. This is the game feel shelf's "the motion is the message" rule, applied to a number.
- Phase two — the chase. After the arc, the drop walks toward you at its own speed until
it's within
stop_distance, then credits and frees itself. The chase means a drop can never strand — walk away mid-pickup and it catches up.stop_distanceis where "the number arrives" and "the coin disappears" agree.
The pick guard is the whole concurrency story: the pickup signal can arrive twice (the area
can re-enter after the push moved the drop), and a drop must arc exactly once. One boolean,
set first, checked everywhere — the flag that does have a job.
B — Who emits picked_up, and why that's allowed#
In the reference project the signal lives on the drop, but the player's pickup area emits it:
# PickUpArea (Area2D, mask 22 only, monitoring on) — on the player
func _on_area_entered(area: Area2D) -> void:
if area is Exp:
area.picked_up.emit()
Normally signals flow from the thing that knows to the thing that cares — the drop knows it's been picked up. This inverts the wire: the player's area is the detector, so it makes the announcement. It's allowed for one reason only: detection is the player's job (the area geometry is the player's magnet reach, tuned in the player's Inspector), and a detector narrating its own detection is honest. The moment a second pickup system exists (a vacuum upgrade, a companion that collects), the announcement moves to the drop — two detectors, one narrator. Keep an eye out for that moment; the event bus shelf is where the general rule lives.
C — Death mints the coin#
EnemyStats grows one export and one job:
@export var exp_drop := 1.0
func _on_health_depleted() -> void:
var drop: Exp = EXP_SCENE.instantiate()
drop.exp_value = exp_drop
drop.position = parent_enemy.global_position
Globals.entities.call_deferred("add_child", drop)
parent_enemy.queue_free()
(EXP_SCENE is a preload constant, the stage-02 habit.)
Two orderings matter here, and both are quiet when wrong:
- Value before parent.
exp_valueis set while the drop is still out of the tree. If you parented first, the drop's_ready/_physics_processwould run with the default value of1.0— every golem worth one exp, no matter what its stats say. Configure, then commit to the tree. call_deferred("add_child", ...)— death happens inside a physics callback, on the very frame the physics server is mid-flush. Adding a node (which runs_ready, which can touch areas) in the middle of that flush is how "free object" and "duplicate signal" errors are born. Deferred says: you're dead, the world will adopt your coin after the frame settles. The reference project defers for exactly this reason; when you're debugging a crash that only happens at 300 enemies, this line is where you look.
D — Crediting: decision 3, and the underscore method#
Design decision 3 — make the call now, record it in the architecture doc:
- (A) Direct. The drop knows the player:
player.player_stats.gain_exp(exp_value). One line, no wires, the reference project's shape. - (B) Signal. The drop emits
experience_credited(value); the player's stats subscribe. One more wire, and the drop stops knowing a player exists.
One-line trade-off: A is one line; B survives the day the player stops being a global. A is the recommended path for this project — there is exactly one drop type, exactly one receiver, and the drop chases the player anyway, so the reference is not an illusion. B is the upgrade the moment a second receiver appears (a score system that also wants the number). Record your call and reason.
And the trap the reference project fell into, worth meeting by name: its magnet called
player.player_stats._on_exp_received(exp_value) — a method with a leading underscore.
The underscore is GDScript's "private" convention: I did not promise this door exists.
Calling across the scene tree into another class's corridor works today and breaks the day
someone renames the method for clarity — with no compiler error, because GDScript's
convention is social, not enforced. The fix is the public door this stage installs:
# PlayerStats — the exp side
signal exp_changed(current_exp: float, previous_exp: float)
var level := 1
var max_exp_points := 2.0
var exp_points := 0.0:
set = _set_exp_points
func _set_exp_points(value: float) -> void:
var prev := exp_points
exp_points = clampf(value, 0.0, max_exp_points)
if exp_points != prev:
exp_changed.emit(exp_points, prev)
## The public door. Drops call this; nothing calls _set_exp_points from outside.
func gain_exp(amount: float) -> void:
exp_points += amount
The setter clamps at max_exp_points — for now the overflow is lost, and the bar tops out
and sits there. That's stage 07's opening: the overflow has a job, and the job is a level-up.
(The curve — why max_exp_points starts at 2 and doubles — is stage 07's design section.)
E — The exp bar#
In PlayerUI, beside the health bar: a ProgressBar for exp, wired in _ready the same way
as health — max_value = stats.max_exp_points, value follows exp_changed. When you kill
your first golem, watch: coin drops, you walk over it, it arcs, it chases, the bar ticks.
F — Commit#
git add . && git commit -m "stage 06: exp drops, two-phase magnet, crediting door"
Checkpoint — definition of done#
- Kill a golem: a drop appears after the golem is gone (deferred), worth exactly its
exp_dropexport — change the export, change the bar's tick, no code touched - Walk away from a touched drop: it arcs, then catches up. Walk the other way for three seconds — the drop never gives up, and it never strands
- A drop arcs exactly once even if your pickup area re-touches it during the push (kill ten in a row and watch for double-hops)
- No call anywhere in the drop's code to an underscore-prefixed method on another class
- Decision 3 recorded with your reason
- Zero warnings; committed
Stretch (no instructions)#
A Brute worth 4 exp: bigger drop sprite or a 2× scale on the value's float text later. If
the value is data, the look of "worth more" should be cheap — find the one-line version.
If you get stuck#
- Drops appear at the screen corner (0,0) → you set
drop.positionafteradd_child, or set it in the wrong space. The parent (Entities) sits at the origin, so local == global here — but the habit to fix is the ordering, not the arithmetic. - Every drop is worth 1 regardless of the export → configure-before-parent from point A.1,
broken. The default
exp_value = 1.0is a fingerprint: if everything reads as the default, the set happened too late. - The bar fills and then jumps back to zero periodically → stage 07's level-up is already
in your file (a paste from the reference project). The clamp is doing its job and something
else is resetting the value — find the second writer of
exp_points. - A drop chases but never credits →
stop_distanceis smaller than the drop's arrival jitter: at 150 px/s and 60 fps the step is 2.5 px, fine — but if you setstop_distanceto 0, thelength() > 0branch is never false and the drop orbits the exact pixel. Give the stop a real radius.