Random stats in the state machine
The heart of the workshop. When a mint event syncs, the node rolls the card's element, power, and name — randomly, yet identically on every node that will ever replay this chain.
Why not Math.random()?
Effectstream hands every state transition a randomGenerator seeded from the block being
processed. Unpredictable before the mint lands; reproducible forever after. That's how a
deterministic machine rolls dice.
1The rules module
Create packages/node/game.ts — all constants and pure functions. Tune the numbers at the top and the whole game rebalances:
// Game rules: constants, card generation, and battle math.
// Everything here is PURE and DETERMINISTIC — the same inputs always produce
// the same outputs on every node that syncs the chain.
// ---------------------------------------------------------------- constants
export const GRID_WIDTH = 8;
export const GRID_HEIGHT = 6;
export const ELEMENTS = ["fire", "water", "grass"] as const;
export type Element = (typeof ELEMENTS)[number];
/** fire burns grass, grass drinks water, water douses fire */
export const BEATS: Record<Element, Element> = {
fire: "grass",
grass: "water",
water: "fire",
};
export const ADVANTAGE_MULTIPLIER = 1.5;
export const DISADVANTAGE_MULTIPLIER = 0.5;
export const SEASON_BUFF = 2;
/** Both fighters lose this much power after every battle (win, lose, or tie). */
export const BATTLE_FATIGUE = 1;
export const MIN_POWER = 1;
export const MAX_POWER = 9;
// ------------------------------------------------------------- card creation
/**
* Minimal interface of the deterministic random generator that Effectstream
* hands to every state transition (seeded from the block being processed).
*/
export interface Rng {
nextInt(min: number, max: number): number;
nextArrayItem<T>(array: T[]): T;
}
const SYLLABLES = [
"ka", "zu", "mi", "ro", "ta", "shi", "go", "ne",
"ba", "ku", "ry", "po", "fen", "dra", "vol", "lux",
];
export interface CardStats {
element: Element;
power: number;
name: string;
}
/** Roll a new card. Called exactly once per minted token, on every node. */
export function generateCardStats(rng: Rng): CardStats {
const element = rng.nextArrayItem([...ELEMENTS]);
const power = rng.nextInt(MIN_POWER, MAX_POWER);
const syllableCount = rng.nextInt(2, 3);
let name = "";
for (let i = 0; i < syllableCount; i++) {
name += rng.nextArrayItem(SYLLABLES);
}
name = name.charAt(0).toUpperCase() + name.slice(1);
return { element, power, name };
}
// ---------------------------------------------------------------- battle math
export function elementMultiplier(attacker: Element, defender: Element): number {
if (BEATS[attacker] === defender) return ADVANTAGE_MULTIPLIER;
if (BEATS[defender] === attacker) return DISADVANTAGE_MULTIPLIER;
return 1;
}
function seasonBoost(cardElement: string, seasonElement: string): number {
return cardElement === seasonElement ? SEASON_BUFF : 0;
}
export interface BattleCard {
element: Element;
power: number;
owner: string;
}
export interface NeighborCell {
owner: string;
power: number;
element: string;
}
/**
* Resolve a displacement battle for a target cell.
*
* attacker total = power (+season buff) x element multiplier
* + sum of powers of attacker-owned neighbors (+their season buffs)
* defender total = power (+season buff)
* + sum of powers of defender-owned neighbors (+their season buffs)
*
* The attacker wins only with a STRICTLY greater total — ties favor the defender.
*/
export function resolveBattle(
attacker: BattleCard,
defender: BattleCard,
neighbors: NeighborCell[],
seasonElement: string,
): { attackerTotal: number; defenderTotal: number; attackerWins: boolean } {
const support = (owner: string) =>
neighbors
.filter((n) => n.owner === owner)
.reduce((sum, n) => sum + n.power + seasonBoost(n.element, seasonElement), 0);
const attackerTotal =
(attacker.power + seasonBoost(attacker.element, seasonElement)) *
elementMultiplier(attacker.element, defender.element) +
support(attacker.owner);
const defenderTotal =
defender.power +
seasonBoost(defender.element, seasonElement) +
support(defender.owner);
return { attackerTotal, defenderTotal, attackerWins: attackerTotal > defenderTotal };
}
/** Season from the Midnight counter: 0 = fire, 1 = water, 2 = grass. */
export function seasonFromCounter(counter: number): Element {
return ELEMENTS[((counter % 3) + 3) % 3];
}
2The grammar
Replace packages/node/grammar.ts. One addition: the place action players will send later (it stays dormant until Step 4 wires it to the chain):
import { Type } from "@sinclair/typebox";
import type { GrammarDefinition } from "@effectstream/concise";
import { builtinGrammars } from "@effectstream/sm/grammar";
export const grammar = {
// Chain events (builtin grammars, fed by primitives in config.dev.ts)
"transfer-assets": builtinGrammars.evmErc721,
"midnightContractState": builtinGrammars.midnightGeneric,
// Player actions (submitted through the batcher / EffectstreamL2)
"place": [
["x", Type.Number()],
["y", Type.Number()],
["tokenId", Type.String()],
],
} as const satisfies GrammarDefinition;
3The state machine
Replace packages/node/state-machine.ts. Three transitions: mint→roll stats,
place→battles (Step 4's subject), Midnight→seasons (Step 6's subject). Read the
transfer-assets one closely — it's this step's whole idea in 20 lines:
import { Stm } from "@effectstream/sm";
import type { BaseStfInput } from "@effectstream/sm";
import type { StartConfigGameStateTransitions } from "@effectstream/runtime";
import { type SyncStateUpdateStream, World } from "@effectstream/coroutine";
import {
getCardByTokenId,
insertCard,
updateCardOwner,
getCell,
getNeighbors,
isTokenPlaced,
placeCard,
insertBattle,
decrementCardPower,
getSeason,
updateSeason,
} from "@evm-midnight/database";
import { grammar } from "./grammar.ts";
import {
GRID_WIDTH,
GRID_HEIGHT,
BATTLE_FATIGUE,
MIN_POWER,
generateCardStats,
resolveBattle,
seasonFromCounter,
type Element,
} from "./game.ts";
const stm = new Stm<typeof grammar, {}>(grammar);
// ---------------------------------------------------------------------------
// 1. EVM mint / transfer → create the card with deterministic-random stats
// ---------------------------------------------------------------------------
stm.addStateTransition("transfer-assets", function* (data) {
const { to, tokenId } = data.parsedInput;
const owner = to.toLowerCase();
const [existing] = yield* World.resolve(getCardByTokenId, { token_id: tokenId });
if (!existing) {
// First time we see this token: roll its stats.
// data.randomGenerator is SEEDED BY THE BLOCK — every node rolls the same.
const stats = generateCardStats(data.randomGenerator);
console.log(
`[game] minted card #${tokenId}: ${stats.name} (${stats.element} ${stats.power}) -> ${owner}`,
);
yield* World.resolve(insertCard, {
token_id: tokenId,
owner,
element: stats.element,
power: stats.power,
name: stats.name,
block_height: data.blockHeight,
});
} else {
// Token moved wallets: keep stats, change owner.
yield* World.resolve(updateCardOwner, { token_id: tokenId, owner });
}
});
// ---------------------------------------------------------------------------
// 2. Player action (via batcher) → place a card / displacement battle
// ---------------------------------------------------------------------------
stm.addStateTransition("place", function* (data) {
const { x, y, tokenId } = data.parsedInput;
const player = data.signerAddress?.toLowerCase();
if (!player) return;
// -- validate the move ----------------------------------------------------
if (!Number.isInteger(x) || !Number.isInteger(y)) return;
if (x < 0 || x >= GRID_WIDTH || y < 0 || y >= GRID_HEIGHT) return;
const [card] = yield* World.resolve(getCardByTokenId, { token_id: tokenId });
if (!card) return; // no such card
if (card.owner !== player) return; // not your card
const [placed] = yield* World.resolve(isTokenPlaced, { token_id: tokenId });
if (placed) return; // already on the map
const [occupant] = yield* World.resolve(getCell, { x, y });
const [season] = yield* World.resolve(getSeason, undefined);
const seasonElement = season?.element ?? "none";
// -- empty cell: just place it --------------------------------------------
if (!occupant) {
console.log(`[game] ${card.name} placed at (${x},${y}) by ${player}`);
yield* World.resolve(placeCard, {
x, y,
token_id: tokenId,
owner: player,
placed_at_height: data.blockHeight,
});
return;
}
// -- occupied by your own card: not allowed --------------------------------
if (occupant.owner === player) return;
// -- battle! ----------------------------------------------------------------
const neighbors = yield* World.resolve(getNeighbors, { x, y });
const result = resolveBattle(
{ element: card.element as Element, power: card.power, owner: player },
{ element: occupant.element as Element, power: occupant.power, owner: occupant.owner },
neighbors,
seasonElement,
);
console.log(
`[game] battle at (${x},${y}): ${card.name} ${result.attackerTotal}` +
` vs ${occupant.name} ${result.defenderTotal} -> ` +
(result.attackerWins ? "attacker wins" : "defender holds"),
);
yield* World.resolve(insertBattle, {
x, y,
attacker: player,
defender: occupant.owner,
attacker_token: tokenId,
defender_token: occupant.token_id,
attacker_total: result.attackerTotal,
defender_total: result.defenderTotal,
attacker_won: result.attackerWins,
block_height: data.blockHeight,
});
// Battle fatigue: BOTH fighters lose power (never below MIN_POWER).
// Fighting is costly even when you win — and even when you lose.
for (const fatigued of [tokenId, occupant.token_id]) {
yield* World.resolve(decrementCardPower, {
token_id: fatigued,
amount: BATTLE_FATIGUE,
min_power: MIN_POWER,
});
}
if (result.attackerWins) {
// The upsert replaces the defender — its card returns to its owner's hand.
yield* World.resolve(placeCard, {
x, y,
token_id: tokenId,
owner: player,
placed_at_height: data.blockHeight,
});
}
});
// ---------------------------------------------------------------------------
// 3. Midnight contract state → the season (global element buff)
// ---------------------------------------------------------------------------
stm.addStateTransition("midnightContractState", function* (data) {
const payload: { round: string } = data.parsedInput.payload;
const counter = Number(payload.round ?? 0);
if (!Number.isFinite(counter)) return;
const element = seasonFromCounter(counter);
console.log(`[game] Midnight counter=${counter} -> season of ${element}`);
yield* World.resolve(updateSeason, {
element,
counter_value: counter,
block_height: data.blockHeight,
});
});
// ---------------------------------------------------------------------------
export const gameStateTransitions: StartConfigGameStateTransitions = function* (
blockHeight: number,
input: BaseStfInput,
): SyncStateUpdateStream<void> {
yield* stm.processInput(input);
};
4The API
Replace packages/node/api.ts — five read-only endpoints plus one game-master
action (POST /api/season/increment, explained in the bonus step):
import { spawn } from "node:child_process";
import { runPreparedQuery } from "@effectstream/db";
import {
cardsTableExists,
getCards,
getMap,
getBattles,
getLeaderboard,
getSeason,
} from "@evm-midnight/database";
import type { Pool } from "pg";
import type { StartConfigApiRouter } from "@effectstream/runtime";
import type { FastifyInstance } from "fastify";
import { GRID_WIDTH, GRID_HEIGHT } from "./game.ts";
// One season-increment job at a time. The heavy lifting (Midnight wallet,
// ZK proving) runs in a SUBPROCESS — the same scripts/increment-season.ts you
// can run by hand — so the sync node itself stays lean.
const seasonJob = { running: false, startedAt: 0, lastExit: null as number | null };
export const apiRouter: StartConfigApiRouter = async function (
server: FastifyInstance,
dbConn: Pool,
): Promise<void> {
// Guard: return empty data until the game migration has been applied.
const ready = async () => {
const [t] = await runPreparedQuery(cardsTableExists.run(undefined, dbConn), "tableExists");
return Boolean(t?.exists);
};
/** All minted cards (the newest first). */
server.get("/api/cards", async (_request, reply) => {
if (!(await ready())) return reply.send([]);
reply.send(await runPreparedQuery(getCards.run(undefined, dbConn), "/api/cards"));
});
/** Occupied cells with card stats joined in. */
server.get("/api/map", async (_request, reply) => {
if (!(await ready())) return reply.send({ width: GRID_WIDTH, height: GRID_HEIGHT, cells: [] });
const cells = await runPreparedQuery(getMap.run(undefined, dbConn), "/api/map");
reply.send({ width: GRID_WIDTH, height: GRID_HEIGHT, cells });
});
/** Last 20 battles. */
server.get("/api/battles", async (_request, reply) => {
if (!(await ready())) return reply.send([]);
reply.send(await runPreparedQuery(getBattles.run(undefined, dbConn), "/api/battles"));
});
/** Cells controlled per player. */
server.get("/api/leaderboard", async (_request, reply) => {
if (!(await ready())) return reply.send([]);
reply.send(await runPreparedQuery(getLeaderboard.run(undefined, dbConn), "/api/leaderboard"));
});
/** Current season — counter_value IS the Midnight contract's ledger `round`,
* as synced by the MidnightContractState primitive. */
server.get("/api/season", async (_request, reply) => {
if (!(await ready())) return reply.send({ element: "none", counter_value: 0, advancing: seasonJob.running });
const [season] = await runPreparedQuery(getSeason.run(undefined, dbConn), "/api/season");
reply.send({ ...(season ?? { element: "none", counter_value: 0 }), advancing: seasonJob.running });
});
/** Game-master action: advance the season by submitting a real Midnight tx.
* Dev/workshop convenience — gate or remove this endpoint in production. */
server.post("/api/season/increment", async (_request, reply) => {
if (seasonJob.running) {
return reply.code(409).send({ started: false, reason: "increment already in progress" });
}
seasonJob.running = true;
seasonJob.startedAt = Date.now();
// process.execPath = the bun binary running this node (PATH-independent).
const child = spawn(process.execPath, ["scripts/increment-season.ts"], {
cwd: import.meta.dirname,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (d) => console.log(`[season-inc] ${String(d).trimEnd()}`));
child.stderr.on("data", (d) => console.error(`[season-inc] ${String(d).trimEnd()}`));
child.on("exit", (code) => {
seasonJob.running = false;
seasonJob.lastExit = code;
console.log(`[game] season increment finished (exit ${code})`);
});
reply.code(202).send({ started: true, note: "proving on Midnight — watch /api/season" });
});
};
5A mint script
Create packages/node/scripts/mint-card.ts (MetaMask UI comes next step; scripts are better for a first test):
// Mint one or more creature cards (ERC-721) on the local Hardhat chain.
//
// bun scripts/mint-card.ts [count] [accountIndex]
//
// count how many cards to mint (default 1)
// accountIndex which Hardhat dev account to mint to (default 0)
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 HARDHAT_MNEMONIC = "test test test test test test test test test test test junk";
const RPC = process.env.EVM_RPC_URL ?? "http://127.0.0.1:8545";
const count = Number(process.argv[2] ?? "1");
const accountIndex = Number(process.argv[3] ?? "0");
const erc721Address = contractAddressesEvmMain()
.chain31337["Erc721DevModule#Erc721Dev"] as `0x${string}`;
const account = mnemonicToAccount(HARDHAT_MNEMONIC, { addressIndex: accountIndex });
const wallet = createWalletClient({ account, chain: hardhat, transport: http(RPC) });
const publicClient = createPublicClient({ chain: hardhat, transport: http(RPC) });
const abi = parseAbi(["function mint(address _to, uint256 _tokenId)"]);
console.log(`minting ${count} card(s) to account #${accountIndex} (${account.address})`);
for (let i = 0; i < count; i++) {
// Unique-enough token id; the card's STATS do not come from this id —
// they are rolled by the state machine when the event is synced.
const tokenId = BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000));
const hash = await wallet.writeContract({
address: erc721Address,
abi,
functionName: "mint",
args: [account.address, tokenId],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log(` card #${tokenId} minted in block ${receipt.blockNumber} (${receipt.status})`);
}
console.log(`\nwatch it appear: curl -s localhost:9999/api/cards | head`);
6Restart + mint
bunx orchestrator restart sync
cd packages/node
bun scripts/mint-card.ts 3 # three cards for account #0
bun scripts/mint-card.ts 2 1 # two for account #1 (your rival)
curl -s localhost:9999/api/cards
[{"token_id":"1783468482899260","owner":"0x7099…79c8","element":"fire","power":1,"name":"Mirygo","block_height":1},
{"token_id":"1783468482797742","owner":"0xf39f…2266","element":"fire","power":9,"name":"Bavol","block_height":1},
{"token_id":"1783468482789653","owner":"0xf39f…2266","element":"grass","power":9,"name":"Zuluxdra","block_height":1}, …]
And in bunx orchestrator logs sync:
[game] minted card #1783468482789653: Zuluxdra (grass 9) -> 0xf39fd6…Your names and stats will differ — that's the point. But run two nodes against the same chain and they'd agree exactly.