Putting It All Together
Build a complete 2-player Pong game
Reuse everything from Wanderer, Ricochet, and Fruit Frenzy
Plus new concepts: @export, Editor Groups, Custom Signals, Sound
@export for Player 2 controlsOpen the starter project in Godot and follow along!
@export — Inspector-Editable Variables (6 min)Let's look at what's already built
Two instances of the same paddle scene — like spawning logos in Ricochet!
paddle.tscn
Same structure as Fruit Frenzy's Basket — Area2D + shape + visual
Used twice in the game scene: Player1 and Player2
ball.tscn
Same structure as Fruit Frenzy's Fruit — Area2D for overlap detection
Both paddle and ball are Area2Ds — so Godot can detect when they overlap
paddle.gd
extends Area2D
var speed = 400
var move_up = "ui_up" # ← Will become @export
var move_down = "ui_down" # ← Will become @export
func _process(delta):
if Input.is_action_pressed(move_up):
position.y = position.y - speed * delta
if Input.is_action_pressed(move_down):
position.y = position.y + speed * delta
position.y = clamp(position.y, 50, 550)
Wanderer pattern: input polling + delta + clamp
Problem: both paddles use the same keys!
ball.gd
extends Area2D
var velocity = Vector2.ZERO
var speed = 400
func _ready():
reset()
func reset():
position = Vector2(400, 300)
var direction = Vector2([-1, 1].pick_random(), randf_range(-0.5, 0.5))
velocity = direction.normalized() * speed
func _process(delta):
position = position + velocity * delta
if position.y < 10 or position.y > 590: # Top/bottom bounce
velocity.y = -velocity.y
Ricochet pattern: velocity + autonomous movement + wall bounce
Missing: scoring, paddle collision, sound
@exportInspector-Editable Variables
Two paddles, same script, same controls
How do we give Player 2 different keys?
We could make two separate scripts...
But there's a better way!
@export KeywordIn paddle.gd — change the variable declarations:
# Before (hardcoded):
var move_up = "ui_up"
var move_down = "ui_down"
# After (configurable):
@export var move_up = "ui_up"
@export var move_down = "ui_down"
@export makes a variable appear in the Inspector
Same script, different settings per instance
Player 1: ui_up / ui_down (arrow keys — default)
Player 2: w / s (set in Inspector)
@export to move_up and move_down in paddle.gdmove_up to wmove_down to sA new way to assign groups
In Fruit Frenzy, we used add_to_group() in code:
func _ready():
add_to_group("fruits") # tag this node as a fruit
Then checked membership in the collision handler:
if area.is_in_group("fruits"):
# It's a fruit — catch it!
That's the code approach. There's also an editor approach!
paddle.tscnpaddle → click AddNow every instance of paddle.tscn is in the "paddle" group
Player1 and Player2 both tagged automatically!
Code (add_to_group) | Editor (Groups tab) | |
|---|---|---|
| Used in | Fruit Frenzy | Pong |
| When to use | Spawned at runtime | Placed in the editor |
| Checked with | is_in_group() — same either way! | |
Spawned nodes → code groups. Editor-placed nodes → editor groups.
Your Own Events
| Signal | Emitter | Used In |
|---|---|---|
area_entered | Area2D | Fruit Frenzy (basket catches fruit) |
timeout | Timer | Fruit Frenzy (spawn fruits) |
These are built-in signals — Godot defines them
Now: define your own signals!
In ball.gd — add at the top of the script:
extends Area2D
signal scored(player) # ← Define at the top of the script
var velocity = Vector2.ZERO
var speed = 400
signal scored(player)
signal — keyword to define a new signalscored — the name (you choose it)(player) — parameter: which player scoredThis is like creating your own area_entered, but for scoring
In ball.gd — add scoring detection to _process():
func _process(delta):
position = position + velocity * delta
if position.y < 10 or position.y > 590:
velocity.y = -velocity.y
# Ball passes left edge → Player 2 scores
if position.x < 0:
scored.emit(2)
reset()
# Ball passes right edge → Player 1 scores
if position.x > 800:
scored.emit(1)
reset()
scored.emit(2) — fire the signal with player number
The ball says "someone scored" — it doesn't update the label itself
# In game.gd:
func _ready():
$Ball.scored.connect(_on_ball_scored)
| Built-in Signal | Custom Signal | |
|---|---|---|
| Defined by | Godot | You |
| Example | area_entered | scored |
| Emitted by | Godot automatically | You call .emit() |
| Connected same way? | Yes! .connect(handler) | |
Same pattern you already know — just applied to YOUR events
The heart of Pong
In ball.gd — complete the _on_area_entered() function:
func _on_area_entered(area):
if area.is_in_group("paddle"):
velocity.x = -velocity.x
position.x = position.x + sign(velocity.x) * 10
Line 2: is_in_group("paddle") — only react to paddles
Line 3: Reverse horizontal direction (bounce!)
Line 4: Nudge ball away — prevents double-hit glitch
This is enough for basic Pong — but it's boring. Every bounce goes straight back.
Still in ball.gd, inside the collision handler:
Where the ball hits the paddle changes the bounce angle
Top of paddle → ball goes up
Center of paddle → ball goes straight
Bottom of paddle → ball goes down
# How far from paddle center did the ball hit?
var hit_offset = (position.y - area.position.y) / 50
# Convert to vertical velocity
velocity.y = hit_offset * speed * 0.75
# Keep total speed consistent
velocity = velocity.normalized() * speed
ball.gd — full _on_area_entered():
func _on_area_entered(area):
if area.is_in_group("paddle"):
# 1. Horizontal bounce
velocity.x = -velocity.x
# 2. Prevent double-hit
position.x = position.x + sign(velocity.x) * 10
# 3. Angle based on hit position
var hit_offset = (position.y - area.position.y) / 50
velocity.y = hit_offset * speed * 0.75
# 4. Maintain consistent speed
velocity = velocity.normalized() * speed
4 steps: bounce → nudge → angle → normalize
This is what makes Pong feel like a skill game, not just random bouncing
Wiring it all up
game.gd — connect the signal and handle scoring:
extends Node2D
var score1 = 0
var score2 = 0
func _ready():
$Ball.scored.connect(_on_ball_scored)
func _on_ball_scored(player):
if player == 1:
score1 = score1 + 1
$Score1.text = str(score1)
else:
score2 = score2 + 1
$Score2.text = str(score2)
Ball passes left edge
↓
scored.emit(2) — "Player 2 scored!"
↓
Game receives signal via _on_ball_scored(2)
↓
score2 += 1, update label
↓
Ball calls reset() — back to center
Two players, paddles, bouncing ball, score tracking
A complete game — built from concepts you already knew!
Audio feedback
| Source | Type | Best For |
|---|---|---|
| jsfxr — sfxr.me | Generator (browser) | Retro blips, hits, jumps, pickups |
| bfxr — bfxr.net | Generator (desktop) | Same style, more controls |
| freesound.org | Library (free, CC) | Realistic sounds, ambient, music |
| kenney.nl/assets | Library (CC0) | Game-ready packs, no attribution |
For Pong: open jsfxr, click Hit/Hurt preset, tweak, export as .wav
Godot supports .wav and .ogg — drop the file into your project folder
HitSoundAudioStreamPlayer = a node that plays sound
Call .play() and it plays the assigned audio
Add to ball.gd, inside the paddle collision handler:
func _on_area_entered(area):
if area.is_in_group("paddle"):
velocity.x = -velocity.x
position.x = position.x + sign(velocity.x) * 10
var hit_offset = (position.y - area.position.y) / 50
velocity.y = hit_offset * speed * 0.75
velocity = velocity.normalized() * speed
# Play hit sound
get_parent().get_node("HitSound").play()
get_parent() — go up to Game node
.get_node("HitSound") — find the audio player
.play() — play the sound
| Concept | What You Learned |
|---|---|
@export | Make variables editable in the Inspector. One script, many configurations. |
| Groups (editor) | Assign groups via the Node panel's Groups tab — no code needed for editor-placed nodes. |
| Custom signals | Define your own events with signal. Emit with .emit(). |
AudioStreamPlayer | Play sound effects. Assign a stream, call .play(). |
Wanderer — Input, movement, clamp
↓
Ricochet — Velocity, autonomous movement, spawning
↓
Fruit Frenzy — Area2D, collision, signals, groups, score
↓
Pong — @export, editor groups, custom signals, sound
4 games. 16+ concepts. One foundation.
Wanderer
Fruit Frenzy
Ricochet
Pong
Try these on your own!
ColorRect (thin, tall, centered). Pure visual — no code needed._on_ball_scored and call $Ball.set_process(false) to freeze the ball.speed *= 1.05 in the collision handler. Cap at 800. What happens to gameplay?_process(), move toward the ball's y position: position.y += (ball_y - position.y) * speed * delta * 0.02. Hint: pass ball reference with @export var ball_path: NodePathYou've built 4 complete games — the foundation is set
Same patterns, bigger games. Let's go!
Try building Pong from scratch on your own
Experiment with the exercises
Challenge: add an AI opponent that's actually fun to play against!
Office hours: [Your time here]