I wanted a game where a holder's AI agent plays overnight and the result is something nobody has to take on faith. That sounds like a slogan until you try to build it. Two things have to be true at once: nobody can peek at your orders before the night is played, and anyone can replay the night later and get the identical result hash. Both turned out to hinge on one arithmetic detail that has to agree in two languages.
Live: semalam.edycu.dev · Code (MIT): github.com/edycutjong/semalam · Demo (2:24): youtu.be/08FeD_edXz0
The game in thirty seconds
SEMALAM ("overnight") is built for the World Is Flat hackathon. You write up to 280 characters of orders. An LLM compiles them once into a small typed policy (at most 6 fields and 3 rules) and the app reads it back: "Your agent will...". You confirm, the policy is sealed in your browser and committed on chain. At 00:00 WIB the commits close and plain deterministic code, no LLM, plays every agent for 300 turns. The result hash goes on chain. At breakfast you replay your night.
Wall 1: sealing without a server that can cheat
If my server held the orders until midnight, you would have to trust me not to read them. So orders are sealed with a drand timelock (tlock): encrypted to a future beacon round that nobody can decrypt early, me included.
The catch is which round. If the settler picks it, the settler can choose its own randomness. So the contract fixes it, and the browser and the settler recompute it with the same integer formula. Here is the Solidity side:
function seedRound(uint256 round) public view returns (uint64) { uint256 t = closeTime(round) + SEED_DELAY; return uint64((t - DRAND_GENESIS + DRAND_PERIOD - 1) / DRAND_PERIOD + 1); } and the TypeScript twin used by the browser, the settler and the replay CLI:
/** First quicknet round at or after closeTime + 300 s - same integer formula as Results.seedRound. */ export function seedRoundAt(closeTime: number): number { const t = closeTime + 300; return Math.floor((t - QUICKNET.genesis_time + QUICKNET.period - 1) / QUICKNET.period) + 1; } Ceiling division on integers, in two languages, and a test that they agree. The 300 second buffer exists because L2 clocks drift: I measured a maximum of 1.75 s over a 12-sample run on Robinhood Chain (chain 4663) against that 300 s buffer.
A second trap was malformed blobs. The first design voided a commit if decryption took too long. That is non-deterministic: a slow laptop would void an order a fast one opens, and the two would post different hashes. The fix is structural. The settler reads the age header and voids anything that does not have exactly one tlock recipient stanza, before any decryption. Every machine classifies the same blob the same way.
Wall 2: "same hash on every machine" is a claim you have to test
The simulator has no randomness, clock reads or map-iteration-order dependence. One seed in, one hash out. I did not trust that, so the repo carries a 230-agent golden hash that must be equal in fresh processes, and three golden rounds built from real quicknet beacons and real timelock blobs. I ran them on 9 runtimes (macOS Node 20/22/24, Linux arm64 and amd64 on Node 20/22). CI pins the same hashes on Linux, macOS and Windows. The matrix is committed in evidence/determinism-matrix.json.
The settler adds one more guard: it only posts a result if a fresh-process run of the CLI produces the same hash as the runner. Speed is not the problem: 230 agents for 300 turns take 15.8 ms at the median on an M1 Max, and a full 2,000-account round including decryption takes 355 ms at the median (5 runs, M1 Max; 1,000 of the 2,000 accounts are admitted).
Where the token comes in
Practice runs against the last settled night's top field cost 200 $WIF through a Scrimmage contract. It pulls the tokens, burns 80% (160) with the token's own burn() in the same transaction, and sends 20% to compute. It checks the burn by balance delta, so a fee-on-transfer or no-op burn token is refused. A fork test runs it against the real $WIF contract on a chain 4663 fork: payer -200, total supply -160, compute +40. The first real one on mainnet, a practice run from my own wallet, did exactly that in one transaction: 0x946a488f…c03d. The contracts hold no prize pool.
Honest limits
- The chain 4663 deployment went live on 3 Nov. Nights 1 to 4 are posted and each replays to its on-chain hash, but every one of them was house agents only: 0 players so far (night 1 was a labelled rehearsal with one operator smoke commit). The only paid run so far is my own. Earlier testnet accounts were my own QA runs.
- A Sybil can fill the 1,000-account admission cap per round with own-wallet commits. Stated, not solved.
- In 48 simulated seeds a pure single-action policy (excluding COPY_LEADER) won outright 5 times, and pure STEAL took 10 of the 13 pure top-10 finishes. Real fields are not random, but I am watching this.
- Gas-free onboarding is a relay with a per-night budget, not account abstraction. When it is empty the app asks for your own wallet.
-
npx semalamis not on npm yet; run the CLI from the repo.
Check it yourself
git clone https://github.com/edycutjong/semalam && cd semalam pnpm install && pnpm --filter semalam build node packages/cli/dist/semalam.mjs replay --fixture golden # offline, three golden rounds node packages/cli/dist/semalam.mjs replay 4 --chain 4663 # mainnet night 4 The second command recomputes a night from public data and prints MATCH or a mismatch against the hash in the Results contract (--chain 46630 does the same for the testnet). Mainnet contracts, source-verified on Sourcify: Orders 0xAAA4D7abdC7FE91d2D4827CA559887eF6288e0C2, Scrimmage 0xed6afbC314187C3e0D7EB3ABCb2962e6375C676C, Results 0x313FBC505aac82b1257F4849A28f4461a3e09e25.
If you try it and the replay does not match, that is the most useful bug report I could get.