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.
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 type | When | Reward |
|---|---|---|
| Phase 1 — run your shard of the live cohort | During the 10-min validation window after the cohort closes | Merit-based at finalization (faster = higher rank) |
| Phase 2 — re-verify a Phase 1 shard from the previous cohort | After Phase 1 is complete; runs in parallel | Merit-based at finalization |
| Retroactive — Phase 1 shards from old finalized rounds | Anytime via GET /api/v1/cohort/retro | 5 AG immediately per shard |
| Phase 0 — pre-validate the upcoming cohort during registration | Only when no live or retro work exists; admin-enabled | 1 AG immediately per shard |
| Consolation — showed up but all shards were already covered | Phase 1 claim returns shard_id: null | 1 AG immediately (once per cohort) |
| Validate ping — visit the Arena page between rounds | Every 10 minutes while a cohort is open for registration | 17 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", …}
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 } */ ] }
retry: true — race condition; retry immediately to get a different shard.
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.
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:
validation_cohortis null (no live battle window)- No open retroactive shards exist
- The deck-registration window is still open
- An admin has enabled pre-validation for that cohort and phase-0 shards are generated
// 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 }
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? }
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
STARTING_LIFE = 100
HONOR_TO_WIN = 100 // reduced to 50 when chaos_effect = "honor_threshold_50"
MAX_TURNS = 600
MAX_HAND = 10
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 slug | Modification (once per occurrence) |
|---|---|
extra_draw | At step 2 (Draw), active player draws 1 additional card. |
extra_energy | At step 3 (Energy), active player gains 1 additional basic energy. |
creatures_enter_melee_boosted | When a Creature enters the battlefield (on_enter), give it +1 Melee permanently. |
permanents_enter_armored | When any permanent enters the battlefield (on_enter), give it +1 base Armor permanently (also affects current curArmor). |
honor_threshold_50 | Set 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_tick | Active player gains 1 poison. |
mill_tick | Active player mills 1 card from their own deck. |
self_creature_damage | Deal 2 damage to a random Creature the active player controls (indestructible creatures are skipped, same targeting rules as deal_damage_to_random_creature). |
recycle_tick | Active player recycles 1 card from their own discard pile back into their deck (shuffled in). |
player_damage_3 | Active player takes 3 damage directly (fires on_life_loss). |
honor_loss_1 | Active player loses 1 Honor. Not floored at 0 -- can go negative (same as the lose_honor effect type). |
discard_tick | Active player discards 1 random card from hand (no-op if hand is empty). |
sacrifice_creature | Active player sacrifices 1 random Creature they control (no-op if none). |
sacrifice_relic | Active player sacrifices 1 random Relic they control (no-op if none). |
life_gain_3 | Active 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. |
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:
- Game 1 — seed =
match.seed, slot A goes first (firstPlayer = 0). First player draws 5 cards; second player draws 6. - Game 2 — seed =
match.seed XOR 0x80000000(flip bit 31, unsigned 32-bit), slot B goes first (firstPlayer = 1). Same hand-size rule applies.
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:
- 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_lossif damage landed; check win. - Draw — active player draws one card. If deck empty →
deck_outloss. Fireon_drawpassives. - Energy — active player gains 1 basic energy.
- Opponent's
each_opponent_turntriggers — shuffle a snapshot of the opponent's board (RNG-driven); fireeach_opponent_turnon each entry (ap = opponent, op = active); prune dead; check win. each_turn/turn_starttriggers — fire both trigger types on a snapshot of the active player's board; prune dead; check win.- Play cards (greedy loop) — if
battle_plan.energy_hold > 0, whether/how the hold applies depends onbattle_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, whenplay_priority = "card_order"andcard_order_holdisn't explicitlyfalse(default on): before scanning, find whichevercard_order-ranked card currently in hand ranks highest and check its effective cost againstenergy + ephemeral— if unaffordable, only cards payable usingephemeralalone 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 bybattle_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; fireon_enterwith the plan; prune dead; check win. Repeat until no card can be played. end_of_turntriggers — fire on active player's board snapshot; prune dead; check win.- Armor recovery — reset
curArmorto base armor (plus any liveanthem_armorbonus) for every creature on both boards. - Combat —
totalMelee = Σ 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 bycombatDmg) plus champion honor link (champion's raw melee, unaffected by anthem, added as honor). - Clear ephemeral; discard to hand limit — set ephemeral = 0; while hand > 10 pick card per
battle_plan.discard_priorityand 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.
| Field | Type | Default | Description |
|---|---|---|---|
play_priority | string | "cheapest" | Hand sort order |
card_order | int[] | [] | Card IDs in desired play order; used when play_priority = "card_order" |
card_order_hold | bool | true | When 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_order | string[] | [] | Supertype names in desired play order; used when play_priority = "type_order" |
tag_order | string[] | [] | 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_hold | int | 0 | Skip card play phase until energy + ephemeral ≥ this value |
energy_hold_scope | string | "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_preference | string | "random" | Targeting for damage/destroy/kill effects: "random", "weakest", "strongest", "least_armor" |
target_tag_order | string[] | [] | Optional tag-priority companion to target_preference — see below |
cost_preference | string | "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_priority | string | "random" | Card to discard when over hand limit: "random", "cheapest", "costliest" |
champion_timing | string | "asap" | Champion play timing: "asap" (as soon as affordable) or "hold" (wait for threshold) |
champion_energy_threshold | int | 0 | Min 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_cost — not 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)
| Value | Sort 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 type | Description |
|---|---|
gain_energy | ap.energy += amount |
gain_ephemeral_energy | ap.ephemeral += amount |
gain_life | ap.life += amount (boosted by ap's amplify_life_gain passives); fire on_life_gain passives |
opponent_gain_life | op.life += amount (boosted by ap's amplify_life_gain passives); fire on_life_gain passives (ap=op, op=ap) |
gain_honor | ap.honor += amount (boosted by ap's amplify_honor passives); fire on_honor_gain passives |
gain_shield | If op has prevent_opponent_shield passive: skip. Else ap.shields += amount (boosted by ap's amplify_shield passives); fire on_shielded passives. |
poison_opponent | If op has prevent_poison passive: skip. Else op.poison += amount (boosted by ap's amplify_poison passives); fire on_poisoned passives. |
remove_poison | ap.poison = max(0, ap.poison − amount) |
draw_card | drawAmt = 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_opponent | If 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_honor | If op has prevent_honor_loss passive: skip. Else op.honor = max(0, op.honor − amount). |
deal_damage_to_player | Apply 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_creature | Pick 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_creature | Stable 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_target | Pick targetable (excludes untargetable) entry from op.board per battle_plan.target_preference; if indestructible, no-op. Else fire on_leave; remove; → op.discard. |
destroy_tagged | Destroy 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_tagged | Deal 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_tagged | Pick 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_tagged | Pick 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_all | Destroy 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_cost | Destroy 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_cost | Deal 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_target | Pick 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_library | count = 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_permanent | Pick 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_and | Same 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:
- Both life ≤ 0 →
draw(mutual_death) - Opponent life ≤ 0 → active player wins (
health) - Active player life ≤ 0 → opponent wins (
health) - Both honor ≥ 100 →
draw(mutual_honor) - Both honor ≤ -100 →
draw(mutual_dishonor) - Active player honor ≥ 100 → active player wins (
honor) - Opponent honor ≥ 100 → opponent wins (
honor) - Active player honor ≤ -100 → opponent wins (
dishonor) - 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
- validate.py — Python 3, stdlib only (~800 lines)
- validate.go — Go, stdlib only (~1600 lines)
/js/battle-engine.js— browser JavaScript simulation (no API calls)- Full API reference (Swagger)
Pre-built binaries are available in /shell/ for the following platforms:
| Platform | File |
|---|---|
| Linux x86-64 | validate_linux_amd64 |
| Linux arm64 | validate_linux_arm64 |
| macOS x86-64 | validate_macos_amd64 |
| macOS arm64 (Apple Silicon) | validate_macos_arm64 |
| Windows x86-64 | validate_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
- Integer overflow — Mulberry32 uses unsigned 32-bit arithmetic. Mask after every multiply in Python/Ruby. In JS use
Math.imuland>>> 0. In Go useuint32. - Game 2 seed — XOR the original seed with
0x80000000before passing to the RNG. Treat as unsigned 32-bit. - Opening hand draw order — the second player draws 6 cards (not 5). Diverging here shifts all subsequent RNG calls.
- Shuffle opponent board — the opponent's board is shuffled via RNG before
each_opponent_turntriggers fire. This consumes RNG state — do not skip the shuffle even if the board has one entry. - Stable sorts — hand sorting and
deal_damage_to_weakest_creaturemust both be stable. Go:sort.SliceStable. Python:sorted(). JS:Array.sort()is stable in ES2019+. - Ephemeral energy — spent before basic energy, cleared at end-of-turn (step 8), not start-of-turn.
- Armor recovery — armor resets at end of turn (after end-of-turn triggers), not at turn start.
- Hash field order —
match_id, winner_slot, reason, turns. Any other order produces a different hash even if the data is correct. - Claim expiry / bans — 10-minute window. Abandon too many claims and you'll receive a validator ban for several rounds.
- Concurrent claim cap — max 3 live shards at once. Submit before claiming more.
- shard_id = null + retry: true — race condition on
shard_claim; retry immediately without backoff. - shard_submit retry: true — DB deadlock; retry 3–4 times with a short (0.2–0.5s) backoff.
