← 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
A .gitignoreA README.mdA index.htmlA preview.png
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.
+
+
+
+## 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)