Gesture Input, Custom Drawing, and Distance Collision
Build the core Fruit Ninja slicing mechanic
Starter has fruits spawning and falling — but nothing happens when you click
Six new concepts: match, gesture input, _draw(), distance_to(), .new(), string %
Today: the core mechanic (slash + slice + score)
Next lecture: tweens, particles, bombs, and the full game loop
Open the starter project in Godot and follow along!
Fruits fly up and fall — but you can't interact with them
Let's fix that.
Three tools we'll use today
match StatementRecap: you already know enum from Flappy Bird
# Flappy Bird's state machine
enum State { MENU, COUNTDOWN, PLAYING, DYING, SCORE }
if current_state == State.MENU:
start_countdown()
elif current_state == State.PLAYING:
pass # gameplay
elif current_state == State.SCORE:
restart()
What if there were 10 states? That's a lot of elif...
match vs if/elifif/elif chain
if fruit == "apple":
color = "red"
elif fruit == "banana":
color = "yellow"
elif fruit == "orange":
color = "orange"
else:
color = "white"
match statement
match fruit:
"apple":
color = "red"
"banana":
color = "yellow"
"orange":
color = "orange"
_:
color = "white"
Same logic, cleaner syntax — match checks one value against multiple patterns
_ is the wildcard — matches anything (like else)
match with Multiple Valuesmatch fruit_type:
FruitType.APPLE, FruitType.WATERMELON:
splash_color = "red"
FruitType.BANANA:
splash_color = "yellow"
FruitType.ORANGE:
splash_color = "orange"
Comma-separated values on one line — matches any of them
We'll use exactly this pattern when slicing fruits later
enum_match_demo.tscnCycle through fruit types → match maps each to a color
In Fruit Ninja: mapping FruitType to splash colors
Recap: you know _input(event) and InputEventMouseButton
InputEventMouseButton fires on click and release
But for a swipe, we need to track the mouse while it's moving
NEW: InputEventMouseMotion
InputEventMouseMotionfunc _input(event):
if event is InputEventMouseButton:
# Fires once on click or release
print("Clicked at ", event.position)
elif event is InputEventMouseMotion:
# Fires every time the mouse moves
print("Moving at ", event.position)
InputEventMouseButton = click/release (one event)
InputEventMouseMotion = every pixel of movement (many events)
But how do we know if the button is held down during motion?
button_mask vs button_index| Property | Event Type | What It Tells You |
|---|---|---|
button_index | InputEventMouseButton | Which button was just clicked |
button_mask | InputEventMouseMotion | Which buttons are currently held |
elif event is InputEventMouseMotion:
if event.button_mask == MOUSE_BUTTON_MASK_LEFT:
# Left button is held while moving = dragging!
print("Dragging at ", event.position)
This is how we detect a swipe
gesture_input_demo.tscnClick-drag to draw a trail of points
In Fruit Ninja: the swipe/slash trail
Recap: you know Area2D + CollisionShape2D for collision
But our swipe is just a list of points — not an Area2D
We need a simpler approach: math
NEW: pos.distance_to(target.position)
distance_to()var distance = swipe_pos.distance_to(fruit.position)
if distance < 50:
# Close enough — it's a hit!
fruit.slice(swipe_pos)
No Area2D, no CollisionShape2D — just one line of math
Check every fruit in the scene:
var fruits = get_tree().get_nodes_in_group("FRUIT")
for fruit in fruits:
var distance = pos.distance_to(fruit.position)
if distance < 50:
fruit.slice(pos)
get_nodes_in_group() returns all nodes in the group — already familiar from Flappy Bird
Loop + distance check = manual collision detection
distance_collision_demo.tscnClick near a circle to pop it — distance_to() checks the gap
In Fruit Ninja: checking if the swipe position is near a fruit
Creating the swipe mechanic
SlashDetector is already in the scene — just needs a script
Right-click SlashDetector → Attach Script → slash_detector.gd
In slash_detector.gd — detect swipe start, motion, and end:
extends Node2D
var is_swiping = false
var trail_points = []
const MAX_TRAIL_POINTS = 20
func _input(event):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
# Start swipe
is_swiping = true
else:
# End swipe
is_swiping = false
trail_points.clear()
is_swiping tracks whether the player is currently dragging
trail_points will store the swipe path
Add the InputEventMouseMotion handler:
func _input(event):
if event is InputEventMouseButton:
# ... (start/end swipe from previous slide)
elif event is InputEventMouseMotion:
if event.button_mask == MOUSE_BUTTON_MASK_LEFT \
and is_swiping:
# Record the swipe position
trail_points.append(event.position)
# Limit trail length
if trail_points.size() > MAX_TRAIL_POINTS:
trail_points.pop_front()
button_mask == MOUSE_BUTTON_MASK_LEFT — only while left button is held
pop_front() removes the oldest point — keeps the trail at 20 points max
elif event is InputEventMouseMotion:
if event.button_mask == MOUSE_BUTTON_MASK_LEFT \
and is_swiping:
trail_points.append(event.position)
if trail_points.size() > MAX_TRAIL_POINTS:
trail_points.pop_front()
print("Swipe at: ", event.position) # Test!
Run the game — drag the mouse and watch the console
You should see a stream of Vector2 positions
Remove the print() after testing — it spams the console!
NEW: The Canvas API _draw()
So far we've used Sprite2D for graphics.
But our swipe trail is a dynamic list of lines. We need to draw it ourselves.
Inside the _draw() function, you can use low-level tools:
draw_line(start, end, color, width)draw_circle(position, radius, color)draw_rect(rect, color)queue_redraw() GotchaWARNING: _draw() is NOT like _process()
_process(delta) runs automatically every single frame.
_draw() only runs ONCE when the node enters the scene.
Why? Optimization! Drawing is expensive. Godot assumes your drawing is static unless you say otherwise.
To update the screen, you MUST call queue_redraw()
This tells Godot: "Hey, my data changed. Please call _draw() again next frame."
custom_draw_demo.tscnWithout queue_redraw(), logic updates but visuals freeze.
If your trail isn't showing up today, you forgot to call queue_redraw()!
Add _draw() to slash_detector.gd:
const TRAIL_COLOR = Color(0.2, 0.2, 0.2, 0.8)
const TRAIL_WIDTH = 8.0
func _draw():
if trail_points.size() > 1:
for i in range(trail_points.size() - 1):
var start = trail_points[i]
var end = trail_points[i + 1]
# Fade: newer points are more opaque
var alpha = float(i) / trail_points.size()
var color = TRAIL_COLOR
color.a = alpha
draw_line(start, end, color, TRAIL_WIDTH)
draw_line(start, end, color, width) — line between two Vector2 points
Alpha trick: older points fade out, newer points stay solid
Add queue_redraw() calls and set z_index:
func _ready():
z_index = 100 # Draw trail on top of everything
func _input(event):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
is_swiping = true
else:
is_swiping = false
trail_points.clear()
queue_redraw() # Clear the trail
elif event is InputEventMouseMotion:
if event.button_mask == MOUSE_BUTTON_MASK_LEFT \
and is_swiping:
trail_points.append(event.position)
if trail_points.size() > MAX_TRAIL_POINTS:
trail_points.pop_front()
queue_redraw() # Update the trail
queue_redraw() on motion → trail updates as you swipe
queue_redraw() on release → trail disappears when you let go
Add check_slice() to slash_detector.gd:
func check_slice(pos: Vector2):
# Check all fruits
var fruits = get_tree().get_nodes_in_group("FRUIT")
for fruit in fruits:
if not fruit.is_sliced:
var distance = pos.distance_to(fruit.position)
if distance < 50:
fruit.slice(pos)
# Check all bombs (same pattern)
var bombs = get_tree().get_nodes_in_group("BOMB")
for bomb in bombs:
if not bomb.is_sliced:
var distance = pos.distance_to(bomb.position)
if distance < 50:
bomb.slice(pos)
Exact same pattern from Demo 3 — groups + loop + distance_to()
Call check_slice() inside the motion handler:
elif event is InputEventMouseMotion:
if event.button_mask == MOUSE_BUTTON_MASK_LEFT \
and is_swiping:
trail_points.append(event.position)
if trail_points.size() > MAX_TRAIL_POINTS:
trail_points.pop_front()
# Check for fruit collisions
check_slice(event.position)
queue_redraw()
Every swipe position is checked against every fruit
Run the game — swipe over fruits. Nothing visible yet (slice() is still pass)
Add a temporary print("HIT!") in check_slice() to confirm it works
Making fruits react to the swipe
slice()In fruit.gd — replace the pass stub:
func slice(slash_pos: Vector2):
if is_sliced:
return
is_sliced = true
emit_signal("fruit_sliced")
# Create two halves
var half1 = FruitPiece.instantiate()
var half2 = FruitPiece.instantiate()
half1.texture = textures[fruit_type]["half1"]
half2.texture = textures[fruit_type]["half2"]
get_parent().add_child(half1)
get_parent().add_child(half2)
is_sliced guard prevents slicing the same fruit twice
FruitPiece is preloaded at the top — same preload/instantiate pattern
Continue in slice() — set positions and velocities:
# Position halves at the fruit's location
half1.position = position + Vector2(-10, 0)
half2.position = position + Vector2(10, 0)
# Opposing velocities — halves fly apart
half1.velocity = Vector2(-100, -100)
half2.velocity = Vector2(100, -100)
# Remove the whole fruit
queue_free()
Halves start slightly offset (left/right) from the fruit center
Opposing x-velocities make them fly apart; negative y = upward pop
fruit_piece.gd already handles gravity and cleanup — pre-built!
Swipe over a fruit — it splits into two falling halves!
The core mechanic is working. Let's add visual juice.
matchAdd splash color logic in fruit.gd slice(), before queue_free():
# Determine splash color using match
var slash_detector = get_parent().get_node("SlashDetector")
if slash_detector:
var splash_color = "red"
match fruit_type:
FruitType.APPLE, FruitType.WATERMELON:
splash_color = "red"
FruitType.BANANA:
splash_color = "yellow"
FruitType.ORANGE:
splash_color = "orange"
slash_detector.create_fruit_splash(slash_pos, splash_color)
queue_free()
Same match pattern from Demo 1 — now applied to real game logic
We ask SlashDetector to create the splash — it owns the visual effects
create_fruit_splash()Add to slash_detector.gd:
# Splash textures (add at the top of the script)
var splash_textures = {
"red": preload("res://assets/images/splash_red_small.png"),
"yellow": preload("res://assets/images/splash_yellow_small.png"),
"orange": preload("res://assets/images/splash_orange_small.png")
}
func create_fruit_splash(pos: Vector2, color: String):
var splash = Sprite2D.new()
splash.texture = splash_textures[color]
splash.global_position = pos
splash.scale = Vector2(0.5, 0.5)
splash.z_index = 100
add_child(splash)
NEW: Sprite2D.new() — creates a node from code, not from a scene file
Same as instantiate() but for single nodes (no .tscn needed)
In game.gd — connect the fruit_sliced signal in spawn_fruit():
func spawn_fruit():
if not is_game_active:
return
var fruit = Fruit.instantiate()
add_child(fruit)
# Connect to the fruit_sliced signal
fruit.fruit_sliced.connect(_on_fruit_sliced)
# ... (existing spawn position + velocity code)
Same .connect() pattern from Flappy Bird and Pong
Each fruit connects its fruit_sliced signal to game.gd's handler
Add the handler in game.gd:
func _on_fruit_sliced():
score += 10
$ScoreLabel.text = "Score: %d" % score
NEW: String formatting with %
| Pattern | Example | Result |
|---|---|---|
"Score: %d" % score | score = 30 | "Score: 30" |
"Score: " + str(score) | score = 30 | "Score: 30" |
%d = insert an integer — cleaner than str() concatenation
The complete flow:
check_slice() detects proximityfruit.slice() → halves fly apart, splash appearsemit_signal("fruit_sliced") → signal fires_on_fruit_sliced() → score updatesRun it — swipe, slice, splash, score!
Sound makes everything better
Add at the top of slash_detector.gd:
var swipe_sounds = [
preload("res://assets/sounds/swipe1.wav"),
preload("res://assets/sounds/swipe2.wav")
]
An array of sounds — we'll pick one at random each swipe
Same preload() pattern, just with audio files instead of textures
play_swipe_sound()Add to slash_detector.gd:
func play_swipe_sound():
var audio = AudioStreamPlayer.new()
audio.stream = swipe_sounds[randi() % swipe_sounds.size()]
audio.volume_db = -10
add_child(audio)
audio.play()
# Clean up after sound finishes
await audio.finished
audio.queue_free()
AudioStreamPlayer.new() — same .new() pattern as Sprite2D.new()
randi() % swipe_sounds.size() = random index from 0 to array length
await for Cleanup # Clean up after sound finishes
await audio.finished
audio.queue_free()
await audio.finished — pauses this function until the sound finishes playing
Then queue_free() removes the temporary audio player
This is a magic keyword!
We will dive deep into how await works in the next lecture. For now, just know it prevents the sound from being destroyed before it finishes playing.
Add the call in the mouse button handler:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
is_swiping = true
play_swipe_sound() # Play on swipe start
else:
is_swiping = false
trail_points.clear()
queue_redraw()
Sound plays once per swipe — on mouse press, not on every motion event
Run it — each swipe now plays a random swoosh sound!
What we learned today
| Concept | What You Learned |
|---|---|
match | Pattern matching — cleaner than if/elif chains |
InputEventMouseMotion | Track mouse movement; button_mask for held buttons |
_draw() + queue_redraw() | Canvas API. _draw() runs once; queue_redraw() forces updates. |
distance_to() | Manual collision — pure math, no physics nodes |
.new() | Create nodes from code (Sprite2D, AudioStreamPlayer) |
String % | "Score: %d" % score — format strings cleanly |
Working Now
Missing (Next Lecture)
Next lecture: tweens, particles, bombs, and the full game loop
event.velocity on InputEventMouseMotion)50 to 30 (hard) or 80 (easy) — which feels best?| Game | Key Concepts |
|---|---|
| Wanderer | Nodes, scripts, input, movement, clamp |
| Ricochet | Vector2, velocity, bouncing, preload, instantiate |
| Fruit Frenzy | Area2D, collision, signals, groups, Timer, queue_free |
| Pong | @export, editor groups, custom signals, sound |
| Flappy Bird | Gravity, state machines, infinite scroll |
| Fruit Ninja (Part 1) | match, gesture input, _draw(), distance_to(), .new() |
6 games, 25+ concepts — each building on the last
Try the exercises — especially the Score Multiplier challenge
Can you track how many fruits were sliced in a single swipe?
Next: Lecture 14 — tweens, particles, bombs, and game flow