← back to Flappy Flight
Flappy Flight — working single-file game + project scaffold
a17fd67184ed6bf4f0e61173796a4b687194daf2 · 2026-07-23 15:43:13 -0700 · Steve Abrams
Built from Claude Opus's Model Arena artifact. Kept the strong core
(dt-integrated physics, circle/rect collision, 3-layer parallax,
procedural Web Audio, medals, best-score persistence). Fixed the
performance.now()-everywhere animation clock (scenery drifted while
stopped) by routing all time-based visuals through one game-owned
clock, which also enabled a true pause. Added pause (P/Esc +
auto-pause on blur/hidden), mute toggle (M + clickable speaker,
persisted), control hints, and scaffolding (zero-dep dev server,
README, MIT license, .gitignore).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A .gitignoreA LICENSEA README.mdA index.htmlA package.jsonA server.js
Diff
commit a17fd67184ed6bf4f0e61173796a4b687194daf2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 23 15:43:13 2026 -0700
Flappy Flight — working single-file game + project scaffold
Built from Claude Opus's Model Arena artifact. Kept the strong core
(dt-integrated physics, circle/rect collision, 3-layer parallax,
procedural Web Audio, medals, best-score persistence). Fixed the
performance.now()-everywhere animation clock (scenery drifted while
stopped) by routing all time-based visuals through one game-owned
clock, which also enabled a true pause. Added pause (P/Esc +
auto-pause on blur/hidden), mute toggle (M + clickable speaker,
persisted), control hints, and scaffolding (zero-dep dev server,
README, MIT license, .gitignore).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 8 +
LICENSE | 21 ++
README.md | 58 ++++++
index.html | 645 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 12 ++
server.js | 34 ++++
6 files changed, 778 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7181d72
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Steve Abrams
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6d66ed9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,58 @@
+# Flappy Flight
+
+A polished, self-contained **Flappy-Bird-style** game in a single HTML file.
+No external assets, no libraries, no build step — just open `index.html`.
+
+ 
+
+## Play
+
+- **Open `index.html`** in any modern browser, or run a local server (below).
+- **Flap:** `Space` · `↑` · `W` · click · tap
+- **Pause:** `P` or `Esc` (also auto-pauses when the tab loses focus)
+- **Mute:** `M`, or click the speaker icon (top-right)
+- **Restart:** click / `Space` on the Game Over screen
+
+Get through the pipe gaps. The game speeds up and the gaps tighten as your
+score climbs. Bronze / silver / gold medals at 10 / 25 / 40. Your best score
+is saved in the browser.
+
+## Local server (optional)
+
+The game is a plain file, but a server avoids any `file://` quirks:
+
+```bash
+npm start # serves on http://localhost:8080 (zero dependencies)
+# or:
+python3 -m http.server 8080
+```
+
+## Features
+
+- Gravity + flap physics, **frame-rate independent** (delta-time integrated, clamped on tab-switch)
+- Scrolling pipe gaps with **increasing difficulty** (scroll speed, gap height, spacing all ramp with score)
+- **3-layer parallax** background — twinkling stars + moon, drifting clouds, two mountain ridges, scrolling ground
+- Circle-vs-rect collision matched precisely to the drawn pipe geometry
+- Ready / Play / Dying / Game-Over states with an animated death fall, screen-shake and flash
+- **True pause** — one game-owned clock drives every animation, so a pause freezes the *entire* scene (not just the bird)
+- **Mute toggle**, persisted to `localStorage` alongside the best score
+- Procedural audio via the Web Audio API (flap / score / hit / swoosh) — no sound files
+- Crisp on HiDPI/Retina; scales to any window while keeping a fixed play field
+
+## Provenance
+
+Started from Claude Opus's Model Arena artifact ("All-Models — Flappy Clone").
+Kept the strong core (physics, collision, parallax, procedural audio, medals),
+then fixed and extended it:
+
+- **Bug fix:** animations were driven by raw `performance.now()`, so the sky,
+ clouds and parallax hills kept drifting even when the game was effectively
+ stopped. Replaced with a single `clock` that only advances while running —
+ which also made a proper pause possible.
+- **Added:** pause (`P`/`Esc` + auto-pause on blur/visibility change), mute
+ toggle (`M` + clickable speaker icon, persisted), on-screen control hints,
+ and project scaffolding (dev server, README, license, git).
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f647b99
--- /dev/null
+++ b/index.html
@@ -0,0 +1,645 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+<meta name="theme-color" content="#0e1420">
+<meta name="description" content="Flappy Flight — a polished, self-contained Flappy-Bird-style game. No assets, no libraries.">
+<title>Flappy Flight</title>
+<style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body {
+ width: 100%; height: 100%;
+ background: #0e1420;
+ overflow: hidden;
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
+ -webkit-user-select: none; user-select: none;
+ touch-action: manipulation;
+ }
+ #wrap {
+ position: absolute; inset: 0;
+ display: flex; align-items: center; justify-content: center;
+ }
+ canvas {
+ display: block;
+ border-radius: 12px;
+ box-shadow: 0 20px 60px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.06);
+ cursor: pointer;
+ }
+</style>
+</head>
+<body>
+<div id="wrap"><canvas id="game" aria-label="Flappy Flight game canvas" role="img"></canvas></div>
+<script>
+(() => {
+'use strict';
+
+const canvas = document.getElementById('game');
+const ctx = canvas.getContext('2d');
+
+// ---------- sizing ----------
+const GW = 420, GH = 640; // logical game size
+let scale = 1;
+function resize() {
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ const availW = window.innerWidth, availH = window.innerHeight;
+ scale = Math.min(availW / GW, availH / GH) * 0.98;
+ canvas.style.width = (GW * scale) + 'px';
+ canvas.style.height = (GH * scale) + 'px';
+ canvas.width = Math.round(GW * scale * dpr);
+ canvas.height = Math.round(GH * scale * dpr);
+ ctx.setTransform(canvas.width / GW, 0, 0, canvas.height / GH, 0, 0);
+}
+window.addEventListener('resize', resize);
+resize();
+
+// ---------- game clock ----------
+// A single monotonic clock that ONLY advances while the game is actively
+// running (never during a pause). All time-based visuals read from this so a
+// pause genuinely freezes the whole scene instead of letting the sky, clouds,
+// and parallax hills keep drifting.
+let clock = 0;
+
+// ---------- audio (generated, no assets) ----------
+let actx = null;
+let muted = false;
+try { muted = localStorage.getItem('ff_muted') === '1'; } catch (e) {}
+function audio() {
+ if (muted) return null;
+ if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e){} }
+ if (actx && actx.state === 'suspended') actx.resume();
+ return actx;
+}
+function beep(freq, dur, type, vol, slide) {
+ const a = audio(); if (!a) return;
+ const o = a.createOscillator(), g = a.createGain();
+ o.type = type || 'sine';
+ o.frequency.setValueAtTime(freq, a.currentTime);
+ if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(slide, 1), a.currentTime + dur);
+ g.gain.setValueAtTime(vol || 0.08, a.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
+ o.connect(g); g.connect(a.destination);
+ o.start(); o.stop(a.currentTime + dur);
+}
+const sfx = {
+ flap: () => beep(430, 0.09, 'triangle', 0.06, 620),
+ score: () => { beep(880, 0.09, 'sine', 0.07); setTimeout(() => beep(1320, 0.12, 'sine', 0.07), 70); },
+ hit: () => beep(200, 0.25, 'sawtooth', 0.10, 60),
+ swoosh:() => beep(320, 0.18, 'sine', 0.04, 90)
+};
+function toggleMute() {
+ muted = !muted;
+ try { localStorage.setItem('ff_muted', muted ? '1' : '0'); } catch (e) {}
+ if (!muted) sfx.swoosh();
+}
+
+// ---------- constants ----------
+const GROUND_H = 88;
+const SKY_H = GH - GROUND_H;
+const GRAVITY = 1350; // px/s^2
+const FLAP_V = -410; // px/s
+const MAX_FALL = 620;
+const BIRD_X = GW * 0.30;
+const BIRD_R = 14;
+const PIPE_W = 66;
+
+// speaker icon hit-box (top-right), in logical coords
+const SPK = { x: GW - 40, y: 26, w: 30, h: 26 };
+
+// difficulty ramps with score
+function diff(score) {
+ const t = Math.min(score / 30, 1);
+ return {
+ speed: 145 + 95 * t, // scroll speed px/s
+ gap: Math.max(196 - score * 2.2, 128), // gap height
+ spacing: Math.max(258 - score * 1.6, 205) // px between pipes
+ };
+}
+
+// ---------- state ----------
+const STATE = { READY: 0, PLAY: 1, DYING: 2, OVER: 3 };
+let state = STATE.READY;
+let paused = false;
+let bird, pipes, score, best, distSincePipe, shake, flash, wingT, deadT, overT, groundX;
+let clouds = [], stars = [];
+best = 0;
+try { best = +localStorage.getItem('ff_best') || 0; } catch(e) {}
+
+function seedScenery() {
+ clouds = [];
+ for (let i = 0; i < 7; i++) clouds.push({
+ x: Math.random() * GW, y: 30 + Math.random() * 200,
+ s: 0.5 + Math.random() * 0.9, layer: Math.random() < 0.5 ? 0 : 1
+ });
+ stars = [];
+ for (let i = 0; i < 26; i++) stars.push({
+ x: Math.random() * GW, y: Math.random() * SKY_H * 0.7,
+ r: 0.6 + Math.random() * 1.3, tw: Math.random() * Math.PI * 2
+ });
+}
+seedScenery();
+
+function reset() {
+ bird = { y: SKY_H * 0.45, v: 0, rot: 0 };
+ pipes = [];
+ score = 0;
+ distSincePipe = diff(0).spacing * 0.55;
+ shake = 0; flash = 0; wingT = 0; deadT = 0; overT = 0; groundX = 0;
+ paused = false;
+}
+reset();
+
+function spawnPipe() {
+ const d = diff(score);
+ const margin = 56;
+ const gapY = margin + Math.random() * (SKY_H - d.gap - margin * 2);
+ pipes.push({ x: GW + PIPE_W, gapY, gap: d.gap, scored: false, wob: Math.random() * Math.PI * 2 });
+}
+
+// ---------- input ----------
+function flap() {
+ if (paused) { paused = false; return; } // any tap un-pauses
+ if (state === STATE.READY) {
+ state = STATE.PLAY;
+ bird.v = FLAP_V;
+ sfx.flap();
+ return;
+ }
+ if (state === STATE.PLAY) {
+ bird.v = FLAP_V;
+ wingT = 0;
+ sfx.flap();
+ return;
+ }
+ if (state === STATE.OVER && overT > 0.45) {
+ reset();
+ state = STATE.READY;
+ sfx.swoosh();
+ }
+}
+function togglePause() {
+ if (state !== STATE.PLAY) return;
+ paused = !paused;
+ if (!paused) sfx.swoosh();
+}
+window.addEventListener('keydown', e => {
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ if (!e.repeat) flap();
+ } else if (e.code === 'KeyP' || e.code === 'Escape') {
+ e.preventDefault();
+ if (!e.repeat) togglePause();
+ } else if (e.code === 'KeyM') {
+ e.preventDefault();
+ if (!e.repeat) toggleMute();
+ }
+});
+canvas.addEventListener('pointerdown', e => {
+ e.preventDefault();
+ // hit-test the speaker icon (convert client px -> logical coords)
+ const r = canvas.getBoundingClientRect();
+ const lx = (e.clientX - r.left) / r.width * GW;
+ const ly = (e.clientY - r.top) / r.height * GH;
+ if (lx >= SPK.x - 6 && lx <= SPK.x + SPK.w + 6 && ly >= SPK.y - 8 && ly <= SPK.y + SPK.h + 6) {
+ toggleMute();
+ return;
+ }
+ flap();
+});
+// auto-pause when the tab/window loses focus so you never come back mid-fall
+window.addEventListener('blur', () => { if (state === STATE.PLAY) paused = true; });
+document.addEventListener('visibilitychange', () => {
+ if (document.hidden && state === STATE.PLAY) paused = true;
+});
+
+// ---------- collision ----------
+function circleRect(cx, cy, r, rx, ry, rw, rh) {
+ const nx = Math.max(rx, Math.min(cx, rx + rw));
+ const ny = Math.max(ry, Math.min(cy, ry + rh));
+ const dx = cx - nx, dy = cy - ny;
+ return dx * dx + dy * dy < r * r;
+}
+
+function die() {
+ state = STATE.DYING;
+ deadT = 0;
+ shake = 1;
+ flash = 1;
+ bird.v = Math.min(bird.v, -220);
+ sfx.hit();
+ if (score > best) {
+ best = score;
+ try { localStorage.setItem('ff_best', best); } catch(e) {}
+ }
+}
+
+// ---------- update ----------
+function update(dt) {
+ const d = diff(score);
+ wingT += dt;
+ if (shake > 0) shake = Math.max(0, shake - dt * 2.6);
+ if (flash > 0) flash = Math.max(0, flash - dt * 3.2);
+
+ // parallax scenery always drifts a little
+ const sceneSpeed = (state === STATE.PLAY || state === STATE.READY) ? d.speed : 20;
+ for (const c of clouds) {
+ c.x -= sceneSpeed * (c.layer === 0 ? 0.18 : 0.32) * c.s * dt;
+ if (c.x < -120) { c.x = GW + 60 + Math.random() * 60; c.y = 30 + Math.random() * 200; }
+ }
+
+ if (state === STATE.READY) {
+ bird.y = SKY_H * 0.45 + Math.sin(clock * 2.63) * 9;
+ bird.rot = 0;
+ groundX = (groundX - d.speed * dt) % 24;
+ return;
+ }
+
+ if (state === STATE.PLAY) {
+ groundX = (groundX - d.speed * dt) % 24;
+
+ // bird physics
+ bird.v = Math.min(bird.v + GRAVITY * dt, MAX_FALL);
+ bird.y += bird.v * dt;
+ const target = bird.v < 0 ? -0.42 : Math.min(bird.v / MAX_FALL * 1.45, 1.35);
+ bird.rot += (target - bird.rot) * Math.min(1, dt * (bird.v < 0 ? 14 : 6));
+
+ // ceiling clamp
+ if (bird.y < BIRD_R) { bird.y = BIRD_R; bird.v = 0; }
+
+ // pipes
+ distSincePipe += d.speed * dt;
+ if (distSincePipe >= d.spacing) { distSincePipe = 0; spawnPipe(); }
+ for (const p of pipes) {
+ p.x -= d.speed * dt;
+ if (!p.scored && p.x + PIPE_W < BIRD_X - BIRD_R) {
+ p.scored = true;
+ score++;
+ sfx.score();
+ }
+ // collide
+ if (circleRect(BIRD_X, bird.y, BIRD_R - 1, p.x, -10, PIPE_W, p.gapY + 10) ||
+ circleRect(BIRD_X, bird.y, BIRD_R - 1, p.x, p.gapY + p.gap, PIPE_W, SKY_H - p.gapY - p.gap)) {
+ die();
+ }
+ }
+ pipes = pipes.filter(p => p.x > -PIPE_W - 10);
+
+ // ground
+ if (bird.y > SKY_H - BIRD_R) { bird.y = SKY_H - BIRD_R; die(); }
+ return;
+ }
+
+ if (state === STATE.DYING) {
+ deadT += dt;
+ bird.v = Math.min(bird.v + GRAVITY * 1.15 * dt, MAX_FALL * 1.2);
+ bird.y += bird.v * dt;
+ bird.rot = Math.min(bird.rot + dt * 7, Math.PI / 2);
+ if (bird.y >= SKY_H - BIRD_R) {
+ bird.y = SKY_H - BIRD_R;
+ state = STATE.OVER;
+ overT = 0;
+ sfx.swoosh();
+ }
+ return;
+ }
+
+ if (state === STATE.OVER) overT += dt;
+}
+
+// ---------- drawing ----------
+function roundRect(x, y, w, h, r) {
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+}
+
+function drawSky() {
+ const g = ctx.createLinearGradient(0, 0, 0, SKY_H);
+ g.addColorStop(0, '#1b2a52');
+ g.addColorStop(0.55, '#3f5f9e');
+ g.addColorStop(1, '#7fa8d9');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, GW, SKY_H);
+
+ // stars (subtle, twinkling in the upper sky)
+ const t = clock;
+ for (const s of stars) {
+ const a = 0.25 + 0.25 * Math.sin(t * 2 + s.tw);
+ ctx.fillStyle = `rgba(255,255,240,${a.toFixed(3)})`;
+ ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
+ }
+
+ // moon
+ ctx.fillStyle = 'rgba(255,248,220,0.9)';
+ ctx.beginPath(); ctx.arc(GW - 74, 78, 26, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = 'rgba(63,95,158,0.55)';
+ ctx.beginPath(); ctx.arc(GW - 84, 70, 22, 0, Math.PI * 2); ctx.fill();
+}
+
+function drawCloudShape(x, y, s, alpha) {
+ ctx.fillStyle = `rgba(255,255,255,${alpha})`;
+ ctx.beginPath();
+ ctx.arc(x, y, 18 * s, 0, Math.PI * 2);
+ ctx.arc(x + 22 * s, y - 8 * s, 14 * s, 0, Math.PI * 2);
+ ctx.arc(x + 42 * s, y, 16 * s, 0, Math.PI * 2);
+ ctx.arc(x + 20 * s, y + 6 * s, 15 * s, 0, Math.PI * 2);
+ ctx.fill();
+}
+
+function drawParallax() {
+ // far clouds
+ for (const c of clouds) if (c.layer === 0) drawCloudShape(c.x, c.y, c.s * 0.8, 0.16);
+
+ // far mountain ridge (drifts with a slow phase tied to the game clock)
+ const t = clock;
+ ctx.fillStyle = 'rgba(38,58,102,0.85)';
+ ctx.beginPath();
+ ctx.moveTo(0, SKY_H);
+ for (let x = 0; x <= GW; x += 8) {
+ const y = SKY_H - 96 - Math.sin((x + t * 12) * 0.012) * 34 - Math.sin((x + t * 12) * 0.031) * 16;
+ ctx.lineTo(x, y);
+ }
+ ctx.lineTo(GW, SKY_H); ctx.closePath(); ctx.fill();
+
+ // near hills
+ ctx.fillStyle = 'rgba(52,84,128,0.95)';
+ ctx.beginPath();
+ ctx.moveTo(0, SKY_H);
+ for (let x = 0; x <= GW; x += 8) {
+ const y = SKY_H - 44 - Math.sin((x + t * 34) * 0.02) * 22 - Math.cos((x + t * 34) * 0.043) * 10;
+ ctx.lineTo(x, y);
+ }
+ ctx.lineTo(GW, SKY_H); ctx.closePath(); ctx.fill();
+
+ // near clouds
+ for (const c of clouds) if (c.layer === 1) drawCloudShape(c.x, c.y, c.s, 0.28);
+}
+
+function drawPipes() {
+ for (const p of pipes) {
+ const bodyG = ctx.createLinearGradient(p.x, 0, p.x + PIPE_W, 0);
+ bodyG.addColorStop(0, '#3f9d4e');
+ bodyG.addColorStop(0.35, '#6fce74');
+ bodyG.addColorStop(0.6, '#57b95f');
+ bodyG.addColorStop(1, '#2f7a3b');
+
+ // top pipe
+ ctx.fillStyle = bodyG;
+ ctx.fillRect(p.x + 4, 0, PIPE_W - 8, p.gapY - 24);
+ roundRect(p.x, p.gapY - 26, PIPE_W, 26, 5); ctx.fill();
+ // bottom pipe
+ ctx.fillRect(p.x + 4, p.gapY + p.gap + 24, PIPE_W - 8, SKY_H - p.gapY - p.gap - 24);
+ roundRect(p.x, p.gapY + p.gap, PIPE_W, 26, 5); ctx.fill();
+
+ // rim highlights
+ ctx.fillStyle = 'rgba(255,255,255,0.22)';
+ ctx.fillRect(p.x + 9, 0, 6, p.gapY - 26);
+ ctx.fillRect(p.x + 9, p.gapY + p.gap + 26, 6, SKY_H - p.gapY - p.gap - 26);
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
+ ctx.fillRect(p.x + PIPE_W - 12, 0, 6, p.gapY - 26);
+ ctx.fillRect(p.x + PIPE_W - 12, p.gapY + p.gap + 26, 6, SKY_H - p.gapY - p.gap - 26);
+ // cap shading
+ ctx.fillStyle = 'rgba(255,255,255,0.18)';
+ ctx.fillRect(p.x + 3, p.gapY - 23, PIPE_W - 6, 5);
+ ctx.fillRect(p.x + 3, p.gapY + p.gap + 3, PIPE_W - 6, 5);
+ }
+}
+
+function drawGround() {
+ const g = ctx.createLinearGradient(0, SKY_H, 0, GH);
+ g.addColorStop(0, '#c9b26a');
+ g.addColorStop(0.12, '#9ac162');
+ g.addColorStop(0.16, '#77a94e');
+ g.addColorStop(1, '#5b8340');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, SKY_H, GW, GROUND_H);
+
+ // striped turf edge
+ ctx.save();
+ ctx.beginPath(); ctx.rect(0, SKY_H, GW, 14); ctx.clip();
+ for (let x = groundX - 24; x < GW + 24; x += 24) {
+ ctx.fillStyle = 'rgba(255,255,255,0.16)';
+ ctx.beginPath();
+ ctx.moveTo(x, SKY_H); ctx.lineTo(x + 12, SKY_H);
+ ctx.lineTo(x + 4, SKY_H + 14); ctx.lineTo(x - 8, SKY_H + 14);
+ ctx.closePath(); ctx.fill();
+ }
+ ctx.restore();
+ ctx.fillStyle = 'rgba(0,0,0,0.20)';
+ ctx.fillRect(0, SKY_H, GW, 3);
+
+ // pebbles
+ ctx.fillStyle = 'rgba(0,0,0,0.10)';
+ for (let i = 0; i < 9; i++) {
+ const x = ((i * 97 + groundX * 2.5) % (GW + 40)) - 20;
+ ctx.beginPath(); ctx.arc(x, SKY_H + 34 + (i * 37 % 40), 3 + (i % 3), 0, Math.PI * 2); ctx.fill();
+ }
+}
+
+function drawBird() {
+ ctx.save();
+ ctx.translate(BIRD_X, bird.y);
+ ctx.rotate(bird.rot);
+
+ // body
+ const g = ctx.createRadialGradient(-4, -5, 3, 0, 0, BIRD_R + 4);
+ g.addColorStop(0, '#ffe38a');
+ g.addColorStop(0.6, '#ffc73d');
+ g.addColorStop(1, '#f0a020');
+ ctx.fillStyle = g;
+ ctx.beginPath(); ctx.ellipse(0, 0, BIRD_R + 2, BIRD_R, 0, 0, Math.PI * 2); ctx.fill();
+ ctx.strokeStyle = 'rgba(120,70,0,0.35)'; ctx.lineWidth = 1.5; ctx.stroke();
+
+ // wing (flaps). During pause, hold a neutral pose.
+ const flapping = (state === STATE.PLAY || state === STATE.READY) && !paused;
+ const wingA = flapping ? Math.sin(wingT * 22) * 0.9 - 0.2 : 0.6;
+ ctx.save();
+ ctx.translate(-3, 1);
+ ctx.rotate(wingA * 0.6);
+ ctx.fillStyle = '#f7b32b';
+ ctx.beginPath(); ctx.ellipse(-2, 2, 9, 6, -0.4, 0, Math.PI * 2); ctx.fill();
+ ctx.strokeStyle = 'rgba(120,70,0,0.3)'; ctx.lineWidth = 1; ctx.stroke();
+ ctx.restore();
+
+ // belly
+ ctx.fillStyle = 'rgba(255,250,230,0.85)';
+ ctx.beginPath(); ctx.ellipse(1, 5, 8, 5.5, 0, 0, Math.PI * 2); ctx.fill();
+
+ // eye
+ ctx.fillStyle = '#fff';
+ ctx.beginPath(); ctx.arc(6, -5, 5.4, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = '#222';
+ ctx.beginPath(); ctx.arc(7.6, -5, 2.5, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = '#fff';
+ ctx.beginPath(); ctx.arc(8.4, -6, 0.9, 0, Math.PI * 2); ctx.fill();
+
+ // beak
+ ctx.fillStyle = '#ff7043';
+ ctx.beginPath();
+ ctx.moveTo(12, -1); ctx.lineTo(21, 1.5); ctx.lineTo(12, 5);
+ ctx.closePath(); ctx.fill();
+ ctx.strokeStyle = 'rgba(150,40,0,0.4)'; ctx.lineWidth = 1; ctx.stroke();
+
+ ctx.restore();
+}
+
+function text(str, x, y, size, fill, align, weight) {
+ ctx.font = `${weight || 800} ${size}px 'Segoe UI', system-ui, sans-serif`;
+ ctx.textAlign = align || 'center';
+ ctx.textBaseline = 'middle';
+ ctx.lineWidth = Math.max(3, size * 0.14);
+ ctx.strokeStyle = 'rgba(20,30,50,0.85)';
+ ctx.strokeText(str, x, y);
+ ctx.fillStyle = fill;
+ ctx.fillText(str, x, y);
+}
+
+function drawSpeaker() {
+ const x = SPK.x, y = SPK.y + SPK.h / 2;
+ ctx.save();
+ ctx.globalAlpha = 0.9;
+ // speaker body
+ ctx.fillStyle = '#fff';
+ ctx.strokeStyle = 'rgba(20,30,50,0.8)';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(x, y - 4);
+ ctx.lineTo(x + 6, y - 4);
+ ctx.lineTo(x + 12, y - 10);
+ ctx.lineTo(x + 12, y + 10);
+ ctx.lineTo(x + 6, y + 4);
+ ctx.lineTo(x, y + 4);
+ ctx.closePath();
+ ctx.fill(); ctx.stroke();
+ if (muted) {
+ // red slash
+ ctx.strokeStyle = '#ff5a5a'; ctx.lineWidth = 2.6;
+ ctx.beginPath(); ctx.moveTo(x + 15, y - 8); ctx.lineTo(x + 25, y + 8); ctx.stroke();
+ } else {
+ // sound waves
+ ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.arc(x + 13, y, 6, -0.8, 0.8); ctx.stroke();
+ ctx.beginPath(); ctx.arc(x + 13, y, 10, -0.7, 0.7); ctx.stroke();
+ }
+ ctx.restore();
+}
+
+function drawUI() {
+ if (state === STATE.PLAY || state === STATE.DYING) {
+ text(String(score), GW / 2, 64, 52, '#fff');
+ }
+
+ if (state === STATE.READY) {
+ text('FLAPPY FLIGHT', GW / 2, 130, 40, '#ffe066');
+ const pulse = 0.75 + 0.25 * Math.sin(clock * 3.33);
+ ctx.globalAlpha = pulse;
+ text('Tap / Click / Space to flap', GW / 2, SKY_H * 0.66, 20, '#fff', 'center', 600);
+ ctx.globalAlpha = 1;
+ if (best > 0) text('Best: ' + best, GW / 2, SKY_H * 0.66 + 34, 17, '#cfe3ff', 'center', 600);
+ text('P pause · M mute', GW / 2, SKY_H * 0.66 + 62, 13, 'rgba(255,255,255,0.7)', 'center', 600);
+
+ // hint arrow near bird
+ ctx.strokeStyle = 'rgba(255,255,255,0.7)';
+ ctx.lineWidth = 3;
+ const ay = bird.y - 40 - Math.sin(clock * 4) * 5;
+ ctx.beginPath();
+ ctx.moveTo(BIRD_X, ay + 16); ctx.lineTo(BIRD_X, ay);
+ ctx.moveTo(BIRD_X - 7, ay + 8); ctx.lineTo(BIRD_X, ay);
+ ctx.lineTo(BIRD_X + 7, ay + 8);
+ ctx.stroke();
+ }
+
+ if (state === STATE.OVER) {
+ const slide = Math.min(overT / 0.35, 1);
+ const ease = 1 - Math.pow(1 - slide, 3);
+ ctx.save();
+ ctx.globalAlpha = ease;
+ ctx.translate(0, (1 - ease) * -30);
+
+ // panel
+ const pw = 280, ph = 190, px = (GW - pw) / 2, py = 150;
+ ctx.fillStyle = 'rgba(18,28,48,0.92)';
+ roundRect(px, py, pw, ph, 14); ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 2; ctx.stroke();
+
+ text('GAME OVER', GW / 2, py + 36, 30, '#ff8a65');
+ text('SCORE', GW / 2 - 66, py + 82, 14, '#9fb4d8', 'center', 600);
+ text(String(score), GW / 2 - 66, py + 112, 34, '#fff');
+ text('BEST', GW / 2 + 66, py + 82, 14, '#9fb4d8', 'center', 600);
+ text(String(best), GW / 2 + 66, py + 112, 34, score >= best && score > 0 ? '#ffe066' : '#fff');
+
+ // medal
+ if (score >= 10) {
+ const medal = score >= 40 ? '#ffd700' : score >= 25 ? '#c0c0c0' : '#cd7f32';
+ ctx.fillStyle = medal;
+ ctx.beginPath(); ctx.arc(GW / 2, py + 148, 13, 0, Math.PI * 2); ctx.fill();
+ ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 2; ctx.stroke();
+ ctx.fillStyle = 'rgba(255,255,255,0.55)';
+ ctx.beginPath(); ctx.arc(GW / 2 - 4, py + 144, 4, 0, Math.PI * 2); ctx.fill();
+ }
+
+ if (overT > 0.45) {
+ const pulse = 0.7 + 0.3 * Math.sin(clock * 3.57);
+ ctx.globalAlpha = ease * pulse;
+ text('Tap or press Space to restart', GW / 2, py + ph + 40, 18, '#fff', 'center', 600);
+ }
+ ctx.restore();
+ }
+
+ if (paused && state === STATE.PLAY) {
+ ctx.fillStyle = 'rgba(10,16,28,0.55)';
+ ctx.fillRect(0, 0, GW, GH);
+ text('PAUSED', GW / 2, SKY_H * 0.42, 40, '#ffe066');
+ const pulse = 0.7 + 0.3 * Math.sin(clock * 3.33);
+ ctx.globalAlpha = pulse;
+ text('Tap or press P to resume', GW / 2, SKY_H * 0.42 + 42, 18, '#fff', 'center', 600);
+ ctx.globalAlpha = 1;
+ }
+
+ drawSpeaker();
+}
+
+function draw() {
+ ctx.save();
+ if (shake > 0) {
+ const s = shake * shake * 9;
+ ctx.translate((Math.random() - 0.5) * s, (Math.random() - 0.5) * s);
+ }
+ drawSky();
+ drawParallax();
+ drawPipes();
+ drawGround();
+ drawBird();
+ ctx.restore();
+
+ drawUI();
+
+ if (flash > 0) {
+ ctx.fillStyle = `rgba(255,255,255,${(flash * 0.75).toFixed(3)})`;
+ ctx.fillRect(0, 0, GW, GH);
+ }
+}
+
+// ---------- main loop ----------
+let last = performance.now();
+function frame(now) {
+ let dt = (now - last) / 1000;
+ last = now;
+ if (dt > 0.05) dt = 0.05; // clamp after tab-switch
+ if (!paused) {
+ clock += dt;
+ update(dt);
+ }
+ draw();
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+})();
+</script>
+</body>
+</html>
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2bf19fc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "flappy-flight",
+ "version": "1.0.0",
+ "description": "A polished, self-contained Flappy-Bird-style game in a single HTML file. No assets, no libraries.",
+ "scripts": {
+ "start": "node server.js",
+ "serve": "node server.js"
+ },
+ "keywords": ["game", "flappy-bird", "canvas", "html5", "single-file", "no-dependencies"],
+ "license": "MIT",
+ "private": true
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..416d1a4
--- /dev/null
+++ b/server.js
@@ -0,0 +1,34 @@
+// Zero-dependency static file server for Flappy Flight.
+// Usage: node server.js [port] (default 8080)
+'use strict';
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || process.argv[2] || 8080;
+const ROOT = __dirname;
+const TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon'
+};
+
+http.createServer((req, res) => {
+ let urlPath = decodeURIComponent(req.url.split('?')[0]);
+ if (urlPath === '/') urlPath = '/index.html';
+ // resolve inside ROOT only (no path traversal)
+ const filePath = path.join(ROOT, path.normalize(urlPath));
+ if (!filePath.startsWith(ROOT)) {
+ res.writeHead(403); res.end('Forbidden'); return;
+ }
+ fs.readFile(filePath, (err, data) => {
+ if (err) { res.writeHead(404); res.end('Not found'); return; }
+ res.writeHead(200, { 'Content-Type': TYPES[path.extname(filePath)] || 'application/octet-stream' });
+ res.end(data);
+ });
+}).listen(PORT, () => {
+ console.log(`Flappy Flight → http://localhost:${PORT}`);
+});
(oldest)
·
back to Flappy Flight
·
chore: harden dev server, v1.0.0 -> v1.0.1 (session close) f89798c →