Midnight seasons
The multi-chain payoff: a counter on Midnight — a privacy-preserving ZK chain — sets the season, and the season changes battle math on the EVM-driven map.
What's already in place
You wired all of this in Step 2 without ceremony — look back at what you pasted:
config.dev.tshas aMidnightContractStateprimitive watching the template's counter contract (written in Compact, Midnight's smart-contract language);state-machine.tshas themidnightContractStatetransition: counter mod 3 → fire / water / grass season;game.tsadds +2 to in-season cards inresolveBattle();- game.html shows the season banner and colors it.
The only missing piece is a way to increment the counter on Midnight.
Because players never write to Midnight in this game — they only feel its effects. The sync node reads Midnight contract state (no wallet needed for reading), and the season transaction below is submitted by a local wallet built from the dev genesis seed, proving through the local proof server — think of it as a game-master action, not a player action.
Browser-wallet Midnight flows (e.g. Lace) absolutely exist — players signing shielded
transactions is the natural next project — but they're a different signing stack than EVM's
personal_sign and out of scope for 40 minutes. Hover the season banner in game.html:
the tooltip says exactly this.
1Midnight SDK dependencies
The season script needs the Midnight wallet + proving SDKs, which the node package doesn't ship
with. Replace packages/node/package.json (adds @effectstream/wallets and
the @midnight-ntwrk/midnight-js-* set), then install:
{
"name": "@evm-midnight/node",
"version": "1.0.0",
"license": "MIT",
"exports": {
".": "./main.ts"
},
"scripts": {
"check": "tsc --noEmit main.dev.ts",
"node:start": "bun --inspect run main.dev.ts",
"node:start:mainnet": "bun --inspect run main.mainnet.ts"
},
"dependencies": {
"@effectstream/concise": "0.101.1",
"@effectstream/config": "0.101.1",
"@effectstream/log": "0.101.1",
"@effectstream/orchestrator": "0.101.1",
"@effectstream/runtime": "0.101.1",
"@effectstream/utils": "0.101.1",
"@effectstream/sm": "0.101.1",
"@effectstream/coroutine": "0.101.1",
"@effectstream/db": "0.101.1",
"@effectstream/explorer": "*",
"@effectstream/midnight-contracts": "0.101.1",
"@effectstream/wallets": "0.101.1",
"@evm-midnight/contracts-evm": "workspace:*",
"@evm-midnight/database": "workspace:*",
"@evm-midnight/midnight-contract": "workspace:*",
"@sinclair/typebox": "0.34.41",
"effection": "^3.5.0",
"fastify": "^5.4.0",
"pg": "^8.14.0",
"viem": "2.37.3",
"@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
"@midnight-ntwrk/compact-js": "2.5.0",
"@midnight-ntwrk/midnight-js-contracts": "4.0.4",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-level-private-state-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-network-id": "4.0.4"
}
}
bun install # from the template root
2The season script
Create packages/node/scripts/increment-season.ts. It builds a Midnight wallet from the
dev genesis seed, attaches to the deployed counter contract, and calls increment() —
generating a real zero-knowledge proof along the way:
// Advance the SEASON by incrementing the counter contract on Midnight.
// This is a real Midnight transaction: it gets proven by the proof server,
// lands on the local Midnight chain, is picked up by the indexer, synced by
// the node, and the `midnightContractState` state transition updates the
// season — which changes battle math on the EVM-driven map.
//
// bun scripts/increment-season.ts
//
// Requires the full dev stack (bun run dev), including the Midnight node,
// indexer, and proof server. First run takes a while (proof generation).
import path from "node:path";
import fs from "node:fs";
const NETWORK_ID = process.env.MIDNIGHT_NETWORK_ID || "undeployed";
const networkUrls = {
indexer: "http://127.0.0.1:8088/api/v3/graphql",
indexerWS: "ws://127.0.0.1:8088/api/v3/graphql/ws",
node: "http://127.0.0.1:9944",
proofServer: "http://127.0.0.1:6300",
};
// The orchestrator's genesis wallet (dev only).
const GENESIS_SEED = "0000000000000000000000000000000000000000000000000000000000000001";
// -- locate the deployed contract address (written by the deploy step) -------
function getContractAddress(): string {
const base = path.resolve(import.meta.dirname!, "../../contracts-midnight");
const candidates = [
path.join(base, `contract-round-value.${NETWORK_ID}.json`),
path.join(base, "contract-round-value.json"),
path.join(base, "contract-round-value", "contract-round-value.json"),
];
for (const c of candidates) {
if (fs.existsSync(c)) {
const json = JSON.parse(fs.readFileSync(c, "utf-8"));
if (json.contractAddress) return json.contractAddress;
}
}
throw new Error("contract-round-value deployment file not found — is the stack running?");
}
const { MidnightLocalConnector } = await import("@effectstream/wallets/midnight-local");
const { syncAndWaitForFunds } = await import("@effectstream/midnight-contracts");
const { CompiledContract } = await import("@midnight-ntwrk/compact-js");
const { findDeployedContract } = await import("@midnight-ntwrk/midnight-js-contracts");
const { indexerPublicDataProvider } = await import("@midnight-ntwrk/midnight-js-indexer-public-data-provider");
const { httpClientProofProvider } = await import("@midnight-ntwrk/midnight-js-http-client-proof-provider");
const { NodeZkConfigProvider } = await import("@midnight-ntwrk/midnight-js-node-zk-config-provider");
const { levelPrivateStateProvider } = await import("@midnight-ntwrk/midnight-js-level-private-state-provider");
const { setNetworkId } = await import("@midnight-ntwrk/midnight-js-network-id");
setNetworkId(NETWORK_ID as any);
const contractAddress = getContractAddress();
console.log("1) building the genesis wallet (facade mode)…");
const provider = await MidnightLocalConnector.instance().connectFromSeed({
seed: GENESIS_SEED,
networkId: NETWORK_ID,
networkUrls,
});
const walletResult = (provider.getConnection().api as any).walletResult;
console.log("2) syncing funds…");
await syncAndWaitForFunds(walletResult.wallet, { timeoutMs: 300_000 });
const walletAndMidnightProvider = {
getCoinPublicKey: () => walletResult.zswapSecretKeys.coinPublicKey,
getEncryptionPublicKey: () => walletResult.zswapSecretKeys.encryptionPublicKey,
async balanceTx(tx: any, ttl?: Date) {
const recipe = await walletResult.wallet.balanceUnboundTransaction(
tx,
{
shieldedSecretKeys: walletResult.walletZswapSecretKeys,
dustSecretKey: walletResult.walletDustSecretKey,
},
{ ttl: ttl ?? new Date(Date.now() + 3600_000) },
);
const signed = await walletResult.wallet.signRecipe(
recipe,
(payload: Uint8Array) => walletResult.unshieldedKeystore.signData(payload),
);
return walletResult.wallet.finalizeRecipe(signed);
},
submitTx: (tx: any) => walletResult.wallet.submitTransaction(tx),
};
const managedDir = path.resolve(
import.meta.dirname!,
"../../contracts-midnight/contract-round-value/src/managed",
);
const zkConfigProvider = new NodeZkConfigProvider(managedDir);
const providers = {
privateStateProvider: levelPrivateStateProvider({
privateStoragePasswordProvider: async () => "EffectstreamStorage1!",
accountId: walletResult.unshieldedAddress || "season-account",
} as any),
publicDataProvider: indexerPublicDataProvider(networkUrls.indexer, networkUrls.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(networkUrls.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};
const { Counter, witnesses } = await import("@evm-midnight/midnight-contract");
const compiledContract = CompiledContract.make("contract-round-value", Counter.Contract).pipe(
CompiledContract.withWitnesses(witnesses as never),
CompiledContract.withCompiledFileAssets(managedDir),
);
console.log(`3) attaching to contract ${contractAddress.slice(0, 16)}…`);
const counterContract = await findDeployedContract(providers, {
contractAddress,
compiledContract: compiledContract as any,
privateStateId: "counterPrivateState",
initialPrivateState: {},
});
console.log("4) calling increment() — proving the transaction (can take ~a minute)…");
const pad = (str: string, length: number) =>
Uint8Array.from(str.padEnd(length, " ").split("").map((c) => c.charCodeAt(0)));
const txResult = await counterContract.callTx.increment(
pad("season", 64), pad("0", 64), pad("season", 32), pad("tick", 32),
);
console.log(` Midnight tx ${txResult.public.txId} in block ${txResult.public.blockHeight}`);
console.log("\nwatch the season change: curl -s localhost:9999/api/season");
process.exit(0);
3Advance the season — script or button
From the terminal:
bun scripts/increment-season.ts
Or from the game itself: the header has an Advance season button. It calls
POST /api/season/increment, and the node runs the exact same script as a subprocess —
look at the bottom of api.ts (Step 2): a 202 immediately, a busy-lock (409 if a proof
is already running), and the script's output streamed into the sync logs as [season-inc].
While it proves, the banner shows proving… and the button is disabled.
Proof generation takes up to a minute — the proof server is doing real cryptography. Then:
curl -s localhost:9999/api/season
{"id":1,"element":"water","counter_value":1,"block_height":…,"advancing":false}
counter_value is the Midnight contract's ledger round — the same number
now shown in the game's season banner. Chain ledger → primitive → Postgres → API → UI, end to end.
POST /api/season/increment is unauthenticated — anyone who can reach the API can flip
the season. Perfect for a workshop, wrong for production: gate it (auth, admin network) or drop it
and keep the script. It's also a nice discussion point — reads are trustless, but this
endpoint is a privileged write, and it shows.
Going further with Midnight
- Open
packages/contracts-midnight/contract-round-value/src/counter.compact— the whole contract is ~25 lines of Compact. Add a field, recompile withbun run build:midnight. - Shielded card ownership (cards whose owner is private until they strike) is a natural next
project — see the
night-bitcoin-v2andzswap-datemplates for heavier Midnight patterns.