C · Building a Game · Step 2

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.

▼ details below

Why not Math.random()?

Math.random() node A: fire 7 node B: water 2 ⚡ consensus broken — every node has a different game data.randomGenerator (block-seeded) node A: grass 9 node B: grass 9 same seed → same roll → same state, yet unpredictable before the block exists

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:

packages/node/game.ts · 126 linesdownload
// 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):

packages/node/grammar.ts · 16 linesdownload
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:

packages/node/state-machine.ts · 174 linesdownload
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):

packages/node/api.ts · 86 linesdownload
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):

packages/node/scripts/mint-card.ts · 43 linesdownload
// 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)
Checkpoint — the money shot
curl -s localhost:9999/api/cards
real output from our test run (~2 s after minting)
[{"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.
← 1 · Card + map tables3 · Mint UI + art →