doc 6 of 10

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:

  1. Which layer did you name solid, and what is the player's mask set to?
  2. From stage 03: what are the two timers, and what happens to each one when a jump fires?
  3. 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.

  1. Add a TileMapLayer node named Solids.
  2. In the Inspector, create a New TileSet, tile size 8, 8.
  3. Open the TileSet bottom panel, add your texture as an atlas, and let it auto-create tiles.
  4. Add a Physics Layer in the TileSet's inspector, and set its collision layer to 1 (solid).
  5. 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.

PropertyValueWhy
limit_left / limit_top0 / 0Room's top-left in world pixels
limit_right / limit_bottom320 / 180Room's bottom-right, in world pixels
position_smoothing_enabledoff for nowTry it on later; precision platformers often want it off
limit_smoothedleave offIt 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_entered requires 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_enabled fighting a player that moves in _physics_process. Turn smoothing off, or set the camera's process_callback to physics.
  • Respawn puts you inside geometry → your SpawnPoint marker 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.