Collision Detection & Signals
Fruits fall from the sky
Catch them with a basket. Score points!
Along the way: Area2D, CollisionShape2D, Signals, queue_free(), Timer
What we built last time
| Concept | What You Learned |
|---|---|
| Velocity | Speed + direction as a Vector2 |
| Autonomous movement | position += velocity * delta |
| Bouncing | Reverse velocity component at boundaries |
preload() / instantiate() | Load a scene, create copies at runtime |
_input(event) | Event-driven input (mouse clicks) |
Ricochet spawns objects and moves them
Today we learn to detect when they touch
This is the missing piece for Pong — how does the ball know it hit a paddle?
How do we know when two things touch?
Use cases:
Area2D needs a shape to know its boundaries
Area2D = the sensor (detects overlaps)
CollisionShape2D = the shape (defines the hitbox)
Common shapes: RectangleShape2D, CircleShape2D, CapsuleShape2D
We'll use RectangleShape2D — it matches our sprites
| Node2D | Area2D | |
|---|---|---|
| Used in | Wanderer, Ricochet | Fruit Frenzy |
| Can move? | Yes | Yes |
| Detects overlaps? | No | Yes |
| Needs collision shape? | No | Yes |
| Emits signals? | Basic only | area_entered, area_exited |
Rule of thumb: if it needs to detect contact with something, use Area2D
The player-controlled catcher
Compare to Wanderer:
Same idea, but Area2D replaces Node2D and we add a CollisionShape2D
BasketRectangleShape2Dbasket.tscn$ Shorthand$NodeName = "get the child node called NodeName"
$Sprite2D is shorthand for get_node("Sprite2D")
Works for any child node — just use the name from the scene tree
How wide/tall is my sprite on screen?
# Raw texture size (pixels in the image file)
var tex_w = $Sprite2D.texture.get_width()
var tex_h = $Sprite2D.texture.get_height()
# Actual display size (accounts for scale!)
var display_w = tex_w * $Sprite2D.scale.x
var display_h = tex_h * $Sprite2D.scale.y
Why does this matter?
If your sprite is 128px wide but scaled to 0.5, it displays as 64px.
Always multiply by scale when you need the real on-screen size.
extends Area2D
var speed = 400
func _process(delta):
if Input.is_action_pressed("ui_left"):
position.x = position.x - speed * delta
if Input.is_action_pressed("ui_right"):
position.x = position.x + speed * delta
var half_w = $Sprite2D.texture.get_width() * $Sprite2D.scale.x / 2 # display half-width
var screen_w = get_viewport_rect().size.x # viewport width
position.x = clamp(position.x, half_w, screen_w - half_w) # keep edges on screen
Look familiar? This is the Wanderer pattern!
extends Area2D instead of extends Node2DInput.is_action_pressed() pollingclamp() to stay on screen| Code Pattern | First Seen In | Reused Here |
|---|---|---|
Input.is_action_pressed() | Wanderer | Basket movement |
position.x +/- speed * delta | Wanderer | Basket movement |
clamp() | Wanderer | Keep basket on screen |
Learn once, use everywhere
The falling collectible
Same structure as the Basket — Area2D with a shape and sprite
Both need Area2D because both need to detect overlap with each other
extends Area2D
var speed = 200 # pixels per second
func _ready():
add_to_group("fruits") # tag for collision check
func _process(delta):
position.y = position.y + speed * delta # fall downward
var half_h = $Sprite2D.texture.get_height() * $Sprite2D.scale.y / 2 # display half-height
var screen_h = get_viewport_rect().size.y # viewport height
if position.y > screen_h + half_h: # fully below screen?
queue_free() # destroy this fruit
Two things happening:
position.y += speed * delta (like Ricochet, but only vertical)queue_free() when off screenqueue_free()queue_free() = "remove me from the game"
In Ricochet, logos bounced back. In Fruit Frenzy, missed fruits are destroyed.
Godot's Event System
Signals let one node notify another that something happened
Analogy: a doorbell
Signals decouple the sender (what happened) from the receiver (what to do about it)
1. Fruit moves into Basket's area
↓
2. Basket emits area_entered signal
↓
3. Signal calls _on_area_entered(area)
↓
4. Handler destroys fruit + updates score
The area parameter is the other Area2D that entered — in our case, the Fruit
area_entered(area: Area2D)_on_area_enteredA group is a tag you attach to a node so you can identify it later
add_to_group("fruits") # in _ready(): tag this node as a fruit
area.is_in_group("fruits") # later: ask "is this node a fruit?"
Why not check the node's name?
When you instantiate() multiple copies, Godot renames them:
"Fruit" → "@Fruit@2" → "@Fruit@3" → ...
Names change. Groups don't.
func _on_area_entered(area):
if area.is_in_group("fruits"): # is it a fruit?
area.queue_free() # destroy it
get_parent().add_score() # tell Game we scored
Line 1: area = the other Area2D that overlapped (the Fruit)
Line 2: is_in_group("fruits") — check if the area belongs to the "fruits" group
Line 3: area.queue_free() — destroy the caught fruit
Line 4: get_parent().add_score() — tell the Game node to update the score
get_parent() Communicationget_parent() = "give me the node above me in the tree"
Basket calls get_parent().add_score() → Game's add_score() runs
This works because the Basket knows its parent has an add_score() function
Putting it all together
Gamebasket.tscn into scene → position at (400, 550)ScoreLabel → text: Score: 0SpawnTimergame.tscnTimer = do something repeatedly, on a schedule
wait_time — seconds between each tickstart() — begin the countdowntimeout signal — emitted every time the timer firesTimer with wait_time = 1.0:
1 sec → timeout → 1 sec → timeout → 1 sec → timeout → ...
Perfect for spawning a fruit every second!
extends Node2D
var fruit_scene = preload("res://fruit.tscn") # load once at startup
var score = 0
func _ready():
$SpawnTimer.wait_time = 1.0 # spawn every 1 second
$SpawnTimer.start() # begin the countdown
$SpawnTimer.timeout.connect(_on_spawn_timer_timeout) # signal → function
func _on_spawn_timer_timeout():
var fruit = fruit_scene.instantiate() # create a new fruit
var screen_w = get_viewport_rect().size.x # viewport width
var margin = 50 # edge padding
fruit.position = Vector2(randf_range(margin, screen_w - margin), -20) # random x, above screen
add_child(fruit) # add to scene, starts falling
func add_score(): # called by Basket on catch
score = score + 1
$ScoreLabel.text = "Score: " + str(score) # update the UI
preload("res://fruit.tscn") — load the fruit scene (from Ricochet!)
$SpawnTimer.wait_time = 1.0 — spawn every 1 second
$SpawnTimer.timeout.connect(...) — connect signal in code (alternative to editor)
fruit_scene.instantiate() — create a new fruit copy (from Ricochet!)
Vector2(randf_range(50, screen_w - 50), -20) — random x, just above screen
add_child(fruit) — add to scene tree, fruit starts falling
$ScoreLabel.text = "Score: " + str(score) — update the UI
| Method | How | When to Use |
|---|---|---|
| Editor | Node panel → Signals tab → double-click | Nodes already in the scene |
| Code | .connect(function_name) |
Dynamic setup, or preference |
We used the editor for Basket's area_entered
We used code for Timer's timeout
Both do the same thing — connect a signal to a function
Fruits fall. Catch them. Score goes up.
A complete game in ~30 lines of code!
Adding visual flair
Add to the Fruit script's _ready():
func _ready():
add_to_group("fruits") # tag for collision check
$Sprite2D.modulate = Color(randf(), randf(), randf()) # random color tint
speed = randf_range(150, 350) # random fall speed
$Sprite2D.modulate — same trick from Ricochet's color bonus!randf_range(150, 350) — each fruit falls at a different speedinstantiate() calls _ready() → unique fruit every time| Concept | What You Learned |
|---|---|
| Area2D | A node that detects when other areas overlap with it |
| CollisionShape2D | Defines the hitbox shape for an Area2D |
| Signals | Nodes emit events; other nodes respond. Godot's event system. |
area_entered | Signal emitted when another Area2D overlaps |
queue_free() | Remove a node from the game (destroy it safely) |
| Timer | Emits timeout signal at regular intervals |
.connect() | Connect a signal to a function in code |
get_parent() | Access the parent node (for upward communication) |
Wanderer
Input, movement, clamp
↓
Ricochet
Velocity, autonomous movement, spawning
↓
Fruit Frenzy
Area2D, collision detection, signals, score
↓
Pong
All of the above, combined!
Try these on your own!
misses variable to the Game script. When a fruit calls queue_free() because it went off screen (not caught), tell the game. Display "Missed: X" in a second label.$SpawnTimer.wait_time by 0.1 (minimum 0.3). Hint: use a second Timer.CollisionShape2D gets 50% wider for 5 seconds. Hint: use $CollisionShape2D.shape.size"bombs" group and check area.is_in_group("bombs")_input(event).We'll combine everything:
Plus new concepts:
@export — customize values from the InspectorThree micro-games → one real game
Try building Fruit Frenzy on your own
Experiment with the exercises
Challenge: add a lives system — 3 missed fruits and game over!
Office hours: [Your time here]