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.
How a move travels
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:
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
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:
// 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!
[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.
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.