C · Building a Game · Step 3

Mint page & card art

The whole game UI is one plain HTML file — no framework, no build step, no image assets. Every creature is drawn as SVG from its on-chain stats.

▼ details below

1Get game.html

Save this anywhere (the template root is fine). It's the complete game — this step uses the mint + hand half; the map half comes alive in Step 4:

game.html · 481 linesdownload
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridBeasts</title>
<style>
  :root {
    --bg: #0e1116; --panel: #161b24; --panel2: #1d2430; --line: #2a3444;
    --text: #e6edf3; --dim: #8b98a9;
    --fire: #ff6b35; --water: #4da6ff; --grass: #5dbb63;
    --gold: #ffd166; --danger: #ef476f;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { background: var(--bg); color: var(--text); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; min-height: 100vh; }

  header { display: flex; align-items: center; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
  header h1 { font-size: 20px; letter-spacing: 2px; }
  #season { padding: 4px 12px; border: 1px solid var(--line); border-radius: 20px; font-size: 12px; color: var(--dim); }
  #season.fire  { border-color: var(--fire);  color: var(--fire); }
  #season.water { border-color: var(--water); color: var(--water); }
  #season.grass { border-color: var(--grass); color: var(--grass); }
  .spacer { flex: 1; }
  button { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; font: inherit; cursor: pointer; }
  button:hover { border-color: var(--dim); }
  button.primary { background: #2b4c7e; border-color: #3d6cb3; }
  button:disabled { opacity: 0.45; cursor: wait; }
  #account { font-size: 12px; color: var(--dim); }

  main { display: grid; grid-template-columns: 300px 1fr 260px; gap: 16px; padding: 16px 20px; align-items: start; }
  @media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
  section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; }
  section h2 { font-size: 12px; letter-spacing: 2px; text-transform: uppercase; color: var(--dim); margin-bottom: 10px; }

  /* hand */
  #hand { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 10px; max-height: 70vh; overflow-y: auto; }
  .card { background: var(--panel2); border: 2px solid var(--line); border-radius: 10px; padding: 6px; cursor: pointer; text-align: center; }
  .card.selected { border-color: var(--gold); box-shadow: 0 0 12px #ffd16644; }
  .card .cname { font-size: 11px; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .card .cmeta { font-size: 11px; color: var(--dim); }

  /* map */
  #grid { display: grid; gap: 4px; }
  .cell { aspect-ratio: 1; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; position: relative; cursor: pointer; display: flex; align-items: center; justify-content: center; }
  .cell:hover { border-color: var(--gold); }
  .cell.mine   { border-color: #3d6cb3; }
  .cell.enemy  { border-color: #7e2b3d; }
  .cell .power { position: absolute; bottom: 2px; right: 5px; font-size: 11px; color: var(--gold); }
  .cell svg { width: 82%; height: 82%; }

  /* side panel */
  #leaderboard div, #battles div { font-size: 12px; padding: 4px 0; border-bottom: 1px dashed var(--line); color: var(--dim); }
  #battles b { color: var(--text); }
  .win  { color: var(--grass); }
  .loss { color: var(--danger); }

  #toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--panel2); border: 1px solid var(--gold); border-radius: 10px; padding: 12px 20px; display: none; z-index: 10; }

  /* wallet picker modal (EIP-6963) */
  #walletModal { position: fixed; inset: 0; background: #000a; display: none; align-items: center; justify-content: center; z-index: 20; }
  #walletModal .sheet { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 20px; min-width: 300px; }
  #walletModal h3 { font-size: 14px; margin-bottom: 6px; }
  #walletModal .hint { font-size: 11px; color: var(--dim); margin-bottom: 12px; }
  #walletModal .wopt { display: flex; align-items: center; gap: 10px; width: 100%; margin: 6px 0; padding: 10px 12px; text-align: left; }
  #walletModal .wopt img { width: 24px; height: 24px; border-radius: 6px; }
</style>
</head>
<body>

<header>
  <h1>GRIDBEASTS</h1>
  <div id="season" title="Set by a counter contract on Midnight. counter = the contract's ledger `round` value, synced by the node. Players don't need a Midnight wallet — advancing the season is a game-master action (a real Midnight tx).">season: none · counter 0</div>
  <button id="seasonBtn" title="Submits a Midnight transaction via the node (POST /api/season/increment). Proving takes up to a minute.">Advance season</button>
  <div class="spacer"></div>
  <span id="account">not connected</span>
  <button id="connectBtn" class="primary">Connect wallet</button>
  <button id="mintBtn">Mint a card</button>
</header>

<main>
  <section>
    <h2>Your hand <span id="handHint" style="text-transform:none"></span></h2>
    <div id="hand"></div>
  </section>

  <section>
    <h2>The shared map — select a card, click a cell</h2>
    <div id="grid"></div>
  </section>

  <section>
    <h2>Leaderboard</h2>
    <div id="leaderboard"></div>
    <h2 style="margin-top:16px">Battles</h2>
    <div id="battles"></div>
  </section>
</main>

<div id="toast"></div>

<div id="walletModal">
  <div class="sheet">
    <h3>Choose a wallet</h3>
    <div class="hint">This game runs on a local Hardhat chain (31337).<br>
    Some wallets refuse custom networks — MetaMask is the safe pick.</div>
    <div id="walletList"></div>
  </div>
</div>

<script>
// ========================== configuration ==================================
// Deployment addresses are deterministic on a fresh local Hardhat chain.
// If yours differ, check packages/contracts-evm/ignition/deployments/.
const CONFIG = {
  API: "http://localhost:9999",
  BATCHER: "http://localhost:3334",
  ERC721: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
  NAMESPACE: "evm-midnight-node",   // matches setSecurityNamespace() in config.dev.ts
  CHAIN_ID: "0x7a69",               // 31337 (local Hardhat)
  RPC: "http://localhost:8545",
};

// ========================== tiny helpers ===================================
const $ = (id) => document.getElementById(id);
const short = (a) => a ? a.slice(0, 6) + ".." + a.slice(-4) : "";
let account = null;
let selectedToken = null;
let state = { cards: [], map: { width: 8, height: 6, cells: [] }, battles: [], leaderboard: [], season: { element: "none" } };

function toast(msg, ms = 3500) {
  const t = $("toast");
  t.textContent = msg; t.style.display = "block";
  clearTimeout(t._h); t._h = setTimeout(() => (t.style.display = "none"), ms);
}

// ========================== wallet discovery (EIP-6963) =====================
// Browsers can have several injected wallets. EIP-6963 lets each one announce
// itself, so we can show a picker instead of grabbing whichever loaded first.
let eth = null; // the EIP-1193 provider the player chose
const discovered = [];
window.addEventListener("eip6963:announceProvider", (e) => {
  if (!discovered.some((p) => p.info.uuid === e.detail.info.uuid)) discovered.push(e.detail);
});
window.dispatchEvent(new Event("eip6963:requestProvider"));

async function connect() {
  if (discovered.length === 0) {
    // Legacy fallback: a single pre-6963 wallet at window.ethereum, or nothing.
    if (!window.ethereum) return toast("No EVM wallet found — install MetaMask");
    return useProvider(window.ethereum, "your wallet");
  }
  if (discovered.length === 1) {
    return useProvider(discovered[0].provider, discovered[0].info.name);
  }
  // Several wallets installed: let the player choose.
  $("walletList").innerHTML = discovered.map((p, i) => `
    <button class="wopt" data-i="${i}">
      ${p.info.icon ? `<img src="${p.info.icon}" alt="">` : ""}
      <span>${p.info.name}</span>
    </button>`).join("");
  document.querySelectorAll("#walletModal .wopt").forEach((el) =>
    el.addEventListener("click", () => {
      $("walletModal").style.display = "none";
      const p = discovered[Number(el.dataset.i)];
      useProvider(p.provider, p.info.name);
    }));
  $("walletModal").style.display = "flex";
}

async function useProvider(provider, name) {
  eth = provider;
  try {
    const accounts = await eth.request({ method: "eth_requestAccounts" });
    account = accounts[0].toLowerCase();
  } catch (e) {
    return toast(`${name} refused the connection`);
  }
  try {
    await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: CONFIG.CHAIN_ID }] });
  } catch (e) {
    try {
      // Chain unknown to the wallet: offer to add it.
      await eth.request({
        method: "wallet_addEthereumChain",
        params: [{ chainId: CONFIG.CHAIN_ID, chainName: "Hardhat (local)", rpcUrls: [CONFIG.RPC],
                   nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 } }],
      });
    } catch (e2) {
      // Some wallets simply refuse localhost/custom chains.
      eth = null; account = null;
      return toast(`${name} doesn't allow the local Hardhat chain — pick MetaMask instead`, 6000);
    }
  }
  eth.on?.("accountsChanged", (a) => { account = a[0]?.toLowerCase() ?? null; render(); });
  $("account").textContent = `${short(account)} · ${name}`;
  $("connectBtn").textContent = "Switch wallet";
  render();
}

// ========================== mint (a REAL on-chain tx) =======================
// mint(address,uint256) selector = 0x40c10f19; arguments are ABI-encoded by hand.
async function mintCard() {
  if (!account) return toast("Connect your wallet first");
  const tokenId = BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000));
  const data = "0x40c10f19" +
    account.slice(2).padStart(64, "0") +
    tokenId.toString(16).padStart(64, "0");
  await eth.request({
    method: "eth_sendTransaction",
    params: [{ from: account, to: CONFIG.ERC721, data }],
  });
  toast("Mint sent! Your card's stats are rolled by the node when the event syncs…");
}

// ========================== place (GASLESS, via the batcher) ================
async function placeCard(x, y) {
  if (!account) return toast("Connect your wallet first");
  if (!selectedToken) return toast("Select a card from your hand first");

  // 1. concise input, matching grammar.ts:  ["place", x, y, tokenId]
  const input = JSON.stringify(["place", x, y, selectedToken]);

  // 2. the message the batcher (and later the node) verifies
  const timestamp = Date.now().toString();
  const message = (CONFIG.NAMESPACE + timestamp + account + input)
    .replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();

  // 3. a normal personal_sign — free, instant, no gas
  const hexMsg = "0x" + [...message].map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
  const signature = await eth.request({ method: "personal_sign", params: [hexMsg, account] });

  // 4. hand it to the batcher
  const res = await fetch(`${CONFIG.BATCHER}/send-input`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      data: { addressType: 0, address: account, signature, input, timestamp },
      confirmationLevel: "no-wait",
    }),
  });
  toast(res.ok ? `Placing at (${x},${y})…` : "Batcher rejected the input");
  selectedToken = null;
  render();
}

// ========================== season (Midnight) ===============================
// The counter shown in the banner is the Midnight contract's ledger `round`
// value, synced into Postgres by the node. This button asks the NODE to submit
// the increment transaction (game-master action — no player wallet involved).
let lastCounter = null;
async function advanceSeason() {
  const res = await fetch(`${CONFIG.API}/api/season/increment`, { method: "POST" });
  const body = await res.json();
  if (res.status === 202) toast("Midnight transaction submitted — proving takes up to a minute…", 6000);
  else if (res.status === 409) toast("A season change is already being proven — patience");
  else toast(`Season increment failed: ${body.reason ?? res.status}`);
}

// ========================== procedural card art =============================
// The token id is hashed once; bits pick the SPECIES (4 per element) and vary
// the individual (eyes, squish, mouth). Same token = same creature everywhere.
const PALETTES = {
  fire:  { body: "#ff6b35", dark: "#c2401c", glow: "#ffd166" },
  water: { body: "#4da6ff", dark: "#1d6fbf", glow: "#a8d8ff" },
  grass: { body: "#5dbb63", dark: "#2e7d36", glow: "#b5e7a0" },
};

const SPECIES = {
  fire:  ["Imp", "Drake", "Phoenix", "Magma Slug"],
  water: ["Jelly", "Puffer", "Octo", "Angler"],
  grass: ["Sproutling", "Shroom", "Treant", "Cactling"],
};

function hashNum(str) {
  let h = 0;
  for (const ch of String(str)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
  return h;
}

function speciesOf(card) {
  const el = SPECIES[card.element] ? card.element : "fire";
  return SPECIES[el][(hashNum(card.token_id) >>> 7) % 4];
}

function cardSVG(card, size = 90) {
  const el = PALETTES[card.element] ? card.element : "fire";
  const p = PALETTES[el];
  const h = hashNum(card.token_id);
  const sp = (h >>> 7) % 4;                     // species (same bits as speciesOf)
  const eyes = 1 + (h % 3);                     // 1..3 eyes
  const squish = 0.85 + ((h >> 3) % 12) / 60;   // individual body squish
  const bend = 4 + (h % 5);                     // mouth curvature

  const eyesAt = (cx, cy, r = 5, gap = 16) => {
    let s = "";
    for (let i = 0; i < eyes; i++) {
      const ex = cx - ((eyes - 1) * gap) / 2 + i * gap;
      s += `<circle cx="${ex}" cy="${cy}" r="${r}" fill="#fff"/>` +
           `<circle cx="${ex}" cy="${cy + 1}" r="${r * 0.48}" fill="#111"/>`;
    }
    return s;
  };
  const mouth = (cx, cy, w = 16) =>
    `<path d="M ${cx - w / 2} ${cy} q ${w / 2} ${bend} ${w} 0" stroke="#111" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
  const B = `fill="${p.body}" stroke="${p.dark}" stroke-width="3"`;

  let g = "";
  if (el === "fire") {
    if (sp === 0) { // Imp — horned blob with a flame tail
      g = `<ellipse cx="50" cy="60" rx="28" ry="${26 * squish}" ${B}/>
        <path d="M 32 42 l -6 -14 l 12 6 z" ${B}/><path d="M 68 42 l 6 -14 l -12 6 z" ${B}/>
        <path d="M 78 66 q 14 -4 10 -16 q 8 12 -4 20 z" fill="${p.glow}"/>
        ${eyesAt(50, 54)}${mouth(50, 68)}`;
    } else if (sp === 1) { // Drake — long body, neck, spikes
      g = `<ellipse cx="42" cy="70" rx="26" ry="${16 * squish}" ${B}/>
        <path d="M 58 66 q 8 -22 16 -26" stroke="${p.body}" stroke-width="12" fill="none" stroke-linecap="round"/>
        <circle cx="76" cy="38" r="11" ${B}/>
        <path d="M 30 56 l 4 -9 l 5 8 z M 42 54 l 4 -9 l 5 8 z" fill="${p.glow}"/>
        <path d="M 14 74 q -8 -6 -2 -14 q 0 10 8 10 z" fill="${p.glow}"/>
        ${eyesAt(76, 36, 3.5, 9)}${mouth(78, 44, 8)}`;
    } else if (sp === 2) { // Phoenix — crest, wings, beak
      g = `<path d="M 44 22 q 2 -10 6 0 q 4 -12 8 -1 q 5 -8 6 2 z" fill="${p.glow}"/>
        <ellipse cx="52" cy="58" rx="20" ry="${24 * squish}" ${B}/>
        <path d="M 32 52 q -16 4 -18 18 q 12 -4 20 -8 z" ${B}/>
        <path d="M 72 52 q 16 4 18 18 q -12 -4 -20 -8 z" ${B}/>
        <path d="M 52 62 l 8 4 l -8 4 z" fill="${p.glow}" stroke="${p.dark}" stroke-width="1.5"/>
        ${eyesAt(52, 50, 4, 12)}`;
    } else { // Magma Slug — low and wide, lava spots, eye stalks
      g = `<ellipse cx="50" cy="72" rx="36" ry="${17 * squish}" ${B}/>
        <circle cx="38" cy="72" r="4" fill="${p.glow}"/><circle cx="56" cy="76" r="3" fill="${p.glow}"/><circle cx="68" cy="70" r="3.5" fill="${p.glow}"/>
        <path d="M 40 58 q -2 -12 -4 -14 M 58 58 q 2 -12 4 -14" stroke="${p.dark}" stroke-width="3" fill="none"/>
        ${eyesAt(36, 42, 4.5, 0)}${eyesAt(62, 42, 4.5, 0)}${mouth(49, 66, 12)}`;
    }
  } else if (el === "water") {
    if (sp === 0) { // Jelly — dome + tentacles
      g = `<path d="M 22 58 a 28 26 0 0 1 56 0 z" ${B} opacity="0.92"/>
        <path d="M 28 60 q -3 12 3 22 M 42 62 q -2 14 2 24 M 58 62 q 2 14 -2 24 M 72 60 q 3 12 -3 22" stroke="${p.dark}" stroke-width="3.5" fill="none" stroke-linecap="round"/>
        <circle cx="36" cy="34" r="4" fill="${p.glow}" opacity="0.9"/>
        ${eyesAt(50, 48)}${mouth(50, 56, 10)}`;
    } else if (sp === 1) { // Puffer — spiky ball
      let spikes = "";
      for (let i = 0; i < 8; i++) {
        const a = (Math.PI * 2 * i) / 8 + 0.4;
        const x1 = 50 + Math.cos(a) * 26, y1 = 58 + Math.sin(a) * 24;
        const x2 = 50 + Math.cos(a) * 36, y2 = 58 + Math.sin(a) * 33;
        spikes += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${p.dark}" stroke-width="3" stroke-linecap="round"/>`;
      }
      g = `${spikes}<circle cx="50" cy="58" r="${25 * squish}" ${B}/>
        ${eyesAt(50, 52)}<circle cx="50" cy="66" r="3.5" fill="#111"/>`;
    } else if (sp === 2) { // Octo — head + curling legs
      g = `<path d="M 26 56 a 24 24 0 0 1 48 0 v 6 h -48 z" ${B}/>
        <path d="M 30 62 q -6 14 -12 14 M 42 64 q -2 16 -8 20 M 58 64 q 2 16 8 20 M 70 62 q 6 14 12 14" stroke="${p.body}" stroke-width="7" fill="none" stroke-linecap="round"/>
        <circle cx="64" cy="34" r="3" fill="${p.glow}" opacity="0.9"/>
        ${eyesAt(50, 48)}${mouth(50, 58, 10)}`;
    } else { // Angler — lure, fangs
      g = `<path d="M 60 34 q 12 -10 16 -2" stroke="${p.dark}" stroke-width="2.5" fill="none"/>
        <circle cx="77" cy="30" r="4.5" fill="${p.glow}"/>
        <ellipse cx="48" cy="62" rx="30" ry="${21 * squish}" ${B}/>
        <path d="M 30 70 l 5 -6 l 5 6 l 5 -6 l 5 6" stroke="#fff" stroke-width="2.5" fill="none"/>
        <path d="M 76 58 l 12 -7 v 14 z" ${B}/>
        ${eyesAt(44, 52, 5.5, 18)}`;
    }
  } else { // grass
    if (sp === 0) { // Sproutling — blob with a sprout
      g = `<path d="M 50 34 q -2 -12 -14 -14 q 10 -2 14 4 q 4 -10 16 -8 q -12 4 -14 18" fill="${p.dark}"/>
        <ellipse cx="50" cy="62" rx="26" ry="${23 * squish}" ${B}/>
        ${eyesAt(50, 56)}${mouth(50, 70)}`;
    } else if (sp === 1) { // Shroom — cap + stem
      g = `<rect x="40" y="52" width="20" height="30" rx="9" fill="#efe6d8" stroke="${p.dark}" stroke-width="2.5"/>
        <path d="M 20 52 a 30 24 0 0 1 60 0 z" ${B}/>
        <circle cx="38" cy="40" r="4" fill="${p.glow}"/><circle cx="58" cy="34" r="3" fill="${p.glow}"/><circle cx="66" cy="44" r="2.5" fill="${p.glow}"/>
        ${eyesAt(50, 64, 4, 12)}${mouth(50, 74, 10)}`;
    } else if (sp === 2) { // Treant — trunk + crown
      g = `<circle cx="50" cy="34" r="20" ${B}/>
        <circle cx="34" cy="42" r="12" ${B}/><circle cx="66" cy="42" r="12" ${B}/>
        <rect x="41" y="48" width="18" height="34" rx="6" fill="#8a6238" stroke="#5d4023" stroke-width="2.5"/>
        <path d="M 41 60 h 18 M 41 70 h 18" stroke="#5d4023" stroke-width="1.5"/>
        ${eyesAt(50, 62, 3.5, 10)}${mouth(50, 74, 9)}`;
    } else { // Cactling — arms, spines, flower
      g = `<circle cx="50" cy="26" r="5" fill="${p.glow}"/><circle cx="50" cy="26" r="2" fill="${p.dark}"/>
        <rect x="38" y="34" width="24" height="46" rx="12" ${B}/>
        <path d="M 38 52 q -12 -2 -12 -12" stroke="${p.body}" stroke-width="8" fill="none" stroke-linecap="round"/>
        <path d="M 62 58 q 12 -2 12 -12" stroke="${p.body}" stroke-width="8" fill="none" stroke-linecap="round"/>
        <path d="M 44 42 l -4 -3 M 56 42 l 4 -3 M 44 68 l -4 3 M 56 68 l 4 3" stroke="${p.dark}" stroke-width="2"/>
        ${eyesAt(50, 52, 4, 11)}${mouth(50, 64, 9)}`;
    }
  }
  return `<svg viewBox="0 0 100 100" width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">${g}</svg>`;
}

// ========================== polling + rendering =============================
async function refresh() {
  try {
    const [cards, map, battles, leaderboard, season] = await Promise.all([
      fetch(`${CONFIG.API}/api/cards`).then((r) => r.json()),
      fetch(`${CONFIG.API}/api/map`).then((r) => r.json()),
      fetch(`${CONFIG.API}/api/battles`).then((r) => r.json()),
      fetch(`${CONFIG.API}/api/leaderboard`).then((r) => r.json()),
      fetch(`${CONFIG.API}/api/season`).then((r) => r.json()),
    ]);
    state = { cards, map, battles, leaderboard, season };
    render();
  } catch (e) { /* node not up yet — keep polling */ }
}

function render() {
  // season banner — counter_value comes straight from the Midnight ledger
  const s = state.season.element || "none";
  const counter = state.season.counter_value ?? 0;
  const banner = $("season");
  banner.textContent =
    (s === "none" ? "season: none" : `season of ${s} (+2 power)`) +
    ` · counter ${counter}` +
    (state.season.advancing ? " · proving…" : "");
  banner.className = s;
  $("seasonBtn").disabled = Boolean(state.season.advancing);
  if (lastCounter !== null && counter !== lastCounter) {
    toast(`The season turned! Midnight counter is now ${counter} — ${state.season.element} is buffed`, 6000);
  }
  lastCounter = counter;

  // hand: my cards that are not on the map
  const placed = new Set(state.map.cells.map((c) => c.token_id));
  const hand = account
    ? state.cards.filter((c) => c.owner === account && !placed.has(c.token_id))
    : [];
  $("handHint").textContent = account ? ` — ${hand.length} card(s)` : " — connect to see yours";
  $("hand").innerHTML = hand.map((c) => `
    <div class="card ${c.token_id === selectedToken ? "selected" : ""}" data-token="${c.token_id}">
      ${cardSVG(c)}
      <div class="cname">${c.name}</div>
      <div class="cmeta">${speciesOf(c)} · ${c.element} ${c.power}</div>
    </div>`).join("") || `<div style="color:var(--dim);font-size:12px">No cards in hand. Mint one!</div>`;
  document.querySelectorAll("#hand .card").forEach((el) =>
    el.addEventListener("click", () => {
      selectedToken = el.dataset.token === selectedToken ? null : el.dataset.token;
      render();
    }));

  // map
  const { width, height, cells } = state.map;
  const grid = $("grid");
  grid.style.gridTemplateColumns = `repeat(${width}, 1fr)`;
  const byPos = Object.fromEntries(cells.map((c) => [`${c.x},${c.y}`, c]));
  let html = "";
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const c = byPos[`${x},${y}`];
      const cls = !c ? "" : c.owner === account ? "mine" : "enemy";
      html += `<div class="cell ${cls}" data-x="${x}" data-y="${y}" title="${c ? `${c.name} the ${speciesOf(c)} (${c.element} ${c.power}) — ${short(c.owner)}` : `(${x},${y})`}">
        ${c ? cardSVG(c, 999) : ""}${c ? `<span class="power">${c.power}</span>` : ""}
      </div>`;
    }
  }
  grid.innerHTML = html;
  document.querySelectorAll("#grid .cell").forEach((el) =>
    el.addEventListener("click", () => placeCard(Number(el.dataset.x), Number(el.dataset.y))));

  // leaderboard + battles
  $("leaderboard").innerHTML = state.leaderboard.map((r, i) =>
    `<div>#${i + 1} ${short(r.owner)} — ${r.cells} cell(s)</div>`).join("") ||
    `<div>Nobody on the map yet</div>`;
  $("battles").innerHTML = state.battles.slice(0, 8).map((b) => `
    <div><b>(${b.x},${b.y})</b> ${short(b.attacker)} ${b.attacker_total}
      vs ${short(b.defender)} ${b.defender_total}
      <span class="${b.attacker_won ? "win" : "loss"}">${b.attacker_won ? "displaced!" : "held"}</span></div>`).join("") ||
    `<div>No battles yet</div>`;
}

$("connectBtn").addEventListener("click", connect);
$("mintBtn").addEventListener("click", mintCard);
$("seasonBtn").addEventListener("click", advanceSeason);
$("walletModal").addEventListener("click", (e) => {
  if (e.target.id === "walletModal") e.target.style.display = "none";
});

refresh();
setInterval(refresh, 2000);
</script>
</body>
</html>

Three parts worth reading before you run it:

2Serve it

python3 -m http.server 8080     # from the folder containing game.html
# or: bunx serve .

Open localhost:8080/game.html. (Opening the file directly also works — the node's API allows any origin — but a server keeps MetaMask happiest.)

3Connect a funded dev wallet

Click Connect wallet. The page uses EIP-6963 wallet discovery: every injected wallet announces itself, and if you have more than one (MetaMask, Rabby, Coinbase, Phantom…) you get a picker instead of whichever extension loaded first.

Not every wallet plays along
Some wallets refuse to add custom/localhost networks like Hardhat (31337). If yours declines, the game tells you and lets you pick again — MetaMask is the safe choice for this workshop.

After choosing, the game adds/switches to the local chain (31337). Then import a pre-funded Hardhat account into the wallet (MetaMask: Account menu → Import account):

# Hardhat dev account #0 — local use ONLY, never on a real network
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
Obvious but said anyway
These keys are baked into every Hardhat install on Earth. Never send real funds to them.

4Mint from the browser

Click Mint a card, confirm in MetaMask, watch your hand.

Checkpoint
Within ~2 seconds of confirming, a creature appears in "Your hand" with art, element, and power — and the same card shows in curl -s localhost:9999/api/cards. You just watched: MetaMask → Hardhat → primitive → grammar → state machine → Postgres → API → SVG.

Clicking map cells doesn't do anything on-chain yet — the batcher input has nowhere to land. Fixing that is Step 4.
← 2 · Random stats4 · Map + battles →