Angry Birds, one level, built by Claude Haiku 5.5
Hostedvia Claude CodeOpenRouter
Show the code the model wroteHide the code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Slingshot Fort</title>
<style>
:root { --ink: #2b1d0e; --cream: #fff6dc; --gold: #ffd54a; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: #7fd0f5;
color: var(--ink);
font-family: "Trebuchet MS", "Avenir Next", "Segoe UI", system-ui, sans-serif;
user-select: none;
-webkit-user-select: none;
}
[hidden] { display: none !important; }
canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
touch-action: none;
cursor: grab;
}
canvas.grabbing { cursor: grabbing; }
.hud { position: fixed; pointer-events: none; color: #fff; text-shadow: 0 3px 0 var(--ink); }
.hud-left { top: 14px; left: 20px; }
.hud-right { top: 14px; right: 16px; display: flex; align-items: center; gap: 14px; }
.label { font-size: 12px; font-weight: 800; letter-spacing: 2px; opacity: .9; }
#score { font-size: clamp(22px, 2.8vw, 36px); font-weight: 900; min-height: 1.2em; }
.icons { display: flex; gap: 6px; margin-top: 8px; }
.hud-right .icons { margin-top: 0; }
.ico {
width: 22px; height: 22px; border-radius: 50%;
border: 2.5px solid var(--ink);
box-sizing: border-box;
box-shadow: inset -3px -4px 0 rgba(0, 0, 0, .18);
text-shadow: none;
}
.ico.bird { background: radial-gradient(circle at 40% 35%, #ff9a8a, #d62f25 70%); }
.ico.pig { background: radial-gradient(circle at 40% 35%, #d6ff9a, #4f9a2a 75%); }
.ico.off { opacity: .25; filter: grayscale(1); }
.icon-btn {
pointer-events: auto;
width: 44px; height: 44px;
border-radius: 14px;
border: 3px solid var(--ink);
background: var(--gold);
color: var(--ink);
box-shadow: 0 4px 0 var(--ink);
display: grid; place-items: center;
cursor: pointer;
padding: 0;
}
.icon-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--ink); }
.icon-btn svg { width: 24px; height: 24px; }
.hint {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
padding: 8px 18px;
border-radius: 999px;
background: rgba(43, 29, 14, .6);
color: #fff;
font-weight: 700;
font-size: 15px;
white-space: nowrap;
pointer-events: none;
}
.overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 40, 60, .35);
}
.card {
background: var(--cream);
border: 4px solid var(--ink);
border-radius: 22px;
padding: 24px 36px 30px;
text-align: center;
box-shadow: 0 10px 0 var(--ink);
min-width: min(360px, 86vw);
animation: pop .35s cubic-bezier(.2, 1.6, .4, 1);
}
h1 { margin: 0; font-size: clamp(34px, 6vw, 56px); font-weight: 900; }
h1.win { color: #2f9e44; text-shadow: 0 4px 0 var(--ink); }
h1.lose { color: #e0483e; text-shadow: 0 4px 0 var(--ink); }
.stars { display: flex; gap: 10px; justify-content: center; margin: 14px 0 6px; }
.star {
width: 64px; height: 64px;
fill: #d9d3c4; stroke: var(--ink); stroke-width: 1.2; stroke-linejoin: round;
opacity: 0; transform: scale(.4);
animation: starIn .45s cubic-bezier(.2, 1.6, .4, 1) forwards;
}
.star.on { fill: var(--gold); }
.star:nth-child(2) { animation-delay: .15s; }
.star:nth-child(3) { animation-delay: .3s; }
#result { font-size: 22px; font-weight: 800; margin: 8px 0 18px; }
.btn {
font: inherit; font-weight: 900; font-size: 20px;
color: var(--ink);
background: var(--gold);
border: 3px solid var(--ink);
border-radius: 14px;
padding: 10px 28px;
box-shadow: 0 5px 0 var(--ink);
cursor: pointer;
}
.btn:active { transform: translateY(4px); box-shadow: 0 1px 0 var(--ink); }
@keyframes pop { from { transform: scale(.6); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes starIn { to { transform: scale(1); opacity: 1; } }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div class="hud hud-left">
<div id="scoreLabel" class="label">SCORE</div>
<div id="score"></div>
<div id="birds" class="icons"></div>
</div>
<div class="hud hud-right">
<div id="pigs" class="icons"></div>
<button id="restart" class="icon-btn" title="Restart (R)" aria-label="Restart">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/>
<path d="M19.5 4.5v4.5h-4.5"/>
</svg>
</button>
</div>
<div id="hint" class="hint" hidden></div>
<div id="overlay" class="overlay" hidden>
<div class="card">
<h1 id="title"></h1>
<div id="stars" class="stars"></div>
<p id="result"></p>
<button id="overlayRestart" class="btn">Restart</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/planck@1.5.0/dist/planck.min.js"></script>
<script>
// ---------- Tunables ----------
const DT = 1 / 120; // fixed physics step (s)
const VEL_ITERS = 10, POS_ITERS = 8;
const GRAVITY = 10;
const VIEW_W = 26, VIEW_H = 12; // metres visible at the smallest fit
const WORLD_RIGHT = 60; // birds past this are gone
const ANCHOR = { x: 3.4, y: 3.1 }; // where the bird rests in the sling
const BIRD_R = 0.36, PIG_R = 0.4;
const MAX_PULL = 2.4, MIN_PULL = 0.35, LAUNCH_K = 6.0; // launch velocity = pull * K
const GRAB_R = 1.6; // how close a click must be to grab the bird
const WOOD_BREAK = 4.0; // per-step contact impulse thresholds (kg·m/s)
const PIG_POP = 3.0;
const STONE_CRACK = 9.0;
const SETTLE_TIME = 4; // the fort is simulated to rest before the first frame
const SCORE = { pig: 5000, wood: 300, bird: 10000 };
const MAT = {
wood: { density: 1.0, friction: 0.5, restitution: 0.1 },
stone: { density: 8.0, friction: 0.6, restitution: 0.05 },
pig: { density: 1.0, friction: 0.5, restitution: 0.1 },
bird: { density: 2.5, friction: 0.4, restitution: 0.25 },
};
// [x, y, w, h] for blocks, [x, y] for pigs (metres, ground at y = 0)
const LEVEL = {
stones: [
[18.2, 0.6, 0.5, 1.2], [20.2, 0.6, 0.5, 1.2], [22.2, 0.6, 0.5, 1.2], [24.2, 0.6, 0.5, 1.2],
[23.2, 1.8, 0.8, 0.8],
],
woods: [
[19.2, 1.3, 1.9, 0.22], [21.2, 1.3, 1.9, 0.22], [23.2, 1.3, 1.9, 0.22],
[18.7, 1.8, 0.2, 0.8], [19.7, 1.8, 0.2, 0.8], [19.2, 2.31, 1.5, 0.22],
],
pigs: [[19.2, 0.4], [21.2, 0.4], [23.2, 0.4]],
};
// ---------- Helpers ----------
const rnd = (a, b) => a + Math.random() * (b - a);
const lerp = (a, b, t) => a + (b - a) * t;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
const fmt = n => Math.round(n).toLocaleString('en-US');
const STAR = 'M12 2.6l2.8 6 6.5.8-4.8 4.5 1.3 6.5L12 17.2l-5.8 3.2 1.3-6.5-4.8-4.5 6.5-.8z';
function rr(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();
}
// ---------- Canvas, view and camera ----------
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const view = { W: 0, H: 0, dpr: 1, scale: 50, defX: 0, defY: 0 };
const cam = { x: 0, y: 0 };
function resize() {
view.dpr = Math.min(2, window.devicePixelRatio || 1);
view.W = window.innerWidth;
view.H = window.innerHeight;
canvas.width = Math.round(view.W * view.dpr);
canvas.height = Math.round(view.H * view.dpr);
view.scale = Math.min(view.H / VIEW_H, view.W / VIEW_W);
view.defX = -0.2 + view.W / (2 * view.scale); // left edge sits just left of the sling
view.defY = 0.3 * view.H / view.scale; // ground sits at ~80% of the height
}
const toWorld = (px, py) => ({
x: (px - view.W / 2) / view.scale + cam.x,
y: (view.H / 2 - py) / view.scale + cam.y,
});
const toScreen = (x, y) => ({
x: view.W / 2 + (x - cam.x) * view.scale,
y: view.H / 2 - (y - cam.y) * view.scale,
});
// ---------- Level and physics ----------
function makeData(kind, body, extra = {}) {
const d = { kind, body, impulse: 0, removed: false, seed: Math.random() * 100, cracks: 0, ...extra };
body.setUserData(d);
return d;
}
function buildLevel() {
const world = new planck.World({ gravity: planck.Vec2(0, -GRAVITY) });
const ground = world.createBody({ type: 'static', position: planck.Vec2(30, -1) });
ground.createFixture(planck.Box(45, 1), { friction: 0.7 });
ground.setUserData({ kind: 'ground' });
const L = { world, blocks: [], pigs: [], toRemove: [] };
const addBlock = (kind, [x, y, w, h]) => {
const m = MAT[kind];
const body = world.createBody({ type: 'dynamic', position: planck.Vec2(x, y) });
body.createFixture(planck.Box(w / 2, h / 2), { density: m.density, friction: m.friction, restitution: m.restitution });
return makeData(kind, body, { w, h });
};
for (const s of LEVEL.stones) L.blocks.push(addBlock('stone', s));
for (const s of LEVEL.woods) L.blocks.push(addBlock('wood', s));
for (const [x, y] of LEVEL.pigs) {
const body = world.createBody({ type: 'dynamic', position: planck.Vec2(x, y) });
body.createFixture(planck.Circle(PIG_R), { density: MAT.pig.density, friction: MAT.pig.friction, restitution: MAT.pig.restitution });
L.pigs.push(makeData('pig', body, { r: PIG_R }));
}
// Let the fort settle and fall asleep before the player (or demo) can touch it.
for (let i = 0; i < Math.round(SETTLE_TIME / DT); i++) world.step(DT, VEL_ITERS, POS_ITERS);
world.on('post-solve', (contact, impulse) => onImpact(L, contact, impulse));
return L;
}
function onImpact(L, contact, impulse) {
const a = contact.getFixtureA().getBody();
const b = contact.getFixtureB().getBody();
const da = a.getUserData(), db = b.getUserData();
let sum = 0;
for (const n of impulse.normalImpulses) sum += n;
if (da) da.impulse += sum;
if (db) db.impulse += sum;
if (L !== game.level || sum < 1.2) return;
const pa = a.getPosition(), pb = b.getPosition();
const x = (pa.x + pb.x) / 2, y = (pa.y + pb.y) / 2;
if (Math.random() < Math.min(1, sum / 10)) spawnDust(x, y, 2 + Math.floor(Math.random() * 2));
const birdHit = (da && da.kind === 'bird') || (db && db.kind === 'bird');
if (birdHit && sum > 2.5) spawnFeathers(x, y, 5);
if (sum > 6) game.shake = Math.min(1, game.shake + sum / 40);
}
function removeBody(L, d) {
if (d.removed) return;
d.removed = true;
L.toRemove.push(d.body);
}
function flushRemovals(L) {
for (const body of L.toRemove) L.world.destroyBody(body);
L.toRemove.length = 0;
}
function resolveDamage(L) {
for (const d of L.blocks) {
if (d.removed) continue;
if (d.kind === 'wood' && d.impulse > WOOD_BREAK) breakWood(L, d);
else if (d.kind === 'stone' && d.impulse > STONE_CRACK && d.cracks < 3) {
d.cracks++;
const p = d.body.getPosition();
spawnChips(p.x, p.y, 4);
}
}
for (const d of L.pigs) {
if (!d.removed && d.impulse > PIG_POP) popPig(L, d);
}
}
function breakWood(L, d) {
removeBody(L, d);
const p = d.body.getPosition();
spawnChunks(p.x, p.y, 6);
spawnDust(p.x, p.y, 3);
addScore(SCORE.wood, p.x, p.y + 0.3, '#ffe9a8', 20);
}
function popPig(L, d) {
removeBody(L, d);
const p = d.body.getPosition();
spawnGoo(p.x, p.y, 12);
spawnFeathers(p.x, p.y, 4);
popups.push({ x: p.x, y: p.y + 0.6, text: 'POP!', color: '#9dff5c', size: 28, age: 0, life: 0.8 });
addScore(SCORE.pig, p.x, p.y + 0.15, '#ffe066', 30);
game.shake = Math.min(1, game.shake + 0.5);
}
// ---------- Particles and popups ----------
const particles = [];
const popups = [];
function spawnParticle(p) {
particles.push({ age: 0, rot: 0, vr: 0, g: 0, ...p });
if (particles.length > 350) particles.splice(0, particles.length - 350);
}
function spawnDust(x, y, n) {
for (let i = 0; i < n; i++) spawnParticle({
type: 'dust', x: x + rnd(-0.2, 0.2), y: y + rnd(-0.15, 0.15),
vx: rnd(-1.4, 1.4), vy: rnd(0.4, 1.8), life: rnd(0.6, 1.0), r: rnd(0.12, 0.24),
color: '#d9c7a6', rot: rnd(0, 6), vr: rnd(-1, 1),
});
}
function spawnFeathers(x, y, n) {
for (let i = 0; i < n; i++) spawnParticle({
type: 'feather', x: x + rnd(-0.2, 0.2), y: y + rnd(-0.2, 0.2),
vx: rnd(-2.5, 2.5), vy: rnd(1.5, 4), life: rnd(1.6, 2.4), w: 0.22, h: 0.09,
color: Math.random() < 0.5 ? '#ffffff' : '#ff6b5e', rot: rnd(0, 6.28), vr: rnd(-8, 8), g: 0.5,
});
}
function spawnChunks(x, y, n) {
for (let i = 0; i < n; i++) spawnParticle({
type: 'chunk', x: x + rnd(-0.3, 0.3), y: y + rnd(-0.1, 0.2),
vx: rnd(-2.2, 2.2), vy: rnd(2, 5), life: rnd(1.2, 1.8),
w: rnd(0.12, 0.26), h: rnd(0.05, 0.08), color: '#c98a4b',
rot: rnd(0, 6), vr: rnd(-8, 8), g: 9.8,
});
}
function spawnChips(x, y, n) {
for (let i = 0; i < n; i++) spawnParticle({
type: 'chip', x: x + rnd(-0.3, 0.3), y: y + rnd(-0.1, 0.2),
vx: rnd(-2, 2), vy: rnd(1, 4), life: rnd(0.9, 1.3),
w: rnd(0.05, 0.1), h: rnd(0.04, 0.07), color: '#a9b3bb',
rot: rnd(0, 6), vr: rnd(-10, 10), g: 9.8,
});
}
function spawnGoo(x, y, n) {
for (let i = 0; i < n; i++) spawnParticle({
type: 'goo', x: x + rnd(-0.2, 0.2), y: y + rnd(-0.2, 0.2),
vx: rnd(-3, 3), vy: rnd(2, 6), life: rnd(0.9, 1.3), r: rnd(0.06, 0.15),
color: '#86d24e', g: 9.8,
});
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.age += dt;
if (p.age >= p.life) { particles.splice(i, 1); continue; }
const drag = p.type === 'feather' ? 2.2 : p.type === 'dust' ? 1.5 : 0.2;
p.vx *= Math.max(0, 1 - drag * dt);
p.vy *= Math.max(0, 1 - drag * dt);
p.vy -= p.g * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
if (p.g > 0 && p.y < 0.05) { // bounce off the ground
p.y = 0.05;
p.vy = -p.vy * 0.35;
p.vx *= 0.6;
}
}
for (let i = popups.length - 1; i >= 0; i--) {
const q = popups[i];
q.age += dt;
q.y += 0.9 * dt;
if (q.age >= q.life) popups.splice(i, 1);
}
}
function addScore(points, x, y, color, size) {
if (game.mode === 'play') game.score += points;
popups.push({ x, y, text: '+' + fmt(points), color, size, age: 0, life: 1.1 });
}
// ---------- Game state ----------
const game = {
mode: 'demo', // 'demo' (plays itself) or 'play'
phase: 'ready', // 'ready' | 'aiming' | 'flying' | 'settling' | 'won' | 'lost'
level: null,
bird: null, // bird data in the sling or in flight
birdsLeft: 3, // birds not yet launched (includes the one in the sling)
score: 0,
stars: 0,
pull: { x: 0, y: 0 }, // bird offset from the anchor while aiming
dragging: false,
flightT: 0,
flightSlow: 0,
settleT: 0,
focusX: ANCHOR.x,
shake: 0,
demoStep: 'wait',
demoT: 0,
demoFrom: { x: 0, y: 0 },
demoTo: { x: 0, y: 0 },
};
function newGame(mode) {
particles.length = 0;
popups.length = 0;
game.level = buildLevel();
game.mode = mode;
game.phase = 'ready';
game.score = 0;
game.birdsLeft = 3;
game.pull = { x: 0, y: 0 };
game.dragging = false;
game.shake = 0;
game.stars = 0;
canvas.classList.remove('grabbing');
setDemoStep('wait');
spawnBird();
hideOverlay();
}
function spawnBird() {
const L = game.level;
game.bird = null;
if (game.birdsLeft <= 0) return;
// Angular damping bleeds off the spin a landed bird picks up, so friction
// slows its roll. It only touches spin, so the flight arc is unchanged.
const body = L.world.createBody({
type: 'dynamic', position: planck.Vec2(ANCHOR.x, ANCHOR.y), bullet: true, allowSleep: false,
angularDamping: 1.5,
});
body.createFixture(planck.Circle(BIRD_R), { density: MAT.bird.density, friction: MAT.bird.friction, restitution: MAT.bird.restitution });
body.setGravityScale(0); // held in the sling: no gravity until released
game.bird = makeData('bird', body);
}
function clampPull(dx, dy) {
dx = Math.min(dx, -0.1); // always pull back, so the shot goes right
dy = clamp(dy, -2.6, 1.0);
const len = Math.hypot(dx, dy);
if (len > MAX_PULL) { dx *= MAX_PULL / len; dy *= MAX_PULL / len; }
return { x: dx, y: dy };
}
function setPull(p) {
game.pull = clampPull(p.x - ANCHOR.x, p.y - ANCHOR.y);
}
function release() {
if (game.phase !== 'aiming') return;
if (Math.hypot(game.pull.x, game.pull.y) < MIN_PULL) {
game.phase = 'ready';
game.pull = { x: 0, y: 0 };
return;
}
launch();
}
function launch() {
const b = game.bird;
if (!b || game.phase !== 'aiming') return;
b.body.setGravityScale(1);
b.body.setLinearVelocity(planck.Vec2(-game.pull.x * LAUNCH_K, -game.pull.y * LAUNCH_K));
game.birdsLeft--;
game.phase = 'flying';
game.flightT = 0;
game.flightSlow = 0;
game.pull = { x: 0, y: 0 };
}
// Physics runs at a fixed step; game rules are checked on the same clock.
function stepFixed(dt) {
const L = game.level;
const b = game.bird;
if (b && (game.phase === 'ready' || game.phase === 'aiming')) {
b.body.setTransform(planck.Vec2(ANCHOR.x + game.pull.x, ANCHOR.y + game.pull.y), 0);
b.body.setLinearVelocity(planck.Vec2(0, 0));
}
for (const d of L.blocks) d.impulse = 0;
for (const d of L.pigs) d.impulse = 0;
L.world.step(dt, VEL_ITERS, POS_ITERS);
resolveDamage(L);
if (game.phase === 'flying') updateFlight(dt);
else if (game.phase === 'settling') updateSettling(dt);
if (game.mode === 'demo') updateDemo(dt);
flushRemovals(L);
}
function updateFlight(dt) {
const b = game.bird;
game.flightT += dt;
const v = b.body.getLinearVelocity();
const still = game.flightT > 0.3 && Math.hypot(v.x, v.y) < 0.6;
game.flightSlow = still ? game.flightSlow + dt : 0;
const p = b.body.getPosition();
// Rolling resistance on the ground only, so the arc through the air stays true.
b.body.setLinearDamping(p.y < BIRD_R + 0.04 ? 2.5 : 0);
if (p.x > WORLD_RIGHT || p.y < -6 || game.flightT > 12 || game.flightSlow > 0.8) {
game.focusX = p.x;
removeBody(game.level, b);
game.bird = null;
game.phase = 'settling';
game.settleT = 0;
}
}
function updateSettling(dt) {
const L = game.level;
game.settleT += dt;
const moving = [...L.blocks, ...L.pigs].some(d => !d.removed && d.body.isAwake());
if ((!moving && game.settleT > 0.4) || game.settleT > 8) finishTurn();
}
function finishTurn() {
const pigsLeft = game.level.pigs.filter(p => !p.removed).length;
if (pigsLeft === 0) {
const bonus = game.birdsLeft * SCORE.bird;
game.phase = 'won';
game.stars = 1 + Math.min(2, game.birdsLeft);
if (game.mode === 'play') {
game.score += bonus;
showOverlay(true, game.stars, game.score, bonus);
}
} else if (game.birdsLeft === 0) {
game.phase = 'lost';
if (game.mode === 'play') showOverlay(false, 0, game.score, 0);
} else {
game.phase = 'ready';
spawnBird();
}
}
// ---------- Demo: the game plays itself until the slingshot is grabbed ----------
function setDemoStep(step) {
game.demoStep = step;
game.demoT = 0;
}
function updateDemo(dt) {
game.demoT += dt;
const t = game.demoT;
switch (game.demoStep) {
case 'wait':
if (t > 1.0) setDemoStep('plan');
break;
case 'plan':
if (game.phase === 'ready' && game.bird) {
game.demoFrom = { ...game.pull };
game.demoTo = solveDemoPull(demoTarget());
game.phase = 'aiming';
setDemoStep('aim');
}
break;
case 'aim': {
const k = Math.min(1, t / 0.9), e = k * k * (3 - 2 * k);
game.pull = {
x: lerp(game.demoFrom.x, game.demoTo.x, e),
y: lerp(game.demoFrom.y, game.demoTo.y, e),
};
if (k >= 1) setDemoStep('hold');
break;
}
case 'hold':
if (t > 0.3) { launch(); setDemoStep('fly'); }
break;
case 'fly':
if (game.phase === 'ready') setDemoStep('pause');
else if (game.phase === 'won' || game.phase === 'lost') setDemoStep('reset');
break;
case 'pause':
if (t > 0.8) setDemoStep('plan');
break;
case 'reset':
if (t > 3.0) newGame('demo');
break;
}
}
function demoTarget() {
const alive = game.level.pigs.filter(p => !p.removed);
if (!alive.length) return { x: 21.2, y: 1.2 };
const p = alive[Math.floor(Math.random() * alive.length)].body.getPosition();
return { x: p.x + rnd(-0.3, 0.3), y: p.y + rnd(-0.2, 0.2) };
}
// Same integration as the physics engine (semi-implicit Euler, no collisions),
// so the closest approach to the target predicts where the bird actually goes.
function pathMiss(pull, T) {
let x = ANCHOR.x + pull.x, y = ANCHOR.y + pull.y;
let vx = -pull.x * LAUNCH_K, vy = -pull.y * LAUNCH_K;
let best = Infinity;
for (let i = 0; i < 1500; i++) {
vy -= GRAVITY * DT;
x += vx * DT;
y += vy * DT;
best = Math.min(best, Math.hypot(x - T.x, y - T.y));
if (y < BIRD_R || x > T.x + 4) break;
}
return best;
}
function solveDemoPull(T) {
let best = { x: -1.6, y: -1.6 }, bestMiss = Infinity;
for (let r = 1.3; r <= MAX_PULL + 1e-6; r += 0.05) {
for (let deg = 180; deg <= 240; deg += 1) {
const a = deg * Math.PI / 180;
const pull = { x: r * Math.cos(a), y: r * Math.sin(a) };
const miss = pathMiss(pull, T);
if (miss < bestMiss) { bestMiss = miss; best = pull; }
}
}
return best;
}
// ---------- Input ----------
canvas.addEventListener('pointerdown', e => {
const p = toWorld(e.clientX, e.clientY);
const nearSling = Math.hypot(p.x - ANCHOR.x, p.y - ANCHOR.y) <= GRAB_R;
if (game.mode === 'demo') {
if (!nearSling) return;
newGame('play');
}
if (game.phase !== 'ready' || !nearSling) return;
canvas.setPointerCapture(e.pointerId);
game.dragging = true;
game.phase = 'aiming';
setPull(p);
canvas.classList.add('grabbing');
});
canvas.addEventListener('pointermove', e => {
if (game.dragging) setPull(toWorld(e.clientX, e.clientY));
});
const endDrag = (cancelled) => {
if (!game.dragging) return;
game.dragging = false;
canvas.classList.remove('grabbing');
if (cancelled) {
if (game.phase === 'aiming') { game.phase = 'ready'; game.pull = { x: 0, y: 0 }; }
} else {
release();
}
};
canvas.addEventListener('pointerup', () => endDrag(false));
canvas.addEventListener('pointercancel', () => endDrag(true));
window.addEventListener('keydown', e => {
if (e.key === 'r' || e.key === 'R') newGame('play');
});
document.getElementById('restart').addEventListener('click', () => newGame('play'));
document.getElementById('overlayRestart').addEventListener('click', () => newGame('play'));
// ---------- Overlay and HUD ----------
const overlayEl = document.getElementById('overlay');
const titleEl = document.getElementById('title');
const starsEl = document.getElementById('stars');
const resultEl = document.getElementById('result');
const scoreEl = document.getElementById('score');
const scoreLabelEl = document.getElementById('scoreLabel');
const birdsEl = document.getElementById('birds');
const pigsEl = document.getElementById('pigs');
const hintEl = document.getElementById('hint');
function showOverlay(won, stars, score, bonus) {
titleEl.textContent = won ? 'Level cleared' : 'Try again';
titleEl.className = won ? 'win' : 'lose';
starsEl.hidden = !won;
starsEl.innerHTML = [0, 1, 2].map(i =>
`<svg class="star${i < stars ? ' on' : ''}" viewBox="0 0 24 24"><path d="${STAR}"/></svg>`).join('');
resultEl.textContent = bonus
? `Score ${fmt(score)} (bird bonus ${fmt(bonus)})`
: `Score ${fmt(score)}`;
overlayEl.hidden = false;
}
function hideOverlay() { overlayEl.hidden = true; }
function hintText() {
if (game.mode === 'demo') return 'Demo: grab the slingshot to play';
if (game.phase === 'ready') return 'Drag the bird back to aim, then let go';
if (game.phase === 'aiming') return 'Let go to fire';
return '';
}
const hudCache = { score: null, birds: null, pigs: null, hint: null };
function updateHud() {
const L = game.level;
const scoreText = game.mode === 'play' ? fmt(game.score) : '';
if (scoreText !== hudCache.score) {
scoreEl.textContent = scoreText;
scoreLabelEl.hidden = !scoreText;
hudCache.score = scoreText;
}
const birdsKey = String(game.birdsLeft);
if (birdsKey !== hudCache.birds) {
birdsEl.innerHTML = '<i class="ico bird"></i>'.repeat(game.birdsLeft);
hudCache.birds = birdsKey;
}
const pigsKey = L.pigs.map(p => (p.removed ? 0 : 1)).join('');
if (pigsKey !== hudCache.pigs) {
pigsEl.innerHTML = L.pigs.map(p => `<i class="ico pig${p.removed ? ' off' : ''}"></i>`).join('');
hudCache.pigs = pigsKey;
}
const hint = hintText();
if (hint !== hudCache.hint) {
hintEl.textContent = hint;
hintEl.hidden = !hint;
hudCache.hint = hint;
}
}
// ---------- Camera ----------
function updateCamera(dt) {
const halfW = view.W / (2 * view.scale), halfH = view.H / (2 * view.scale);
let tx = view.defX, ty = view.defY;
if (game.phase === 'flying' && game.bird) {
const p = game.bird.body.getPosition();
tx = p.x + 2.5;
ty = Math.max(view.defY, p.y - halfH * 0.5);
} else if (game.phase === 'settling') {
tx = game.focusX + 2.5;
}
tx = clamp(tx, view.defX, WORLD_RIGHT - halfW);
const k = 1 - Math.exp(-dt * 3);
cam.x += (tx - cam.x) * k;
cam.y += (ty - cam.y) * k;
}
// ---------- Drawing: background (screen space) ----------
const clouds = [
{ x: 2, y: 0.16, s: 1.0, v: 9 }, { x: 14, y: 0.1, s: 0.8, v: 6 },
{ x: 28, y: 0.2, s: 1.2, v: 11 }, { x: 40, y: 0.12, s: 0.9, v: 7 },
];
function drawSky(t) {
const { W, H } = view;
const g = ctx.createLinearGradient(0, 0, 0, H * 0.85);
g.addColorStop(0, '#3fa9ea');
g.addColorStop(0.6, '#9ad9f7');
g.addColorStop(1, '#ecf9ff');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const sx = W * 0.84, sy = H * 0.16, sr = Math.min(W, H) * 0.07;
const glow = ctx.createRadialGradient(sx, sy, sr * 0.5, sx, sy, sr * 3.2);
glow.addColorStop(0, 'rgba(255, 246, 200, .9)');
glow.addColorStop(1, 'rgba(255, 246, 200, 0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#fff3b0';
ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill();
const span = W + 400;
ctx.fillStyle = 'rgba(255, 255, 255, .92)';
for (const c of clouds) {
const px = ((c.x * 60 - cam.x * 10 + t * c.v) % span + span) % span - 200;
const py = c.y * H, s = c.s * Math.min(W, H) * 0.06;
const puffs = [[0, 0, 1], [1.1, -0.35, 0.8], [-1.1, -0.2, 0.7], [0.3, 0.2, 0.9]];
for (const [dx, dy, r] of puffs) {
ctx.beginPath();
ctx.arc(px + dx * s, py + dy * s, r * s, 0, Math.PI * 2);
ctx.fill();
}
}
}
function drawHills() {
const { W, H, scale } = view;
const layers = [
{ par: 0.05, base: 0.72, amp: 0.10, col: '#9fd98a', ph: 0.0, f: 0.0045 },
{ par: 0.15, base: 0.77, amp: 0.08, col: '#79c35c', ph: 2.0, f: 0.0075 },
{ par: 0.3, base: 0.82, amp: 0.05, col: '#5aa745', ph: 4.0, f: 0.011 },
];
for (const L of layers) {
const off = cam.x * scale * L.par; // parallax: far layers move less
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W + 8; x += 8) {
const u = x + off;
const h = 0.5 + 0.3 * Math.sin(u * L.f + L.ph) + 0.2 * Math.sin(u * L.f * 2.3 + L.ph * 1.7);
ctx.lineTo(x, H * L.base - H * L.amp * h);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = L.col;
ctx.fill();
}
}
// ---------- Drawing: world (metres, y up) ----------
function drawGround() {
const dirt = ctx.createLinearGradient(0, -0.2, 0, -10);
dirt.addColorStop(0, '#b07a46');
dirt.addColorStop(1, '#6b4422');
ctx.fillStyle = dirt;
ctx.fillRect(-20, -12, 110, 12);
ctx.fillStyle = 'rgba(60, 35, 15, .22)';
for (let i = 0; i < 70; i++) {
ctx.beginPath();
ctx.arc(-20 + hash(i) * 110, -0.6 - hash(i + 99) * 10, 0.05 + hash(i + 7) * 0.07, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#6fc24c';
ctx.beginPath();
ctx.moveTo(-20, -0.2);
for (let x = -20; x <= 90; x += 0.4) ctx.lineTo(x, 0.04 * Math.sin(x * 2.2) + 0.02 * Math.sin(x * 5.1));
ctx.lineTo(90, -0.2);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#4f9f35';
ctx.fillRect(-20, -0.2, 110, 0.06);
}
function drawWood(d) {
const { w, h } = d;
const g = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
g.addColorStop(0, '#f2c484');
g.addColorStop(0.5, '#d89a54');
g.addColorStop(1, '#a9662f');
rr(-w / 2, -h / 2, w, h, Math.min(w, h) * 0.18);
ctx.fillStyle = g;
ctx.fill();
ctx.save();
ctx.clip();
ctx.strokeStyle = 'rgba(100, 56, 22, .45)';
ctx.lineWidth = 0.018;
const along = w >= h; // grain runs along the long side
const short = along ? h : w;
const n = Math.max(2, Math.round(short / 0.07));
for (let i = 0; i < n; i++) {
const t = (i + 0.5) / n - 0.5;
ctx.beginPath();
if (along) {
ctx.moveTo(-w / 2, t * h);
for (let x = -w / 2; x <= w / 2; x += w / 8) ctx.lineTo(x, t * h + Math.sin(x * 9 + d.seed + i) * 0.012);
} else {
ctx.moveTo(t * w, -h / 2);
for (let y = -h / 2; y <= h / 2; y += h / 8) ctx.lineTo(t * w + Math.sin(y * 9 + d.seed + i) * 0.012, y);
}
ctx.stroke();
}
ctx.restore();
ctx.lineWidth = 0.04;
ctx.strokeStyle = '#4b2a10';
rr(-w / 2, -h / 2, w, h, Math.min(w, h) * 0.18);
ctx.stroke();
}
function drawStone(d) {
const { w, h } = d;
const g = ctx.createLinearGradient(-w / 2, h / 2, w / 2, -h / 2);
g.addColorStop(0, '#d9e0e6');
g.addColorStop(0.5, '#a3adb6');
g.addColorStop(1, '#7b858e');
rr(-w / 2, -h / 2, w, h, 0.07);
ctx.fillStyle = g;
ctx.fill();
ctx.save();
ctx.clip();
ctx.fillStyle = 'rgba(40, 46, 52, .14)';
for (let i = 0; i < 12; i++) {
ctx.beginPath();
ctx.arc(hash(d.seed + i) * w - w / 2, hash(d.seed + i + 40) * h - h / 2, 0.02 + hash(d.seed + i + 9) * 0.03, 0, Math.PI * 2);
ctx.fill();
}
if (d.cracks > 0) {
ctx.strokeStyle = '#353b41';
ctx.lineWidth = 0.03;
for (let c = 0; c < d.cracks; c++) {
const x0 = hash(d.seed + c * 3) * w - w / 2;
ctx.beginPath();
ctx.moveTo(x0, h / 2);
ctx.lineTo(x0 + 0.06, h * 0.1);
ctx.lineTo(x0 - 0.04, -h * 0.1);
ctx.lineTo(x0 + 0.05, -h / 2);
ctx.stroke();
}
}
ctx.restore();
ctx.lineWidth = 0.05;
ctx.strokeStyle = '#3d454c';
rr(-w / 2, -h / 2, w, h, 0.07);
ctx.stroke();
}
function drawPig() {
ctx.lineJoin = 'round';
ctx.strokeStyle = '#2d5e1a';
ctx.lineWidth = 0.035;
ctx.fillStyle = '#6fbf3a';
ctx.beginPath();
ctx.moveTo(-0.26, 0.2); ctx.lineTo(-0.38, 0.44); ctx.lineTo(-0.06, 0.34); ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(0.1, 0.32); ctx.lineTo(0.3, 0.46); ctx.lineTo(0.3, 0.18); ctx.closePath();
ctx.fill(); ctx.stroke();
const g = ctx.createRadialGradient(-0.12, 0.14, 0.03, 0, 0, PIG_R);
g.addColorStop(0, '#d6ff9a');
g.addColorStop(0.6, '#8fd64e');
g.addColorStop(1, '#4f9a2a');
ctx.beginPath();
ctx.arc(0, 0, PIG_R, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#fff';
for (const [ex, ey] of [[-0.14, 0.1], [0.1, 0.12]]) {
ctx.beginPath(); ctx.arc(ex, ey, 0.09, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = '#1b1212';
for (const [ex, ey] of [[-0.12, 0.07], [0.12, 0.09]]) {
ctx.beginPath(); ctx.arc(ex, ey, 0.04, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = '#b5ec72';
ctx.beginPath(); ctx.ellipse(0.25, -0.04, 0.15, 0.12, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#2d5e1a';
for (const nx of [0.21, 0.29]) {
ctx.beginPath(); ctx.arc(nx, -0.04, 0.03, 0, Math.PI * 2); ctx.fill();
}
}
function drawBird() {
const g = ctx.createRadialGradient(-0.12, 0.14, 0.04, 0, 0, BIRD_R);
g.addColorStop(0, '#ff9a8a');
g.addColorStop(0.55, '#ea3d30');
g.addColorStop(1, '#b3211b');
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, BIRD_R, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
ctx.clip();
ctx.fillStyle = '#ffe7c2';
ctx.beginPath(); ctx.ellipse(0.02, -0.2, 0.3, 0.22, 0, 0, Math.PI * 2); ctx.fill();
ctx.restore();
ctx.lineWidth = 0.035;
ctx.strokeStyle = '#5c1410';
ctx.beginPath(); ctx.arc(0, 0, BIRD_R, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = 0.02;
for (const ex of [-0.08, 0.14]) {
ctx.beginPath(); ctx.arc(ex, 0.1, 0.1, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
ctx.fillStyle = '#1b1212';
for (const ex of [-0.04, 0.18]) {
ctx.beginPath(); ctx.arc(ex, 0.08, 0.05, 0, Math.PI * 2); ctx.fill();
}
// Angry eyebrows: the inner ends sit lower than the outer ends.
ctx.strokeStyle = '#1b1212';
ctx.lineWidth = 0.06;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(-0.24, 0.27); ctx.lineTo(0.02, 0.2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0.06, 0.2); ctx.lineTo(0.3, 0.27); ctx.stroke();
ctx.lineCap = 'butt';
ctx.fillStyle = '#ffae2e';
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = 0.03;
ctx.beginPath();
ctx.moveTo(0.3, -0.03); ctx.lineTo(0.46, 0.02); ctx.lineTo(0.3, 0.07);
ctx.closePath();
ctx.fill(); ctx.stroke();
}
// A body is drawn at its physics pose: move to it, rotate, then draw in local metres.
function drawAt(body, fn) {
const p = body.getPosition();
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(body.getAngle());
fn();
ctx.restore();
}
function drawSlingBack(loaded) {
// Foot and post
ctx.fillStyle = '#7a4a22';
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = 0.04;
ctx.beginPath();
ctx.moveTo(2.95, 0); ctx.lineTo(3.85, 0); ctx.lineTo(3.7, 0.22); ctx.lineTo(3.1, 0.22);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(3.25, 0.1); ctx.lineTo(3.55, 0.1); ctx.lineTo(3.45, 2.65); ctx.lineTo(3.35, 2.65);
ctx.closePath(); ctx.fill(); ctx.stroke();
// Left fork arm (behind the bird)
drawArm(3.35, 2.6, SLING.tipL.x, SLING.tipL.y);
if (loaded) drawBand(SLING.tipL, game.bird.body.getPosition());
}
function drawSlingFront(loaded) {
if (loaded) drawBand(SLING.tipR, game.bird.body.getPosition());
drawArm(3.45, 2.6, SLING.tipR.x, SLING.tipR.y);
}
const SLING = {
tipL: { x: ANCHOR.x - 0.42, y: ANCHOR.y + 0.02 },
tipR: { x: ANCHOR.x + 0.42, y: ANCHOR.y + 0.02 },
};
function drawArm(x0, y0, x1, y1) {
ctx.lineCap = 'round';
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = 0.22;
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
ctx.strokeStyle = '#a8703a';
ctx.lineWidth = 0.14;
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
ctx.lineCap = 'butt';
}
function drawBand(tip, pouch) {
ctx.lineCap = 'round';
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = 0.08;
ctx.beginPath(); ctx.moveTo(tip.x, tip.y); ctx.lineTo(pouch.x, pouch.y); ctx.stroke();
ctx.strokeStyle = '#8a6036';
ctx.lineWidth = 0.03;
ctx.beginPath(); ctx.moveTo(tip.x, tip.y); ctx.lineTo(pouch.x, pouch.y); ctx.stroke();
ctx.lineCap = 'butt';
}
function drawWaitingBirds() {
const loaded = game.bird && (game.phase === 'ready' || game.phase === 'aiming');
const waiting = game.birdsLeft - (loaded ? 1 : 0);
for (let i = 0; i < waiting; i++) {
ctx.save();
ctx.translate(0.6 + i * 0.85, BIRD_R);
drawBird();
ctx.restore();
}
}
function previewPoints(x, y, vx, vy) {
const pts = [];
for (let i = 1; i <= 60 * 8 && pts.length < 90; i++) {
vy -= GRAVITY * DT;
x += vx * DT;
y += vy * DT;
if (i % 6 === 0) pts.push({ x, y });
if (y < BIRD_R || x > WORLD_RIGHT) break;
}
return pts;
}
function drawPreview() {
if (game.phase !== 'aiming' || !game.bird) return;
const p = game.pull;
if (Math.hypot(p.x, p.y) < MIN_PULL) return;
const pts = previewPoints(ANCHOR.x + p.x, ANCHOR.y + p.y, -p.x * LAUNCH_K, -p.y * LAUNCH_K);
pts.forEach((q, i) => {
const t = i / pts.length;
ctx.globalAlpha = 0.95 - t * 0.6;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(q.x, q.y, 0.075 - t * 0.03, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
}
function drawParticles() {
for (const p of particles) {
const t = p.age / p.life;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
if (p.type === 'dust') {
ctx.globalAlpha = (1 - t) * 0.55;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(0, 0, p.r * (1 + t * 1.5), 0, Math.PI * 2); ctx.fill();
} else if (p.type === 'goo') {
ctx.globalAlpha = 1 - t * 0.6;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(0, 0, p.r, 0, Math.PI * 2); ctx.fill();
} else if (p.type === 'feather') {
ctx.globalAlpha = 1 - Math.max(0, t - 0.7) * 3.3;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.ellipse(0, 0, p.w / 2, p.h / 2, 0, 0, Math.PI * 2); ctx.fill();
} else { // wood chunks and stone chips
ctx.globalAlpha = 1 - Math.max(0, t - 0.6) * 2.5;
ctx.fillStyle = p.color;
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = 0.02;
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.strokeRect(-p.w / 2, -p.h / 2, p.w, p.h);
}
ctx.restore();
}
ctx.globalAlpha = 1;
}
function drawLabels() {
const { dpr } = view;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
for (const p of popups) {
const s = toScreen(p.x, p.y), t = p.age / p.life;
ctx.globalAlpha = Math.max(0, 1 - t * t);
ctx.font = `900 ${p.size}px "Trebuchet MS", "Avenir Next", Arial, sans-serif`;
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = Math.max(3, p.size / 5);
ctx.strokeText(p.text, s.x, s.y);
ctx.fillStyle = p.color;
ctx.fillText(p.text, s.x, s.y);
}
ctx.globalAlpha = 1;
if (game.phase === 'aiming' && game.bird) {
const pct = Math.round(Math.hypot(game.pull.x, game.pull.y) / MAX_PULL * 100);
const s = toScreen(ANCHOR.x + game.pull.x, ANCHOR.y + game.pull.y + 0.8);
ctx.font = '900 18px "Trebuchet MS", Arial, sans-serif';
ctx.strokeStyle = '#2b1d0e';
ctx.lineWidth = 5;
ctx.strokeText(`POWER ${pct}%`, s.x, s.y);
ctx.fillStyle = '#fff';
ctx.fillText(`POWER ${pct}%`, s.x, s.y);
}
}
function draw(t) {
const { W, H, dpr, scale } = view;
const shakePx = game.shake * 7;
const jx = (Math.random() - 0.5) * shakePx, jy = (Math.random() - 0.5) * shakePx;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawSky(t);
drawHills();
ctx.setTransform(dpr * scale, 0, 0, -dpr * scale,
dpr * (W / 2 - cam.x * scale + jx), dpr * (H / 2 + cam.y * scale + jy));
drawGround();
drawWaitingBirds();
const L = game.level;
const loaded = !!game.bird && (game.phase === 'ready' || game.phase === 'aiming');
drawSlingBack(loaded);
for (const d of L.blocks) {
if (d.removed) continue;
drawAt(d.body, () => (d.kind === 'wood' ? drawWood(d) : drawStone(d)));
}
for (const d of L.pigs) {
if (!d.removed) drawAt(d.body, drawPig);
}
if (game.bird) drawAt(game.bird.body, drawBird);
drawSlingFront(loaded);
drawParticles();
drawPreview();
drawLabels();
}
// ---------- Main loop ----------
let last = performance.now(), acc = 0;
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
acc += dt;
let n = 0;
while (acc >= DT && n < 12) { stepFixed(DT); acc -= DT; n++; }
if (n === 12) acc = 0;
updateCamera(dt);
updateParticles(dt);
game.shake *= Math.exp(-dt * 5);
draw(now / 1000);
updateHud();
requestAnimationFrame(frame);
}
window.addEventListener('resize', resize);
resize();
cam.x = view.defX; // start framed on the sling, not gliding in from the origin
cam.y = view.defY;
newGame('demo');
requestAnimationFrame(frame);
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.