Flap & Dodge Obstacle Game — Free HTML CSS JS Snippet
Flap & Dodge Obstacle Game · Games · Plain HTML, CSS & JS · Live preview
For supporters
Source Code
Want the source code?
Play the game for free. Become a WebDevPuneet supporter to unlock the complete HTML, CSS and JavaScript source code, the exports (HTML, React, Vue, Angular, Tailwind) and Fork.
Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Flap & Dodge Obstacle Game — Delta-Time Physics, Canvas Rendering & Gap Collision Detection

Side-scrolling flap-and-dodge games look simple on the surface but hinge entirely on one detail most quick clones get wrong: frame-rate independence. If gravity and obstacle speed are applied as a fixed number of pixels per rendered frame, the game runs at wildly different speeds on a 60Hz versus a 144Hz display, or stutters unpredictably when the browser tab is throttled. This snippet uses genuine delta-time physics — every update multiplies velocity and position changes by the elapsed time since the last frame, in seconds, so the character falls, accelerates, and the obstacles scroll at a truly constant real-world speed regardless of the device's refresh rate.
The game loop and delta time
The core loop runs through requestAnimationFrame(loop), and each call computes dt as the milliseconds since the previous frame converted to seconds and clamped to a maximum of 0.033 (roughly 30fps) so a dropped frame or tab-switch stall never produces a huge physics jump that teleports the character through an obstacle. Gravity is defined as GRAVITY = 1400 pixels per second squared, applied each frame as bird.vy += GRAVITY * dt, and the resulting velocity moves the character as bird.y += bird.vy * dt. A click, tap, or spacebar press calls flap(), which simply overwrites the vertical velocity to a fixed upward value (FLAP_VELOCITY = -420) — the same physics integration then naturally arcs the character back down under gravity afterward.
Obstacle spawning and gap collision
New obstacle pairs are pushed onto a pipes array on a fixed real-time interval (PIPE_INTERVAL seconds, not frame count), each with a randomly chosen gapCenter kept away from the very top and bottom of the canvas via a margin. Every frame, each pipe's x decreases by PIPE_SPEED * dt, and pipes are filtered out of the array once they scroll fully off the left edge, keeping the array small and collision checks cheap. Collision detection is a straightforward two-axis check: a pipe is only a collision candidate while the character's horizontal extent overlaps the pipe's horizontal extent, and within that window the character loses if its vertical extent extends above the gap's top edge or below the gap's bottom edge — exactly the classic "AABB vs gap" test used by every implementation of this genre.
Scoring and canvas rendering
A pipe is marked passed and the score increments the instant the pipe's right edge scrolls behind the character's left edge, so scoring happens exactly once per obstacle regardless of frame rate. Rendering is done entirely on a single 2D canvas context: a vertical sky gradient, filled rectangles for the top and bottom pipe segments (derived directly from each pipe's gapCenter and PIPE_GAP), and a hand-drawn circular character with a rotating tilt driven by the current vertical velocity (Math.atan-free — a simple clamped ratio of vy) so the character visibly noses down while falling and up while flapping, a small but important readability cue borrowed from the genre's classic feel. Best score persists via localStorage and is shown on both the start screen and the game-over screen.
Step by step
How to Use
- 1Start the gameClick "Start" on the opening screen, or simply click/tap the stage or press Space — any flap input while idle begins the run immediately via startGame().
- 2Flap to gain heightEach click, tap, or spacebar press sets the character's vertical velocity to a fixed upward value (FLAP_VELOCITY), instantly interrupting its fall. Gravity then pulls it back down every frame until you flap again.
- 3Dodge the gapsObstacle pairs scroll in from the right at a constant real-world speed (PIPE_SPEED, not frame-dependent) with a randomly positioned vertical gap. Keep the character's circle within the gap as each pair passes.
- 4Watch your score climbThe score increments by one the instant a pipe pair is fully passed, tracked per-pipe with a passed flag so each obstacle only scores once no matter the frame rate.
- 5See your final score on Game OverColliding with a pipe, the ceiling, or the ground ends the run immediately, freezes the loop, and shows the Game Over screen with your final score and your all-time best.
- 6Beat your best scoreYour highest score persists across sessions via localStorage. Click "Try Again" to instantly reset the character, obstacles, and score and start a fresh delta-time-driven run.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
requestAnimationFrame does not guarantee a fixed interval between calls — it can fire at 60fps, 120fps, or drop frames under load. If gravity and obstacle speed were applied as constant pixel amounts per frame, the game would run faster on high-refresh-rate displays and stutter unpredictably under load. By multiplying every physics change by dt (seconds elapsed since the last frame), this snippet keeps gravity acceleration and obstacle scroll speed constant in real-world time regardless of how often the frame callback fires.
For each pipe, the code first checks horizontal overlap: does the character's circle extent intersect the pipe's x-range? Only if that is true does it check the vertical condition — whether the character's top edge is above the gap's top boundary or its bottom edge is below the gap's bottom boundary. If either is true while horizontally overlapping, it is a collision. This two-stage check is cheap because most pipes are off-screen or far away and get skipped by the horizontal test immediately.
Yes. Add a scaling factor to PIPE_SPEED and/or shrink PIPE_GAP based on the current score inside update(), for example recalculating an effective speed as PIPE_SPEED + score * 3 each frame. Keep the change gradual and test that the gap never shrinks below roughly 2.5x the character's diameter, or the game becomes unfairly difficult to react to.
best is read from localStorage once when the script first loads and kept in memory for the whole session, only being overwritten (in memory and in localStorage) when a completed run's score exceeds it. Starting a new game resets the current score to 0 but intentionally leaves best untouched so it always reflects your highest-ever run, shown on both the start screen and the game-over screen.
Create Audio objects (e.g. const flapSound = new Audio("flap.mp3")) and call .play() inside flap(), inside the score-increment branch in update(), and inside endGame() respectively. Since many browsers block audio playback before any user interaction, the very first flap() call (which also starts the game from idle) is a safe place to "unlock" audio playback for the rest of the session.