A polished 3D Flappy Bird-style PWA built with Three.js (vanilla, no React/R3F). Cel-shaded graphics, full audio, offline-capable via service worker.
Live: quietbuildlab.github.io/flappy-3d/
- Title screen comes alive: bird hover-bobs, demo pipes scroll past, logo letter-stagger entrance, "Tap to start" pulses
- In-game juice:
+1score popups, milestone celebrations at 10/25/50 (gold burst + flash), optional flap trail (Settings), 4-color pipe cycling - Glass UI: Press Start 2P arcade font on headings,
backdrop-filterblur on overlays, gradient buttons with hover/active depth, 2-layer focus ring - Every motion effect respects
prefers-reduced-motion(OS) and the in-game motion toggle - Bundle: 196KB / 250KB gzipped
- Endless Flappy Bird loop: tap/click/spacebar to flap, gravity falls, pipes scroll, score on pass, die on contact
- Cel-shaded toon materials, post-processing (bloom + vignette) gated on desktop / hi-tier mobile
- 5 screens (Title, HUD, Pause, GameOver, Settings) with leaderboard + persistence
- PWA installable, offline play, Lighthouse PWA 1.00, iOS Safari audio unlock
- Accessibility: keyboard nav (Space/Enter/Esc/Tab), focus rings, ARIA-live score, colorblind palette toggle, motion-reduce override
- xstate v5 state machine, GSAP juice (squash, screen shake, particles), Howler audio with WebAudio synth fallback
Production JS bundle must stay ≤ 250 KB gzipped (PERF-01).
Current baseline (Phase 3): 194.49 KB gzip — 55.5 KB headroom.
npm run bundle-checkThis runs npm run build and then scripts/bundle-check.sh, which:
- Gzips all
dist/assets/*.jsfiles - Compares total gzip bytes against the 256,000-byte (250 KB) limit
- Exits non-zero if the limit is exceeded
After any build, open dist/stats.html in a browser to see the interactive treemap (generated by rollup-plugin-visualizer). Large chunks to watch:
three— should be tree-shaken; verify no wildcard imports (import * as THREE) insrc/gsap— ~28 KB gzip (core only)howler— ~15 KB gzippreact— ~4.7 KB gzip
PERF-03: Sustained 60fps on iPhone 12 / Pixel 6 class device during normal play.
This is a manual test — no automated gate exists for frame rate on real hardware.
-
Build and serve:
npm run build && npx serve -s dist -l 5000Or deploy to GitHub Pages and test the live URL.
-
On a mid-tier mobile device (iPhone 12, Pixel 6, or similar — NOT an emulator):
- Open in Chrome for Android or Safari on iOS
- Open DevTools (Android:
chrome://inspect, iOS: Safari > Develop menu) - Navigate to Performance panel → enable FPS Meter overlay
-
Test scenario:
- Start a game run
- Let 20–30 pipes pass (score ≥ 20 so difficulty has ramped)
- Confirm FPS stays ≥ 58 fps throughout; brief dips to 55 on pipe spawn are acceptable
-
If FPS drops below 55:
- Check
navigator.hardwareConcurrencyin DevTools console — should be ≤ 4 on mid-tier, which gates bloom off - Check that
createComposerreturnsnullon the test device (no EffectComposer overhead) - Reduce
POOL_SIZEor review particle count if allocations are occurring during gameplay
- Check
-
Record result in
.planning/STATE.mdPerformance Metrics table.
Run these checks before tagging a release. Each maps to a Phase 5 Success Criterion.
- Open https://quietbuildlab.github.io/flappy-3d/ in Chrome desktop (DEV build:
npm run dev) - Open DevTools → Console
- Play 10 death + restart cycles
- Observe
[mem probe] round=N geometries=X textures=Ylog lines (DEV build only) - Pass: geometries and textures values plateau — no consistent growth across rounds
Alternate (any browser): DevTools → Memory → take Heap Snapshot before and after 10 rounds. WebGLBuffer count should not grow.
- Open https://quietbuildlab.github.io/flappy-3d/ in Safari on a real iOS device (iOS 16+)
- Tap the screen to start a game
- Confirm flap, score, and death sounds play immediately (not synth oscillators)
- In Safari Web Inspector (Mac → Develop menu → your device), run:
Howler.ctx.state— expected:"running" - Ringer ON: all audio plays normally
- Ringer OFF (silent switch): audio is silenced by iOS — this is expected behaviour, not a bug. The Settings modal documents this.
- Pass:
Howler.ctx.state === 'running'after first tap; SFX are recognisably flap/score/death sounds
- Open the game and start a round
- Switch to a different tab (or press Cmd+H on mobile to background the browser)
- Expected: music stops immediately; game transitions to paused state
- Return to the tab
- Expected: Pause screen is shown; score is preserved
- Tap RESUME
- Expected: music resumes; game continues
- Pass: All three steps confirmed correct. No music ghost-playing after tab-switch.
- Open https://quietbuildlab.github.io/flappy-3d/ in Chrome
- Open DevTools Console (filter: Warnings + Errors)
- Play 20 death + restart cycles
- Pass: Zero "Event sent to stopped actor" warnings
Listener stability check (Chrome only):
- After page load (before any play):
Object.values(getEventListeners(window)).flat().length— record baseline - After 10 restart cycles: run same command
- Pass: Count is identical (or within ±1 for browser internals)
The game is deployed to GitHub Pages at:
https://<owner>.github.io/flappy-3d/
- Go to Repository Settings > Pages
- Set Source to GitHub Actions (not "Deploy from branch")
- Save. No branch selection needed — the workflow handles everything.
Push to main triggers .github/workflows/deploy.yml which:
- Installs dependencies (
npm ci) - Builds the production bundle (
npm run build) - Bundle size gate: fails if JS gzip > 250 KB (
scripts/bundle-check.sh) - Uploads
dist/as a GitHub Pages artifact - Deploys to
https://<owner>.github.io/flappy-3d/ - Lighthouse PWA gate: runs Lighthouse in headless Chrome against the live URL and fails if PWA score < 0.90 (PERF-05)
npm run build
npx serve -s dist -l 5000
# Open http://localhost:5000/flappy-3d/After each workflow run, two artifacts are available for download:
bundle-stats—dist/stats.htmlinteractive treemap (rollup-plugin-visualizer)lighthouse-report—lighthouse-pwa.jsonfull Lighthouse audit
If migrating from GitHub Pages to Cloudflare Pages:
- Change
vite.config.ts:base: '/flappy-3d/'→base: '/' - Re-audit
dist/manifest.webmanifestforstart_url/scope— they will change to/ - Delete
.github/workflows/deploy.yml - Connect repo to Cloudflare Pages dashboard (auto-builds on push, same
npm run buildcommand, output:dist/) - No application code changes required.