C · Building a Game · Step 4

The shared map & battles

Placing a card is gasless: players sign a message, the batcher lands it on-chain, and your state machine judges the battle. Two small config changes make the whole pipeline flow.

▼ details below

How a move travels

player signspersonal_sign, free batcher :3334verifies + batches EffectstreamL2one tx, many inputs Game_L2primitive + grammar "place" STMvalidate + battle
the batcher pays the gas; the player only signs

1Add the L2 primitive

The batcher already lands inputs on the EffectstreamL2 contract — but nothing is listening. Replace packages/node/config.dev.ts; the only change from the template is the imports and the third primitive at the bottom:

packages/node/config.dev.ts · 151 linesdownload
import { contractAddressesEvmMain } from "@evm-midnight/contracts-evm";
import { readMidnightContract } from "@effectstream/midnight-contracts/read-contract";
import { midnightNetworkConfig } from "@effectstream/midnight-contracts/midnight-env";
import * as CounterContract from "@evm-midnight/midnight-contract/contract";

import {
  ConfigBuilder,
  ConfigNetworkType,
  ConfigSyncProtocolType,
} from "@effectstream/config";
import { hardhat } from "viem/chains";
import { getConnection } from "@effectstream/db";
import {
  PrimitiveTypeEVMERC721,
  PrimitiveTypeMidnightGeneric,
  PrimitiveTypeEVMEffectstreamL2,
} from "@effectstream/sm/builtin";
import { grammar } from "./grammar.ts";

const mainSyncProtocolName = "mainNtp";
let launchStartTime: number | undefined;
const dbConn = getConnection();
try {
  const result = await dbConn.query(`
    SELECT * FROM effectstream.sync_protocol_pagination
    WHERE protocol_name = '${mainSyncProtocolName}'
    ORDER BY page_number ASC
    LIMIT 1
  `);
  if (!result || !result.rows.length) {
    throw new Error("DB is empty");
  }
  launchStartTime = result.rows[0].page.root -
    (result.rows[0].page_number * 1000);
} catch {
  // DB not initialized yet
}

export const config = new ConfigBuilder()
  .setNamespace(
    (builder) => builder.setSecurityNamespace("evm-midnight-node"),
  )
  .buildNetworks((builder) =>
    builder
      .addNetwork({
        name: "ntp",
        type: ConfigNetworkType.NTP,
        startTime: launchStartTime ?? new Date().getTime(),
        blockTimeMS: 1000,
      })
      .addViemNetwork({
        ...hardhat,
        name: "evmMain",
      })
      .addNetwork({
        name: "midnight",
        type: ConfigNetworkType.MIDNIGHT,
        networkId: midnightNetworkConfig.id,
        nodeUrl: midnightNetworkConfig.node,
      })
  )
  .buildDeployments((builder) =>
    builder
      .addDeployment(
        (networks) => networks.evmMain,
        (_network) => ({
          name: "Erc721DevModule#Erc721Dev",
          address: contractAddressesEvmMain()
            .chain31337["Erc721DevModule#Erc721Dev"],
        }),
      )
  )
  .buildSyncProtocols((builder) =>
    builder
      .addMain(
        (networks) => networks.ntp,
        (network, deployments) => ({
          name: mainSyncProtocolName,
          type: ConfigSyncProtocolType.NTP_MAIN,
          chainUri: "",
          startBlockHeight: 1,
          pollingInterval: 1000,
        }),
      )
      .addParallel(
        (networks) => networks.evmMain,
        (network, deployments) => ({
          name: "mainEvmRPC",
          type: ConfigSyncProtocolType.EVM_RPC_PARALLEL,
          chainUri: network.rpcUrls.default.http[0],
          startBlockHeight: 1,
          pollingInterval: 500,
          confirmationDepth: 1,
        }),
      )
      .addParallel(
        (networks) => networks.midnight,
        (network, deployments) => ({
          name: "parallelMidnight",
          type: ConfigSyncProtocolType.MIDNIGHT_PARALLEL,
          startBlockHeight: 1,
          pollingInterval: 1000,
          indexer: midnightNetworkConfig.indexer,
          indexerWs: midnightNetworkConfig.indexerWS,
        }),
      )
  )
  .buildPrimitives((builder) =>
    builder
      .addPrimitive(
        (syncProtocols) => syncProtocols.mainEvmRPC,
        (network, deployments, syncProtocol) => ({
          name: "Arbitrum_ERC721",
          type: PrimitiveTypeEVMERC721,
          startBlockHeight: 0,
          contractAddress: contractAddressesEvmMain()
            .chain31337["Erc721DevModule#Erc721Dev"],
          stateMachinePrefix: "transfer-assets",
        }),
      )
      .addPrimitive(
        (syncProtocols) => syncProtocols.parallelMidnight,
        (network, deployments, syncProtocol) => ({
          name: "MidnightContractState",
          type: PrimitiveTypeMidnightGeneric,
          startBlockHeight: 1,
          contractAddress: readMidnightContract(
            "contract-round-value",
            { networkId: midnightNetworkConfig.id },
          ).contractAddress,
          stateMachinePrefix: "midnightContractState",
          contract: { ledger: CounterContract.ledger },
          networkId: midnightNetworkConfig.id,
        }),
      )
      // Player actions land on the EffectstreamL2 contract (via the batcher).
      // This primitive parses them with OUR grammar and routes each input to
      // its state transition ("place", ...).
      .addPrimitive(
        (syncProtocols) => syncProtocols.mainEvmRPC,
        (network, deployments, syncProtocol) => ({
          name: "Game_L2",
          type: PrimitiveTypeEVMEffectstreamL2,
          startBlockHeight: 0,
          contractAddress: contractAddressesEvmMain()
            .chain31337["PaimaL2ContractModule#MyPaimaL2Contract"],
          paimaL2Grammar: grammar,
        }),
      )
  )
  .build();

paimaL2Grammar: grammar is the key line: inputs from this contract are parsed with your grammar, so ["place", x, y, tokenId] routes straight to your place transition, with signerAddress verified for you.

2Align the batcher's namespace

Signatures include a security namespace, and the batcher and node must agree on it. In packages/batcher/batcher.dev.ts find namespace: "" and change it to:

  namespace: "evm-midnight-node",   // must match setSecurityNamespace() in config.dev.ts
The #1 silent failure
Mismatched namespaces = batcher says success:true, node drops the input during signature re-verification, nothing appears, no error anywhere. If moves vanish, check this first.

3A place script

Create packages/node/scripts/place-card.ts. Read the four numbered comments — this is the entire batcher protocol, no SDK required:

packages/node/scripts/place-card.ts · 56 linesdownload
// Place a card on the shared map — a GASLESS action sent through the batcher.
//
//   bun scripts/place-card.ts <x> <y> <tokenId> [accountIndex]
//
// The batcher verifies the signature, batches the input, and lands it on the
// EffectstreamL2 contract; the sync node then runs the "place" state transition.

import { mnemonicToAccount } from "viem/accounts";

const HARDHAT_MNEMONIC = "test test test test test test test test test test test junk";
const BATCHER_URL = process.env.BATCHER_URL ?? "http://127.0.0.1:3334";

// Must match `setSecurityNamespace(...)` in packages/node/config.dev.ts
// AND `namespace` in packages/batcher/batcher.dev.ts.
const SECURITY_NAMESPACE = "evm-midnight-node";

const [x, y, tokenId, accountIndexRaw] = process.argv.slice(2);
if (x === undefined || y === undefined || !tokenId) {
  console.error("usage: bun scripts/place-card.ts <x> <y> <tokenId> [accountIndex]");
  process.exit(1);
}
const accountIndex = Number(accountIndexRaw ?? "0");
const account = mnemonicToAccount(HARDHAT_MNEMONIC, { addressIndex: accountIndex });

// 1. The input, in "concise" form: [grammarKey, ...fields] — matches grammar.ts
const input = JSON.stringify(["place", Number(x), Number(y), tokenId]);

// 2. The message the batcher expects: namespace + target + timestamp + address
//    + input, with every non-alphanumeric character replaced by "-", lowercased.
const timestamp = Date.now().toString();
const message = (SECURITY_NAMESPACE + timestamp + account.address + input)
  .replace(/[^a-zA-Z0-9]/g, "-")
  .toLocaleLowerCase();

// 3. A normal personal_sign signature — same thing MetaMask produces.
const signature = await account.signMessage({ message });

// 4. POST it to the batcher.
const response = await fetch(`${BATCHER_URL}/send-input`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    data: {
      addressType: 0, // AddressType.EVM
      address: account.address,
      signature,
      input,
      timestamp,
    },
    confirmationLevel: "no-wait",
  }),
});

console.log(`place [${x},${y}] card #${tokenId} as account #${accountIndex} (${account.address})`);
console.log(`batcher says: ${response.status} ${JSON.stringify(await response.json())}`);
console.log(`\nwatch the map:  curl -s localhost:9999/api/map`);

4Restart + play a battle by hand

bunx orchestrator restart batcher
bunx orchestrator restart sync

cd packages/node
curl -s localhost:9999/api/cards | head -c 400    # pick two token ids

bun scripts/place-card.ts 3 3 <P0_TOKEN>          # account #0 claims (3,3)
bun scripts/place-card.ts 3 3 <P1_TOKEN> 1        # account #1 attacks it!
real battle from our test run (sync logs)
[game] Drabazu placed at (3,3) by 0xf39fd6…
[game] battle at (3,3): Luxkata 8 vs Drabazu 9 -> defender holds

That's the worked example from the rules page, happening for real: Luxkata (water 4) attacked Drabazu (fire 9). Water beats fire → 4 × 1.5 = 6, plus an adjacent ally worth 2 → 8. The defender stood alone at 9. Defender holds.

Check /api/cards afterwards: both fighters are now 1 power weaker — battle fatigue (the decrementCardPower call at the end of the place transition). Drabazu held the cell, but at power 8 it won't hold forever.

Checkpoint
curl -s localhost:9999/api/map
curl -s localhost:9999/api/battles
The map shows occupied cells with card stats; the battle log shows attacker/defender totals and who won. Your signed message became a judged, permanent, replayable game event — and the player paid nothing.
← 3 · Mint UI + art5 · Play →