A Flappy Bird clone written in Rust using the ggez game framework.
A minimal, playable Flappy Bird clone rendered in a native desktop window. The player controls a bird through a series of randomly generated pipe pairs by tapping a single key. The game tracks score, detects collisions with pipes and screen boundaries, and supports restarting after a game-over.
Despite its compact scope, this project demonstrates several competencies that are directly relevant for backend, systems, and game-adjacent engineering roles:
- Ownership and borrowing — multiple
implblocks share mutable state without usingRc/RefCell, relying entirely on Rust's compile-time aliasing rules. - Module system — the project is split into focused modules with explicit visibility
(
pub,pub(super)) rather than making everything public. - Type safety — game phase is modelled as an enum (
Phase) to eliminate invalid states, not as a string or integer flag. - Idiomatic error handling — errors propagate with
?throughGameResultall the way tomain, with no silent panics in normal game flow. - Iterator combinators —
retain,any, anditer_mutreplace manual index loops. - Constants over magic numbers — all physics, layout, and timing values live in a
single
constantsmodule so tuning the game requires changing one place.
| Technology | Role |
|---|---|
| Rust 2021 edition | Language |
| ggez 0.6 | 2D game framework (window, input, rendering) |
| rand 0.8 | Random pipe gap placement |
No additional dependencies were introduced during refactoring.
src/
├── main.rs Entry point — builds ggez context, starts event loop
└── game/
├── mod.rs GameState struct + constructor
├── constants.rs All physics, layout, and timing constants
├── behavior.rs Pure game logic: obstacle spawning, collision detection,
│ scoring, restart. Also contains unit tests.
└── event_handler.rs ggez EventHandler impl: update loop, rendering, key input
graph TD
main["main()"] -->|creates| ctx["ggez Context"]
main -->|creates| gs["GameState"]
main -->|starts| el["Event Loop"]
el -->|tick| update["update() — physics, collision, score"]
el -->|frame| draw["draw() — clear, bird, pipes, HUD"]
el -->|key| input["key_down_event() — flap / start / restart"]
update --> behavior["behavior.rs — pure logic"]
draw --> event_handler["event_handler.rs — ggez calls"]
Menu ──[Enter]──► Playing ──[collision]──► GameOver ──[R]──► Playing
Phase is a private enum. State transitions happen only in event_handler.rs
(input) and behavior.rs (game_over, restart). There is no way to reach
an undefined phase.
- Menu screen with keyboard prompt
- Bird subject to constant gravity; tap
Spaceto flap - Pipe pairs spawn at a fixed interval with randomised vertical gap positions
- Off-screen pipes are removed from the
Veceach frame (retain) - Score increments exactly once per pipe pair using a
passedflag - Game-over triggered by pipe collision or leaving the screen vertically
- Full restart without re-initialising the ggez context
- Rust 1.65 or later (2021 edition)
- A system with a display (X11, XWayland, or native X11 on macOS/Windows)
- Required system libraries for ggez:
libasound2-dev,libudev-devon Debian/Ubuntu; see ggez system dependencies
Linux/Wayland note:
ggez 0.6useswayland-client 0.28, which crashes on modern Wayland compositors. The project ships a.cargo/config.tomlthat setsWINIT_UNIX_BACKEND=x11automatically, socargo runworks out of the box via XWayland. No manual configuration needed.
git clone https://github.com/<your-username>/flap-bird-rust
cd flap-bird-rustcargo runcargo testcargo clippy -- -D warnings
cargo fmt --check| Key | Action |
|---|---|
Enter |
Start game from menu |
Space |
Flap (jump) |
R |
Restart after game over |
The game window opens at 800×600. All physics values (gravity, jump velocity, obstacle
speed, spawn interval) are in src/game/constants.rs and can be tuned without touching
game logic.
All ggez operations that can fail (image loading, mesh creation, drawing) return
GameResult<T> and are propagated with ?. main itself returns GameResult, so
startup errors are reported cleanly to the terminal. There are no unwrap or expect
calls in normal game flow.
restart() previously returned GameResult<()> and used .expect(...) inside an input
handler — an infallible operation wrapped in an error type. After refactoring it is a
plain fn restart(&mut self) with no error surface.
The test suite is in src/game/behavior.rs and covers the pure mathematical logic
that does not require a ggez context:
| Test | What it verifies |
|---|---|
bird_clears_gap_no_collision |
Bird in the gap produces no collision |
bird_hits_top_pipe |
Overlap with top pipe is detected |
bird_hits_bottom_pipe |
Overlap with bottom pipe is detected |
bird_before_pipe_no_collision |
Bird before the pipe does not collide |
score_marks_passed_when_bird_clears_pipe |
Score flag set when bird's right edge passes pipe |
score_not_marked_when_bird_has_not_cleared_pipe |
Score flag not set too early |
score_not_double_counted_after_already_passed |
Already-counted pipes are not incremented again |
Honest caveat: collision detection for screen boundaries and the actual GameState
methods (check_collision, update_score) require a ggez Context and therefore
cannot be tested without a display. The architecture does not currently separate the
ggez-dependent surface area from the pure domain logic. This is the primary remaining
testing gap.
Mesh::new_rectangleis called once per pipe per frame insidedraw(). This allocates GPU resources every frame, which is not ideal for production game engines. For a Flappy Bird clone with at most ~4 visible pipes at a time, the overhead is negligible. A production-quality solution would pre-allocate aMeshBatchor use a sprite-based approach.- Off-screen obstacles are eagerly removed via
Vec::retain, keeping the obstacle list bounded to the number of pipes currently on-screen (~3–4). - Obstacle positions are stored as
ggez::graphics::Rectvalues and mutated in-place every frame, avoiding any per-frame allocation on the game-logic side.
This project demonstrates that Rust's ownership model is not an obstacle for game
development — it is a design tool. The compiler prevented every aliasing bug that
would be silent in C++ (e.g., iterating and modifying obstacles simultaneously).
Modelling game phase as an enum rather than a mutable integer flag eliminated an
entire class of invalid-state bugs at the type level.
The most instructive refactoring moment was recognising that fn score() — a method
that mutates state — had the same name as the score field. Rust allows this (it
dispatches correctly), but it is a readability trap that clippy does not catch. Naming
discipline matters more in Rust than in languages with IDE-only tooling, because you
read code in terminals and diff views as often as in an IDE.