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.
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:
<!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:
- The mint (
mintCard()) — a raweth_sendTransactionwith hand-encoded calldata: selector0x40c10f19(mint(address,uint256)) + two padded arguments. No web3 library. - The art (
cardSVG()) — element picks the palette, and a hash of the token id deterministically picks one of four species per element (12 creatures total — see the gallery), with the remaining hash bits varying eyes, body squish, and expression. The same card looks the same on every screen, but no two cards are twins. - The polling loop (
refresh()) — the UI is a dumb mirror of the five API endpoints, redrawn every 2 seconds. All truth lives in the node.
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.
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
4Mint from the browser
Click Mint a card, confirm in MetaMask, watch your hand.
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.