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:
Here 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.
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.
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
- G · 50.0% → $100.00 · next wager $0.00
- R · 33.3% → $100.00 · next wager $100.00
- G · 100.0% → $200.00 · next wager $200.00
- G · 100.0% → $400.00 · finish
- G · 100.0% → $200.00 · next wager $200.00
Every possible runout
| Draw sequence | Probability | Terminal bankroll |
|---|---|---|
| ● G → ● G → ◆ R | 33.3% | $100.00 |
| ● G → ◆ R → ● G | 33.3% | $200.00 |
| ◆ R → ● G → ● G | 33.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:
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 . 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 be the first dollar bet and the dollar bet after a first green:
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.
Hence
with equality at . 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.
━ 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 . The second bet is one-third of the $150 bankroll after a green. Every branch ends at $200:
A higher guarantee is impossible. Requiring all three endings to be at least gives
The first-bet-only strategy () 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 : the endings are $300, $200, and $150, averaging $216⅔.
More generally, for a floor ,
Each extra dollar of guarantee costs one-third of a dollar in average bankroll.
| Goal | 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 , so . Choosing produces endings , , and . For , their minimum is : 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 : remaining greens, reds, blues, and draws. Write and wager a fraction of bankroll .
Wealth scales linearly, so memoize only the count state. Let and be optimal expected and guaranteed wealth per starting dollar. Both equal 1 when or the bag is empty. Subscripts denote continuation values after the corresponding draw.
Expected wealth
This is affine in . Its slope determines the wager:
The implementation chooses zero on ties. Blue contributes current wealth and terminates; it has no continuation value.
Guaranteed wealth
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
when this lies in . For one green, one red, and two draws, and :
↔ Scroll each chart sideways to see its full range.
The mean decreases from the outset; the guarantee peaks at . 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 ,
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
Thus zero is optimal while a red remains. The zero-bet recurrence agrees with the formula, as do the boundaries and . Induction proves the result.
Limited horizon, no blue: maximum guarantee
For , define . Then
Out-of-range binomial coefficients are zero. The original game has , giving and .
The number of greens disappears once the horizon is feasible. Extra greens improve probabilities but leave the worst feasible sequences unchanged when stay fixed.
Proof via the reciprocal recurrence
If , an all-red prefix is possible and . If , all draws are green and . Otherwise both colors exist. Inductively, their continuation guarantees satisfy . Equalizing the branches gives
Pascal’s identity gives , completing the induction. Substitution into gives the stated wager fraction.
Code and checks
Download pikurn.ts and run it with Node.js 24.2 or newer:
node pikurn.tsNo 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: 1console.table(enumerate(state, s => solve(s, 'expected').fraction, 100))Read the complete solver and command-line program
/** 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) => numberexport 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 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
- azh-shower-thought.webp (13.6 KB)
- DecisionGeometry.astro (5.1 KB)
- game-pikurn.mdx (10.4 KB)
- pikurn-article.css (2.7 KB)
- pikurn-explorer.css (7.4 KB)
- pikurn-explorer.ts (11.7 KB)
- The $200 guarantee — editable SVG diagram (2.4 KB)
- Pikurn risk and certainty — cover illustration (2.1 MB)
- pikurn-snapshots.ts (4.9 KB)
- Pikurn solver — runnable TypeScript (7.3 KB)
Dynamic programming, runout enumeration, and a standalone command-line program.
- PikurnExplorer.astro (5.4 KB)
- wager-lab.ts (3.0 KB)
- WagerLab.astro (6.0 KB)