Files
systemic-game-design-course/course/05-assemble/lesson.md
T
Lucas WintherandClaude Opus 5.5 21fb40c6f5 feat: robustness in Module 5 (nesting guard, Death Phase limit, well-formed Assemble, multi-summon order)
A trigger is never queued while that same (minion, trigger) is resolving;
more than DEATH_PHASE_LIMIT = 64 Death Phases is an error ('Death Phase
limit'); Assemble refuses and never offers ill-formed text (wellFormed /
well_formed); summon count > 1 places tokens in summon order. Modules 05-10,
both tracks. Four new Module 5 tests, a tools test that every shipped card
is well-formed, lessons 2, 3, 5, 13 and harness.md updated.

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

43 KiB
Raw Blame History

Module 5: Behavior as data and Assemble

In Modules 2–4 a card's behaviour was a function in your content: Harvest Golem's Deathrattle was a few lines of Rust or TypeScript that returned a Summon action. That was enough to keep card names out of the engine. It is not enough for this module's mechanic, which takes half of one card's text and glues it onto another card. You can't cut a function in half. So in this module every card becomes data, a JSON file that your engine reads and interprets, and the functions in your content go away. Then Assemble is a few lines of list surgery, and the last Hearthstone card of the course is a data file.

You need your Module 4 workspace with all Module 1–4 tests passing, under git. The Module 4 Expansion Drop (Brann Bronzebeard) is not needed. This module is mostly a refactor, and your Module 1–4 tests are its safety net: they must stay green while every card moves out of code.

Observe

Assemble arrived in Patch 37.0 (October 2026), with the Reign of the Black Empire expansion (Assemble). Blizzard's announcement says only "Combine minions from a curated pool to build custom creations." (Reign of the Black Empire Is Now Live!). The keyword's reminder text:

Get a random minion. Change half its text.

The wiki page gives the details (all from Assemble, paraphrased where not quoted):

  • It "adds a random minion to the hand, then presents two other minions."
  • The added minion "can have either the top or bottom half of its text replaced with the corresponding text from either presented minion".
  • The Assembled minion keeps its own stats, cost, name, art, rarity and minion type. Only its text changes.
  • "The Assembled minion must have to have its text altered." (The typo is the wiki's.)
  • The base comes from a curated pool of about 250 minions whose text can be split in half. The two presented minions cost the same as the base or up to 2 more.
  • When a card names a pool, only the base minion must come from it.

One of the 13 cards with Assemble is a spell, Fleshshape, 3-mana (Fleshshape):

Assemble a Deathrattle minion. Give all minions in your hand +2/+2.

Two minions you already have, and one new one (Harvest Golem, Dread Infernal, Loot Hoarder):

  • Harvest Golem, 3-mana 2/3: "Deathrattle: Summon a 2/1 Damaged Golem."
  • Dread Infernal, 6-mana 6/6: "Battlecry: Deal 1 damage to ALL other characters."
  • Loot Hoarder, 2-mana 2/1: "Deathrattle: Draw a card."

Notice what the wiki does not say: what a "half" is. It gives no worked example, and it doesn't say whether the split is between trigger and effect, between two sentences, or something else (Assemble). For "Deathrattle: Summon a 2/1 Damaged Golem." the obvious reading is "Deathrattle:" on top and "Summon a 2/1 Damaged Golem." below, but nothing in the sources confirms it. The course has to define "half" itself, and it does so in data: see Spec.

Predict

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

  1. Take Harvest Golem and replace its bottom half with Dread Infernal's. What does the new card say? Its stats? You summon Baron Rivendare, a 1/1, a 1/3 and then the new card, and destroy the new card. Count the damage events. When does the 1/1 leave play?
  2. In your Module 4 engine, Harvest Golem's Deathrattle is a function. Write down what assemble("Harvest Golem", "Dread Infernal", "bottom") would have to do with that function. Can it?
  3. Replace Loot Hoarder's top half with Novice Engineer's. What does the card do when you play it? When it dies?
  4. Assemble Loot Hoarder with Loot Hoarder. Now replace Loot Hoarder's bottom half with Novice Engineer's bottom half. What should happen in each case?
  5. Fleshshape picks a minion at random. How do you write a test whose expected log is exact?
Discussion
  1. "Deathrattle: Deal 1 damage to ALL other characters." It is still Harvest Golem's 3-cost 2/3 body. With Baron, the Deathrattle activates twice: each activation damages both heroes and the three minions still on the board, so ten Damaged events and both heroes at 28. The 1/1 is at 0 Health after the first activation but stays in play (Module 3), so the second activation hits it again. It leaves play in the Death Creation Step after the Death Phase, and the 1/3 survives at 1.

  2. It can't. A function is opaque: there is no "top half" of a closure to keep and no "bottom half" of another closure to splice in. You could special-case pairs of cards, or write the effects as several small functions that assemble recombines, but at that point you are already turning text into data, just without saying so.

  3. "Battlecry: Draw a card." on Loot Hoarder's 2/1 body: one draw when played, nothing when it dies. The Deathrattle is gone.

  4. Both are errors: the new text equals the old. The first is obvious. The second is less so: Loot Hoarder's and Novice Engineer's bottom halves are both "Draw a card.", so the "changed" card is identical. The rule ("must have its text altered") is about the result, not about which card the half came from, which is why assemble compares texts.

  5. You don't let the engine roll dice in the test. Every random choice goes through one function the game owns, a chooser; the real game asks the player or picks at random, and a test injects a chooser that answers what the test wants and records what it was offered. The test then checks the offer, which is the part of the rule that is deterministic.

Spec

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

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

start-module now also copies the course's card files into ~/my-engine/cards/: one JSON file per card, in the format of course/schema/card.schema.json. course/cards/ is the single source; re-running start-module refreshes those files and keeps any you added.

The card format

A card is a name, a type (minion or spell), a cost, Attack and Health for minions, a text and a split:

{
  "name": "Harvest Golem",
  "type": "minion",
  "cost": 3,
  "attack": 2,
  "health": 3,
  "split": 1,
  "text": [
    { "kind": "trigger", "on": "deathrattle" },
    { "kind": "effect", "effect": { "type": "summon", "card": "Damaged Golem", "count": 1 } }
  ]
}
  • text is a list of clauses. A clause is a keyword (for example taunt), a trigger (on: deathrattle, battlecry, selfDamaged, friendlyMinionDamaged, anyMinionDamaged, heroDamaged, endOfTurn), an aura (one of Module 4's two kinds: the trigger multiplier, now in its final form, or Bolf Ramshield's damageRedirect) or an effect.
  • A trigger clause applies to the effect clauses after it, until the next trigger, keyword or aura. Effect clauses before any trigger are what a spell does when cast.
  • Effects: dealDamage{target, amount}, draw{count}, summon{card, count}, gainArmor{amount}, gainAttack{target, amount}, destroy{target}, and two new ones, assemble{filter} and buffHand{attack, health}. Targets: self, allMinions, allOtherMinions, allOtherCharacters, enemyHero, chosen. (spawnText belongs to Part III; you can ignore it until then.)
  • split is the course's definition of "half": the top half is text[0:split], the bottom half text[split:]. Each card's data says where its top half ends. For the course's trigger cards that is right after the trigger, so "Deathrattle:" is the top and "Summon a 2/1 Damaged Golem." the bottom. Baron Rivendare's single aura clause has split 0: all bottom.

The harness

TypeScript Rust
registerCards(defs: CardDef[]) register_cards(&mut self, defs: Vec<CardDef>)
assemble(base, donor, half: "top" | "bottom"): string (throws AssembleError) assemble(&mut self, base: &str, donor: &str, half: Half) -> Result<String, AssembleError>
cost(id): number cost(&self, id: Id) -> i32
setChooser(choose: (kind, options) => string) set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)
  • CardDef is the card format. In Rust it must implement serde::Deserialize, because the tests parse cards/*.json with serde_json: add serde = { version = "1", features = ["derive"] } and serde_json = "1" to Cargo.toml. In TypeScript the tests import the files with Vite's import.meta.glob("../cards/*.json"), which Vitest supports out of the box.
  • newGame loads every file in cards/. Nothing else knows the cards.
  • assemble(base, donor, half) registers a new card, named "<base> (Assembled)", whose half is replaced by the same half of donor. Everything else is the base's. It fails with AssembleError if the new text equals the base's, or if it is not well-formed: for a minion, every effect clause belongs to a trigger (none comes before the first trigger, or after an aura or keyword), and every trigger has at least one effect clause before the next trigger, aura, keyword or the end. Stress it question 1 shows what goes wrong without this rule.
  • The chooser makes the choices an assemble effect needs, in this order: "base" (the names of the pool minions the filter allows that have at least one donor, sorted), "donor" (the pool minions with a half that gives the base a changed, well-formed text, sorted), "half" ("top", "bottom": those that do). Until a test sets one, any deterministic default will do.

Once a test can write any card as data, a test can also write a card that never stops. This module therefore adds three rules about a Sequence:

  • No trigger nests inside itself. A trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. This is Hearthstone's rule as the Advanced rulebook reads it: "It seems that a trigger cannot be nested inside itself." (Advanced rulebook). Module 2's Stress it question 1 asked about it.
  • At most 64 Death Phases. More than 64 Death Phases in one Sequence is an error: TypeScript throws an Error and Rust panics, with a message containing Death Phase limit. The rulebook says the Death Phase cycle "repeats forever until no new Deaths occur" (same source); it names no limit. The limit, and its value, are the course's choice. Module 3's Stress it question 1 asked about it.
  • Several tokens stand in summon order. A summon effect with count 2 or more places its tokens left to right, each immediately right of the one before.

The tests

The full statements are in course/05-assemble/scenarios.md.

  • test_05_regression_data_cards: every file in cards/ parses, registerCards accepts them all, and every minion summons with the file's name, Attack, Health and cost. The rest of the regression is your Module 1–4 tests: they now run against cards loaded from data.
  • test_05_assemble_bottom_half: Harvest Golem with Dread Infernal's bottom half, under Baron, next to a Test Filler (1/1) and a Test Sponge (1/3). Destroy it. Exact log: two activations of "Deal 1 damage to ALL other characters", the Filler dies afterwards, both heroes at 28.
  • test_05_assembled_keeps_base_stats: the same card in hand costs 3 and is 2/3.
  • test_05_assemble_must_change_text: Loot Hoarder with Loot Hoarder fails, top and bottom.
  • test_05_assemble_never_rewrites_an_earlier_card: Harvest Golem is re-registered with a new cost, and the same Assemble is made again. The second card gets a new name, and the first card in hand keeps its cost.
  • test_05_top_half_swap: Loot Hoarder with Novice Engineer's top half. Playing it draws once; destroying it draws nothing.
  • test_05_assemble_effect_with_chooser: cast Test Workshop ("Assemble a Battlecry minion. Give all minions in your hand +1/+1.") with an injected chooser. The base options are exactly the Battlecry minions in cards/; the new minion and a Test Filler already in hand both get +1/+1.
  • test_05_additive_multipliers_stack: two Test Chorus ("Your minions trigger their Deathrattles an additional time.", stacking additive) and a Loot Hoarder: three activations. Module 4 built the additive branch; this is the first card that uses it, and it needed no code.
  • test_05_summoned_multiplier_counts_for_later_entries and test_05_own_aura_misses_own_battlecry: two timing rules from Module 4 that no Module 4 card could reach. Each test registers a one-off card written as data inside the test (a Deathrattle that summons Baron Rivendare; a minion with a Battlecry and its own Battlecry multiplier). If your Module 4 engine followed the lesson's Aura Update schedule, both pass without new code. See Module 4's Stress it, questions 1 and 2.
  • test_05_trigger_does_not_nest_in_itself: two Test Bombs ("Whenever a minion takes damage, deal 1 damage to all minions."), registered in the test. Damage one: exactly six Triggered events, in an order the scenario works out by hand. Work it out yourself before you read it.
  • test_05_death_phase_limit: Test Ember ("Deathrattle: Summon a Test Ember. Deal 1 damage to all minions."). Damage it: 64 Death Phases, then the Death Phase limit error.
  • test_05_assemble_rejects_ill_formed_text: Loot Hoarder with either half of Baron Rivendare fails, and Test Workshop offers no aura-only minion (Baron Rivendare, Bolf Ramshield, ...) as a donor for Novice Engineer.
  • test_05_multi_summon_order: Test Brood summons two Test Fillers from its Battlecry and two from its Deathrattle; each pair stands left to right in the order it was summoned.

The top-half test:

#[test]
fn test_05_top_half_swap() {
    let mut g = new_game(GameOpts {
        decks: Some([fillers(5), vec![]]),
    });
    let name = g
        .assemble("Loot Hoarder", "Novice Engineer", Half::Top)
        .unwrap();
    let e = g.add_to_hand(&name, 0);
    g.clear_log();

    g.play(e);

    let hand = g.hand(0);
    assert_eq!(hand.len(), 1);
    let c = hand[0];
    assert_eq!(
        g.log(),
        vec![
            GameEvent::Summoned { id: e },
            triggered(e, "battlecry"),
            GameEvent::Drew { player: 0, id: c },
        ]
    );
    assert_eq!(g.attack(e), 2);
    assert_eq!(g.health(e), 1);

    g.clear_log();
    g.destroy(e);

    assert_eq!(
        g.log(),
        vec![
            GameEvent::Destroyed { id: e },
            GameEvent::DeathPhase { round: 1 },
        ]
    );
    assert_eq!(g.hand(0).len(), 1);
}
test("test_05_top_half_swap", () => {
  const g = newGame({ decks: [fillers(5), []] });
  const name = g.assemble("Loot Hoarder", "Novice Engineer", "top");
  const e = g.addToHand(name, 0);
  g.clearLog();

  g.play(e);

  const hand = g.hand(0);
  expect(hand).toHaveLength(1);
  const [c] = hand;
  expect(g.log()).toEqual([
    { type: "Summoned", id: e },
    { type: "Triggered", source: e, trigger: "battlecry" },
    { type: "Drew", player: 0, id: c },
  ]);
  expect(g.attack(e)).toBe(2);
  expect(g.health(e)).toBe(1);

  g.clearLog();
  g.destroy(e);

  expect(g.log()).toEqual([
    { type: "Destroyed", id: e },
    { type: "DeathPhase", round: 1 },
  ]);
  expect(g.hand(0)).toHaveLength(1);
});

Run cargo test or npm test. The Module 5 tests fail to compile (Rust) or fail (TypeScript): the harness has no assemble, and Rust has no CardDef the tests can parse. Modules 1–4 still pass. That is RED.

Build

Make the fourteen tests pass, and keep Modules 1–4 green at every step. Where code goes:

  • No card in code. Your content directory becomes a loader: it reads cards/*.json and hands the definitions to the engine. Delete the card functions from Modules 2–4 once the data versions pass the old tests.
  • The engine interprets. src/engine/ gets types that mirror the card format, and one routine that turns an effect plus its context (owner, source, target, position) into the actions you already have. That routine is the only code that knows what draw or dealDamage means. It names effect kinds and targets, never cards.
  • Assemble is engine code that works on text and split and never looks inside a clause beyond comparing it.
  • The assemble effect asks; it doesn't roll. It asks a chooser that lives in your world. Tests inject one through the harness.
  • src/harness stays thin: registerCards, assemble, cost and setChooser each hand one call to the engine.
  • The guards live in the resolution loop. The nesting guard belongs where your Phase fills its Queue, and the Death Phase limit where your Sequence starts each Death Phase. No card knows about either.

A suggested order: (1) add the card types and parse the course's files in a throwaway test; (2) make the engine build its card library from those definitions and run your old card functions' behaviour through the interpreter, until Modules 1–4 pass with no card functions left; (3) assemble; (4) the assemble and buffHand effects and the chooser; (5) wellFormed, the nesting guard, the Death Phase limit and the summon order.

Hint 1 — nudge

Look at what your Module 4 card functions actually did. Each one built a short list of actions from a handful of facts: who owns the card, which minion is running the trigger, where it stood, which minions are in play. Every function is "an action kind plus some parameters". That pair is what the JSON stores; the facts are what your interpreter supplies.

Then Assemble: if a card's text is a list, what is "half of a list"?

Hint 2 — concept
  • Parse, then compile once. When a definition is registered, walk its clauses once and build what the engine reads while resolving: a list of triggers (each with its on and its effects), a list of auras (Module 4's activations reads these unchanged), and a spell's effects. A trigger collects the effect clauses after it until the next trigger, keyword or aura.
  • One interpreter. actions(effect, ctx) -> Action[]. Targets resolve against the context: self is the source minion; allOtherCharacters is both heroes plus the minions in play, minus the source.
  • Interpret one effect at a time. For each effect of a resolving ability, interpret it, perform its actions (depth-first, as before), then read the next effect. Fleshshape's "Give all minions in your hand +2/+2." must see the minion its first sentence put there.
  • Assemble is list surgery. top = text[0:split], bottom = text[split:]. Replacing the top gives donor.top + base.bottom with split = donor.top.length; replacing the bottom gives base.top + donor.bottom with split = base.split. Copy everything else from the base, rename, compare the new text with the base's (structurally), register.
  • The pool is the minions the engine may offer. The reference uses every registered minion with text that wasn't itself made by Assemble.
  • The chooser is a function stored in the world: (kind, options) -> option. The effect calls it three times. Validate the answer.
  • Well-formed is a walk over the clauses with one piece of state: no trigger open, a trigger open with no effect yet, or a trigger open with an effect. An effect with no trigger open fails; anything else arriving while a trigger has no effect fails; so does ending there.
  • The nesting guard needs to know what is resolving. Keep a stack of (minion, trigger) pairs: push when a trigger starts resolving, pop when it ends, and skip a pair that is on the stack when you fill a Queue. If you built logTree in Module 2, the stack's height is your nesting depth.
Hint 3 — pseudo-code
register(def):
    triggers = []; auras = []; onCast = []; open = none
    for clause in def.text:
        trigger: open = {on, effects: []}; triggers.push(open)
        effect:  (open ? open.effects : onCast).push(clause.effect)
        aura:    auras.push(clause.modifier); open = none
        keyword: open = none
    library[def.name] = {def, triggers, auras, onCast,
                         inPool: def is a minion and def.text not empty}

runEffects(effects, ctx):                 // used by react() and by Cast
    for e in effects:
        for a in actions(e, ctx): perform(a)

actions(effect, ctx):
    dealDamage(t, n) -> [Damage(targets(t, ctx), n)]
    draw(n)          -> n x Draw(ctx.owner)
    summon(c, n)     -> for i in 0..n: Summon(c, ctx.owner, at: p + i)
                        // p: right of source, or its death position; right end for a spell
    assemble(f)      -> [Assemble(ctx.owner, f)]
    buffHand(a, h)   -> [Buff(id, a, h) for id in minions in ctx.owner's hand]
    ...

assemble(base, donor, half):
    top    = half == top    ? donor.text[0:donor.split] : base.text[0:base.split]
    bottom = half == bottom ? donor.text[donor.split:]  : base.text[base.split:]
    if top + bottom == base.text: error AssembleError
    if not wellFormed(top + bottom): error AssembleError
    return base with name = base.name + " (Assembled)", text = top + bottom, split = len(top)

perform(Assemble(player, filter)):
    pool   = pool minions, sorted by name
    fits(b, d, h) = assemble(b, d, h) does not fail
    donors(b) = [m in pool where m != b and fits(b, m, h) for some half h]
    base   = choose("base",  [m in pool where (filter.trigger is none or m has that trigger)
                                        and donors(m) is not empty])
    donor  = choose("donor", donors(base))
    half   = choose("half",  [h in (top, bottom) where fits(base, donor, h)])
    register assemble(base, donor, half); create it in player's hand

react(events):                            // the queue fill gains one condition
    for each event, listener, trigger that responds:
        if (listener, trigger) is not on the resolving stack: queue it
    for each entry: push it on the stack; resolve it; pop it

runSequence(action):
    perform(action); round = 0
    while deaths:
        round += 1
        if round > 64: error "Death Phase limit"
        ...

When your tests pass, compare with the reference engine in course/05-assemble/reference/rust/src/engine/ or course/05-assemble/reference/ts/src/engine/: card (types, compile, library), effect (the interpreter) and assemble. The reference's content directory is now a short loader that reads files.

Decisions in the reference that the tests don't force:

  • Where a summon goes. Until now only Deathrattles summoned, at the dead minion's remembered position (Module 3). With Assemble, "Battlecry: Summon a 2/1 Damaged Golem." exists, and its minion is still on the board. The reference summons to the right of a source still in play, at the remembered position for a source that has left, and at the right end for a spell. Several tokens from one effect go left to right in summon order. The course's research notes don't cover Battlecry summons, so treat this as the course's choice.
  • What "the same trigger" means for the nesting guard. The reference keeps a stack of (minion, index of the trigger in its card's text) pairs in the world, beside the log's depth (openBlock(source, trigger), isResolving in world.ts; open_block, is_resolving in world.rs). The stack's height is the log tree's depth. A trigger activated twice by a multiplier is two activations of one Queue entry, one after the other, not one inside the other, so the guard doesn't stop the second.
  • The Death Phase limit is checked before a Death Phase starts, between Death Phases, where no trigger is resolving. So the error leaves no block open: in TypeScript, which has no finally in the reference's loop, the depth is already 0 when it throws, and the test checks that the next event is logged at depth 0. The world is otherwise left as it was when the limit was reached (an Ember on the board, mortally wounded). The value 64 is the course's choice: large enough for any Sequence of the course's cards, small enough to fail at once.
  • Well-formed for a spell. The rule is written for minions, the only cards Assemble makes; the reference passes any spell. It is a little stricter than "no effect before the first trigger": an effect after an aura or keyword that follows a finished trigger ([trigger, effect, aura, effect]) also fails, because nothing would ever run it. No card in the course has that shape.
  • The pool is every registered minion with text, minus Assembled cards. That includes the fictional Test cards. The real pool is curated: see below.
  • Two Assembles of the same base with different donors would both be named "Harvest Golem (Assembled)", and registering the second would silently rewrite the first. The reference names the second "Harvest Golem (Assembled 2)". The harness contract fixes the first name, and test_05_assemble_never_rewrites_an_earlier_card fixes only that the second differs. "Different" means anything the engine reads: the text, the split, or a base re-registered with a new cost. The two reference engines once disagreed here, because one of them compared only the text.
  • The default chooser takes the first option. A real game would ask the player or roll; the tests never rely on the default.
  • Unknown keywords do nothing. Unstable Ghoul's data says taunt; the course has no combat, so the engine keeps the clause (it is part of the text Assemble moves) and ignores it.

How the real game differs

  • The real Assemble puts a random minion in your hand and presents two other minions (Assemble). The course offers every eligible minion and lets the chooser pick each part. A random chooser that draws one base and two donors gives back the real game's shape; the engine doesn't change.
  • The presented minions cost the same as the base or up to 2 more; for 9- and 10-cost bases they can cost 8, 9 or 10. Minions come from your class or neutral unless the card says otherwise (same source). That is part of selection, so it belongs in the chooser's options, and the course doesn't model it: it has no mana and no classes.
  • The real pool is about 250 curated minions "whose effects can be split in half" (paraphrase, same source). The course's pool is every minion with text, and every card's data says where its half ends.
  • The wiki doesn't define "half" (same source). The course's split is a definition, not a claim about the real game.
  • The real Assembled minion also keeps its art, rarity and minion type (same source). The course's cards have none of these.

Name it

The pattern is behaviour as data, interpreted. A card's ability is no longer code the engine calls; it is a small tree (clauses, holding effects, holding targets) that one routine in the engine walks. Robert Nystrom's Bytecode chapter states the goal: "Give behavior the flexibility of data by encoding it as instructions for a virtual machine." Its motivating example is a spell system, and the reason it gives is the course's reason too: "We could define these spells in code, but that means an engineer has to be involved every time one is modified." Read it for that argument, and for the sandboxing point below. Don't read it as advice to build a virtual machine: the chapter contrasts the Interpreter pattern (walk a tree of expression objects) with a compact bytecode, and the course stays deliberately on the tree side. Card abilities are short, and a tree you can print, compare and cut in half is worth more here than speed.

The interpreter in the reference, one effect in, actions out:

pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
    let owner = ctx.owner;
    match effect {
        Effect::DealDamage { target, amount } => vec![Action::Damage {
            targets: targets(*target, ctx),
            amount: *amount,
        }],
        Effect::Draw { count } => (0..*count)
            .map(|_| Action::Draw { player: owner })
            .collect(),
        // ... summon, gainArmor, gainAttack, destroy ...
        Effect::Assemble { filter } => vec![Action::Assemble {
            player: owner,
            filter: filter.clone(),
        }],
        // ... buffHand, spawnText ...
    }
}
export function actions(effect: Effect, ctx: Ctx): Action[] {
  const player = ctx.owner;
  switch (effect.type) {
    case "dealDamage":
      return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
    case "draw":
      return times(effect.count, () => ({ type: "draw", player }));
    // ... summon, gainArmor, gainAttack, destroy ...
    case "assemble":
      return [{ type: "assemble", player, filter: effect.filter }];
    // ... buffHand, spawnText ...
  }
}

And Assemble, which only works because of it:

pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef, AssembleError> {
    let (base_top, base_bottom) = base.text.split_at(base.split);
    let (donor_top, donor_bottom) = donor.text.split_at(donor.split);
    let (top, bottom) = match half {
        Half::Top => (donor_top, base_bottom),
        Half::Bottom => (base_top, donor_bottom),
    };
    let text = [top, bottom].concat();
    let refuse = |reason| AssembleError {
        base: base.name.clone(),
        donor: donor.name.clone(),
        half,
        reason,
    };
    if text == base.text {
        return Err(refuse(AssembleFailure::Unchanged));
    }
    if !well_formed(&text, base.card_type) {
        return Err(refuse(AssembleFailure::IllFormed));
    }
    Ok(CardDef {
        name: format!("{} (Assembled)", base.name),
        split: top.len(),
        text,
        ..base.clone()
    })
}
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
  const topOf = (c: CardDef) => c.text.slice(0, c.split);
  const bottomOf = (c: CardDef) => c.text.slice(c.split);
  const top = half === "top" ? topOf(donor) : topOf(base);
  const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
  const text = [...top, ...bottom];
  if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half, "unchanged");
  if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed");
  return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}

Three things follow from making behaviour data:

  • New cards are files. Test Chorus is the first card to use Module 4's additive stacking. It is one short JSON file; nothing in the engine changed for it.
  • The vocabulary is closed, and that is a feature. A card can only do what the interpreter's match can do. Nystrom: "The bytecode can't do anything malicious or reach out into weird parts of the game engine because we've only defined a few instructions that touch the rest of the game." A card file can't name a function, open a file or loop forever. When a new card needs a new verb (this module added assemble and buffHand), that is an engine change, made once, and every later card may use it.
  • Behaviour can be inspected and transformed. Assemble compares two halves for equality and concatenates lists. The must-change rule is text == base.text, and the well-formed rule is a ten-line walk over the clauses. None of this is possible with closures.

The Argentum engine (Magic: The Gathering, Kotlin) makes the same commitment in its docs/architecture-principles.md. §1.1: "Cards are pure, serializable data structures with zero execution logic." §1.2 goes one level further than the course: "Numbers are represented as inspectable AST nodes, not integers or lambdas." The course's amount is a plain integer; an "X damage, where X is the number of cards in your hand" card would need §1.2.

Forge, a long-lived Java engine for the same game, scripts its cards as plain-text key/value data. A trigger line such as T:Mode$ DamageDone | ValidSource$ Card.Self | ValidTarget$ Opponent names its effect indirectly (Execute$ TrigDraw), and the effect is another line: SVar:TrigDraw:DB$ Draw | Defined$ You | NumCards$ 1. Compare that with Acolyte of Pain's file in cards/: a trigger clause, then an effect clause. Different syntax, same split between when and what, and the same extensible effect vocabulary.

Run the coupling audit again: node tools/coupling-audit.mjs ~/my-engine should print coupling-audit: ok. Your engine now knows clause kinds, effect kinds, targets, halves and choosers. Card names live only in cards/.

Expansion Drop

A new card arrives: Fleshshape, "Assemble a Deathrattle minion. Give all minions in your hand +2/+2." (Fleshshape). It is not in cards/: course/cards/ leaves it out on purpose.

The constraint, as always: do not edit anything under src/engine/. The harness doesn't need to change either. This time not even src/content/ should change: the drop is one new file in cards/.

  1. Commit your workspace and note the commit: git add -A && git commit -m "module 5 done".
  2. Copy the expansion test for your track from course/05-assemble/expansion/tests/rust/test_05x.rs or course/05-assemble/expansion/tests/ts/test_05x.test.ts into your workspace's tests/. Run it and watch it fail: there is no card called Fleshshape.
  3. Write cards/fleshshape.json.
  4. When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD

The test, test_05x_fleshshape_pool_filter, casts Fleshshape with an injected chooser. It checks that the base options are exactly the Deathrattle minions in cards/, that the donor options are not filtered (the wiki says a named pool applies only to the base, Assemble), and that the Assembled Loot Hoarder ends up 4/3. The full scenario is in course/05-assemble/expansion/README.md.

Discussion: what this drop measures

If your assemble effect takes its filter from data and your effects run one at a time, Fleshshape is Test Workshop with two values changed:

{
  "name": "Fleshshape",
  "type": "spell",
  "cost": 3,
  "split": 1,
  "text": [
    { "kind": "effect", "effect": { "type": "assemble", "filter": { "trigger": "deathrattle" } } },
    { "kind": "effect", "effect": { "type": "buffHand", "attack": 2, "health": 2 } }
  ]
}

That is the reference answer in course/05-assemble/expansion/solution/cards/fleshshape.json (pretty-printed there), the same file for both tracks.

Shapes that fail this drop:

  • The filter is code. If Test Workshop passed because your engine checks for Battlecries inside the assemble effect, Deathrattle needs another branch.
  • Effects computed up front. If an ability's actions are all built before any is performed, "Give all minions in your hand +2/+2" is computed while the hand is still empty of the new minion: the test sees 2/1, not 4/3.
  • Cards still in code. If Test Workshop is a function in your content, Fleshshape is another function, and "add a JSON file" isn't available to you.

If the check fails, 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. assemble("Loot Hoarder", "Baron Rivendare", "top") and assemble("Loot Hoarder", "Baron Rivendare", "bottom"). Baron's split is 0: his top half is empty and his bottom half is the aura. Both fail in this module (test_05_assemble_rejects_ill_formed_text). Write out the text of each result, then say what each card would do if assemble accepted it. If you like, comment out your wellFormed check, summon each one, destroy it, and read the log.
Discussion
  • Top: [] + [effect: draw]. A minion whose whole text is "Draw a card." with no trigger in front of it. The clause would compile into the "effects before any trigger" list, which only a spell ever runs. The card would do nothing, ever, and nothing would report that.
  • Bottom: [trigger: deathrattle] + [aura: Deathrattles ×2]. It reads "Deathrattle: Your minions trigger their Deathrattles twice." The trigger would get no effects, so on death the log would show a Triggered(deathrattle) followed by nothing; and the aura would be in force the whole time the minion is in play, exactly like Baron's, because an aura clause isn't attached to the trigger before it.

Neither result is what the text says to a player. Until this revision of the course, the reference's assemble accepted both. The function is closed over lists of clauses, but a card's text has a grammar (an effect belongs to a trigger; a trigger needs an effect; an aura stands alone), and cutting two well-formed texts in half and swapping can leave that grammar. text == base.text is the only check, and it is about a different question.

A design has three honest ways out:

  • Curate the pool, so that every pair of halves fits. This is what the real game does: the base comes from minions "whose effects can be split in half" (Assemble, paraphrased in Observe). Curation is data, and it costs designer time per card.
  • Check the result. Write wellFormed(text) and have the assemble effect offer only donors and halves whose result passes. The rule lives in one function, next to the must-change rule. Alexis King's Parse, don't validate argues for going one step further: make the compiled form unable to represent a trigger without effects, so the check can't be skipped.
  • Give every list a meaning. Decide that an aura after a trigger is granted when the trigger fires, and that a bare effect on a minion is a Battlecry. Now nothing is ill-formed, and you have designed several new mechanics by accident.

The reference takes the second, from this module on: wellFormed / well_formed in card, called by assemble, so the effect's options leave out every combination that would fail. It doesn't take King's further step: the compiled card can still hold a trigger without effects, and only the check at the boundary keeps one out. Every card the course ships passes the same rule (tools/test/well-formed.test.mjs). Count how many of the base × donor × half combinations in cards/ change the text, and how many of those your wellFormed rejects. (With the course's cards at this module: 386 change it, and 200 of those are ill-formed.) More than half: the pool is not curated, and the check does the curation.

  1. course/catalog.json has a text field for every card: the words a player reads. Your engine has the same card as text clauses. Write render(def), a function from the data to the English sentence, and test it against the catalog for every card in cards/.
Discussion

A dozen cases cover the course: one phrase per trigger ("Deathrattle:", "Whenever this minion takes damage,"), one per effect, one per target, and the joining punctuation. The test is a loop over the catalog.

It is worth doing for what it proves. The card file is now the single source of both what the card does and what it says, so the two can't drift apart. And Assemble gets its rules text for free: render the assembled clause list. In the real game the assembled minion's "text changes"; with behaviour as data, that is the same operation as changing what it does. Try render on the two texts from question 1. A renderer that can't produce a sensible sentence is a second detector for ill-formed text.

You will find cards where the catalog's sentence and your rendering can't match word for word (Harvest Golem's "Summon a 2/1 Damaged Golem." needs the token's stats). Decide whether render looks the token up or the test allows a difference, and note which. That is the gap Argentum's "inspectable AST nodes" in Further reading are there to close.

  1. Every random or player choice in your engine goes through the chooser. Write a chooser that records each answer it gives, play a short game with three Assembles, then start a new game and feed the recorded answers back in. Do you get the same log? What else, besides the answers, did you have to record?
Discussion

The same log, provided you also recorded the inputs: every harness call that started a Sequence, in order, with its arguments. Inputs plus choices is everything that enters the engine from outside. Everything else is computed. That makes a game replayable from a short list, and it is why the chooser was worth the trouble: one port for nondeterminism instead of a random() call wherever a card needed one.

Two things break replay, and both are worth trying. Iterating over an unordered map somewhere in the engine (TypeScript objects keep insertion order; a Rust HashMap does not, so on the Rust track this can bite) and ids that depend on something outside the game. If your replayed log differs, the first differing event points at the place.

Replay is the foundation for three things the course doesn't build: saving a game as its inputs, undo by replaying all but the last input, and reproducing a bug report exactly. Robert Nystrom's Command chapter makes the same observation about recorded input: "We can take the player's input, push it over the network to another machine, and then replay it." Module 13 uses replay to build a solver.

Further reading

  • Robert Nystrom, Bytecode, Game Programming Patterns: read it for the case for behaviour as data and for the sandboxing argument. The course stops at the Interpreter pattern the chapter starts from, on purpose.
  • Argentum, docs/architecture-principles.md, §1.1 ("Cards are pure, serializable data structures") and §1.2 (amounts as inspectable AST nodes). Compare §1.2 with the course's integer amount: what would "deal damage equal to your hand size" need?
  • Forge wiki, Card scripting API and Creating a custom Card: abilities as data at the scale of a whole game's card pool, with triggers, statics and replacement effects as separate kinds of line.
  • Assemble, hearthstone.wiki.gg: the rules, the 13 cards with the keyword, and what the page leaves undefined. Check whether it defines "half" by the time you read it.
  • Alexis King, Parse, don't validate (2019). Written about Haskell types, and it applies to the register step of this module: turn loose input into a form that can't be wrong, once, at the boundary. Read it with Stress it question 1 in mind.