[object Object]

← back to Model Arena

Agent-driven game: DW Pattern Match (frontend-developer) — 8x8 match-3, cascading combos, 6 SVG textures. Registered.

a0f576c71a83dcbccd4f038a01ffd4cee6b0e78e · 2026-07-25 11:42:44 -0700 · Steve Abrams

Files touched

Diff

commit a0f576c71a83dcbccd4f038a01ffd4cee6b0e78e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 11:42:44 2026 -0700

    Agent-driven game: DW Pattern Match (frontend-developer) — 8x8 match-3, cascading combos, 6 SVG textures. Registered.
---
 data/arcade-games.json             |   6 +
 public/games/dw-pattern-match.html | 839 +++++++++++++++++++++++++++++++++++++
 2 files changed, 845 insertions(+)

diff --git a/data/arcade-games.json b/data/arcade-games.json
index c2ea4d5..730ca7d 100644
--- a/data/arcade-games.json
+++ b/data/arcade-games.json
@@ -59,6 +59,12 @@
       "title": "DW Design IQ",
       "credit": "frontend-developer",
       "note": "Agent-built trivia game \u2014 12 interior-design/wallcovering questions, SVG pattern prompts, timer + streak scoring"
+    },
+    {
+      "slug": "dw-pattern-match",
+      "title": "DW Pattern Match",
+      "credit": "frontend-developer",
+      "note": "Agent-built match-3 game \u2014 swap luxury swatch tiles, cascading combos, 6 textures"
     }
   ]
 }
\ No newline at end of file
diff --git a/public/games/dw-pattern-match.html b/public/games/dw-pattern-match.html
new file mode 100644
index 0000000..d16361c
--- /dev/null
+++ b/public/games/dw-pattern-match.html
@@ -0,0 +1,839 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Pattern Match — Designer Wallcoverings</title>
+<style>
+:root{
+  --bg:#f4f1ea;--bg2:#ece8de;--ink:#1c1a17;--ink2:#3a3630;--ink3:#7a7469;
+  --accent:#c9a961;--accent2:#b8944e;--accent3:#e8d5a3;
+  --danger:#8b3a2a;--success:#2d5a3d;
+  --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+  --sans:'Gill Sans','Optima',Optima,Candara,system-ui,sans-serif;
+  --ease:cubic-bezier(.4,0,.2,1);
+  --tile:60px;--gap:4px;
+  --radius:3px;
+}
+*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
+html,body{height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans)}
+body{display:flex;flex-direction:column;align-items:center;min-height:100vh;overflow-x:hidden}
+
+/* HEADER */
+header{
+  width:100%;padding:14px 24px 12px;
+  border-bottom:1px solid var(--accent);
+  display:flex;align-items:baseline;justify-content:space-between;
+  background:var(--bg);position:sticky;top:0;z-index:50;
+}
+.wordmark{
+  font-family:var(--serif);font-size:clamp(18px,3vw,28px);
+  font-weight:400;text-transform:uppercase;letter-spacing:.18em;
+  color:var(--ink);line-height:1;
+}
+.wordmark span{display:block;font-size:.45em;letter-spacing:.22em;color:var(--ink3);margin-top:2px}
+#mute-btn{
+  background:none;border:1px solid var(--accent);border-radius:2px;
+  color:var(--ink2);font-size:13px;padding:4px 10px;cursor:pointer;
+  font-family:var(--sans);letter-spacing:.08em;transition:all .2s var(--ease);
+}
+#mute-btn:hover{background:var(--accent);color:var(--bg)}
+
+/* STATS BAR */
+.stats{
+  display:flex;gap:24px;align-items:center;padding:12px 20px;
+  width:100%;max-width:600px;flex-wrap:wrap;
+}
+.stat{display:flex;flex-direction:column;align-items:center}
+.stat-label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink3)}
+.stat-value{font-family:var(--serif);font-size:22px;color:var(--ink);line-height:1.1}
+.stat-value.accent{color:var(--accent2)}
+#combo-display{
+  font-family:var(--serif);font-size:16px;color:var(--accent);
+  letter-spacing:.08em;min-height:20px;transition:opacity .3s;
+  opacity:0;padding:0 8px;
+}
+#combo-display.show{opacity:1}
+.goal-bar-wrap{
+  width:100%;max-width:600px;padding:0 20px 4px;
+}
+.goal-bar-track{
+  height:6px;background:var(--bg2);border-radius:3px;
+  border:1px solid rgba(201,169,97,.3);overflow:hidden;
+}
+.goal-bar-fill{
+  height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));
+  border-radius:3px;transition:width .4s var(--ease);width:0%;
+}
+
+/* BOARD */
+.board-wrap{
+  padding:16px 20px 20px;width:100%;max-width:600px;
+  display:flex;justify-content:center;
+}
+#board{
+  display:grid;
+  grid-template-columns:repeat(8,var(--tile));
+  grid-template-rows:repeat(8,var(--tile));
+  gap:var(--gap);
+  position:relative;
+}
+.tile{
+  width:var(--tile);height:var(--tile);
+  border-radius:var(--radius);
+  cursor:pointer;position:relative;
+  transition:transform .15s var(--ease),box-shadow .15s var(--ease),filter .15s var(--ease);
+  overflow:hidden;
+  user-select:none;
+  border:1px solid rgba(28,26,23,.08);
+}
+.tile svg{width:100%;height:100%;display:block}
+.tile:hover{
+  transform:scale(1.07);
+  box-shadow:0 4px 14px rgba(201,169,97,.35);
+  z-index:5;border-color:var(--accent);
+}
+.tile.selected{
+  transform:scale(1.1);
+  box-shadow:0 0 0 2px var(--accent),0 4px 20px rgba(201,169,97,.5);
+  z-index:10;
+}
+.tile.shimmer{animation:shimmer .45s var(--ease) forwards}
+.tile.fall{animation:fall .3s var(--ease) both}
+.tile.drop-in{animation:drop-in .35s var(--ease) both}
+.tile.swap-anim{animation:swap-pulse .2s var(--ease) both}
+.tile.no-swap{animation:no-swap .25s ease both}
+.tile.combo-flare{animation:combo-flare .4s var(--ease) both}
+
+@keyframes shimmer{
+  0%{opacity:1;transform:scale(1)}
+  40%{opacity:.9;transform:scale(1.12);filter:brightness(1.5) saturate(1.4)}
+  100%{opacity:0;transform:scale(.6);filter:brightness(2)}
+}
+@keyframes fall{
+  0%{transform:translateY(-4px);opacity:.7}
+  100%{transform:translateY(0);opacity:1}
+}
+@keyframes drop-in{
+  0%{transform:translateY(calc(-1 * var(--tile) * 2));opacity:0}
+  70%{opacity:1}
+  85%{transform:translateY(4px)}
+  100%{transform:translateY(0);opacity:1}
+}
+@keyframes swap-pulse{
+  0%{transform:scale(1)}
+  50%{transform:scale(1.08);filter:brightness(1.2)}
+  100%{transform:scale(1)}
+}
+@keyframes no-swap{
+  0%{transform:translateX(0)}
+  25%{transform:translateX(-5px)}
+  75%{transform:translateX(5px)}
+  100%{transform:translateX(0)}
+}
+@keyframes combo-flare{
+  0%{transform:scale(1);filter:brightness(1)}
+  50%{transform:scale(1.2);filter:brightness(1.8) saturate(1.6)}
+  100%{transform:scale(1);filter:brightness(1)}
+}
+
+/* OVERLAYS */
+.overlay{
+  position:fixed;inset:0;
+  background:rgba(13,11,9,.82);
+  display:flex;align-items:center;justify-content:center;
+  z-index:100;visibility:hidden;opacity:0;
+  transition:opacity .3s var(--ease),visibility .3s;
+}
+.overlay.open{visibility:visible;opacity:1}
+.overlay-card{
+  background:var(--bg);border:1px solid var(--accent);
+  border-radius:4px;padding:44px 48px;text-align:center;
+  max-width:380px;width:90%;
+  font-family:var(--serif);
+  box-shadow:0 24px 60px rgba(0,0,0,.4);
+}
+.overlay-title{
+  font-size:32px;text-transform:uppercase;letter-spacing:.14em;
+  color:var(--ink);margin-bottom:8px;
+}
+.overlay-sub{
+  font-size:16px;color:var(--ink3);letter-spacing:.06em;margin-bottom:6px;
+  font-family:var(--sans);
+}
+.overlay-score{
+  font-size:52px;color:var(--accent);letter-spacing:.02em;margin:16px 0 6px;
+}
+.overlay-best{
+  font-size:14px;color:var(--ink3);letter-spacing:.1em;
+  font-family:var(--sans);margin-bottom:28px;
+}
+.btn{
+  display:inline-block;padding:12px 36px;
+  background:var(--accent);color:var(--ink);
+  font-family:var(--sans);font-size:13px;letter-spacing:.14em;
+  text-transform:uppercase;border:none;border-radius:2px;cursor:pointer;
+  transition:background .2s var(--ease),transform .1s;
+}
+.btn:hover{background:var(--accent2);transform:translateY(-1px)}
+.btn:active{transform:translateY(0)}
+.btn.outline{
+  background:none;border:1px solid var(--accent);color:var(--ink2);
+  margin-top:10px;
+}
+.btn.outline:hover{background:rgba(201,169,97,.12)}
+
+/* move warning */
+.moves-warning #moves-val{color:var(--danger);animation:pulse-warn .6s ease infinite alternate}
+@keyframes pulse-warn{0%{opacity:1}100%{opacity:.5}}
+
+/* combo text floater */
+#cascade-floater{
+  position:fixed;pointer-events:none;font-family:var(--serif);
+  font-size:28px;color:var(--accent);letter-spacing:.06em;
+  text-shadow:0 2px 8px rgba(0,0,0,.4);
+  opacity:0;z-index:200;transition:none;
+}
+</style>
+</head>
+<body>
+
+<header>
+  <div class="wordmark">
+    Designer Wallcoverings
+    <span>Pattern Match</span>
+  </div>
+  <button id="mute-btn" aria-label="Toggle sound">♪ Sound</button>
+</header>
+
+<div class="stats">
+  <div class="stat">
+    <span class="stat-label">Score</span>
+    <span class="stat-value accent" id="score-val">0</span>
+  </div>
+  <div class="stat">
+    <span class="stat-label">Best</span>
+    <span class="stat-value" id="best-val">0</span>
+  </div>
+  <div class="stat">
+    <span class="stat-label">Moves</span>
+    <span class="stat-value" id="moves-val">30</span>
+  </div>
+  <div class="stat">
+    <span class="stat-label">Goal</span>
+    <span class="stat-value" id="goal-val">3000</span>
+  </div>
+  <div id="combo-display" aria-live="polite"></div>
+</div>
+<div class="goal-bar-wrap">
+  <div class="goal-bar-track"><div class="goal-bar-fill" id="goal-bar"></div></div>
+</div>
+
+<div class="board-wrap">
+  <div id="board" role="grid" aria-label="Pattern match game board"></div>
+</div>
+
+<div class="overlay" id="start-overlay" aria-modal="true">
+  <div class="overlay-card">
+    <div class="overlay-title">Pattern Match</div>
+    <div class="overlay-sub" style="margin-bottom:20px">Swap adjacent tiles to match 3 or more luxurious wallcovering patterns. Chain cascades for bonus points.</div>
+    <div class="overlay-sub">Goal: <strong>3,000 pts</strong> in <strong>30 moves</strong></div>
+    <div style="margin:20px 0;display:flex;gap:8px;justify-content:center;flex-wrap:wrap" id="legend"></div>
+    <button class="btn" id="start-btn">Begin</button>
+  </div>
+</div>
+
+<div class="overlay" id="win-overlay" aria-modal="true">
+  <div class="overlay-card">
+    <div class="overlay-title">Exquisite!</div>
+    <div class="overlay-sub">Goal achieved</div>
+    <div class="overlay-score" id="win-score">0</div>
+    <div class="overlay-best" id="win-best">New best score!</div>
+    <button class="btn" id="win-btn">Play Again</button>
+  </div>
+</div>
+
+<div class="overlay" id="lose-overlay" aria-modal="true">
+  <div class="overlay-card">
+    <div class="overlay-title">So Close</div>
+    <div class="overlay-sub">No moves remaining</div>
+    <div class="overlay-score" id="lose-score">0</div>
+    <div class="overlay-best" id="lose-best"></div>
+    <button class="btn" id="lose-btn">Try Again</button>
+    <br>
+  </div>
+</div>
+
+<div id="cascade-floater"></div>
+
+<script>
+/* ─── DESIGN TOKENS ─── */
+const TYPES = ['grasscloth','damask','stripe','chinoiserie','lattice','botanical'];
+const COLS = {
+  grasscloth:'#8fa882',damask:'#7b5ea7',stripe:'#4a7fa5',
+  chinoiserie:'#c95c3a',lattice:'#b8944e',botanical:'#4a8c6f'
+};
+const GOAL = 3000;
+const MAX_MOVES = 30;
+const ROWS = 8, COLS_N = 8;
+
+/* ─── SVG TEXTURES ─── */
+function makeSVG(type){
+  const c = COLS[type];
+  const bg = lighten(c,.72);
+  const mid = lighten(c,.45);
+  switch(type){
+    case 'grasscloth': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+      ${weaveLines(c,60,4,'horizontal')}
+      ${weaveLines(mid,60,9,'vertical')}
+    </svg>`;
+    case 'damask': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+      <g fill="${c}" opacity=".55">
+        <ellipse cx="30" cy="30" rx="10" ry="14"/>
+        <ellipse cx="30" cy="30" rx="14" ry="10"/>
+        <ellipse cx="30" cy="8" rx="5" ry="7"/>
+        <ellipse cx="30" cy="52" rx="5" ry="7"/>
+        <ellipse cx="8" cy="30" rx="7" ry="5"/>
+        <ellipse cx="52" cy="30" rx="7" ry="5"/>
+      </g>
+      <g fill="none" stroke="${c}" stroke-width="1" opacity=".35">
+        <ellipse cx="30" cy="30" rx="16" ry="20"/>
+        <ellipse cx="30" cy="30" rx="20" ry="16"/>
+      </g>
+    </svg>`;
+    case 'stripe': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+      <rect x="0" y="0" width="10" height="60" fill="${c}" opacity=".6"/>
+      <rect x="14" y="0" width="3" height="60" fill="${c}" opacity=".3"/>
+      <rect x="20" y="0" width="10" height="60" fill="${mid}" opacity=".5"/>
+      <rect x="34" y="0" width="3" height="60" fill="${c}" opacity=".3"/>
+      <rect x="40" y="0" width="10" height="60" fill="${c}" opacity=".6"/>
+      <rect x="54" y="0" width="3" height="60" fill="${c}" opacity=".3"/>
+    </svg>`;
+    case 'chinoiserie': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+      <g stroke="${c}" stroke-width="1.5" fill="none" opacity=".6">
+        <path d="M10 45 Q15 30 20 40 Q25 50 30 35 Q35 20 40 32 Q45 44 50 30"/>
+        <path d="M5 55 Q12 42 18 52 Q24 62 30 48 Q36 34 42 44 Q48 54 55 42"/>
+      </g>
+      <g fill="${c}" opacity=".5">
+        <circle cx="20" cy="38" r="3"/>
+        <circle cx="35" cy="28" r="3"/>
+        <circle cx="50" cy="35" r="3"/>
+        <circle cx="12" cy="20" r="2"/>
+        <circle cx="48" cy="15" r="2"/>
+      </g>
+      <g fill="none" stroke="${c}" stroke-width="1" opacity=".4">
+        <path d="M15 20 Q22 12 30 18 Q38 24 45 16"/>
+        <line x1="18" y1="14" x2="18" y2="22"/><line x1="30" y1="16" x2="30" y2="20"/>
+        <line x1="42" y1="13" x2="42" y2="19"/>
+      </g>
+    </svg>`;
+    case 'lattice': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+      <g stroke="${c}" stroke-width="1.8" opacity=".55">
+        ${latticeLines(60,15)}
+      </g>
+      <g fill="${c}" opacity=".4">
+        ${latticeDots(60,15,3)}
+      </g>
+    </svg>`;
+    case 'botanical': return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><rect width="60" height="60" fill="${bg}"/>
+      <g fill="${c}" opacity=".5">
+        <ellipse cx="20" cy="38" rx="10" ry="5" transform="rotate(-30 20 38)"/>
+        <ellipse cx="40" cy="25" rx="10" ry="5" transform="rotate(25 40 25)"/>
+        <ellipse cx="30" cy="48" rx="8" ry="4" transform="rotate(-10 30 48)"/>
+        <ellipse cx="12" cy="18" rx="7" ry="3.5" transform="rotate(40 12 18)"/>
+        <ellipse cx="50" cy="45" rx="7" ry="3.5" transform="rotate(-20 50 45)"/>
+      </g>
+      <g stroke="${c}" stroke-width="1.2" fill="none" opacity=".45">
+        <path d="M30 55 Q28 42 20 35"/>
+        <path d="M30 55 Q32 40 40 32"/>
+        <path d="M30 55 Q30 45 30 38"/>
+        <path d="M30 38 Q22 30 14 20"/>
+        <path d="M30 38 Q36 28 46 18"/>
+      </g>
+      <g fill="${c}" opacity=".5">
+        <circle cx="14" cy="20" r="2.5"/>
+        <circle cx="46" cy="18" r="2.5"/>
+        <circle cx="20" cy="35" r="2"/>
+        <circle cx="40" cy="30" r="2"/>
+      </g>
+    </svg>`;
+  }
+}
+
+function lighten(hex, amt){
+  const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
+  const to=v=>Math.min(255,Math.round(v+(255-v)*amt));
+  return `#${to(r).toString(16).padStart(2,'0')}${to(g).toString(16).padStart(2,'0')}${to(b).toString(16).padStart(2,'0')}`;
+}
+function weaveLines(c,size,spacing,dir){
+  let s='';
+  for(let i=0;i<size;i+=spacing){
+    if(dir==='horizontal') s+=`<line x1="0" y1="${i}" x2="${size}" y2="${i}" stroke="${c}" stroke-width="1.5" opacity=".35"/>`;
+    else s+=`<line x1="${i}" y1="0" x2="${i}" y2="${size}" stroke="${c}" stroke-width=".8" opacity=".25"/>`;
+  }
+  return s;
+}
+function latticeLines(size,spacing){
+  let s='';
+  for(let i=-size;i<size*2;i+=spacing){
+    s+=`<line x1="${i}" y1="0" x2="${i+size}" y2="${size}"/>`;
+    s+=`<line x1="${i}" y1="${size}" x2="${i+size}" y2="0"/>`;
+  }
+  return s;
+}
+function latticeDots(size,spacing,r){
+  let s='';
+  for(let x=0;x<=size;x+=spacing){
+    for(let y=0;y<=size;y+=spacing){
+      s+=`<circle cx="${x}" cy="${y}" r="${r}"/>`;
+    }
+  }
+  return s;
+}
+
+/* ─── AUDIO ─── */
+let muted=false;
+let ctx=null;
+function getCtx(){if(!ctx)ctx=new(window.AudioContext||window.webkitAudioContext)();return ctx;}
+function playTone(freq,dur,type='sine',vol=.15,detune=0){
+  if(muted)return;
+  try{
+    const c=getCtx();const g=c.createGain();const o=c.createOscillator();
+    o.type=type;o.frequency.value=freq;o.detune.value=detune;
+    g.gain.setValueAtTime(vol,c.currentTime);
+    g.gain.exponentialRampToValueAtTime(.001,c.currentTime+dur);
+    o.connect(g);g.connect(c.destination);
+    o.start();o.stop(c.currentTime+dur);
+  }catch(e){}
+}
+function soundSwap(){playTone(660,.12,'triangle',.1)}
+function soundNoSwap(){playTone(200,.18,'sawtooth',.08);setTimeout(()=>playTone(180,.12,'sawtooth',.06),80)}
+function soundClear(combo=1){
+  const base=440+combo*40;
+  playTone(base,.18,'sine',.15);
+  setTimeout(()=>playTone(base*1.25,.15,'sine',.12),80);
+  setTimeout(()=>playTone(base*1.5,.2,'sine',.1),160);
+}
+function soundCascade(n){
+  for(let i=0;i<Math.min(n,5);i++) setTimeout(()=>playTone(523+i*110,.2,'triangle',.12),i*60);
+}
+function soundWin(){
+  const notes=[523,659,784,1047];
+  notes.forEach((f,i)=>setTimeout(()=>playTone(f,.4,'sine',.2),i*120));
+}
+function soundLose(){
+  playTone(330,.3,'sawtooth',.12);
+  setTimeout(()=>playTone(220,.5,'sawtooth',.1),200);
+}
+
+/* ─── GAME STATE ─── */
+let grid=[];
+let selected=null;
+let score=0;
+let moves=MAX_MOVES;
+let best=parseInt(localStorage.getItem('dw-pm-best')||'0');
+let busy=false;
+let gameActive=false;
+let dragStart=null;
+
+/* ─── DOM ─── */
+const boardEl=document.getElementById('board');
+const scoreEl=document.getElementById('score-val');
+const bestEl=document.getElementById('best-val');
+const movesEl=document.getElementById('moves-val');
+const goalEl=document.getElementById('goal-val');
+const goalBar=document.getElementById('goal-bar');
+const comboEl=document.getElementById('combo-display');
+const floater=document.getElementById('cascade-floater');
+const startOv=document.getElementById('start-overlay');
+const winOv=document.getElementById('win-overlay');
+const loseOv=document.getElementById('lose-overlay');
+
+function $(id){return document.getElementById(id)}
+
+/* ─── LEGEND ─── */
+(function buildLegend(){
+  const el=document.getElementById('legend');
+  TYPES.forEach(t=>{
+    const d=document.createElement('div');
+    d.style.cssText='display:flex;flex-direction:column;align-items:center;gap:4px';
+    d.innerHTML=`<div style="width:32px;height:32px;border-radius:2px;overflow:hidden">${makeSVG(t)}</div>
+      <span style="font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:#7a7469">${t}</span>`;
+    el.appendChild(d);
+  });
+})();
+
+/* ─── GRID INIT ─── */
+function makeGrid(){
+  let g=[];
+  for(let r=0;r<ROWS;r++){
+    g[r]=[];
+    for(let c=0;c<COLS_N;c++){
+      g[r][c]=randomType([]);
+    }
+  }
+  // guarantee no pre-existing matches
+  for(let r=0;r<ROWS;r++){
+    for(let c=0;c<COLS_N;c++){
+      const forbidden=[];
+      if(r>=2&&g[r-1][c]===g[r-2][c]) forbidden.push(g[r-1][c]);
+      if(c>=2&&g[r][c-1]===g[r][c-2]) forbidden.push(g[r][c-1]);
+      g[r][c]=randomType(forbidden);
+    }
+  }
+  return g;
+}
+function randomType(exclude){
+  const pool=TYPES.filter(t=>!exclude.includes(t));
+  return pool[Math.floor(Math.random()*pool.length)];
+}
+
+/* ─── RENDER ─── */
+let tileEls=[];
+function renderBoard(){
+  boardEl.innerHTML='';
+  tileEls=[];
+  for(let r=0;r<ROWS;r++){
+    tileEls[r]=[];
+    for(let c=0;c<COLS_N;c++){
+      const el=createTileEl(r,c);
+      boardEl.appendChild(el);
+      tileEls[r][c]=el;
+    }
+  }
+}
+function createTileEl(r,c){
+  const el=document.createElement('div');
+  el.className='tile';
+  el.setAttribute('role','gridcell');
+  el.setAttribute('aria-label',`${grid[r][c]} tile row ${r+1} column ${c+1}`);
+  el.setAttribute('tabindex','0');
+  el.dataset.r=r;el.dataset.c=c;
+  el.innerHTML=makeSVG(grid[r][c]);
+  el.dataset.type=grid[r][c];
+
+  // events
+  el.addEventListener('click',()=>onTileClick(r,c));
+  el.addEventListener('keydown',e=>{
+    if(e.key===' '||e.key==='Enter'){e.preventDefault();onTileClick(r,c);}
+    if(e.key==='ArrowUp'&&r>0)onKeySwap(r,c,r-1,c);
+    if(e.key==='ArrowDown'&&r<ROWS-1)onKeySwap(r,c,r+1,c);
+    if(e.key==='ArrowLeft'&&c>0)onKeySwap(r,c,r,c-1);
+    if(e.key==='ArrowRight'&&c<COLS_N-1)onKeySwap(r,c,r,c+1);
+  });
+  el.addEventListener('pointerdown',e=>{dragStart={r,c,x:e.clientX,y:e.clientY};});
+  el.addEventListener('pointerup',e=>{
+    if(!dragStart)return;
+    const dx=e.clientX-dragStart.x,dy=e.clientY-dragStart.y;
+    const adx=Math.abs(dx),ady=Math.abs(dy);
+    if(adx<8&&ady<8){dragStart=null;return;}
+    let nr=r,nc=c;
+    if(adx>ady){nc=dx>0?c+1:c-1;}else{nr=dy>0?r+1:r-1;}
+    if(nr>=0&&nr<ROWS&&nc>=0&&nc<COLS_N) trySwap(r,c,nr,nc);
+    dragStart=null;
+  });
+  return el;
+}
+function updateTileEl(r,c){
+  const el=tileEls[r][c];
+  el.innerHTML=makeSVG(grid[r][c]);
+  el.dataset.type=grid[r][c];
+  el.setAttribute('aria-label',`${grid[r][c]} tile row ${r+1} column ${c+1}`);
+}
+
+/* ─── INTERACTION ─── */
+function onTileClick(r,c){
+  if(busy||!gameActive)return;
+  if(selected===null){
+    selected={r,c};
+    tileEls[r][c].classList.add('selected');
+  } else {
+    const sr=selected.r,sc=selected.c;
+    tileEls[sr][sc].classList.remove('selected');
+    if(sr===r&&sc===c){selected=null;return;}
+    if(isAdjacent(sr,sc,r,c)){
+      selected=null;
+      trySwap(sr,sc,r,c);
+    } else {
+      selected={r,c};
+      tileEls[r][c].classList.add('selected');
+    }
+  }
+}
+function onKeySwap(r,c,nr,nc){
+  if(busy||!gameActive)return;
+  if(selected){tileEls[selected.r][selected.c].classList.remove('selected');selected=null;}
+  trySwap(r,c,nr,nc);
+}
+function isAdjacent(r1,c1,r2,c2){
+  return (Math.abs(r1-r2)+Math.abs(c1-c2))===1;
+}
+
+async function trySwap(r1,c1,r2,c2){
+  if(busy||!gameActive)return;
+  busy=true;
+  soundSwap();
+  animateTile(r1,c1,'swap-anim');
+  animateTile(r2,c2,'swap-anim');
+  // do swap
+  const tmp=grid[r1][c1];grid[r1][c1]=grid[r2][c2];grid[r2][c2]=tmp;
+  updateTileEl(r1,c1);updateTileEl(r2,c2);
+
+  await delay(160);
+  const matches=findMatches();
+  if(matches.length===0){
+    // revert
+    const tmp2=grid[r1][c1];grid[r1][c1]=grid[r2][c2];grid[r2][c2]=tmp2;
+    updateTileEl(r1,c1);updateTileEl(r2,c2);
+    soundNoSwap();
+    animateTile(r1,c1,'no-swap');
+    animateTile(r2,c2,'no-swap');
+    await delay(260);
+    busy=false;
+    return;
+  }
+
+  // deduct move
+  moves--;
+  updateStats();
+
+  await resolveMatches(matches,1);
+  busy=false;
+  checkEndGame();
+}
+
+async function resolveMatches(matches,cascade){
+  if(matches.length===0)return;
+  // score
+  const pts=matches.length*50*cascade;
+  score+=pts;
+  updateStats();
+  soundClear(cascade);
+  if(cascade>1){
+    soundCascade(cascade);
+    showCascade(cascade);
+  }
+
+  // shimmer out
+  await Promise.all(matches.map(([r,c])=>shimmerTile(r,c)));
+  // remove + gravity
+  matches.forEach(([r,c])=>{grid[r][c]=null;});
+  await gravity();
+  await refill();
+  updateStats();
+
+  // chain
+  const next=findMatches();
+  if(next.length>0){
+    await delay(120);
+    await resolveMatches(next,cascade+1);
+  }
+}
+
+async function shimmerTile(r,c){
+  return new Promise(res=>{
+    const el=tileEls[r][c];
+    el.classList.add('shimmer');
+    el.addEventListener('animationend',()=>{el.classList.remove('shimmer');el.style.opacity='0';res();},{once:true});
+  });
+}
+async function gravity(){
+  // columns fall down
+  const promises=[];
+  for(let c=0;c<COLS_N;c++){
+    let writeRow=ROWS-1;
+    for(let r=ROWS-1;r>=0;r--){
+      if(grid[r][c]!==null){
+        if(writeRow!==r){
+          grid[writeRow][c]=grid[r][c];
+          grid[r][c]=null;
+        }
+        writeRow--;
+      }
+    }
+  }
+  // re-render fallen tiles
+  for(let r=0;r<ROWS;r++){
+    for(let c=0;c<COLS_N;c++){
+      const el=tileEls[r][c];
+      if(grid[r][c]!==null){
+        el.innerHTML=makeSVG(grid[r][c]);
+        el.dataset.type=grid[r][c];
+        el.style.opacity='1';
+        el.classList.remove('shimmer');
+        animateTile(r,c,'fall');
+      } else {
+        el.style.opacity='0';
+      }
+    }
+  }
+  await delay(200);
+}
+async function refill(){
+  const promises=[];
+  for(let r=0;r<ROWS;r++){
+    for(let c=0;c<COLS_N;c++){
+      if(grid[r][c]===null){
+        grid[r][c]=randomType([]);
+        const el=tileEls[r][c];
+        el.innerHTML=makeSVG(grid[r][c]);
+        el.dataset.type=grid[r][c];
+        el.setAttribute('aria-label',`${grid[r][c]} tile row ${r+1} column ${c+1}`);
+        el.style.opacity='1';
+        animateTile(r,c,'drop-in');
+      }
+    }
+  }
+  await delay(280);
+}
+
+/* ─── MATCH FINDING ─── */
+function findMatches(){
+  const matched=new Set();
+  // rows
+  for(let r=0;r<ROWS;r++){
+    for(let c=0;c<COLS_N-2;c++){
+      const t=grid[r][c];
+      if(t&&t===grid[r][c+1]&&t===grid[r][c+2]){
+        let e=c+2;
+        while(e+1<COLS_N&&grid[r][e+1]===t)e++;
+        for(let x=c;x<=e;x++) matched.add(`${r},${x}`);
+        c=e-1;
+      }
+    }
+  }
+  // cols
+  for(let c=0;c<COLS_N;c++){
+    for(let r=0;r<ROWS-2;r++){
+      const t=grid[r][c];
+      if(t&&t===grid[r+1][c]&&t===grid[r+2][c]){
+        let e=r+2;
+        while(e+1<ROWS&&grid[e+1][c]===t)e++;
+        for(let y=r;y<=e;y++) matched.add(`${y},${c}`);
+        r=e-1;
+      }
+    }
+  }
+  return [...matched].map(k=>{const[r,c]=k.split(',').map(Number);return[r,c];});
+}
+
+/* ─── HELPERS ─── */
+function animateTile(r,c,cls){
+  const el=tileEls[r][c];
+  el.classList.remove(cls);
+  void el.offsetWidth;
+  el.classList.add(cls);
+  el.addEventListener('animationend',()=>el.classList.remove(cls),{once:true});
+}
+function delay(ms){return new Promise(r=>setTimeout(r,ms));}
+
+function showCascade(n){
+  comboEl.textContent=`Cascade ×${n}`;
+  comboEl.classList.add('show');
+  clearTimeout(comboEl._t);
+  comboEl._t=setTimeout(()=>comboEl.classList.remove('show'),1400);
+
+  // floating text
+  const x=window.innerWidth/2,y=window.innerHeight/2-60;
+  floater.textContent=`×${n} Cascade`;
+  floater.style.cssText=`left:${x}px;top:${y}px;transform:translate(-50%,-50%);opacity:0;font-size:${22+n*4}px`;
+  floater.style.transition='none';
+  void floater.offsetWidth;
+  floater.style.transition='transform .6s ease, opacity .6s ease';
+  floater.style.opacity='1';
+  floater.style.transform=`translate(-50%,calc(-50% - ${20+n*8}px))`;
+  setTimeout(()=>{floater.style.opacity='0';},700);
+}
+
+/* ─── STATS ─── */
+function updateStats(){
+  scoreEl.textContent=score.toLocaleString();
+  bestEl.textContent=Math.max(score,best).toLocaleString();
+  movesEl.textContent=moves;
+  goalEl.textContent=GOAL.toLocaleString();
+  const pct=Math.min(100,(score/GOAL)*100);
+  goalBar.style.width=pct+'%';
+  // moves warning
+  document.body.classList.toggle('moves-warning',moves<=5&&moves>0);
+}
+
+/* ─── END GAME ─── */
+function checkEndGame(){
+  if(score>=GOAL){
+    if(score>best){best=score;localStorage.setItem('dw-pm-best',best);}
+    $('win-score').textContent=score.toLocaleString();
+    $('win-best').textContent=score>best?'New best score!':`Best: ${best.toLocaleString()}`;
+    gameActive=false;
+    soundWin();
+    setTimeout(()=>winOv.classList.add('open'),400);
+    return;
+  }
+  if(moves<=0){
+    if(score>best){best=score;localStorage.setItem('dw-pm-best',best);}
+    $('lose-score').textContent=score.toLocaleString();
+    $('lose-best').textContent=score>0?`Best: ${Math.max(score,best).toLocaleString()}`:'';
+    gameActive=false;
+    soundLose();
+    setTimeout(()=>loseOv.classList.add('open'),400);
+  }
+}
+
+/* ─── GAME START ─── */
+function startGame(){
+  score=0;moves=MAX_MOVES;busy=false;selected=null;gameActive=true;
+  best=parseInt(localStorage.getItem('dw-pm-best')||'0');
+  grid=makeGrid();
+  renderBoard();
+  updateStats();
+  comboEl.classList.remove('show');
+  // clear any lingering initial matches (shouldn't happen but safety)
+  const init=findMatches();
+  if(init.length>0){
+    // silent clear without scoring
+    init.forEach(([r,c])=>{grid[r][c]=randomType([]);updateTileEl(r,c);});
+  }
+}
+
+/* ─── EVENT WIRING ─── */
+$('mute-btn').addEventListener('click',()=>{
+  muted=!muted;
+  $('mute-btn').textContent=muted?'♪ Muted':'♪ Sound';
+  if(!muted){try{getCtx().resume();}catch(e){}}
+});
+$('start-btn').addEventListener('click',()=>{
+  startOv.classList.remove('open');
+  startGame();
+});
+$('win-btn').addEventListener('click',()=>{
+  winOv.classList.remove('open');
+  startGame();
+});
+$('lose-btn').addEventListener('click',()=>{
+  loseOv.classList.remove('open');
+  startGame();
+});
+
+// keyboard: escape deselects
+document.addEventListener('keydown',e=>{
+  if(e.key==='Escape'&&selected){
+    tileEls[selected.r][selected.c].classList.remove('selected');
+    selected=null;
+  }
+});
+
+// responsive tile size
+function resizeTiles(){
+  const avail=Math.min(window.innerWidth-40,560);
+  const size=Math.floor((avail-7*4)/8);
+  document.documentElement.style.setProperty('--tile',size+'px');
+}
+window.addEventListener('resize',resizeTiles);
+resizeTiles();
+
+/* ─── INIT ─── */
+bestEl.textContent=best.toLocaleString();
+goalEl.textContent=GOAL.toLocaleString();
+startOv.classList.add('open');
+</script>
+</body>
+</html>

← e02303a Agent-driven trivia game: DW Design IQ (frontend-developer)  ·  back to Model Arena  ·  Agent-driven editorial: DW The Lookbook (frontend-developer) ef2e59b →