DEV Community

Cover image for Building Agent Thunderdome: Real AI Agent-to-Agent Micropayments on Hedera Testnet Using x402
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building Agent Thunderdome: Real AI Agent-to-Agent Micropayments on Hedera Testnet Using x402

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)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pays the Hedera network fees (tinybars for transaction submission)
  2. Acts as escrow in auction mode
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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"); };
}, []);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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}`,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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)" },
};
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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.",
],
Enter fullscreen mode Exit fullscreen mode

Hannah being Hannah:

defect: [
  "💀 I... defect. I'm sorry. The math forced my hand.",
  "😔 Defecting. I feel terrible about this.",
],
Enter fullscreen mode Exit fullscreen mode

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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_USDC asset 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:mainnet and 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

Screenshots

Agent Thunderdome 1

Agent Thunderdome 2

Agent Thunderdome 3

Top comments (0)