Rook Street is chess where every piece is also a stock. Pawns are Industrials, knights are Tech, bishops are Finance, rooks are Real Estate and the queen is Energy. The king is a Bond: it has a price on screen but counts zero toward your score. The daily puzzle gives you a mid-game position, a market forecast that is only partly visible, and eight moves. Your score is your net worth at the closing bell: cash plus the current market price of every piece you still own.
Legal moves come from chess.js. Everything else lives in a TypeScript package called game-core: a seeded RNG, a market that ticks after every full move, a search that grades candidate puzzles, and versioned configs that keep old results valid. This post covers the decisions in that package, including the ones that were wrong the first time.
The contract: a game is a pure function of its inputs
The project brief has one sentence that shapes the whole codebase: all randomness is seeded and deterministic, a pure function of seed plus move list, so games are replayable, shareable and verifiable on the server. If that holds, the server never trusts a score and any bug is reproducible from a seed and a move list.
Seeds start life as strings. The daily seed is a hash of a fixed prefix plus the UTC date, with an optional reroll suffix. The hash is a cyrb53 variant folded to 32 bits, and the generator is mulberry32:
/** mulberry32: small, fast, deterministic. Returns floats in [0, 1). */
export function createRng(seed: number): Rng {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Seed prefix. Puzzles change if this changes, so treat it like a rule. */
export const DAILY_SEED_PREFIX = "rookstreet:daily:";
export function dailySeed(date: string, reroll = 0): number {
return hashSeed(`${DAILY_SEED_PREFIX}${date}${reroll ? `:${reroll}` : ""}`);
}
Both are a few lines of Math.imul and unsigned shifts, so they give the same integers in any JavaScript engine with no dependency. Math.random cannot be seeded, so it never appears in the engine. The comment on the prefix matters most: editing that innocent string silently replaces every puzzle. Constants that feed the RNG are rules, not configuration.
Draw order is part of the rules too
A less obvious trap is the order in which numbers are pulled from the stream. puzzleFromSeed creates one RNG and uses it twice: first to play 12 to 20 plausible plies from the starting position (captures weighted up, moves onto attacked squares weighted down), then to generate the market forecast. The forecast reads from wherever the position generator left the stream. Change one weight in the position generator and the market for that date changes too, even though no market code was touched. That coupling is acceptable only because every stored puzzle carries its full forecast, so a stored daily never has to be regenerated from its seed.
The scripted opponent does not share that stream. It gets its own generator, createRng(puzzle.seed ^ 0x9e3779b9), used only for tie-breaks between equally good replies. Its choices depend on the seed and the position, not on how many numbers the generator consumed. ADR 0002 rejected storing the replies in the puzzle as "redundant with the seed and easy to desync".
The forecast is generated up front, and it is a secret
The random walk and every news event are generated when the game is created; only the board-driven part of the market is computed live. That is what makes the fog work: the server holds the full forecast and the client receives only the first three ticks. ADR 0004 spells out the protocol: after each player move the client posts the full action list so far, the server replays it from scratch, appends the opponent reply, and returns the reply plus whatever forecast rows have now been revealed.
export function stepDaily(puzzle: DailyPuzzle, actions: readonly Action[]): StepResult {
const game = dailyGame(puzzle);
const rng = opponentRng(puzzle);
let reply: string | null = null;
for (const action of actions) {
if (game.state.result) throw new Error("game already over");
if (game.turn !== "w") throw new Error("not the player's turn");
const err = game.apply(action);
if (err) throw new Error(`illegal action: ${err}`);
reply = null;
if (action.kind === "move" && !game.state.result) {
reply = scriptedReply(game, rng);
const replyErr = game.apply({ kind: "move", san: reply });
if (replyErr) throw new Error(`opponent failed: ${replyErr}`);
}
}
// ... return reply, fen, revealed forecast rows, prices, net worth
}
Replaying the whole list on every request is quadratic in moves, a bad trade in a long game. The daily has eight moves, a replay costs milliseconds, and the Worker keeps no session state at all. ADR 0004 rejects the two obvious alternatives in a line each: sending the forecast to the client with obfuscation ("trivially reversed") and a Durable Object per game ("state to lose, nothing to gain for 8 moves"). The client never sends a score.
A strict verifier also turns protocol bugs into loud failures. The worklog records one: the client first posted its whole history, including the opponent's replies, so move 2 was rejected with a 422. The fix was in the client store, which now tracks player actions separately. A lenient server would have produced wrong scores instead of an obvious error.
One tick of the market
A market tick fires after every full move pair. Each sector's new price is the old price times one plus the sum of three things: the seeded random walk for that tick, board signals accumulated since the last tick, and any scheduled news. Captures produce the signals: the capturing piece's sector gains 6 percent and the captured piece's sector loses 8 percent.
for (const s of SECTORS) {
let pct = (walk[s] ?? 0) + (signals[s] ?? 0);
for (const n of forecast.news) if (n.tick === tick && n.sector === s) pct += n.pct;
let price = prices[s] * (1 + pct);
if (s === "k") price = Math.max(price, cfg.basePrice.k * cfg.kingFloor);
next[s] = Math.max(0.01, Math.round(price * 100) / 100);
}
Two details in those six lines are rules, not presentation. Prices are rounded to cents on every tick, inside the simulation, so the rounding compounds and is part of the outcome. Moving it to display time would change scores and need a new engine version. The 0.01 floor keeps every later percentage move meaningful, and the king has its own floor at 80 percent of base.
A pawn taking a pawn nets Industrials minus 2 percent, and a unit test pins that sum. A capture pays the capturer the captured piece's current price in cash, so the real decision is timing: taking a knight before a forecast Tech drop turns a falling asset into cash.

Version 1 got the volatility wrong
The first engine set each sector's volatility by how exciting the sector felt: Energy 10 percent per tick, Tech 9, Finance 6, Real Estate 5, Industrials 4. After playing the live site, the owner reported that the prices did not fit. ADR 0006 records the arithmetic behind that feeling. A full-size tick moves your portfolio by the sector's volatility times the total value of your pieces in it. With one queen worth 9, Energy swung 0.9 of portfolio per tick. With eight pawns worth 1 each, Industrials swung 0.32. The market effectively touched only the queen.
Engine v2 weights volatility by each sector's total board value at the start of the game instead:
- Industrials: 8 pawns, value 8, at 8 percent = 0.64 per tick
- Tech and Finance: value 6 each, at 9 percent = 0.54 per tick
- Real Estate: value 10, at 6 percent = 0.60 per tick
- Energy: value 9, at 6 percent = 0.54 per tick
Every piece type now matters about equally to your net worth, which is the same promise chess makes about its pieces. The same ADR indexed the chart to percent change from the open, since a 1.00 line next to a 9.00 line cannot be read, and shows each tick's effect on your holdings: "Tech +4.5% (+0.27)". Equal base prices were rejected because they break the intuition that a queen is worth more than a pawn.
Frozen configs: changing the rules without breaking history
Every number in the rules lives in one config object, and configs are frozen by engine version:
/** Bump on any rule or tunable change and add an ADR. Stored results record this. */
export const ENGINE_VERSION = "3";
export const QUICK_MATCH_CONFIG_V2: GameConfig = {
...QUICK_MATCH_CONFIG_V1,
version: "2",
volatility: { p: 0.08, n: 0.09, b: 0.09, r: 0.06, q: 0.06, k: 0.005 },
};
/** Frozen configs by engine version, so old games always replay identically. */
export const CONFIGS: Record<string, { quick: GameConfig; daily: GameConfig }> = {
"1": { quick: QUICK_MATCH_CONFIG_V1, daily: DAILY_CONFIG_V1 },
"2": { quick: QUICK_MATCH_CONFIG_V2, daily: DAILY_CONFIG_V2 },
"3": { quick: QUICK_MATCH_CONFIG, daily: DAILY_CONFIG },
};
Each version is a spread of the previous one plus what changed, so the diff is readable in the source. Every puzzle row has an engine_version column, and server and client replay a puzzle on its own config. When v2 shipped, v1 results stayed valid and only the upcoming dailies were regenerated.
Version 3 shows why a rule change needs code paths, not just numbers. In v1 and v2 a short was binary: if the sector fell you won stake times 2 times the drop, otherwise you lost the whole stake, and negative cash meant bankruptcy. v3 gives the daily Long and Short with a linear payout of stake times the sector's next-tick move. Settlement branches on cfg.betMode, and the old branch stays forever because v1 and v2 games still replay through it. One leftover is honest naming debt: the open position is still stored in a field called short, with a dir flag that is absent on old positions.
Versioning protects stored data from rule changes, not from deploying an old build. The worklog records a deploy from a feature branch that rolled ENGINE_VERSION back to 1, fixed by cherry-picking the unmerged change and redeploying. The lookup has a related weakness: CONFIGS[version]?.daily ?? DAILY_CONFIG falls back to the current config when it meets an unknown version. That keeps the game from going dark, but an outdated build would replay a newer puzzle on the wrong rules silently. For a verifier, throwing would be the better choice.
Property tests: what fast-check actually proves here
The brief requires property-based tests with fast-check for determinism and rule invariants. The core one generates a random seed and a random game length, builds an action list from that seed (random legal moves, plus a short attempt on roughly 30 percent of turns), and replays it twice:
test("property: same seed and actions always give the same snapshot", () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 2 ** 31 }),
fc.integer({ min: 1, max: 60 }),
(seed, plies) => {
const actions = randomActions(seed, plies);
const a = replay({ cfg, forecast: forecast(seed) }, actions).snapshot();
const b = replay({ cfg, forecast: forecast(seed) }, actions).snapshot();
expect(a).toEqual(b);
return true;
},
),
{ numRuns: 25 },
);
}, 60000);
Be precise about what this catches. Two replays in one process diverge if the engine reads hidden state: Math.random, the clock, a module-level cache, or a forecast the game mutates. Those are the realistic ways determinism breaks. It does not prove a phone and a Worker agree; that rests on the RNG using only 32-bit integer operations.
The second property checks invariants over 60-ply random games: every price stays above zero, and no cash goes negative unless the game ended in bankruptcy. Under binary shorts a lost oversized short does push cash below zero, so the property checks that the two always arrive together.
There are two tradeoffs here. First, the run counts are small (25 and 15) and the tests carry 60-second timeouts. Per the worklog, chess.js move generation is the cost. Second, because the action list is derived from a seed inside the property, fast-check can only shrink the seed and the ply count, not the actions themselves. A failure shrinks to "seed X, N plies" rather than a minimal move sequence. That is still reproducible, which is what matters.
Puzzles with one right answer
A random position with a random market is usually a bad puzzle. The daily is graded by judge(), which runs a depth-3 negamax over market-valued net worth with three personalities (aggressive, value and momentum) and rejects a candidate, cheapest checks first, when a forced mate is in reach, when fewer than three captures are available along the first four plies of the main line, when the decision gap is outside the weekday's band, when the market does not change any personality's top move compared with a flat market, or when the engine's par score falls outside the allowed range.
const gapPct = second ? (best.score - second.score) / game.netWorth("w") : 0;
const [lo, hi] = filter.bands[difficulty];
if (gapPct < lo) reasons.push(`gap ${(gapPct * 100).toFixed(1)}% below ${lo * 100}%`);
if (gapPct > hi) reasons.push(`gap ${(gapPct * 100).toFixed(1)}% above ${hi * 100}%`);
The decision gap is the core idea: the score of the best move minus the second best, as a fraction of net worth. A wide gap means one clearly right move (easy days, Monday and Tuesday). A narrow gap means the best move is only slightly better (hard days, the weekend).
The first version of this filter was wrong in three ways, all recorded in the phase report. It took 7.5 seconds per candidate, because the root ran a full-window search and generated moves at every leaf. Now it takes 0.2 to 1.5 seconds: root alpha-beta keeps exact scores for the top two moves only, which is all the gap needs, and leaves skip move generation. Second, the gap bands in the brief almost never occurred in real positions, so they were retuned from data to 10-60, 5-10 and 2-5 percent. Third, the "market matters" check rarely fired, for a revealing reason: a depth-3 search sees one or two ticks at most, so the engine grading a market-timing puzzle was nearly blind to the market. The fix values leaves at prices projected through the visible forecast.
Generation cannot run in a Cloudflare Worker: one date can take 20 to 60 seconds of CPU against a 10 ms free-tier budget, so puzzles are generated offline and uploaded. Retries are deterministic (seed plus one, up to 50 attempts, then the same weekday up to ten weeks earlier), so an accepted puzzle is reproducible from the date and reroll counter. The recorded 30-day run took 356 attempts, a median of 12 per date, with no fallbacks.
Takeover and Bull & Bear: proving uniqueness exactly
The two prototype modes go further, from "one clearly best move" to "exactly one solution". Takeover went through three rule sets in one day. In v1, against a bot with kings, a king got boxed in by an acquired pawn. In v2 the kings were dropped, and the result felt like chess with hope instead of a right answer. v3 is a solo puzzle: one white piece, five to seven black ones, and every move must acquire a piece you attack. Acquisition is a swap, not a flip: your piece takes the target's square and the target turns white on the square you left. The feature doc gives the reason: a flip in place would never change the position, so the order of moves would not matter.
/** Number of move sequences that own every piece, counting stops at `cap`. */
export function countSolutions(pieces: SoloPieces, cap = 2): { count: number; line: SoloMove[] } {
let count = 0;
let line: SoloMove[] = [];
const go = (p: SoloPieces, path: SoloMove[]) => {
if (count >= cap) return;
if (isWon(p)) {
if (count++ === 0) line = path;
return;
}
for (const m of soloMoves(p)) go(soloApply(p, m), [...path, m]);
};
go(pieces, []);
return { count, line };
}
The cap of 2 is the trick: you do not need to count every solution, only to know whether a second one exists, so the search stops early. Shipped puzzles are stored as seeds and sizes, not boards, and a test regenerates each layout and asserts that the count is exactly 1. If anyone changes how soloLayout draws from the RNG, that test fails instead of quietly shipping a puzzle with two answers.


Bull & Bear needed a better definition of "one solution". The Bull charges and buys, the Bear steps and sells, and some moves do nothing. Counting move sequences would report many solutions that differ only in where the waiting moves go. The memoised solver instead counts distinct deal plans: which stock is bought or sold, in what order. A layout ships only if exactly one plan reaches the maximum cash, the profit is at least $6, the plan includes two buys and two sells, and a greedy player with one-move lookahead falls short of the target. That last condition separates a correct puzzle from an interesting one.
The thread is the same throughout: rules are data, data is frozen by version, randomness comes only from seeds, and the server can rebuild any game from scratch. That is what makes server-side scoring cheap and puzzle uniqueness something a test checks rather than a comment promises.