[object Object]

← back to Flappy Clone

Flappy Clone: polished single-file game built from Grok 4.5 artifact

511ca55ef481a93c7600f0b6d353c55285ed17ce · 2026-07-23 15:38:50 -0700 · Steve Abrams

- Fixed framerate dependence (fixed-timestep accumulator)
- Fixed resize corruption (virtual 480x720 space, DPR-crisp render)
- Softened instant ceiling death to a clamp
- Added: persistent best (localStorage), sound + mute, pause, medals,
  screen-shake, on-canvas mobile buttons, blur-to-pause

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 511ca55ef481a93c7600f0b6d353c55285ed17ce
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 23 15:38:50 2026 -0700

    Flappy Clone: polished single-file game built from Grok 4.5 artifact
    
    - Fixed framerate dependence (fixed-timestep accumulator)
    - Fixed resize corruption (virtual 480x720 space, DPR-crisp render)
    - Softened instant ceiling death to a clamp
    - Added: persistent best (localStorage), sound + mute, pause, medals,
      screen-shake, on-canvas mobile buttons, blur-to-pause
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore  |   8 +
 README.md   |  68 ++++++
 index.html  | 673 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 preview.png | Bin 0 -> 38347 bytes
 4 files changed, 749 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/README.md b/README.md
new file mode 100644
index 0000000..d04fad4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,68 @@
+# Flappy Clone
+
+A polished, self-contained Flappy-Bird-style game in a single HTML file — no
+external assets, no libraries, no build step. Space / click / tap to flap
+through scrolling pipe gaps.
+
+![preview](preview.png)
+
+## Play
+
+Open `index.html` in any modern browser:
+
+```sh
+open index.html          # macOS
+# or just double-click the file
+```
+
+That's it — the whole game is one file.
+
+## Controls
+
+| Action  | Input                         |
+|---------|-------------------------------|
+| Flap    | Space · Click · Tap           |
+| Pause   | `P` · `Esc` · ⏸ button        |
+| Mute    | `M` · 🔊 button               |
+| Restart | Space · Click (on Game Over)  |
+
+## Features
+
+- Gravity + flap physics, scrolling pipes, score, increasing difficulty
+  (pipes speed up and gaps tighten as your score climbs)
+- Parallax background — drifting clouds, sun, two layers of hills, and a
+  scrolling grass foreground
+- Game-over panel with **medals** (Bronze 10 · Silver 20 · Gold 30 · Platinum 40)
+- **Persistent best score** via `localStorage`
+- Synthesized **sound effects** (Web Audio, no audio files) with mute toggle
+- **Pause** support (auto-pauses when the tab loses focus)
+- Screen-shake on crash, particle bursts on flap / score / death
+- Fully responsive + retina-crisp (devicePixelRatio aware)
+
+## Provenance & what changed
+
+This started from Grok 4.5's Model Arena attempt at the "All-Models — Flappy
+Clone" challenge. That artifact was a solid, good-looking base. This build
+keeps its art and feel while fixing real defects and extending it:
+
+**Defects fixed**
+- **Framerate dependence** — the original tied physics to a 60 fps assumption
+  via `requestAnimationFrame`, so it ran ~2× too fast on 120 Hz displays.
+  Replaced with a fixed-timestep accumulator (physics is now identical at any
+  refresh rate).
+- **Resize corruption** — the original scattered `*scale` through all logic and
+  never repositioned objects on resize, so resizing mid-game broke the world.
+  Rewrote to simulate in a fixed **480×720 virtual space** and scale only at
+  draw time — resizing is now seamless, and rendering is DPR-crisp.
+- **Unfair instant ceiling death** — touching the top killed you instantly;
+  softened to a bonk/clamp (classic Flappy behavior).
+
+**Extensions added**
+- Persistent best score, sound effects + mute, pause, medals, screen-shake,
+  on-canvas touch buttons for mobile, and blur-to-pause.
+
+## Tech
+
+Plain Canvas 2D + Web Audio. ~500 lines, one file, zero dependencies.
+Verified with headless Chrome (no runtime errors on the ready screen or during
+live play).
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..a19602e
--- /dev/null
+++ b/index.html
@@ -0,0 +1,673 @@
+<!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>
diff --git a/preview.png b/preview.png
new file mode 100644
index 0000000..0d73a58
Binary files /dev/null and b/preview.png differ

(oldest)  ·  back to Flappy Clone  ·  (newest)