Build Flappy
Build Flappy
One button, endless obstacles, a score, and a game over you can restart.
no experience needed · about 30 minutes · 7 steps
The finished project is a starter template: create a new project with the flappy template (in the Hub, or floptle --new <dir> --template flappy) to read the answer.
Follow this along inside the editor — the 🎓 Learn tab has the same steps and ticks each one off as your project starts to match it.
The shortest complete game there is: one button, one rule, and a number that goes up. You can finish this in half an hour and you'll have made something with a beginning, a middle and an end — which is more than most first projects manage.
It also teaches the two things every arcade game needs and the other tutorials don't: spawning objects while the game runs, and a game state that can end and start again.
A 3D engine playing a 2D game
Floptle is 3D. Flappy is flat. The trick is simply to refuse to use the third axis: everything sits at z = 0, and the camera looks straight down it. There's no 2D mode to switch on, and you don't need one.
1. Point the camera down the Z axis
Start from a fresh project. Delete the starter crate, ball and capsule — you want an empty stage.
Select the Camera and remove its freelook script (the … beside it →
🗑 Remove) — otherwise you will fly the camera off by accident mid-game.
Set the camera's position to about 0, 4, 18 and its rotation to all
zeroes. It now looks along -Z at a flat plane, which is the whole stage.
Everything you place from here goes at z = 0.
2. The bird
Add a Sphere, name it Bird, put it at about -4, 4, 0, and shrink it a
little.
Give it a Rigidbody, left on Dynamic, with shape Sphere and affected by gravity ticked. Press Play: it falls off the bottom of the screen. That is the game working correctly — falling is the default state, and the entire player input is a refusal to.
Then create and attach the script
Press Play again. Space now flaps.
Why we set velocity rather than add to it
node.vel = vec3(0, params.flap, 0)
Adding force would make each flap depend on how fast you were already falling — tap twice quickly and you'd rocket away. Replacing the velocity means every flap is identical wherever you were, which is what makes the game learnable. Nearly every arcade jump works this way.
Staying on the plane
Nothing pushes the bird in Z, but a collision later might. The two lines that
snap z back to 0 cost nothing and save a confusing bug where the bird slowly
drifts behind the pipes and sails through them.
scripts/flappyBird.lua
-- One button. Press it and you go up; do nothing and gravity wins.
defaults = {
--@desc Upward speed given by one flap.
--@range 0 20 --@units m/s
flap = 6.0,
--@desc Tilt with the climb. Purely cosmetic.
tilt = true,
}
function fixedUpdate(node, dt)
-- Once it is over, stop responding — but keep falling, so the failure is
-- something you watch happen rather than a freeze.
local game = findScript("flappyGame")
if game and game.over then return end
-- REPLACE the velocity rather than adding to it, so every flap is identical
-- whatever you were already doing. That is what makes it learnable.
if input.justPressed("Jump") then
node.vel = vec3(0, params.flap, 0)
end
-- The game is flat: nothing should ever leave z = 0.
if math.abs(node.z) > 0.001 then
node.pos = vec3(node.x, node.y, 0)
end
if params.tilt then
node.pitch = math.clamp(node.vy * 0.08, -0.6, 0.6)
end
end
function onCollisionEnter(node, other, hit)
local game = findScript("flappyGame")
if game then game.lose() end
end
Done when: Bird runs flappyBird.lua.
3. Build one pipe and give it its behaviour
Add an Empty node called Pipe. Under it — drag them onto its row in the
Hierarchy to parent them — add two Cubes: one stretched upward above the
gap, one below it.
The two cubes are what the bird actually hits, so give each a Rigidbody with mode Kinematic. Kinematic, not Static, because the script below moves the parent every frame and a static body is a baked collider that would stay behind.
Then create the script and attach it to the Pipe root (not to the cubes —
moving the parent carries both halves).
Every pipe cleans up after itself
if node.x < -params.behind then
node:destroy()
end
The alternative is a spawner holding a list of every pipe it ever made, remembering to walk it, and leaking the ones it forgets. Letting each object decide when it is finished is smaller, and it stays correct when something destroys a pipe for a reason the spawner never hears about.
Scoring on the pipe, not on the bird
The pipe knows when it has passed x = 0. The bird would have to check every pipe every frame to work the same thing out. Put the decision where the information already is — that principle will save you more code than any other one here.
The scored flag is what stops one pipe scoring sixty times a second while it
crosses the line.
scripts/flappyPipe.lua
-- One obstacle: drifts toward the bird, scores as it passes, deletes itself
-- once it is safely off-screen.
defaults = {
--@range 0 20 --@units m/s
speed = 4.0,
--@desc Removed once it is this far past the bird.
--@range 0 40 --@units m
behind = 12.0,
}
local scored = false
function update(node, dt)
local game = findScript("flappyGame")
if game and game.over then return end
node.x = node.x - params.speed * dt
-- Score once, as it goes past. Without the flag this fires every frame the
-- pipe spends left of zero.
if not scored and node.x < 0 then
scored = true
if game then game.score() end
end
-- Each pipe tidies itself up, so the spawner never keeps a list.
if node.x < -params.behind then
node:destroy()
end
end
Done when: scripts/flappyPipe.lua exists.
4. Save it as a prefab
Drag the Pipe node from the Hierarchy into the Assets panel. That writes
prefabs/Pipe.prefab.ron — the node, its two children, their bodies and the
script, all saved together.
Then delete the Pipe node from the scene. The spawner will make its own.
What a prefab is, and why this game needs one
A prefab is a saved node and everything under it. spawn("Pipe", position)
stamps out a fresh copy while the game runs.
You need one here because a script can create nodes but can't give them colliders. Anything that has to be solid is authored once and spawned. That is not a limitation you'll fight — it is the normal way to make bullets, enemies, debris and pipes.
Changing a prefab later works the same way round: drag it back into the scene, edit it, drag it into Assets again.
Done when: prefabs/Pipe.prefab.ron exists.
5. The rules
Add an Empty node called Game and attach the script below.
Press Play. Pipes arrive, the score counts, hitting one ends it, and Space starts again.
Spawning on a schedule
spawner = every(params.interval, function()
...
end)
every repeats on the game clock and hands back a handle you can :cancel().
Doing this with a countdown in update is four more lines and one more thing to
get wrong; more importantly, every pauses when the game does.
Cancelling it in lose() is what stops pipes marching on over the game-over
screen.
A published variable
over = false
Declared with no local, on purpose. The bird and the pipes read
game.over through their script handles. Locals are private to a file; this one
is the script's public state, and that difference is worth being deliberate
about.
Restarting
scene.load(scene.current())
Reloading the current scene is a complete reset: every node back to its authored
state, every script's start() run again. It's the cheapest possible restart
and it is exactly right for an arcade game.
Note the high score goes through save.*, so it survives the reload — and
quitting, and the exported build.
scripts/flappyGame.lua
-- The rules: spawn pipes, keep score, end the run, start it again.
--
-- Put this on an Empty node. It is the only script that knows the game is a
-- game — the bird just flaps, the pipes just drift.
defaults = {
--@desc Prefab spawned as an obstacle.
pipe = "Pipe",
--@range 0.5 5 --@units s
interval = 1.6,
--@desc How far to the right pipes appear.
--@units m
spawnX = 14.0,
--@header Gap height
--@units m
gapLow = 2.0,
--@units m
gapHigh = 6.5,
}
-- No `local`: this is the script's PUBLIC state. The bird and the pipes read
-- it through a script handle, and locals are private to their file.
over = false
local points = 0
local best = 0
local spawner
function start(node)
over = false
points = 0
best = save.get("best", 0)
-- Repeats on the game clock and hands back a cancellable handle, so there is
-- no countdown to keep in update() and it pauses when the game pauses.
spawner = every(params.interval, function()
local y = params.gapLow + math.random() * (params.gapHigh - params.gapLow)
spawn(params.pipe, vec3(params.spawnX, y, 0))
end)
end
function score()
points = points + 1
end
function lose()
if over then return end
over = true
-- Stop the pipes marching on over the game-over screen.
if spawner then spawner:cancel() end
if points > best then
best = points
save.set("best", best)
end
end
function update(node, dt)
if not camera.exists() then return end
local w, h = camera.screenSize()
draw.text(w * 0.5, 40, tostring(points), 48, 1, 1, 1, 1, "center")
if over then
draw.text(w * 0.5, h * 0.5 - 20, "Game over", 40, 1, 0.5, 0.45, 1, "center")
draw.text(w * 0.5, h * 0.5 + 30, "Best " .. best .. " — Space to try again",
22, 1, 1, 1, 0.8, "center")
-- Reloading the scene is a complete reset: every node back to how it was
-- authored, every start() run again.
if input.justPressed("Jump") then
scene.load(scene.current())
end
end
end
Done when: Game runs flappyGame.lua.
6. Tune it until it's actually fun
This is the real work, and it is entirely done from the Inspector with the game running. Nothing below needs a code change.
flapon the bird, andinterval,gapLowandgapHighon the game, are the numbers that decide whether this is playable. Move one at a time.- If it's too hard, widen the gap before you slow the pipes down — a slow game is boring in a way a hard one isn't.
- Make the gap narrow as the score climbs. Two lines in
score().
Press Play and get a score you're pleased with before moving on. Playing your own game for five minutes will tell you more than reading about game feel for an hour.
Done when: you've pressed Play.
7. Make it a real game
It's finished. Everything from here is polish — which is most of what separates a project from a game.
Cheap wins, roughly in order
- Sound. A flap, a score blip, a crash. The ≣ Mixer tab, and
node:sound(). - A particle burst on death — the ✱ Particles tab, then
spawnEffect("Crash", node.pos). - A start screen.
over = trueat the beginning and a different message until the first Space. - Something behind it. A few slow-moving background shapes at negative z give an enormous amount of depth for almost nothing.
- Ship it. File → Export Game builds a standalone executable for Windows, macOS or Linux. This is a small enough game to actually finish and hand to someone, which is a rare and worthwhile thing.
And then
The 3D platformer tutorial covers cameras, moving platforms and level structure; the top-down RPG covers state that outlives a scene. Between the three you have seen most of the engine's shape.
The finished version of this one is the flappy starter template.