Files
systemic-game-design-course/course/01-handles/lesson.md
T
Lucas WintherandClaude Opus 5.5 fb9d0a4eb4 feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)
Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks,
the cumulative reference (Module 11's plus combat) and the drop. The Damage
action carries per-target hits with a priority; react stable-sorts its Queue by
priority before order of play, so the triggers answering the defender's Damage
Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack
because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari
Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness
contract, catalog, research notes and earlier lessons' forward pointers follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:34:20 +01:00

22 KiB

Module 1: Handles and order of play

Your real engine starts here. In this module it learns three things: every card in the game has an id that never changes, every card is in exactly one zone, and every card on the board knows when it entered play. None of this is exciting yet. Every later module stands on it.

You need the workspace you copied from the template at the end of Module 0, under git.

Observe

Acolyte of Pain is a 3-mana 1/4 (Acolyte of Pain, hearthstone.wiki.gg):

Whenever this minion takes damage, draw a card.

You have two Acolytes of Pain on your board. Call them A and B. You played A first. For this exercise, suppose B stands to the left of A. That is a setup the course chooses, not a claim about how the game places minions; an example below shows one sourced way the newest minion can end up left of an older one. You cast Whirlwind: "Deal 1 damage to ALL minions." (Whirlwind).

Both Acolytes take damage and both draw a card. By the rule quoted next, A's trigger goes first, even though B is further left. When Hearthstone orders triggers, it does not ask where a minion stands. It asks when the minion entered play. The Advanced rulebook's Rule 2a: "A Queue is created and filled with all triggers that can respond, in order of play." And its definition: "Order of play means the order the Entities each Event/trigger is associated with entered play, from oldest to newest." (Advanced rulebook). Area damage follows the same order: "all of the Damage/Healing Events are created in play order" (same source).

Board position and order of play really do come apart in the game. Harvest Golem says "Deathrattle: Summon a 2/1 Damaged Golem." (Harvest Golem), and Deathrattle summons appear "in the board position the Death Event remembered from when it was created" (Advanced rulebook). The Damaged Golem is the newest minion on the board, standing wherever the old Golem stood. So if the old Golem stood at the far left, the newest minion stands there too. That example is the course's reading of the quoted rule; the rulebook doesn't spell it out.

So the engine has to remember two different orders for the same minions: where they stand, and when they arrived.

Predict

Write your answers down before you open the discussion, for example in notes/01.md in your workspace.

  1. In the Observe scenario, your engine keeps each player's board as a list, left to right. Can it work out from that list alone which Acolyte triggers first? If not, what extra fact does it need, and when should that fact be recorded?
  2. Later modules hold on to minions across several steps. A queued trigger has to remember which minion it belongs to; a minion that has died still needs its Deathrattle resolved. What should that "remembering" store: the minion object itself, its index on the board, or something else? What goes wrong with each?
Discussion
  1. No. The board list gives position, and position is the wrong order. The engine needs each minion's order of play: a number that says when it entered play, larger meaning later. It must be recorded at the moment the minion arrives on the board, because nothing about the minion afterwards (its position, its stats, which card it was) tells you when that was. Note what the rulebook's wording implies: it is the moment of entering play that counts, not the moment the card was created. Keep that in mind for the Expansion Drop.

  2. A board index goes stale as soon as anything moves: a minion to its left dies or a new one is summoned in the middle, and index 2 is now someone else. A direct reference (a pointer, Rc<RefCell<…>>, a plain JavaScript object) survives moves, but it survives too much. Module 0's Whirlwind looped over a copy of the board and could have hit a minion that had already died, because the reference cannot tell you the minion is gone. In Rust, shared mutable references also fight the borrow checker every step of the way.

    The answer is a handle: a plain id the engine issues once and never reuses. Anything that needs to remember a minion stores its id and asks the engine about it when needed: where is it, who owns it, is it still on the board. The engine stays the only owner of the data.

Spec

From the course repository, copy the module's tests into your workspace:

node tools/start-module.mjs 01 ~/my-engine

It prints the harness functions this module adds. They are listed in full in course/harness.md (M1 line):

TypeScript Rust
newGame(opts?) new_game(opts: GameOpts) -> Game
summon(card, player?) summon(&mut self, card: &str, player: Player) -> Id
addToHand(card, player?), addToDeck(card, player?) add_to_hand(...), add_to_deck(...)
zoneOf(id) zone_of(&self, id: Id) -> Zone
board(player), hand(player) board(&self, player) -> Vec<Id>, hand(...)
orderOfPlay(id) order_of_play(&self, id: Id) -> u32
nameOf(id) name_of(&self, id: Id) -> String
play(handId) play(&mut self, hand_id: Id): leave it as a stub until the Expansion Drop

In TypeScript, player defaults to 0. Rust has no default arguments, so the tests pass the player explicitly.

The four tests (full statements in course/01-handles/scenarios.md):

  • test_01_ids_stable_and_order_increases: summon two Test Fillers A then B. The ids differ, A's order of play is lower than B's and does not change when B arrives, A still names the same minion, and board(0) is [A, B].
  • test_01_zones: a card added to the deck, one added to the hand and one summoned report deck, hand and board.
  • test_01_board_query_per_player: each player's summons appear only on that player's board.
  • test_01_names: nameOf returns the card's name, on the board and in hand.

Test Filler is a fictional minion with no text, used wherever a test needs "some minion". The first test looks like this:

#[test]
fn test_01_ids_stable_and_order_increases() {
    let mut g = new_game(GameOpts::default());
    let a = g.summon("Test Filler", 0);
    let order_a = g.order_of_play(a);
    let b = g.summon("Test Filler", 0);

    assert_ne!(a, b);
    assert!(g.order_of_play(a) < g.order_of_play(b));
    assert_eq!(g.order_of_play(a), order_a);
    assert_eq!(g.name_of(a), "Test Filler");
    assert_eq!(g.zone_of(a), Zone::Board);
    assert_eq!(g.board(0), vec![a, b]);
}
test("test_01_ids_stable_and_order_increases", () => {
  const g = newGame();
  const a = g.summon("Test Filler", 0);
  const orderA = g.orderOfPlay(a);
  const b = g.summon("Test Filler", 0);

  expect(a).not.toBe(b);
  expect(g.orderOfPlay(a)).toBeLessThan(g.orderOfPlay(b));
  expect(g.orderOfPlay(a)).toBe(orderA);
  expect(g.nameOf(a)).toBe("Test Filler");
  expect(g.zoneOf(a)).toBe("board");
  expect(g.board(0)).toEqual([a, b]);
});

Run cargo test or npm test. All four tests fail: the harness stubs are still todo!() / throw. That is RED.

Build

Make the four tests pass. Two rules for where code goes:

  • Generic machinery (entities, zones, order of play) goes in src/engine/.
  • Test Filler is a card, so it is defined in src/content/. The engine must not contain its name. From this module on, the coupling audit checks that (see Name it).

src/harness stays thin: each harness function should be one or two calls into your engine.

Don't build graveyards, heroes or damage yet. The Graveyard zone exists in the harness type, but nothing moves there until Module 3.

Hint 1 — nudge

Who owns the truth about a card? Try keeping all cards in one table inside the engine, keyed by id, and keep each player's zones as lists of ids rather than lists of cards. Then ask: when exactly does a card get its order of play?

Hint 2 — concept
  • Id (handle): a number issued when a card is created and never reused. It says which card, nothing else.
  • Entity record: what the engine knows about that id: card name, owner, current zone, order of play.
  • Zones: per player, an ordered list of ids for each zone. The board list's order is board position; nothing else needs to store position.
  • Order of play: a separate counter, stamped on the entity when it enters the board. It is not the id and not the board index. A card created in the hand early and played late must get a late order of play.

Give the engine one function that moves an entity into a zone, and make that function the only place the stamp happens. Summoning is then "create the entity, move it to the board".

Hint 3 — pseudo-code
entities: list of { card, owner, zone, orderOfPlay? }   // index = id
zones[player][zone]: list of ids                          // board is left to right
nextOrder = 0

create(card, owner, zone):
    check card exists in the content library
    id = entities.length
    entities.push({ card, owner, zone })
    enter(id, zone)
    return id

moveTo(id, zone):
    remove id from zones[owner][current zone]
    enter(id, zone)

enter(id, zone):                       // the only way into a zone
    zones[owner][zone].push(id)        // right end
    entity.zone = zone
    if zone == board:
        nextOrder += 1
        entity.orderOfPlay = nextOrder

summon(card, p)    = create(card, p, board)
addToHand(card, p) = create(card, p, hand)

When your tests pass, compare with the reference engine in course/01-handles/reference/rust/src/engine/ or course/01-handles/reference/ts/src/engine/. The reference engine names no cards; its content library is one line defining Test Filler.

How the real game differs The course stamps order of play only when a minion enters the board. The rulebook's order of play is about Entities entering play in general, and it has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and in combat "the defender ALWAYS triggers first, regardless of order of play" (Advanced rulebook). The course models the combat rule in Module 12, as a priority on queue entries; the other two are not modelled. The course also has no heroes yet (Module 2 adds them), and it puts every summoned or played minion at the right end of the board instead of letting a player choose a position.

Name it

The pattern is handles: refer to game objects by stable ids that the engine owns, never by pointers or positions. The Argentum engine (a Magic: The Gathering rules engine on GitHub) states it as a principle: "every card, token, player, spell, and ability is just an EntityId" (Argentum, docs/architecture-principles.md §2.2). The phase-rs engine does the same in Rust with a newtype, pub struct ObjectId(pub u64); in crates/engine/src/types/identifiers.rs.

The second idea is that order of play is a fact recorded at entry, separate from identity and from position. In the reference engine, one private function is the only way an entity arrives in a zone, and it is where the stamp happens:

/// The single place an entity arrives in a zone. Entering the board starts a new order of play.
fn enter(&mut self, id: EntityId, zone: Zone) {
    let owner = self.entity(id).owner;
    self.sides[owner as usize].zone_mut(zone).push(id);
    let entity = &mut self.entities[id as usize];
    entity.zone = zone;
    if zone == Zone::Board {
        self.next_order_of_play += 1;
        entity.order_of_play = Some(self.next_order_of_play);
    }
}
/** The single place an entity arrives in a zone. Entering the board starts a new order of play. */
private enter(id: EntityId, zone: Zone): void {
  const entity = this.entity(id);
  this.sides[entity.owner][zone].push(id);
  entity.zone = zone;
  if (zone === "board") entity.orderOfPlay = ++this.nextOrderOfPlay;
}

Every later rule that needs "in order of play" (damage triggers in Module 2, Death Phases in Module 3) sorts ids by this number. None of them will care how the minion got onto the board.

Run the coupling audit. This is the Zero-Coupling Law from Module 0, now checked by a tool:

node tools/coupling-audit.mjs ~/my-engine

It searches the directories listed as engineDirs in your workspace's .course.json (src/engine) for the name of any card in the course catalog, in several spellings (Test Filler, test_filler, TestFiller, …). It should print coupling-audit: ok. If it flags a line, move that card-specific code into src/content/.

Expansion Drop

A new rule arrives: cards can be played from the hand. play(handId) moves the card from its owner's hand to the right end of their board, and a played minion gets its order of play when it is played.

The constraint is the one every Expansion Drop has: do not edit anything under src/engine/. Only the harness (and content, if you need it) may change.

  1. Commit your workspace and note the commit: git add -A && git commit -m "module 1 done".
  2. Copy the expansion test for your track from course/01-handles/expansion/tests/rust/test_01x.rs or course/01-handles/expansion/tests/ts/test_01x.test.ts into your workspace's tests/. Run it and watch it fail.
  3. Implement play in your harness.
  4. When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD

HEAD is the commit from step 1, as long as you have not committed since. The check fails if any file under src/engine/ changed.

The test, test_01x_play_assigns_order_on_entry, adds a card to the hand first (H), summons a minion second (S), then plays H. H must end up on the board after S, and with the larger order of play: S entered play first, even though H was created first.

Discussion: what this drop measures

If your engine stamps order of play in one place, on entry to the board, then play is a single call to your existing zone move, and the engine does not change. The reference answer is exactly that (course/01-handles/expansion/solution/):

pub fn play(&mut self, hand_id: Id) {
    assert_eq!(
        self.world.zone_of(hand_id),
        Zone::Hand,
        "play: entity {hand_id} is not in a hand"
    );
    self.world.move_to(hand_id, Zone::Board);
}
play: (handId) => {
  if (world.zoneOf(handId) !== "hand") throw new Error(`play: entity ${handId} is not in a hand`);
  world.moveTo(handId, "board");
},

Two common shapes fail this drop, and each failure points at a design choice:

  • The id is used as the order of play. H was created before S, so H has the smaller id and the test's last assertion fails. Identity and order of play are different facts.
  • The stamp lives in summon. Then playing a card either never stamps it, or you copy the stamping into a new engine function. The test passes, but the check fails because src/engine/ changed. Moving the stamp to the zone move is the right fix, but it is an engine change, and the point of the drop is to show you which rule was in the wrong place.

If you hit either, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.

Stress it

Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.

  1. A minion on the board is returned to its owner's hand and played again. After that: is its id the same? Is its order of play the same? Add a bounce(id) to your harness (board to hand, harness only, no engine change) and write the test before you run it.
Discussion

In an engine shaped like the reference, the id is the same and the order of play is new and larger. The id says which card; nothing about the card changed. The order of play says when it entered play, and it entered play again. If bounce is one call to your zone move and play is another, the stamp happens for free, for the same reason the Expansion Drop cost nothing.

Whether a card that changes zones should keep its identity is a real design fork, and the two big games chose differently:

  • Hearthstone's data keeps the zone as a tag on the entity. python-hearthstone's GameTag has ZONE = 49 and ZONE_POSITION = 263, and its Zone enum lists PLAY, DECK, HAND, GRAVEYARD and more (enums.py). That is the shape you built: one entity record, with a zone field that changes. (The enum file is names and numbers only. That entities keep their id across zones is the course's reading of that shape, not a quoted rule.)
  • Magic: The Gathering says the opposite in so many words, rule 400.7: "An object that moves from one zone to another becomes a new object with no memory of, or relation to, its previous existence." (Comprehensive Rules). Under that rule a bounced creature forgets its damage, its enchantments and every effect that was tracking it, by definition.

Neither is wrong. "Same id, fields reset on a zone change" and "new id on every zone change" can express the same games. What differs is which mistakes are easy: with one id, forgetting to reset something; with new ids, forgetting to carry something over (Argentum's "Rules object identity across zones", in Further reading, is about exactly that).

  1. Your ids are never reused. Is that a memory leak? A long game creates thousands of tokens. When would you have to reuse an id, and what would then stop an old handle from silently pointing at a new minion?
Discussion

For a card game it is not a problem worth solving: a game creates a few hundred entities, and a record per entity is small. Never reusing ids buys the strongest guarantee a handle can have, that it means one thing forever.

Engines that create and destroy objects by the million can't afford that, and reuse array slots. The standard protection is a generation counter: the handle is (slot index, generation), the slot stores its current generation, and a lookup with a stale generation fails instead of returning the slot's new occupant. Andre Weissflog's Handles are the better pointers describes the scheme (his 2018 update adds "a per-slot generation counter" to prevent "handle collisions"), and Catherine West's RustConf 2018 keynote calls it by its usual name: "Generational indexes are awesome." (Using Rust for Game Development). Her talk is also the best account of why Rust pushes you to ids in the first place.

Notice what both schemes share with yours: a stale handle must be detectably stale. An index into the board fails that test, which is Predict question 2, and it will come back in Module 3.

  1. Write down three facts that are true of your world after any sequence of summon, addToHand, addToDeck, play and bounce calls, whatever the order. Then write one test that makes 200 random calls and checks all three after each call.
Discussion

Three that hold in the reference:

  • Every id is in exactly one zone list, and that list belongs to the zone the entity record names.
  • No two minions on the board (either board) have the same order of play.
  • An entity has an order of play if and only if it has been on the board at some time.

These are invariants: facts no operation may break. The four Module 1 tests each check one path through the engine; an invariant test checks the same few facts on paths nobody thought to write. It needs no library, only a random number generator you seed yourself, so a failure can be replayed:

seed = 12345
next():  seed = (seed * 1103515245 + 12345) mod 2^31;  return seed

repeat 200 times:
    pick an operation with next(); if it needs an id, pick one that is legal for it
    do it
    assert every invariant
on failure: print the seed and the operations so far

One thing a tempting fourth invariant gets wrong: "the board list is sorted by order of play" is false. It holds while every minion arrives at the right end, and stops holding the first time one arrives anywhere else (Module 3's Deathrattle summons). Position and order of play are different facts, which is the point of this module. Module 13 takes this style of testing further.

Further reading

  • Celia Wagar, Card Game Design as Systems Architecture (CritPoints, 2023). A designer's essay on why card games behave like long-lived software: "Card games are surprisingly a lot like enterprise web applications." Read it for the framing of the whole course; it has no engine code.
  • Argentum, docs/architecture-principles.md §2.2, Entity-Component-System. Read the principle and the subsection "Rules object identity across zones". Note that its id allocator "is independent of timestamps used to order continuous effects": the same split between identity and ordering you built here.
  • Advanced rulebook, hearthstone.wiki.gg: read the definition of order of play and Rule 2 on Queues. Module 2 builds the Queue.
  • Andre Weissflog, Handles are the better pointers (2018). The case for handles from the systems side: "move all memory management into centralized systems" and "only return an 'index-handle' to the outside world". Read the update at the end on generation counters.
  • Catherine West, Using Rust for Game Development (RustConf 2018 closing keynote, written up). A long walk from an object-oriented game design to ids and generational indexes, driven by the borrow checker. Worth reading on the Rust track before Module 2.