Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

flap-bird-rust

A Flappy Bird clone written in Rust using the ggez game framework.


What it does

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.


Why this project matters

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 impl blocks share mutable state without using Rc/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 ? through GameResult all the way to main, with no silent panics in normal game flow.
  • Iterator combinatorsretain, any, and iter_mut replace manual index loops.
  • Constants over magic numbers — all physics, layout, and timing values live in a single constants module so tuning the game requires changing one place.

Tech Stack

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.


Architecture

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

Data flow

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"]
Loading

Game phases

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.


Features

  • Menu screen with keyboard prompt
  • Bird subject to constant gravity; tap Space to flap
  • Pipe pairs spawn at a fixed interval with randomised vertical gap positions
  • Off-screen pipes are removed from the Vec each frame (retain)
  • Score increments exactly once per pipe pair using a passed flag
  • Game-over triggered by pipe collision or leaving the screen vertically
  • Full restart without re-initialising the ggez context

Getting Started

Requirements

  • 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-dev on Debian/Ubuntu; see ggez system dependencies

Linux/Wayland note: ggez 0.6 uses wayland-client 0.28, which crashes on modern Wayland compositors. The project ships a .cargo/config.toml that sets WINIT_UNIX_BACKEND=x11 automatically, so cargo run works out of the box via XWayland. No manual configuration needed.

Installation

git clone https://github.com/<your-username>/flap-bird-rust
cd flap-bird-rust

How to run

cargo run

How to test

cargo test

How to check for warnings

cargo clippy -- -D warnings
cargo fmt --check

Usage

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.


Error Handling

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.


Testing

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.


Performance Notes

  • Mesh::new_rectangle is called once per pipe per frame inside draw(). 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 a MeshBatch or 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::Rect values and mutated in-place every frame, avoiding any per-frame allocation on the game-logic side.

Lessons Learned

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.


About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages