Writing a Validator

Implement the AutoBattle battle engine in any language and earn Autogold for every shard you validate. This page covers the complete current protocol.

Contents
Overview & Rewards Authentication Validation Flow API Reference Battle Engine Hash Format Reference Implementations Tips & Gotchas

Overview & Rewards

A validator claims a shard — a slice of matches from a cohort — runs the battle simulations locally, and submits the results plus a SHA-256 hash. The server compares results across validators and rewards those whose output matches consensus.

Work typeWhenReward
Phase 1 — run your shard of the live cohortDuring the 10-min validation window after the cohort closesMerit-based at finalization (faster = higher rank)
Phase 2 — re-verify a Phase 1 shard from the previous cohortAfter Phase 1 is complete; runs in parallelMerit-based at finalization
Retroactive — Phase 1 shards from old finalized roundsAnytime via GET /api/v1/cohort/retro5 AG immediately per shard
Phase 0 — pre-validate the upcoming cohort during registrationOnly when no live or retro work exists; admin-enabled1 AG immediately per shard
Consolation — showed up but all shards were already coveredPhase 1 claim returns shard_id: null1 AG immediately (once per cohort)
Validate ping — visit the Arena page between roundsEvery 10 minutes while a cohort is open for registration17 AG per ping

The battle engine is deterministic given the same seed — every validator running the same match must produce the same result and hash.

Authentication

Pass your API key as a Bearer token in the Authorization header, or include "api_key": "…" in POST request bodies (fallback for hosts that strip the header).

# Header (preferred)
Authorization: Bearer YOUR_API_KEY

# Body fallback
{"cohort_key": "…", "api_key": "YOUR_API_KEY", …}
Auth is required to claim shards. Anonymous requests cannot claim — they get a 401. You can still run anonymous simulations locally, but results won't be attributed. Register at autobattle.online to get a key.

Validation Flow

1Phase 1 — Run your shard GET POST

Step 1a: Discover the active cohort.

GET /api/v1/cohort

Check validation_cohort.cohort_key and validation_cohort.shard_mode. A null validation_cohort means no live validation window — look for retroactive or pre-validation work instead.

Step 1b: Claim a shard.

// Request
POST /api/v1/cohort/shard_claim
{ "cohort_key": "abc123…", "phase": 1, "api_key": "…" }

// Response
{
  "ok":            true,
  "shard_id":      4182,
  "shard_index":   7,
  "cohort_key":    "abc123…",
  "phase":         1,
  "match_count":   1000,
  "claim_expires": "2026-06-20 18:45:00",
  "cards":   { /* card definitions keyed by card_id string */ },
  "slots":   [ /* slot objects: slot_id, deck_name, cards[], battle_plan */ ],
  "matches": [ /* { match_id, slot_a, slot_b, seed } */ ]
}
shard_id = null — no shards left this round (all claimed or done). You receive 1 AG consolation if you haven't already submitted a shard this cohort. Check for retroactive work next.
retry: true — race condition; retry immediately to get a different shard.
Claim expiry: you have 10 minutes to submit. If you don't submit in time the shard reverts to open. Repeatedly abandoning claims triggers a validator ban — you'll be locked out of live shards for several rounds (retroactive shards are always accessible).
Concurrent claim cap: you may hold at most 10 live (non-expired) shards at once. Submit before claiming more.

Step 1c: Simulate every match in matches[] and submit results.

// Request
POST /api/v1/cohort/shard_submit
{
  "api_key":    "…",
  "shard_id":   4182,
  "shard_hash": "a3f8…",   // SHA-256 of sorted results (see Hash Format)
  "results": [
    { "match_id": "…", "winner_slot": "slot_3", "reason": "health", "turns": 14 },
    { "match_id": "…", "winner_slot": null,        "reason": "draw",   "turns": 600 }
  ]
}

// Response
{ "ok": true, "phase": 1, "shards_done": 8, "shards_total": 12, "retro": false }

Valid reason values: health, honor, dishonor, deck_out, draw, mutual_death, mutual_honor, mutual_dishonor, timeout_honor, timeout_life.
winner_slot is the slot_id string of the winning slot, or null for a draw.

2Phase 2 — Verify a previous shard

After Phase 1 is complete for the current cohort, Phase 2 shards open on the previous cohort. Each Phase 2 shard covers the same matches as a Phase 1 shard; you re-run them and compare hashes.

// Claim with the PREVIOUS cohort's key and phase: 2
POST /api/v1/cohort/shard_claim
{ "cohort_key": "prev_cohort_key…", "phase": 2, "api_key": "…" }

// Phase 2 response adds:
{
  …,
  "phase":          2,
  "phase1_results": [ /* original Phase 1 validator's results */ ]
}

Run the matches and submit exactly as Phase 1. If your shard_hash differs from the Phase 1 hash, the shard is automatically flagged as disputed — you don't need to implement dispute logic yourself.

Auto-fallthrough: if you call shard_claim { phase: 1 } and all Phase 1 shards are already covered, the server may automatically return a Phase 2 shard for the previous cohort instead. Check the "phase" field in the response — it tells you which phase you actually got.

3Retroactive validation (+5 AG)

Old finalized cohorts sometimes have unclaimed Phase 1 shards. Any authenticated validator can claim them at any time for an immediate 5 AG reward.

// Discover the best retro cohort
GET /api/v1/cohort/retro
→ { "ok": true, "cohort": { "cohort_key": "…", "shards_done": 1, "shards_total": 42 } }

// Then claim and submit as normal phase 1, using that cohort_key
POST /api/v1/cohort/shard_claim { "cohort_key": "…", "phase": 1 }
POST /api/v1/cohort/shard_submit { … }
→ { …, "retro": true, "retro_reward": 5.0 }

0Phase 0 — Pre-validation (+1 AG)

Pre-validation lets validators work during the deck-registration window, before a cohort closes and the formal 20-minute validation window opens. It is the lowest-priority work and is only offered when there is no live validation window and no open retroactive shards.

GET /api/v1/cohort returns a pre_validation_cohort key only when all of the following are true:

// Claim a phase-0 shard
POST /api/v1/cohort/shard_claim
{ "cohort_key": "…", "phase": 0, "api_key": "…" }

// Submit identically to phase 1
POST /api/v1/cohort/shard_submit { "shard_id": …, "results": […], "shard_hash": "…" }
→ { "ok": true, "phase": 0, "pre_validation": true, "pre_reward": 1.0 }
Note: Phase 0 results have no effect on final standings — they are discarded when the real battle window opens. They exist purely to let idle validators earn a small reward and to warm up engine implementations.

API Reference

GET /api/v1/cohort GET

Returns the current validation state. The three top-level keys are mutually exclusive by priority: live work suppresses retro work, which suppresses pre-validation work.

{
  "ok": true,
  "open_cohort": {
    "cohort_key": "…",
    "closes_at":  "…"
  },
  "validation_cohort": {               // null if no live window
    "cohort_key":          "abc123…",
    "validation_closes_at":"2026-06-19 18:20:00",
    "val_secs_remaining":  487,
    "submission_count":    3,
    "shard_mode":          true,
    "total_matches":       460000,
    "shards_phase1_total": 12,
    "shards_phase1_done":  5,
    "expected_validators": 58,
    "validates": {                   // previous cohort for Phase 2 (may be null)
      "cohort_key":          "xyz789…",
      "shard_mode":          true,
      "shards_phase2_total": 12,
      "shards_phase2_done":  3
    }
  },
  "pre_validation_cohort": {          // null unless fully idle (see Phase 0)
    "cohort_key":       "…",
    "closes_at":        "…",
    "open_shards":      8,
    "registered_count": 412,
    "pre_validation":   true
  }
}

GET /api/v1/cohort/retro GET

Returns the best available retroactive cohort (oldest finalized cohort with open phase-1 shards). cohort is null if nothing is available.

{ "ok": true, "cohort": { "cohort_key": "…", "closes_at": "…", "shards_done": 1, "shards_total": 42 } }

POST /api/v1/cohort/shard_claim POST

Body: { "api_key": "…", "cohort_key": "…", "phase": 0|1|2 }
Returns the match payload filtered to your shard. Full field list: shard_id, shard_index, cohort_key, phase, match_count, claim_expires, chaos_effect, cards, slots, matches. Phase 2 also returns phase1_results.

POST /api/v1/cohort/shard_submit POST

Body: { "api_key": "…", "shard_id": N, "results": […], "shard_hash": "sha256hex" }
Returns: { ok, phase, shards_done, shards_total, retro, retro_reward?, pre_reward?, disputed? }

Deadlock retry: shard_submit may return {"ok":false,"retry":true} due to DB contention. Retry 3–4 times with a short backoff.

Battle Engine

RNG — Mulberry32

All shuffles, target selection, and hand-discard ordering are driven by this PRNG seeded from match.seed. Every language must implement it identically.

// JavaScript
function mulberry32(seed) {
  return function() {
    seed = (seed + 0x6D2B79F5) >>> 0;
    let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) >>> 0;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

# Python (mask to 32 bits after every operation)
def make_rng(seed):
    s = [seed & 0xFFFFFFFF]
    def rng():
        s[0] = (s[0] + 0x6D2B79F5) & 0xFFFFFFFF
        t = ((s[0] ^ (s[0] >> 15)) * (1 | s[0])) & 0xFFFFFFFF
        t = ((t ^ (t >> 7)) * (61 | t)) & 0xFFFFFFFF
        t = (t ^ (t >> 14)) & 0xFFFFFFFF
        return t / 4294967296.0
    return rng

Constants & Player State

Constants
STARTING_LIFE = 100
HONOR_TO_WIN  = 100   // reduced to 50 when chaos_effect = "honor_threshold_50"
MAX_TURNS     = 600
MAX_HAND      = 10
Chaos Arena modifiers (chaos_effect field on shard payload)

A Chaos Arena cohort always picks exactly 2 effects, independently and with replacement -- chaos_effect is a comma-joined string of 1-2 slugs (e.g. "extra_energy,extra_energy" or "spell_tax,mill_tick"), not a single bare slug. Split on , and count occurrences per slug (0/1/2) before the game loop starts; wherever a modification below applies, apply it once per occurrence (a slug appearing twice means the modification happens twice, not once at double magnitude -- e.g. two separate 3-damage hits, not one 6-damage hit). honor_threshold_50 is the sole exception: a second occurrence is a no-op (the threshold is just set to 50 once). Treat a null, absent, or empty chaos_effect as standard rules with no modifiers.

chaos_effect slugModification (once per occurrence)
extra_drawAt step 2 (Draw), active player draws 1 additional card.
extra_energyAt step 3 (Energy), active player gains 1 additional basic energy.
creatures_enter_melee_boostedWhen a Creature enters the battlefield (on_enter), give it +1 Melee permanently.
permanents_enter_armoredWhen any permanent enters the battlefield (on_enter), give it +1 base Armor permanently (also affects current curArmor).
honor_threshold_50Set HONOR_TO_WIN = 50 for the entire game. Win by honor at ≥ 50, not 100. (Non-stacking -- see above.)
The following are new (v3.47) -- resolved once at the very start of the active player's own turn (before Draw), self-inflicted (never against the opponent):
poison_tickActive player gains 1 poison.
mill_tickActive player mills 1 card from their own deck.
self_creature_damageDeal 2 damage to a random Creature the active player controls (indestructible creatures are skipped, same targeting rules as deal_damage_to_random_creature).
recycle_tickActive player recycles 1 card from their own discard pile back into their deck (shuffled in).
player_damage_3Active player takes 3 damage directly (fires on_life_loss).
honor_loss_1Active player loses 1 Honor. Not floored at 0 -- can go negative (same as the lose_honor effect type).
discard_tickActive player discards 1 random card from hand (no-op if hand is empty).
sacrifice_creatureActive player sacrifices 1 random Creature they control (no-op if none).
sacrifice_relicActive player sacrifices 1 random Relic they control (no-op if none).
life_gain_3Active player gains 3 Life (fires on_life_gain).
Cost taxes -- not turn-scoped; apply whenever either player attempts to play a matching card, at cost-computation time (after cost_modifier/dynamic_cost discounts, before the discount floor's min() clamp is checked against the final cost):
spell_tax+1 energy to play any Spell.
creature_tax+1 energy to play any Creature. Does not apply to Champions (separate cost path, separate supertype).
relic_tax+1 energy to play any Relic.
enchantment_tax+1 energy to play any Enchantment.
Player state (initial)
life      = 100
honor     = 0
energy    = 0       // basic; persists across turns
ephemeral = 0       // spent before basic; cleared at end of turn
shields   = 0
poison    = 0
hand      = []
deck      = [shuffled cards]
board     = []
discard   = []

Match Structure

Each match in the payload is a best-of-2:

Match winner: whichever slot wins more games. Tied at 1–1: draw (winner_slot = null).

Turn Sequence

Each turn the active player performs steps 0–9 in order:

  1. Refresh + poison + shield decay — mark all active player's permanents as not-utilized; if shields > 0 decay by 1; if poison > 0 deal poison damage then reduce by 1; fire on_life_loss if damage landed; check win.
  2. Draw — active player draws one card. If deck empty → deck_out loss. Fire on_draw passives.
  3. Energy — active player gains 1 basic energy.
  4. Opponent's each_opponent_turn triggers — shuffle a snapshot of the opponent's board (RNG-driven); fire each_opponent_turn on each entry (ap = opponent, op = active); prune dead; check win.
  5. each_turn / turn_start triggers — fire both trigger types on a snapshot of the active player's board; prune dead; check win.
  6. Play cards (greedy loop) — if battle_plan.energy_hold > 0, whether/how the hold applies depends on battle_plan.energy_hold_scope (see below): for "every_turn" (default) and "first_play" it's a single upfront check — total energy < threshold skips the entire card phase (for "first_play", only once nothing has been played yet this game; once anything has, the hold never re-applies). For "first_spend" there's no upfront skip at all — sort the hand as usual, and while scanning, any candidate card with effective cost > 0 is skipped (not the whole phase — try the next hand card) while energy is under threshold and nothing that costs energy has ever been played yet this game; 0-cost cards are never held regardless. Separately, when play_priority = "card_order" and card_order_hold isn't explicitly false (default on): before scanning, find whichever card_order-ranked card currently in hand ranks highest and check its effective cost against energy + ephemeral — if unaffordable, only cards payable using ephemeral alone stay eligible this pass (basic energy is reserved for the priority pick; ephemeral is exempt since it clears at end of turn regardless of whether it's spent). Otherwise (or once neither hold applies): sort hand by battle_plan.play_priority (see below); scan left-to-right for the first affordable card with a satisfiable utilize cost; pay cost (ephemeral first); fulfill utilize cost; add card to board or discard; fire on_enter with the plan; prune dead; check win. Repeat until no card can be played.
  7. end_of_turn triggers — fire on active player's board snapshot; prune dead; check win.
  8. Armor recovery — reset curArmor to base armor (plus any live anthem_armor bonus) for every creature on both boards.
  9. CombattotalMelee = Σ non-utilized ap creatures' melee + live anthem_melee bonuses; totalOpArmor = Σ op permanents' armor + live anthem_armor bonuses; combatDmg = max(0, totalMelee − totalOpArmor). If > 0, deal it to the opponent (shields first) and resolve link/bound passives (lifelink/honorlink/drawlink/shieldlink/millbound/honorbound, scaled by combatDmg) plus champion honor link (champion's raw melee, unaffected by anthem, added as honor).
  10. Clear ephemeral; discard to hand limit — set ephemeral = 0; while hand > 10 pick card per battle_plan.discard_priority and move to discard.

Then switch the active player.

Battle Plan Fields

The optional battle_plan object stored on a deck controls AI behavior. Fields irrelevant to the current arena type are silently ignored.

FieldTypeDefaultDescription
play_prioritystring"cheapest"Hand sort order
card_orderint[][]Card IDs in desired play order; used when play_priority = "card_order"
card_order_holdbooltrueWhen play_priority = "card_order": reserve basic energy for the highest-ranked card currently in hand until it's affordable (ephemeral energy stays freely spendable). Set false to disable and always play the cheapest affordable card in priority order instead, accepting an expensive top pick may never accumulate enough energy if cheaper cards keep consuming it first.
type_orderstring[][]Supertype names in desired play order; used when play_priority = "type_order"
tag_orderstring[][]Ability tag names in desired play order; used when play_priority = "tag_order". A card matching several tags ranks by whichever listed tag it holds that ranks best.
energy_holdint0Skip card play phase until energy + ephemeral ≥ this value
energy_hold_scopestring"every_turn"How long energy_hold applies: "every_turn" (re-checked every turn, blocking even 0-cost cards while under threshold), "first_play" (same blanket block, but only until the player's first successful play of the game — never re-applies after), "first_spend" (0-cost cards always play immediately regardless of the hold; only the first card that would cost energy > 0 is held until threshold, and once any paid card has been played, never re-applies)
target_preferencestring"random"Targeting for damage/destroy/kill effects: "random", "weakest", "strongest", "least_armor"
target_tag_orderstring[][]Optional tag-priority companion to target_preference — see below
cost_preferencestring"random"Which of ap's own permanents/discard/library cards to pick when the engine chooses automatically — additional costs (utilize/sacrifice/recycle/exile), utilize_own_permanent/utilize_and, reanimate_random_tagged, and tag-mode tutor: "random", "weakest", "strongest", "least_armor"
discard_prioritystring"random"Card to discard when over hand limit: "random", "cheapest", "costliest"
champion_timingstring"asap"Champion play timing: "asap" (as soon as affordable) or "hold" (wait for threshold)
champion_energy_thresholdint0Min energy + ephemeral to play champion when champion_timing = "hold"

Note: target_preference applies to deal_damage_to_random_creature, destroy_target, kill_target, deal_damage_to_random_tagged, destroy_random_tagged, return_opponent_permanent, and return_own_permanent — including when any of these fire from a triggered or passive ability, not just a direct card play (plan/opPlan thread all the way through the trigger/passive dispatch chain). The one exception is eff.target_highest_cost (a fixed per-card flag, not a battle-plan field), which always overrides target_preference entirely, regardless of source.

target_tag_order (string[], default []): an optional companion to target_preference for the same 7 effect types listed above. Same "best (lowest-index) position wins" rule as tag_order, but since this feeds a single-target selection rather than a full hand sort, the candidate pool is first narrowed to just whichever tag ranks best (entries matching no listed tag are excluded, not merely deprioritized) — target_preference then breaks any remaining tie exactly as it does today. Falls back to the full pool untouched if nothing in it matches any listed tag, so an effect never fizzles for lack of a match. Empty/unset (the default) is a pure no-op — existing decks are unaffected until a player explicitly adds tags via the deck builder's "Target tag priority" list.

Untargetable: a permanent with this passive is excluded from the candidate pool for any single-target-selection effect (the five listed above). It can never be picked. Mass "all matching" effects (deal_damage_to_tagged, destroy_tagged, destroy_all, destroy_permanents_by_cost, deal_damage_to_permanents_by_cost) do not check untargetable at all, since they never perform a selection — untargetable is immunity to targeting, not immunity to everything.

Indestructible: the opposite trade-off. A permanent with this passive is not excluded from any pool or filter, targeted or mass — it can still be selected or matched normally — but the destroy or damage effect that lands on it is a no-op: it stays on the board unharmed at full hp, and no on_leave/on_death fires for it.

Amplify passives (amplify_poison, amplify_mill, amplify_recycle, amplify_shield, amplify_draw, amplify_damage, amplify_life_gain, amplify_honor): each has an amount and boosts the magnitude of a matching effect when the ap that owns the passive resolves that effect — the passive's amount is added on top of the effect's own amount before the effect applies (effective = eff.amount + sum of ap.board's amplify_X passive amounts). Multiple copies stack additively. amplify_damage covers every non-combat damage effect type (deal_damage_to_player, deal_damage_to_random_creature, deal_damage_to_weakest_creature, deal_damage_to_tagged, deal_damage_to_random_tagged, deal_damage_to_permanents_by_cost) but does not apply to combat damage. amplify_recycle boosts shuffle_discard_to_library. amplify_honor boosts gain_honor only — there is no opponent_gain_honor effect to also cover, unlike life. There is no amplify_energy — energy isn't part of this family.

Symmetric effects (symmetric: true, opt-in boolean on the effect object): the five mass "all matching permanents" effect types — destroy_all, destroy_tagged, destroy_permanents_by_cost, deal_damage_to_tagged, deal_damage_to_permanents_by_cost — normally only ever touch op.board (the opponent). Setting symmetric: true runs the exact same filter and effect a second time against ap.board too, as a genuine board wipe / AoE rather than pure removal — amplify_damage (if any) is computed once and applied to both passes, indestructible permanents are checked independently per side, and on_leave fires correctly with ap/op swapped for the caster's own side. Absent or false preserves the original opponent-only behavior exactly. This flag does not exist on any other effect type (single-target and random-target selection effects are unaffected).

Anthem passives (anthem_melee, anthem_armor): a static aura, each with an amount and a recipient filter — either tag (matches permanents carrying that tag) or, if tag is absent, target_supertype (default "creature"); tag takes precedence when both happen to be set. While the anthem-granting permanent remains in play, every other permanent on the same board matching the filter gets +amount to the given stat — the anthem source never buffs itself, and multiple anthem sources stack additively. This is computed live, not baked into a stored stat: if the anthem source leaves play, the bonus disappears immediately. anthem_melee is folded into the active player's total melee at the combat-phase computation. anthem_armor is folded into curArmor whenever it's set — on recoverArmor's once-per-turn reset and at a permanent's on-enter setup — and into the opponent's total armor at the combat-phase computation; a creature that enters after the anthem is already up is armored correctly right away, but if the anthem source is destroyed mid-turn, already-recovered curArmor for that turn is not retroactively reduced (matches the existing once-per-turn depletion granularity).

Cost Modifier passive (cost_modifier): taxes or discounts matching cards' energy cost by the signed amount (positive = costs more, negative = costs less). Same either/or recipient filter as anthem passives — tag if set, else target_supertype (default "creature"). target_player (default "self") picks whose cards it affects: "self" sources on ap's own board tax/discount ap's own matching cards; "opponent" sources on op's board tax/discount ap's matching cards (i.e. the source affects its owner's opponent). All applicable deltas across both boards sum together and the result is floored at 0 energy.

count_source (object on any amount-bearing effect, sibling of amount rather than replacing it): lets an effect's amount be computed live at resolution time instead of fixed on the card. Shape: {tag, zone, side}zone is "board" (default) | "discard" | "hand", side is "self" (default) | "opponent". Resolves to the count of cards in that zone/side carrying tag. Resolved once, up front, before the effect's own body runs, so a self-referential effect (e.g. recycle cards equal to a count of your own discard) counts the zone as it stood before this same effect starts mutating it.

typeburst_source (boolean on any amount-bearing effect, mutually exclusive with count_source — both override amount the same way): resolves to the number of distinct permanent supertypes (Creature/Relic/Structure/Enchantment/Spell, max 5) present across ap's own board + discard combined — always self, no zone/side/tag to configure. Each board/discard entry's supertype is matched case-insensitively against the 5 canonical values (same convention as matches_supertype); the result is a set-cardinality count (0–5), not a sum of matches.

cost_scaling (object, card-level field living in effects_json alongside additional_costnot a passive and not attached to a specific effect): discounts or taxes the card's own cost to play, based on live game state. Shape: {terms: [...], amount, minimum}. Each term is either {kind: "tag_count", tag, zone, side} (identical resolution to count_source) or {kind: "stat", stat, side} where stat is one of "honor"/"shields"/"life"/"poison"/"energy". All terms resolve to integers and sum to a total; the effective cost delta is total * amount (same signed convention as cost_modifier's amount), and the final cost is floored at max(minimum, cost_modifier's own floor) — never below that combined minimum. Evaluated fresh on every hand-scan pass, same call site as cost_modifier. Unlike cost_modifier, this never applies to Champion casts (tryPlayChampion never evaluates it, in any engine).

Hand Sorting (battle_plan.play_priority)

ValueSort order
"cheapest" (default)Ascending cost; unknown cards treated as cost 99
"costliest"Descending cost
"draw_order"Order the cards were drawn in (oldest first) — hand is left untouched, since draws only ever append
"random"Fisher-Yates shuffle using the match's own RNG (deterministic given the match seed, not true randomness)
"card_order"Position in battle_plan.card_order list ascending; ties broken by cost
"type_order"Position of card's supertype in battle_plan.type_order list (case-insensitive); unmatched types rank last; ties broken by cost
"tag_order"Best (lowest-index) position among all of the card's ability tags found in battle_plan.tag_order (case-insensitive) — a card can hold several tags, unlike exactly one supertype; unmatched/tagless cards rank last; ties broken by cost

Sort is stable — ties preserve existing hand order.

Effect Dispatcher

Effect typeDescription
gain_energyap.energy += amount
gain_ephemeral_energyap.ephemeral += amount
gain_lifeap.life += amount (boosted by ap's amplify_life_gain passives); fire on_life_gain passives
opponent_gain_lifeop.life += amount (boosted by ap's amplify_life_gain passives); fire on_life_gain passives (ap=op, op=ap)
gain_honorap.honor += amount (boosted by ap's amplify_honor passives); fire on_honor_gain passives
gain_shieldIf op has prevent_opponent_shield passive: skip. Else ap.shields += amount (boosted by ap's amplify_shield passives); fire on_shielded passives.
poison_opponentIf op has prevent_poison passive: skip. Else op.poison += amount (boosted by ap's amplify_poison passives); fire on_poisoned passives.
remove_poisonap.poison = max(0, ap.poison − amount)
draw_carddrawAmt = max(amount, 1) + sum of ap.board's amplify_draw passive amounts; ap draws up to drawAmt cards one at a time, stopping early if the deck empties; fire on_draw passives once if at least one card was drawn.
mill_opponentIf op.deck empty or op has prevent_mill passive: skip. Else mill amount cards (boosted by ap's amplify_mill passives), capped at op.deck size: op.deck.shift() → op.discard per card.
reduce_opponent_honorIf op has prevent_honor_loss passive: skip. Else op.honor = max(0, op.honor − amount).
deal_damage_to_playerApply amount damage (boosted by ap's amplify_damage passives) to op (shields first); if lost > 0 fire on_life_loss (ap=op, op=ap); fire on_damage_dealt (ap=ap, op=op).
deal_damage_to_random_creaturePick target alive, targetable (excludes untargetable) creature on op.board (hp > 0) per battle_plan.target_preference; if target is indestructible, no damage. Else apply damage (boosted by ap's amplify_damage passives; armor absorbs first).
deal_damage_to_weakest_creatureStable sort alive, targetable (excludes untargetable) op.board by (baseHealth + baseArmor) ascending; if sorted[0] is indestructible, no damage. Else damage sorted[0] (boosted by ap's amplify_damage passives).
destroy_targetPick targetable (excludes untargetable) entry from op.board per battle_plan.target_preference; if indestructible, no-op. Else fire on_leave; remove; → op.discard.
destroy_taggedDestroy all op.board permanents with the given tag (does not exclude untargetable — mass effect, no selection); indestructible matches are skipped (survive). Fire on_leave for each destroyed. No on_death. symmetric: true repeats the same on ap.board.
deal_damage_to_taggedDeal amount damage (boosted by ap's amplify_damage passives) to every alive (hp > 0) op.board permanent with the given tag (does not exclude untargetable); indestructible matches take no damage. symmetric: true repeats the same on ap.board.
deal_damage_to_random_taggedPick one alive (hp > 0), targetable (excludes untargetable) op.board permanent with the given tag per battle_plan.target_preference; if indestructible, no damage. Else apply amount damage (boosted by ap's amplify_damage passives; armor absorbs first).
destroy_random_taggedPick one targetable (excludes untargetable) op.board permanent with the given tag per battle_plan.target_preference; if indestructible, no-op. Else fire on_leave; remove; → op.discard. No on_death.
destroy_allDestroy all matching permanents on op.board (does not exclude untargetable). Filters: target_supertype / target_supertypes[]; tag / tags[]; cost_op + amount. indestructible matches are skipped (survive); the rest fire on_leave, no on_death. symmetric: true repeats the same filter on ap.board. Not exposed in either card-creator UI (hand-authored/admin cards only).
destroy_permanents_by_costDestroy all op.board permanents matching target_supertype (default "permanent") and cost filter (cost_op: lt/lte/gt/gte/eq, default "lte", vs cost_threshold, default 0) (does not exclude untargetable). indestructible matches are skipped (survive); the rest fire on_leave, no on_death. symmetric: true repeats the same on ap.board.
deal_damage_to_permanents_by_costDeal amount damage (boosted by ap's amplify_damage passives) to every alive (hp > 0) op.board permanent matching target_supertype (default "permanent") and the same cost filter as above (does not exclude untargetable); indestructible matches take no damage. symmetric: true repeats the same on ap.board (same computed amp bonus applied to both passes).
kill_targetPick damageable (hasHP), targetable (excludes untargetable) op.board entry per battle_plan.target_preference; if indestructible, no-op. Else fire on_death then on_leave; remove; → op.discard.
shuffle_discard_to_librarycount = min(amount + sum of ap.board's amplify_recycle passive amounts, ap.discard.length); shuffle discard; take first count; shuffle (remaining deck + picked) together into new deck.
utilize_own_permanentPick amount (1–10, default 1) non-utilized board entries matching target_supertype (default "permanent"), one at a time without replacement, via battle_plan.cost_preference (default: lowest-melee creature first, plain random among non-creature permanents) — each consumes one RNG call and fires on_utilized passives. Utilizes as many as available if fewer than amount exist.
utilize_andSame as above, then fire each effect in then[] if at least one permanent was utilized.

Creature damage: absorbed = min(curArmor, amount); curArmor -= absorbed; hp -= (amount - absorbed)

Board pruning (after any effect that could kill): collect all entries with hp ≤ 0; for each fire on_death then on_leave; add card_id to discard; remove from board.

Win Conditions

Checked after every state change, in this priority order:

  1. Both life ≤ 0 → draw (mutual_death)
  2. Opponent life ≤ 0 → active player wins (health)
  3. Active player life ≤ 0 → opponent wins (health)
  4. Both honor ≥ 100 → draw (mutual_honor)
  5. Both honor ≤ -100 → draw (mutual_dishonor)
  6. Active player honor ≥ 100 → active player wins (honor)
  7. Opponent honor ≥ 100 → opponent wins (honor)
  8. Active player honor ≤ -100 → opponent wins (dishonor)
  9. Opponent honor ≤ -100 → active player wins (dishonor)

Honor thresholds scale together under the honor_threshold_50 chaos effect (100 → 50, -100 → -50). reduce_opponent_honor and the honorbound combat keyword aren't floored at 0 — they're the intended path to a dishonor loss. lose_honor (a self-inflicted drawback effect) stays floored at 0.

After MAX_TURNS (600) without a winner: higher honor wins (timeout_honor); tie → higher life (timeout_life); still tied → draw.

Hash Format

Compute SHA-256 of the compact JSON array of results, sorted by match_id.

Field order within each result object must be exactly: match_id, winner_slot, reason, turns. No spaces after : or , separators. Integers must be integers (not floats). Null winner is JSON null.

# Python — streaming approach (avoids large string in memory)
import hashlib, json
results_sorted = sorted(results, key=lambda r: r["match_id"])
h = hashlib.sha256()
h.update(b"[")
for i, r in enumerate(results_sorted):
    if i: h.update(b",")
    h.update(json.dumps(
        {"match_id": r["match_id"], "winner_slot": r["winner_slot"],
         "reason": r["reason"], "turns": r["turns"]},
        separators=(',', ':')
    ).encode())
h.update(b"]")
shard_hash = h.hexdigest()

In shard mode the hash covers only your shard's matches. Phase 1 and Phase 2 both sort the same match IDs, so their hashes must match if both simulations are correct.

Reference Implementations

Pre-built binaries are available in /shell/ for the following platforms:

PlatformFile
Linux x86-64validate_linux_amd64
Linux arm64validate_linux_arm64
macOS x86-64validate_macos_amd64
macOS arm64 (Apple Silicon)validate_macos_arm64
Windows x86-64validate_windows_amd64.exe

A PowerShell wrapper script (validate.ps1) is also available for Windows users who prefer not to run the binary directly.

Tips & Gotchas