Stage 04 — A room to fall in
You will build: a tiled room with real collision, spikes that kill, a camera framed to the room, and instant respawn.
You'll learn: TileMapLayer and TileSet physics · collision layers in practice ·
Camera2D limits · Area2D as a sensor · why respawn should be nearly instant.
Warm-up — from memory:
- Which layer did you name
solid, and what is the player's mask set to? - From stage 03: what are the two timers, and what happens to each one when a jump fires?
- From the farming game: what replaced the old single TileMap node, and in which version?
Why this exists#
The StaticBody2D floor has done its job. A real level needs geometry you can draw rather than
place by hand, hazards, and a reason to move — and it needs a camera that shows the right part
of the world.
The genre decision from the architecture document comes due here: fixed screen-sized rooms or a scrolling level. This stage builds the fixed-room version, because it's the Celeste model and the camera code is nearly free. If you chose scrolling, build that instead — the tilemap and hazard work is identical, and only the camera section changes. Write down which you chose and why.
Build it#
A — The TileSet#
Draw or grab an 8×8 tile sheet. A grid of flat colours is genuinely fine; you are not doing art today.
- Add a TileMapLayer node named
Solids. - In the Inspector, create a New TileSet, tile size
8, 8. - Open the TileSet bottom panel, add your texture as an atlas, and let it auto-create tiles.
- Add a Physics Layer in the TileSet's inspector, and set its collision layer to 1
(
solid). - In the TileSet panel, select the Physics Layer 0 paint mode and draw a full-square collision polygon on each solid tile. The "reset to default tile shape" button fills a square in one click.
Then a second TileMapLayer named Decoration with no physics layer, ordered behind.
One node per layer — this is the 4.3-and-later design. Anything showing a single TileMap node
with layers inside it is older material, and while it still runs it's deprecated.
B — Draw a room#
In the TileMap bottom panel with Solids selected, paint a room 40 tiles wide and 22 tall —
320×176 at 8 px. The viewport is 320×180, so that's one screenful with four pixels to spare
(180 isn't a whole number of 8-pixel tiles, which is worth noticing now rather than wondering
about later). Give it a floor, walls, a couple of platforms at jumpable
heights, and a gap.
Delete the StaticBody2D floor from stage 01. Run. You should collide with painted tiles
exactly as you did with the old floor — and if you don't, that's the physics layer, which is
almost always step 4 above.
Predict before you run: you painted collision on the tiles but haven't touched the player. Why does the player collide with them at all? Trace it: which layer is the TileSet's physics layer on, and what does the player's mask include?
C — The camera#
Add a Camera2D as a child of Player.
| Property | Value | Why |
|---|---|---|
limit_left / limit_top | 0 / 0 | Room's top-left in world pixels |
limit_right / limit_bottom | 320 / 180 | Room's bottom-right, in world pixels |
position_smoothing_enabled | off for now | Try it on later; precision platformers often want it off |
limit_smoothed | leave off | It eases the camera into its limits — but only when position smoothing is on. With smoothing off it does nothing at all |
With limits set to exactly one room, the camera cannot show anything outside it — so a screen-sized room means a camera that never moves at all. That's the whole trick, and it's why this style needs no camera code.
Note the property names. In Godot 3 these were smoothing_enabled and smoothing_speed; in
Godot 4 they are position_smoothing_enabled and position_smoothing_speed, with
rotation_smoothing_* alongside. Tutorials using the old names are Godot 3, and the whole file
they come from should be treated with suspicion.
D — Spikes#
A Spike scene:
Spike (Area2D) ← collision layer 3 (hazard), mask 2 (player)
├── Sprite2D
└── CollisionShape2D
Add it to the group hazards (Node dock → Groups tab).
Note the layer/mask split: the spike is on layer 3 so it can be found, and detects layer
2 so it notices the player. It has no collision mask for solid at all — it's a sensor, and it
must never push anything.
Now the wiring, which is the interesting part. The spike does not know what a player is:
# spike.gd
extends Area2D
signal touched(body: Node2D)
func _on_body_entered(body: Node2D) -> void:
touched.emit(body)
The room decides what touching a spike means:
Add a Marker2D child of Room named SpawnPoint, positioned where the player should start,
and right-click it → Access as Unique Name so the % path below resolves. (Without that,
%SpawnPoint is null and respawning crashes.)
# room.gd
extends Node2D
@onready var spawn_point: Marker2D = %SpawnPoint
func _ready() -> void:
for spike in get_tree().get_nodes_in_group("hazards"):
spike.touched.connect(_on_hazard_touched)
func _on_hazard_touched(body: Node2D) -> void:
if body is Player:
_respawn(body)
func _respawn(player: Player) -> void:
player.global_position = spawn_point.global_position
player.velocity = Vector2.ZERO
This is "call down, signal up" in miniature. The spike reports a fact; the room applies a rule. Swap in a room where spikes damage instead of killing and the spike doesn't change.
E — Respawn should be instant#
No death animation, no fade, no scene reload. Reposition and zero the velocity, this frame.
That's a design decision, not laziness. This genre is built on rapid retries — a player who dies two hundred times in a room needs those deaths to cost a fraction of a second each. Half a second of death animation is two minutes of waiting across those attempts, and it turns "one more go" into "I'll stop here".
Zeroing velocity matters too: respawn with the falling speed you died at and you arrive
already moving, which feels like the game shoved you.
F — Commit#
git add . && git commit -m "stage 04: tiled room, camera limits, hazards, respawn"
Checkpoint — definition of done#
- Painted tiles collide; decoration tiles don't
- The camera shows exactly the room, and doesn't drift past its edges
- Touching a spike returns you to the spawn point with zero velocity
- The spike script contains no reference to
Player - You can die and be moving again in well under a second
- Zero warnings; committed
- You can explain: why does the spike signal up to the room instead of moving the player itself?
Stretch (no instructions)#
Add a collectible that the room counts, and a second room the player travels to once all are taken. You'll need to decide how a room hands over to the next — write that decision into the architecture document while the reasoning is fresh.
If you get stuck#
- Player falls through painted tiles → the TileSet's physics layer exists but the individual tiles have no collision polygon painted. They're separate steps, and this catches everyone.
- Spike never fires →
body_enteredrequires the Area2D's mask to include the player's layer. Also confirm the signal is connected — print in the handler first. - Camera shows blue void past the room → limits aren't set, or they're in the wrong units. They're world pixels, not tiles: 40 tiles × 8 px = 320.
- Camera jitters →
position_smoothing_enabledfighting a player that moves in_physics_process. Turn smoothing off, or set the camera'sprocess_callbackto physics. - Respawn puts you inside geometry → your
SpawnPointmarker is embedded in a tile.
Next: Stage 05 — the machine, where player.gd gets taken apart. It will
have grown uncomfortable by now, which is exactly the right time.