DRats: a daily race that resolves entirely in a canister. Architecture, seed handling, and an agent-playable game surface

Hi all. Sharing what we’ve been building, how it works under the hood, and where it’s going next. Feedback on the design is very welcome, especially on the seed handling.

TL;DR

  • A once-a-day, ten-turn race. Every player gets the same track. The game is three canister methods.
  • The day’s seed comes from raw_rand, stays private while the day is live, and is revealed afterwards so any run can be re-simulated.
  • Because the whole game is a small typed interface, an agent can play it under exactly the same rules as a human. We’re wiring that through the ICP MCP server now.

The game

One run per principal per day, resetting at 00:00 UTC.

A run is ten segments. You start with 100 stamina, and on each segment you choose one of three moves: sprint, jog or rest. Each segment has one of four terrains, and terrain modifies the move:

Move Open pipe Grate Floodwater Narrows
Sprint 18 dist, costs 24, 10% slip 12 dist, costs 24, 45% slip 6 dist, costs 24 8 dist, costs 24, 25% slip
Jog 7 dist, costs 9 7 dist, costs 9, 10% slip 4 dist, costs 9 11 dist, costs 9
Rest 2 dist, restores 20 2 dist, restores 20 1 dist, restores 26 2 dist, restores 20

Attempting a move without enough stamina gives a forced breather (+1 distance, +10 stamina). A slip yields zero distance and drains the move’s cost plus 5.

You can see the segment you’re on and one ahead. Everything beyond is hidden.

Before writing any canister code we simulated the mechanic across 30 seeds to check that decisions actually matter:

Strategy Mean distance
Sprint every turn 50.5
Sprint when stamina allows 64.1
Jog every turn 68.9
Terrain-aware 77.8
Perfect information (exhaustive search) 94.4

Greedy sprinting is the worst strategy, terrain-aware play beats a safe baseline, and there’s a real gap between good and perfect play. That gap is the property a leaderboard needs.

Architecture

Two canisters.

race is a Motoko persistent actor. It is authoritative for every move, and the frontend never decides an outcome.

frontend is an asset canister. / serves the landing page and /game/ serves the app. Both routes are emitted as real files, so there’s no SPA fallback or rewrite configuration. All JS dependencies are bundled with esbuild and served from the canister, so nothing is loaded from a CDN at runtime. The game bundle is ~213 KB gzipped.

Public interface

startRun      : (opt nat32) -> (variant { ok : Run; err : text })
makeMove      : (Move) -> (variant { ok : record { Run; TurnResult }; err : text })
visibleTrack  : () -> (record { turn : nat; segments : vec opt Terrain }) query
myRun         : () -> (opt Run) query
currentDay    : () -> (nat32) query
todayLeaderboard : (nat) -> (vec Entry) query
leaderboard   : (nat32, nat) -> (vec Entry) query
revealedSeed  : (nat32) -> (opt nat32) query
revealedTrack : (nat32) -> (opt Track) query
verify        : (nat32, vec Move) -> (opt nat32) query

Seed handling

This is the part we’d most like scrutiny on.

There’s a trilemma here. For a daily competitive game you’d like three properties, and you can only fully have two:

  1. Identical luck for everyone: same track, same slip outcomes
  2. Unknowable at play time: nobody can precompute the optimal line
  3. Publicly verifiable: anyone can re-derive any result

An early version derived the seed from the day number and pre-drew every slip roll from it. That gave 1 and 3, but lost 2 completely: the seed was public, the PRNG was public, and the optimal line for any day could be brute-forced in milliseconds (3¹⁰ paths). A leaderboard would have been a list of whoever ran a solver.

The current design:

  • The seed is drawn from raw_rand on the first startRun of the day.
  • It is stored and never readable while the day is live.
  • visibleTrack returns terrain only for the caller’s current segment and the next one, as vec opt Terrain. It never returns rolls.
  • Once the day ends, revealedSeed, revealedTrack and verify open up, and any run can be re-simulated from (seed, moves).

That gets all three, with verification moved to after the day closes.

We considered a hash commitment and decided against it: ensureSeed writes a day’s seed exactly once, and no code path can overwrite it. The code is the commitment, and the module hash can be checked against source. ensureSeed also re-checks after its await on raw_rand, since a concurrent call could have written the seed during the await.

Known leak: someone who ran early can describe the track to others before it’s revealed. That’s the same hole every daily puzzle has, and describing a track is a lot weaker than solving it.

Trust assumption: because the code is the guarantee, a controller able to upgrade the canister could change the rules. We’d like to address that properly before the game carries any value, likely by moving control to an SNS or making the race canister immutable. Opinions welcome.

Determinism and the PRNG

Resolution uses mulberry32, implemented in Motoko on Nat32 with wrapping operators (+%, *%). The same function runs in JS on the landing page for revealed days. We compiled the Motoko with moc, ran it in the interpreter, and diffed its output stream against the JS implementation. The streams were byte-identical.

Wallets and NFT sprites

The game optionally reads sprites from an existing EXT collection canister. If you own a token from it, you race as that token; otherwise you race as a default sprite under identical rules.

EXT indexes ownership by account identifier, not principal, which is the main friction:

  • Plug and Stoic expose an account identifier directly.
  • Internet Identity gives a principal, so we derive the default-subaccount account identifier ourselves (sha224("\x0Aaccount-id" || principal || subaccount), CRC32-prefixed).

We validated the derivation against a real principal/account-identifier pair from the collection’s ownership registry, and it was an exact match. Token identifiers ("\x0Atid" || canister || u32 index) round-trip across every token index in the collection with zero failures, checked against @dfinity/principal. Ownership lookups use tokens(accountId) with a fallback to getRegistry() for older EXT deployments, cached client-side for five minutes.

Important caveat: the ratId passed to startRun is cosmetic. It selects a sprite and is not verified in the canister. That’s deliberate for now. A cross-canister call to a query method executes as a replicated update, adding latency and cycles to every run start, plus a dependency on another canister’s availability. It has to change before anything of value attaches to a result, at which point ownership verification moves canister-side.

Frontend details

  • Screens map to history entries (#connect, #pick, #race, #board), so the mobile back gesture works and boards are linkable. The popstate handler refuses to restore a screen that needs a session or a live run.
  • The race canister id resolves at runtime: ?canister= query param, then window.RACE_CANISTER_ID, then a <meta> tag, then the build-time value. Ids are validated against the principal format, and the collection canister id is explicitly rejected. An earlier misconfiguration routed game calls to the NFT canister, and this makes that impossible.
  • A token picker handles wallets with hundreds of tokens: numeric search, native lazy loading, and a sticky selection bar.

Status

  • Game canister: written, type-checked and compiled with moc
  • Frontend: built and bundling cleanly
  • Mainnet deployment: in progress this week

What’s next

  1. Agent play via MCP. Because the game is a small typed interface, an agent can call startRun, visibleTrack and makeMove under exactly the same rules as a human. We’re wiring this through the ICP MCP server, with the goal of agent and human runs on the same board.
  2. A separate agent board alongside the human one, so the two can be compared without one crowding out the other.
  3. Canister-side ownership verification before anything of value is attached to results.
  4. Timer-based maintenance in place of manually triggered cache refreshes.
  5. Governance for the race canister, per the trust assumption above.

Feedback we’d value

  • Is there a cleaner answer to the seed trilemma than private-then-revealed?
  • Immutable canister vs SNS for a small game: what has worked for others?
  • Anyone running agents against canisters via MCP in production who’d be up for testing against the race canister once it’s live?

Thanks for reading.

X: @DfinityRats
Discord: discord.gg/nj5GbzGD3M

2 Likes