Read the template in 10 minutes
The whole app is five small files plus one service. Read them in this order and the rest of the workshop is just editing them.
The golden path: one mint, traced
1 · packages/node/grammar.ts — 7 lines
Every input the app understands. The template ships two, both from builtin grammars:
export const grammar = {
"transfer-assets": builtinGrammars.evmErc721, // {to, from, tokenId, isBurn}
"midnightContractState": builtinGrammars.midnightGeneric,
} as const satisfies GrammarDefinition;
2 · packages/node/config.dev.ts — the wiring
Three nested builders, always in the same order:
- networks — an NTP clock (the main timeline), Hardhat, and Midnight;
- sync protocols — NTP as main, the two chains as parallel;
- primitives — an ERC-721 watcher (prefix
transfer-assets) and a Midnight contract-state watcher (prefixmidnightContractState). The prefix is the routing key into the grammar.
3 · packages/node/state-machine.ts — the logic
One stm.addStateTransition(kind, function* (data) {...}) per grammar key.
The data object carries parsedInput (typed by the grammar!), blockHeight,
signerAddress, and randomGenerator — remember that last one.
DB writes go through yield* World.resolve(query, params).
4 · packages/node/api.ts — read-only REST
A Fastify router with direct SQL reads. State changes NEVER come through here — they only come from chains.
5 · packages/database/ — migrations + typed queries
migrations/*.sql create tables (applied by the sync node at startup);
sql/*.sql holds named queries that bun run build:pgtypes compiles into
typed TypeScript (*.queries.ts) — those are the objects the STM resolves.
6 · packages/batcher/ — the gasless mailbox
Everything above is reading chains. The batcher is how users write without
paying gas: it's a small HTTP service (:3334) that accepts signed inputs at
POST /send-input, verifies each signature, collects inputs for a time window
(1 s in dev), and submits the whole batch as one transaction to the
EffectstreamL2 contract — the batcher's wallet pays, and the sync node re-verifies every
inner signature when the batch arrives, so the batcher can't forge anyone's moves.
Two things to spot in batcher.dev.ts:
- adapters — where batches can land. This template has two:
paimaL2(the EVM contract; the default target — our game moves go here) andmidnight(balances/submits Midnight transactions). - namespace — the security string users sign into every message. It must match
the node's
setSecurityNamespace(), or inputs get silently dropped — we'll set it in Step 4.
What we'll touch in Section C
| File | Step | Change |
|---|---|---|
packages/database/migrations/001-game.sql | 1 | new: cards, map, battles, season tables |
packages/database/sql/game.sql | 1 | new: 15 typed queries |
packages/node/game.ts | 2 | new: rules constants, stats, battle math |
packages/node/{grammar,state-machine,api}.ts | 2 | replace: game logic |
game.html | 3 | new: the whole UI, one file |
packages/node/config.dev.ts + batcher | 4 | add: the L2 primitive that makes moves flow |
transfer-assets, the transition that writes to evm_midnight,
and the batcher's two adapter targets in batcher.dev.ts.
If you can point at all four, you know the codebase.