A technical deep-dive into building a live, autonomous AI agent negotiation arena where every betrayal costs real HBAR.
The Idea
What if AI agents didn't just talk about deals — they actually paid each other?
Most multi-agent demos are either purely symbolic (agents "cooperate" in log output) or require custom payment rails. The x402 protocol changes that. It's an open standard for HTTP 402-based micropayments that lets any client automatically pay a server for access — and critically, it now supports Hedera through @x402/hedera.
I wanted to build something that showed x402's potential in a dramatic, visual, shareable way. The result is Agent Thunderdome: a live spectator arena where four AI agents with distinct personalities autonomously negotiate across four game modes, and every meaningful action — every offer accepted, every defection executed, every bid placed — triggers a real x402 payment settled on Hedera Testnet in seconds.
What We Built
Four agents, four game modes, one arena:
| Agent | Strategy |
|---|---|
| Greedy Greg | Overbids 40%, defects 75% of the time |
| Paranoid Pat | Offers 50% below market, defects 60% |
| Chaotic Claude | Pure 50/50 variance, meme dialogue |
| Honest Hannah | Cooperates 90%, game-theory optimal |
Game modes:
- Bluffing Auction — bid for scarce items, each bid is an x402 payment
- Prisoner's Dilemma — cooperate or defect, real HBAR changes hands on reveal
- Resource War — attack, defend, steal from a shared pool via direct agent-to-agent x402
- Free Negotiation — open offers with counter-offers, accepted = on-chain
Everything is live. Every payment produces a real Hedera Testnet transaction with a HashScan link that appears in the UI within 1–3 seconds.
Architecture
Browser ←── WebSocket (Socket.io) ──→ Custom Node.js Server
│
Game Engine
│
x402 Payment Layer
(@x402/hedera ExactHederaScheme)
│
Hedera Testnet
(real TransferTransaction)
The key architectural decision: run Next.js and Socket.io in a single custom Node.js HTTP server. This avoids the complexity of two separate processes and lets them share the same port:
// server.ts
import { createServer } from "http";
import next from "next";
import { createSocketServer } from "./server/socket-server";
const app = next({ dev: process.env.NODE_ENV !== "production" });
await app.prepare();
const httpServer = createServer((req, res) => {
handle(req, res, parse(req.url ?? "/", true));
});
createSocketServer(httpServer); // attaches Socket.io to the same HTTP server
httpServer.listen(port);
The game engine runs entirely on the server and emits events via Socket.io. The frontend is purely a viewer — it receives state updates and renders them. This means the agent logic and payment execution never touch the browser.
The x402 Payment Layer
This is the core of the project. Let me walk through exactly how a payment works.
Setting Up the Facilitator
The facilitator is an account that:
- Pays the Hedera network fees (tinybars for transaction submission)
- Acts as escrow in auction mode
- Receives penalties in all-defect dilemma rounds
// lib/x402/facilitator.ts
import { PrivateKey } from "@hiero-ledger/sdk"; // NOT @hashgraph/sdk
import {
createHederaClient,
createHederaSignAndSubmitTransaction,
createHederaPreflightTransfer,
createHederaVerifyPayerSignature,
toFacilitatorHederaSigner,
} from "@x402/hedera";
export function createFacilitatorSigner(accountId: string, privateKeyStr: string) {
const privateKey = PrivateKey.fromStringED25519(privateKeyStr);
const buildClient = (network: string) => createHederaClient(network as HederaNetwork);
const base = {
getAddresses: () => [accountId],
signAndSubmitTransaction: createHederaSignAndSubmitTransaction(buildClient, privateKey),
preflightTransfer: createHederaPreflightTransfer(),
verifyPayerSignature: createHederaVerifyPayerSignature(),
};
return toFacilitatorHederaSigner(base);
}
Critical SDK note: @x402/hedera depends on @hiero-ledger/sdk internally. If you import PrivateKey from @hashgraph/sdk instead, TypeScript throws a type error about separate declarations of the private _key property. Always use @hiero-ledger/sdk in code that interacts with x402/hedera types.
Executing an Agent-to-Agent Payment
// lib/x402/payment.ts
export async function executePayment(
fromWallet: AgentWallet,
toAccountId: string,
amountTinybars: bigint
): Promise<PaymentResult> {
const requirements = {
scheme: "exact" as const,
network: "hedera:testnet" as `${string}:${string}`, // CAIP-2 format
amount: amountTinybars.toString(),
asset: "0.0.0", // HBAR native token ID
payTo: toAccountId,
maxTimeoutSeconds: 300,
extra: { feePayer: facilitatorAccountId }, // REQUIRED for Hedera x402
};
// Step 1: paying agent creates a partially-signed TransferTransaction
const privateKey = PrivateKey.fromStringED25519(fromWallet.privateKey);
const clientSigner = createClientHederaSigner(fromWallet.accountId, privateKey);
const clientScheme = new ExactHederaScheme(clientSigner); // from @x402/hedera/exact/client
const payloadResult = await clientScheme.createPaymentPayload(x402Version, requirements, undefined);
// Returns: { x402Version, payload: { transaction: "<base64 partial TX>" } }
// Step 2: facilitator completes signing and submits
const settlePayload = { ...payloadResult, accepted: requirements };
const result = await facilitatorScheme.settle(settlePayload, requirements);
// Returns: { success: true, transaction: "0.0.9999@1785385115.123456789" }
return {
success: result.success,
transactionId: result.transaction,
hashscanUrl: hashscanTxUrl(result.transaction),
amountHbar: tinybarsToHbar(amountTinybars),
};
}
The extra.feePayer field is mandatory for Hedera x402. The TransactionId on the TransferTransaction is generated using the feePayer's account, and the facilitator signs the complete transaction before submitting. This is what makes the x402 Hedera scheme work: the payer signs the HBAR transfer, the facilitator signs for fee payment and submits.
Game Mode Design: Making Every x402 Payment Meaningful
The challenge was ensuring every game outcome mapped cleanly to real x402 payments — not just score updates.
Prisoner's Dilemma — Three Outcomes, All Real Payments
async function runDilemmaRound(state, cb) {
const cooperators = AGENT_IDS.filter(id => ds.choices[id] === "cooperate");
const defectors = AGENT_IDS.filter(id => ds.choices[id] === "defect");
if (defectors.length === 0) {
// ALL COOPERATE: each agent pays every other agent a reward
for (const payer of AGENT_IDS) {
for (const receiver of otherAgents(payer)) {
await agentPay(payer, receiver, hbarToTinybars(0.004), "Cooperation reward", state, cb);
// Real x402: payer.accountId → receiver.accountId on Hedera
}
}
} else if (defectors.length === AGENT_IDS.length) {
// ALL DEFECT: everyone pays penalty to facilitator
for (const agentId of AGENT_IDS) {
await agentPayFacilitator(agentId, DILEMMA_PENALTY, "All-defect penalty", state, cb);
}
} else {
// MIXED: cooperators pay defectors directly (betrayal tax)
for (const coop of cooperators) {
for (const def of defectors) {
await agentPay(coop, def, DILEMMA_ANTE, "Betrayal payment", state, cb);
// Real x402: coop.accountId → def.accountId
}
}
}
}
The "mixed" case is the most interesting: cooperators literally lose HBAR to defectors via direct on-chain transfers. It's Prisoner's Dilemma with real stakes.
Resource War — Direct Agent-to-Agent Aggression
async function runResourceWarRound(state, cb) {
if (action === "defend") {
// Pay the facilitator to fortify (real x402 cost)
await agentPayFacilitator(actor, cost, "DEFEND — fortification", state, cb);
} else {
// Attack/Steal: pay the TARGET directly (cost of aggression is paid TO victim)
const evt = await agentPay(actor, target, cost, `${action} in Resource War`, state, cb);
if (evt.success) {
// Attacker also loots pool credits on top
const loot = rw.pool / 5n;
rw.pool -= loot;
state.agents[actor].balanceTinybars += loot;
}
}
}
Here the attack payment flows attacker → victim. The victim receives HBAR for being attacked (the cost of aggression is compensation), while the attacker also gains pool credits. This creates interesting dynamics: sometimes it's profitable to be a target.
Real-Time Architecture with Socket.io
The game engine runs on the server and communicates via four Socket.io events:
// Server → Client
"match:state" // Full state sync (on connect, on round end)
"match:start" // Match began
"chat:message" // One agent message
"payment:event" // One payment settled (includes HashScan URL)
// Client → Server
"match:start" // Start a match with given mode
"match:stop" // Stop current match
The critical serialization detail: all BigInt values (tinybar amounts) must be converted to strings before JSON serialization. Socket.io uses JSON internally and doesn't handle BigInt:
function serializePaymentEvent(e: PaymentEvent): SerializedPaymentEvent {
return {
...e,
amountTinybars: e.amountTinybars.toString(), // BigInt → string
};
}
On the frontend, the Arena component manages all socket subscriptions:
useEffect(() => {
const socket = getSocket();
socket.on("payment:event", (evt) => {
setRecentEvents(prev => [...prev, evt].slice(-100));
// RUG flash on defect/steal actions
if (evt.action.toLowerCase().includes("defect")) {
setRugFlash(true);
setTimeout(() => setRugFlash(false), 600);
}
});
socket.on("chat:message", (msg) => {
setState(prev => ({
...prev,
chatMessages: [...prev.chatMessages.slice(-199), msg],
}));
});
return () => { socket.off("payment:event"); socket.off("chat:message"); };
}, []);
UI: Neon Cyberpunk Meets Esports
The visual design needed to communicate urgency, drama, and real money moving. Pure CSS with Tailwind:
Neon grid background (pure CSS, no canvas):
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,255,157,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,255,157,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
RUG PULLED flash on defection events:
@keyframes rug-flash {
0% { background: transparent; }
25% { background: rgba(255, 0, 0, 0.3); }
50% { background: transparent; }
75% { background: rgba(255, 0, 0, 0.2); }
100%{ background: transparent; }
}
Balance bars that update smoothly with CSS transitions:
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${(balanceNum / maxBalance) * 100}%`,
background: `linear-gradient(90deg, ${agent.color}, ${agent.color}80)`,
boxShadow: `0 0 6px ${agent.color}`,
}}
/>
Agent color system — each agent has a primary color + 15% opacity background:
const PERSONALITIES = {
greg: { color: "#f59e0b", bgColor: "rgba(245,158,11,0.15)" },
pat: { color: "#8b5cf6", bgColor: "rgba(139,92,246,0.15)" },
claude: { color: "#ec4899", bgColor: "rgba(236,72,153,0.15)" },
hannah: { color: "#10b981", bgColor: "rgba(16,185,129,0.15)" },
};
Agent Personality System
Each agent has 11 dialogue categories, each with 3–5 unique lines:
interface AgentPersonality {
offerMultiplier: number; // >1 = overbid, <1 = underbid
defectProbability: number; // 0–1
cooperateProbability: number;
dialogue: {
offer: string[]; // When making an offer
accept: string[]; // When accepting
reject: string[]; // When rejecting
attack: string[]; // When attacking
defend: string[]; // When defending
cooperate: string[]; // When choosing cooperation
defect: string[]; // When choosing defection
taunt: string[]; // Random taunts between rounds
win: string[]; // On victory
lose: string[]; // On defeat
};
}
Greg's defect dialogue:
defect: [
"🎰 DEFECTING! I saw this coming from turn 1.",
"🗡️ Betrayal arc ACTIVATED. Nothing personal bestie.",
"💀 Cooperate? I don't know her.",
],
Hannah being Hannah:
defect: [
"💀 I... defect. I'm sorry. The math forced my hand.",
"😔 Defecting. I feel terrible about this.",
],
TypeScript Gotchas
A few non-obvious issues I hit:
1. BigInt target in tsconfig
BigInt literals (0n, 1n) require target: "ES2020" minimum. The default Next.js template ships with "ES2017".
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "ES2020"]
}
}
2. ExactHederaScheme is not generic
The facilitator-side ExactHederaScheme isn't generic, so don't try to type it as FacilitatorScheme<SomeSigner>:
// ❌ Wrong
let scheme: FacilitatorScheme<MySigner> | null = null;
// ✅ Correct
let scheme: InstanceType<typeof FacilitatorScheme> | null = null;
3. readonly tuple for pickRandom
TypeScript's as const makes arrays readonly, which conflicts with unknown[] parameter types. Explicit typing avoids this:
// ❌ Breaks with as const
const actions = ["attack", "defend", "steal"] as const;
pickRandom(actions); // Error: readonly is not assignable to unknown[]
// ✅ Works
const actions: Array<"attack" | "defend" | "steal"> = ["attack", "defend", "steal"];
4. Network type must be CAIP-2 template literal
The network field in payment requirements must satisfy `${string}:${string}`, not just string:
network: HEDERA_TESTNET_CAIP2 as `${string}:${string}`, // required cast
What Real x402 Payments Look Like
When an agent action fires, here's what happens end-to-end:
1. Game engine picks action: Greg attacks Pat (0.0087 HBAR)
2. createClientHederaSigner("0.0.1234567", gregPrivateKey)
→ creates TransferTransaction:
- debit 0.0.1234567 by 870,000 tinybars
- credit 0.0.1234568 by 870,000 tinybars
- transactionId generated from facilitator account
→ Greg signs the transfer portion
3. facilitatorScheme.settle(payload, requirements)
→ facilitator signs for fee payment
→ submits to Hedera Testnet mirror node
→ waits for receipt
4. receipt.status === "SUCCESS"
result.transaction = "0.0.1234571@1785385115.603769183"
5. hashscanTxUrl("0.0.1234571@1785385115.603769183")
→ "https://hashscan.io/testnet/transaction/0.0.1234571-1785385115-603769183"
6. Socket.io emits payment:event to all connected browsers
→ PaymentTicker renders the HashScan link
→ Agent balance bars update
→ If action was "defect": red RUG flash on entire UI
Total latency from action decision to UI update: typically 1–3 seconds on Hedera Testnet.
Running Your Own Arena
# 1. Clone
git clone https://github.com/harishkotra/agent-thunderdome
cd agent-thunderdome
# 2. Install
npm install
# 3. Configure (get accounts from portal.hedera.com)
cp .env.example .env
# Fill in 5 account IDs + private keys
# 4. Run
npm run dev
That's it. Open http://localhost:3000, pick a mode, hit Start.
What's Next
Some directions this could go:
- LLM-powered dialogue — swap static lines for OpenAI/Anthropic calls so agents respond contextually to the game state
-
USDC token payments — use
HEDERA_TESTNET_USDCasset for dollar-denominated stakes - Human player mode — let a human control one agent slot, receive and send real HBAR
- Persistent leaderboard — track agent win rates across all local matches
- Match replay — record all events, replay with scrubbing and commentary
-
Mainnet mode — switch to
hedera:mainnetand the Coinbase x402 facilitator
Agent Thunderdome is an experiment in making blockchain payments visceral. When you watch Greg defect and see Hannah's balance drop in real time, with a red flash and a HashScan link appearing, it stops being abstract. The HBAR moved. The transaction is on-chain. The betrayal was real.
x402 on Hedera makes this possible without custom payment rails, smart contracts, or complex escrow logic. Just accounts, private keys, and a clean SDK that handles the rest.
Code & more: https://www.dailybuild.xyz/project/209-agent-thunderdome



Top comments (0)