C · Building a Game · Bonus

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.

▼ details below

What's already in place

You wired all of this in Step 2 without ceremony — look back at what you pasted:

The only missing piece is a way to increment the counter on Midnight.

"Why is there no Midnight connect-wallet button?"

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.

increment()ZK proof generated Midnight chaintx lands indexer :8088state extracted sync nodeseason updated EVM map shifts+2 to in-season cards
one Midnight transaction rebalances every battle on the EVM map

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:

packages/node/package.json · 43 linesdownload
{
  "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:

packages/node/scripts/increment-season.ts · 126 linesdownload
// 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
expected
{"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.

Game-master endpoint = dev convenience
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.
Checkpoint
Click Advance season: the banner shows proving…, then flips color with a toast, and the counter ticks up — a battle that was a loss before can now be a win (+2 to every in-season card). A shielded-by-default chain just changed the weather on a public one — that's the sentence to remember from this workshop.

Going further with Midnight

← 5 · Play7 · Production deploy →