Gravity, State Machines, and Infinite Scroll
Build Flappy Bird
Same nodes as Pong: Area2D, Sprite2D, Timer, Label
Three new patterns: gravity, state machines, infinite scroll
Open the starter project in Godot and follow along!
Let's look at what's already built
All familiar nodes: Node2D, Sprite2D, Area2D, Timer, Label, AudioStreamPlayer
bird.tscn
Same structure as Fruit Frenzy's basket and Pong's ball
Area2D + visual + collision shape = detectable game object
pipe_pair.tscn
Three Area2Ds: two pipes + one invisible scoring trigger
Groups: "pipe" = death, "score_zone" = +1 point
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
The illusion of movement
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
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
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
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!
Making things fall
Gravity = constant acceleration downward
Every frame, three steps:
velocity.y += GRAVITY * deltavelocity.y = clamp(velocity.y, ...)position.y += velocity.y * deltaSame formula used in real physics engines — we're doing it manually
| Frame | velocity.y | position.y | Effect |
|---|---|---|---|
| 1 | 0 | 144 | Stationary |
| 2 | +13 | 144.2 | Starting to fall |
| 3 | +26 | 144.6 | Falling faster |
| 10 | +133 | 155 | Falling fast! |
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!
Fighting gravity
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
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!
Bird falls under gravity and flaps on spacebar
The core Flappy Bird feel, already working!
Obstacles that scroll
"pipe")"pipe")"score_zone")Move the parent Node2D — all children move together
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!
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
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
Endless obstacles
Same pattern as Ricochet & Fruit Frenzy:
preload() — load the scene (done once).instantiate() — create a copyadd_child() — add to the gameTimer 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
PIPE_MARGIN from viewport top and ground
PIPE_Y_MIN = PIPE_MARGIN + PIPE_GAP/2 - 144PIPE_Y_MAX = 280 - PIPE_MARGIN - PIPE_GAP/2 - 144MAX_PIPE_SHIFT in Y
max()/min() to narrow the random range around last_pipe_yWhen difficulty increases, raise MAX_PIPE_SHIFT — wider swings = harder
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
z_indexProblem: 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
| Component | Technique |
|---|---|
| Background | Two sprites cycling, speed 20 (pre-built) |
| Ground | Two sprites cycling, speed 80 (pre-built) |
| Pipes | Spawn right, scroll left, free left |
Three layers, one illusion: the world moves, the bird stays
Pipes kill, gaps score
What happens when the bird hits something?
| Bird enters... | Group | Result |
|---|---|---|
| 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
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
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
Managing game flow
What happens when you press Space during Game Over?
Without state management:
We need a way to say: "what does Space do right now?"
Five states, five transitions — each state defines what happens on screen
enumDefine all possible states at the top of game.gd:
enum State { MENU, COUNTDOWN, PLAYING, DYING, SCORE }
var current_state = State.MENU
| Name | Value | Purpose |
|---|---|---|
State.MENU | 0 | "Press Space to Start" |
State.COUNTDOWN | 1 | 3-2-1 countdown |
State.PLAYING | 2 | Active gameplay |
State.DYING | 3 | Freeze for 1 second |
State.SCORE | 4 | Show final score |
Named constants are clearer than magic numbers: State.PLAYING vs 2
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!
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!
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
| Without State Machine | With State Machine |
|---|---|
Scattered if checks everywhere | One if/elif block routes everything |
| Hard to add new states | Add a new enum value + elif case |
| Input conflicts between phases | Each state handles input independently |
| Bugs when states overlap | Only one state active at a time |
State machines appear in every game: menus, player states, enemy AI
Extending the state machine
| State | What Happens | Transition |
|---|---|---|
| MENU | "Press Space to Start" label | Space → COUNTDOWN |
| COUNTDOWN | Shows 3, 2, 1 on screen | Auto after 3s → PLAYING |
| PLAYING | Normal gameplay | Hit pipe/ground → DYING |
| DYING | All movement frozen | Auto after 1s → SCORE |
| SCORE | "Score: X" + continue prompt | Space → MENU |
Two states need input, three transition automatically
awaitPre-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!
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
The finishing touch
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 | When | Where in Code |
|---|---|---|
| HitSound | Bird hits pipe/ground | game_over() — TODO 7 |
| ScoreSound | Bird passes through gap | _on_bird_area_entered() — TODO 5 |
| FlapSound | Bird flaps (exercise) | bird.gd flap section |
Let's play — who can beat score 5?
What we learned today
| Concept | What You Learned |
|---|---|
| Gravity | velocity += acceleration * delta, then position += velocity * delta |
| Flap | Instantly replace velocity with upward value |
| Infinite scroll | Two-sprite cycling for BG/ground; spawn-move-free for pipes |
| State machine | enum + if/elif to route input per game phase |
| ScoreZone | Invisible Area2D as a trigger + queue_free() to prevent repeats |
| 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 |
5 games, 20+ concepts — all building on each other
get_parent().get_node("Sound/FlapSound").play() in bird.gd's flap sectionPIPE_GAP to 60 or 100From manual physics to built-in physics. Let's go!
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]