← back to Flappy Clone

index.html

674 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="#4ec0ca">
<title>Flappy Clone</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#1a1a2e;font-family:system-ui,-apple-system,sans-serif}
canvas{display:block;margin:0 auto;background:#70c5ce;cursor:pointer;touch-action:none}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(function(){
const canvas=document.getElementById('c');
const ctx=canvas.getContext('2d');

// ---- Virtual world: all game logic runs in a fixed 480x720 space. ----
// The canvas is scaled to the display only at draw time, so resizing never
// corrupts object positions and physics stays resolution-independent.
const VW=480, VH=720;
let cssW=VW, cssH=VH, dpr=1;

function resize(){
  const maxW=480, maxH=720;
  const rw=window.innerWidth, rh=window.innerHeight;
  // Fit the 480:720 (2:3) frame inside the viewport, allow slight upscale.
  const s=Math.min(rw/maxW, rh/maxH, 1.5);
  cssW=Math.floor(maxW*s);
  cssH=Math.floor(maxH*s);
  dpr=Math.min(window.devicePixelRatio||1, 2);
  canvas.width=Math.floor(cssW*dpr);
  canvas.height=Math.floor(cssH*dpr);
  canvas.style.width=cssW+'px';
  canvas.style.height=cssH+'px';
}
resize();
window.addEventListener('resize',resize);

// ---- Tunable constants (in virtual units / per 60Hz tick) ----
const GRAVITY=0.35;
const FLAP=-6.8;
const PIPE_W=60;
const PIPE_GAP=150;
const PIPE_SPEED=2.2;
const BIRD_R=16;
const GROUND_H=80;
const STEP=1000/60;            // fixed physics timestep (ms)

let bird,pipes,score,best,state,frame,particles,clouds,hills,speed,gapSize,shake;
let muted=false;

try{ best=parseInt(localStorage.getItem('flappy_best'))||0; }catch(e){ best=0; }
try{ muted=localStorage.getItem('flappy_muted')==='1'; }catch(e){ muted=false; }

// UI buttons in virtual coords (top-left, away from centered score).
const BTN={size:30, y:14, muteX:14, pauseX:52};

function init(){
  bird={x:VW*0.28,y:VH*0.4,vy:0,rot:0,wing:0};
  pipes=[];
  score=0;
  state='ready';
  frame=0;
  particles=[];
  speed=PIPE_SPEED;
  gapSize=PIPE_GAP;
  shake=0;
  spawnClouds();
  spawnHills();
}

function spawnClouds(){
  clouds=[];
  for(let i=0;i<6;i++){
    clouds.push({
      x:Math.random()*VW*1.5,
      y:VH*0.05+Math.random()*VH*0.35,
      w:40+Math.random()*60,
      speed:0.15+Math.random()*0.25,
      op:0.4+Math.random()*0.4
    });
  }
}

function spawnHills(){
  hills=[];
  for(let i=0;i<8;i++){
    hills.push({
      x:i*VW*0.4+Math.random()*50,
      y:VH-GROUND_H,
      w:120+Math.random()*100,
      h:40+Math.random()*80,
      layer:i%2,
      speed:0.3+(i%2)*0.2
    });
  }
}

function addPipe(){
  const minY=100;
  const maxY=VH-GROUND_H-gapSize-80;
  const top=minY+Math.random()*(maxY-minY);
  pipes.push({x:VW+10,top:top,gap:gapSize,passed:false});
}

// ---- Web Audio: tiny synthesized blips, no external assets ----
let actx=null;
function beep(freq,dur,type,vol,slideTo){
  if(muted)return;
  try{
    if(!actx)actx=new (window.AudioContext||window.webkitAudioContext)();
    if(actx.state==='suspended')actx.resume();
    const o=actx.createOscillator(), g=actx.createGain();
    o.type=type||'square';
    const t=actx.currentTime;
    o.frequency.setValueAtTime(freq,t);
    if(slideTo)o.frequency.exponentialRampToValueAtTime(slideTo,t+dur);
    g.gain.setValueAtTime((vol||0.15),t);
    g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
    o.connect(g);g.connect(actx.destination);
    o.start(t);o.stop(t+dur);
  }catch(e){}
}
const sndFlap =()=>beep(520,0.09,'square',0.12,300);
const sndScore=()=>{beep(660,0.08,'square',0.12);setTimeout(()=>beep(880,0.09,'square',0.12),70);};
const sndHit  =()=>beep(180,0.25,'sawtooth',0.2,60);
const sndDie  =()=>setTimeout(()=>beep(120,0.4,'triangle',0.18,50),120);

function flap(){
  if(state==='ready'){state='play';bird.vy=FLAP;sndFlap();}
  else if(state==='play'){bird.vy=FLAP;burst(bird.x,bird.y,6,'#fff');sndFlap();}
  else if(state==='over'){init();}
  // if paused, flap does nothing (resume is via button/key)
}

function togglePause(){
  if(state==='play')state='paused';
  else if(state==='paused')state='play';
}

function toggleMute(){
  muted=!muted;
  try{localStorage.setItem('flappy_muted',muted?'1':'0');}catch(e){}
}

function burst(x,y,n,col){
  for(let i=0;i<n;i++){
    particles.push({
      x,y,
      vx:(Math.random()-0.5)*4,
      vy:(Math.random()-0.5)*4-1,
      life:20+Math.random()*15,
      max:35,
      col:col||'#ffd700',
      r:2+Math.random()*3
    });
  }
}

// ---- Input: map pointer to virtual coords, test UI buttons first ----
function toVirtual(clientX,clientY){
  const r=canvas.getBoundingClientRect();
  return {
    x:(clientX-r.left)*(VW/r.width),
    y:(clientY-r.top)*(VH/r.height)
  };
}
function hitBtn(v,bx){
  return v.x>=bx && v.x<=bx+BTN.size && v.y>=BTN.y && v.y<=BTN.y+BTN.size;
}
function handlePointer(clientX,clientY){
  const v=toVirtual(clientX,clientY);
  if(hitBtn(v,BTN.muteX)){toggleMute();return;}
  if(hitBtn(v,BTN.pauseX)){ if(state==='play'||state==='paused')togglePause(); return; }
  flap();
}

canvas.addEventListener('mousedown',e=>{e.preventDefault();handlePointer(e.clientX,e.clientY);});
canvas.addEventListener('touchstart',e=>{
  e.preventDefault();
  const t=e.changedTouches[0];
  handlePointer(t.clientX,t.clientY);
},{passive:false});
window.addEventListener('keydown',e=>{
  if(e.code==='Space'||e.key===' '){e.preventDefault();flap();}
  else if(e.key==='p'||e.key==='P'||e.code==='Escape'){e.preventDefault();togglePause();}
  else if(e.key==='m'||e.key==='M'){e.preventDefault();toggleMute();}
});
// Pause when the tab/window loses focus so you don't die while away.
window.addEventListener('blur',()=>{ if(state==='play')state='paused'; });

function update(){
  frame++;
  if(shake>0)shake*=0.9;

  // parallax (also drifts slowly on non-play screens)
  const bgMul=(state==='play')?1:0.3;
  clouds.forEach(c=>{
    c.x-=c.speed*bgMul;
    if(c.x+c.w<-10){c.x=VW+Math.random()*100;c.y=VH*0.05+Math.random()*VH*0.35;}
  });
  hills.forEach(h=>{
    h.x-=h.speed*(state==='play'?1:0.2);
    if(h.x+h.w<-10){h.x=VW+Math.random()*80;h.h=40+Math.random()*80;h.w=120+Math.random()*100;}
  });

  particles=particles.filter(p=>{
    p.x+=p.vx;p.y+=p.vy;p.vy+=0.08;p.life--;
    return p.life>0;
  });

  if(state!=='play')return;

  bird.vy+=GRAVITY;
  bird.y+=bird.vy;
  bird.rot=Math.min(Math.PI/3,Math.max(-Math.PI/4,bird.vy*0.06));
  bird.wing=Math.sin(frame*0.4)*0.4;

  // soft ceiling: bonk instead of instant death (fairer than the original)
  if(bird.y-BIRD_R<0){bird.y=BIRD_R;if(bird.vy<0)bird.vy=0;}

  // difficulty ramps with score
  const diff=Math.min(score*0.08,2.5);
  speed=PIPE_SPEED+diff;
  gapSize=Math.max(PIPE_GAP-score*2.5,100);

  if(pipes.length===0||pipes[pipes.length-1].x<VW-180-diff*15){
    addPipe();
  }

  for(let i=pipes.length-1;i>=0;i--){
    const p=pipes[i];
    p.x-=speed;
    if(!p.passed&&p.x+PIPE_W<bird.x){
      p.passed=true;
      score++;
      burst(bird.x+20,bird.y,8,'#ffd700');
      sndScore();
      if(score>best){best=score;try{localStorage.setItem('flappy_best',best);}catch(e){}}
    }
    if(p.x+PIPE_W<-10)pipes.splice(i,1);

    // collision (circle-vs-rect, forgiving radius)
    const bx=bird.x,by=bird.y,br=BIRD_R*0.85;
    const px=p.x,pw=PIPE_W;
    if(bx+br>px&&bx-br<px+pw){
      if(by-br<p.top||by+br>p.top+p.gap){
        die();
      }
    }
  }

  const groundY=VH-GROUND_H;
  if(bird.y+BIRD_R>groundY)die();
}

function die(){
  if(state!=='play')return;
  state='over';
  shake=12;
  burst(bird.x,bird.y,20,'#ff6b6b');
  burst(bird.x,bird.y,12,'#fff');
  sndHit();sndDie();
}

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 drawBg(){
  const g=ctx.createLinearGradient(0,0,0,VH);
  g.addColorStop(0,'#4ec0ca');
  g.addColorStop(0.5,'#70c5ce');
  g.addColorStop(1,'#b8e0e8');
  ctx.fillStyle=g;
  ctx.fillRect(0,0,VW,VH);

  // sun
  ctx.beginPath();
  ctx.arc(VW*0.82,VH*0.12,30,0,Math.PI*2);
  ctx.fillStyle='rgba(255,240,180,0.7)';
  ctx.fill();
  ctx.beginPath();
  ctx.arc(VW*0.82,VH*0.12,18,0,Math.PI*2);
  ctx.fillStyle='rgba(255,250,220,0.9)';
  ctx.fill();

  // clouds
  clouds.forEach(c=>{
    ctx.globalAlpha=c.op;
    ctx.fillStyle='#fff';
    drawCloud(c.x,c.y,c.w/50);
  });
  ctx.globalAlpha=1;

  // hills (far then near)
  hills.filter(h=>h.layer===0).forEach(h=>{
    ctx.fillStyle='#5aad5a';
    ctx.beginPath();
    ctx.ellipse(h.x+h.w/2,h.y,h.w/2,h.h,0,Math.PI,0);
    ctx.fill();
  });
  hills.filter(h=>h.layer===1).forEach(h=>{
    ctx.fillStyle='#4a9a4a';
    ctx.beginPath();
    ctx.ellipse(h.x+h.w/2,h.y,h.w/2,h.h*1.1,0,Math.PI,0);
    ctx.fill();
  });
}

function drawCloud(x,y,s){
  ctx.beginPath();
  ctx.arc(x,y,12*s,0,Math.PI*2);
  ctx.arc(x+16*s,y-6*s,14*s,0,Math.PI*2);
  ctx.arc(x+34*s,y,13*s,0,Math.PI*2);
  ctx.arc(x+16*s,y+4*s,11*s,0,Math.PI*2);
  ctx.fill();
}

function drawGround(){
  const gy=VH-GROUND_H;
  ctx.fillStyle='#ded895';
  ctx.fillRect(0,gy,VW,GROUND_H);
  ctx.fillStyle='#5ee05e';
  ctx.fillRect(0,gy,VW,14);
  ctx.fillStyle='#4ec04e';
  ctx.fillRect(0,gy+10,VW,6);
  // scrolling grass tufts
  const off=(frame*(state==='play'?speed:0.3)*0.5)%24;
  ctx.fillStyle='#3da83d';
  for(let x=-off;x<VW;x+=24){
    ctx.beginPath();
    ctx.moveTo(x,gy+14);
    ctx.lineTo(x+8,gy);
    ctx.lineTo(x+16,gy+14);
    ctx.fill();
  }
  ctx.strokeStyle='#c4a84a';
  ctx.lineWidth=2;
  ctx.beginPath();
  ctx.moveTo(0,gy);
  ctx.lineTo(VW,gy);
  ctx.stroke();
}

function drawPipes(){
  const pw=PIPE_W;
  pipes.forEach(p=>{
    const cap=pw*0.15;
    const grad1=ctx.createLinearGradient(p.x,0,p.x+pw,0);
    grad1.addColorStop(0,'#3d9e2f');
    grad1.addColorStop(0.3,'#5ed14a');
    grad1.addColorStop(0.7,'#4caf3a');
    grad1.addColorStop(1,'#2d7a22');
    // top pipe body
    ctx.fillStyle=grad1;
    ctx.fillRect(p.x+2,0,pw-4,p.top);
    roundRect(p.x-cap,p.top-28,pw+cap*2,28,4);
    ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.15)';
    ctx.fillRect(p.x+4,0,6,p.top-28);
    // bottom pipe
    const by=p.top+p.gap;
    ctx.fillStyle=grad1;
    ctx.fillRect(p.x+2,by,pw-4,VH-by-GROUND_H);
    roundRect(p.x-cap,by,pw+cap*2,28,4);
    ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.15)';
    ctx.fillRect(p.x+4,by+28,6,VH-by-GROUND_H-28);
    // outlines
    ctx.strokeStyle='rgba(0,0,0,0.2)';
    ctx.lineWidth=2;
    ctx.strokeRect(p.x+2,0,pw-4,p.top-28);
    ctx.strokeRect(p.x+2,by+28,pw-4,VH-by-GROUND_H-28);
  });
}

function drawBird(){
  const r=BIRD_R;
  ctx.save();
  ctx.translate(bird.x,bird.y);
  ctx.rotate(bird.rot);

  ctx.beginPath();
  ctx.ellipse(0,0,r*1.1,r*0.95,0,0,Math.PI*2);
  const bg=ctx.createRadialGradient(-r*0.3,-r*0.3,0,0,0,r);
  bg.addColorStop(0,'#ffe566');
  bg.addColorStop(1,'#f0c020');
  ctx.fillStyle=bg;
  ctx.fill();
  ctx.strokeStyle='#d4a010';
  ctx.lineWidth=1.5;
  ctx.stroke();

  ctx.save();
  ctx.rotate(bird.wing);
  ctx.beginPath();
  ctx.ellipse(-2,2,r*0.65,r*0.4,0.3,0,Math.PI*2);
  ctx.fillStyle='#f5d040';
  ctx.fill();
  ctx.strokeStyle='#d4a010';
  ctx.lineWidth=1;
  ctx.stroke();
  ctx.restore();

  ctx.beginPath();
  ctx.arc(r*0.4,-r*0.25,r*0.38,0,Math.PI*2);
  ctx.fillStyle='#fff';
  ctx.fill();
  ctx.strokeStyle='#333';
  ctx.lineWidth=1;
  ctx.stroke();
  ctx.beginPath();
  ctx.arc(r*0.5,-r*0.25,r*0.18,0,Math.PI*2);
  ctx.fillStyle='#222';
  ctx.fill();
  ctx.beginPath();
  ctx.arc(r*0.55,-r*0.35,r*0.08,0,Math.PI*2);
  ctx.fillStyle='#fff';
  ctx.fill();

  ctx.beginPath();
  ctx.moveTo(r*0.7,0);
  ctx.lineTo(r*1.45,r*0.12);
  ctx.lineTo(r*0.7,r*0.3);
  ctx.closePath();
  ctx.fillStyle='#ff8c00';
  ctx.fill();
  ctx.strokeStyle='#e07000';
  ctx.lineWidth=1;
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(r*0.7,r*0.12);
  ctx.lineTo(r*1.3,r*0.12);
  ctx.strokeStyle='#e07000';
  ctx.stroke();

  ctx.beginPath();
  ctx.ellipse(2,r*0.35,r*0.55,r*0.4,0,0,Math.PI*2);
  ctx.fillStyle='rgba(255,255,255,0.35)';
  ctx.fill();

  ctx.restore();
}

function drawParticles(){
  particles.forEach(p=>{
    ctx.globalAlpha=p.life/p.max;
    ctx.fillStyle=p.col;
    ctx.beginPath();
    ctx.arc(p.x,p.y,p.r*0.6,0,Math.PI*2);
    ctx.fill();
  });
  ctx.globalAlpha=1;
}

function drawButtons(){
  // Mute button
  drawIconBtn(BTN.muteX,BTN.y,()=>{
    ctx.strokeStyle='#fff';ctx.fillStyle='#fff';ctx.lineWidth=2;
    const cx=BTN.muteX+9,cy=BTN.y+BTN.size/2;
    ctx.beginPath();
    ctx.moveTo(cx-4,cy-4);ctx.lineTo(cx,cy-4);ctx.lineTo(cx+5,cy-9);
    ctx.lineTo(cx+5,cy+9);ctx.lineTo(cx,cy+4);ctx.lineTo(cx-4,cy+4);
    ctx.closePath();ctx.fill();
    if(muted){
      ctx.beginPath();ctx.moveTo(cx+9,cy-6);ctx.lineTo(cx+16,cy+6);
      ctx.moveTo(cx+16,cy-6);ctx.lineTo(cx+9,cy+6);ctx.stroke();
    }else{
      ctx.beginPath();ctx.arc(cx+8,cy,5,-0.6,0.6);ctx.stroke();
      ctx.beginPath();ctx.arc(cx+8,cy,9,-0.6,0.6);ctx.stroke();
    }
  });
  // Pause / play button (only while in a round)
  if(state==='play'||state==='paused'){
    drawIconBtn(BTN.pauseX,BTN.y,()=>{
      ctx.fillStyle='#fff';
      const cx=BTN.pauseX+BTN.size/2,cy=BTN.y+BTN.size/2;
      if(state==='play'){
        ctx.fillRect(cx-6,cy-7,4,14);
        ctx.fillRect(cx+2,cy-7,4,14);
      }else{
        ctx.beginPath();
        ctx.moveTo(cx-5,cy-7);ctx.lineTo(cx+7,cy);ctx.lineTo(cx-5,cy+7);
        ctx.closePath();ctx.fill();
      }
    });
  }
}
function drawIconBtn(x,y,drawIcon){
  ctx.fillStyle='rgba(0,0,0,0.25)';
  roundRect(x,y,BTN.size,BTN.size,7);ctx.fill();
  drawIcon();
}

function medalFor(s){
  if(s>=40)return{name:'PLATINUM',c1:'#e5e4e2',c2:'#b0c4de'};
  if(s>=30)return{name:'GOLD',c1:'#ffd700',c2:'#daa520'};
  if(s>=20)return{name:'SILVER',c1:'#e0e0e0',c2:'#a8a8a8'};
  if(s>=10)return{name:'BRONZE',c1:'#cd7f32',c2:'#8c5a2b'};
  return null;
}

function drawUI(){
  ctx.textAlign='center';
  ctx.textBaseline='middle';

  if(state==='ready'){
    ctx.font='bold 42px system-ui,sans-serif';
    ctx.fillStyle='rgba(0,0,0,0.25)';
    ctx.fillText('FLAPPY',VW/2+2,VH*0.22+2);
    ctx.fillStyle='#fff';
    ctx.strokeStyle='#e87a20';
    ctx.lineWidth=4;
    ctx.strokeText('FLAPPY',VW/2,VH*0.22);
    ctx.fillText('FLAPPY',VW/2,VH*0.22);

    ctx.font='bold 28px system-ui,sans-serif';
    ctx.fillStyle='rgba(0,0,0,0.25)';
    ctx.fillText('CLONE',VW/2+2,VH*0.29+2);
    ctx.fillStyle='#ffd700';
    ctx.strokeStyle='#c49000';
    ctx.lineWidth=3;
    ctx.strokeText('CLONE',VW/2,VH*0.29);
    ctx.fillText('CLONE',VW/2,VH*0.29);

    const pulse=0.7+Math.sin(frame*0.08)*0.3;
    ctx.globalAlpha=pulse;
    ctx.font='16px system-ui,sans-serif';
    ctx.fillStyle='#fff';
    ctx.strokeStyle='rgba(0,0,0,0.3)';
    ctx.lineWidth=3;
    ctx.strokeText('Click / Tap / Space to flap',VW/2,VH*0.55);
    ctx.fillText('Click / Tap / Space to flap',VW/2,VH*0.55);
    ctx.globalAlpha=1;

    if(best>0){
      ctx.font='14px system-ui,sans-serif';
      ctx.fillStyle='rgba(255,255,255,0.75)';
      ctx.fillText('Best: '+best,VW/2,VH*0.62);
    }
  }

  if(state==='play'||state==='over'||state==='paused'){
    ctx.font='bold 48px system-ui,sans-serif';
    ctx.fillStyle='rgba(0,0,0,0.2)';
    ctx.fillText(String(score),VW/2+2,VH*0.12+2);
    ctx.fillStyle='#fff';
    ctx.strokeStyle='rgba(0,0,0,0.4)';
    ctx.lineWidth=4;
    ctx.strokeText(String(score),VW/2,VH*0.12);
    ctx.fillText(String(score),VW/2,VH*0.12);
  }

  if(state==='paused'){
    ctx.fillStyle='rgba(0,0,0,0.35)';
    ctx.fillRect(0,0,VW,VH);
    ctx.font='bold 34px system-ui,sans-serif';
    ctx.fillStyle='#fff';
    ctx.fillText('PAUSED',VW/2,VH*0.44);
    ctx.font='15px system-ui,sans-serif';
    ctx.fillStyle='rgba(255,255,255,0.85)';
    ctx.fillText('Press P or tap ▶ to resume',VW/2,VH*0.52);
  }

  if(state==='over'){
    const pw=220,ph=200;
    const px=(VW-pw)/2,py=VH*0.26;
    ctx.fillStyle='rgba(0,0,0,0.45)';
    roundRect(px+3,py+3,pw,ph,12);ctx.fill();
    ctx.fillStyle='rgba(255,255,255,0.94)';
    roundRect(px,py,pw,ph,12);ctx.fill();
    ctx.strokeStyle='#e87a20';
    ctx.lineWidth=3;
    roundRect(px,py,pw,ph,12);ctx.stroke();

    ctx.font='bold 28px system-ui,sans-serif';
    ctx.fillStyle='#e74c3c';
    ctx.fillText('GAME OVER',VW/2,py+32);

    // medal
    const med=medalFor(score);
    const medX=px+42, medY=py+92;
    if(med){
      const mg=ctx.createRadialGradient(medX-6,medY-6,2,medX,medY,26);
      mg.addColorStop(0,med.c1);mg.addColorStop(1,med.c2);
      ctx.beginPath();ctx.arc(medX,medY,26,0,Math.PI*2);
      ctx.fillStyle=mg;ctx.fill();
      ctx.strokeStyle='rgba(0,0,0,0.25)';ctx.lineWidth=2;ctx.stroke();
      ctx.font='9px system-ui,sans-serif';
      ctx.fillStyle='rgba(0,0,0,0.55)';
      ctx.fillText(med.name,medX,medY);
    }else{
      ctx.beginPath();ctx.arc(medX,medY,26,0,Math.PI*2);
      ctx.strokeStyle='#ccc';ctx.setLineDash([4,4]);ctx.lineWidth=2;ctx.stroke();
      ctx.setLineDash([]);
      ctx.font='9px system-ui,sans-serif';ctx.fillStyle='#bbb';
      ctx.fillText('—',medX,medY);
    }

    ctx.textAlign='left';
    ctx.font='13px system-ui,sans-serif';
    ctx.fillStyle='#888';
    ctx.fillText('SCORE',px+92,py+78);
    ctx.fillText('BEST',px+92,py+112);
    ctx.font='bold 26px system-ui,sans-serif';
    ctx.fillStyle='#333';
    ctx.fillText(String(score),px+92,py+98);
    ctx.fillStyle=(score>=best&&score>0)?'#e87a20':'#333';
    ctx.fillText(String(best),px+92,py+132);
    ctx.textAlign='center';

    if(score>=best&&score>0){
      ctx.font='bold 12px system-ui,sans-serif';
      ctx.fillStyle='#e87a20';
      ctx.fillText('NEW BEST!',VW/2,py+160);
    }

    const bp=0.85+Math.sin(frame*0.1)*0.15;
    ctx.globalAlpha=bp;
    ctx.font='14px system-ui,sans-serif';
    ctx.fillStyle='#e87a20';
    ctx.fillText('Click / Space to restart',VW/2,py+183);
    ctx.globalAlpha=1;
  }
}

function render(){
  const s=cssW/VW;                 // uniform virtual->CSS scale (aspect preserved)
  ctx.setTransform(dpr*s,0,0,dpr*s,0,0);
  ctx.clearRect(0,0,VW,VH);
  ctx.save();
  if(shake>0){
    ctx.translate((Math.random()-0.5)*shake,(Math.random()-0.5)*shake);
  }
  drawBg();
  drawPipes();
  drawGround();
  drawBird();
  drawParticles();
  ctx.restore();
  drawButtons();
  drawUI();
}

// ---- Fixed-timestep main loop: physics is framerate-independent ----
let last=performance.now(), acc=0;
function loop(now){
  let dt=now-last; last=now;
  if(dt>250)dt=250;              // clamp after tab-switch / stalls
  acc+=dt;
  let steps=0;
  while(acc>=STEP && steps<5){ update(); acc-=STEP; steps++; }
  render();
  requestAnimationFrame(loop);
}

init();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>