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:

SideHookAllowed effect
shooteronPostRollMutate the raw dice roll
bettoronPostResolveMutate 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:

  1. Compile-time — side-crossing is a type error. Chip is a discriminated union on side:

    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 | BettorChip

    A wrong-side hook (or a skillThresholds gate on a bettor chip) is a compile error. CLAUDE.md’s claim that “the TypeScript types make crossing sides unreachable” is literally true.

  2. Runtime — the dice seam. After shooter chips run, assertValidRoll(roll) throws INVALID_ROLL if either die isn’t an integer 1-6. A misbehaving (or malicious) shooter chip can’t emit an impossible face.

  3. Runtime — the settlement seam. After bettor chips run, assertValidBettorSettlements enforces 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 every payout / principalReturned / principalLost is a finite non-negative integer. Throws INVALID_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.