CS 470 Game Development

Week 5, Lecture 14

Juice & Game Loop — Fruit Ninja Part 2

Tweens, Particles, Bombs & Autoloads

Today's Goal

Add juice & build the complete game loop

Last lecture: we sliced fruit and detected collisions

Today: animations, particles, bombs, and scene management

From playable but flat → fully juiced game with game-over state

Recap: Where We Left Off

Lecture 13

  • match statement
  • Gesture input
  • Trail collision
  • String formatting
  • Simple slash effect

Your Code Now

  • Can slice fruit ✓
  • Score updates ✓
  • Basic sound ✓
  • BUT: no animations
  • No bomb mechanics
  • No game over
before-after-juice.png
Side-by-side comparison: Left = flat game (no animations), Right = juiced game (tweens, particles, bomb effects)

Today: we turn the left into the right.

Agenda (~50 minutes)

  1. Demos: Dynamic Nodes & Autoloads (8 min)
  2. Build Tweens / "Juice" (12 min)
  3. Build Particles (8 min)
  4. Build Bombs (13 min)
  5. Multi-Scene Game Loop (7 min)
  6. Wrap-up & Exercises (2 min)

Block 1: Demos — New Concepts

Before we code, let's see what's possible

Demo 1: Dynamic Nodes .new()

You already know .instantiate()

Loads a saved .tscn scene

NEW: .new() creates a node purely from code

No .tscn file needed

Perfect for temporary effects: particles, labels, lines, sounds

When to use:

  • .instantiate() = complex saved scenes (Fruit, Bomb)
  • .new() = simple dynamic objects (Label, AudioStreamPlayer)

Example: Creating an AudioStreamPlayer

var audio = AudioStreamPlayer.new()
audio.stream = load("res://assets/sounds/slash.wav")
audio.volume_db = -5
add_child(audio)
audio.play()

Steps:

  1. Create instance: .new()
  2. Set properties: stream, volume_db
  3. Add to scene tree: add_child()
  4. Start playing: .play()

Same pattern for Label, Line2D, GPUParticles2D, Sprite2D…

Dynamic Nodes in Fruit Ninja

  • Label: Score popup "+10" (splash animation)
  • AudioStreamPlayer: Fuse sound, explosion sound
  • GPUParticles2D: Star burst on slice
  • Line2D: Bomb rays during detonation

All created with .new() because they're temporary visual effects

Run the Demo: dynamic_nodes_demo.tscn

Click anywhere on screen

Colored circles spawn where you click

Each shrinks and fades, then vanishes

That shrink-fade effect? Created with tweens. (More on that in 3 slides.)

Demo 2: Autoloads (Singletons)

Problem: Score resets when changing scenes

Each scene is a fresh node tree — all variables reset

Solution: Autoload — a persistent singleton

Project Settings → Autoload tab → add script

That node lives forever, persists across all scene changes

Accessed by name: Global.current_score

How Autoload Works

  1. Create script: global.gd
  2. Add to Project Settings → Autoload
  3. Godot automatically instantiates it on startup
  4. Access anywhere: Global.current_score
  5. Never unloads — persists across all scene changes

Like a global variable, but better: it's a real node with methods

Autoload in Fruit Ninja

Global

  • current_score
  • reset_score()
  • set_score(score)
  • get_score()

MusicManager

  • Persistent music
  • Plays once on startup
  • Never stops
  • Survives scene changes

Both autoloads: set up once, used everywhere

Autoload Persistence Concept

autoload-diagram.png
Diagram showing autoload node at the top, persisting across three scenes below it: main_menu, game, score_screen. Arrows show scene transitions while autoload remains constant.

The autoload (Global) never unloads, even as scenes change below it

Run the Demo: autoload_demo.tscn

Press the button to increment a counter

Counter increments on-screen

Now press: "Change Scene" button

The counter persists in the new scene!

That's an autoload preserving state across scene changes.

Demo 3: The Magic of await

Recap: We used await audio.finished in Lecture 13

What does await actually do?

In many languages, a "sleep" or "wait" command freezes the entire program.

In Godot, await is asynchronous.

CRITICAL RULE

await pauses THIS SPECIFIC FUNCTION until a signal fires.

The rest of the game (drawing, physics, `_process`) keeps running perfectly!

Run the Demo: await_demo.tscn

await-demo.mp4
Video showing an apple sprite spinning continuously. The user clicks "Wait 2 Seconds", the screen flashes red and says "Waiting...", but the apple never stops spinning. After 2 seconds, it flashes green and says "Done!"

The function was paused for 2 seconds.

But the Apple sprite kept spinning in _process()!

How to use await

Syntax: await [object].[signal]

# 1. Wait for a sound to finish
await audio.finished

# 2. Wait for an animation/tween to finish
await tween.finished

# 3. Wait for an exact amount of time (super useful!)
await get_tree().create_timer(2.5).timeout

We will use ALL of these today to choreograph the bomb sequence.

Block 2: Build Tweens / "Juice"

Bringing animations to life

What is a Tween?

Animate any property from A → B over time

Manual: if time < 0.3: scale += (1/30) * delta

With tweens: create_tween().tween_property(fruit, "scale", Vector2(1,1), 0.3)

Tweens handle the math. You describe the endpoint.

Tween Basics

var tween = create_tween()
tween.tween_property(node, "scale", Vector2(1, 1), 0.3)
    .from(Vector2(0.1, 0.1))
    .set_trans(Tween.TRANS_BOUNCE)
    .set_ease(Tween.EASE_OUT)

Key methods:

  • tween_property(node, prop, target, duration) — animate a property
  • .from(start_value) — override starting value
  • .set_trans(curve) — transition curve (SINE, BOUNCE, LINEAR, CUBIC, etc.)
  • .set_ease(direction) — IN, OUT, or IN_OUT

Transition Curves

tween-curves.png
Visual diagram showing BOUNCE, SINE, and LINEAR curves: x-axis time, y-axis value. BOUNCE overshoots then settles. SINE is smooth. LINEAR is straight.

Curves control feel. Choose wisely:

  • BOUNCE: Playful pop-in (spawn animations)
  • SINE: Smooth, organic (fades, floats)
  • LINEAR: Mechanical (scrolling, steady movement)

Tween: Parallel & Sequences

Sequential:

tween.tween_property(
  label, "scale", 
  Vector2(1, 1), 0.2)
tween.tween_property(
  label, "position", 
  pos - Vector2(0, 50), 0.5)

Scale, then move

Parallel:

var tween = create_tween()
tween.set_parallel(true)
tween.tween_property(
  label, "scale",
  Vector2(1, 1), 0.2)
tween.tween_property(
  label, "position",
  pos - Vector2(0, 50), 0.5)

Scale and move together

Or use .parallel() method on individual properties

Tweening ANY Property

Tweens aren't just for nodes and sprites!

You can tween any variable on any object.

# Example: Animate a score variable from 0 to 1000
var tween = create_tween()
tween.tween_property(self, "current_score", 1000, 2.0)

If current_score has a setter function that updates a label, you instantly get a smooth rolling score counter!

Run the Demo: tween_demo.tscn

tween-demo.mp4
Video showing an interactive panel with 5 buttons. Clicking the buttons shows an apple sprite moving across the screen linearly, bouncing, sequentially (move then fade), and in parallel (move while fading). The final button shows a score counter rolling rapidly from 0 to 1000.

Click the buttons to see exactly how transition curves and parallel logic behave in real-time.

Step 1: Spawn Animation

In game.gd, spawn_fruit() function:

add_child(fruit)

# NEW: Spawn animation
var tween = create_tween()
tween.tween_property(fruit, "scale", Vector2(1, 1), 0.3)\
    .from(Vector2(0.1, 0.1))\
    .set_trans(Tween.TRANS_BOUNCE)\
    .set_ease(Tween.EASE_OUT)

Create tween, animate scale from 0.1 to 1.0 over 0.3 seconds

BOUNCE curve with OUT easing = playful pop-in

Test: Run game, fruits pop in with a bounce

Step 2: Splash Effect Tween

In slash_detector.gd, create_fruit_splash():

func create_fruit_splash(pos: Vector2):
    var splash = Sprite2D.new()
    splash.texture = preload("res://assets/images/splash_01.png")
    splash.position = pos
    splash.z_index = 100
    add_child(splash)
    
    # Tween the splash
    var tween = create_tween()
    tween.set_parallel(true)  # All properties at once
    tween.tween_property(splash, "scale", Vector2(1.2, 1.2), 0.4)\
        .from(Vector2(0.5, 0.5))\
        .set_trans(Tween.TRANS_BOUNCE)\
        .set_ease(Tween.EASE_OUT)
    tween.tween_property(splash, "modulate:a", 0.0, 0.4)\
        .set_trans(Tween.TRANS_SINE)\
        .set_ease(Tween.EASE_IN)
    
    # Cleanup when done
    await tween.finished
    splash.queue_free()

Anatomy: Splash Tween

  1. Scale: 0.5 → 1.2 (pop out) with BOUNCE
  2. Alpha: 1.0 → 0.0 (fade) with SINE
  3. Parallel: Both happen simultaneously
  4. Cleanup: await tween.finished then queue_free()

Result: splash appears, grows and fades, disappears

No manual cleanup code needed; tween handles it

Step 3: Score Popup

In game.gd:

func _on_fruit_sliced():
    score += 10
    score_label.text = "Score: %d" % score
    
    # NEW: Score popup
    score_popup(get_global_mouse_position())

func score_popup(pos: Vector2):
    var label = Label.new()
    label.text = "+10"
    label.add_theme_font_size_override("font_size", 48)
    label.position = pos
    label.z_index = 1000
    add_child(label)
    
    var tween = create_tween()
    # Bounce in: scale 0.1 -> 1.0
    tween.tween_property(label, "scale", Vector2(1, 1), 0.3)\
        .from(Vector2(0.1, 0.1))\
        .set_trans(Tween.TRANS_BOUNCE)\
        .set_ease(Tween.EASE_OUT)
    
    # Then parallel: float up + fade
    tween.set_parallel(true)
    tween.tween_property(label, "position", pos - Vector2(0, 50), 0.6)\
        .set_trans(Tween.TRANS_SINE)\
        .set_ease(Tween.EASE_OUT)
    tween.tween_property(label, "modulate:a", 0.0, 0.6)\
        .set_trans(Tween.TRANS_SINE)\
        .set_ease(Tween.EASE_IN)
    
    await tween.finished
    label.queue_free()

Score Popup: Breaking It Down

Creation:

  • Label.new() — create label from code
  • Set text, font size, position, z_index
  • add_child() — add to scene

Animation (2-stage):

  • Stage 1: Bounce in (0.3s)
  • Stage 2: Float up + fade (0.6s, parallel)

Cleanup:

  • await tween.finished
  • queue_free()

Test: Slice fruit → "+10" pops up, bounces, floats, fades

Block 3: Build Particles

Burst effects for slices

What is a Particle System?

GPUParticles2D

Instead of manually moving 100 sprites, we let the GPU do it.

We provide a Material that defines the rules:

  • Spread: Do they shoot in a line (0°) or a circle (360°)?
  • Gravity: Are they pulled down over time?
  • One-Shot: Do they explode all at once, or stream continuously?

Run the Demo: particle_demo.tscn

particle-demo-controls.mp4
Video showing the particle demo scene. Clicking "Continuous Fountain" streams stars upward. "One-Shot Burst" explodes them in a circle. "Zero Gravity" explodes them but they drift without falling. "Final Fruit Burst" shows the exact effect we will code.

Notice how changing just 3 variables completely alters the visual effect.

Static Functions (NEW CONCEPT)

A function that belongs to the class, not an instance

Normal function: fruit.slice() — called on an object

Static function: ParticleBurst.create_fruit_burst() — called on the class

# Regular function (instance method)
func slice(): ...
fruit.slice()

# Static function (class method)
static func create_fruit_burst(parent, pos):
    ...
ParticleBurst.create_fruit_burst(game, Vector2(100, 200))

Why Static Functions?

Particle bursts are:

  • Temporary effects (created, play, destroyed)
  • Reusable (fruit slice, bomb detonation)
  • Not tied to a single object

Static function benefits:

  • Called anywhere: no instance needed
  • Factory pattern: "create this effect at this location"
  • Cleaner than passing around particle configs

Create: particle_burst.gd (Part 1)

New file: scripts/particle_burst.gd

extends Node2D

static func create_fruit_burst(parent: Node, pos: Vector2):
    """Create a sparkle burst for fruit slices"""
    var particles = GPUParticles2D.new()
    particles.texture = preload("res://assets/images/star_01.png")
    particles.position = pos
    particles.z_index = 50
    parent.add_child(particles)
    
    # Configure particle behavior
    var material = ParticleProcessMaterial.new()
    material.direction = Vector3(1, 0, 0)      # Emission axis
    material.spread = 180.0                     # 180° = full circle
    material.particle_flag_disable_z = true    # Stay in 2D
    
    material.initial_velocity_min = 100.0
    material.initial_velocity_max = 250.0
    material.angular_velocity_min = -360.0
    material.angular_velocity_max = 360.0
    material.damping_min = 50.0
    material.damping_max = 100.0
    material.scale_min = 0.5
    material.scale_max = 1.5
    material.gravity = Vector3(0, 200, 0)
    
    particles.process_material = material
    particles.lifetime = 1.0
    particles.amount = 30
    particles.explosiveness = 1.0
    particles.one_shot = true
    particles.emitting = true
    
    # Auto-cleanup
    await particles.finished
    particles.queue_free()

GPUParticles2D: Key Properties

  • texture — image for each particle
  • process_material — ParticleProcessMaterial with behavior
  • amount — number of particles (30 for fruit, 100 for bomb)
  • lifetime — how long particles live
  • one_shot = true — emit all at once
  • explosiveness = 1.0 — emit all immediately
  • emitting = true — start emitting

Material properties control physics:

  • direction, spread — emission shape
  • initial_velocity_min/max — speed range
  • angular_velocity — spin
  • gravity — pull downward
  • damping — air friction

Create: particle_burst.gd (Part 2 — Bomb Burst)

Add to particle_burst.gd:

static func create_bomb_burst(parent: Node, pos: Vector2):
    """Create a large explosive burst for bomb detonation"""
    var particles = GPUParticles2D.new()
    particles.texture = preload("res://assets/images/star_01.png")
    particles.position = pos
    particles.z_index = 100  # Higher than fruit burst
    parent.add_child(particles)
    
    var material = ParticleProcessMaterial.new()
    material.direction = Vector3(1, 0, 0)
    material.spread = 180.0
    material.particle_flag_disable_z = true
    
    # MORE intense than fruit burst
    material.initial_velocity_min = 300.0      # Faster
    material.initial_velocity_max = 500.0
    material.angular_velocity_min = -720.0
    material.angular_velocity_max = 720.0
    material.damping_min = 100.0
    material.damping_max = 200.0
    material.scale_min = 1.0
    material.scale_max = 2.5                    # Bigger
    material.gravity = Vector3(0, 300, 0)      # More gravity
    
    particles.process_material = material
    particles.lifetime = 1.5                    # Longer
    particles.amount = 100                      # More particles
    particles.explosiveness = 1.0
    particles.one_shot = true
    particles.emitting = true
    
    await particles.finished
    particles.queue_free()

Wire Particles: Fruit Slicing

In fruit.gd, in the slice() function:

func slice(slash_pos: Vector2):
    # ... existing code ...
    
    # Emit particle burst at slash position
    var ParticleBurst = preload("res://scripts/particle_burst.gd")
    ParticleBurst.create_fruit_burst(get_parent(), slash_pos)

preload() loads the script, ParticleBurst.create_fruit_burst() calls the static method

get_parent() is the game node (parent of all fruits)

Test: Slice fruit → stars burst outward

Particle Burst Demo

particle-burst-demo.mp4
Video: Player slices a fruit, and colorful star particles burst outward in all directions and fall downward, fading out as they fall

That's the fruit burst effect — instant visual feedback for successful slices

Block 4: Build Bombs

Game over with style

Step 1: Bomb Script Basics

In bomb.gd (fill in stubs):

func play_fuse_sound():
    fuse_audio = AudioStreamPlayer.new()
    fuse_audio.stream = preload("res://assets/sounds/bomb-fuse.wav")
    fuse_audio.volume_db = -10
    add_child(fuse_audio)
    fuse_audio.play()

func slice(slash_pos: Vector2):
    if is_sliced:
        return
    
    is_sliced = true
    
    # Stop fuse sound
    if fuse_audio:
        fuse_audio.stop()
    
    # Stop movement
    velocity = Vector2.ZERO
    
    # Signal to game: bomb was sliced
    emit_signal("bomb_sliced", self)

func detonate():
    # Swap to explosion texture
    texture = explosion_texture
    
    # Play explosion sound
    play_explosion_sound()
    
    # Particle burst
    var ParticleBurst = preload("res://scripts/particle_burst.gd")
    ParticleBurst.create_bomb_burst(get_parent(), global_position)

Typed Signals (Brief Intro)

# At top of bomb.gd
signal bomb_sliced(bomb: Node2D)

This signal passes the bomb node itself as an argument

Used in game.gd to react: emit_signal("bomb_sliced", self)

In game.gd connect: bomb.bomb_sliced.connect(func(b): game_over())

Step 2: Add Bomb Spawning

In game.gd:

const BOMB_SPAWN_CHANCE = 0.15  # 15% chance

func spawn_fruit():
    # Roll for bomb
    if randf() < BOMB_SPAWN_CHANCE:
        spawn_bomb()
        return
    
    # ... existing fruit spawn code ...

func spawn_bomb():
    var bomb = bomb_scene.instantiate()
    bomb.position = Vector2(randf_range(100, 1180), -50)
    add_child(bomb)
    
    # Connect signal
    bomb.bomb_sliced.connect(_on_bomb_sliced)

15% of the time, spawn bomb instead of fruit

Connect the signal to handle bomb detonation

Step 3: Camera Shake (Concept)

Random offset on camera each frame

var is_shaking = false
var shake_intensity = 0.0

func _process(delta):
    if is_shaking:
        camera_2d.offset = \
            Vector2(randf_range(-shake_intensity, shake_intensity),
                    randf_range(-shake_intensity, shake_intensity))

That's it! Random offset makes the screen jitter

Control intensity to feel impact (0 = steady, 20 = wild)

Step 4: Execute Bomb Sequence

In game.gd:

func _on_bomb_sliced(bomb: Node2D):
    # Stop game immediately
    spawn_timer.stop()
    
    # Run the explosion sequence
    await execute_bomb_sequence()
    
    # Save score and end game
    Global.set_score(score)
    get_tree().change_scene_to_file("res://scenes/score_screen.tscn")

func execute_bomb_sequence():
    # T=0: Start camera shake
    is_shaking = true
    shake_intensity = 8.0
    
    # T=0 to 2: Shoot rays at intervals
    for i in range(5):
        create_ray()
        await create_timer(0.4).timeout
    
    # T=2: Bomb detonates
    bomb.detonate()
    shake_intensity = 12.0
    
    # Wait a bit
    await create_timer(2.5).timeout
    
    # Stop shake
    is_shaking = false
    camera_2d.offset = Vector2.ZERO

Bomb Sequence Timeline

Time Event Intensity
T=0s Slice bomb, timer stops
T=0s Camera shake starts 8
T=0–2s Rays shoot (5×, 0.4s apart) 8
T=2s Bomb detonates 12 (increase)
T=2.5s Shake stops, camera resets 0
T=4.5s Scene changes to score screen

Choreographed chaos: each moment timed for maximum impact

Step 5: Rays (Reveal & Explain)

This code is pre-written in the final project

Read it to understand the pattern

Rays are Line2D nodes created dynamically

Animate with tween: start point → extended point (with parallax offset)

Parallel tween: fade out alpha while extending

Lambda cleanup on tween.finished

Rays combine: .new(), tweens, trigonometry (cos/sin/TAU)

This is "bonus content" — study in final code if interested

Bomb Sequence Demo

bomb-sequence-demo.mp4
Video: Player slices a bomb, screen shakes, rays shoot outward, explosion texture appears with particle burst, camera shakes intensely, then stabilizes before scene transition

Full 4.5-second game-over sequence — dramatic, but under complete control

Block 5: Multi-Scene Game Loop

Wiring the complete flow

Create Autoloads

New: scripts/global.gd

extends Node

var current_score = 0

func reset_score():
    current_score = 0

func set_score(score: int):
    current_score = score

func get_score() -> int:
    return current_score

Simple but essential: holds score across scenes

Register Autoloads

Project Settings → Autoload tab

  1. Add Node Path: res://scripts/global.gd
  2. Set Node Name: Global
  3. Click Add

Now use it anywhere: Global.current_score

Godot instantiates autoloads automatically on startup

Wire: main_menu.gd

extends Control

func _ready():
    # Reset score on menu open
    Global.reset_score()
    
    # Connect play button
    $PlayButton.pressed.connect(_on_play_pressed)

func _on_play_pressed():
    get_tree().change_scene_to_file("res://scenes/game.tscn")

On ready: reset global score and connect button

Button press: change to game scene

Wire: score_screen.gd

extends Control

func _ready():
    # Display final score from Global
    $ScoreLabel.text = "Final Score: %d" % Global.get_score()
    
    # Connect buttons
    $RestartButton.pressed.connect(_on_restart)
    $MenuButton.pressed.connect(_on_menu)

func _on_restart():
    get_tree().change_scene_to_file("res://scenes/game.tscn")

func _on_menu():
    get_tree().change_scene_to_file("res://scenes/main_menu.tscn")

On ready: display score from Global, connect buttons

Restart: go to game. Menu: go to main menu.

Scene Flow

scene-flow-diagram.png
Diagram with three boxes: "main_menu.tscn" → "game.tscn" → "score_screen.tscn", with arrows showing flow. Restart arrow from score_screen back to game. Menu arrow back to main_menu.

Full game loop:

  1. Player starts at main menu
  2. Clicks Play → Game starts
  3. Slices fruit, scores points
  4. Hits bomb → Game over sequence
  5. Shows final score
  6. Can restart or return to menu

Set Main Scene

Project Settings → General → Main Scene

Set to: res://scenes/main_menu.tscn

When you run the game, it starts here

Test Full Flow

Press F5 (Run Project)

✓ Main menu appears

✓ Click Play → Game starts

✓ Slice fruits, score increases

✓ Hit bomb → Explosion, game over

✓ Score screen shows final score (from Global!)

✓ Click Restart → Back to game (score reset)

✓ Click Menu → Back to main menu

Wrap-up & Summary

What we built today

Concept Recap: Week 5

Lecture 13 Lecture 14 Lecture 15
match ✓ Tweens Extensions
✓ Gesture input ✓ Particles & Polish
✓ Trail collision ✓ Static functions
✓ String format ✓ Autoloads
✓ Simple effects ✓ Scene transitions

14 new concepts in 2 lectures → Complete playable game

Today's Build: From Code → Game

What We Wrote

  • Tween animations (4×)
  • Particle burst system
  • Bomb mechanics
  • Camera shake
  • Scene management
  • Autoloads

Game Now Has

  • Bouncy spawn effects
  • Juice: splash, popups
  • Particle bursts
  • Bomb detonation
  • Game over state
  • Persistent score
  • Full menu loop

~350 lines of code total (starter + our builds)

Next: Lecture 15 (Activity)

Pair programming challenges

Extend the game with new features:

  • Difficulty scaling (faster fruits over time)
  • Combo counter (bonus points for rapid slices)
  • Multiple lives / mini-bombs
  • Sound volume control
  • High score persistence

You have all the tools — now customize!

Exercises

Try these on your own:

  1. Enhance splash animation: Add rotation to splash sprite during tween
  2. Score combo: Track slices without bomb, reset on bomb. Multiply points by combo.
  3. Visual feedback: Change bomb color 1 second before detonation
  4. Sound polish: Add "warning beep" loop to fuse sound
  5. Difficulty: Spawn fruit faster as score increases

From Empty Screen → Complete Game

Two lectures, three new game mechanics

L13: Slash detection, scoring, match statements

L14: Juice, particles, bombs, full game loop

You now understand game feel, polish, and state management.