Solutions
Concrete proposals for the simulation engine. Each solution references specific problems, provides a storyboard, and answers: what do you get?
The Game: Poker Mario Kart
Poker with items. You play poker, but every round you roll dice to get items that mess with your opponent's hand, apply status effects, and create chaos. Player who's behind gets better items (Mario Kart rubber-banding). First player to bust out of chips loses.
This is the complete game. Not abstract patterns — the actual code, start to finish.
1. The Game Class and PlayerData
# poker_mario_kart.gdclass_namePokerMarioKartextendsGameconst STARTING_CHIPS := 100
const ANTE := 5
const HAND_SIZE := 5
const MAX_DISCARD := 3
# ── PlayerData contract ──classPlayerData:
var chips: ChipStack# how many chips they havevar hand: PokerHand# their 5 cardsvar items: ItemBag# items they can playvar effects: EffectList# active status effects on themfunccreate_player(pid: String) -> PlayerData:
var pd = PlayerData.new()
pd.chips = ChipStack.new(pid)
pd.chips.amount = STARTING_CHIPS
pd.hand = PokerHand.new(pid)
pd.items = ItemBag.new(pid)
pd.effects = EffectList.new(pid)
return pd
funcget_event_types() -> Array:
return [UseItemsEvent, DrawEvent]
funcget_initial_state() -> GameState:
return AnteState.new()
var _round: int = 0
var _pot: int = 0
var _deck: PokerDeck# ── Trigger system: no event bus. Just a loop. ──enumTrigger { ROUND_START, BEFORE_SHOWDOWN, AFTER_SHOWDOWN, ROUND_END }
funcrun_trigger(trigger: Trigger):
for pid in player_ids:
for effect in player(pid).effects.get_for_trigger(trigger):
effect.execute(self, pid)
functick_durations():
for pid in player_ids:
player(pid).effects.remove_expired()
funcget_opponent(pid: String) -> String:
for id in player_ids:
if id != pid: return id
return pid
No event bus. No fire_event. No pub/sub. The game knows exactly where it is in the loop. At each point, run_trigger() asks: "what effects listen to this trigger?" Loops over them. Calls them. Done. Status effects store their trigger — we just read it.
2. Every Game State
The complete round loop: Ante → Roll for Items → Deal → Play Items → Draw/Discard → Showdown → Round End → repeat or Game Over.
# ── AnteState: everyone puts chips in ──classAnteStateextendsGameState:
func_on_enter():
game._round += 1
game._pot = 0
for pid in game.player_ids:
var ante = mini(game.ANTE, game.player(pid).chips.amount)
game.player(pid).chips.amount -= ante
game._pot += ante
game.log("Round %d — Ante %d each, pot %d" % [game._round, game.ANTE, game._pot])
funcget_next_state(): return ItemRollState.new()
# ── ItemRollState: roll dice, get items. Rubber-banding: behind = better items. ──classItemRollStateextendsGameState:
func_on_enter():
for pid in game.player_ids:
var roll = randi_range(1, 6)
var standing = _get_standing(pid)
var item = ItemTable.roll(roll, standing)
if item:
game.player(pid).items.add(item)
game.log("%s rolls %d → %s" % [pid, roll, item.item_name])
else:
game.log("%s rolls %d → nothing" % [pid, roll])
func_get_standing(pid) -> ItemTable.Standing:
var my = game.player(pid).chips.amount
var opp = game.player(game.get_opponent(pid)).chips.amount
if my < opp - 20: return ItemTable.Standing.BEHIND
if my > opp + 20: return ItemTable.Standing.AHEAD
return ItemTable.Standing.EVEN
funcget_next_state(): return DealState.new()
# ── ItemPlayState: both players choose items simultaneously ──classItemPlayStateextendsGameState:
var _subs: Dictionary = {}
funchandle_player_input(pid, event):
if event isUseItemsEvent:
_subs[pid] = event.chosen_items
funcget_next_state():
if _subs.size() < game.player_ids.size(): return null
_resolve_items()
return DrawState.new()
func_resolve_items():
# Collect all plays, sort by priority (shields first)var plays = []
for pid in game.player_ids:
for item in _subs[pid]:
plays.append({"owner": pid, "item": item})
plays.sort_custom(func(a, b): return a.item.priority < b.item.priority)
for play in plays:
var target = play.item.get_target(game, play.owner)
# Check shield: does target have one?if target != play.owner and game.player(target).effects.is_shielded():
game.player(target).effects.consume_shield()
game.log("%s's %s blocked!" % [play.owner, play.item.item_name])
continue
play.item.on_play(game, play.owner)
# ── DrawState: both players choose cards to discard/redraw ──classDrawStateextendsGameState:
var _subs: Dictionary = {}
funchandle_player_input(pid, event):
if event isDrawEvent:
_subs[pid] = event.discard_indices
funcget_next_state():
if _subs.size() < game.player_ids.size(): return nullfor pid in game.player_ids:
var discarded = game.player(pid).hand.discard(_subs[pid])
game.player(pid).hand.add_cards(game._deck.draw(discarded.size()))
game.log("%s discards %d, draws %d" % [pid, discarded.size(), discarded.size()])
game.run_trigger(Trigger.BEFORE_SHOWDOWN)
return ShowdownState.new()
# ── ShowdownState: evaluate hands, award pot ──classShowdownStateextendsGameState:
func_on_enter():
var hands = {}
for pid in game.player_ids:
hands[pid] = HandEvaluator.evaluate(game.player(pid).hand.cards)
game.log("%s: %s" % [pid, hands[pid].rank_name])
var p1 = game.player_ids[0]
var p2 = game.player_ids[1]
var winner = HandEvaluator.compare(hands[p1], hands[p2])
if winner == 0:
game.player(p1).chips.amount += game._pot / 2
game.player(p2).chips.amount += game._pot / 2
game.log("Split pot!")
else:
var wid = p1 if winner == 1 else p2
game.player(wid).chips.amount += game._pot
game.log("%s wins %d chips with %s!" % [wid, game._pot, hands[wid].rank_name])
game.run_trigger(Trigger.AFTER_SHOWDOWN)
funcget_next_state(): return RoundEndState.new()
# ── RoundEndState: tick durations, check bust, loop or end ──classRoundEndStateextendsGameState:
func_on_enter():
game.run_trigger(Trigger.ROUND_END)
game.tick_durations()
for pid in game.player_ids:
game.player(pid).hand.return_to_deck()
funcget_next_state():
for pid in game.player_ids:
if game.player(pid).chips.amount <= 0:
return GameOverState.new()
return AnteState.new()
# ── GameOverState ──classGameOverStateextendsGameState:
func_on_enter():
for pid in game.player_ids:
if game.player(pid).chips.amount > 0:
game.log("GAME OVER — %s wins with %d chips!" % [pid, game.player(pid).chips.amount])
game.game_completed.emit()
3. Items
An item has on_play() — it executes immediately. It might modify hands, steal cards, or apply a persistent effect. That's all an item does.
# items/banana_peel.gd — Opponent discards 1 random card, draws replacementclass_nameBananaPeelextendsItemfunc_init(): item_name = "Banana Peel"; priority = 50
funcon_play(game, source_id):
var opp = game.get_opponent(source_id)
var hand = game.player(opp).hand
var idx = randi_range(0, hand.size() - 1)
hand.discard([idx])
hand.add_cards(game._deck.draw(1))
game.log("Banana Peel! %s loses a card." % opp)
# items/red_shell.gd — Steal opponent's best cardclass_nameRedShellextendsItemfunc_init(): item_name = "Red Shell"; priority = 50
funcon_play(game, source_id):
var opp = game.get_opponent(source_id)
var stolen = game.player(opp).hand.remove_highest()
game.player(opp).hand.add_cards(game._deck.draw(1))
game.player(source_id).hand.replace_lowest(stolen)
game.log("Red Shell! %s steals %s" % [source_id, stolen])
# items/star.gd — Shield: immune to items this roundclass_nameStarextendsItemfunc_init(): item_name = "Star"; priority = 10 # resolves firstfuncget_target(game, source_id): return source_id
funcon_play(game, source_id):
game.player(source_id).effects.add(ShieldEffect.new())
game.log("%s activates Star! Immune this round." % source_id)
# items/poison_mushroom.gd — Opponent loses chips each roundclass_namePoisonMushroomextendsItemfunc_init(): item_name = "Poison Mushroom"; priority = 50
funcon_play(game, source_id):
var opp = game.get_opponent(source_id)
game.player(opp).effects.add(PoisonEffect.new(3, 3))
game.log("%s is poisoned! -3 chips/round for 3 rounds." % opp)
# items/lightning.gd — Opponent's best card becomes their worstclass_nameLightningextendsItemfunc_init(): item_name = "Lightning"; priority = 50
funcon_play(game, source_id):
var opp = game.get_opponent(source_id)
var hand = game.player(opp).hand
hand.replace_highest_with_lowest()
game.log("Lightning! %s's best card destroyed." % opp)
# items/mushroom.gd — Draw 2 extra, keep best 5class_nameMushroomextendsItemfunc_init(): item_name = "Mushroom"; priority = 50
funcget_target(game, source_id): return source_id
funcon_play(game, source_id):
game.player(source_id).hand.add_cards(game._deck.draw(2))
game.player(source_id).hand.keep_best(HAND_SIZE)
game.log("Mushroom! %s draws 2, keeps best 5." % source_id)
# items/green_shell.gd — Dice roll: ≥4 = opponent loses a cardclass_nameGreenShellextendsItemfunc_init(): item_name = "Green Shell"; priority = 50
funcon_play(game, source_id):
var roll = randi_range(1, 6)
game.log("%s throws Green Shell, rolls %d..." % [source_id, roll])
if roll >= 4:
var opp = game.get_opponent(source_id)
game.player(opp).hand.discard_random(1)
game.player(opp).hand.add_cards(game._deck.draw(1))
game.log("Hit! %s loses a card." % opp)
else:
game.log("Missed!")
4. Persistent Effects (Status Effects)
A persistent effect has a trigger it listens for, an execute method, and a duration. That's it. Items apply them. The game loop reads them at each trigger point. No event bus.
# effects/poison.gd — Lose chips each roundclass_namePoisonEffectextendsPersistentEffectvar chip_loss: intfunc_init(loss: int, duration: int):
chip_loss = loss; remaining_duration = duration
funcget_trigger(): return Trigger.ROUND_START
funcexecute(game, owner_id):
game.player(owner_id).chips.amount -= chip_loss
game.log("Poison: %s loses %d chips (%d left)" % [owner_id, chip_loss, remaining_duration])
# effects/cursed.gd — Best card replaced before showdownclass_nameCursedEffectextendsPersistentEffectfunc_init(duration: int):
remaining_duration = duration
funcget_trigger(): return Trigger.BEFORE_SHOWDOWN
funcexecute(game, owner_id):
var hand = game.player(owner_id).hand
hand.remove_highest()
hand.add_cards(game._deck.draw(1))
game.log("Curse: %s's best card was replaced!" % owner_id)
# effects/shield.gd — Blocks next item (consumed by ItemPlayState)class_nameShieldEffectextendsPersistentEffectfunc_init(): remaining_duration = 1
funcget_trigger(): return Trigger.ROUND_END # doesn't trigger; consumed by item resolution# effects/lucky.gd — Permanent: always roll +1 on item diceclass_nameLuckyEffectextendsPersistentEffectfunc_init(): remaining_duration = -1 # permanentfuncget_trigger(): return Trigger.ROUND_START # checked by ItemRollStatefuncexecute(game, owner_id):
game.log("Lucky: %s gets +1 to item roll" % owner_id)
Items and persistent effects are the only two classes. An item has on_play() — executes immediately, might apply a persistent effect. A persistent effect has get_trigger(), execute(), and tick(). Duration = -1 means permanent (relic-like). That's the whole system. No third class needed.
5. EffectList: Where Effects Live
# data/effect_list.gd — Per-player storage for active effectsclass_nameEffectListextendsNetworkGameDatavar _effects: Array[PersistentEffect] = []
funcadd(effect: PersistentEffect):
_effects.append(effect)
funcget_for_trigger(trigger: Trigger) -> Array:
return _effects.filter(func(e): return e.get_trigger() == trigger)
funcis_shielded() -> bool:
return _effects.any(func(e): return e isShieldEffect)
funcconsume_shield():
for i in range(_effects.size() - 1, -1, -1):
if _effects[i] isShieldEffect:
_effects.remove_at(i)
returnfuncremove_expired():
_effects = _effects.filter(func(e): return not e.tick())
6. Dice and Rubber-Banding
Mario Kart's secret: the player in last place gets the best items. Same here. Behind in chips? Your dice roll gets boosted.
# items/item_table.gdclass_nameItemTableenumStanding { AHEAD, EVEN, BEHIND }
const COMMON = [BananaPeel, GreenShell]
const UNCOMMON = [RedShell, Mushroom, PoisonMushroom]
const RARE = [Star, Lightning]
static funcroll(die: int, standing: Standing) -> Item:
# Rubber-banding: behind players get upgraded rollsvar effective = die
match standing:
Standing.BEHIND: effective = mini(die + 2, 6)
Standing.EVEN: effective = mini(die + 1, 6)
Standing.AHEAD: effective = die
var pool: Arrayif effective <= 2: pool = COMMON
elif effective <= 4: pool = UNCOMMON
else: pool = RARE
return pool[randi() % pool.size()].new()
7. Adding a New Item
Want a new item? One file. Drop it in items/. Add it to the item table tier. Done.
# items/blooper.gd — Opponent plays blind (can't see their own cards)class_nameBlooperextendsItemfunc_init(): item_name = "Blooper"; priority = 50
funcon_play(game, source_id):
var opp = game.get_opponent(source_id)
game.player(opp).effects.add(BlindEffect.new(1))
game.log("Blooper! %s can't see their cards!" % opp)
# effects/blind.gd — Cards hidden from owner (visibility = NON_OWNER)class_nameBlindEffectextendsPersistentEffectfunc_init(duration: int): remaining_duration = duration
funcget_trigger(): return Trigger.ROUND_END # just lasts; AI ignores hand info
That's the whole game. Poker rules, item system, persistent effects, dice with rubber-banding, simultaneous resolution. One game class, 8 states, 7 items, 4 persistent effects. Every item is one file. Every effect is one file. The game loop is explicit — you can read it top to bottom and know exactly what happens.
Backend: How the Game Stays in Sync
The first storyboard shows the game — what players do, what states fire, what items and effects exist. This storyboard shows what's underneath: how data gets from the host to every client, who owns what, who can write what, and how the timeline records it all for replay. Same game. Different lens.
1. The Players in the System
A Poker Mario Kart match has these actors:
# One Host (runs game logic, owns the truth)
Host: authority over all game data
- Runs the state machine (Ante → ItemRoll → Deal → ... → GameOver)
- Modifies PlayerData directly (chips, hand, items, effects)
- Records every state change to the timeline
# Two Sim Clients (one per player — observe the game for replay/UI)
SimClient "player_1": sees player_1's data as OWNER, player_2's as NON_OWNER
SimClient "player_2": sees player_2's data as OWNER, player_1's as NON_OWNER
# Two AI Players (submit input events — cannot modify game data directly)
AI "player_1": observes game state, submits UseItemsEvent / DrawEvent
AI "player_2": same
Key insight: The host is the only thing that writes game data. AIs submit input events through the timeline. The host reads those events and decides what happens. Clients never touch the registry directly.
2. What Gets Registered Where
There are three registries per match. Each is an AuthoritativeRegistry — a flat key-value store where keys are "script_path::owner_id".
# ── Registry 1: Host Game Data Registry ──# The single source of truth for all game state.# Authority: host_id ("player_1")"res://game/chip_stack.gd::player_1" → ChipStack { amount: 95 }
"res://game/chip_stack.gd::player_2" → ChipStack { amount: 105 }
"res://game/poker_hand.gd::player_1" → PokerHand { cards: [A♠, K♠, Q♠, J♠, 10♠] } # owner-only!"res://game/poker_hand.gd::player_2" → PokerHand { cards: [2♣, 7♦, 4♥, 9♠, 3♦] } # owner-only!"res://game/item_bag.gd::player_1" → ItemBag { items: [RedShell] }
"res://game/item_bag.gd::player_2" → ItemBag { items: [Star] }
"res://game/effect_list.gd::player_1" → EffectList { effects: [] }
"res://game/effect_list.gd::player_2" → EffectList { effects: [PoisonEffect(3, 2)] }
# ── Registry 2: Host Timeline Registry ──# Stores the event log and player input timelines.# Authority: host_id"res://timeline/global_timeline.gd::player_1" → GlobalTimeline { event_log: [...] }
"res://timeline/player_timeline.gd::player_1" → PlayerTimeline { event_log: [UseItemsEvent, ...] }
"res://timeline/player_timeline.gd::player_2" → PlayerTimeline { event_log: [DrawEvent, ...] }
# ── Registry 3+: Sim Game State Registries (one per sim client) ──# Copies of game data, filtered by visibility.# These are populated by replaying StateChangeEvents from the timeline.
3. Ownership and Visibility: Who Sees What
Every NetworkGameData instance has a _data_owner. The pack() and unpack() methods take a Visibility parameter that controls what gets serialized.
# poker_hand.gd — the cards in a player's handclass_namePokerHandextendsNetworkGameDatavar cards: Array = [] # The actual cards — secret!var card_count: int = 0 # How many cards — publicfuncpack(buffer: StreamPeerBuffer, visibility: Visibility):
buffer.put_32(card_count) # always sentif visibility == Visibility.OWNER:
buffer.put_32(cards.size()) # only to ownerfor card in cards:
buffer.put_string(card.id) # only to ownerfuncunpack(buffer: StreamPeerBuffer, visibility: Visibility, _peer_id: String = "") -> bool:
var new_count = buffer.get_32()
var changed = new_count != card_count
card_count = new_count
if visibility == Visibility.OWNER:
var n = buffer.get_32()
cards.clear()
for i in n:
cards.append(Card.from_id(buffer.get_string()))
changed = truereturn changed
When the sync layer copies data from host to a client:
# InMemoryRegistrySync determines visibility from the key's owner_id# Syncing PokerHand owned by player_1:# → to SimClient "player_1": pack(OWNER) → gets cards + count# → to SimClient "player_2": pack(NON_OWNER) → gets count only# This is how poker hands stay secret. The opponent never receives# the card data — it's simply not in the byte stream.
4. The EffectList Problem: Who Owns It?
Here's the tension. Each player has an EffectList registered as "effect_list.gd::player_1". Under the current ownership model:
# Current ownership rules (from AuthoritativeRegistry.get_permission):## If key.owner_id == requesting_client_id → WRITE + READ# If key has explicit WRITE permission flag → WRITE + READ# Otherwise → READ only## EffectList for player_1 is keyed as "effect_list.gd::player_1"# So player_1 is the OWNER and gets WRITE access.## Problem: player_1 should NOT be able to write to their own EffectList.# They shouldn't be able to remove a Poison effect or add a Lucky effect.# Only the server (game logic) should modify effects.
The ownership model conflates two things: "who this data belongs to" (player_1's effects) and "who can write to it" (only the server). Right now, owner_id does both. If you own the data, you can write it. But for EffectList, ItemBag, ChipStack — the player has these things, but the server is the only one who should change them.
5. The Fix: Server-Owned Data with Player Association
Two approaches. Both work with the existing system.
Option A: Host Owns Everything (Minimal Change)
Register all game data with owner_id = host_id, not the player_id. Use a separate field to track which player the data is associated with.
# Instead of:
ChipStack.new("player_1") # owner_id = "player_1" → player can write!# Do:
ChipStack.new("host") # owner_id = "host" → only host can write
chip_stack.player_id = "player_1"# association, not ownership# Key becomes: "chip_stack.gd::host"# But now we need MULTIPLE ChipStacks with owner "host" — key collision!# The key is "script_path::owner_id" and we'd have two "chip_stack.gd::host".
This doesn't work. The key is script_path::owner_id. If both ChipStacks are owned by the host, they collide. We'd need a different key scheme. Doable but invasive.
Option B: Add Write Authority to Permission Model (Surgical)
Keep the existing key scheme (script_path::player_id). Add one concept: write authority. Data can specify that only the authority (host) can write, even if the player is the owner.
# Add to NetworkGameData — one method override:class_nameNetworkGameData# Override this to restrict writes to server only.# Default: false (owner can write, as today).funcis_server_authoritative() -> bool:
return false
# EffectList overrides it:class_nameEffectListextendsNetworkGameDatafuncis_server_authoritative() -> bool:
return true# Only the host can modify my effects# Same for ChipStack, ItemBag — server-authoritative.# PokerHand stays owner-writable (players submit draw choices).
# One change to AuthoritativeRegistry.get_permission():funcget_permission(key: AuthoritativeKey, requesting_client_id: String) -> int:
var instance = get_instance_with_key(key) asNetworkGameData# NEW: server-authoritative data — only authority can writeif instance and instance.is_server_authoritative():
if requesting_client_id == authority_id:
return AuthoritativeKey.Permission.WRITE | AuthoritativeKey.Permission.READ
return AuthoritativeKey.Permission.READ # everyone else: read-only# Existing logic for non-server-authoritative data:if key.owner_id == requesting_client_id:
return AuthoritativeKey.Permission.WRITE | AuthoritativeKey.Permission.READ
if key.permissions & AuthoritativeKey.Permission.WRITE:
return AuthoritativeKey.Permission.WRITE | AuthoritativeKey.Permission.READ
return AuthoritativeKey.Permission.READ
That's it. Three lines in get_permission() and one override per data class. The sync layer already calls get_permission() before allowing client writes — it's enforced in InMemoryRegistrySync._on_client_data_changed() and AuthoritativeRegistry.authorized_unpack_and_apply(). No new infrastructure needed.
What about visibility?is_server_authoritative() controls writes. Visibility controls reads. They're orthogonal. EffectList is server-authoritative (only host writes) but still uses owner-based visibility (each player sees their own effects in full, opponent effects as public data). Both players can see each other's effects — neither can change them.
6. The Complete Data Flow: One Round of Poker Mario Kart
Follow one complete round through the system. Every arrow is a real operation in the codebase.
# ═══════════════════════════════════════════════════# ROUND START: AnteState._on_enter()# ═══════════════════════════════════════════════════# Step 1: Host game logic modifies data
game.player("p1").chips.amount -= 5 # ante
game.player("p2").chips.amount -= 5
game._pot += 10
# Step 2: ChipStack._notify_data_changed() fires (from GameData base)# This emits the data_changed signal.# Step 3: Two listeners react to data_changed:# 3a. HostEventRecordingSync._on_host_data_changed()# → Packs ChipStack with Visibility.OWNER (full state)# → Creates StateChangeEvent("chip_stack.gd::p1", packed_bytes)# → Records it in TimelineManager → GlobalTimeline.event_log# → GlobalTimeline.data_changed fires# → SimClient timelines pick up the new event (live mode)# 3b. InMemoryRegistrySync._on_host_data_changed() (for each sim client)# → For SimClient "p1": pack(OWNER) → unpack into sim registry# → For SimClient "p2": pack(NON_OWNER) → unpack into sim registry# → is_unpacking=true prevents re-triggering data_changed# Step 4: Host runs trigger
game.run_trigger(Trigger.ROUND_START)
# → PoisonEffect on p2 fires: p2.chips.amount -= 3# → Same flow: data_changed → recording → sync# Step 5: Host transitions to ItemRollState
game.transition_to(ItemRollState.new())
# ═══════════════════════════════════════════════════# ITEM ROLL: ItemRollState._on_enter()# ═══════════════════════════════════════════════════# Step 6: Host rolls dice for each player, awards itemsvar p1_item = ItemTable.roll(randi() % 6 + 1, standing_of("p1"))
game.player("p1").items.add(p1_item)
# → ItemBag.data_changed → recording + sync (same flow)
game.transition_to(DealState.new())
# ═══════════════════════════════════════════════════# DEAL: DealState._on_enter()# ═══════════════════════════════════════════════════# Step 7: Host deals 5 cards to each player
game.player("p1").hand.set_cards(game._deck.draw(5))
# → PokerHand.data_changed → recording + sync# → SimClient "p1" gets OWNER visibility → sees their cards# → SimClient "p2" gets NON_OWNER visibility → sees card_count=5 only
game.transition_to(ItemPlayState.new())
# ═══════════════════════════════════════════════════# ITEM PLAY: ItemPlayState._on_enter()# ═══════════════════════════════════════════════════# Step 8: Host waits for both players to submit item choices# AI decides which items to use, submits via timeline:# AI player_1 side:var event = UseItemsEvent.new(["RedShell"])
sim_timeline_manager.record_local_event(event)
# → Event goes into PlayerTimeline for player_1# → PlayerTimeline.data_changed fires# → InMemoryRegistrySync propagates PlayerTimeline to host's timeline registry# → Host TimelineManager._on_player_timeline_changed() detects new event# → Emits player_input_received("player_1", UseItemsEvent)# → Host game.process_player_input() handles it# Step 9: Host resolves items# RedShell.on_play(game, "p1") → modifies p2's hand# → Checks p2.effects.is_shielded() first# → PokerHand.data_changed → recording + sync# ═══════════════════════════════════════════════════# SHOWDOWN: ShowdownState._on_enter()# ═══════════════════════════════════════════════════# Step 10: Host runs BEFORE_SHOWDOWN trigger
game.run_trigger(Trigger.BEFORE_SHOWDOWN)
# → LuckyEffect swaps p1's worst card (data_changed → recording → sync)# Step 11: Host compares hands, awards pot
game.player(winner).chips.amount += game._pot
# → ChipStack.data_changed → recording + sync# Step 12: Host runs AFTER_SHOWDOWN trigger
game.run_trigger(Trigger.AFTER_SHOWDOWN)
# ═══════════════════════════════════════════════════# ROUND END: RoundEndState._on_enter()# ═══════════════════════════════════════════════════# Step 13: Host runs ROUND_END trigger, ticks durations
game.run_trigger(Trigger.ROUND_END)
game.tick_durations()
# → PoisonEffect.tick() → duration 2→1 (or removed if 0)# → EffectList.data_changed → recording + sync# Step 14: Host transitions to next round or GameOver
game.transition_to(AnteState.new()) # or GameOverState
7. The Sync Workers: Who Does What
# For each match, NetworkService creates these sync workers:# 1. HostEventRecordingSync# Listens to: Host Game Data Registry (data_changed on every NetworkGameData)# Does: Packs changed data → creates StateChangeEvent → records in TimelineManager# Purpose: Timeline recording. Every data mutation becomes a replayable event.# 2. InMemoryRegistrySync (one per sim client, for timeline registry)# Listens to: Host Timeline Registry (GlobalTimeline + PlayerTimelines)# Does: Clones timeline data to each sim client's timeline registry# Purpose: Sim clients receive events for playback.# Also: Forwards PlayerTimeline changes FROM client TO host (input events)# Flow when host data changes:# Host Data Change# ├─→ HostEventRecordingSync → StateChangeEvent → GlobalTimeline# │ └─→ GlobalTimeline.data_changed# │ └─→ InMemoryRegistrySync (timeline) → Sim GlobalTimeline copy# │ └─→ SimClient TimelineManager picks up new events# │ └─→ SimClient applies StateChangeEvent to its game state registry# │# └─→ [Direct sync to sim game state registries is NOT done by sync workers.# Sim clients replay events from the timeline instead.]# Flow when AI submits input:# AI calls sim_timeline.record_local_event(UseItemsEvent)# └─→ PlayerTimeline.data_changed (on sim side)# └─→ InMemoryRegistrySync._on_client_data_changed()# └─→ Permission check: PlayerTimeline owned by player → WRITE allowed# └─→ Syncs to Host Timeline Registry's PlayerTimeline# └─→ Host TimelineManager._on_player_timeline_changed()# └─→ player_input_received signal# └─→ Host game.process_player_input()
8. EffectList Visibility in Practice
Here's exactly what each player sees for EffectList, and why.
# effect_list.gd — server-authoritative, fully publicclass_nameEffectListextendsNetworkGameDatavar _effects: Array = []
funcis_server_authoritative() -> bool:
return true# Only host can writefuncpack(buffer: StreamPeerBuffer, visibility: Visibility):
# Effects are PUBLIC — both players can see all active effects.# (You should know your opponent is poisoned. It's part of the game.)
buffer.put_32(_effects.size())
for effect in _effects:
buffer.put_string(effect.get_name())
buffer.put_32(effect.duration)
buffer.put_32(effect.get_trigger())
funcunpack(buffer: StreamPeerBuffer, visibility: Visibility, _peer_id: String = "") -> bool:
# Same data for OWNER and NON_OWNER — effects are fully publicvar count = buffer.get_32()
_effects.clear()
for i in count:
var name = buffer.get_string()
var dur = buffer.get_32()
var trigger = buffer.get_32()
_effects.append(_reconstruct_effect(name, dur, trigger))
return true# Result:# - Host modifies EffectList (add PoisonEffect, remove expired, etc.)# - Both sim clients receive the same effect data (fully public)# - Neither client can modify effects (is_server_authoritative = true)# - If a cheating client tries to modify their EffectList locally:# → data_changed fires → InMemoryRegistrySync._on_client_data_changed()# → get_permission() checks is_server_authoritative() → READ only# → "In-memory sync blocked write from client" → REJECTED
9. Permission Matrix for Poker Mario Kart
Data Class
Owner (player)
Opponent
Host (authority)
Server-Auth?
Rationale
ChipStack
READ
READ
WRITE
Yes
Only game logic awards/deducts chips
PokerHand
READ (full)
READ (count only)
WRITE
Yes
Host deals/modifies cards. Owner sees theirs, opponent sees count.
ItemBag
READ
READ
WRITE
Yes
Only game logic awards items from dice rolls
EffectList
READ
READ
WRITE
Yes
Only game logic adds/ticks/removes effects
PlayerTimeline
WRITE
—
READ
No
Players submit input events. Host reads them.
GlobalTimeline
READ
READ
WRITE
Yes
Only host records state change events
The pattern: In Poker Mario Kart, almost all game data is server-authoritative. Players can only observe game state and submit input through their PlayerTimeline. The host is the single writer for all game state. This is the authoritative server model — same as every competitive online game. The only thing that changes between games is which data is server-authoritative and which isn't.
10. Simplified Sync: What's Actually Needed
The current sync system has more complexity than our use case requires. Here's what each piece does and what we could simplify.
# CURRENT ARCHITECTURE:## BaseRegistrySync (abstract)# ├── InMemoryRegistrySync — host↔client sync for local/single-process# ├── HostEventRecordingSync — host data → StateChangeEvents in timeline# └── [RPCRegistrySync] — host↔client sync over ENet (in game/ codebase)## What each does:# InMemoryRegistrySync:# - Holds references to BOTH host and client registries# - Listens to data_changed on both sides# - Packs host → unpacks into client (with visibility filtering)# - Packs client → unpacks into host (with permission checking)## HostEventRecordingSync:# - Listens to host registry data_changed only# - Creates StateChangeEvent for every mutation# - Records in TimelineManager## RPCRegistrySync (game/ only, not in simulation/):# - Same as InMemory but serializes to PackedByteArray# - Sends over ENet via NetworkConnector# - Handles packet receipt, deserialization, permission checking
What Works Well
Signal-driven sync.data_changed fires, sync workers react. No polling. No manual sync calls.
Visibility filtering at pack time. Private data never leaves the host for non-owners. Can't leak what you never sent.
Permission checking at sync time. Client writes are validated before being applied to the host. The sync layer enforces the rules.
BaseRegistrySync connects to BOTH sides in the base class, but HostEventRecordingSync only uses the host side. The base class forces a shape that not all subclasses need.
Sync workers are Nodes (added to scene tree) but don't use _process() or _ready(). They're just signal listeners with state. Could be RefCounted.
The client→host sync path in InMemoryRegistrySync duplicates the permission logic from AuthoritativeRegistry. The sync worker checks get_permission() AND has_editable_variables_by_non_owner() separately, when authorized_unpack_and_apply() already does both.
NetworkService._process_subscription_request_on_host creates an InMemoryRegistrySync with null client registry — then it never syncs data. This is dead code for in-memory mode since we always pass a real client registry via subscribe_to_registry().
Proposed Cleanup
# Proposed: Simplify BaseRegistrySync to just the host-side contract.# Client binding is optional — subclasses opt in.# base_registry_sync.gd — SLIMMED DOWNclass_nameBaseRegistrySyncextendsRefCounted# No need for Node — no _process, no scene treevar _host_registry: AuthoritativeRegistryfunc_init(p_host_registry: AuthoritativeRegistry):
_host_registry = p_host_registry
_bind_to_host()
# Connect to all current + future data_changed signals on hostfunc_bind_to_host():
_host_registry.instance_registered.connect(_on_host_registered)
_host_registry.instance_unregistered.connect(_on_host_unregistered)
for key_str in _host_registry.get_all_keys():
var inst = _host_registry.get_instance_from_key_str(key_str)
var key = _host_registry.key_str_to_class(key_str)
(inst asGameData).data_changed.connect(
_on_host_data_changed.bind(inst, key))
# Subclasses override these:func_on_host_registered(inst, key): passfunc_on_host_unregistered(inst, key): passfunc_on_host_data_changed(inst, key): pass
# in_memory_registry_sync.gd — client binding added on topclass_nameInMemoryRegistrySyncextendsBaseRegistrySyncvar _client_registry: AuthoritativeRegistryvar _client_id: Stringfunc_init(host_reg, client_reg, client_id):
_client_registry = client_reg
_client_id = client_id
super(host_reg)
_bind_to_client() # Opt-in: only InMemory does this# Client→Host write path: use AuthoritativeRegistry.authorized_unpack_and_apply()# instead of duplicating permission checks here.func_on_client_data_changed(inst: NetworkGameData, key: AuthoritativeKey):
if inst.is_unpacking: returnvar vis = _visibility_for(key)
var buf = StreamPeerBuffer.new()
inst.pack(buf, vis)
buf.seek(0)
# One call — registry handles permission + unpack + is_unpacking
_host_registry.authorized_unpack_and_apply(key, buf, _client_id, vis)
func_visibility_for(key: AuthoritativeKey) -> NetworkGameData.Visibility:
if key.owner_id == _client_id:
return NetworkGameData.Visibility.OWNER
return NetworkGameData.Visibility.NON_OWNER
Key changes:
BaseRegistrySync → RefCounted, not Node. It's just a signal listener.
Base class only binds to host. Client binding is opt-in for InMemoryRegistrySync.
Client→host writes go through authorized_unpack_and_apply() instead of duplicating permission logic. One place, one check.
Same public API. NetworkService creates sync workers the same way. HostEventRecordingSync doesn't change at all.
RPCRegistrySync / ENet support unchanged. It would extend BaseRegistrySync and add its own serialization/deserialization over packets instead of in-memory references.
Constraint respected: All three transports (InMemory, RPC, ENet) continue to work. The BaseRegistrySync contract is simpler but not different. Sync workers still react to signals, still check permissions, still filter by visibility. We're removing duplication and dead code, not changing semantics.
11. Snapshot and Replay: The Full Picture
When you want to replay a match or seek to a specific point:
# A snapshot is every piece of data in the registry, packed with OWNER visibility.# It's the complete truth at a moment in time.var snapshot = game.game_data_registry.create_snapshot()
# Returns: { "chip_stack.gd::p1": PackedByteArray, "poker_hand.gd::p1": PackedByteArray, ... }# To replay from the start:
sim_timeline.seek_to(initial_snapshot, 0) # restore to round 0 state
sim_timeline.play_all() # step through events one by one# Each StateChangeEvent in the GlobalTimeline contains:# - target_key_str: "chip_stack.gd::p1"# - after_state_packed: PackedByteArray (the full state AFTER the change)## When stepping, the sim's game_state_registry calls:# registry.apply_state_change(event)# → Finds or creates the instance for that key# → Unpacks the after_state into it (with visibility based on local_client_id)# → The sim client now sees the updated state# This means:# SimClient "p1" replaying event for "poker_hand.gd::p2"# → visibility = NON_OWNER (p1 is not p2)# → unpack gets card_count only, not the actual cards# → Even in replay, p1 can't see p2's hand. Security is baked in.
What does it allow the player to do? Play a fair game. Your opponent can't cheat by modifying their effects or chips. Your hand stays secret. The server decides outcomes. Same trust model as any online poker room.
What does it enable you (the developer) to do? Add new data types and know they're automatically synced with correct permissions. Override is_server_authoritative() and pack()/unpack() — the sync layer handles the rest. Test sync correctness by comparing host snapshots to sim registries after a match.
What did you learn from making it? The real game's sync system is well-designed but has accumulated complexity (Node-based sync workers that don't use Node features, duplicated permission checks, dead code paths for null client registries). The core pattern — signal-driven, visibility-filtered, permission-checked — is solid. The cleanup is about removing duplication, not redesigning.
What does it mean for your game? You can trust the sync system. Add EffectList, ItemBag, ChipStack — mark them server-authoritative, write pack()/unpack(), done. The game logic runs on the host. Clients observe and submit input. Replays work automatically because every mutation is recorded. This is the backbone that makes everything else possible.
Solution 1: Auto-Serialization for NetworkGameData
Problem 1.2Problem 1.3 Manual pack/unpack with raw bytes. Silent corruption. Manual is_unpacking flag.
The Idea
Declare fields with visibility annotations. The engine auto-generates pack/unpack. No hand-written byte manipulation. No manual is_unpacking. Inspired by Protocol Buffers' field numbering and Unreal's UPROPERTY(Replicated) system. — Multiplayer Game Programming ch. 5 (Glazer & Madhav)
Before (today) — player_stats.gd
class_namePlayerStatsextendsNetworkGameDatavar hp: int = 30
var defense: int = 0
var mana: int = 3
funcpack(buffer, _vis):
buffer.put_32(hp)
buffer.put_32(defense)
buffer.put_32(mana)
funcunpack(buffer, _vis, _pid) -> bool:
var new_hp = buffer.get_32()
var new_def = buffer.get_32()
var new_mana = buffer.get_32()
var changed = new_hp != hp or ...
hp = new_hp
defense = new_def
mana = new_mana
if changed and not is_unpacking:
_notify_data_changed()
return changed
After — player_stats.gd
class_namePlayerStatsextendsNetworkGameData# Public to all players@export var hp: int = 30
@export var defense: int = 0
@export var mana: int = 3
# That's it. No pack(). No unpack().# Engine reads @export vars, serializes# them in declaration order, handles# change detection and is_unpacking# automatically.
Visibility Control
The real game has fields that only the owner should see (your hand of cards) vs fields everyone sees (your HP). The auto-serializer respects this with a metadata convention:
class_namePlayerHandextendsNetworkGameData# Visible to everyone (default)@export var hand_size: int = 0
# Only visible to the owner — opponents see default value@export var hand: Array = [] ## @owner_only@export var draw_pile_size: int = 0
# Owner-only: opponents don't see what's in your draw pile@export var draw_pile: Array = [] ## @owner_only@export var discard_pile: Array = []
The engine walks all @export vars at registration time, builds a serialization descriptor, and uses it for all pack/unpack calls. Fields tagged @owner_only (via doc comment metadata or a const array override) are skipped when packing for Visibility.NON_OWNER.
How It Works Internally
# Inside NetworkGameData (engine code, NOT game code)# Built once at registration time from @export varsvar _field_descriptors: Array = [] # [{name, type, owner_only}]func_build_field_descriptors():
for prop in get_property_list():
if prop.usage & PROPERTY_USAGE_STORAGE: # @export vars
_field_descriptors.append({
"name": prop.name,
"type": prop.type,
"owner_only": prop.name in _get_owner_only_fields()
})
funcpack(buffer: StreamPeerBuffer, visibility: Visibility):
for desc in _field_descriptors:
if desc.owner_only and visibility == Visibility.NON_OWNER:
continue
_pack_field(buffer, get(desc.name), desc.type)
funcunpack(buffer: StreamPeerBuffer, visibility: Visibility, peer_id: String) -> bool:
var changed = falsefor desc in _field_descriptors:
if desc.owner_only and visibility == Visibility.NON_OWNER:
continuevar old_val = get(desc.name)
var new_val = _unpack_field(buffer, desc.type)
if old_val != new_val:
set(desc.name, new_val)
changed = truereturn changed # is_unpacking handled by caller (the sync layer), not here# Game author overrides this IF they have owner-only fieldsfunc_get_owner_only_fields() -> Array[String]:
return []
Constraint respected: The data layer stays. NetworkGameData stays. The authorization model (owner read/write, non-owner read-only, visibility filtering) stays exactly as-is. We're replacing the implementation of pack/unpack, not the semantics. The sync layer (InMemoryRegistrySync, RPCRegistrySync, etc.) calls pack/unpack the same way — it doesn't know or care that it's auto-generated now.
Arrays and Dictionaries: The Special Cases
GDScript has a footgun: set("my_array", new_value) doesn't always work the same as my_array = new_value. Arrays and Dicts are reference types — set() can silently alias instead of replacing. The auto-serializer must handle this explicitly. — Godot docs: "Array is a reference type"
# Inside NetworkGameData auto-unpack — the critical distinction:func_unpack_field(buffer, desc) -> Variant:
match desc.type:
TYPE_INT:
return buffer.get_32()
TYPE_FLOAT:
return buffer.get_float()
TYPE_STRING:
return buffer.get_utf8_string()
TYPE_ARRAY:
# NEVER use set() for arrays. Reconstruct in place.var target_array: Array = get(desc.name)
target_array.clear()
var count = buffer.get_32()
for i in count:
target_array.append(_unpack_element(buffer, desc.element_type))
return target_array # same reference, new contents
TYPE_DICTIONARY:
# Same story. Clear and re-populate, don't replace.var target_dict: Dictionary = get(desc.name)
target_dict.clear()
var count = buffer.get_32()
for i in count:
var key = _unpack_element(buffer, desc.key_type)
var val = _unpack_element(buffer, desc.value_type)
target_dict[key] = val
return target_dict
Why this matters: In the real game, PlayerHandPData stores an array of card IDs. If unpack replaces the array reference via set(), anything holding a reference to the old array silently points to stale data. The same bug exists for Dictionaries. Clear-and-repopulate preserves the reference while updating contents. This is a known class of bugs in Godot networking code.
Custom Serialization Escape Hatch
For complex nested types (arrays of objects with their own serialization, mixed-type dicts), the auto-serializer provides a per-field override:
class_namePlayerHandextendsNetworkGameData@export var hand: Array = []
# Override ONLY if auto-serialize can't handle your element typefunc_pack_field_custom(buffer, field_name, value):
if field_name == "hand":
buffer.put_32(value.size())
for card in value:
buffer.put_utf8_string(JSON.stringify(card.to_dict()))
return true# handledreturn false# use auto-serializefunc_unpack_field_custom(buffer, field_name):
if field_name == "hand":
hand.clear() # preserve reference!var count = buffer.get_32()
for i in count:
hand.append(CardSchema.Card.from_json(buffer.get_utf8_string()))
return truereturn false
What does it allow the player to do? Nothing directly. This is invisible infrastructure. But it means fewer sync bugs → fewer "my HP shows wrong" moments.
What does it enable you (the developer) to do? Add a new data class in 5 lines instead of 30. No pack/unpack boilerplate. No field-order corruption. 25 data classes × ~20 lines saved each = 500 fewer lines of manual byte manipulation.
What did you learn from making it? That declaration-driven serialization (like protobufs, like Unreal UPROPERTY) eliminates an entire class of bugs. The real game already has 2 known visibility bugs (TODO comments in PlayerDiceRollPData and PlayerManaPData) from getting pack order wrong.
What does it mean for your game? You can iterate on data models faster. Add a field? It just works. Change visibility? Flip a tag. No more "did I update both pack AND unpack?" No more silent corruption.
Problem 3.1Problem 3.2Problem 3.3Problem 3.4 Hardcoded switch statement. Flat schema. No status effects. No way to modify gameplay across turns.
The Idea
Two classes. That's it. Items have immediate effects when played. PersistentEffects have a trigger, a duration, and execute when the game loop reaches their trigger point. No event bus. No pub/sub. No fire_event. The state machine already knows exactly where we are in the game — just ask each player's effects "do you listen to this trigger?" and call them in order. — "Don't solve problems you don't have." — YAGNI
Why Not an Event Bus?
An event bus (fire_event / on_event / subscribe) makes sense when producers and consumers are decoupled — you don't know who's listening. But we do know. We have a state machine. At every point in the game loop, we know exactly what state we're in. We can just... ask. Loop over every player's active effects, check the trigger, call execute. Done.
The event bus adds indirection without adding clarity. With the trigger-lookup approach, you can read any game state and see exactly what happens: "before showdown, run all BEFORE_SHOWDOWN effects." No hidden subscribers. No callback registration. No debugging "what fired when."
The Two Base Classes
# engine/item.gd — Immediate effect when playedclass_nameItemextendsRefCountedfuncget_name() -> String:
return""# override: "Banana Peel", "Red Shell", etc.funcget_description() -> String:
return""# override: "Force opponent to discard 2 cards"# Do the thing. Called once when the item is played.# Has full access to game state. Can modify anything.# Can apply PersistentEffects to players.funcon_play(game: Game, user_id: String) -> void:
pass# override
# engine/persistent_effect.gd — Lasts across turns, fires on triggersclass_namePersistentEffectextendsRefCountedvar duration: int = 1 # -1 = permanent (relic-like)funcget_name() -> String:
return""# WHEN does this fire? The game loop checks this.funcget_trigger() -> int:
return -1 # override with Game.Trigger enum value# DO the thing when triggeredfuncexecute(game: Game, owner_id: String) -> void:
pass# override# Tick duration. Returns true if expired. Called at end of round.functick() -> bool:
if duration == -1:
return false# permanent, never expires
duration -= 1
return duration <= 0
No EffectContext. No DamageContext. No modifier pipeline. The effect gets the game and the owner ID. That's enough. game.player(pid) gives you typed access to all player data. game.get_opponent(pid) gives you the other player. You don't need a context object wrapping things you can already reach.
The Trigger Enum
Defined on the Game class. Each game defines its own triggers — poker has different trigger points than a deckbuilder.
# Inside PokerMarioKart (the Game subclass)enumTrigger {
ROUND_START, # After ante, before item roll
BEFORE_SHOWDOWN, # Hands are final, about to compare
AFTER_SHOWDOWN, # Winner determined, before chips move
ROUND_END, # After chips awarded, before cleanup
}
That's four trigger points. Not twenty. Not a generic event bus that could fire anything at any time. Four specific moments in the game loop where persistent effects can do their thing.
The run_trigger() Loop
This is the entire "effect system." No bus. No registry. No subscription. Just a loop.
# Inside PokerMarioKartfuncrun_trigger(trigger: Trigger):
for pid in player_ids:
for effect in player(pid).effects.get_for_trigger(trigger):
effect.execute(self, pid)
functick_durations():
for pid in player_ids:
player(pid).effects.remove_expired()
Read it out loud: "For each player, get all effects that listen to this trigger, and execute them." That's it. You can look at any game state and know exactly what will happen.
EffectList: Per-Player Storage
Each player has an EffectList that stores their active persistent effects and provides the trigger lookup.
# effect_list.gd — Stores active effects for one playerclass_nameEffectListextendsNetworkGameDatavar _effects: Array = []
# The trigger-lookup. Returns only effects that match.funcget_for_trigger(trigger: int) -> Array:
return _effects.filter(func(e): return e.get_trigger() == trigger)
funcadd(effect: PersistentEffect):
_effects.append(effect)
funchas_effect(name: String) -> bool:
return _effects.any(func(e): return e.get_name() == name)
# Shield is checked directly, not through triggersfuncis_shielded() -> bool:
return _effects.any(func(e): return e isShieldEffect)
funcconsume_shield():
for i in range(_effects.size() - 1, -1, -1):
if _effects[i] isShieldEffect:
_effects.remove_at(i)
return# Tick all durations, remove expiredfuncremove_expired():
for i in range(_effects.size() - 1, -1, -1):
if _effects[i].tick():
_effects.remove_at(i)
Concrete Items
Items are played once for immediate effect. Some also apply persistent effects.
# items/banana_peel.gd — Force opponent to discard and redrawclass_nameBananaPeelextendsItemfuncget_name(): return"Banana Peel"funcget_description(): return"Opponent discards 2, redraws 2"funcon_play(game, user_id):
var opp = game.get_opponent(user_id)
var hand = game.player(opp).hand
hand.discard_random(2)
hand.draw(game._deck, 2)
print("[%s] Banana Peel! %s redraws 2 cards" % [user_id, opp])
# items/poison_mushroom.gd — Applies a persistent effectclass_namePoisonMushroomextendsItemfuncget_name(): return"Poison Mushroom"funcget_description(): return"Poison opponent: lose 3 chips/round for 3 rounds"funcon_play(game, user_id):
var opp = game.get_opponent(user_id)
if game.player(opp).effects.is_shielded():
game.player(opp).effects.consume_shield()
print("[%s] Poison blocked by shield!" % user_id)
return
game.player(opp).effects.add(PoisonEffect.new(3, 3))
print("[%s] Poisoned %s for 3 rounds!" % [user_id, opp])
Concrete Persistent Effects
Each effect stores its trigger and does its thing when called.
# effects/lucky_effect.gd — Permanent (relic-like)class_nameLuckyEffectextendsPersistentEffectfunc_init():
duration = -1 # permanentfuncget_name(): return"Lucky"funcget_trigger(): return PokerMarioKart.Trigger.BEFORE_SHOWDOWN
funcexecute(game, owner_id):
# Swap worst card for a new drawvar hand = game.player(owner_id).hand
var worst = hand.get_lowest_card()
hand.replace(worst, game._deck.draw_one())
print(" [Lucky] %s swaps %s for a new card" % [owner_id, worst])
How It Flows in a Game State
Here's what ShowdownState looks like. No event bus. No fire_event. Just: run the trigger, do the game logic.
# Inside ShowdownState._on_enter()# ── Before showdown: run all BEFORE_SHOWDOWN effects ──# Lucky swaps a card. CursedEffect downgrades a card.# We know exactly what can happen here.
game.run_trigger(Trigger.BEFORE_SHOWDOWN)
# ── Compare hands ──var p1_rank = PokerRank.evaluate(game.player("p1").hand)
var p2_rank = PokerRank.evaluate(game.player("p2").hand)
var winner = p1_rank.beats(p2_rank)
# ── After showdown: run all AFTER_SHOWDOWN effects ──# Could double winnings, steal chips, etc.
game.run_trigger(Trigger.AFTER_SHOWDOWN)
# ── Award pot ──
game.player(winner).chips.amount += game._pot
game._pot = 0
# ── Transition ──
game.transition_to(RoundEndState.new())
Duration Management: When to Tick
Critical design decision: durations tick at ROUND_END, after all effects have had their chance to fire. This means a 1-round effect fires once, then expires.
# Inside RoundEndState._on_enter()# Run ROUND_END trigger effects first
game.run_trigger(Trigger.ROUND_END)
# THEN tick durations and remove expired
game.tick_durations()
# Check for game overfor pid in game.player_ids:
if game.player(pid).chips.amount <= 0:
game.transition_to(GameOverState.new())
return
game.transition_to(AnteState.new())
Order matters. Effects execute at their trigger point. Durations tick at round end. If you tick too early, a ROUND_END effect never fires on its last turn. If you tick inside execute(), you can't have effects that trigger multiple times per round at different trigger points (unlikely for poker, but the pattern generalizes).
Shield: A Special Case Done Simply
Shield doesn't use the trigger system at all. It's checked directly in item resolution — when an item would apply a negative effect, check is_shielded() first. This is simpler and more readable than making Shield intercept via a trigger.
# Inside PoisonMushroom.on_play():if game.player(opp).effects.is_shielded():
game.player(opp).effects.consume_shield()
print("Blocked by shield!")
return# Shield doesn't need get_trigger(). It doesn't fire on a game loop point.# It's a flag that items check. That's all it needs to be.
Adding a New Item: The Full Checklist
Want to add Blooper (blinds opponent so they can't see their hand)?
Done. No registry. No event type. No subscription code.
# items/blooper.gdclass_nameBlooperextendsItemfuncget_name(): return"Blooper"funcon_play(game, user_id):
var opp = game.get_opponent(user_id)
if game.player(opp).effects.is_shielded():
game.player(opp).effects.consume_shield()
return
game.player(opp).effects.add(BlindEffect.new(1))
# effects/blind_effect.gdclass_nameBlindEffectextendsPersistentEffectfuncget_name(): return"Blind"funcget_trigger(): return PokerMarioKart.Trigger.BEFORE_SHOWDOWN
funcexecute(game, owner_id):
# AI can't see their hand → makes random discard choices
game.player(owner_id).hand.set_visible(false)
print(" [Blind] %s can't see their hand!" % owner_id)
What does it allow the player to do? Experience chaos — items that mess with hands, poison that drains chips, shields that block attacks, lucky streaks that swap cards. The kind of unpredictability that makes poker social and exciting.
What does it enable you (the developer) to do? Add an item in one file. Add a persistent effect in one file. No switch statement. No registry. No event subscription. The game loop reads effects directly — you can trace the entire execution path by reading game states top to bottom.
What did you learn from making it? The real game uses an event bus (TurnResolutionContext, StatusEffectRegistry, priority-based execution) because it has 22 event types and complex interaction chains. We have 4 trigger points and 7 items. The simpler pattern works. If we ever need the complexity, the upgrade path is clear: add more Trigger enum values. The data shape doesn't change.
What does it mean for your game? You can prototype new items in minutes. Want "every round you're poisoned, your best card gets downgraded"? One file, one test. The cost of trying new mechanics is one class. The game design space opens up because you're not fighting infrastructure — you're just writing game logic.
Solution 3: Declarative Game Definition + Simplified MatchSetup
Problem 4.1Problem 4.2Problem 4.4Problem 5.1 90-line procedural MatchSetup. Unclear ownership. Game knows about engine plumbing. Manual event registration.
The Idea
The Game class becomes a declaration: "here are my data types, event types, effect types, states, and player setup." MatchSetup reads the declaration and wires everything. The game author never touches TimelineManager, NetworkService, AuthoritativeRegistry, or factory callables. — Declarative style, inspired by React's component model and ECS registration patterns
After — game.gd: PlayerData contract + declarative methods
# Inner class: the PlayerData contract# Override this to define what data each player hasclassPlayerData:
pass# override with typed fields# Engine calls these to learn what the game needsfuncget_event_types() -> Array:
return [] # overridefunccreate_player(id: String) -> PlayerData:
return null# override: return typed PlayerDatafuncget_initial_state() -> GameState:
return null# override# Typed access: game.player(pid).handvar _players: Dictionary = {} # pid → PlayerDatafuncplayer(pid: String) -> PlayerData:
return _players[pid]
# Low-level access still availablefuncdata(pid: String, type: GDScript):
return game_data_registry
.get_registered_script(type, pid)
Key change: PlayerData contract. Instead of get_player_data_types() → Array returning loose classes, the game defines an inner PlayerData class with typed fields. create_player() returns one of these, and the engine registers each NetworkGameData field automatically. Access goes from game.data(pid, PlayerStats) to game.player(pid).stats — typed, discoverable, IDE-completable.
What PokerMarioKart Looks Like
class_namePokerMarioKartextendsGameconst STARTING_CHIPS := 100
const ANTE := 5
const HAND_SIZE := 5
var _round: int = 0
var _pot: int = 0
var _deck: PokerDeck# ── PlayerData contract: what each player has ──classPlayerData:
var chips: ChipStackvar hand: PokerHandvar items: ItemBagvar effects: EffectList# ── Declaration ──funcget_event_types() -> Array:
return [UseItemsEvent, DrawEvent]
funccreate_player(pid: String) -> PlayerData:
var pd = PlayerData.new()
pd.chips = ChipStack.new(pid)
pd.chips.amount = STARTING_CHIPS
pd.hand = PokerHand.new(pid)
pd.items = ItemBag.new(pid)
pd.effects = EffectList.new(pid)
return pd
# Engine registers chips, hand, items, effects in AuthoritativeRegistry# Access: game.player(pid).chips, game.player(pid).hand, etc.funcget_initial_state() -> GameState:
return AnteState.new()
# ── Trigger system ──enumTrigger { ROUND_START, BEFORE_SHOWDOWN, AFTER_SHOWDOWN, ROUND_END }
funcrun_trigger(trigger: Trigger):
for pid in player_ids:
for effect in player(pid).effects.get_for_trigger(trigger):
effect.execute(self, pid)
# No TimelineManager in constructor. No AuthoritativeRegistry in args.# Data access: game.player(pid).chips.amount — typed, discoverable.
MatchConfig: Typed Configuration
No string dictionaries, no magic keys. MatchConfig is a typed inner class — IDE autocomplete, compile-time safety, clear documentation of what's required.
# engine/match_config.gdclass_nameMatchConfigextendsRefCountedvar game_class: GDScript# Required. The Game subclass.var ai_class: GDScript# Optional. AI player implementation.var player_ids: Array[String] # Required. Who's playing.var host_id: String = ""# Defaults to player_ids[0]
The New MatchSetup
MatchSetup reads the game's declaration and handles all wiring. The 90-line procedural script becomes a config-driven orchestrator.
var config = MatchConfig.new()
config.game_class = PokerMarioKart
config.ai_class = PokerAI
config.player_ids = ["player_1", "player_2"]
var match = await MatchSetup.launch(config, self)
await match.game.game_completed
# MatchSetup.launch() internally:# 1. Instantiate game from config.game_class# 2. Read get_event_types() → auto-register# 3. Create NetworkService (in-memory)# 4. Create TimelineManagers# 5. For each player: call create_player()# → inspect PlayerData, register fields# 6. Create AI from config.ai_class# 7. Call start_game()# 8. Transition to get_initial_state()
What MatchSetup.launch() Does Internally
The same 12 steps still happen — they're just driven by the game's declaration and typed config instead of manual wiring:
# match_setup.gd — config-driven orchestrationstatic funclaunch(config: MatchConfig, parent: Node) -> MatchResult:
var player_ids = config.player_ids
var host_id = config.host_id if config.host_id != ""else player_ids[0]
# 1. Create game instance (no engine args in constructor)var game: Game = config.game_class.new()
parent.add_child(game)
# 2. Auto-register event types from declarationfor event_type in game.get_event_types():
if event_type.has_method("register"):
event_type.register()
# 3. Create network servicevar net = NetworkService.new()
parent.add_child(net)
await net.start_host(host_id, NetworkService.TransportType.IN_MEMORY)
# 4. Create timeline managersvar host_tm = _create_host_timeline(host_id, parent)
var sim_tms = _create_sim_timelines(player_ids, host_id, parent)
# 5. Wire engine internals (game never sees this)
game._configure(host_id, Game.LaunchMode.IN_MEMORY_HOST)
host_tm.player_input_received.connect(game.process_player_input)
net.record_host_registry_events(game.game_data_registry, host_tm)
net.host_registry(host_tm.get_timeline_registry(), "timeline_data")
# 6. Add players — engine inspects PlayerData contractfor pid in player_ids:
var player_data = game.create_player(pid)
game._players[pid] = player_data
# Walk the PlayerData's fields, find all NetworkGameData instancesfor prop in player_data.get_property_list():
var val = player_data.get(prop.name)
if val isNetworkGameData:
game.game_data_registry.register_instance(val)
game.player_ids.append(pid)
host_tm.add_player_timeline(pid)
# 7. Create AIif config.ai_class:
for pid in player_ids:
var ai = config.ai_class.new(pid, game)
parent.add_child(ai)
if"ai_players"in game:
game.ai_players[pid] = ai
# 8. Subscribe sims, snapshot, startfor sid in sim_tms:
await net.subscribe_to_registry(sim_tms[sid].get_timeline_registry(), "timeline_data", sid)
sim_tms[sid].get_game_state_registry().load_from_snapshot(game.game_data_registry.create_snapshot())
sim_tms[sid].set_live_mode(true)
sim_tms[sid].play_all()
game.start_game()
return MatchResult.new(game, net, host_tm, sim_tms)
Constraints respected: ContextNode stays. Timeline Manager stays. Registry Sync stays (InMemoryRegistrySync, RPCRegistrySync, etc. — untouched). NetworkService stays. We're changing who calls these systems, not the systems themselves. MatchSetup is the single orchestrator; the game never sees the plumbing.
What does it allow the player to do? Nothing directly — but it means new game types ship faster, so players get more game variety.
What does it enable you (the developer) to do? Create a new game without understanding TimelineManager, NetworkService, or sync wiring. Define a PlayerData contract, event types, states, and player setup. MatchSetup reads the config and handles the rest. Access player data via game.player(pid).hand — typed, discoverable, IDE-completable. The barrier drops from "understand 6 engine classes" to "override 3 methods and fill out a MatchConfig."
What did you learn from making it? The real game's GameLauncher hardcodes AIBGCreatureCombat. Our current MatchSetup uses factory callables which leaks engine types. String-dict configs are fragile and untyped. The cleanest approach: typed MatchConfig + PlayerData contract. The engine inspects the contract's fields and auto-registers all NetworkGameData instances.
What does it mean for your game? Poker Mario Kart was built on this exact pattern — PokerMarioKart extends Game, defines PlayerData with chips/hand/items/effects, and MatchSetup.launch() handles the rest. Want a different game? Same engine, same MatchSetup.launch(), different Game subclass + MatchConfig.
Solution 4: Test Infrastructure
No test coverage for game logicNo determinism verificationNo CI integration The testing gap is the biggest risk in the project. The real game has ~27 test files covering ~20% of the codebase. Battle logic, effect resolution, and individual data classes have zero tests.
This is not optional. Every other solution in this document changes engine internals. Without tests, we can't verify that changes work. Without tests, we can't refactor safely. Without tests, "it ran once headlessly" is our only validation. This solution gates everything else.
What We Build
Layer 1: Unit Test Framework
A lightweight test runner that works headlessly. No external dependencies. Mirrors the real game's SimulationTestCase pattern but standardized.
# engine/testing/test_case.gdclass_nameTestCaseextendsNodevar _assertions: int = 0
var _failures: Array = []
funcassert_eq(actual, expected, msg: String = ""):
_assertions += 1
if actual != expected:
_failures.append("%s: expected %s, got %s" % [msg, str(expected), str(actual)])
funcassert_true(condition: bool, msg: String = ""):
_assertions += 1
if not condition:
_failures.append("assert_true failed: %s" % msg)
# Override: define test methods prefixed with test_# Runner auto-discovers and calls them
Layer 2: Game Logic Unit Tests
# tests/test_items.gdclass_nameTestItemsextendsTestCasefunctest_banana_peel_forces_redraw():
var game = TestHelper.create_game(["p1", "p2"])
var original_hand = game.player("p2").hand.cards.duplicate()
BananaPeel.new().on_play(game, "p1")
assert_eq(game.player("p2").hand.cards.size(), 5, "still 5 cards")
assert_true(game.player("p2").hand.cards != original_hand, "hand changed")
functest_poison_mushroom_applies_effect():
var game = TestHelper.create_game(["p1", "p2"])
PoisonMushroom.new().on_play(game, "p1")
assert_true(game.player("p2").effects.has_effect("Poison"), "opponent poisoned")
functest_shield_blocks_poison():
var game = TestHelper.create_game(["p1", "p2"])
game.player("p2").effects.add(ShieldEffect.new())
PoisonMushroom.new().on_play(game, "p1")
assert_true(not game.player("p2").effects.has_effect("Poison"), "shield blocked it")
assert_true(not game.player("p2").effects.is_shielded(), "shield consumed")
functest_poison_ticks_on_round_start():
var game = TestHelper.create_game(["p1", "p2"])
game.player("p2").chips.amount = 100
game.player("p2").effects.add(PoisonEffect.new(3, 3))
game.run_trigger(PokerMarioKart.Trigger.ROUND_START)
assert_eq(game.player("p2").chips.amount, 97, "lost 3 chips to poison")
functest_poison_expires_after_duration():
var game = TestHelper.create_game(["p1", "p2"])
game.player("p2").effects.add(PoisonEffect.new(3, 1))
game.tick_durations()
assert_true(not game.player("p2").effects.has_effect("Poison"), "expired after 1 tick")
functest_permanent_effect_never_expires():
var game = TestHelper.create_game(["p1", "p2"])
game.player("p1").effects.add(LuckyEffect.new())
for i in 10:
game.tick_durations()
assert_true(game.player("p1").effects.has_effect("Lucky"), "permanent = never expires")
Layer 3: Integration Test (Full Match)
# tests/test_full_match.gdclass_nameTestFullMatchextendsTestCasefunctest_match_completes_with_winner():
var config = MatchConfig.new()
config.game_class = PokerMarioKart
config.ai_class = PokerAI
config.player_ids = ["p1", "p2"]
var result = await MatchSetup.launch(config, self)
await result.game.game_completed
var p1_chips = result.game.player("p1").chips.amount
var p2_chips = result.game.player("p2").chips.amount
assert_true(p1_chips <= 0 or p2_chips <= 0, "someone busted")
functest_data_sync_matches_host():
# Verify that sim timeline registry matches host after matchvar config = MatchConfig.new()
config.game_class = PokerMarioKart
config.ai_class = PokerAI
config.player_ids = ["p1", "p2"]
var result = await MatchSetup.launch(config, self)
await result.game.game_completed
var host_snapshot = result.game.game_data_registry.create_snapshot()
for key in host_snapshot:
assert_true(host_snapshot.has(key), "sim has key: %s" % key)
Layer 4: Auto-Serialization Tests
# tests/test_auto_serialize.gdfunctest_round_trip_owner():
var stats = PlayerStats.new("p1")
stats.hp = 17; stats.defense = 3; stats.mana = 4
var buf = StreamPeerBuffer.new()
stats.pack(buf, NetworkGameData.Visibility.OWNER)
buf.seek(0)
var copy = PlayerStats.new("p1")
copy.unpack(buf, NetworkGameData.Visibility.OWNER)
assert_eq(copy.hp, 17)
assert_eq(copy.defense, 3)
assert_eq(copy.mana, 4)
functest_owner_only_fields_hidden_from_opponent():
var hand = PlayerHand.new("p1")
hand.hand = ["card_a", "card_b"]
var buf = StreamPeerBuffer.new()
hand.pack(buf, NetworkGameData.Visibility.NON_OWNER)
buf.seek(0)
var copy = PlayerHand.new("p1")
copy.unpack(buf, NetworkGameData.Visibility.NON_OWNER)
assert_eq(copy.hand.size(), 0, "opponent shouldn't see hand contents")
Unit tests need a lightweight way to create a game with data, without the full MatchSetup pipeline. TestHelper provides this:
# engine/testing/test_helper.gdclass_nameTestHelper# Create a minimal game with players and data, no network, no timelinestatic funccreate_game(player_ids: Array, GameClass = PokerMarioKart) -> Game:
var game = GameClass.new()
for pid in player_ids:
var pd = game.create_player(pid)
game._players[pid] = pd
# Walk PlayerData fields, register all NetworkGameDatafor prop in pd.get_property_list():
var val = pd.get(prop.name)
if val isNetworkGameData:
game.game_data_registry.register_instance(val)
game.player_ids.append(pid)
return game
What does it allow the player to do? Nothing directly — but it means the game works correctly. Fewer bugs in combat resolution. Fewer "I should have won that" moments.
What does it enable you (the developer) to do? Refactor with confidence. Change the effect system? Run tests. Change serialization? Run tests. Add a new effect? Write a test first, then implement. TDD becomes possible. CI can gate merges on test passing.
What did you learn from making it? The real game has 2 known visibility bugs (TODO comments in PlayerDiceRollPData and PlayerManaPData). Tests would have caught these. The existing stress test (100 games) catches crashes but not logic errors. Unit tests catch logic errors.
What does it mean for your game? Velocity. Right now, every change requires manually running a headless match and reading output. With tests, you get instant feedback. Change effect damage formula → test fails or passes in <1 second. This is the difference between trying 3 balance iterations per day vs 30.
Solution 5: Rich Game Event Log
Problem 5.1Problem 5.5 Magic integer event IDs. Only 2 event types (STATE_CHANGE, UI_EVENT). Timeline records "what bytes changed" not "what happened."
The Problem
After a match, the timeline contains: [StateChangeEvent(PlayerStats binary blob), StateChangeEvent(PlayerStats binary blob), ...]. You can reconstruct what the state was at any point, but you can't answer "what happened in round 3?" without diffing binary blobs. The real game solves this with 22 game-specific event types (DamageEvent, HealEvent, etc.). We need the same, but structured so new games can define their own events without magic integers.
The Idea
Replace magic integer registration with string-based type names (like effects). Add a GameLogEvent that carries human-readable descriptions alongside the binary state changes.
# use_items_event.gdfuncget_event_name() -> String:
return"use_items"# poker_mario_kart.gdfuncget_event_types() -> Array:
return [UseItemsEvent, DrawEvent]
# MatchSetup reads this and registers
# automatically. No manual step.
What the Timeline Looks Like After
# After a match, you can query the timeline:for event in timeline.get_events_of_type("showdown"):
print("Round %d: %s won %d chips with %s" % [
event.data.round, event.source_id, event.data.pot, event.data.hand_rank])
# Output:# Round 1: player_1 won 10 chips with two_pair# Round 2: player_2 won 10 chips with flush# Round 3: player_1 won 10 chips with full_house# Full log for round 3:for event in timeline.get_events_in_round(3):
print(event.message)
# Output:# player_2 loses 3 chips to Poison (2 rounds left)# player_1 rolls 5 → gets Red Shell# player_2 rolls 3 (+2 rubber band) → gets Star# player_1 uses Red Shell — player_2 discards highest card# player_2 uses Star — shield active for 1 round# player_1 wins with Full House, takes 30 chips
What does it allow the player to do? See a match log — "Red Shell forced discard of Ace." See a replay. Understand why they lost, not just that they did. This is table stakes for any card game.
What does it enable you (the developer) to do? Debug matches by reading events instead of diffing binary blobs. Build a replay viewer. Train AI on structured match data ("which items won in which situations"). Export match statistics as JSON for balance analysis.
What did you learn from making it? The real game already has 22 event types. The timeline system already supports them. We just need to add the generic GameLogEvent to the simulation so game-specific events don't require modifying the engine's SimulationEvent enum.
What does it mean for your game? Match data becomes first-class. Replays, combat logs, AI training data, balance analytics — all become possible because the timeline records meaning, not just bytes.
Architecture After All Solutions
How the pieces fit together. This is the target state — what the engine looks like when S1–S5 are implemented.
The Key Boundaries
Layer
You Touch
Never Touch
Why
Game
Game subclass, data classes, items, persistent effects, states