[object Object]

← back to Games Agentabrams

Add 3 newest Model Arena game winners: DVD Bounce, Verlet Rope, Particle Word

9a1b82f5246777f4003b7ef2ff96da4ae0ddaa83 · 2026-07-24 18:54:44 -0700 · Steve

Files touched

Diff

commit 9a1b82f5246777f4003b7ef2ff96da4ae0ddaa83
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 24 18:54:44 2026 -0700

    Add 3 newest Model Arena game winners: DVD Bounce, Verlet Rope, Particle Word
---
 games.json                     |  24 +++
 games/dvd-bounce/index.html    | 312 ++++++++++++++++++++++++++++++++++++
 games/particle-text/index.html | 143 +++++++++++++++++
 games/rope-physics/index.html  | 350 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 829 insertions(+)

diff --git a/games.json b/games.json
index 427b947..ed96445 100644
--- a/games.json
+++ b/games.json
@@ -1,5 +1,29 @@
 {
   "games": [
+    {
+      "id": "dvd-bounce",
+      "file": "DVDBounce.aa",
+      "title": "DVD Bounce — Elevated",
+      "desc": "The classic bouncing-logo screensaver, elevated — glowing trails, corner-hit celebration, color shifts on every wall bounce. An ambient toy, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
+      "path": "games/dvd-bounce/",
+      "added": "2026-07-24"
+    },
+    {
+      "id": "rope-physics",
+      "file": "VerletRope.aa",
+      "title": "Verlet Rope",
+      "desc": "A physics toy — drag to swing a Verlet-integrated rope, watch it settle and whip under gravity. A generative-physics playground, not a scored game. Model Arena winner (Claude). Pure Canvas 2D, zero dependencies.",
+      "path": "games/rope-physics/",
+      "added": "2026-07-24"
+    },
+    {
+      "id": "particle-text",
+      "file": "ParticleWord.aa",
+      "title": "Particle Word",
+      "desc": "Type a word and watch it assemble from a swarm of particles that scatter and reform. An interactive typography toy, not a scored game. Model Arena winner (Qwen3-14B). Pure Canvas 2D, zero dependencies.",
+      "path": "games/particle-text/",
+      "added": "2026-07-24"
+    },
     {
       "id": "rubiks-cube",
       "file": "AbramsCube.aa",
diff --git a/games/dvd-bounce/index.html b/games/dvd-bounce/index.html
new file mode 100644
index 0000000..4d24ee9
--- /dev/null
+++ b/games/dvd-bounce/index.html
@@ -0,0 +1,312 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>DVD Bounce — Elevated</title>
+<style>
+  :root{
+    --bg:#05060a;
+    --ink:#e8ecf5;
+    --accent:#7c5cff;
+  }
+  *{box-sizing:border-box;margin:0;padding:0}
+  html,body{height:100%;overflow:hidden}
+  body{
+    background:radial-gradient(120% 120% at 50% 0%, #0d1020 0%, #04050a 60%, #000 100%);
+    color:var(--ink);
+    font-family:"Segoe UI",system-ui,-apple-system,sans-serif;
+    -webkit-font-smoothing:antialiased;
+  }
+  #stage{position:fixed;inset:0;display:block}
+  /* HUD */
+  .hud{
+    position:fixed;top:16px;left:16px;z-index:10;
+    display:flex;flex-direction:column;gap:10px;
+    pointer-events:none;
+  }
+  .panel{
+    pointer-events:auto;
+    background:rgba(18,20,34,.55);
+    backdrop-filter:blur(12px) saturate(140%);
+    -webkit-backdrop-filter:blur(12px) saturate(140%);
+    border:1px solid rgba(255,255,255,.09);
+    border-radius:14px;
+    padding:14px 16px;
+    box-shadow:0 10px 40px rgba(0,0,0,.45);
+  }
+  .stats{display:flex;gap:22px;align-items:center}
+  .stat{display:flex;flex-direction:column;line-height:1.1}
+  .stat .num{font-size:26px;font-weight:800;letter-spacing:.5px;font-variant-numeric:tabular-nums}
+  .stat .lbl{font-size:10px;text-transform:uppercase;letter-spacing:2px;opacity:.55;margin-top:3px}
+  .corner .num{color:#ffd35c;text-shadow:0 0 18px rgba(255,211,92,.55)}
+  .controls{display:flex;gap:8px;flex-wrap:wrap}
+  button{
+    pointer-events:auto;
+    font:inherit;font-size:12px;font-weight:600;letter-spacing:.4px;
+    color:var(--ink);
+    background:linear-gradient(180deg,rgba(124,92,255,.35),rgba(124,92,255,.15));
+    border:1px solid rgba(124,92,255,.5);
+    border-radius:10px;
+    padding:9px 14px;cursor:pointer;
+    transition:transform .08s ease, background .15s ease, box-shadow .15s ease;
+  }
+  button:hover{background:linear-gradient(180deg,rgba(124,92,255,.55),rgba(124,92,255,.25));box-shadow:0 0 22px rgba(124,92,255,.4)}
+  button:active{transform:translateY(1px) scale(.98)}
+  button.ghost{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14)}
+  button.ghost:hover{background:rgba(255,255,255,.1);box-shadow:none}
+  .hint{
+    position:fixed;bottom:14px;right:16px;z-index:10;
+    font-size:11px;opacity:.4;letter-spacing:.5px;pointer-events:none;
+  }
+  .flash{animation:pop .4s ease}
+  @keyframes pop{0%{transform:scale(1)}35%{transform:scale(1.28)}100%{transform:scale(1)}}
+</style>
+</head>
+<body>
+<canvas id="stage"></canvas>
+
+<div class="hud">
+  <div class="panel stats">
+    <div class="stat corner">
+      <span class="num" id="cornerCount">0</span>
+      <span class="lbl">Corners</span>
+    </div>
+    <div class="stat">
+      <span class="num" id="wallCount">0</span>
+      <span class="lbl">Wall hits</span>
+    </div>
+    <div class="stat">
+      <span class="num" id="logoCount">1</span>
+      <span class="lbl">Logos</span>
+    </div>
+  </div>
+  <div class="panel controls">
+    <button id="addBtn">+ Add logo</button>
+    <button id="chaosBtn">Chaos ×5</button>
+    <button class="ghost" id="resetBtn">Reset</button>
+  </div>
+</div>
+
+<div class="hint">corner hits trigger the confetti · click canvas to spawn</div>
+
+<script>
+(() => {
+  const canvas = document.getElementById('stage');
+  const ctx = canvas.getContext('2d');
+
+  const cornerEl = document.getElementById('cornerCount');
+  const wallEl   = document.getElementById('wallCount');
+  const logoEl   = document.getElementById('logoCount');
+
+  let W = 0, H = 0, DPR = 1;
+  function resize() {
+    DPR = Math.min(window.devicePixelRatio || 1, 2);
+    W = window.innerWidth;
+    H = window.innerHeight;
+    canvas.width  = Math.floor(W * DPR);
+    canvas.height = Math.floor(H * DPR);
+    canvas.style.width  = W + 'px';
+    canvas.style.height = H + 'px';
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+  }
+  window.addEventListener('resize', resize);
+  resize();
+
+  // ---- palette ----
+  const PALETTE = [
+    '#ff4d6d','#ffd35c','#4ade80','#38bdf8','#7c5cff',
+    '#f472b6','#fb923c','#22d3ee','#a3e635','#e879f9'
+  ];
+  const rand  = (a, b) => a + Math.random() * (b - a);
+  const pick  = arr => arr[(Math.random() * arr.length) | 0];
+  const nextColor = cur => { let c; do { c = pick(PALETTE); } while (c === cur); return c; };
+
+  // ---- state ----
+  let logos = [];
+  let particles = [];
+  let cornerHits = 0, wallHits = 0;
+
+  // The DVD wordmark, drawn once to an offscreen canvas and tinted per-logo.
+  function makeLogoSprite() {
+    const w = 150, h = 78;
+    const c = document.createElement('canvas');
+    c.width = w; c.height = h;
+    const g = c.getContext('2d');
+
+    g.fillStyle = '#fff';
+    g.textAlign = 'center';
+    g.textBaseline = 'middle';
+
+    // "DVD"
+    g.font = 'italic 900 52px "Arial Black", Arial, sans-serif';
+    g.save();
+    g.translate(w/2, h*0.36);
+    g.scale(1, 0.82);
+    g.fillText('DVD', 0, 0);
+    g.restore();
+
+    // squashed ellipse under it (the classic logo oval)
+    g.beginPath();
+    g.ellipse(w/2, h*0.70, 46, 12, 0, 0, Math.PI*2);
+    g.fill();
+    // knock out the label bar
+    g.save();
+    g.globalCompositeOperation = 'destination-out';
+    g.fillRect(w/2 - 34, h*0.66, 68, 8);
+    g.restore();
+    // small "video" text on the bar
+    g.font = '900 11px Arial, sans-serif';
+    g.fillText('V I D E O', w/2, h*0.70);
+
+    return c;
+  }
+  const SPRITE = makeLogoSprite();
+  const LW = SPRITE.width, LH = SPRITE.height;
+
+  function spawnLogo(x, y) {
+    const speed = rand(2.2, 3.6);
+    const ang = rand(0, Math.PI * 2);
+    logos.push({
+      x: x != null ? x : rand(0, Math.max(0, W - LW)),
+      y: y != null ? y : rand(0, Math.max(0, H - LH)),
+      vx: Math.cos(ang) * speed * (Math.random() < .5 ? 1 : -1),
+      vy: Math.sin(ang) * speed * (Math.random() < .5 ? 1 : -1),
+      color: pick(PALETTE),
+      trail: []
+    });
+    updateHUD();
+  }
+
+  function burst(x, y, big) {
+    const n = big ? 90 : 22;
+    for (let i = 0; i < n; i++) {
+      const a = Math.random() * Math.PI * 2;
+      const sp = rand(big ? 2 : 1, big ? 9 : 4);
+      particles.push({
+        x, y,
+        vx: Math.cos(a) * sp,
+        vy: Math.sin(a) * sp - (big ? 1.5 : 0),
+        life: 1,
+        decay: rand(0.012, 0.03),
+        size: rand(2, big ? 6 : 4),
+        color: pick(PALETTE),
+        spin: rand(-0.3, 0.3),
+        rot: Math.random() * Math.PI
+      });
+    }
+  }
+
+  function flash(el){ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); }
+  function updateHUD() {
+    cornerEl.textContent = cornerHits;
+    wallEl.textContent   = wallHits;
+    logoEl.textContent   = logos.length;
+  }
+
+  // ---- main loop ----
+  function frame() {
+    // fade previous frame → motion trail
+    ctx.fillStyle = 'rgba(4,5,10,0.20)';
+    ctx.fillRect(0, 0, W, H);
+
+    const maxX = W - LW, maxY = H - LH;
+
+    for (const L of logos) {
+      L.x += L.vx;
+      L.y += L.vy;
+
+      let hitX = false, hitY = false;
+      if (L.x <= 0)     { L.x = 0;    L.vx =  Math.abs(L.vx); hitX = true; }
+      else if (L.x >= maxX) { L.x = maxX; L.vx = -Math.abs(L.vx); hitX = true; }
+      if (L.y <= 0)     { L.y = 0;    L.vy =  Math.abs(L.vy); hitY = true; }
+      else if (L.y >= maxY) { L.y = maxY; L.vy = -Math.abs(L.vy); hitY = true; }
+
+      if (hitX || hitY) {
+        L.color = nextColor(L.color);
+        if (hitX && hitY) {
+          // CORNER!
+          cornerHits++;
+          burst(L.x + LW/2, L.y + LH/2, true);
+          flash(cornerEl);
+        } else {
+          wallHits++;
+          burst(hitX ? (L.x <= 0 ? 0 : W) : L.x + LW/2,
+                hitY ? (L.y <= 0 ? 0 : H) : L.y + LH/2, false);
+        }
+        updateHUD();
+      }
+
+      // trail
+      L.trail.push({ x: L.x, y: L.y });
+      if (L.trail.length > 14) L.trail.shift();
+      for (let i = 0; i < L.trail.length; i++) {
+        const t = L.trail[i];
+        const alpha = (i / L.trail.length) * 0.28;
+        drawSprite(t.x, t.y, L.color, alpha);
+      }
+
+      drawSprite(L.x, L.y, L.color, 1);
+    }
+
+    // particles
+    for (let i = particles.length - 1; i >= 0; i--) {
+      const p = particles[i];
+      p.vy += 0.08;             // gravity
+      p.vx *= 0.99;
+      p.x += p.vx;
+      p.y += p.vy;
+      p.rot += p.spin;
+      p.life -= p.decay;
+      if (p.life <= 0) { particles.splice(i, 1); continue; }
+
+      ctx.save();
+      ctx.globalAlpha = Math.max(0, p.life);
+      ctx.translate(p.x, p.y);
+      ctx.rotate(p.rot);
+      ctx.fillStyle = p.color;
+      ctx.shadowColor = p.color;
+      ctx.shadowBlur = 8;
+      const s = p.size;
+      ctx.fillRect(-s/2, -s/2, s, s * 1.6);
+      ctx.restore();
+    }
+
+    requestAnimationFrame(frame);
+  }
+
+  function drawSprite(x, y, color, alpha) {
+    ctx.save();
+    ctx.globalAlpha = alpha;
+    // tint: draw white sprite then multiply the color through
+    ctx.drawImage(SPRITE, x, y);
+    ctx.globalCompositeOperation = 'source-atop';
+    ctx.globalAlpha = alpha;
+    ctx.fillStyle = color;
+    ctx.fillRect(x, y, LW, LH);
+    ctx.restore();
+  }
+
+  // ---- controls ----
+  document.getElementById('addBtn').addEventListener('click', () => spawnLogo());
+  document.getElementById('chaosBtn').addEventListener('click', () => {
+    for (let i = 0; i < 5; i++) spawnLogo();
+  });
+  document.getElementById('resetBtn').addEventListener('click', () => {
+    logos = []; particles = [];
+    cornerHits = 0; wallHits = 0;
+    spawnLogo(W/2 - LW/2, H/2 - LH/2);
+  });
+  canvas.addEventListener('pointerdown', e => spawnLogo(
+    Math.min(Math.max(0, e.clientX - LW/2), W - LW),
+    Math.min(Math.max(0, e.clientY - LH/2), H - LH)
+  ));
+
+  // ---- go ----
+  spawnLogo(W/2 - LW/2, H/2 - LH/2);
+  updateHUD();
+  requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
\ No newline at end of file
diff --git a/games/particle-text/index.html b/games/particle-text/index.html
new file mode 100644
index 0000000..36408d4
--- /dev/null
+++ b/games/particle-text/index.html
@@ -0,0 +1,143 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>Particle Word</title>
+  <style>
+    html, body {
+      margin: 0;
+      padding: 0;
+      overflow: hidden;
+      background: #000;
+      height: 100%;
+      width: 100%;
+      font-family: 'Arial', sans-serif;
+    }
+    #word {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      color: #fff;
+      font-size: 48px;
+      pointer-events: none;
+      text-transform: uppercase;
+      text-align: center;
+    }
+    #controls {
+      position: absolute;
+      top: 20px;
+      left: 50%;
+      transform: translateX(-50%);
+      color: #fff;
+      font-size: 18px;
+    }
+  </style>
+</head>
+<body>
+  <div id="word">TYPE HERE</div>
+  <div id="controls">Type a word to see particles form it</div>
+  <canvas id="canvas"></canvas>
+  <script>
+    const canvas = document.getElementById('canvas');
+    const ctx = canvas.getContext('2d');
+    let width, height;
+    let particles = [];
+    let targetWord = 'TYPE HERE';
+    let isHovering = false;
+
+    function resize() {
+      width = canvas.width = window.innerWidth;
+      height = canvas.height = window.innerHeight;
+    }
+    window.addEventListener('resize', resize);
+    resize();
+
+    function createParticles(word, count = 2000) {
+      particles = [];
+      const chars = word.split('');
+      const totalChars = chars.length;
+      for (let i = 0; i < count; i++) {
+        const charIndex = Math.floor(i / (count / totalChars));
+        const char = chars[charIndex % totalChars];
+        const angle = (Math.random() * Math.PI * 2);
+        const radius = Math.random() * 150 + 100;
+        const x = width / 2 + Math.cos(angle) * radius;
+        const y = height / 2 + Math.sin(angle) * radius;
+        const size = Math.random() * 2 + 1;
+        const speed = Math.random() * 0.5 + 0.2;
+        particles.push({
+          x,
+          y,
+          targetX: width / 2 + (char.charCodeAt(0) - 65) * 30,
+          targetY: height / 2 + (Math.floor(i / totalChars)) * 30,
+          size,
+          speed,
+          vx: (Math.random() - 0.5) * 2,
+          vy: (Math.random() - 0.5) * 2,
+          char: char
+        });
+      }
+    }
+
+    function updateParticles() {
+      for (let p of particles) {
+        const dx = p.targetX - p.x;
+        const dy = p.targetY - p.y;
+        const dist = Math.sqrt(dx * dx + dy * dy);
+        if (dist < 2) {
+          p.x = p.targetX;
+          p.y = p.targetY;
+        } else {
+          const angle = Math.atan2(dy, dx);
+          p.x += Math.cos(angle) * p.speed;
+          p.y += Math.sin(angle) * p.speed;
+        }
+        if (isHovering) {
+          const nx = (Math.random() - 0.5) * 10;
+          const ny = (Math.random() - 0.5) * 10;
+          p.x += nx;
+          p.y += ny;
+        }
+      }
+    }
+
+    function drawParticles() {
+      ctx.clearRect(0, 0, width, height);
+      ctx.fillStyle = '#00ffcc';
+      for (let p of particles) {
+        ctx.beginPath();
+        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
+        ctx.fill();
+      }
+    }
+
+    function animate() {
+      updateParticles();
+      drawParticles();
+      requestAnimationFrame(animate);
+    }
+
+    function resetParticles() {
+      createParticles(targetWord);
+    }
+
+    window.addEventListener('mousemove', () => {
+      isHovering = true;
+      setTimeout(() => {
+        isHovering = false;
+      }, 500);
+    });
+
+    window.addEventListener('keydown', (e) => {
+      if (e.key.length === 1 && e.key.match(/[a-zA-Z]/)) {
+        targetWord = e.key.toUpperCase();
+        resetParticles();
+      }
+    });
+
+    createParticles(targetWord);
+    animate();
+  </script>
+</body>
+</html>
\ No newline at end of file
diff --git a/games/rope-physics/index.html b/games/rope-physics/index.html
new file mode 100644
index 0000000..265a9c3
--- /dev/null
+++ b/games/rope-physics/index.html
@@ -0,0 +1,350 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Verlet Rope · Physics Toy</title>
+<style>
+  :root{
+    --bg0:#0b0f1a; --bg1:#151b2e; --ink:#e8ecf7; --muted:#8a93ad;
+    --accent:#5ad1ff; --accent2:#ff5a8a; --line:rgba(255,255,255,.08);
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;overflow:hidden;background:var(--bg0);color:var(--ink);
+    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    -webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
+  #stage{position:fixed;inset:0}
+  canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}
+  canvas.cut{cursor:crosshair}
+  canvas:active{cursor:grabbing}
+
+  .hud{position:fixed;top:16px;left:16px;right:16px;display:flex;gap:14px;
+    align-items:flex-start;justify-content:space-between;pointer-events:none;flex-wrap:wrap}
+  .panel{pointer-events:auto;background:linear-gradient(180deg,rgba(21,27,46,.86),rgba(11,15,26,.86));
+    backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
+    border:1px solid var(--line);border-radius:14px;padding:12px 14px;
+    box-shadow:0 10px 40px rgba(0,0,0,.45)}
+  .title{font-weight:700;letter-spacing:.3px;font-size:15px;display:flex;align-items:center;gap:9px}
+  .dot{width:9px;height:9px;border-radius:50%;background:var(--accent);
+    box-shadow:0 0 12px var(--accent)}
+  .hint{color:var(--muted);font-size:12px;margin-top:5px;line-height:1.5;max-width:280px}
+  .controls{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
+  button{font:inherit;font-size:13px;font-weight:600;color:var(--ink);cursor:pointer;
+    border:1px solid var(--line);background:rgba(255,255,255,.05);
+    padding:9px 14px;border-radius:10px;transition:.15s;display:inline-flex;align-items:center;gap:7px}
+  button:hover{background:rgba(255,255,255,.11);transform:translateY(-1px)}
+  button:active{transform:translateY(0)}
+  button.on{background:var(--accent2);border-color:transparent;color:#fff;
+    box-shadow:0 4px 18px rgba(255,90,138,.5)}
+  button.on .ic{filter:none}
+  .ic{font-size:15px;line-height:1}
+  .seg{display:flex;gap:6px;align-items:center;color:var(--muted);font-size:12px}
+  input[type=range]{-webkit-appearance:none;appearance:none;width:96px;height:4px;border-radius:4px;
+    background:rgba(255,255,255,.18);outline:none}
+  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:15px;height:15px;border-radius:50%;
+    background:var(--accent);cursor:pointer;box-shadow:0 0 8px var(--accent)}
+  input[type=range]::-moz-range-thumb{width:15px;height:15px;border:none;border-radius:50%;
+    background:var(--accent);cursor:pointer}
+  .stats{position:fixed;bottom:14px;left:16px;color:var(--muted);font-size:11px;
+    font-variant-numeric:tabular-nums;letter-spacing:.4px;pointer-events:none}
+  kbd{background:rgba(255,255,255,.09);border:1px solid var(--line);border-bottom-width:2px;
+    border-radius:5px;padding:1px 6px;font-family:inherit;font-size:11px}
+</style>
+</head>
+<body>
+<div id="stage">
+  <canvas id="c"></canvas>
+</div>
+
+<div class="hud">
+  <div class="panel">
+    <div class="title"><span class="dot"></span>Verlet Rope Toy</div>
+    <div class="hint">Drag any point to swing the rope. Toggle <b>Cut</b> and slice across
+      strands to sever them. Everything is verlet-integrated cloth physics — no libraries.</div>
+  </div>
+  <div class="panel controls">
+    <button id="cutBtn" title="Toggle cut tool"><span class="ic">✂️</span><span>Cut</span></button>
+    <button id="resetBtn" title="Rebuild the rope"><span class="ic">↺</span><span>Reset</span></button>
+    <div class="seg"><span>Gravity</span><input id="grav" type="range" min="0" max="200" value="70"></div>
+    <div class="seg"><span>Wind</span><input id="wind" type="range" min="0" max="100" value="0"></div>
+  </div>
+</div>
+
+<div class="stats" id="stats"></div>
+
+<script>
+(() => {
+  "use strict";
+  const canvas = document.getElementById('c');
+  const ctx = canvas.getContext('2d');
+  const statsEl = document.getElementById('stats');
+  const cutBtn = document.getElementById('cutBtn');
+  const resetBtn = document.getElementById('resetBtn');
+  const gravSlider = document.getElementById('grav');
+  const windSlider = document.getElementById('wind');
+
+  let W = 0, H = 0, DPR = 1;
+  function resize(){
+    DPR = Math.min(window.devicePixelRatio || 1, 2);
+    W = window.innerWidth; H = window.innerHeight;
+    canvas.width = W * DPR; canvas.height = H * DPR;
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+  }
+  window.addEventListener('resize', () => { resize(); rebuild(); });
+
+  // ---------- Physics model ----------
+  // Points: {x,y,px,py,pinned}   Sticks: {a,b,len,live}
+  let points = [], sticks = [];
+  const FRICTION = 0.995;          // velocity damping
+  const BOUNCE = 0.9;
+  let gravity = 70, wind = 0;
+
+  function makePoint(x, y, pinned=false){
+    return { x, y, px:x, py:y, pinned, ox:x, oy:y };
+  }
+  function makeStick(a, b){
+    const p = points[a], q = points[b];
+    const len = Math.hypot(p.x - q.x, p.y - q.y);
+    return { a, b, len, live:true };
+  }
+
+  // Build a hanging rope grid: several ropes pinned at the top,
+  // cross-linked into a light cloth-like lattice.
+  function rebuild(){
+    points = []; sticks = [];
+    const cols = Math.max(5, Math.min(11, Math.round(W / 90)));
+    const rows = Math.max(9, Math.min(20, Math.round(H / 55)));
+    const spanW = Math.min(W * 0.72, cols * 70);
+    const spacing = spanW / (cols - 1);
+    const startX = (W - spanW) / 2;
+    const startY = H * 0.14;
+
+    const idx = (r, c) => r * cols + c;
+    for (let r = 0; r < rows; r++){
+      for (let c = 0; c < cols; c++){
+        const pinned = (r === 0) && (c % 2 === 0);
+        points.push(makePoint(startX + c * spacing, startY + r * spacing * 0.8, pinned));
+      }
+    }
+    for (let r = 0; r < rows; r++){
+      for (let c = 0; c < cols; c++){
+        if (c < cols - 1) sticks.push(makeStick(idx(r,c), idx(r,c+1)));   // horizontal
+        if (r < rows - 1) sticks.push(makeStick(idx(r,c), idx(r+1,c)));   // vertical
+      }
+    }
+  }
+
+  // ---------- Interaction ----------
+  let cutMode = false;
+  let dragPoint = null;
+  const mouse = { x:0, y:0, px:0, py:0, down:false };
+
+  function pointerPos(e){
+    const t = e.touches ? e.touches[0] : e;
+    return { x: t.clientX, y: t.clientY };
+  }
+  function nearestPoint(x, y, maxD){
+    let best = null, bd = maxD * maxD;
+    for (const p of points){
+      const dx = p.x - x, dy = p.y - y, d = dx*dx + dy*dy;
+      if (d < bd){ bd = d; best = p; }
+    }
+    return best;
+  }
+
+  function down(e){
+    e.preventDefault();
+    const {x,y} = pointerPos(e);
+    mouse.x = mouse.px = x; mouse.y = mouse.py = y; mouse.down = true;
+    if (!cutMode){
+      dragPoint = nearestPoint(x, y, 40);
+    }
+  }
+  function move(e){
+    const {x,y} = pointerPos(e);
+    mouse.px = mouse.x; mouse.py = mouse.y;
+    mouse.x = x; mouse.y = y;
+    if (cutMode && mouse.down) sliceAt(mouse.px, mouse.py, x, y);
+  }
+  function up(){ mouse.down = false; dragPoint = null; }
+
+  // Segment intersection for the cutting blade
+  function segHit(x1,y1,x2,y2, x3,y3,x4,y4){
+    const d = (x2-x1)*(y4-y3) - (y2-y1)*(x4-x3);
+    if (d === 0) return false;
+    const t = ((x3-x1)*(y4-y3) - (y3-y1)*(x4-x3)) / d;
+    const u = ((x3-x1)*(y2-y1) - (y3-y1)*(x2-x1)) / d;
+    return t >= 0 && t <= 1 && u >= 0 && u <= 1;
+  }
+  let cutFlashes = [];
+  function sliceAt(x1,y1,x2,y2){
+    for (const s of sticks){
+      if (!s.live) continue;
+      const p = points[s.a], q = points[s.b];
+      if (segHit(x1,y1,x2,y2, p.x,p.y, q.x,q.y)){
+        s.live = false;
+        cutFlashes.push({ x:(p.x+q.x)/2, y:(p.y+q.y)/2, life:1 });
+      }
+    }
+  }
+
+  canvas.addEventListener('mousedown', down);
+  window.addEventListener('mousemove', move);
+  window.addEventListener('mouseup', up);
+  canvas.addEventListener('touchstart', down, {passive:false});
+  canvas.addEventListener('touchmove', e => { e.preventDefault(); move(e); }, {passive:false});
+  window.addEventListener('touchend', up);
+
+  cutBtn.addEventListener('click', () => {
+    cutMode = !cutMode;
+    cutBtn.classList.toggle('on', cutMode);
+    canvas.classList.toggle('cut', cutMode);
+  });
+  resetBtn.addEventListener('click', rebuild);
+  gravSlider.addEventListener('input', () => gravity = +gravSlider.value);
+  windSlider.addEventListener('input', () => wind = +windSlider.value);
+
+  window.addEventListener('keydown', e => {
+    if (e.key === 'c' || e.key === 'C') cutBtn.click();
+    if (e.key === 'r' || e.key === 'R') rebuild();
+  });
+
+  // ---------- Simulation ----------
+  let t = 0;
+  function integrate(dt){
+    const g = gravity * dt;
+    const windForce = wind * 0.02 * Math.sin(t * 1.7) * dt;
+    for (const p of points){
+      if (p.pinned) continue;
+      let vx = (p.x - p.px) * FRICTION;
+      let vy = (p.y - p.py) * FRICTION;
+      p.px = p.x; p.py = p.y;
+      p.x += vx + windForce;
+      p.y += vy + g;
+    }
+  }
+  function constrain(){
+    // Drag: pull the grabbed point toward the mouse
+    if (dragPoint && mouse.down){
+      dragPoint.x = mouse.x; dragPoint.y = mouse.y;
+      dragPoint.px = mouse.x; dragPoint.py = mouse.y;
+    }
+    // Solve stick constraints several times for stiffness
+    const iterations = 12;
+    for (let k = 0; k < iterations; k++){
+      for (const s of sticks){
+        if (!s.live) continue;
+        const p = points[s.a], q = points[s.b];
+        let dx = q.x - p.x, dy = q.y - p.y;
+        let d = Math.hypot(dx, dy) || 0.0001;
+        const diff = (s.len - d) / d * 0.5;
+        const ox = dx * diff, oy = dy * diff;
+        if (!p.pinned){ p.x -= ox; p.y -= oy; }
+        if (!q.pinned){ q.x += ox; q.y += oy; }
+      }
+      // keep points on screen
+      for (const p of points){
+        if (p.pinned) continue;
+        if (p.x < 0){ p.x = 0; p.px = p.x + (p.x - p.px)*BOUNCE; }
+        else if (p.x > W){ p.x = W; p.px = p.x + (p.x - p.px)*BOUNCE; }
+        if (p.y > H){ p.y = H; p.py = p.y + (p.y - p.py)*BOUNCE; }
+      }
+    }
+  }
+
+  // ---------- Render ----------
+  function render(){
+    ctx.clearRect(0, 0, W, H);
+
+    // subtle vignette background
+    const bg = ctx.createRadialGradient(W/2, H*0.3, 50, W/2, H*0.5, Math.max(W,H)*0.8);
+    bg.addColorStop(0, 'rgba(30,40,66,.55)');
+    bg.addColorStop(1, 'rgba(6,9,17,0)');
+    ctx.fillStyle = bg;
+    ctx.fillRect(0,0,W,H);
+
+    // strands
+    ctx.lineCap = 'round';
+    ctx.lineWidth = 2.4;
+    let live = 0;
+    for (const s of sticks){
+      if (!s.live) continue;
+      live++;
+      const p = points[s.a], q = points[s.b];
+      const d = Math.hypot(q.x-p.x, q.y-p.y);
+      // color from tension (stretched = warmer)
+      const stretch = Math.min(1, Math.max(0, (d - s.len) / (s.len)));
+      const hue = 195 - stretch * 200;      // cyan -> pink when taut
+      const sat = 70 + stretch * 30;
+      ctx.strokeStyle = `hsla(${hue},${sat}%,${62 - stretch*10}%,0.9)`;
+      ctx.beginPath();
+      ctx.moveTo(p.x, p.y);
+      ctx.lineTo(q.x, q.y);
+      ctx.stroke();
+    }
+
+    // nodes
+    for (const p of points){
+      if (p.pinned){
+        ctx.fillStyle = '#ffd166';
+        ctx.beginPath(); ctx.arc(p.x, p.y, 4.5, 0, Math.PI*2); ctx.fill();
+        ctx.strokeStyle = 'rgba(255,209,102,.35)';
+        ctx.lineWidth = 1;
+        ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI*2); ctx.stroke();
+      } else {
+        ctx.fillStyle = 'rgba(200,225,255,.55)';
+        ctx.beginPath(); ctx.arc(p.x, p.y, 2.2, 0, Math.PI*2); ctx.fill();
+      }
+    }
+
+    // highlight the point under the cursor / being dragged
+    if (dragPoint){
+      ctx.strokeStyle = 'var(--accent)';
+      ctx.strokeStyle = '#5ad1ff';
+      ctx.lineWidth = 2;
+      ctx.beginPath(); ctx.arc(dragPoint.x, dragPoint.y, 12, 0, Math.PI*2); ctx.stroke();
+    }
+
+    // cut blade trail
+    if (cutMode && mouse.down){
+      ctx.strokeStyle = 'rgba(255,90,138,.9)';
+      ctx.lineWidth = 2;
+      ctx.beginPath(); ctx.moveTo(mouse.px, mouse.py); ctx.lineTo(mouse.x, mouse.y); ctx.stroke();
+    }
+
+    // cut flashes
+    for (const f of cutFlashes){
+      ctx.fillStyle = `rgba(255,120,160,${f.life*0.8})`;
+      ctx.beginPath(); ctx.arc(f.x, f.y, (1-f.life)*14 + 3, 0, Math.PI*2); ctx.fill();
+      f.life -= 0.06;
+    }
+    cutFlashes = cutFlashes.filter(f => f.life > 0);
+
+    statsEl.textContent = `${points.length} points · ${live} strands · ` +
+      `gravity ${gravity} · wind ${wind}  —  keys: C cut · R reset`;
+  }
+
+  // ---------- Loop ----------
+  let lastT = performance.now();
+  function frame(now){
+    let dt = (now - lastT) / 1000;
+    lastT = now;
+    dt = Math.min(dt, 0.033);       // clamp big gaps
+    t += dt;
+    // fixed-ish substeps for stability
+    const steps = 2;
+    for (let i = 0; i < steps; i++){
+      integrate(dt * 55 / steps);   // scale so units feel right
+      constrain();
+    }
+    render();
+    requestAnimationFrame(frame);
+  }
+
+  resize();
+  rebuild();
+  requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
\ No newline at end of file

← aeeb0bf Add 4 arcade games: Pong 2P, Emoji Memory, Spirograph, Const  ·  back to Games Agentabrams  ·  Add 8 new arcade games: Asteroids, Minesweeper, Simon, Conne 94a2079 →