Skip to main content

World Map 2D

Template: templates/world-map-2d

Players join a shared grid, walk around it, and bump a per-cell visit counter. Every action is an on-chain input to an Effectstream L2 contract on a local Hardhat chain; the sync node replays those inputs deterministically into two Postgres tables, and a plain HTML page renders the grid from the node's REST API.

It is the smallest complete example of spatial state — position that must stay inside a world, and world cells that accumulate history. Read it if you are building tile movement, territory, location-gated actions, or anything else where "where is this player" is part of consensus state.

Gameplay

What this template shows

The map is data, not code. The 10×10 world is not a constant in the state machine; it is 100 rows inserted by the migration in packages/database/src/migrations/database.sql, and player positions point at those rows through a composite foreign key:

CREATE TABLE global_world_state (
x INTEGER NOT NULL,
y INTEGER NOT NULL,
can_visit BOOLEAN NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (x, y)
);

CREATE TABLE global_user_state (
wallet TEXT NOT NULL PRIMARY KEY,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
FOREIGN KEY (x, y) REFERENCES global_world_state (x, y)
);

So validation is declarative, and the transitions are one line each. Coordinate bounds are enforced by TypeBox in packages/node/grammar.ts (Type.Number({ minimum: 0, maximum: 9 })), so an out-of-range input never reaches a transition. Cell existence is enforced by the foreign key. What is left in packages/node/state-machine.ts is a single World.resolve per action — no guards, no lookups, no branching:

stm.addStateTransition("submitMove", function* (data) {
const { signerAddress: wallet, parsedInput } = data;
const { x, y } = parsedInput;
yield* World.resolve(updateUserGlobalPosition, { wallet, x, y });
});

Reshaping the world — a bigger grid, holes, cells flagged can_visit = FALSE — is a migration edit plus a grammar bound. The state machine does not change.

And it shows where that stops. The adjacency rule players actually feel lives in the browser: inrange() in packages/frontend/index.html only draws move / +1 buttons on orthogonally neighbouring cells. Nothing on chain enforces it — the grammar accepts any in-bounds (x, y), so a hand-crafted transaction can teleport. That is the honest lesson of the template: rules you want enforced belong in the grammar, the schema, or the transition; a rule in the UI is only an affordance.

Effectstream features used

FeatureWhereUsed for
TypeBox grammar (@effectstream/concise)packages/node/grammar.tsDeclares joinWorld / submitMove / submitIncrement and bounds both coordinates to 0..9.
@effectstream/sm Stm state machinepackages/node/state-machine.tsRoutes each parsed input to a generator transition.
@effectstream/coroutine World.resolvepackages/node/state-machine.tsQueues the pgtyped query + params as the transition's only effect.
EVM sync via PrimitiveTypeEVMEffectstreamL2packages/node/config.dev.tsTurns effectstreamSubmitGameInput calls on MyEffectstreamL2 into grammar inputs.
NTP main sync protocolpackages/node/config.dev.tsConfigSyncProtocolType.NTP_MAIN with blockTimeMS: 1000 drives the block clock; the EVM RPC protocol is attached as a parallel chain.
Effectstream L2 contract (@effectstream/evm-contracts)packages/contracts-evm/src/contracts/MyEffectstreamL2.solThe on-chain mailbox; deployed with Hardhat Ignition.
DB migrations (migrationTable)packages/database/src/migration-order.tsApplies database.sql, which creates both tables and seeds all 100 world cells.
pgtyped typed queriespackages/database/src/sql/.sql files compiled to *.queries.ts; the node imports the generated query objects by name.
runPreparedQuery (@effectstream/db)packages/node/api.tsSerializes reads behind the PGLite mutex (a no-op against a real Postgres).
Custom Fastify API router (StartConfigApiRouter)packages/node/api.tsAdds GET /user_stats and GET /world_stats to the node's HTTP server.
@effectstream/walletspackages/frontend/index.jswalletLogin in both EvmInjected (MetaMask) and EvmViem (local private key) modes, plus sendTransaction.
Orchestrator (@effectstream/orchestrator)start.dev.tsBoots PGLite, Hardhat + contract deploy, the sync node, and the frontend in dependency order.

Quick start

Prerequisites:

  • Bun — the runtime for everything here.
  • Node.js — Hardhat and the frontend's esbuild.js run under Node ("build": "node esbuild.js").
  • Foundryforge must be on your PATH; launchEvm checks for it up front and refuses to start without it.
  • Chrome or Chromium — only for the browser tests (CHROME_PATH overrides detection).
bun install
bun run dev

bun run dev runs bunx orchestrator start, which picks up start.dev.ts from the effectstream.default field in package.json and brings up the whole stack — including the frontend build and server, so no second terminal is needed.

ServiceURL
Frontendhttp://localhost:10599
Sync node APIhttp://localhost:9999
Hardhat JSON-RPChttp://localhost:8545 (WebSocket on 8546)
PGLite (Postgres wire protocol)postgres://postgres:postgres@localhost:5432/postgres
Orchestrator control APIhttp://localhost:4747

On the page, Connect Local Wallet (dev) signs with Hardhat account #0 through WalletMode.EvmViem — no browser extension required, which is also what makes the headless end-to-end test possible. Connect Browser Wallet uses WalletMode.EvmInjected; for that, point MetaMask at http://localhost:8545 (chain ID 31337) and import a Hardhat key:

0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

⚠️ That key is baked into Hardhat and publicly known. Local development only — never send real funds to it or reuse it on a live network.

The deployed contract charges fee: 0 (see packages/contracts-evm/deploy.ts), so submitting an input costs gas only.

Docker

The Dockerfile builds the entire stack — Bun, Node, Foundry, a pre-cached solc 0.8.30, the compiled contracts — into one image, whose CMD is bunx orchestrator start --config start.dev.ts: the same stack bun run dev gives you. It is architecture-aware (it selects the arm64 or amd64 Foundry build), so it runs natively on Apple Silicon.

docker build -f Dockerfile . -t world-map-2d
docker run -p 9999:9999 -p 10599:10599 world-map-2d

The image declares EXPOSE 8545 8546 9999 10599; publish 8545 as well if you want to attach a browser wallet to the container's chain. To run the test suite inside the image:

docker run world-map-2d bun run test

Docker

Project structure

world-map-2d/
├── start.dev.ts # Orchestrator config: PGLite + Hardhat + sync + frontend
├── Dockerfile # Single-container build of the whole stack
├── link.sh # Symlink local @effectstream/* packages (monorepo dev)
└── packages/
├── contracts-evm/ # MyEffectstreamL2 + Hardhat/Foundry/Ignition setup
│ ├── src/contracts/MyEffectstreamL2.sol
│ ├── ignition/modules/effectstreamL2.ts
│ ├── deploy.ts # Ignition deployment (owner + fee parameters)
│ ├── hardhat.config.ts
│ └── foundry.toml
├── database/ # Schema + pgtyped queries
│ ├── src/migrations/database.sql # Two tables and the 100 seeded world cells
│ ├── src/sql/{select,insert,update}.sql
│ ├── src/migration-order.ts # Exports migrationTable
│ └── src/mod.ts
├── node/ # Sync node
│ ├── main.dev.ts # Entry point: init + start
│ ├── config.dev.ts # Networks, sync protocols, EVM primitive
│ ├── grammar.ts # Input grammar
│ ├── state-machine.ts # Three transitions
│ └── api.ts # /user_stats and /world_stats
├── frontend/ # Vanilla HTML/JS client
│ ├── index.js # Wallet + API bindings exposed as window.worldMap2D
│ ├── index.html # Grid rendering and click handlers
│ ├── style.css
│ ├── esbuild.js # Bundles index.js -> dist/min.js, copies html + css
│ └── server.ts # Fastify static server on port 10599
└── tests/ # Three-phase integration suite
├── run-tests.ts # Runner: boots the stack, then phases A/B/C
├── start.test.ts # Orchestrator config used by the tests
├── infra/{chain-ready,deploy}.test.ts
├── stm/{actions,api}.test.ts
└── frontend/{build-smoke,render,interactions,e2e}.test.ts

How it works

Grammar

Three commands, with the world's dimensions expressed as TypeBox bounds (packages/node/grammar.ts):

export const grammar = {
joinWorld: [],
submitMove: [
["x", Type.Number({ minimum: 0, maximum: 9 })],
["y", Type.Number({ minimum: 0, maximum: 9 })],
],
submitIncrement: [
["x", Type.Number({ minimum: 0, maximum: 9 })],
["y", Type.Number({ minimum: 0, maximum: 9 })],
],
} as const satisfies GrammarDefinition;

On the wire an input is a JSON array — ["joinWorld"], ["submitMove", 3, 4], ["submitIncrement", 5, 5] — hex-encoded into the contract call.

State machine

packages/node/state-machine.ts is the whole of the game logic:

const stm = new Stm<typeof grammar, {}>(grammar);

stm.addStateTransition("joinWorld", function* (data) {
const { signerAddress: wallet } = data;
yield* World.resolve(createGlobalUserState, { wallet, x: 0, y: 0 });
});

stm.addStateTransition("submitMove", function* (data) {
const { signerAddress: wallet, parsedInput } = data;
const { x, y } = parsedInput;
yield* World.resolve(updateUserGlobalPosition, { wallet, x, y });
});

stm.addStateTransition("submitIncrement", function* (data) {
const { parsedInput } = data;
const { x, y } = parsedInput;
yield* World.resolve(updateWorldStateCounter, { x, y });
});

World.resolve takes a pgtyped query object and its parameters, so a transition describes an update rather than executing one — that is what keeps replay deterministic. joinWorld uses ON CONFLICT (wallet) DO NOTHING, so re-joining is idempotent, and submitIncrement needs no signer at all: bumping a cell's counter is world state, not player state.

main.dev.ts wires these together with the config, the migrations, and the API router:

yield* start({
appName: "world-map-2d",
appVersion: "1.0.0",
syncInfo: toSyncProtocolWithNetwork(config),
gameStateTransitions,
migrations: migrationTable,
apiRouter,
grammar,
});

Contracts

packages/contracts-evm/src/contracts/MyEffectstreamL2.sol is the stock L2 mailbox with nothing added:

contract MyEffectstreamL2 is EffectstreamL2Contract {
constructor(address _owner, uint256 _fee) EffectstreamL2Contract(_owner, _fee) {}
}

Hardhat Ignition deploys it as EffectstreamL2Module#MyEffectstreamL2, and config.dev.ts reads the address back out of the generated bindings rather than hardcoding it:

contractAddress:
contractAddressesEvmMain()
.chain31337["EffectstreamL2Module#MyEffectstreamL2"],
paimaL2Grammar: grammar,

The primitive is PrimitiveTypeEVMEffectstreamL2 on the mainEvmRPC sync protocol (EVM_RPC_PARALLEL, 500 ms polling, confirmation depth 1), running in parallel with an NTP_MAIN clock at one block per second.

API

packages/node/api.ts registers two read endpoints on the node's Fastify server:

server.get("/user_stats", async (request, reply) => {
const { wallet } = request.query as { wallet: string };
if (!wallet) {
return reply.code(400).send({ error: "wallet parameter required" });
}
const [userStats] = await runPreparedQuery(
getUserStats.run({ wallet }, dbConn),
"getUserStats"
);
return reply.send(userStats || null);
});
curl "http://localhost:9999/user_stats?wallet=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"
curl "http://localhost:9999/world_stats"

/user_stats returns the global_user_state row (wallet, x, y) or null for a wallet that has not joined. Wallet addresses are stored lowercase, so query with a lowercased address. /world_stats returns every can_visit = TRUE cell — all 100 of them — each with x, y, counter and can_visit. Both reads go through runPreparedQuery, which takes the PGLite mutex while a query is in flight; against a real Postgres it passes straight through.

Database

Queries live as annotated SQL and are compiled by pgtyped into *.queries.ts, which packages/database/src/mod.ts re-exports. The three the node uses:

/* @name getAllWorldStats */
SELECT * FROM global_world_state
WHERE can_visit = TRUE
;

/* @name updateWorldStateCounter */
UPDATE global_world_state
SET counter = counter + 1
WHERE can_visit = TRUE
AND x = :x!
AND y = :y!
;

Note that the increment query re-checks can_visit, so flipping a cell off in the data disables it without touching TypeScript. Never hand-edit the generated *.queries.ts; after changing any .sql file, regenerate:

bun run build:pgtypes

(That runs pgtyped:update in packages/database, which reads the connection settings from packages/database/pgtypedconfig.jsonpostgres:postgres@localhost:5432, i.e. the running dev database.)

Frontend

packages/frontend/index.js is the only module the page loads; it exposes wallet and fetch helpers on window.worldMap2D, and index.html does the rendering. Actions are submitted with sendTransaction at the "wait-receipt" confirmation level:

async function submitMove(x, y) {
if (!wallet) throw new Error("Connect a wallet first");
return await sendTransaction(
wallet,
["submitMove", x, y],
effectstreamConfig,
"wait-receipt",
);
}

The source comment explains the choice: "wait-receipt" confirms the chain has the transaction, while indexing and the database write land a beat later — the page re-renders on a timer rather than subscribing to the MQTT stream. esbuild.js bundles this to dist/min.js and copies index.html and style.css alongside it; server.ts serves dist/ with @fastify/static on port 10599. (Both are run for you by the frontend-build and frontend-server orchestrator processes.) The bundler stubs out @lucid-evolution/* and @midnight-ntwrk/* — optional peer dependencies of @effectstream/wallets that this EVM-only template never reaches.

Configuration

The template targets the local Hardhat chain and has no .env. Networks are declared inline in packages/node/config.dev.ts via addViemNetwork({ ...hardhat, name: "evmMain" }), and the security namespace is world-map-2d-node.

VariableDefaultEffect
NODE_ENVset to development by bun run devDevelopment mode for the orchestrator and node.
PGLITEtrue (set by start.dev.ts for the sync process)Use embedded PGLite; false makes the orchestrator wait for an external Postgres instead of starting one.
DB_PORT5432Port of that external Postgres, and the port the test runner connects to.
EFFECTSTREAM_API_PORT9999Node HTTP API port.
ENABLE_DEV_AND_DEBUG_ENDPOINTSunset (true under packages/tests/start.test.ts)Exposes the node's debug endpoints.
CHROME_PATHautodetectedBrowser binary for the Playwright-driven tests.

To point the node at a real EVM network, replace the hardhat chain in config.dev.ts with the target chain and set startBlockHeight on the mainEvmRPC protocol and the primitive; then update the contract address in packages/frontend/index.js, which is currently pinned to the deterministic local Ignition address 0x5FbDB2315678afecb367f032d93F642f64180aa3.

Other root scripts:

bun run build:evm # compile contracts and regenerate the TypeScript bindings
bun run build:pgtypes # regenerate pgtyped query types
bun run check # typecheck the node package

If you are working inside the Effectstream monorepo and want your local @effectstream/* sources instead of the published 0.102.0 packages, run ./link.sh in place of bun install.

Testing

bun run test

packages/tests/run-tests.ts starts its own orchestrator from packages/tests/start.test.ts, waits on the control API, and runs three phases:

  • Phase A — infrastructure: the chain answers eth_chainId with 31337 on port 8545, and MyEffectstreamL2 deploys to a well-formed address.
  • Phase B — state machine, database, API: submits ["joinWorld"], ["submitMove", 3, 4] and ["submitIncrement", 5, 5] straight to effectstreamSubmitGameInput with viem (there is no batcher here), then polls Postgres until global_user_state shows the wallet at (0,0) and then (3,4), and global_world_state at (5,5) has a higher counter than before. It then checks both REST endpoints report the same values. The increment assertion is written as a delta, not an absolute, so repeated runs against a warm database stay green.
  • Phase C — frontend: the esbuild bundle builds cleanly, the served page renders, its interactions work, and a headless Chromium drives the full flow — connect the local EvmViem wallet, render the grid at the current position, click a move button, watch the cell repaint.

The runner tears the stack down in a finally block and exits non-zero if any assertion failed.

Where to go next

  • Grammar — how input schemas are declared and parsed, and what else you can express in a bound.
  • State machine — the transition model this template keeps deliberately thin.
  • Database — migrations and the pgtyped workflow behind build:pgtypes.
  • Effectstream L2 contract — what effectstreamSubmitGameInput does on chain.
  • hex-battle — the next step up for grid games: hex coordinates, lobbies, simultaneous hidden moves, and rules in a shared engine package rather than in the schema.
  • minimal — the same EVM stack with a single action, if this one has more moving parts than you want.