Warbandry · for engineers
Warbandry is written so that balance can keep moving after launch and ownership cannot. Seven pieces, specified down to their events, the competitive mode with them. All of it is free to change until the coin deploys, and a named part of it freezes forever the moment the first roll is sold. That asymmetry is the thing to understand before touching anything.
The line
The contracts are written against interfaces rather than implementations, so the game layer can be re-tuned for years without migrating a token. The split is not a matter of taste — it follows one rule, and every borderline case is decided by it.
A swappable module may read state and gate whether a match can happen. It may never touch coins, seeds, composition or ownership.
Never behind a pointer. A holder's floor is computed from these alone, so no owner action can strand collateral.
Owner-settable pointers, an append-only list, and parameters bounded in code. All of them views or allowlists; none of them holds a coin.
tokenURI view and its data files. Cosmetics evolve without touching a token.| Piece | Mutability | It may never |
|---|---|---|
| Observer | Immutable | Revert inside a swap. The hook swallows the revert, and the roll would be lost in silence. |
| Decoder | Append-only | Decode a roll it has already decoded, differently. |
| Vault | Immutable core + parameters | Stop a melt, or read a pointer to compute a floor. |
| Renderer + its data files | Replaceable behind a pointer | Revert on a token the vault accepted. A revert there hides the token from every marketplace. |
| Calculator + its tables | Replaceable behind a pointer | Hold state — or stop answering once retired, since a match in flight still calls it. |
| Match contract | Replaceable, removable | Block a withdrawal of the free balance, or forfeit a stake. |
| Registry, inside the vault | Owner list | Hold more than eight games, or leave a removed game's locks standing. |
Guardrails
What must hold
Each of these holds from the first roll onward, and none of them can be revisited afterwards.
| Invariant | What it means in practice |
|---|---|
| The roll counter is gross and monotonic | It counts coin ever bought, per wallet, and the wallet is the transaction's origin —
hookData is never read. Sells, transfers and balance moves never decrement
it. Buy 10,000 then sell everything and you still hold ten rolls; buy 1,000 more and
the counter reaches 11,000, so an eleventh is written. |
| A seed is written once | The commitment is keccak256(poolId ‖ wallet ‖ index ‖ subIndex) at fixed
widths of 32, 20, 8 and 1 bytes, and the seed is
keccak256(commitment ‖ drawBlockHash). The draw block sits outside
the commitment, because a re-anchor rewrites it. Nothing ever rerolls an existing
creature; the only path to a different one is a new roll. |
| The draw block is read the same way everywhere | The chain's block-history contract at the L2 anchor first; native
blockhash at the L1 anchor as the fallback. Reading early or late gives the
same hash, so choosing when to settle cannot change the outcome. |
| The observer knows nothing of the vault | It counts and it writes. The vault keeps its own bitmap, one bit per roll index per wallet, of what has been recruited. No contract answers "what can this wallet still recruit" — the browser joins the two reads itself. |
| Composition is the identity | The vault's composition view is the source of truth: creature, trait,
row, seed and frozen coins per seat, readable without the renderer. Recruiting appends;
dismissing shifts the later seats left; the composition hash is one keccak over the
ordered seats and the reserve word, so any change of member, order, formation,
reserve or mode changes it. |
| Building is free; leaving and carding cost coins | Dismissing to coins burns the melt fee out of what comes back. Making a card out of a member burns the carding fee — 2% at the start, never above 10% — pulled from the holder on top, so the card keeps the member's full floor. Moving a card into a warband burns the card token and costs nothing. Moving a member between two warbands is free. |
| The melt fee is capped in code | Owner-settable in basis points under a hard-coded 10% ceiling, so a buyer can compute the worst-case floor of any warband before paying for it. |
| A lock stops a composition, never a transfer | A registered game increments a counter on the token, so one warband can sit in several pools at once. The holder can still sell it; the match settles for the player whose balance is engaged, not for whoever holds the token. |
| The cosmetics path may never write | Rendering lives in a view contract the vault points to, and each token carries one
styleData word. Only the style contract writes it, and that pointer is the
zero address at launch, so nobody does; the holder may always clear it. The vault treats
the bits as opaque, and nothing about seeds or composition may ever pass through that
path. |
The frozen list
The coin deploys with the observer already bound to its pool, and the deploy's own buy is the first roll. So this list binds from the pool's first block. Everything not on it is a parameter, a pointer or a later epoch.
keccak256(poolId ‖ wallet ‖ index ‖ subIndex)
at widths 32 / 20 / 8 / 1, then keccak256(commitment ‖ drawBlockHash). The
sub-index is zero: a roll yields exactly one thing.blockhash as the fallback. Never
mixed: one is addressed by L2 height, the other by L1 estimate.The promise
Whoever runs Warbandry can change how your warband looks, how it scores in games, the melt fee — never above 10% — and what a new creature locks — never above 1,000 tokens. Your floor is what you locked. They can never touch your coins, your creatures, what your creatures are, or who owns them.
There is no delay anywhere: no notice period, no timelock, no window in which a holder gets to react. The guarantee is not that a change is announced in advance. It is that the dangerous change cannot be expressed at all — the melt fee has a ceiling in code, a melt cannot be stopped, the floor reads storage the owner cannot rewrite, and seeds, composition and ownership are out of the owner's reach entirely.
The vault
The ERC-721. It holds the coins, the warbands and the cards, and it ships on day one with the surface every future game reads — the registry, the lock, the calculator pointer — before any game exists.
A player recruits a creature, dismisses it, and cards it. Freeze and melt stay the technical words for what happens to the coins. Recruiting from a roll pulls the lock — 1,000 tokens at launch — settles the roll if nobody else has, decodes the seed, and writes the seat. Dismissing to coins returns what that seat froze less the melt fee, and burns the fee. If the last seat leaves, the token is burned; the client warns first.
A warband holds up to the seat cap. A card holds exactly one member, and the kind is fixed when the token is minted. Moving a card into a warband is one gesture: the seat moves over with its seed and its coins, the card token is burned, and it costs nothing. The other direction — a member out of a warband into a new card — burns the carding fee in coins, pulled from the holder on top, so the card still holds the full amount its seat froze.
1 to 32 bytes of printable ASCII, minus the five characters that would need escaping inside the JSON and the SVG. No uniqueness — a token id already tells two warbands of the same name apart. Renaming is allowed at the same fee, an empty name restores the default, and naming works while the warband is locked, because it changes nothing in a duel.
One bytes32 a warband, written by its holder and opaque to the vault. It
carries which seats are reserve, which of the two modes the warband plays, manual or
automatic, and, for an automatic warband, the one condition and the swaps that condition
fires. The vault checks nothing inside it: a malformed word is the match contract's to
refuse at reveal, with a reason code and a refund. Every function that adds, removes or moves a seat clears the word,
since it names seats by position and a shifted seat would name the wrong creature; the
client writes it again in the next transaction. A cleared word is a warband with no reserve
and no rule, which plays exactly as a warband did before the reserve existed. The
composition hash covers it, so a lock covers it too: a rule cannot change between the reveal
and the settlement.
| Parameter | At launch | Bound in code |
|---|---|---|
| Melt fee, burned out of what returns | 2% | 10% |
| Carding fee, in coins, burned, paid on top | 2% | 10% |
| Freeze fee, per creature | 0 ETH | 0.001 ETH |
| Naming fee | 0 ETH | 0.01 ETH |
| Royalty (ERC-2981) | 0% | 5% |
| Lock per creature | 1,000 tokens | 10 to 1,000 tokens |
| Seat cap | 21 | 32, the storage bound |
The seat cap starts at 21: the format's sixteen fielded creatures and five in reserve, the five a starting value the bench confirms. A reserve seat is a seat like any other, freezing the same coins and counting in the same floor. What stays under the renderer's own limit is the fielded count rather than the cap, since the renderer draws the fielded seats alone.
ETH fees accrue and are pulled by the fee recipient. The vault never pushes ETH, so a recipient that reverts can never block a freeze.
The subgraph never calls a view for history, so every event carries every field the
history needs. Created and Burned; Recruited with
the seat, the creature, the trait, the seed, where it came from and the coins frozen;
Dismissed with where it went, what returned, what burned, and whether it
was the last member; Formation, Named, Styled,
AdjustmentSet, emitted with a zero word whenever a seat change clears it;
Locked and Unlocked with the game; MetadataUpdate
on every change of composition, name, formation or style, or marketplaces keep the old
image. Every governance change emits its old and its new value.
The views: composition — the source of truth, readable without the
renderer — compositionHash, floor, kind,
name, styleData, adjustment, locks,
isLocked, rollsUsed over a range of roll indexes, and every
parameter and pointer.
The pause covers entries only — recruiting from a roll, and carding. A warband emptied of its last member is burned rather than kept as a husk. And a lock is a counter per game, not a flag: one warband can be engaged in several games at once, and none of those locks blocks a transfer.
The observer
Frontier's hook calls it after every settled swap on the pool. It counts what each wallet has bought, gross, and writes a record when that count crosses a 1,000-token boundary. One record per buy covers a range of roll indexes, so the cost is the same whether a buy makes one roll or a thousand — which is what keeps a large buy from ever failing.
Each record carries two anchors: the L2 height, read through the chain's block-history
contract, and the L1 estimate as a fallback through native blockhash. The
history contract reaches about eleven hours back; the fallback is coarser and its window
is about fifty-two minutes. Degraded, never dead.
settle(id) is open to anyone. It reads the draw block's hash and writes it
once. Reading early or late gives the same hash, so choosing when to settle cannot change
the outcome. reanchor(id) is open to anyone too, and only when both anchors
have become unreadable: it writes a fresh pair, both in the future, as many times as it
takes.
Three events — a roll written, a roll settled, a roll re-anchored — and the views that read a wallet's records, a record's status, and the seed of one roll. That is the whole surface.
It has no owner and no lever, because a bound hook extension is exactly what runs, forever. And it must never revert inside a swap: the hook swallows the revert, and the roll would be lost in silence. So there is no unbounded loop in it, no arithmetic that can trip, and the one external call it makes sits inside a try/catch.
The match contract
Every number in this section is an owner parameter with its cap in code — the starting values of one launch bracket, not laws. A bracket is a stake level and a format; nothing about the players.
Coins sit inside the match contract. Deposit any amount at any time, including while engaged. Withdraw any amount of the free balance at any time, and nothing may ever block that withdrawal — not a pause, not a removal from the registry, not a tick nobody settled. Entering moves the stake from free to engaged. Winnings land on the free balance and stay there until the player takes them. Accounting is per tick: one tick's pot can never pay another, and a tick nobody settles ends by refund.
One click is one match. The player signs a message naming the bracket and carrying a commitment — the warband, its composition hash and a salt, hashed in the client, the salt derived from a wallet signature so it can be rebuilt on any device. The keeper relays it; a player who would rather send the transaction calls the same function. Deposit and withdraw are the only transactions a player ever has to send. Cancelling takes effect at the next tick, and only if no pairing was possible there — so nobody can watch someone arrive and leave.
Every 30 seconds. The bracket's keeper sends one transaction carrying every entry it holds a secret for, and that transaction closes the window. Per entry, in order: the commitment matches, the player still owns the warband, the composition hash still matches, the reserve word is well formed, the format holds on the sixteen that fight, the vault's lock is taken, and the warband's composition-only half is read from its cache or computed and stored. An automatic warband, one whose swaps are written in advance, is checked twice: on its sixteen and on the warband those swaps would produce, and both halves are cached, which is what leaves settlement a comparison and a pick. Every failure is a refund of the stake, minus a small ETH gas fee, with a reason code — never a forfeit. An entry the transaction does not carry is refunded too. If no reveal arrives within 60 seconds of the tick's time, the tick did not happen: every entry stays in the pool, still engaged.
The format is the bracket's values at that moment: at most 16 members, at most 6 copies of one creature, one mythic, two legendaries, no relic until a relic kind exists, five reserve seats, three swaps a match, and no tier limit on what waits in reserve. Those are today's launch values, and the five and the three wait on the bench. A paired match never re-reads them. The caps bind the warband a swap produces as much as the one composed, so a reserve of five legendaries still brings in the two the format allows and no more.
The tick also records the calculator's address as the reveal closes, and settlement calls that address whatever the pointer says by then.
Once the tick's first anchor is readable, anyone may pair. The caller submits the revealed entries sorted by rating and the contract only checks that the order does not decrease — verifying is linear, sorting is not. The field is cut into groups of 6, shuffled inside each group by the anchor's hash, and paired with neighbours. A player with no neighbour within 300 rating points, a pair that already fought inside the last hour, or the odd one out stays in the pool for the next tick: the lock released, the stake still engaged. Each paired match moves a fixed ETH fee from both gas balances into the gas pot. Where the bracket's adjustment window is zero, the second anchor is set 15 seconds ahead, 150 L2 blocks, and it is the one that decides. Where the window is set, pairing opens it instead and no draw block exists yet.
A warband carries five creatures beside its sixteen and may bring up to three of them in for one match, one for one. Which seats are reserve, which mode the warband plays and, for an automatic warband, its one condition and its swaps, all sit in the reserve word the vault holds. There are two modes, chosen per warband when it is composed. Manual asks its owner inside the match; automatic carries the answer in advance, as one condition about the opponent and a fixed set of swaps the contract applies at settlement. One contract runs both, and the bracket's adjustment window decides which one exists: at zero the bracket is automatic only.
In a manual bracket, pairing puts the match in a state of its own, awaiting
adjustment. Each player signs Adjustment(matchId, adjustmentHash, nonce,
deadline), the keeper holds both and calls revealAdjustments for that
match as soon as it has both. An empty list is a legitimate answer: it says the base warband
and it lets a match settle before the window ends. Per side the contract checks the
signature, the nonce, the deadline, the hash against its preimage, that each swap pairs a
reserve seat with a fielded one and names no seat twice, and that the warband the swaps
produce still passes the format recorded at reveal. Every failure plays the base warband
with a reason code, and none of them is a refund: the match is already paired and the
opponent is owed a fight.
The timing rule is enforced on chain. Before the window's end, a reveal has to carry a valid sealed adjustment for both sides or that match is skipped untouched. From the window's end the keeper may sweep, a missing side counting as an empty adjustment. From the sweep grace on, anyone may sweep, so a keeper outage costs the adjustments of one window and never the match.
The draw anchor is set here, per match, in the transaction that reveals both adjustments, 15 seconds out, and never at pairing. The win probability is a public function of the two warbands, so a draw block readable during the window would let a player work out which of their swaps lands the draw on their side and send that one. The rule in one sentence: no draw block exists while a player may still choose. In an automatic bracket every adjustment is bound before the pairing, so the pairing anchors as it did.
The events: Paired carries the window's end; Adjusted(tick, matchId,
player, ok, reason, swaps) lands once a side at the reveal; DrawAnchored(tick,
matchId, l2, l1) when the anchor is written. Nothing at all is emitted when a
sealed adjustment arrives, and that silence is deliberate: an event there would tell the
opponent that the other side has answered, and how fast.
Said plainly: on chain the reserve is hidden from the screen, not from a reader of the
chain. The seats and the reserve word are storage in a public contract, and
Paired names both warband ids, so anyone reading the chain during the window
knows the opponent's reserve, their mode and their rule. The client shows none of it, no
event carries it, the lobby's odds are computed on base warbands. Once the fight is settled, the result shows
the opponent's whole reserve: it is public in the vault already, and it says what you played against. What stays sealed either way is the opponent's answer.
If the reading ever matters, the remedy needs no change in the vault: a later match contract
takes the reserve as cards committed by hash and revealed with the adjustment, and the vault
already holds cards.
| Parameter, per bracket | At launch | Bound in code |
|---|---|---|
| Reserve seats | 5 | the seat cap less the member cap |
| Swaps in a match | 3 | the reserve's seats |
| Tier limit on the reserve | none | a cap in code, written only if the bench asks for one |
| Adjustment window | 60 s · 600 L2 blocks | a cap in code; 0 makes the bracket automatic only |
| Sweep grace | 300 s · 3,000 L2 blocks | a cap in code |
| Minimum entrants to pair | 0 on the free bracket; six suggested on a staked one | per bracket; a tick under it pairs nobody and everyone stays in the pool |
| Adjustment reward | sized on measured gas | a cap in code |
Anyone, in chunks of at most 50 matches, once the second anchor is readable; a match still awaiting its adjustment is skipped and the chunk carries on. Per match, the state that plays is decided first, side by side: in a manual bracket, whatever the adjustment reveal stored; in an automatic one, the side's own condition read against the opponent's base warband, locked since the reveal and unable to have moved, so the two rules never depend on each other. Then the recorded calculator returns a win probability in basis points, the draw is the anchor's hash combined with the match id, and the comparison decides. The pot is the two stakes; the rake comes out of it and the rest lands on the winner's free balance; both warbands unlock. The caller is reimbursed per chunk from the gas pot. Because the result is readable before it is paid, the client carries two states — result and settled — or a late settle looks like a missing payout.
Elo on chain, per season and per format, on the regulation bracket only. Everyone starts
at 1000. K is 32, and 64 over an account's first 20 matches, because sorting a field that
all starts at 1000 is the whole job at the beginning. The expected score comes from a
rational lookup table over the rating difference — no 10^x anywhere in
Solidity. Surprise is measured on the two ratings and never on the computed odds, or the
rating would be measuring luck. A season change resets every rating to base. No decay on
chain.
5% of the pot at the start, capped at 15% in code, set per bracket. Half is burned; half goes to an address the owner sets — the team's wallets at first. The contract holds no season pot; it only transfers, and the first seasons are paid by hand.
Every state-changing call checks first that the vault still registers this contract as a game. The moment it does not, the contract liquidates itself: no entries, no reveals, no pairing; everything engaged returns to the free balance; paired but unsettled matches are refunded, because their locks are gone and a composition may have moved. Withdrawals stay open throughout.
The keeper
The keeper is a service the team runs, one per bracket. It relays signed entries, sends the reveal for each tick in one transaction, submits the sorted order, settles in chunks, and re-anchors a window that lapsed.
The reveal is its alone — one registered address per bracket, changeable by the owner. One keeper per bracket, so two of them can never race with different batches and refund each other's players.
Its second job, in a manual bracket: it receives both sealed adjustments of a match, relays nothing until it holds both, sends the reveal the moment it does, and sweeps at the window's end. It sees both answers before the chain does, which is the trust the pool already places in it, bounded the same way: it can delay a match to the sweep and it can drop a side's answer, both visible in the events, and it can forge neither, since every adjustment is signed by its player. That is one more keeper transaction a tick, and several small ones rather than one large, since a match whose two players answered in ten seconds is not made to wait for the window.
What it costs. A reveal is about 200 k of gas an entry, and about 350 k for an automatic warband, since its adjusted half is computed and cached beside the base. An adjustment reveal is about 300 k a match. Both batch sizes are set under the chain's block gas limit, measured rather than assumed, and an automatic bracket's batch is set lower than a manual one's.
Everything after the reveal is open to anyone: pairing, settling, re-anchoring. Each action is reimbursed from a gas pot fed by a fixed ETH fee taken from both players at pairing. The fee is fixed rather than proportional because the work is: one reveal per player, a fiftieth of a settle, whatever the stake is. A third party settles at no loss, so an outage of ours has a cure that pays for itself.
What an outage costs is waiting, never a stake. An entry has these fates and no others:
| The entry | What happens to it |
|---|---|
| In the reveal, every check passed | Paired. |
| In the reveal, a check failed — recomposed, transferred, illegal | Refunded, minus the gas fee, with the reason. Nothing was visible before this transaction, so the failure gained its player nothing. |
| Not in the reveal — the secret never arrived, or it was left out | Refunded. Not the player's doing, and nothing was gained by it. |
| No reveal transaction at all | The tick did not happen. Every entry stays in the pool, still engaged, and the screen says "searching" one tick longer. |
The keeper sees the pool one tick before anyone else. It cannot change anything with that — it can only deny, never take — and the entries it is looking at are already bound by their commitments. What closes the rest is a rule stated in public rather than in code: the operator does not play ranked.
Before the first buy
Still open