Skip to content

The action log is an analytics engine waiting to happen

If you are new to building with an AI agent, one of the most useful habits you can copy is this: when you make an infrastructure choice that feels boring, write down the payoff you are buying. Otherwise the choice is just a default, and a default has no payoff to collect on later.

This project made two boring-looking choices early. It records actions, not states -- the source of truth is an ordered log of moves, and positions are replayed from it. And it stores that log in DuckDB instead of the more obvious SQLite. Neither choice does anything visible on its own. A new contributor could easily "clean them up" into something more conventional without noticing what they cost.

This post is the field report on what those two choices unlock together, plus a third primitive that rides on the same rails: the move-assessment pipeline that grades every move, and the randomness service that makes every dice draw seedable and replayable. Wire them together and you get one thing: an observable game. The same durable trail that lets a player review their match also lets you audit the dice for bias. One log, two payoffs.

I want to be honest about the edge up front, because it is easy to oversell this. The trail is shipped -- the actions, the move grades, and the seedable randomness are all real, persisted code today. The analytics layer built on top -- the histograms, the distribution tables, the cross-game fairness dashboards I describe below -- is mostly proposed. The point of the post is that the primitives make that layer reachable, not that it already ships.

Why DuckDB beat SQLite

Start with the storage choice, because it is the one most likely to look like a mistake to someone who has only built transactional apps.

SQLite is tuned for transactional access: many small reads and writes, a row at a time, with strong guarantees about a single row's lifecycle. It is the right default for "store this user's settings and read them back." DuckDB is columnar and built for the opposite shape of work: aggregate scans over a whole column, grouping and counting across the entire table at once. It is the right default for "across every game ever played, how often did each move get graded a blunder?"

The recorded artifact here is the second shape, not the first. Look at what the recorder actually persists. the recorder writes one row per action, assessment, commentary line, and tension snapshot into a single DuckDB file, and the schema in the database schema shows the shape: an actions table holding action_data, seq, player_id, and -- deliberately -- only state_before, never a post-move state snapshot; an assessments table holding quality, eval_before, eval_after, eval_delta, best_move, and demo_line. That is not a transactional workload. Nobody fetches one action row by id to render a screen. The value is in scanning the columns.

Three query shapes make the point concrete. None of them ship as a built dashboard yet; all three are natural one-liners against the tables above:

  • Per-personality bluff-rate histograms. Join the actions log against the players recorded on each game and bucket how often a given AI personality chose a bluff-shaped action. A columnar scan over action_data grouped by personality is exactly the access pattern DuckDB is built for, and exactly the access pattern SQLite is not.
  • Move-quality distributions. The assessments table already stores a quality column drawn from a closed five-value set. SELECT quality, COUNT(*) FROM assessments GROUP BY quality returns the distribution across the BRILLIANT / GOOD / INACCURACY / MISTAKE / BLUNDER buckets in one scan. More on where those buckets come from in the next section.
  • Opening-frequency tables. Filter the action log to the first move of each game and group by the move played. Because the log is ordered by seq, "what is the most common opening, and how does it differ by personality?" is a WHERE seq = 1 ... GROUP BY away.

This is not a hypothetical that the project has never tried. It already runs SQL over its own durable history in a different domain: the workflow history index documented in the history-index docs builds a disposable DuckDB database from tickets, run records, git history, and orchestrator state, then answers questions like "which tickets touched this module and later reported drift?" with a query. The queryable project memory post is the field report on that index. The lesson transfers directly: if SQL-over-DuckDB is the right tool for the project's process history, it is the right tool for the project's gameplay history, because both are analytical workloads over an append-only log.

The five-bucket grade is already a column

The second primitive is the move-assessment pipeline, and the useful surprise is that its output is already a database column waiting to be aggregated.

the relevant AI module defines a Quality enum with exactly five members -- BRILLIANT, GOOD, INACCURACY, MISTAKE, BLUNDER -- and a QUALITY_THRESHOLDS table that maps a move's evaluation delta to one of those buckets. The assess function runs a Monte Carlo Tree Search from the position before the move, compares the move actually played against the move the search would have recommended, and records the result as a MoveAssessment carrying quality, eval_delta, best_action, and an optional demonstration line. If you have read AI opponents that play like people, not optimizers, this is the same five-quality grading that post describes from the player's side; this post is about what happens to those grades after the game ends.

What happens is that they get written down. The recorder persists each assessment into the assessments table, quality column and all. That single fact is what turns "we grade moves" into "we can chart how moves are graded." A move-quality distribution is not a feature anyone has to build into the engine -- it is a GROUP BY over a column that already exists. A per-personality version is a join away. A "did this player's blunder rate fall over their last twenty games?" view is a window function away.

The honest caveat: the column ships, the chart does not. There is no player-facing quality-distribution dashboard in the repo today. But the expensive part -- computing a trustworthy grade for every move and durably recording it -- is done. The cheap part is the query.

Fairness auditing rides on the same trail

The third primitive is randomness, and it is where "observable" turns into "auditable."

the randomness service does not hand the engine a raw random.randint. It hands back draws from a service with three designed properties. It is seedable: the service is constructed with a root seed, so the same seed in produces the same dice out. It is replay-deterministic: rather than one global generator, it derives an independent sub-stream per (game_id, purpose) pair, seeding each by hashing the root seed together with the game id and purpose through SHA-256 in derive_substream_seed. And it is auditable: the referee-only referee_snapshot exposes the root seed, every sub-stream's cursor position, and each believability mode's internal counters.

Those properties are what make a fairness audit possible at all. The audit loop looks like this:

  1. Replay a seed to reproduce the exact draw sequence a session saw. Because the derivation is pure and the streams are independent, this is byte-for-byte reproducible from the recorded action log -- you store the seed and the ordered actions, not every roll.
  2. Run a chi-square goodness-of-fit test comparing the observed face counts against the expected distribution for that die. A biased stream shows up as a gap between observed and expected.
  3. Bound the acceptable deviation per believability mode. This is the subtle part, and it is where the audit has to know what it is testing.

That third step matters because not every mode is supposed to be uniform. The randomness service runs every draw through a believability mode first, and the modes under the relevant server module make different promises. pure is the identity -- an unreshaped Mersenne Twister stream, the honest fair die. bag, mercy, and cinematic each deliberately bend the stream: a draw bag that bounds droughts, an anti-streak floor after a cold run, and a personality-driven luck arc, respectively. They trade strict per-roll independence for a better felt experience. The a fair die is a product decision post is the deep dive on that trade.

So a fairness audit cannot hold every mode to the same bar. pure must pass a tight chi-square bound -- if it deviates from uniform, that is a real bug. The bending modes are held to different bounds, keyed to what each mode is supposed to do: the audit checks that bag bounds droughts without skewing the long-run distribution past its declared tolerance, that mercy only lifts cold streaks, and that cinematic bends in the shape its personality profile claims. The point is that a fairness audit on this trail is not a single pass/fail -- it is a per-mode contract check, and the seedable-replayable randomness service is what makes each contract testable. As with the rest of this post, the chi-square audit and per-mode dashboards are a proposed layer; the seedable, replayable, mode-aware service they would run against is shipped.

The observability thesis

Now put the three primitives in one frame, because separately they look like unrelated infrastructure and together they are a single idea.

The project records actions, not states -- the architecture rule that the action log is the source of truth and positions are replayed from it, visible in the engine contract in the engine base contract and the recorder's deliberate "only state_before" schema. It stores that log in DuckDB, which is built for aggregate scans. And it assesses every move and seeds every draw, writing both the grades and the seed into the same durable trail.

The thesis is that those choices compound: because the engine records actions and grades moves, and because the draws are seedable and replayable, one trail serves two very different readers. A player wants analytics -- how am I improving, which openings do I lean on, how does each AI personality actually play. An auditor wants fairness -- are the dice doing what the mode says they do. Conventionally those are two systems with two data models. Here they are two queries against one append-only log. You did not build an analytics database and a fairness harness; you built one observable trail and pointed two kinds of question at it.

That is the payoff worth writing down next to the two boring choices. Record-actions-not-states is not just a storage tidiness rule, and DuckDB-over-SQLite is not just a dependency preference. They are the two halves of a bet that the game's own history is the most valuable artifact it produces -- more valuable than any single rendered screen -- and that the history is worth keeping in a shape a machine can interrogate later.

The honest edge: shipped vs. proposed

It would be easy to read the sections above as a tour of a finished analytics product. It is not, and saying so plainly is part of the point of a forward-looking post.

Shipped today: the move-assessment pipeline (the relevant AI module) computes and grades moves. The believability-mode randomness service (the randomness service, the relevant server module) produces seedable, replayable, mode-aware draws and can snapshot its own state for a referee. The recorder (the recorder) and DuckDB schema (the database schema) persist the action log and the move grades into a single analytical database. And the project already runs SQL over a sibling DuckDB index of its own process history (the history-index docs). The trail and its primitives are real.

Proposed, not shipped: the per-personality bluff-rate histograms, the move-quality distribution tables, the cross-game opening-frequency views, and the chi-square fairness dashboards described here are a proposed analytics layer. There is no player-facing analytics screen that renders them today. The closest shipped surface is narrow -- a single observed-vs-expected view -- and the general cross-game layer is a query away, not a feature away.

The distinction matters for anyone learning to build this way, because the failure mode is presenting a reachable thing as a finished thing. The discipline is to separate "the primitive that makes this cheap" from "the screen we have not built," and to keep claiming only the first.

What to take away

The meta-lesson is not about board games. It is that the most leverage you get from an AI-assisted build often comes from arranging ordinary artifacts so a machine can read them later -- and then being patient about collecting the payoff.

The action log was already being written, because the engine needs it to replay. The move grades were already being computed, because the review screen needs them. The dice were already seedable, because tournaments need reproducibility. None of that was built for analytics or fairness auditing. But because each one lands in the same durable, queryable trail, the analytics and the audits are reachable without a new data model -- a GROUP BY here, a chi-square pass there, a seed replay to reproduce a session exactly.

Write down the payoff next to the boring choice. Record actions, not states. Keep the log somewhere you can scan it. Grade the moves and seed the dice into the same trail. Then, when someone asks "is the AI bluffing too predictably?" or "are these dice actually fair?", the answer is a query against history you already kept -- not a system you still have to build.