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.
- 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?
- 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
-
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.
-
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 twoTest 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, andboard(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:nameOfreturns 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 Filleris a card, so it is defined insrc/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.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 1 done". - Copy the expansion test for your track from
course/01-handles/expansion/tests/rust/test_01x.rsorcourse/01-handles/expansion/tests/ts/test_01x.test.tsinto your workspace'stests/. Run it and watch it fail. - Implement
playin your harness. - 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 becausesrc/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.