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