Skip to main content
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.

Design goals

  1. 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.
  2. 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.
  3. 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).
  4. 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

Battle lifecycle: commit, intent, lock, reveal+simulate, settle
A battle proceeds through five phases. The first phase happens once per layout change; the other four happen for every battle.

Phase 1 — Commit (layout setup)

A player sets or modifies their layout while in Unaction mode. Off-chain (player’s wallet or client):
On-chain:
The player submits 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.
The client also POSTs the plaintext to the Engine’s plaintext cache (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:
The query is signed with EIP-191 (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).
The signed intent is submitted to the Engine via API:
The Engine verifies:
  • Signature matches the attacker pixel’s owner.
  • keccak256(units, nonce, attackerPixelId) == attackerLayoutHash.
  • attackerLayoutHash matches 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.
If any check fails, the Engine rejects with a structured error (see Errors).

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 no permit-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:
This:
  • 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 engineSignature was produced by the registered ENGINE_SIGNER over the AttackIntent hash (proves the Engine validated this intent in Phase 2).
  • Confirms the attacker owns attackerPixelId and that neither pixel is already in a battle (atomic check + set).
  • Sets inBattle = true for both pixels.
  • Emits BattleLocked(battleId, attackerPixelId, defenderPixelId, attackerLayoutHash, defenderLayoutHash, lockedAt).
The defender’s layout hash is read from on-chain storage at lock time. That snapshot determines what the defender is committing to fight with — even if they switch to Unaction or change their layout afterward (they can’t, because 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 sees BattleLocked. 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:
  1. Loads attacker + defender plaintext from the in-process cache (warm path: ~10ms; cold path falls back to the Postgres layout_plaintexts table).
  2. Verifies each plaintext against its on-chain layoutHash.
  3. Runs the deterministic simulator (below) to produce the full ReplayLog.
  4. Persists battle_steps in a single batched multi-row INSERT and stamps replay_hash on the battles row.
  5. Broadcasts a BattleStreamEvent::Simulated { battle } payload over an in-process tokio::sync::broadcast bus.
Connected SSE clients (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:
The simulator is pure — no I/O, no clock, no global state. The exact same code is published as a public npm package (@basepixel/simulator) so anyone can rerun it.

Replay log format

The Engine produces a structured replay log:
The log is canonicalized (JCS — JSON Canonicalization Scheme) and hashed:
The Engine signs the hash with its dedicated 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:
The contract:
  • Verifies engineSignature was produced by the registered ENGINE_SIGNER.
  • Stores replayHash and replayUri on-chain — anyone can fetch the log later and re-verify.
  • Performs the asset transfer based on outcome:
    • attackerWin / forfeit → defender’s NFT and insurance pool transfer to attacker; the attack fee paid at lock refills the captured pixel’s insurance pool back to the 0.0005 ETH floor.
    • defenderWin → attacker’s NFT and insurance pool transfer to defender; the attack fee is paid to the defender as cash.
    • draw → defender holds the contested pixel; the attack fee is paid to the defender as cash (no NFT transfer).
  • Skims a 20% platform commission off the ETH that would otherwise flow to the winner’s wallet (the “cash spoils” — defender’s pre-battle insurance pool on AttackerWin, attacker’s pre-battle pool plus the attack fee on DefenderWin). Draw is a push and pays no commission. The captured pool’s seed slice is not in the tax base, preserving the 0.0005 floor needed to re-enable Action without a top-up.
  • Clears inBattle = false for 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: The Engine cannot:
  • Steal NFTs (it can only call resolveBattle with a valid signed replay; the contract verifies the signature is from the registered ENGINE_SIGNER and atomically transfers per outcome).
  • Fabricate layouts (hash mismatch is detectable by anyone).
  • Bias outcomes (the simulator is public; replay log on-chain hash must match).
The Engine can (and this is the residual trust):
  • 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

All errors are returned with HTTP status codes and a requestId for support traceability. The full list is enforced by the Engine’s OpenAPI spec.

What’s stored where

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.