The Battle Engine is the off-chain server that runs the actual fight simulation. It exists because 9-unit, multi-round combat is too expensive to run on-chain, but every input and output is anchored on-chain so that anyone can replay a battle and verify the result. This page is the protocol spec — what gets signed, what gets submitted, what gets stored where. If you’re a player, you can skip to Battle.Documentation Index
Fetch the complete documentation index at: https://docs.basepixel.io/llms.txt
Use this file to discover all available pages before exploring further.
Design goals
- Server-authoritative, publicly verifiable. The Engine runs the fight, but the inputs (both layouts, both signatures, the random seed) and the signed replay are public. Anyone can rerun the simulator on the inputs and check that the Engine didn’t lie.
- No on-chain gas for players. Players sign messages off-chain (EIP-712). The Engine pays gas for settlement and deducts it from the spoils.
- Layouts stay private until combat. Layouts are committed on-chain as a hash. The plaintext layout never goes on-chain — it goes from the player’s wallet to the Engine, and shows up in the replay log only after the battle. Players can recover their plaintext on a fresh device via a signature-gated restore endpoint (see Phase 1).
- Deterministic. Given the same inputs (both layouts, attacker pixel ID, defender pixel ID, random seed), the simulator must always produce the same output. No wall-clock, no
Math.random(), no off-chain state.
Lifecycle
Phase 1 — Commit (layout setup)
A player sets or modifies their layout while in Unaction mode. Off-chain (player’s wallet or client):layoutHash to the PixelCoreFacet. The plaintext layout is never sent on-chain — the player keeps it locally (and optionally backs it up encrypted).
The nonce is derived from a fixed-message wallet signature. This makes the layout recoverable from the wallet alone — no separate backup needed — while still being unguessable to anyone who doesn’t control the wallet.
POST /api/layouts/:tokenId/plaintext) so the simulator has it ready at lock time. If the player wipes localStorage on a different device, they can recover via:
personal_sign) over BasePixel.RestoreLayout.v1\ntokenId=:id\ndeadline=:ts, proving the requester controls the wallet that owns the pixel. The signed window is short (5 minutes from deadline) so a leaked signature can’t be replayed indefinitely.
The local draft is keyed basepixel:formation:v3:<tokenId> and stores a single 9-slot units array — one pixel only ever has one faction, so no per-side split is needed.
Phase 2 — Intent (attack signature)
When a player wants to attack, they sign an attack intent off-chain (EIP-712).- Signature matches the attacker pixel’s owner.
keccak256(units, nonce, attackerPixelId) == attackerLayoutHash.attackerLayoutHashmatches what’s stored on-chain for that pixel.- Defender pixel is in Action mode and not currently locked in another battle.
- Deadline hasn’t passed.
Phase 3 — Lock (on-chain commit)
Once the intent is validated, the attacker submits the lock transaction directly and pays the attack fee with the same call. There is no Engine-managed escrow and nopermit-style approval — V1.1 simplified the fee path to “the attacker pays per battle, on-chain, in the same tx that locks the fight.” The Engine’s role at lock time is purely advisory (it had to sign off on the intent in Phase 2).
The attacker calls:
- Requires
msg.value == attackFee(currently 0.0005 ETH). The fee accrues to the eventual winner’s spoils — no escrow held outside the battle’s lifetime. - Verifies
engineSignaturewas produced by the registeredENGINE_SIGNERover theAttackIntenthash (proves the Engine validated this intent in Phase 2). - Confirms the attacker owns
attackerPixelIdand that neither pixel is already in a battle (atomic check + set). - Sets
inBattle = truefor both pixels. - Emits
BattleLocked(battleId, attackerPixelId, defenderPixelId, attackerLayoutHash, defenderLayoutHash, lockedAt).
inBattle = true blocks edits), the locked hash is what the simulator will verify against.
After the lock tx confirms in the wallet, the frontend POSTs the receipt’s BattleLocked log to POST /api/battles/notify-locked. The backend decodes it locally (no eth_getTransactionReceipt round-trip), runs the eager simulation, and broadcasts the replay over SSE — see Phase 4.
Phase 4 — Simulate + Stream
The Engine runs the simulation eagerly as soon as it seesBattleLocked. There is no separate “wait for defender to come online” phase — V1.1 requires both sides to have pre-uploaded their plaintext at commit time, so the Engine has both layouts in its plaintext cache the moment lock fires.
Concretely, the indexer’s BattleLocked handler:
- Loads attacker + defender plaintext from the in-process cache (warm path: ~10ms; cold path falls back to the Postgres
layout_plaintextstable). - Verifies each plaintext against its on-chain
layoutHash. - Runs the deterministic simulator (below) to produce the full
ReplayLog. - Persists
battle_stepsin a single batched multi-row INSERT and stampsreplay_hashon the battles row. - Broadcasts a
BattleStreamEvent::Simulated { battle }payload over an in-processtokio::sync::broadcastbus.
GET /api/battles/stream?battleId=:id, edge-runtime) receive the full battle + replay almost immediately after the lock tx confirms. The stream emits a connected sentinel, then the simulated event, then a 5s keep-alive heartbeat. This replaces the older “wait for defender reveal then simulate” flow.
If the defender never uploaded plaintext (rare — the formation editor uploads on commit), the eager-sim aborts, the resolver loop falls back to defender forfeits → attacker wins after the on-chain lock window expires.
The simulator itself:
@basepixel/simulator) so anyone can rerun it.
Replay log format
The Engine produces a structured replay log:ENGINE_SIGNER key. The full replay log is uploaded to long-term storage (S3 + IPFS pin), keyed by battleId.
Phase 5 — Settle (on-chain transfer)
The Engine submits the result:- Verifies
engineSignaturewas produced by the registeredENGINE_SIGNER. - Stores
replayHashandreplayUrion-chain — anyone can fetch the log later and re-verify. - Performs the asset transfer based on
outcome:attackerWin→ defender’s NFT and insurance pool transfer to attacker; the attack fee paid at lock joins the (now attacker-owned) pixel’s insurance pool.defenderWin/draw→ attacker’s NFT and insurance pool transfer to defender; the attack fee accrues to the defender’s insurance pool.
- Clears
inBattle = falsefor both pixels. - Starts the 24-hour redeem window for the loser.
- Emits
BattleResolved(battleId, outcome, replayHash, replayUri).
Trust model
The Engine is server-authoritative but verifiable, similar to Pirate Nation or Realm.gg:| Threat | Mitigation |
|---|---|
| Engine fakes the result | replayHash is on-chain; full log is public. Anyone can rerun @basepixel/simulator on the inputs and check that the hash matches. A mismatched hash is a verifiable cheat. |
| Engine lies about a layout | The plaintext layout in the replay log must hash to the layoutHash already on-chain (committed at mint or last edit). Engine cannot fabricate a layout. |
| Player swaps layout mid-battle | inBattle = true blocks layout edits. The defender’s layout hash is snapshot at lockBattle. |
| Player replays the same intent | EIP-712 nonce + per-attacker counter on-chain. Each intent usable once. |
| Engine front-runs by predicting the seed | Seed = keccak256(lockBlockHash, battleId). The block hash isn’t known until the lock tx is mined, so neither player nor Engine can know the seed when the intent is signed. |
| Defender goes offline to dodge | Plaintext was uploaded at commit time, so the Engine can simulate without the defender being online. If the upload is missing, the resolver falls back to forfeit → attacker wins after the lock window. |
| Engine is offline | Battles can’t start. Players are unaffected (no funds locked). On Engine restart, any locked-but-unresolved battles past their settlement deadline can be force-resolved by a guardian multisig. |
- Steal NFTs (it can only call
resolveBattlewith a valid signed replay; the contract verifies the signature is from the registeredENGINE_SIGNERand atomically transfers peroutcome). - Fabricate layouts (hash mismatch is detectable by anyone).
- Bias outcomes (the simulator is public; replay log on-chain hash must match).
- Censor specific players (refuse to start their battles). Mitigation: open-source the Engine, support multiple Engine instances eventually.
- Delay settlement (within the settlement deadline). Mitigation: published SLA + guardian fallback.
Errors
requestId for support traceability. The full list is enforced by the Engine’s OpenAPI spec.
What’s stored where
| Data | Location | Reason |
|---|---|---|
| Layout hash | On-chain (PixelCoreFacet) | Public commitment, can’t be changed mid-battle |
| Layout plaintext + nonce | Player’s wallet + Engine cache | Private until reveal |
| Attack intent + signature | Engine database (transient) | Used once, then archived |
| Replay log (full) | IPFS + S3 backup | Long-term, anyone can verify |
| Replay hash | On-chain (BattleFacet) | Verifiable anchor |
| Battle outcome | On-chain (BattleFacet) | Source of truth for asset transfer |
| Battle ID counter | On-chain | Guarantees unique, monotonic IDs |
Open implementation questions
These are deferred to post-MVP, not blocking the spec:- Engine high availability. Single-instance today; multi-region active-passive after launch.
- Replay log retention. Hot in S3 for 90 days, IPFS pinned indefinitely. Cost analysis at scale TBD.
- Anti-spam on attack intents. Rate limit per wallet + the 0.0005 ETH cost itself acts as Sybil protection. Reassess after launch.