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.gd class_name PokerMarioKart extends Game const STARTING_CHIPS := 100 const ANTE := 5 const HAND_SIZE := 5 const MAX_DISCARD := 3 # ── PlayerData contract ── class PlayerData: var chips: ChipStack # how many chips they have var hand: PokerHand # their 5 cards var items: ItemBag # items they can play var effects: EffectList # active status effects on them func create_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 func get_event_types() -> Array: return [UseItemsEvent, DrawEvent] func get_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. ── enum Trigger { ROUND_START, BEFORE_SHOWDOWN, AFTER_SHOWDOWN, ROUND_END } func run_trigger(trigger: Trigger): for pid in player_ids: for effect in player(pid).effects.get_for_trigger(trigger): effect.execute(self, pid) func tick_durations(): for pid in player_ids: player(pid).effects.remove_expired() func get_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 ── class AnteState extends GameState: 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]) func get_next_state(): return ItemRollState.new()
# ── ItemRollState: roll dice, get items. Rubber-banding: behind = better items. ── class ItemRollState extends GameState: 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 func get_next_state(): return DealState.new()
# ── DealState: tick ROUND_START effects, deal 5 cards ── class DealState extends GameState: func _on_enter(): game.run_trigger(Trigger.ROUND_START) game._deck.shuffle() for pid in game.player_ids: game.player(pid).hand.set_cards(game._deck.draw(HAND_SIZE)) func get_next_state(): return ItemPlayState.new()
# ── ItemPlayState: both players choose items simultaneously ── class ItemPlayState extends GameState: var _subs: Dictionary = {} func handle_player_input(pid, event): if event is UseItemsEvent: _subs[pid] = event.chosen_items func get_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 ── class DrawState extends GameState: var _subs: Dictionary = {} func handle_player_input(pid, event): if event is DrawEvent: _subs[pid] = event.discard_indices func get_next_state(): if _subs.size() < game.player_ids.size(): return null for 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 ── class ShowdownState extends GameState: 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) func get_next_state(): return RoundEndState.new()
# ── RoundEndState: tick durations, check bust, loop or end ── class RoundEndState extends GameState: 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() func get_next_state(): for pid in game.player_ids: if game.player(pid).chips.amount <= 0: return GameOverState.new() return AnteState.new() # ── GameOverState ── class GameOverState extends GameState: 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/item.gd — Base class class_name Item extends RefCounted var item_name: String var description: String var priority: int = 50 # lower = resolves first (shields=10, attacks=50) func get_target(game, source_id) -> String: return game.get_opponent(source_id) # default: targets opponent func on_play(game, source_id) -> void: pass
# items/banana_peel.gd — Opponent discards 1 random card, draws replacement class_name BananaPeel extends Item func _init(): item_name = "Banana Peel"; priority = 50 func on_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 card class_name RedShell extends Item func _init(): item_name = "Red Shell"; priority = 50 func on_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 round class_name Star extends Item func _init(): item_name = "Star"; priority = 10 # resolves first func get_target(game, source_id): return source_id func on_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 round class_name PoisonMushroom extends Item func _init(): item_name = "Poison Mushroom"; priority = 50 func on_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 worst class_name Lightning extends Item func _init(): item_name = "Lightning"; priority = 50 func on_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 5 class_name Mushroom extends Item func _init(): item_name = "Mushroom"; priority = 50 func get_target(game, source_id): return source_id func on_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 card class_name GreenShell extends Item func _init(): item_name = "Green Shell"; priority = 50 func on_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/persistent_effect.gd — Base class class_name PersistentEffect extends RefCounted var remaining_duration: int = 1 # -1 = permanent func get_trigger() -> Trigger: return Trigger.ROUND_END # override func execute(game, owner_id) -> void: pass # override func tick() -> bool: # returns true if expired if remaining_duration < 0: return false # permanent remaining_duration -= 1 return remaining_duration <= 0
# effects/poison.gd — Lose chips each round class_name PoisonEffect extends PersistentEffect var chip_loss: int func _init(loss: int, duration: int): chip_loss = loss; remaining_duration = duration func get_trigger(): return Trigger.ROUND_START func execute(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 showdown class_name CursedEffect extends PersistentEffect func _init(duration: int): remaining_duration = duration func get_trigger(): return Trigger.BEFORE_SHOWDOWN func execute(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_name ShieldEffect extends PersistentEffect func _init(): remaining_duration = 1 func get_trigger(): return Trigger.ROUND_END # doesn't trigger; consumed by item resolution # effects/lucky.gd — Permanent: always roll +1 on item dice class_name LuckyEffect extends PersistentEffect func _init(): remaining_duration = -1 # permanent func get_trigger(): return Trigger.ROUND_START # checked by ItemRollState func execute(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 effects class_name EffectList extends NetworkGameData var _effects: Array[PersistentEffect] = [] func add(effect: PersistentEffect): _effects.append(effect) func get_for_trigger(trigger: Trigger) -> Array: return _effects.filter(func(e): return e.get_trigger() == trigger) func is_shielded() -> bool: return _effects.any(func(e): return e is ShieldEffect) func consume_shield(): for i in range(_effects.size() - 1, -1, -1): if _effects[i] is ShieldEffect: _effects.remove_at(i) return func remove_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.gd class_name ItemTable enum Standing { AHEAD, EVEN, BEHIND } const COMMON = [BananaPeel, GreenShell] const UNCOMMON = [RedShell, Mushroom, PoisonMushroom] const RARE = [Star, Lightning] static func roll(die: int, standing: Standing) -> Item: # Rubber-banding: behind players get upgraded rolls var effective = die match standing: Standing.BEHIND: effective = mini(die + 2, 6) Standing.EVEN: effective = mini(die + 1, 6) Standing.AHEAD: effective = die var pool: Array if 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_name Blooper extends Item func _init(): item_name = "Blooper"; priority = 50 func on_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_name BlindEffect extends PersistentEffect func _init(duration: int): remaining_duration = duration func get_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 hand class_name PokerHand extends NetworkGameData var cards: Array = [] # The actual cards — secret! var card_count: int = 0 # How many cards — public func pack(buffer: StreamPeerBuffer, visibility: Visibility): buffer.put_32(card_count) # always sent if visibility == Visibility.OWNER: buffer.put_32(cards.size()) # only to owner for card in cards: buffer.put_string(card.id) # only to owner func unpack(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 = true return 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_name NetworkGameData # Override this to restrict writes to server only. # Default: false (owner can write, as today). func is_server_authoritative() -> bool: return false
# EffectList overrides it: class_name EffectList extends NetworkGameData func is_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(): func get_permission(key: AuthoritativeKey, requesting_client_id: String) -> int: var instance = get_instance_with_key(key) as NetworkGameData # NEW: server-authoritative data — only authority can write if 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 items var 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 public class_name EffectList extends NetworkGameData var _effects: Array = [] func is_server_authoritative() -> bool: return true # Only host can write func pack(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()) func unpack(buffer: StreamPeerBuffer, visibility: Visibility, _peer_id: String = "") -> bool: # Same data for OWNER and NON_OWNER — effects are fully public var 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 ClassOwner (player)OpponentHost (authority)Server-Auth?Rationale
ChipStackREADREADWRITEYesOnly game logic awards/deducts chips
PokerHandREAD (full)READ (count only)WRITEYesHost deals/modifies cards. Owner sees theirs, opponent sees count.
ItemBagREADREADWRITEYesOnly game logic awards items from dice rolls
EffectListREADREADWRITEYesOnly game logic adds/ticks/removes effects
PlayerTimelineWRITEREADNoPlayers submit input events. Host reads them.
GlobalTimelineREADREADWRITEYesOnly 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

What Could Be Cleaner

Proposed Cleanup

# Proposed: Simplify BaseRegistrySync to just the host-side contract. # Client binding is optional — subclasses opt in. # base_registry_sync.gd — SLIMMED DOWN class_name BaseRegistrySync extends RefCounted # No need for Node — no _process, no scene tree var _host_registry: AuthoritativeRegistry func _init(p_host_registry: AuthoritativeRegistry): _host_registry = p_host_registry _bind_to_host() # Connect to all current + future data_changed signals on host func _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 as GameData).data_changed.connect( _on_host_data_changed.bind(inst, key)) # Subclasses override these: func _on_host_registered(inst, key): pass func _on_host_unregistered(inst, key): pass func _on_host_data_changed(inst, key): pass
# in_memory_registry_sync.gd — client binding added on top class_name InMemoryRegistrySync extends BaseRegistrySync var _client_registry: AuthoritativeRegistry var _client_id: String func _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: return var 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:

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.2 Problem 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_name PlayerStats extends NetworkGameData var hp: int = 30 var defense: int = 0 var mana: int = 3 func pack(buffer, _vis): buffer.put_32(hp) buffer.put_32(defense) buffer.put_32(mana) func unpack(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_name PlayerStats extends NetworkGameData # 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_name PlayerHand extends NetworkGameData # 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 vars var _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() }) func pack(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) func unpack(buffer: StreamPeerBuffer, visibility: Visibility, peer_id: String) -> bool: var changed = false for desc in _field_descriptors: if desc.owner_only and visibility == Visibility.NON_OWNER: continue var old_val = get(desc.name) var new_val = _unpack_field(buffer, desc.type) if old_val != new_val: set(desc.name, new_val) changed = true return changed # is_unpacking handled by caller (the sync layer), not here # Game author overrides this IF they have owner-only fields func _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_name PlayerHand extends NetworkGameData @export var hand: Array = [] # Override ONLY if auto-serialize can't handle your element type func _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 # handled return false # use auto-serialize func _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 true return 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.

Solution 2: Items & Persistent Effects (Trigger-Lookup)

Problem 3.1 Problem 3.2 Problem 3.3 Problem 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 played class_name Item extends RefCounted func get_name() -> String: return "" # override: "Banana Peel", "Red Shell", etc. func get_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. func on_play(game: Game, user_id: String) -> void: pass # override
# engine/persistent_effect.gd — Lasts across turns, fires on triggers class_name PersistentEffect extends RefCounted var duration: int = 1 # -1 = permanent (relic-like) func get_name() -> String: return "" # WHEN does this fire? The game loop checks this. func get_trigger() -> int: return -1 # override with Game.Trigger enum value # DO the thing when triggered func execute(game: Game, owner_id: String) -> void: pass # override # Tick duration. Returns true if expired. Called at end of round. func tick() -> 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) enum Trigger { 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 PokerMarioKart func run_trigger(trigger: Trigger): for pid in player_ids: for effect in player(pid).effects.get_for_trigger(trigger): effect.execute(self, pid) func tick_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 player class_name EffectList extends NetworkGameData var _effects: Array = [] # The trigger-lookup. Returns only effects that match. func get_for_trigger(trigger: int) -> Array: return _effects.filter(func(e): return e.get_trigger() == trigger) func add(effect: PersistentEffect): _effects.append(effect) func has_effect(name: String) -> bool: return _effects.any(func(e): return e.get_name() == name) # Shield is checked directly, not through triggers func is_shielded() -> bool: return _effects.any(func(e): return e is ShieldEffect) func consume_shield(): for i in range(_effects.size() - 1, -1, -1): if _effects[i] is ShieldEffect: _effects.remove_at(i) return # Tick all durations, remove expired func remove_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 redraw class_name BananaPeel extends Item func get_name(): return "Banana Peel" func get_description(): return "Opponent discards 2, redraws 2" func on_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 effect class_name PoisonMushroom extends Item func get_name(): return "Poison Mushroom" func get_description(): return "Poison opponent: lose 3 chips/round for 3 rounds" func on_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/poison_effect.gd class_name PoisonEffect extends PersistentEffect var chip_loss: int func _init(p_loss: int, p_duration: int): chip_loss = p_loss duration = p_duration func get_name(): return "Poison" func get_trigger(): return PokerMarioKart.Trigger.ROUND_START func execute(game, owner_id): game.player(owner_id).chips.amount -= chip_loss print(" [Poison] %s loses %d chips (%d rounds left)" % [owner_id, chip_loss, duration])
# effects/lucky_effect.gd — Permanent (relic-like) class_name LuckyEffect extends PersistentEffect func _init(): duration = -1 # permanent func get_name(): return "Lucky" func get_trigger(): return PokerMarioKart.Trigger.BEFORE_SHOWDOWN func execute(game, owner_id): # Swap worst card for a new draw var 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 over for 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)?

  1. Create items/blooper.gd extending Item
  2. Create effects/blind_effect.gd extending PersistentEffect
  3. Add Blooper to the ItemTable loot table
  4. Done. No registry. No event type. No subscription code.
# items/blooper.gd class_name Blooper extends Item func get_name(): return "Blooper" func on_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.gd class_name BlindEffect extends PersistentEffect func get_name(): return "Blind" func get_trigger(): return PokerMarioKart.Trigger.BEFORE_SHOWDOWN func execute(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.1 Problem 4.2 Problem 4.4 Problem 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

The New Game Base Class

Before — game.gd _init() takes engine plumbing
func _init(mode: LaunchMode, game_host_id: String): _state_machine = StateMachine.new(self) add_child(_state_machine) self.game_mode = mode self.host_id = game_host_id _create_game_data(game_data_registry) # Game subclass constructor: func _init( p_timeline_manager: TimelineManager, mode: LaunchMode, game_host_id: String, p_sim_timeline_managers: Dictionary ): super(mode, game_host_id) host_timeline_manager = p_timeline_manager sim_timeline_managers = ...
After — game.gd: PlayerData contract + declarative methods
# Inner class: the PlayerData contract # Override this to define what data each player has class PlayerData: pass # override with typed fields # Engine calls these to learn what the game needs func get_event_types() -> Array: return [] # override func create_player(id: String) -> PlayerData: return null # override: return typed PlayerData func get_initial_state() -> GameState: return null # override # Typed access: game.player(pid).hand var _players: Dictionary = {} # pid → PlayerData func player(pid: String) -> PlayerData: return _players[pid] # Low-level access still available func data(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_name PokerMarioKart extends Game const 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 ── class PlayerData: var chips: ChipStack var hand: PokerHand var items: ItemBag var effects: EffectList # ── Declaration ── func get_event_types() -> Array: return [UseItemsEvent, DrawEvent] func create_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. func get_initial_state() -> GameState: return AnteState.new() # ── Trigger system ── enum Trigger { ROUND_START, BEFORE_SHOWDOWN, AFTER_SHOWDOWN, ROUND_END } func run_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.gd class_name MatchConfig extends RefCounted var 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.

Before — match_runner.gd
PlayCardEvent.register() # manual _network_service = NetworkService.new() _network_service.name = "NetworkService" add_child(_network_service) _launcher = MatchSetup.new() _launcher.name = "GameLauncher" add_child(_launcher) var game_factory = func( timeline_manager: TimelineManager, mode: Game.LaunchMode, p_host_id: String, sim_timeline_managers: Dictionary ) -> Game: return SimpleCardGame.create( timeline_manager, mode, p_host_id, sim_timeline_managers) var ai_factory = func(...): return CardGameAI.new(...) _game = await _launcher.launch_game( game_factory, ai_factory, player_ids, host_id, _network_service, self)
After — match_runner.gd
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 orchestration static func launch(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 declaration for event_type in game.get_event_types(): if event_type.has_method("register"): event_type.register() # 3. Create network service var net = NetworkService.new() parent.add_child(net) await net.start_host(host_id, NetworkService.TransportType.IN_MEMORY) # 4. Create timeline managers var 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 contract for pid in player_ids: var player_data = game.create_player(pid) game._players[pid] = player_data # Walk the PlayerData's fields, find all NetworkGameData instances for prop in player_data.get_property_list(): var val = player_data.get(prop.name) if val is NetworkGameData: game.game_data_registry.register_instance(val) game.player_ids.append(pid) host_tm.add_player_timeline(pid) # 7. Create AI if 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, start for 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 logic No determinism verification No 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.gd class_name TestCase extends Node var _assertions: int = 0 var _failures: Array = [] func assert_eq(actual, expected, msg: String = ""): _assertions += 1 if actual != expected: _failures.append("%s: expected %s, got %s" % [msg, str(expected), str(actual)]) func assert_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.gd class_name TestItems extends TestCase func test_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") func test_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") func test_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") func test_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") func test_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") func test_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.gd class_name TestFullMatch extends TestCase func test_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") func test_data_sync_matches_host(): # Verify that sim timeline registry matches host after match 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 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.gd func test_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) func test_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")

Layer 5: Test Runner

# Run all tests headlessly: godot --path simulation/ --headless -s tests/run_all.gd # Output: [PASS] TestItems.test_banana_peel_forces_redraw [PASS] TestItems.test_poison_mushroom_applies_effect [PASS] TestItems.test_shield_blocks_poison [PASS] TestItems.test_poison_ticks_on_round_start [PASS] TestItems.test_poison_expires_after_duration [PASS] TestItems.test_permanent_effect_never_expires [PASS] TestAutoSerialize.test_round_trip_owner [PASS] TestAutoSerialize.test_owner_only_fields_hidden [PASS] TestFullMatch.test_match_completes_with_winner 9 tests, 22 assertions, 0 failures

TestHelper: Creating Games Without MatchSetup

Unit tests need a lightweight way to create a game with data, without the full MatchSetup pipeline. TestHelper provides this:

# engine/testing/test_helper.gd class_name TestHelper # Create a minimal game with players and data, no network, no timeline static func create_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 NetworkGameData for prop in pd.get_property_list(): var val = pd.get(prop.name) if val is NetworkGameData: 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.1 Problem 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.

# engine/events/game_log_event.gd — Human-readable game event class_name GameLogEvent extends SimulationEvent var message: String # "player_1 deals 8 damage to player_2" var event_type: String # "damage", "heal", "status_applied", "card_played" var source_id: String var target_id: String var data: Dictionary # Arbitrary structured data: {amount: 8, card: "Fireball"} func pack(buffer: StreamPeerBuffer): super(buffer) buffer.put_utf8_string(message) buffer.put_utf8_string(event_type) buffer.put_utf8_string(source_id) buffer.put_utf8_string(target_id) buffer.put_utf8_string(JSON.stringify(data)) func unpack(buffer: StreamPeerBuffer): super(buffer) message = buffer.get_utf8_string() event_type = buffer.get_utf8_string() source_id = buffer.get_utf8_string() target_id = buffer.get_utf8_string() data = JSON.parse_string(buffer.get_utf8_string())

Game States Emit Rich Events

When a game state resolves, it logs what happened. Items and effects can also log via game.record_event():

# Inside ShowdownState, after determining winner: game.record_event(GameLogEvent.new( "player_1 wins with Full House, takes 30 chips", "showdown", "player_1", "", {"hand_rank": "full_house", "pot": 30})) # Inside PoisonEffect.execute(): game.record_event(GameLogEvent.new( "%s loses %d chips to Poison" % [owner_id, chip_loss], "effect", owner_id, "", {"effect": "Poison", "amount": chip_loss}))

Event Registration: String-Based, Auto-Discovered

Before — magic integers, manual registration
# play_card_event.gd const SIMPLE_ACTION_TYPE = 100 static func register(): PlayerEvent.register_player_event_type( 100, PlayCardEvent) # match_runner.gd — must remember! PlayCardEvent.register()
After — declared in game, auto-registered
# use_items_event.gd func get_event_name() -> String: return "use_items" # poker_mario_kart.gd func get_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.

GAME LAYER (you write this) ENGINE LAYER (provided) INFRASTRUCTURE (you never touch this) TEST LAYER YourGame extends Game class PlayerData (contract) create_player(id) → PlayerData get_event_types() get_initial_state() → GameState YourData extends NGameData @export var hp, mana... (auto-packed) YourItem extends Item on_play(game, user_id) — one file each YourStates extends GameState _on_enter(), get_next_state() YourEffect extends PersistentEffect get_trigger(), execute(game, pid) ItemTable — loot tables, rubber-banding weights, dice roll → Item mapping Game (base class) player(pid) → PlayerData contract run_trigger(Trigger) — simple loop StateMachine transition_to(state) data-bound auto-transitions Item / PersistentEffect Item: on_play() — immediate PersistentEffect: trigger + duration NetworkGameData auto pack/unpack from @export MatchSetup.launch(MatchConfig) — one call to launch a game reads typed config + PlayerData contract → wires everything automatically AuthoritativeRegistry storage + permissions + snapshots TimelineManager record + replay + GameLogEvent NetworkService transport + sync workers ContextNode (DI) hierarchical service locator RegistrySync InMemory / RPC / ENet SimulationEvent STATE_CHANGE + UI_EVENT + LOG EffectList per-player trigger lookup + storage InMemoryConnector local transport (no network) LogManager / HotReload / GameUtils logging, dev tooling, utilities Unit Tests TestItems TestAutoSerialize TestPersistentEffects Integration Tests TestFullMatch TestDataSync TestNetworkVisibility Stress Tests 100-game stability Aggregate win rate stats JSON output for CI TestHelper create_game() — no MatchSetup Isolated game + data for tests No network, no timeline Test Runner: godot --headless -s tests/run_all.gd Auto-discovers test files, runs all test_ methods, reports pass/fail, exits with code 0/1 for CI Coverage target: effects (100%), data serialization (100%), match flow (smoke), network sync (visibility + permissions) CI: Run on every push. Block merge on failure. Export test results as JSON artifact.

The Key Boundaries

LayerYou TouchNever TouchWhy
GameGame subclass, data classes, items, persistent effects, statesThis is YOUR game. All creativity lives here.
EngineRarely (to extend base classes)Game, StateMachine, Item, PersistentEffect, MatchSetup, NetworkGameDataThese are stable abstractions. Extend, don't modify.
InfrastructureNeverRegistry, Timeline, Network, Sync, DI, EventsBattle-tested plumbing. It works. Don't break it.
TestsWrite tests for your game logicTestCase, TestHelper, TestRunnerFramework provides structure. You provide assertions.

What Changes vs What Stays

ComponentStatusDetails
AuthoritativeRegistryStaysUnchanged. Core storage + permissions.
ContextNode / DIStaysUnchanged. Hierarchical service locator.
TimelineManagerStays + extendedGains GameLogEvent type for rich events.
RegistrySync (all variants)StaysInMemory, RPC, ENet — all unchanged.
NetworkServiceStaysUnchanged. Transport + sync orchestration.
StateMachine / GameStateStaysUnchanged. Data-bound transitions work as-is.
NetworkGameDataModified (S1)Auto pack/unpack from @export. Custom override still works.
Game base classModified (S3)Adds PlayerData contract, player() accessor, declarative methods.
EffectResolverReplaced (S2)Switch statement → Item + PersistentEffect base classes. Trigger-lookup, no event bus.
MatchSetupModified (S3)Typed MatchConfig input. Inspects PlayerData contract. run() → launch().
PlayerEvent registrationModified (S5)Magic ints → string names. Auto-registered from game declaration.
SimulationEventExtended (S5)Gains GameLogEvent (LOG type). Existing types unchanged.
Test infrastructureNew (S4)TestCase, TestHelper, TestRunner, all test files.