Skip to content

A fair die is a product decision

If you are new to building with an AI agent, here is a small, slightly uncomfortable idea: most of the decisions that define your product are decisions you are not making. They are defaults. The agent autocompletes them from a few million examples of how everyone else did it, you accept the result because it looks normal, and the product quietly becomes the average of its training data.

Randomness is the cleanest example I have found. Ask any builder -- human or AI -- to "roll a die," and you get random.randint(1, 6). It works. It even is uniform. And it is, almost always, a decision nobody made on purpose.

This post is about the surface in this project where we forced ourselves to make that decision on purpose. It is called Dice Room, and it is barely a game at all.

Dice Room is not really a game

Dice Room has no turns, no score, and no winner. Phones roll dice, the TV shows the public roll feed, and the room owner gets a stats dashboard. The engine (the relevant engine module) does one thing: it keeps an append-only log of rolls. There is no is_terminal() other than the owner ending the session.

So why build it?

Because it is the reference consumer and visualizer for the part of the system that actually matters here: the randomness service. The design doc (the game docs) is blunt about it -- the dashboard is "the screen that justifies the engine's existence." The believability modes (more on those in a second) are invisible until you can roll thousands of dice and watch the distribution move. Dice Room is the machine that lets you watch.

If you are learning to work with an AI agent, this is a pattern worth stealing: when you are building an abstract capability, build the smallest possible thing that makes the capability visible. You will spend the rest of the project leaning on it -- to demo, to debug, to tune, and to catch the agent when it confidently ships something that is subtly wrong. A capability you cannot see is a capability you cannot review.

The thing it makes visible: four believability modes

The randomness service does not just hand back uniform numbers. It hands them to a mode first. There are four, and each one lives in its own small file under the relevant server module. The service picks one when it resolves a roll (resolve_die / resolve_draw in the randomness service), and every mode implements the same interface: take a batch of already-uniform draws and return the values the engine will see. The interface and the create_mode factory both live in the relevant server module.

  • Pure (the relevant server module) is the identity. It returns the uniform draws unchanged. This is the honest baseline -- a real, unreshaped Mersenne Twister stream. Its own description in the code is "Uniform Mersenne Twister stream with no reshaping."
  • Bag (the relevant server module) is a draw bag. For a d6 it puts all six faces in a bag and draws without replacement until the bag is empty, then refills. The effect: you cannot roll four 1s before you have seen the other faces, because they are still in the bag waiting. It bounds droughts -- the long gaps between repeats of a face that make real randomness feel broken.
  • Mercy (the relevant server module) is an anti-streak floor. It watches each player for a run of low rolls (by default, three in a row), and when the streak hits the threshold it floors the next roll upward to roughly the 75th percentile of the die. The player who has rolled cold for a while gets a quiet hand up.
  • Cinematic (the relevant server module) binds each player to one of the project's AI personalities -- Steady Eddie, The Prodigy, Old Reliable -- and shapes their luck to match that character's arc. The Prodigy runs cold and then gets a "comeback hot" window; Old Reliable's rolls cluster toward the middle; Steady Eddie gets a mild nudge out of a bad run. If that cast sounds familiar, it is the same lineup from AI personalities, not optimizers -- Cinematic mode imports them straight from the relevant AI module, so the opponent's temperament and the dice's feel come from one source of truth.

Four files, four behaviors, one interface. None of this is what random.randint gives you for free.

The honest edge: believability is a feel, not a fairness claim

Here is the part the post has to say plainly, because it is the easy thing to get wrong: Bag, Mercy, and Cinematic do not make the dice "more fair."

Pure is the fair one. It is uniform and every roll is independent of the last. That is the entire definition of a fair die.

The other three deliberately give up something to buy something. Bag gives up per-roll independence (a face that just came up is now out of the bag, so it is less likely next time, not equally likely) to buy bounded droughts. Mercy gives up independence (your streak changes your odds) to buy a sense of relief after a cold run. Cinematic gives up independence to buy a narrative. They trade strict per-roll independence for a better felt experience.

That trade is a legitimate product decision -- "streaky-but-fair dice feel bad to a lot of players" is a real finding, and the GameTek and Building Blocks probability material the design doc cites is exactly about player perception of randomness. But it is a UX choice about feel, and dressing it up as "fairer dice" would be a lie. The dashboard exists partly to keep us honest about this: it overlays observed counts against the expected uniform distribution, so you can see exactly where a mode is bending the curve away from Pure.

If you take one thing from this section: when an AI agent offers you a behavior that "feels better," make it tell you what it traded to get there. Here, the trade is independence, and we wrote it down.

A fair RNG is a property you design, not a side effect

Now back to the uncomfortable idea from the top. Even Pure -- the boring, uniform, do-nothing mode -- is not a default we inherited. It is a designed property. Three properties, actually, and none of them come from random.randint:

  1. Seedable. The service is constructed with a root seed (RandomnessService(root_seed) in the randomness service). Same seed in, same dice out.
  2. Replay-deterministic. The service does not keep one global random generator. It derives an independent sub-stream per (game_id, purpose) pair, and it derives each sub-stream's seed by hashing the root seed together with the game id and purpose (derive_substream_seed builds a small JSON payload, sorts the keys, and runs it through SHA-256). Because the derivation is pure and the streams are independent, a recorded session replays byte-for-byte from its action log. That is also why the project's architecture rule is "record actions, not states" -- you do not need to store every roll, only the seed and the ordered actions, and replay reconstructs the rest.
  3. Auditable. The referee-only view can ask the service for a snapshot (referee_snapshot) that exposes the root seed, every sub-stream's cursor position, and each mode's internal counters. The Dice Room dashboard turns that into observed-vs-expected histograms and chi-square goodness-of-fit per die, so a biased stream -- whether it is an intended believability mode or an actual bug -- shows up as a visible gap.

random.randint gives you exactly none of those. It is not seedable in any disciplined way across a session, it is not independently reproducible per purpose, and it leaves no audit trail. The difference between "uniform numbers" and "a fair die you can seed, replay, and audit" is the difference between a default and a decision.

The one rule that makes all of this possible

None of the above survives contact with a large codebase unless you stop people -- and agents -- from reaching around it. So the project has a hard rule, written into CLAUDE.md:

Gameplay randomness must use RandomnessService, never Python random directly.

Concretely: gameplay code under the relevant engine module, the relevant server module, and the AI game-logic under the relevant AI module may not write import random or call random. members. All gameplay draws route through the randomness service (or the AI helper the relevant AI module), and those two wrapper modules are the only sanctioned places that import Python random internally. The rule is enforced mechanically -- the project dev wrapper scans the gameplay trees and fails the build on an unexempt direct-random import.

It is worth being clear about why a single import ban is load-bearing, because this is the lesson for anyone building this way. Every one of the three properties above is only true if there is no second source of randomness. The moment one handler calls random.randint directly:

  • the session is no longer seedable, because that draw did not come from the seeded stream;
  • the replay diverges, because the action log cannot reconstruct a number the service never produced;
  • the audit lies, because the dashboard's histograms do not count the rolls that bypassed the service.

One leak and all three guarantees quietly become false -- and nothing visibly breaks, which is the dangerous part. The lint is not bureaucracy; it is the thing that keeps "fair, seedable, replayable, auditable" from degrading into "uniform-ish and unprovable." When you are working with an agent that will happily autocomplete random.randint because that is what its training data does ten thousand times over, a mechanical gate is how you make the deliberate decision stick across every future change.

This is the same discipline that the Device profiles, not accounts post describes from a different angle: the way you beat an autocompleted default is to write the rule down explicitly and check every artifact against it. There the default was the person-shaped profile screen; here it is random.randint. The fix is identical in shape -- name the rule, enforce it, and refuse the first leak.

The hidden roll: a tiny test that the views do not leak

There is one more reason Dice Room earns its keep, and it has nothing to do with statistics. It is a privacy test.

Most of a Dice Room session is public -- you want the table to see what everyone rolled. But the owner can roll hidden (the DM rolling behind the screen). A hidden roll goes into a per-player private log -- hidden_roll_log in the engine's private state -- and it must never leak into anyone else's view. Not even a placeholder: the design is explicit that "X made a hidden roll" is itself information, so the public feed shows nothing at all.

That makes Dice Room a small, clean fixture for the project's role-based view filter. The same engine builds three views:

  • public_view -- the public roll feed only. No private logs.
  • player_view(pid) -- the public feed plus that one player's own hidden rolls, and nobody else's.
  • full_view -- everything, including every player's hidden rolls. This is the referee / DM ground-truth view.

If the three-view pipeline ever accidentally leaked one player's private data to another, the hidden-roll case is where it would show up first, on the smallest possible surface. A capability you cannot see is a capability you cannot review -- and "did the private data stay private?" is exactly the kind of thing that is invisible until you build the surface that makes it visible. Dice Room is that surface for randomness and for the view filter at the same time.

What to take away

If you are learning to build with an AI agent, the meta-lesson is not about dice. It is that the agent's defaults are a draft, not a decision, and the interesting work is deciding which defaults to keep.

We kept "uniform" -- but as Pure, a named mode we can seed, replay, and audit, not as an accident of the standard library. We added three more modes because we decided the feel of randomness was a product surface worth designing, and we were honest that those modes trade fairness-as-independence for feel. We wrote one hard rule and enforced it with a lint, because a guarantee with a leak is not a guarantee. And we built one small not-really-a-game so that all of it is visible enough to review.

A fair die is a product decision. So is every other default the agent hands you. The skill is noticing which ones you are accepting without deciding.

(Two sibling posts go deeper on the seed-derivation machinery and on tuning the believability modes against the dashboard; they will link in here once they are written.)