CS 470 Game Development

Week 3, Lecture 8

Micro-Game 5: Flappy Bird

Gravity, State Machines, and Infinite Scroll

Today's Goal

Build Flappy Bird

Same nodes as Pong: Area2D, Sprite2D, Timer, Label

Three new patterns: gravity, state machines, infinite scroll

The Starter Project

Pre-Built (You Know This)

  • Scrolling ground
  • Bird rotation visual
  • Die/reset functions
  • Game restart + UI labels
  • Signal connections

Your Job (New Today)

  • Gravity — bird falls
  • Flap — bird jumps
  • Pipe movement — scroll left
  • Pipe spawning — endless pipes
  • Collision & scoring
  • State machine — game flow
  • Sound

Open the starter project in Godot and follow along!

Agenda (~59 minutes)

  1. Starter Tour (5 min)
  2. Scrolling Background (4 min)
  3. Gravity (6 min)
  4. Flap Mechanic (4 min)
  5. Pipe Pairs & Movement (6 min)
  6. Pipe Spawning (5 min)
  7. Collision & Scoring (6 min)
  8. State Machine (10 min)
  9. Menus (5 min)
  10. Sound & Polish (4 min)
  11. Summary + Exercises (4 min)

1. Starter Tour

Let's look at what's already built

Game Scene Tree

Game (Node2D) ├── Background (Node2D) │ ├── BG1 (Sprite2D) │ └── BG2 (Sprite2D) ├── Ground (Node2D) [z_index: 1] │ ├── G1 (Sprite2D) │ ├── G2 (Sprite2D) │ └── GroundCollider (Area2D) ├── Bird (bird.tscn) ├── PipeSpawnTimer (Timer) ├── ScoreLabel (Label) ├── ReadyLabel (Label) ├── GameOverLabel (Label) └── Sound (Node2D) ├── HitSound (AudioStreamPlayer) ├── FlapSound (AudioStreamPlayer) ├── ScoreSound (AudioStreamPlayer) └── Music (AudioStreamPlayer)

All familiar nodes: Node2D, Sprite2D, Area2D, Timer, Label, AudioStreamPlayer

Bird Scene

bird.tscn

Bird (Area2D) ├── Sprite2D └── CollisionShape2D (CircleShape2D)

Same structure as Fruit Frenzy's basket and Pong's ball

Area2D + visual + collision shape = detectable game object

Pipe Pair Scene

pipe_pair.tscn

PipePair (Node2D) ├── TopPipe (Area2D) │ ├── Sprite2D (flipped vertically) │ └── CollisionShape2D ├── BottomPipe (Area2D) │ ├── Sprite2D │ └── CollisionShape2D └── ScoreZone (Area2D) └── CollisionShape2D (thin strip in the gap)

Three Area2Ds: two pipes + one invisible scoring trigger

Groups: "pipe" = death, "score_zone" = +1 point

Pre-Built: Bird (Starter)

bird.gd — what's already there

extends Area2D

const GRAVITY = 800
const FLAP_STRENGTH = -250
const MAX_FALL_SPEED = 400
var velocity = Vector2.ZERO
var alive = true

func _process(delta):
    if not alive:
        return
    # === TODO 1: Gravity ===
    # === TODO 2: Flap ===
    rotation = clamp(velocity.y / 400.0, -0.5, 1.0)

func die():  ...   # Sets alive=false, tilts bird
func reset(): ...  # Resets position, velocity, rotation

Constants, alive flag, rotation, die/reset — all pre-built

Your job: fill in TODOs 1 and 2

2. Scrolling Background

The illusion of movement

The Two-Sprite Trick

How do you scroll an image that never ends?

Place two identical sprites side by side

Both scroll left every frame

When one goes off-screen, wrap it to the right

scroll-diagram.webp
Diagram showing two sprites cycling: BG1 and BG2 side by side, scrolling left, BG1 wraps to the right when it exits the viewport

scrolling_bg.gd

This script is pre-built in the starter:

extends Node2D

@export var scroll_speed = 60

func _process(delta):
    for child in get_children():
        if child is Sprite2D:
            var w = child.texture.get_width()
            child.position.x -= scroll_speed * delta
            if child.position.x <= -w:
                child.position.x += w * 2

Iterates Sprite2D children, scrolls each left, wraps when off-screen

@export lets us set speed per node in the editor

Parallax

Background

scroll_speed = 20

Slow — far away

Ground

scroll_speed = 80

Fast — close to camera

Different speeds = depth illusion

One @export per node: scroll_speed. Wrap width is read from the texture automatically.

Sprites use centered = false so position.x is the left edge

Run It!

Static bird, moving world

The background scrolls slowly behind

The ground rushes past below

The bird sits still — but it looks like it's flying!

3. Gravity

Making things fall

The Gravity Pattern

Gravity = constant acceleration downward

Every frame, three steps:

  1. Accelerate: velocity.y += GRAVITY * delta
  2. Clamp: velocity.y = clamp(velocity.y, ...)
  3. Move: position.y += velocity.y * delta

Same formula used in real physics engines — we're doing it manually

Gravity Visualized

gravity-diagram.webp
Diagram showing velocity increasing frame by frame under constant gravity. Frame 1: v=0, Frame 2: v=50, Frame 3: v=100, etc. Bird falls faster each frame.
Framevelocity.yposition.yEffect
10144Stationary
2+13144.2Starting to fall
3+26144.6Falling faster
10+133155Falling fast!

TODO 1: Gravity

In bird.gd — fill in TODO 1:

func _process(delta):
    if not alive:
        return

    # === TODO 1: Gravity ===
    velocity.y += GRAVITY * delta
    velocity.y = clamp(velocity.y, -MAX_FALL_SPEED, MAX_FALL_SPEED)
    position.y += velocity.y * delta

    # === TODO 2: Flap ===

    rotation = clamp(velocity.y / 400.0, -0.5, 1.0)

3 lines: accelerate → clamp → move

Run the game — the bird falls!

4. Flap Mechanic

Fighting gravity

The Flap Pattern

Flap = instantly set velocity upward

Gravity pulls down (+y) every frame

Flap replaces velocity with a negative value (-y = up)

Then gravity immediately starts pulling down again

This creates the iconic arc: quick rise, slow peak, accelerating fall

TODO 2: Flap

In bird.gd — fill in TODO 2:

    # === TODO 1: Gravity ===
    velocity.y += GRAVITY * delta
    velocity.y = clamp(velocity.y, -MAX_FALL_SPEED, MAX_FALL_SPEED)
    position.y += velocity.y * delta

    # === TODO 2: Flap ===
    if Input.is_action_just_pressed("flap"):
        velocity.y = FLAP_STRENGTH

    rotation = clamp(velocity.y / 400.0, -0.5, 1.0)

2 lines: check input → set velocity

is_action_just_pressed — not is_action_pressed!

Run It!

bird-gravity-demo.webp
Screenshot or animated GIF of bird falling under gravity and flapping upward

Bird falls under gravity and flaps on spacebar

The core Flappy Bird feel, already working!

5. Pipe Pairs & Movement

Obstacles that scroll

Pipe Pair Design

PipePair (Node2D) ├── TopPipe (Area2D) ├── BottomPipe (Area2D) └── ScoreZone (Area2D)
  • TopPipe — death zone (group: "pipe")
  • BottomPipe — death zone (group: "pipe")
  • ScoreZone — invisible trigger in the gap (group: "score_zone")

Move the parent Node2D — all children move together

The ScoreZone Trick

How do we know the bird passed through safely?

An invisible Area2D sits in the gap between pipes

When the bird enters it: +1 point

Then we queue_free() it so it can't score twice

Same collision detection pattern from Fruit Frenzy — just invisible!

TODO 3: Pipe Movement

In pipe_pair.gd — fill in TODO 3:

extends Node2D

var speed = 60
var gap = 80   # Vertical gap between pipes (pixels)

func _ready():
    # Position pipes around the gap center
    $TopPipe.position.y = -gap / 2.0
    $BottomPipe.position.y = 288 + gap / 2.0
    $ScoreZone.position.y = 144

func _process(delta):
    # === TODO 3: Pipe Movement ===
    position.x -= speed * delta
    if position.x < -100:
        queue_free()

3 lines: move left → check off-screen → remove

queue_free() prevents memory buildup from old pipes

Ground as a "Pipe"

Notice the GroundCollider is in the "pipe" group

Same collision handler triggers for both pipe hits and ground hits

One group, two uses — no extra code needed!

This is why groups are powerful: categorize by behavior, not identity

6. Pipe Spawning

Endless obstacles

The Spawning Pattern

Same pattern as Ricochet & Fruit Frenzy:

  1. preload() — load the scene (done once)
  2. .instantiate() — create a copy
  3. Set position — clamp Y within margins & smooth path
  4. add_child() — add to the game

Timer triggers every 2 seconds for steady pipe flow

Pre-built in _ready():

$PipeSpawnTimer.timeout.connect(_on_pipe_spawn_timer_timeout)

Add $PipeSpawnTimer.start() to _ready() for now — we'll move it later

Two Constraints on Pipe Y

  1. Margin clamping — gap must stay ≥ PIPE_MARGIN from viewport top and ground
    • PIPE_Y_MIN = PIPE_MARGIN + PIPE_GAP/2 - 144
    • PIPE_Y_MAX = 280 - PIPE_MARGIN - PIPE_GAP/2 - 144
  2. Smooth path — consecutive pipes can't shift more than MAX_PIPE_SHIFT in Y
    • Use max()/min() to narrow the random range around last_pipe_y

When difficulty increases, raise MAX_PIPE_SHIFT — wider swings = harder

TODO 4: Pipe Spawning

In game.gd — fill in TODO 4:

const PIPE_GAP = 80
const PIPE_MARGIN = 20
const PIPE_Y_MIN = PIPE_MARGIN + PIPE_GAP / 2 - 144
const PIPE_Y_MAX = 280 - PIPE_MARGIN - PIPE_GAP / 2 - 144
const MAX_PIPE_SHIFT = 60

var last_pipe_y = 0.0

func _on_pipe_spawn_timer_timeout():
    var pipe = pipe_scene.instantiate()
    pipe.gap = PIPE_GAP
    pipe.position.x = 550
    var min_y = max(PIPE_Y_MIN, last_pipe_y - MAX_PIPE_SHIFT)
    var max_y = min(PIPE_Y_MAX, last_pipe_y + MAX_PIPE_SHIFT)
    pipe.position.y = randf_range(min_y, max_y)
    last_pipe_y = pipe.position.y
    pipe.add_to_group("pipe_pairs")
    add_child(pipe)

Margin clamp + smooth path — then random within that window

Draw Order & z_index

Problem: add_child(pipe) adds pipes after Ground in the tree

Godot 2D draws children top-to-bottom — later = on top

Pipes draw over the ground!

Fix: Set Ground's z_index = 1 in the Inspector

Pipes default to z_index = 0 → Ground always draws on top

Higher z_index = drawn later, regardless of tree order

The Infinite Scroll Illusion

infinite-scroll-demo.webp
Diagram showing pipes spawning on the right, scrolling left, and being freed on the left — creating an endless stream
ComponentTechnique
BackgroundTwo sprites cycling, speed 20 (pre-built)
GroundTwo sprites cycling, speed 80 (pre-built)
PipesSpawn right, scroll left, free left

Three layers, one illusion: the world moves, the bird stays

7. Collision & Scoring

Pipes kill, gaps score

Collision Table

What happens when the bird hits something?

Bird enters...GroupResult
TopPipe or BottomPipe"pipe"Game Over
GroundCollider"pipe"Game Over
ScoreZone"score_zone"Score +1, free zone

Same is_in_group() pattern from Fruit Frenzy and Pong

TODO 5: Collision Handling

In game.gd — fill in TODO 5:

func _on_bird_area_entered(area):
    # === TODO 5: Collision Handling ===
    if area.is_in_group("pipe"):
        game_over()
    elif area.is_in_group("score_zone"):
        score += 1
        $ScoreLabel.text = str(score)
        $Sound/ScoreSound.play()
        area.queue_free()

6 lines: check group → respond

area.queue_free() on ScoreZone prevents double-scoring

Signal Flow

How the pieces connect:

1. Bird overlaps a ScoreZone Area2D

2. Godot fires area_entered signal on the Bird

3. _on_bird_area_entered(area) runs in game.gd

4. is_in_group("score_zone") → increment score, update label

5. area.queue_free() → remove the ScoreZone

Same signal pattern from Fruit Frenzy — just with groups to distinguish targets

8. State Machine

Managing game flow

The Problem

What happens when you press Space during Game Over?

Without state management:

  • Space triggers flap AND restart simultaneously
  • Bird flaps during the "Press Space to Start" screen
  • Pipes spawn before the game starts

We need a way to say: "what does Space do right now?"

State Diagram

MENU
[Space]
COUNTDOWN
[3s]
PLAYING
PLAYING
[Hit]
DYING
[1s]
SCORE
[Space]
MENU

Five states, five transitions — each state defines what happens on screen

The enum

Define all possible states at the top of game.gd:

enum State { MENU, COUNTDOWN, PLAYING, DYING, SCORE }
var current_state = State.MENU
NameValuePurpose
State.MENU0"Press Space to Start"
State.COUNTDOWN13-2-1 countdown
State.PLAYING2Active gameplay
State.DYING3Freeze for 1 second
State.SCORE4Show final score

Named constants are clearer than magic numbers: State.PLAYING vs 2

State-Based Logic

Check the current state and act accordingly:

if current_state == State.MENU:
    # what to do in MENU state
elif current_state == State.COUNTDOWN:
    # countdown runs itself via await
elif current_state == State.PLAYING:
    # what to do in PLAYING state
elif current_state == State.DYING:
    # freeze runs itself via await
elif current_state == State.SCORE:
    # what to do in SCORE state

Every frame, check the current state and act accordingly

Same if/elif pattern you already know from Python!

TODO 6: State Machine

In game.gd — fill in TODO 6:

func _process(_delta):
    # === TODO 6: State Machine ===
    if current_state == State.MENU:
        if Input.is_action_just_pressed("flap"):
            start_countdown()
    elif current_state == State.COUNTDOWN:
        pass  # Countdown runs itself via await
    elif current_state == State.PLAYING:
        pass  # Bird and pipes update themselves
    elif current_state == State.DYING:
        pass  # Freeze runs itself via await
    elif current_state == State.SCORE:
        if Input.is_action_just_pressed("flap"):
            restart()

11 lines: check state → handle input per state

Two states need input (MENU, SCORE), three run automatically!

Transition Functions

Each transition changes state and updates the game:

func start_countdown():     # MENU -> COUNTDOWN
    current_state = State.COUNTDOWN
    # ... 3-2-1 with await, then start_game()

func start_game():           # COUNTDOWN -> PLAYING
    current_state = State.PLAYING
    $Bird.alive = true
    $PipeSpawnTimer.start()

func game_over():            # PLAYING -> DYING
    current_state = State.DYING
    # ... freeze everything, then show_score_screen()

func show_score_screen():    # DYING -> SCORE
    current_state = State.SCORE

func restart():              # SCORE -> MENU (via show_menu)
    # ... clean up, reset bird, unfreeze, show_menu()

These are already pre-built in the starter!

Now remove $PipeSpawnTimer.start() from _ready()start_game() handles it

Uncomment show_menu() in _ready() — the state machine now controls the game flow

Why State Machines Matter

Without State MachineWith State Machine
Scattered if checks everywhereOne if/elif block routes everything
Hard to add new statesAdd a new enum value + elif case
Input conflicts between phasesEach state handles input independently
Bugs when states overlapOnly one state active at a time

State machines appear in every game: menus, player states, enemy AI

9. Menus

Extending the state machine

The Full Menu Flow

StateWhat HappensTransition
MENU"Press Space to Start" labelSpace → COUNTDOWN
COUNTDOWNShows 3, 2, 1 on screenAuto after 3s → PLAYING
PLAYINGNormal gameplayHit pipe/ground → DYING
DYINGAll movement frozenAuto after 1s → SCORE
SCORE"Score: X" + continue promptSpace → MENU

Two states need input, three transition automatically

Countdown: await

Pre-built in the starter — start_countdown():

func start_countdown():
    current_state = State.COUNTDOWN
    $ReadyLabel.text = "3"
    await get_tree().create_timer(1.0).timeout
    $ReadyLabel.text = "2"
    await get_tree().create_timer(1.0).timeout
    $ReadyLabel.text = "1"
    await get_tree().create_timer(1.0).timeout
    $ReadyLabel.visible = false
    start_game()

await pauses the function until the timer fires

Reuses $ReadyLabel — no new scene nodes needed!

Dying & Score Screen

Pre-built in the starter — game_over() and show_score_screen():

func game_over():
    current_state = State.DYING
    $Bird.die()
    $PipeSpawnTimer.stop()
    $Sound/HitSound.play()
    $Background.set_process(false)    # Freeze!
    $Ground.set_process(false)        # Freeze!
    get_tree().call_group("pipe_pairs", "set_process", false)
    await get_tree().create_timer(1.0).timeout
    show_score_screen()

func show_score_screen():
    current_state = State.SCORE
    $GameOverLabel.text = "Score: " + str(score) + \
        "\nPress Space to Continue"
    $GameOverLabel.visible = true

set_process(false) stops _process() — everything freezes in place

After 1s delay, show score on $GameOverLabel

10. Sound & Polish

The finishing touch

TODO 7: Hit Sound

In game.gd — already included in game_over():

func game_over():
    current_state = State.DYING
    $Bird.die()
    $PipeSpawnTimer.stop()
    $Sound/HitSound.play()            # <-- Hit sound
    $Background.set_process(false)
    $Ground.set_process(false)
    get_tree().call_group("pipe_pairs", "set_process", false)
    await get_tree().create_timer(1.0).timeout
    show_score_screen()

1 line: $Sound/HitSound.play()

Same AudioStreamPlayer pattern from Pong!

Sound Summary

SoundWhenWhere in Code
HitSoundBird hits pipe/groundgame_over() — TODO 7
ScoreSoundBird passes through gap_on_bird_area_entered() — TODO 5
FlapSoundBird flaps (exercise)bird.gd flap section

The Complete Game

flappy-final-demo.webp
Screenshot of the complete Flappy Bird game with scrolling background, pipes, score display, and bird

Let's play — who can beat score 5?

11. Summary

What we learned today

New Concepts

ConceptWhat You Learned
Gravityvelocity += acceleration * delta, then position += velocity * delta
FlapInstantly replace velocity with upward value
Infinite scrollTwo-sprite cycling for BG/ground; spawn-move-free for pipes
State machineenum + if/elif to route input per game phase
ScoreZoneInvisible Area2D as a trigger + queue_free() to prevent repeats

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

5 games, 20+ concepts — all building on each other

Concept Map

Wanderer Nodes / Scenes Sprite2D _process(delta) Input polling clamp() Ricochet Vector2 Velocity Bouncing preload() instantiate() Fruit Frenzy Area2D CollisionShape2D Signals (built-in) Groups queue_free() / Timer Pong @export Custom signals AudioStreamPlayer Flappy Bird Gravity (manual physics) State machines (enum) Infinite scrolling

Exercises

  1. Flap Sound: Add get_parent().get_node("Sound/FlapSound").play() in bird.gd's flap section
  2. Difficulty Tuning: Try GRAVITY=600/1000, FLAP_STRENGTH=-200/-300, change PIPE_GAP to 60 or 100
  3. Score-Based Speed: Every 5 points, increase pipe speed +10 and decrease spawn timer -0.1s
  4. High Score: Track best score across restarts, show on Game Over

What's Next

What you know

  • 2D movement and physics
  • Area2D + collision detection
  • Signals for game events
  • Score tracking with labels
  • Sound effects
  • State machines

Coming up: Mario

  • CharacterBody2D — built-in physics
  • TileMap — level design
  • Camera2D — scrolling viewport
  • AnimatedSprite2D — character animation

From manual physics to built-in physics. Let's go!

Questions?

Try the exercises — especially the Pause State challenge

Can you add a PAUSED state to the state machine?

Challenge: beat score 10!

Office hours: [Your time here]