Play
Everything is wired. Now play it like a player: two wallets, one map, clicking instead of scripting.
1Reload game.html
The map half of the UI is now live: select a card in your hand, click a cell — MetaMask pops a signature request (not a transaction — no gas), and the card lands within a couple of seconds. Click an enemy card to attack it; the battle result pops as a toast and appears in the log panel.
2Get yourself an opponent
Option A — seed a rival (solo). Create packages/node/scripts/seed-demo.ts and run it;
it mints for dev account #1 and places its cards on the map:
// Seed a lively demo board: mint cards for two dev accounts and place a few.
// Great when following the workshop solo — you instantly have an opponent.
//
// bun scripts/seed-demo.ts
import { createWalletClient, createPublicClient, http, parseAbi } from "viem";
import { mnemonicToAccount } from "viem/accounts";
import { hardhat } from "viem/chains";
import { contractAddressesEvmMain } from "@evm-midnight/contracts-evm";
const MNEMONIC = "test test test test test test test test test test test junk";
const RPC = "http://127.0.0.1:8545";
const API = "http://127.0.0.1:9999";
const BATCHER = "http://127.0.0.1:3334";
const NAMESPACE = "evm-midnight-node";
const erc721 = contractAddressesEvmMain().chain31337["Erc721DevModule#Erc721Dev"] as `0x${string}`;
const abi = parseAbi(["function mint(address _to, uint256 _tokenId)"]);
const publicClient = createPublicClient({ chain: hardhat, transport: http(RPC) });
const accounts = [0, 1].map((i) => mnemonicToAccount(MNEMONIC, { addressIndex: i }));
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
console.log("minting 3 cards for you (account #0) and 3 for your rival (account #1)…");
for (const [idx, account] of accounts.entries()) {
const wallet = createWalletClient({ account, chain: hardhat, transport: http(RPC) });
let hash: `0x${string}` | undefined;
for (let i = 0; i < 3; i++) {
const tokenId = BigInt(Date.now()) * 1000n + BigInt(idx * 100 + i);
hash = await wallet.writeContract({ address: erc721, abi, functionName: "mint", args: [account.address, tokenId] });
}
if (hash) await publicClient.waitForTransactionReceipt({ hash });
}
console.log("waiting for the cards to sync…");
let cards: any[] = [];
for (let i = 0; i < 20; i++) {
await sleep(1000);
cards = await (await fetch(`${API}/api/cards`)).json();
if (cards.length >= 6) break;
}
async function place(accountIdx: number, x: number, y: number, tokenId: string) {
const account = accounts[accountIdx];
const input = JSON.stringify(["place", x, y, tokenId]);
const timestamp = Date.now().toString();
const message = (NAMESPACE + timestamp + account.address + input)
.replace(/[^a-zA-Z0-9]/g, "-").toLocaleLowerCase();
const signature = await account.signMessage({ message });
await fetch(`${BATCHER}/send-input`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { addressType: 0, address: account.address, signature, input, timestamp },
confirmationLevel: "no-wait",
}),
});
}
console.log("placing your rival's cards on the map…");
const rival = accounts[1].address.toLowerCase();
const rivalCards = cards.filter((c) => c.owner === rival);
const spots: [number, number][] = [[4, 2], [5, 3], [3, 2]];
for (let i = 0; i < Math.min(rivalCards.length, spots.length); i++) {
await place(1, spots[i][0], spots[i][1], rivalCards[i].token_id);
await sleep(2500);
}
console.log("\ndone! Open game.html, connect account #0, and take back the map.");
console.log("map state: curl -s localhost:9999/api/map");
bun scripts/seed-demo.ts
Option B — a real second player (live class). Open a second browser profile (or another browser), import Hardhat account #1, and battle yourself:
# Hardhat dev account #1 — local use ONLY
0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
3Watch the leaderboard
The header of the side panel ranks players by cells controlled — same data as:
curl -s localhost:9999/api/leaderboard
[{"owner":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","cells":2}]
4Run the whole test suite
We also ship the end-to-end test that validated everything you just built — it mints for two players, places, battles, tries illegal moves, and cross-checks the state machine's battle math against an independent local calculation:
// End-to-end gameplay test: mint for two players, place cards through the
// batcher, trigger a displacement battle, and verify the state machine's
// result matches the same battle math computed locally.
//
// bun scripts/test-play.ts
import { createWalletClient, createPublicClient, http, parseAbi } from "viem";
import { mnemonicToAccount } from "viem/accounts";
import { hardhat } from "viem/chains";
import { contractAddressesEvmMain } from "@evm-midnight/contracts-evm";
import { resolveBattle, type Element } from "../game.ts";
const MNEMONIC = "test test test test test test test test test test test junk";
const RPC = "http://127.0.0.1:8545";
const API = "http://127.0.0.1:9999";
const BATCHER = "http://127.0.0.1:3334";
const NAMESPACE = "evm-midnight-node";
const erc721 = contractAddressesEvmMain().chain31337["Erc721DevModule#Erc721Dev"] as `0x${string}`;
const abi = parseAbi(["function mint(address _to, uint256 _tokenId)"]);
const publicClient = createPublicClient({ chain: hardhat, transport: http(RPC) });
const accounts = [0, 1].map((i) => mnemonicToAccount(MNEMONIC, { addressIndex: i }));
const wallets = accounts.map((account) =>
createWalletClient({ account, chain: hardhat, transport: http(RPC) })
);
const api = async (path: string) => (await fetch(`${API}${path}`)).json();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function pollUntil<T>(fn: () => Promise<T | undefined>, what: string, tries = 20): Promise<T> {
for (let i = 0; i < tries; i++) {
const v = await fn();
if (v !== undefined) return v;
await sleep(1000);
}
throw new Error(`timeout waiting for ${what}`);
}
async function mint(playerIdx: number, count: number) {
let hash: `0x${string}` | undefined;
for (let i = 0; i < count; i++) {
const tokenId = BigInt(Date.now()) * 1000n + BigInt(playerIdx * 100 + i);
hash = await wallets[playerIdx].writeContract({
address: erc721, abi, functionName: "mint",
args: [accounts[playerIdx].address, tokenId],
});
}
if (hash) await publicClient.waitForTransactionReceipt({ hash });
}
async function place(playerIdx: number, x: number, y: number, tokenId: string) {
const account = accounts[playerIdx];
const input = JSON.stringify(["place", x, y, tokenId]);
const timestamp = Date.now().toString();
const message = (NAMESPACE + timestamp + account.address + input)
.replace(/[^a-zA-Z0-9]/g, "-").toLocaleLowerCase();
const signature = await account.signMessage({ message });
const res = await fetch(`${BATCHER}/send-input`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { addressType: 0, address: account.address, signature, input, timestamp },
confirmationLevel: "no-wait",
}),
});
const body = await res.json();
console.log(` place(${x},${y}) #${tokenId} by P${playerIdx}: HTTP ${res.status} ${JSON.stringify(body).slice(0, 120)}`);
return res.ok;
}
// ---------------------------------------------------------------- test flow
let failures = 0;
const check = (cond: boolean, label: string) => {
console.log(` ${cond ? "PASS" : "FAIL"} ${label}`);
if (!cond) failures++;
};
console.log("1) minting 3 cards for P0, 2 for P1");
await mint(0, 3);
await mint(1, 2);
const owners = accounts.map((a) => a.address.toLowerCase());
const cards: any[] = await pollUntil(async () => {
const c: any[] = await api("/api/cards");
const p0 = c.filter((k) => k.owner === owners[0]).length;
const p1 = c.filter((k) => k.owner === owners[1]).length;
return p0 >= 3 && p1 >= 2 ? c : undefined;
}, "cards to sync");
console.log(` synced ${cards.length} cards`);
for (const c of cards) console.log(` #${c.token_id} ${c.name} (${c.element} ${c.power}) ${c.owner.slice(0, 8)}`);
const byPower = (owner: string) =>
cards.filter((c) => c.owner === owner).sort((a, b) => b.power - a.power);
const p0cards = byPower(owners[0]);
const p1cards = byPower(owners[1]);
console.log("2) P0 places strongest card at (3,3)");
await place(0, 3, 3, p0cards[0].token_id);
await pollUntil(async () => {
const m = await api("/api/map");
return m.cells.find((c: any) => c.x === 3 && c.y === 3) ? true : undefined;
}, "placement to sync");
check(true, "card placed on empty cell via batcher");
console.log("3) P1 places support card at (2,3) (adjacent to target)");
await place(1, 2, 3, p1cards[1].token_id);
await pollUntil(async () => {
const m = await api("/api/map");
return m.cells.find((c: any) => c.x === 2 && c.y === 3) ? true : undefined;
}, "support placement to sync");
check(true, "second placement synced");
console.log("4) P1 attacks (3,3) with its strongest card — battle!");
const mapBefore = await api("/api/map");
const defender = mapBefore.cells.find((c: any) => c.x === 3 && c.y === 3);
const neighbors = mapBefore.cells
.filter((c: any) => Math.abs(c.x - 3) + Math.abs(c.y - 3) === 1)
.map((c: any) => ({ owner: c.owner, power: c.power, element: c.element }));
const attackerCard = p1cards[0];
const expected = resolveBattle(
{ element: attackerCard.element as Element, power: attackerCard.power, owner: owners[1] },
{ element: defender.element as Element, power: defender.power, owner: defender.owner },
neighbors,
"none",
);
console.log(` expecting: attacker ${expected.attackerTotal} vs defender ${expected.defenderTotal} -> ${expected.attackerWins ? "attacker wins" : "defender holds"}`);
await place(1, 3, 3, attackerCard.token_id);
const battle: any = await pollUntil(async () => {
const b: any[] = await api("/api/battles");
return b.find((k) => k.attacker_token === attackerCard.token_id);
}, "battle to sync");
console.log(` battle row: attacker ${battle.attacker_total} vs defender ${battle.defender_total}, attacker_won=${battle.attacker_won}`);
check(Math.abs(battle.attacker_total - expected.attackerTotal) < 1e-6, "attacker total matches local math");
check(Math.abs(battle.defender_total - expected.defenderTotal) < 1e-6, "defender total matches local math");
check(battle.attacker_won === expected.attackerWins, "winner matches local math");
const mapAfter = await api("/api/map");
const cellAfter = mapAfter.cells.find((c: any) => c.x === 3 && c.y === 3);
check(
expected.attackerWins
? cellAfter.token_id === attackerCard.token_id
: cellAfter.token_id === defender.token_id,
"map cell reflects battle outcome",
);
// battle fatigue: both fighters lose 1 power (floor 1)
const cardsAfter: any[] = await api("/api/cards");
const powerNow = (t: string) => cardsAfter.find((c) => c.token_id === t)?.power;
const fatigued = (before: number) => Math.max(1, before - 1);
check(powerNow(attackerCard.token_id) === fatigued(attackerCard.power), "attacker lost 1 power (fatigue)");
check(powerNow(defender.token_id) === fatigued(defender.power), "defender lost 1 power (fatigue)");
console.log("5) invalid moves are rejected by the state machine");
await place(1, 3, 3, p1cards[1].token_id); // P1 attacking... p1cards[1] is on the map already
await place(0, 7, 7, p0cards[1].token_id); // y=7 out of bounds (grid is 8x6)
await sleep(4000);
const mapFinal = await api("/api/map");
check(mapFinal.cells.length === mapAfter.cells.length, "already-placed / out-of-bounds moves ignored");
const leaderboard: any[] = await api("/api/leaderboard");
console.log(` leaderboard: ${JSON.stringify(leaderboard)}`);
check(leaderboard.reduce((s, r) => s + r.cells, 0) === mapFinal.cells.length, "leaderboard sums to occupied cells");
console.log(failures === 0 ? "\nALL CHECKS PASSED" : `\n${failures} CHECK(S) FAILED`);
process.exit(failures === 0 ? 0 : 1);
bun scripts/test-play.ts
PASS card placed on empty cell via batcher PASS second placement synced PASS attacker total matches local math PASS defender total matches local math PASS winner matches local math PASS map cell reflects battle outcome PASS attacker lost 1 power (fatigue) PASS defender lost 1 power (fatigue) PASS already-placed / out-of-bounds moves ignored PASS leaderboard sums to occupied cells ALL CHECKS PASSED
bunx orchestrator stop, then bun run dev.
The database is wiped — yet every card and placement comes back, replayed from the chains.
The chain is the save file.