Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <[email protected]>
30 KiB
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.
- 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?
- 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? - Replace Loot Hoarder's top half with Novice Engineer's. What does the card do when you play it? When it dies?
- 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?
- Fleshshape picks a minion at random. How do you write a test whose expected log is exact?
Discussion
-
"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
Damagedevents 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. -
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
assemblerecombines, but at that point you are already turning text into data, just without saying so. -
"Battlecry: Draw a card." on Loot Hoarder's 2/1 body: one draw when played, nothing when it dies. The Deathrattle is gone.
-
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
assemblecompares texts. -
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 } }
]
}
textis a list of clauses. A clause is akeyword(for exampletaunt), atrigger(on:deathrattle,battlecry,selfDamaged,friendlyMinionDamaged,anyMinionDamaged,heroDamaged,endOfTurn), anaura(the Module 4 trigger multiplier, now in its final form) or aneffect.- 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}andbuffHand{attack, health}. Targets:self,allMinions,allOtherMinions,allOtherCharacters,enemyHero,chosen. (spawnTextbelongs to Part III; you can ignore it until then.) splitis the course's definition of "half": the top half istext[0:split], the bottom halftext[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 hassplit0: 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) |
CardDefis the card format. In Rust it must implementserde::Deserialize, because the tests parsecards/*.jsonwithserde_json: addserde = { version = "1", features = ["derive"] }andserde_json = "1"toCargo.toml. In TypeScript the tests import the files with Vite'simport.meta.glob("../cards/*.json"), which Vitest supports out of the box.newGameloads every file incards/. Nothing else knows the cards.assemble(base, donor, half)registers a new card, named"<base> (Assembled)", whosehalfis replaced by the same half ofdonor. Everything else is the base's. It fails withAssembleErrorif the new text equals the base's.- The chooser makes the choices an
assembleeffect needs, in this order:"base"(the names of the pool minions the filter allows, sorted),"donor"(the pool minions that would change the base's text, sorted),"half"("top","bottom": those that change it). Until a test sets one, any deterministic default will do.
The tests
The full statements are in course/05-assemble/scenarios.md.
test_05_regression_data_cards: every file incards/parses,registerCardsaccepts 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 aTest Filler(1/1) and aTest 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_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: castTest 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 incards/; the new minion and aTest Filleralready in hand both get +1/+1.test_05_additive_multipliers_stack: twoTest Chorus("Your minions trigger their Deathrattles an additional time.", stackingadditive) and a Loot Hoarder: three activations. Module 4 built theadditivebranch; this is the first card that uses it, and it needed no code.
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 seven 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/*.jsonand 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 whatdrawordealDamagemeans. It names effect kinds and targets, never cards. - Assemble is engine code that works on
textandsplitand never looks inside a clause beyond comparing it. - The
assembleeffect asks; it doesn't roll. It asks a chooser that lives in your world. Tests inject one through the harness. src/harnessstays thin:registerCards,assemble,costandsetChoosereach hand one call to the engine.
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.
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
onand its effects), a list of auras (Module 4'sactivationsreads 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:selfis the source minion;allOtherCharactersis 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 givesdonor.top + base.bottomwithsplit = donor.top.length; replacing the bottom givesbase.top + donor.bottomwithsplit = 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.
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) -> n x Summon(c, ctx.owner, at: right of source, or its death position)
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
return base with name = base.name + " (Assembled)", text = top + bottom, split = len(top)
perform(Assemble(player, filter)):
pool = pool minions, sorted by name
base = choose("base", [m in pool where filter.trigger is none or m has that trigger])
donor = choose("donor", [m in pool where m != base and some half changes base's text])
half = choose("half", [h in (top, bottom) where it changes base's text])
register assemble(base, donor, half); create it in player's hand
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. The course's research notes don't cover Battlecry summons, so treat this as the course's choice.
- The pool is every registered minion with text, minus Assembled cards. That includes the fictional
Testcards. 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 only fixes the first name.
- 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
splitis 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();
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
}
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);
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
Three things follow from making behaviour data:
- New cards are files.
Test Chorusis the first card to use Module 4'sadditivestacking. 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
matchcan 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 addedassembleandbuffHand), 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. 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/.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 5 done". - Copy the expansion test for your track from
course/05-assemble/expansion/tests/rust/test_05x.rsorcourse/05-assemble/expansion/tests/ts/test_05x.test.tsinto your workspace'stests/. Run it and watch it fail: there is no card called Fleshshape. - Write
cards/fleshshape.json. - 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 Workshoppassed because your engine checks for Battlecries inside theassembleeffect, 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.
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 integeramount: 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.