07May. 2025Logic and physics. Team projectShipped

Flappy Bird 3D

One axis becomes three.

A Three.js reimplementation of Flappy Bird with movement on all three axes, built by a team. I owned the game logic and physics: the collision system, the boundary clamp that keeps the bird in playable space, and obstacle generation.

My contribution

Collision detection and the scoring trigger that reuses it, three-axis boundary definition, obstacle group generation and lifecycle. Models, textures, audio and interface were built by teammates.

The problem

A team project with a Three.js brief. We picked Flappy Bird because everyone already knows the rules, so no time went into designing a game and all of it went into building one.

The actual question turned out to be more interesting than the premise. Flappy Bird works because it has one input and one axis. Give the player three axes and the design does not survive translation: a gap stops being a slot you time and becomes a volume you aim at, and the skill moves from rhythm to spatial judgement.

The constraints

A team, which meant the work had to split cleanly. My role was logic; models, textures, audio and interface went to teammates. That boundary is why the collision system had to be self-contained enough that nobody needed to understand it to place an obstacle.

Browser-only, no build step, no dependencies to install. The whole game is one 434-line index.html that opens and runs.

What I built

My part is three things.

The collision system, which is one routine doing two jobs. Each obstacle group holds several boxes; the visible ones are walls, and one invisible box fills the gap. The check runs the bird against every box in the group and branches on visibility: hit a visible box and the round ends, hit the invisible one and the group is marked passed and scores 10. A passed flag stops a slow traversal scoring repeatedly.

The boundary, which clamps the bird on all three axes every frame: y between 0 and 3, x between -9 and 9, z between -29 and 9.

Obstacle generation and lifecycle. A group is a wall of twenty boxes on a fixed grid, ten columns two high, spawned at a fixed depth every five seconds. One box is chosen at random and made invisible, and that hole is the gap. Groups travel toward the player at 0.05 per frame and are removed from the scene once they pass behind the camera.

That is worth being precise about, because "procedurally generated obstacles" would overstate it. The wall is deterministic. The only random value in the whole system is which of the twenty boxes disappears.

What this is and is not

A team project, not solo work. I am describing the parts I wrote. The game looks the way it does because of teammates' work on models, lighting and audio, and I would not claim any of it.

It is not a finished game. It is keyboard only with no mobile support, there is no difficulty progression, and the round is a flat 120 seconds with obstacles arriving at a constant rate. The obstacle layout is identical every time; only the gap moves.

The technical decisions

Decision Reasoning
One collision routine for both death and scoring The gap is defined by the same geometry as the walls, so they cannot drift out of alignment. Move the walls and the scoring volume moves with them. A separate scoring trigger would need to be kept in sync by hand
A flap sets velocity outright rather than adding to it velY = flapStrength means a flap always produces the same rise regardless of how fast you were falling. Adding would make a panicked double-flap behave differently from a calm one, and the control needs to be predictable
Clamping the bird rather than blocking it Clamping makes the bird slide along a boundary. Blocking makes it stop dead, which reads as a bug rather than as a wall
A ceiling at y = 3 Without it, continuous flapping carries the player above every obstacle and the game has no failure state. The ceiling is what makes the game a game
Obstacles destroyed and recreated, not pooled At this object count, allocation is not the bottleneck and pooling would add lifecycle bookkeeping for nothing. Worth stating plainly because the answer would be different at a larger count

The hardest thing

Collisions that felt unfair, which turned out not to be a physics problem at all.

Playing it, I kept dying on passes that looked clean. The bird was visibly inside the gap and the round ended anyway. My first instinct was that the physics was wrong, so I spent time adjusting gravity and flap strength, which changed how the game felt and did not change the problem at all.

The cause is that collision uses axis-aligned bounding boxes built with setFromObject, which produces the smallest axis-aligned box containing the entire mesh. A bird with outstretched wings has a box substantially larger than the bird a player sees. The player aims the sprite; the game tests the box. Every clip was correct according to the code and wrong according to the screen.

Once that was clear the fix was obvious in principle, if not something I got to inside the deadline: use a sphere or a hand-tuned smaller box for the bird specifically, rather than deriving the hitbox from the visual mesh. A hitbox slightly smaller than the model is standard practice in games, and it exists precisely because generosity toward the player is better than accuracy toward the geometry.

What I actually took from it is that "it feels unfair" is a technical report rather than a complaint about taste, and that the fix was in a layer I had not suspected. I was tuning the simulation when the mismatch was between the simulation and what was being drawn.

What I would do differently

The physics is frame-rate dependent, and this is the one that bothers me. Gravity, movement and obstacle speed are applied per frame with no delta multiplier, so the game runs noticeably faster on a 144Hz display than on a 60Hz one. The uncomfortable part is that a delta clock already exists in the file and dt is computed every frame, but it is only used for the wing flap animation. The tool was in my hand and I applied it to a cosmetic detail rather than to the simulation.

There are two clocks that disagree, for the same reason. Obstacle spawning runs on a one second setInterval while everything else runs on requestAnimationFrame, so spawn spacing and bird speed drift apart on different hardware.

And there is a real bug I would fix: the removal splice runs inside a forEach over the same array, so removing a group shifts the next one into the current index and it is skipped that frame. It gets picked up on the following frame, which is why nobody ever saw it, but it is still wrong. Iterating backwards is the fix.

What came of it

It shipped and it runs in a browser.

For me it was the first time I built a simulation loop rather than an application, and the lesson that transferred was about the gap between the model and what the user perceives. The collision code was correct the whole time. The game still felt broken, because correctness was being measured against geometry the player could not see.