Files
systemic-game-design-course/course/01-handles/lesson.md
T
2026-10-29 00:17:31 +01:00

16 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. B sits to the left of A. You cast Whirlwind: "Deal 1 damage to ALL minions." (Whirlwind).

Both Acolytes take damage and both draw a card. 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, possibly at the far left.

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). None of these are modelled in this course. 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.

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.