Showcase

Angry Birds, one level, built by Claude Haiku 5.5

Hostedvia Claude CodeOpenRouter
Running live in your browserNew tab
Show the code the model wrote
HTML44,256 characters
<!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

2 more
Compare side by side

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.