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/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 = 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:
The Engine cannot:
- 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
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.