Poker-scoring roguelikes look like a pile of special cases: every joker or charm bends the rules a little. Most of that goes away with a few structural choices, so here’s how I structured it in plain GDScript, in case it helps anyone making a card game.
1. A poker hand is a pattern, not a function
Instead of writing is_full_house(), is_flush() and so on, each hand type is a resource with two things: a list of same-rank group sizes and two flags.
Pair: groups = [2]
Two Pair: groups = [2, 2]
Full House: groups = [3, 2]
Straight Flush: groups = [], needs_straight = true, needs_flush = true
Flush Five: groups = [5], needs_flush = true
The evaluator counts cards by rank, sorts the groups biggest first, and checks the hands from best to worst: does the group list fit the pattern, and is there a flush or straight if the hand needs one? The first match wins. Adding a new hand type (the three secret ones, say) is a new .tres file, no code. Rules that bend hands (“flushes and straights need only 4 cards”, “straights can skip one rank”) are just parameters passed into the same two helper functions.
2. One fixed scoring order, recorded as events
Scoring always runs in the same order, so players can learn it and charm order matters in a predictable way:
- “before” charms (charms that grow over a run grow here)
- the hand’s base Points and Mult, by its level
- each scoring card, left to right: its Points, its upgrade, then charms that fire on scored cards; a retrigger repeats that card’s step
- each card held in hand: charms that fire on held cards
- charms that fire on the whole hand, left to right
- total = Points x Mult
The scorer is a plain RefCounted class that never touches the screen. Every step appends an event: which card or charm fired, what it added, and the running Points and Mult after it. The table then plays the list back with pop-ups, sounds and a count-up. Because each event carries the running totals, the animation can never disagree with the real score, and the tests can check scoring without a scene at all.
3. Charms are data first, code second
A charm resource says when it fires (before, scored card, held card, whole hand, discard, end of round, or a passive rule), when it applies (a suit, certain ranks, face cards, “hand contains a pair”, first or last hand, a chance) and what it does (+Points, +Mult, xMult, money, retrigger), plus optional scaling like “+1 Mult per $4”. One generic function applies all of that, which covers 39 of the 40 included charms.
For anything that doesn’t fit, a charm can point at a small script that overrides one hook:
extends CharmBehavior
## Five Alive: x1.75 Mult when five cards score.
func on_hand(ctx: ScoreContext, charm: CharmState) -> void:
if ctx.scoring.size() >= 5:
ctx.add_xmult(1.75, charm)
The hooks run at the same points in the scoring order as data charms, so code charms and data charms mix freely.
4. Seeded everything, and a bot to check it
Every random choice (shuffles, shop rolls, packs) comes from the run’s own seeded RNG, and its state is saved with the run. That gives you seeded runs and save-and-resume anywhere, including mid-round. A simple bot plays whole runs headless, and the stress tests use it to check that saving and resuming at a random point changes nothing.
I’ve packaged this up as Full House, a complete card roguelike template for Godot 4: 12 poker hands, 40 charms, 8 antes with boss rules, a shop with packs and rerolls, 6 decks and 5 difficulty levels, mouse, keyboard and gamepad, and a demo export preset:
There’s a free browser demo if you want to see the scoring play out: Full House - Playable Demo by Spare Parts Studio
Happy to answer questions about any of it.