In The Incidence Reduction, we showed that running an ODE with uniform rates on a tic-tac-toe analysis net recovers the strategic hierarchy center=4, corner=3, edge=2 purely from topology. The values are incidence degrees — arc counts to terminal transitions — and the ODE recovers them without game-specific heuristics.
That post noted that Texas Hold'em falls on the other side of the boundary: competing resource flows produce genuinely dynamic values, and the ODE earns its keep. It left a question open: can the same technique produce the hand ranking itself from network structure?
Partly. We build a hand analysis net whose drain counts encode how common each hand is, run the ODE, and the poker order comes out — straight flush at the top, high card at the bottom. But the order goes in with the drain counts, and the ODE hands it back as 1/drains; what the net adds is a mechanical, inspectable path from a frequency table to payout multipliers. We then prove every game action with Groth16 ZK proofs and export the multipliers into a generated Solidity contract. All of it is one Rust example, zk_holdem.rs in pflow-rs; the contract template is render_holdem_contract in solidity_export.rs. Nothing below was re-run for this revision; the counts and formulas are read from those two files.
Prerequisites: The Incidence Reduction for the analysis-net construction, ZK Petri Nets for the transition circuit.
Each of the nine hand categories gets a source place src_H holding one token, a value place val_H starting empty, a play_H transition that reads the source catalytically (consumes and returns its token) and puts one into val_H, and N drain transitions that each consume from val_H. How common the hand is becomes how many ways out of its accumulator there are (build_hand_analysis_net in the example).
The drain counts are hand-picked to be monotone in the 5-card combination counts. The code comment calls them log-scaled, but they aren't a fixed log or power transform of those counts — one pair to two pair is a factor of 8.9 in combinations and 1.5 in drains, high card to one pair is 1.19 in combinations and 1.33 in drains. The ODE value column is 32/drains, because the example normalizes every value by the smallest (high card's 1/32):
| Hand | 5-Card Combos | Drains | ODE Value |
|---|---|---|---|
| Straight Flush | 40 | 1 | 32.0 |
| Four of a Kind | 624 | 2 | 16.0 |
| Full House | 3,744 | 4 | 8.0 |
| Flush | 5,108 | 5 | 6.4 |
| Straight | 10,200 | 8 | 4.0 |
| Three of a Kind | 54,912 | 12 | 2.7 |
| Two Pair | 123,552 | 16 | 2.0 |
| One Pair | 1,098,240 | 24 | 1.3 |
| High Card | 1,302,540 | 32 | 1.0 |
The full net has 18 places and 113 transitions (9 play + 104 drain).
At equilibrium under mass-action kinetics, inflow equals outflow for each value place:
rate * [src_H] = num_drains * rate * [val_H]
Since src_H is catalytic (always 1) and rates are uniform:
val_H = 1 / num_drains
Rare hands have fewer drains, accumulate more, and score higher; common hands drain fast and score low. That is the whole computation — each accumulator is its own decoupled ODE, so the paper version proves the equilibrium is an exact reciprocal and no solver is needed. The example runs Tsit5 to equilibrium anyway (hand_integer_reduction). So the ranking is determined by topology only in the sense that the topology was written from the frequency table: the frequency encoding is the poker knowledge, and the ODE reads it back.
The mechanism is tic-tac-toe's, inverted: there, more connections to win transitions meant higher value, so we inverted the concentration; here fewer drains mean higher value and the raw concentration already has the right order. The difference that matters is where the counts come from. The tic-tac-toe drain counts are read off the game — a cell drains into each win line that passes through it — while the hold'em drain counts are typed in. Nothing in the net counts poker hands.
The game itself runs on a separate Petri net (18 places, 16 transitions; build_game_net — its section comment says 17, the code adds 16) that models:
player_turn and house_turn places enforce alternationplayer_chips(100), house_chips(100), pot(0) as token countsplayer_wins, house_wins, game_over as terminal placesSome of the game flow is enforced structurally. A player can't bet when it's the house's turn — player_turn has no tokens. A call can't fire without bet_open. Dealing the next street requires round_complete. Other rules live in the Rust driver, not the net: nothing inhibits a check or a second bet while bet_open holds a token, so after a house bet the net still enables p_check and p_bet; and at showdown resolve_player_win, resolve_house_win and resolve_draw are all enabled from phase_showdown — evaluate_hand, outside the net, picks which one fires. None of the resolve transitions moves the pot; payouts happen in the driver's chip counters.
Each game action fires a transition and produces a Groth16 proof over BN254 — 128 bytes in arkworks' compressed serialization (prover.rs), with the pre-state root, post-state root and transition id as public inputs. The timings below are from one run, rounded to whole milliseconds (the example prints one decimal); the run wasn't preserved and the numbers are machine-dependent:
ZK: deal | prove 53ms | verify 0.8ms | 128B | VALID
ZK: p_check | prove 54ms | verify 0.8ms | 128B | VALID
ZK: h_check | prove 54ms | verify 0.8ms | 128B | VALID
ZK: deal_flop | prove 55ms | verify 0.8ms | 128B | VALID
ZK: p_bet | prove 54ms | verify 0.8ms | 128B | VALID
ZK: h_call | prove 54ms | verify 0.8ms | 128B | VALID
...
ZK: showdown | prove 54ms | verify 0.8ms | 128B | VALID
ZK: resolve_... | prove 55ms | verify 0.8ms | 128B | VALID
The example verifies each proof on its own. The chaining is in the generated contract: playerAction and houseAction revert unless the proof's pre-state root equals the stored root, then store its post-state root, so the on-chain sequence of Poseidon roots has to be unbroken. The 0.8 ms is arkworks verifying off-chain; what a Groth16 verifier on-chain costs is gas, and we haven't measured it. The contract also doesn't yet tie the proof to the call: it never compares the action argument or the caller against the proven transition id, so a valid proof of h_call could be submitted as a player check.
The game net is smaller than the ZK tic-tac-toe net (16 transitions against 35), which keeps the circuit small. The circuit is a transition multiplexer — every transition's arcs baked in as constants, one boolean selector per transition, exactly one active (circuit.rs in pflow-zk-arkworks) — so one proving key covers the whole net. Groth16 setup for it took ~46 ms in the same unpreserved run.
Card dealing uses commit-reveal with Poseidon hashing:
Poseidon(seed) as a commitmentshuffle_deck)deck[0..2] player, deck[2..4] house, deck[4..9] communityPoseidon(seed) == commitment and replay the shuffleThe commitment is binding — the house can't change the deck after seeing how the hand unfolds. That is as far as it goes. The house picks the seed alone, so it knows every card from the start and could try seeds until it likes the deal before committing; and the commitment is an unsalted hash of a clock reading, so it hides the deck only as well as the clock time is unguessable. We haven't tried to recover a seed from a commitment. The stackedup.gg version addresses both: a 32-byte server seed, and a client seed mixed into every hand's key.
The generated contract is behind the protocol. revealAndSettle does not check the seed against the commitment yet — the template says so in a comment ("For now, trust the ZK proof chain") — and it takes both hand ranks as caller-supplied arguments. claimTimeout was meant to let the player take the pot if the house never reveals, but with no reveal revealDeadline stays 0, so the claim opens as soon as two actions are recorded and the chain is past block 100 rather than after a timeout. Settlement emits a GameOver event; no funds move.
The incidence reduction values become payout multipliers in the Solidity contract. The winner's settlement is the pot plus a bonus proportional to hand strength (from the template; the contract's comment calls the small blind the ante):
uint256 bonus = HAND_STRENGTH[playerRank] * SMALL_BLIND;
HAND_STRENGTH is the ODE output rounded to integers, high card first: [1, 1, 2, 3, 4, 6, 8, 16, 32]. A straight flush pays 32 small blinds as bonus; high card and one pair both round to 1, so the contract can't tell them apart in the bonus. No game designer tuned these numbers directly, but they are 32/drains and the drains were chosen by hand, so the design choice moved from the multipliers to the drain counts rather than disappearing.
The house AI is deterministic: it looks up its hand category's (unrounded) strength and applies fixed thresholds plus a pot-odds check (house_decision). It is a heuristic, not an optimal strategy. Determinism means the house's moves could be checked after the seed is revealed, since anyone can recompute its cards and replay the rule; the generated contract doesn't do that — it never sees the cards and accepts any proven house transition. Its header comment says house strategy is "enforced on-chain"; that isn't implemented.
The implementation is a single Rust example in pflow-rs, crates/pflow/examples/zk_holdem.rs, which needs the zk-arkworks feature:
# AI vs AI demo
cargo run --example zk_holdem -p pflow --features zk-arkworks --release -- --demo
# Interactive: human vs house
cargo run --example zk_holdem -p pflow --features zk-arkworks --release
# Export Solidity contracts
cargo run --example zk_holdem -p pflow --features zk-arkworks --release -- --export-solidity ./contracts/
The --export-solidity flag generates Groth16Verifier.sol and ZKHoldem.sol with the verifying key, initial state root, and hand strength values embedded.
This is the second game where we read values off an analysis net's topology:
| Game | What Reduction Recovers | Net Size |
|---|---|---|
| Tic-Tac-Toe | Position value: center=4, corner=3, edge=2 | 18p x 33t |
| Hold'em | Hand ranking: SF=32, 4K=16, ..., HC=1 | 18p x 113t |
In both, a uniform-rate ODE returns integer-like ratios, but they don't carry the same weight. Tic-tac-toe's are counted from the game's win lines, so they're a finding about tic-tac-toe (absorbing the search tree). Hold'em's are reciprocals of a hand-made table, so they show the construction works and nothing more about poker. A hold'em analysis net that earned its values would build its drains from the cards — something like the 74-place hand classifier in Building ZK Poker, which detects pairs, trips, quads and straight flushes from card places — and we haven't connected the two.
The question from The Incidence Reduction was where the boundary lies between pure counting and genuine dynamics. Hand ranking sits on the counting side: the drains are independent sinks with no shared upstream resources, so the equilibrium values are exactly 1/drains. That post put hold'em play on the dynamics side — competing chip flows, conditional phases, betting — but this example doesn't test it: the game net is only ever fired and proven, never simulated. And the proof system covers only the game net. The analysis net's output enters the contract as nine constants, and nothing proves they came from the ODE.
2026-10-13. Checked against zk_holdem.rs and solidity_export.rs in pflow-rs (read, not re-run). The net sizes, the 32/drains table and the [1, 1, 2, 3, 4, 6, 8, 16, 32] multipliers all match the code. Corrected: the drain counts were called log-scaled, which no fixed log transform bears out, and the ranking was said to emerge from topology when the drains were chosen in ranking order; the game net was said to make invalid sequences impossible, when it allows a check or second bet facing a bet and leaves the showdown winner to Rust code; the proof chain is enforced by the contract, not the example, and 0.8 ms is off-chain verification, not on-chain cost; the contract was said to verify the shuffle commitment, enforce a timeout and check that the house "played optimally", and it does none of these yet; the bonus formula is HAND_STRENGTH * SMALL_BLIND; the shuffle seed is a clock reading chosen by the house alone. The proof timings and the 46 ms setup are now marked as one unpreserved run.
Related: The Incidence Reduction · Paper: Incidence Reduction via Petri Net ODE Equilibrium · Texas Hold'em Model · ZK Petri Nets · Building ZK Poker · Chapter 13: Topology-Driven Verification