Chip Dispatch Contract (the purist-test enforcer)
Chips are the Balatro-style modifier layer. They are data + pure hooks, registered by id in engine/src/chips.ts (DEFAULT_REGISTRY). Each chip has exactly one side:
| Side | Hook | Allowed effect |
|---|---|---|
shooter | onPostRoll | Mutate the raw dice roll |
bettor | onPostResolve | Mutate the settlement list |
There is no API for a shooter chip to touch payouts, and no API for a bettor chip to touch dice. This is what mechanically guarantees the purist test: strip every chip and the table is still faithful craps, because chips can only ever bend their own side.
Adding a new chip is a registry entry, not a reducer change.
Enforced at all three seams
The contract is not a convention — it is fenced in three places:
-
Compile-time — side-crossing is a type error.
Chipis a discriminated union onside:export interface ShooterChip extends BaseChip { side: "shooter" onPostRoll?: (raw: Roll, ctx: ShooterChipCtx) => Roll onPostResolve?: never skillThresholds?: SkillThresholds } export interface BettorChip extends BaseChip { side: "bettor" onPostResolve?: (events: SettlementEvent[], ctx: BettorChipCtx) => SettlementEvent[] onPostRoll?: never skillThresholds?: never } export type Chip = ShooterChip | BettorChipA wrong-side hook (or a
skillThresholdsgate on a bettor chip) is a compile error. CLAUDE.md’s claim that “the TypeScript types make crossing sides unreachable” is literally true. -
Runtime — the dice seam. After shooter chips run,
assertValidRoll(roll)throwsINVALID_ROLLif either die isn’t an integer 1-6. A misbehaving (or malicious) shooter chip can’t emit an impossible face. -
Runtime — the settlement seam. After bettor chips run,
assertValidBettorSettlementsenforces a 1:1 bijection with the input bet ids (rejecting not-owned → cross-player credit, duplicate → minting, missing → silently dropped), rejects unknown settlement kinds, and validates that everypayout/principalReturned/principalLostis a finite non-negative integer. ThrowsINVALID_SETTLEMENT.
RNG purity
Chips never reach for Math.random() — they consume the engine’s seeded RNG, and the two sides are isolated:
- Shooter chips consume the dice
rng(the snapshot is written back once after the dice are settled). - Bettor chips get an independent per-player derived RNG (
deriveRngState(diceSnapshot, hashPlayerId(playerId))) that cannot advance the dice stream. A bettor chip’s randomness can never perturb what the dice show — another expression of the purist test.
The shooter side: dice-skill chips
Shooter chips can read a per-roll SkillInput { power, angle, hold } threaded through ChipCtx.skillInput (from ROLL { skillInput? }). SkillInput is per-action only — never persisted into TableState. Without a skill-aware chip equipped, skillInput is inert, so the purist test holds by construction. Skill-aware chips declare skillThresholds; the client surfaces a per-chip ✓/✗ gate breakdown in the training panel.
The bettor side: payout modifiers
Bettor chips mutate the settlement list in onPostResolve — multiplying payouts, adding scoring. Because they run after resolution and are bound by the settlement-seam guard, they can change how much a won bet pays but can never invent a win, credit another player, or mint money.
Drafting heuristics (A2 shop)
The chip shop draws offers rarity-weighted then synergy-biased (not uniform):
- Rarity — chips carry an optional
rarity(common | uncommon | rare | legendary);RARITY_WEIGHTS(100/40/15/5) is the balance knob. - Synergy — chips carry optional
synergyTags; a candidate sharing a tag with any owned chip gets weight ×SYNERGY_MULTIPLIER(3), layered multiplicatively on rarity.
Both layers affect offer frequency only — never effect, dice, or payout — so the purist test is untouched. Owned chips are excluded from the pool, and the shared shop biases toward the union of all bettors’ chips.