CS 470 Game Development

Week 5, Lecture 13

Slash & Slice — Fruit Ninja Part 1

Gesture Input, Custom Drawing, and Distance Collision

Today's Goal

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 %

What We're Building This Week

fruit-ninja-final-demo.mp4
Complete Fruit Ninja gameplay: swipe to slice fruits, splash effects, score tracking, bomb explosions

Today: the core mechanic (slash + slice + score)

Next lecture: tweens, particles, bombs, and the full game loop

The Starter Project

Pre-Built (You Know This)

  • Fruits spawn on a timer
  • Fruits fly up and fall with gravity
  • Random fruit types + textures
  • Fruit piece scene (halves)
  • Score label + camera

Your Job (New Today)

  • Slash detection — swipe input
  • Trail drawing — visual feedback
  • Collision check — is swipe near fruit?
  • Fruit slicing — split into halves
  • Splash effects — juice on slice
  • Score + audio — wire it up

Open the starter project in Godot and follow along!

Run the Starter

starter-demo.mp4
Starter code running: fruits spawn from the bottom, arc upward, and fall — clicking does nothing

Fruits fly up and fall — but you can't interact with them

Let's fix that.

Agenda (~50 minutes)

  1. Demos — New Concepts (12 min)
  2. Build — Slash Detection (15 min)
  3. Build — Fruit Slicing (12 min)
  4. Build — Swipe Audio (5 min)
  5. Summary + Exercises (3 min)

1. Demos — New Concepts

Three tools we'll use today

Demo 1: match Statement

Recap: 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/elif

if/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 Values

match 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

Run: enum_match_demo.tscn

match-vs-if.png
Demo scene showing fruit type cycling with match statement — color changes based on fruit type

Cycle through fruit types → match maps each to a color

In Fruit Ninja: mapping FruitType to splash colors

Demo 2: Gesture Input

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

InputEventMouseMotion

func _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

PropertyEvent TypeWhat It Tells You
button_indexInputEventMouseButtonWhich button was just clicked
button_maskInputEventMouseMotionWhich 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

Run: gesture_input_demo.tscn

gesture-tracking-diagram.png
Demo showing mouse drag creating a trail of dots — each dot is an InputEventMouseMotion position

Click-drag to draw a trail of points

In Fruit Ninja: the swipe/slash trail

Demo 3: Distance Collision

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()

distance-collision-diagram.png
Diagram showing a circle (radius 50) around a fruit, with a swipe point inside the circle labeled "HIT" and one outside labeled "MISS"
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

Groups + Loop

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

Run: distance_collision_demo.tscn

distance-collision-demo.webp
Demo showing circles on screen that pop when you click near them — distance_to() collision in action

Click near a circle to pop it — distance_to() checks the gap

In Fruit Ninja: checking if the swipe position is near a fruit

2. Build — Slash Detection

Creating the swipe mechanic

Game Scene Tree

Game (Node2D) ← game.gd ├── Camera2D ├── SpawnTimer (Timer) ├── SlashDetector (Node2D) ← NEW SCRIPT HERE ├── Background (Node2D) │ ├── bg_texture (Sprite2D) │ └── ColorRect └── ScoreLabel (Label)

SlashDetector is already in the scene — just needs a script

Right-click SlashDetector → Attach Script → slash_detector.gd

Step 1: Input Handling

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

Step 1: Track Motion

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

Test: Print Swipe Positions

    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!

Step 2: Custom Drawing

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)

The queue_redraw() Gotcha

WARNING: _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."

Run: custom_draw_demo.tscn

custom-draw-demo.mp4
Video showing crosshair following mouse. When checkbox is unchecked, the crosshair freezes in place while the text coordinates continue to update.

Without queue_redraw(), logic updates but visuals freeze.

If your trail isn't showing up today, you forgot to call queue_redraw()!

Drawing the Trail

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

Triggering Redraws

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

Step 3: Collision Check

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()

Wire Into Input

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

3. Build — Fruit Slicing

Making fruits react to the swipe

Step 1: Fill In 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

Step 1: Position the Halves

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!

Test: Slice a Fruit!

slice-halves-demo.mp4
Fruit splitting into two halves that fly apart when swiped — the core slicing mechanic working

Swipe over a fruit — it splits into two falling halves!

The core mechanic is working. Let's add visual juice.

Step 2: Splash Colors with match

Add 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

Step 2: 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)

Step 3: Wire Signals + Score

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

Step 3: Score Handler

Add the handler in game.gd:

func _on_fruit_sliced():
    score += 10
    $ScoreLabel.text = "Score: %d" % score

NEW: String formatting with %

PatternExampleResult
"Score: %d" % scorescore = 30"Score: 30"
"Score: " + str(score)score = 30"Score: 30"

%d = insert an integer — cleaner than str() concatenation

Test: Full Slice Loop

The complete flow:

  1. Swipe over a fruit → check_slice() detects proximity
  2. fruit.slice() → halves fly apart, splash appears
  3. emit_signal("fruit_sliced") → signal fires
  4. _on_fruit_sliced() → score updates

Run it — swipe, slice, splash, score!

4. Swipe Audio

Sound makes everything better

Preload Swipe Sounds

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.

Call on Swipe Start

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!

5. Summary

What we learned today

New Concepts

ConceptWhat You Learned
matchPattern matching — cleaner than if/elif chains
InputEventMouseMotionTrack 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

Current State

Working Now

  • Fruits spawn and fly
  • Swipe draws a fading trail
  • Swipe slices nearby fruits
  • Fruits split into halves
  • Splash effect on slice
  • Score tracks slices
  • Swipe sound plays

Missing (Next Lecture)

  • Splash animations (tweens)
  • Particle effects
  • Bomb slicing + explosions
  • Camera shake
  • Score popups
  • Game over + scene transitions

Next lecture: tweens, particles, bombs, and the full game loop

Exercises

  1. Trail Width: Make the trail thicker for fast swipes and thinner for slow ones (hint: check event.velocity on InputEventMouseMotion)
  2. Slice Sensitivity: Change the distance threshold from 50 to 30 (hard) or 80 (easy) — which feels best?
  3. Score Multiplier: Award more points for slicing multiple fruits in a single swipe (track slices per swipe)
  4. Trail Color: Make the trail change color based on the last fruit sliced (use the splash color)

The Journey So Far

GameKey Concepts
WandererNodes, scripts, input, movement, clamp
RicochetVector2, velocity, bouncing, preload, instantiate
Fruit FrenzyArea2D, collision, signals, groups, Timer, queue_free
Pong@export, editor groups, custom signals, sound
Flappy BirdGravity, state machines, infinite scroll
Fruit Ninja (Part 1)match, gesture input, _draw(), distance_to(), .new()

6 games, 25+ concepts — each building on the last

Questions?

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