Pikurn: A Betting Game with a Twist

Pikurn: A Betting Game with a Twist

on by Cade Brown · 5 min read · 1,178 words


Two greens, one red, and $100. Maximize the average—or guarantee $200.

Start with $100 and a uniformly shuffled bag of two green balls and one red ball.

  • Before each draw, bet any amount of your current bankroll on green.
  • Green adds your stake; red subtracts it.
  • Draw without replacement. You see the ball even if you bet nothing.

The best first bet depends on the objective:

max  E[W]:bet 0,E[W]=7003.max  minW:bet 50,W=200 on every runout.\begin{aligned} \max\;\mathbb E[W]&:\quad\text{bet }0,\qquad\mathbb E[W]=\tfrac{700}{3}.\\ \max\;\min W&:\quad\text{bet }50,\qquad W=200\text{ on every runout}. \end{aligned}

Here WW is final bankroll, including the starting $100. Austin Henley posed the original puzzle during a conversation at Khaki AI. The distinction between those two objectives is what makes it interesting.

Explore the game

Choose an objective, then a possible draw. The wager updates after each observation. Blue balls are optional: drawing one returns the stake and ends the game.

Urn explorer

Choose the bag and objective. Reveal any possible draw to follow the optimal strategy.

Starting bag · $100

Up to 4 of each color, 8 balls total. Changing a control starts a new $100 game.

2 green · 1 red · 0 blue / 3 draws left

Recommended bet

Wager $0.00

0% of your $100.00 bankroll.

Choose the next color; draws here are not random.

New game. Choose a possible draw to follow the recommended strategy.

    Terminal bankroll · selected strategy

    Average finish
    $233.33
    Worst finish
    $100.00
    Best finish
    $400.00

    Includes the original $100. Values cover every possible runout.

    Outcome distribution
    $100.0033.3%
    $200.0033.3%
    $400.0033.3%
    Adaptive game tree · first three draws

    Branches show conditional probabilities and the next bet. The tree stops after three draws; the distribution includes all draws.

    Start $100 · wager $0.00

    • G · 66.7% → $100.00 · next wager $0.00
      • G · 50.0% → $100.00 · next wager $0.00
        • R · 100.0% → $100.00 · finish
      • R · 50.0% → $100.00 · next wager $100.00
        • G · 100.0% → $200.00 · finish
    • R · 33.3% → $100.00 · next wager $100.00
      • G · 100.0% → $200.00 · next wager $200.00
        • G · 100.0% → $400.00 · finish
    Every possible runout
    Draw sequenceProbabilityTerminal bankroll
    ● G → ● G → ◆ R33.3%$100.00
    ● G → ◆ R → ● G33.3%$200.00
    ◆ R → ● G → ● G33.3%$400.00

    Three useful comparisons:

    • Original bag, three draws: maximum average $233⅓; maximum guarantee $200.
    • Same bag, one draw: maximizing the average means going all-in.
    • Three greens, one red, one blue, five draws: go all-in for the best average, but no policy can guarantee a profit—blue might appear first.

    Maximum average

    The original bag has three equally likely runouts:

    P(RGG)=P(GRG)=P(GGR)=13.P(RGG)=P(GRG)=P(GGR)=\tfrac13.

    Wait until the red appears, then go all-in on each remaining green:

    Runout Bankroll after each draw Finish
    RGG 100 → 200 → 400 $400
    GRG 100 → 100 → 200 $200
    GGR 100 → 100 → 100 $100

    The average is (400+200+100)/3=700/3(400+200+100)/3=700/3. Why pass up a ⅔ chance of green? A dollar lost on an early red would otherwise be doubled twice. The value of a bet includes its effect on future wagers.

    To prove optimality, keep both uncertain decisions in the calculation. Let XX be the first dollar bet and YY the dollar bet after a first green:

    0X100,0Y100+X.0\le X\le100,\qquad 0\le Y\le100+X.

    All other decisions have known colors: bet everything on green and nothing on red. These choices weakly improve every ending, so they lose no optimum.

    WRGG=4(100X),WGRG=2(100+XY),WGGR=100+X+Y.\begin{aligned} W_{RGG}&=4(100-X),\\ W_{GRG}&=2(100+X-Y),\\ W_{GGR}&=100+X+Y. \end{aligned}

    Hence

    E[W]=700XY37003,\mathbb E[W]=\frac{700-X-Y}{3}\le\frac{700}{3},

    with equality at X=Y=0X=Y=0. Even the 50–50 second bet reduces the whole game’s expectation: losing there removes money that could double on the final green.

    Strategy explorer

    Set the two uncertain bets. Then wager everything on certain greens and nothing on certain reds.

    After a green, you have $100 available for the second bet.

    ↔ Scroll each chart sideways to see its full range.

    Every runout has probability ⅓
    Three equally likely endingsR G G$400.00G R G$200.00G G R$100.00Terminal bankroll ($)
    Average versus guarantee
    Average versus guaranteed bankroll: your strategy and the efficient frontier100150200250050100150200Average ($)Guaranteed bankroll ($)

    Best average at each guaranteed floor. Your strategy.

    Expected final bankroll: $233.33. Guaranteed final bankroll: $100.

    The maximum-average strategy can finish at $100, $200, or $400.

    Maximum guarantee

    Set X=Y=50X=Y=50. The second bet is one-third of the $150 bankroll after a green. Every branch ends at $200:

    Flowchart

    G · 2/3

    R · 1/3

    G · 1/2

    R · 1/2

    G · certain

    R · certain

    G · certain

    G · certain

    $100 · bet $50

    $150 · bet $50

    $50 · bet $50

    $200 · bet $0

    $100 · bet $100

    $100 · bet $100

    GGR: $200

    GRG: $200

    RGG: $200

    Flowchart

    G · 2/3

    R · 1/3

    G · 1/2

    R · 1/2

    G · certain

    R · certain

    G · certain

    G · certain

    $100 · bet $50

    $150 · bet $50

    $50 · bet $50

    $200 · bet $0

    $100 · bet $100

    $100 · bet $100

    GGR: $200

    GRG: $200

    RGG: $200

    A higher guarantee is impossible. Requiring all three endings to be at least mm gives

    RGG:X100m4,GRG,  GGR:X3m4100.m200.\begin{aligned} RGG:&\quad X\le100-\tfrac m4,\\ GRG,\;GGR:&\quad X\ge\tfrac{3m}{4}-100. \end{aligned} \qquad\Longrightarrow\qquad m\le200.

    The first-bet-only strategy (Y=0Y=0) peaks at a guarantee of $160. Adapting the second bet raises it to $200.

    The average–guarantee frontier

    Suppose a ticket home costs $150. You can guarantee it with X=Y=25X=Y=25: the endings are $300, $200, and $150, averaging $216⅔.

    More generally, for a floor 100m200100\le m\le200,

    maxWmE[W]=800m3,X=Y=m1002.\boxed{\max_{W\ge m}\mathbb E[W]=\frac{800-m}{3}}, \qquad X=Y=\frac{m-100}{2}.

    Each extra dollar of guarantee costs one-third of a dollar in average bankroll.

    Goal XX YY Guarantee Average
    Maximum average 0 0 $100 $233⅓
    $150 ticket 25 25 $150 $216⅔
    Maximum guarantee 50 50 $200 $200
    Deriving the frontier

    The GGR constraint gives X+Ym100X+Y\ge m-100, so E[W](800m)/3\mathbb E[W]\le(800-m)/3. Choosing X=Y=(m100)/2X=Y=(m-100)/2 produces endings 6002m600-2m, 200200, and mm. For m[100,200]m\in[100,200], their minimum is mm: the upper bound is attained.

    The rules permit arbitrarily divisible stakes, with no fees or borrowing. Rounding bets to cents changes the optimization problem. Maximizing the probability of reaching a target is also a separate objective.

    General bags: the Bellman equations

    Use state s=(g,r,b,n)s=(g,r,b,n): remaining greens, reds, blues, and draws. Write T=g+r+bT=g+r+b and wager a fraction f[0,1]f\in[0,1] of bankroll DD.

    (D,s){(D(1+f),  (g1,r,b,n1))with probability g/T,(D(1f),  (g,r1,b,n1))with probability r/T,D(stop)with probability b/T.(D,s)\longrightarrow \begin{cases} \bigl(D(1+f),\;(g-1,r,b,n-1)\bigr)&\text{with probability }g/T,\\ \bigl(D(1-f),\;(g,r-1,b,n-1)\bigr)&\text{with probability }r/T,\\ D\quad\text{(stop)}&\text{with probability }b/T. \end{cases}

    Wealth scales linearly, so memoize only the count state. Let E(s)E(s) and H(s)H(s) be optimal expected and guaranteed wealth per starting dollar. Both equal 1 when n=0n=0 or the bag is empty. Subscripts G,RG,R denote continuation values after the corresponding draw.

    Expected wealth

    E(s)=max0f1g(1+f)EG+r(1f)ER+bT.E(s)=\max_{0\le f\le1}\frac{g(1+f)E_G+r(1-f)E_R+b}{T}.

    This is affine in ff. Its slope determines the wager:

    fE={1gEG>rER,0gEG<rER,any f[0,1]gEG=rER.f_E^*=\begin{cases} 1&gE_G>rE_R,\\ 0&gE_G<rE_R,\\ \text{any }f\in[0,1]&gE_G=rE_R. \end{cases}

    The implementation chooses zero on ties. Blue contributes current wealth and terminates; it has no continuation value.

    Guaranteed wealth

    H(s)=max0f1min{(1+f)HG,  (1f)HR,  1 if b>0}.H(s)=\max_{0\le f\le1}\min\bigl\{ (1+f)H_G,\;(1-f)H_R,\;1\text{ if }b>0 \bigr\}.

    Omit absent colors. The optimum lies at an endpoint or an intersection of branch lines. With both green and red present, their equalizing candidate is

    fH=HRHGHR+HG,f_H=\frac{H_R-H_G}{H_R+H_G},

    when this lies in [0,1][0,1]. For one green, one red, and two draws, HG=1H_G=1 and HR=2H_R=2:

    E(f)=32f2,H(f)=min{1+f,  22f}.E(f)=\tfrac32-\tfrac f2, \qquad H(f)=\min\{1+f,\;2-2f\}.

    ↔ Scroll each chart sideways to see its full range.

    Maximum average: bet nothing
    0 1 2 E = 3/2 Wealth multiplier 01 Wager fraction f
    Maximum guarantee: equalize the branches
    0 1 2 H = 4/3 1 + f2 − 2f Wealth multiplier 01 Wager fraction f

    The mean decreases from the outset; the guarantee peaks at f=1/3f=1/3. If any blue remains, an immediate stop caps the guarantee at 1, attained by betting zero throughout.

    Closed forms

    Full bag, no blue: maximum average

    For N=g+rN=g+r,

    E(g,r,0,N)=k=0g(Nk)(Ng).E(g,r,0,N)=\frac{\sum_{k=0}^{g}\binom Nk}{\binom Ng}.

    Wait for the last red, then go all-in on every green. This policy is optimal for any complete bag without blue; it need not be optimal with a shorter horizon or blue stops.

    Proof by induction

    Substitution into the wager coefficient, using Pascal’s identity, gives for g,r>0g,r>0

    gE(g1,r)rE(g,r1)=r<0.gE(g-1,r)-rE(g,r-1)=-r<0.

    Thus zero is optimal while a red remains. The zero-bet recurrence agrees with the formula, as do the boundaries E(0,r)=1E(0,r)=1 and E(g,0)=2gE(g,0)=2^g. Induction proves the result.

    Limited horizon, no blue: maximum guarantee

    For 0ng+r0\le n\le g+r, define S(n,r)=k=0min(r,n)(nk)S(n,r)=\sum_{k=0}^{\min(r,n)}\binom nk. Then

    H(g,r,0,n)=2nS(n,r),fH=(n1r)S(n,r)(n>0).H(g,r,0,n)=\frac{2^n}{S(n,r)}, \qquad f_H^*=\frac{\binom{n-1}{r}}{S(n,r)}\quad(n>0).

    Out-of-range binomial coefficients are zero. The original game has S(3,1)=4S(3,1)=4, giving H=2H=2 and fH=1/2f_H^*=1/2.

    The number of greens disappears once the horizon is feasible. Extra greens improve probabilities but leave the worst feasible sequences unchanged when n,rn,r stay fixed.

    Proof via the reciprocal recurrence

    If rnr\ge n, an all-red prefix is possible and H=1H=1. If r=0r=0, all draws are green and H=2nH=2^n. Otherwise both colors exist. Inductively, their continuation guarantees A,CA,C satisfy ACA\le C. Equalizing the branches gives

    H=2ACA+C,1H=12(1A+1C).H=\frac{2AC}{A+C}, \qquad \frac1H=\frac12\left(\frac1A+\frac1C\right).

    Pascal’s identity gives S(n,r)=S(n1,r)+S(n1,r1)S(n,r)=S(n-1,r)+S(n-1,r-1), completing the induction. Substitution into (CA)/(C+A)(C-A)/(C+A) gives the stated wager fraction.

    Code and checks

    Download pikurn.ts and run it with Node.js 24.2 or newer:

    Terminal window
    node pikurn.ts

    No dependencies or build step. It prints both policies and every runout. For a custom bag:

    import { solve, enumerate } from './pikurn.ts'
    const state = { g: 3, r: 1, b: 1, n: 5 }
    console.log(solve(state, 'expected')) // value: 1.9; fraction: 1
    console.table(enumerate(state, s => solve(s, 'expected').fraction, 100))
    Read the complete solver and command-line program
    pikurn.ts
    /** Pikurn: finite, adaptive betting without replacement. No runtime dependencies. */
    export type State = Readonly<{ g: number; r: number; b: number; n: number }>
    export type Objective = 'expected' | 'guaranteed'
    export type Outcome = 'G' | 'R' | 'B'
    export type Decision = Readonly<{ value: number; fraction: number }>
    export type Policy = (state: State) => number
    export type Branch = Readonly<{ outcome: Outcome; probability: number; next: State | null }>
    export type TerminalPath = Readonly<{ path: Outcome[]; probability: number; wealth: number }>
    /** Extra requested draws are harmless: the game also ends when the bag empties. */
    function normalize(state: State): State {
    for (const key of ['g', 'r', 'b', 'n'] as const) {
    if (!Number.isSafeInteger(state[key]) || state[key] < 0) {
    throw new RangeError(`${key} must be a nonnegative safe integer`)
    }
    }
    const total = state.g + state.r + state.b
    if (!Number.isSafeInteger(total)) throw new RangeError('The bag total must be a safe integer')
    return { ...state, n: Math.min(state.n, total) }
    }
    function branches(state: State): Branch[] {
    if (state.n === 0) return []
    const total = state.g + state.r + state.b
    const result: Branch[] = []
    if (state.g) {
    result.push({
    outcome: 'G',
    probability: state.g / total,
    next: { ...state, g: state.g - 1, n: state.n - 1 },
    })
    }
    if (state.r) {
    result.push({
    outcome: 'R',
    probability: state.r / total,
    next: { ...state, r: state.r - 1, n: state.n - 1 },
    })
    }
    if (state.b) result.push({ outcome: 'B', probability: state.b / total, next: null })
    return result
    }
    /** Feasible draws only. Blue returns the stake and immediately terminates play. */
    export function outcomes(state: State): Branch[] {
    return branches(normalize(state))
    }
    export function transition(state: State, outcome: Outcome): State | null {
    const branch = outcomes(state).find((candidate) => candidate.outcome === outcome)
    if (!branch) throw new RangeError(`Cannot draw ${outcome} from this state`)
    return branch.next
    }
    type Line = { intercept: number; slope: number }
    function finite(value: number, quantity: string): number {
    if (!Number.isFinite(value)) {
    throw new RangeError(`${quantity} exceeded the finite IEEE-754 number range`)
    }
    return value
    }
    /** Maximum of a lower envelope of affine functions occurs at an endpoint or crossing. */
    function guarantee(lines: Line[]): Decision {
    const candidates = [0, 1]
    for (let i = 0; i < lines.length; i++) {
    for (let j = i + 1; j < lines.length; j++) {
    const a = lines[i]!
    const b = lines[j]!
    if (a.slope === b.slope) continue
    const difference = finite(b.intercept - a.intercept, 'Continuation difference')
    const slope = finite(a.slope - b.slope, 'Continuation slope difference')
    const f = finite(difference / slope, 'Candidate wager')
    if (f > 0 && f < 1) candidates.push(f)
    }
    }
    let best: Decision | undefined
    for (const fraction of candidates.sort((a, b) => a - b)) {
    const value = Math.min(
    ...lines.map((line) => finite(line.intercept + line.slope * fraction, 'Normalized wealth'))
    )
    // Floating-point near ties retain the smaller wager; errors scale with the value.
    const tolerance = 32 * Number.EPSILON * Math.max(1, value, best?.value ?? 0)
    if (!best || value - best.value > tolerance) best = { value, fraction }
    }
    return best!
    }
    /**
    * Optimal terminal wealth per starting dollar and first wager fraction.
    * Later decisions are adaptive: solve again after observing each draw.
    * Values use IEEE-754 arithmetic, not rational arithmetic or Monte Carlo.
    * Numerical overflow throws RangeError instead of returning a misleading policy.
    */
    export function solve(input: State, objective: Objective = 'expected'): Decision {
    if (objective !== 'expected' && objective !== 'guaranteed') {
    throw new RangeError('Unknown objective')
    }
    const initial = normalize(input)
    const memo = new Map<string, Decision>()
    function visit(state: State): Decision {
    if (state.n === 0) return { value: 1, fraction: 0 }
    const key = `${state.g},${state.r},${state.b},${state.n}`
    const cached = memo.get(key)
    if (cached) return cached
    const choices = branches(state)
    const lines = choices.map(({ outcome, next }) => {
    const value = next ? visit(next).value : 1
    return { intercept: value, slope: outcome === 'G' ? value : outcome === 'R' ? -value : 0 }
    })
    let result: Decision
    if (objective === 'expected') {
    const intercept = finite(
    lines.reduce((sum, line, i) => sum + choices[i]!.probability * line.intercept, 0),
    'Expected continuation wealth'
    )
    const slope = finite(
    lines.reduce((sum, line, i) => sum + choices[i]!.probability * line.slope, 0),
    'Expected continuation slope'
    )
    const tolerance = 32 * Number.EPSILON * Math.max(1, intercept)
    const fraction = slope > tolerance ? 1 : 0
    result = { value: finite(intercept + slope * fraction, 'Normalized wealth'), fraction }
    } else result = guarantee(lines)
    memo.set(key, result)
    return result
    }
    return visit(initial)
    }
    /**
    * Enumerate every possible color sequence, retaining its true probability.
    * This is exponential in the horizon; use small bags for trees and histograms.
    * A policy is a deterministic wager fraction based on the observed remaining bag.
    * A path whose wealth overflows the finite number range throws RangeError.
    */
    export function enumerate(input: State, policy: Policy, bankroll = 100): TerminalPath[] {
    const initial = normalize(input)
    if (!Number.isFinite(bankroll) || bankroll < 0)
    throw new RangeError('Bankroll must be finite and nonnegative')
    const result: TerminalPath[] = []
    function walk(state: State | null, path: Outcome[], probability: number, wealth: number): void {
    if (!state || state.n === 0) {
    result.push({ path, probability, wealth })
    return
    }
    const fraction = policy(state)
    if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1) {
    throw new RangeError('Policy must return a finite wager fraction in [0, 1]')
    }
    for (const branch of branches(state)) {
    const multiplier =
    branch.outcome === 'G' ? 1 + fraction : branch.outcome === 'R' ? 1 - fraction : 1
    walk(
    branch.next,
    [...path, branch.outcome],
    probability * branch.probability,
    finite(wealth * multiplier, 'Path wealth')
    )
    }
    }
    walk(initial, [], 1, bankroll)
    return result
    }
    // Node 24.2+ runs TypeScript directly. Importing this module never runs the CLI.
    // Usage: node pikurn.ts [green=2] [red=1] [blue=0] [draws=bag-size] [bankroll=100]
    if (import.meta.main) {
    const args = process.argv.slice(2).map(Number)
    const g = args[0] ?? 2
    const r = args[1] ?? 1
    const b = args[2] ?? 0
    const state: State = { g, r, b, n: args[3] ?? g + r + b }
    const bankroll = args[4] ?? 100
    for (const objective of ['expected', 'guaranteed'] as const) {
    const decision = solve(state, objective)
    console.log(
    `${objective}: first wager ${(100 * decision.fraction).toFixed(2)}%; value $${(bankroll * decision.value).toFixed(2)}`
    )
    console.table(
    enumerate(state, (next) => solve(next, objective).fraction, bankroll).map((leaf) => ({
    path: leaf.path.join('') || '(no draws)',
    probability: leaf.probability,
    wealth: leaf.wealth,
    }))
    )
    }
    }

    The listing and explorer import the same file. The ZIP below includes all visualization code and editable figures.

    • Solver: memoized count states; at most (g+1)(r+1)(b+1)(n+1)(g+1)(r+1)(b+1)(n+1) combinations, with constant work per state.
    • Explorer: exhaustive runouts for bags up to eight balls; displayed trees stop after three draws. Enumeration can grow exponentially.
    • Verification: independent action-grid recursion, probability conservation, bankroll scaling, and comparisons with both closed forms.

    The code uses floating-point arithmetic with a small tie tolerance and rejects numerical overflow. The derivations establish the exact results; large computations remain limited by memory, recursion depth, and number range.

    Background: Bellman’s On the Theory of Dynamic Programming and Auckland’s sampling-without-replacement notes. The Pikurn formulas are derived above. Thanks to Austin Henley, Gregory Croisdale, and Ben Klein for the original discussion.

    Files & source

    Original files, including the article source. Download all files (ZIP) or browse the source and history.

    13 files