← back to Flappy Flight

index.html

646 lines

<!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>