Back to blog
Nov 20, 2024
8 min read
Muhammad Waqar Ilyas

Building Bitcoin Ordinals Infrastructure for Web3 Gaming: A Deep Dive

Technical insights from architecting and deploying a complete Bitcoin Ordinals minting infrastructure for a Web3 gaming platform, handling thousands of NFT rewards tied to gameplay achievements.

At Zogi Labs, I led the development of a pioneering Web3 gaming platform that integrated WebGL gameplay with Bitcoin Ordinals—essentially NFTs on Bitcoin. This wasn’t your typical smart contract deployment. Bitcoin doesn’t have native smart contracts like Ethereum, which meant we had to build everything from scratch.

Here’s how we did it, the challenges we faced, and the lessons learned.

What Are Bitcoin Ordinals?

Bitcoin Ordinals inscribe arbitrary data (images, text, code) directly onto individual satoshis (the smallest unit of Bitcoin). Unlike Ethereum NFTs where metadata lives off-chain, Ordinals data is permanently inscribed on the Bitcoin blockchain itself.

Why Ordinals for Gaming?

  1. True Ownership: Data lives on the most secure blockchain
  2. Permanence: Can’t be taken down or modified
  3. Uniqueness: Each satoshi is individually numbered
  4. Bitcoin’s Security: Leveraging 15+ years of battle-tested blockchain

The Architecture

High-Level System Design

┌─────────────┐      ┌──────────────┐      ┌─────────────┐
│   WebGL     │──────│   Game API   │──────│  Ordinals   │
│   Game      │      │   Server     │      │  Service    │
└─────────────┘      └──────────────┘      └─────────────┘
       │                     │                      │
       │                     │                      │
       v                     v                      v
  Player Actions      Achievement         Bitcoin Network
                      Verification        (Inscription)

Core Components

  1. Game Server: Validates achievements and triggers rewards
  2. Ordinals Service: Handles inscription creation and broadcasting
  3. Wallet Service: Manages Bitcoin addresses and UTXOs
  4. Admin Dashboard: Real-time monitoring and manual controls

Challenge #1: Understanding UTXOs

Unlike Ethereum’s account model, Bitcoin uses UTXOs (Unspent Transaction Outputs). This fundamental difference shaped our entire architecture.

UTXO Mental Model

interface UTXO {
  txid: string; // Transaction ID
  vout: number; // Output index
  value: number; // Satoshis
  scriptPubKey: string; // Spending conditions
}

// You don't have a "balance" - you have UTXOs you can spend
const getBalance = (utxos: UTXO[]): number => {
  return utxos.reduce((sum, utxo) => sum + utxo.value, 0);
};

Our UTXO Management System

class UTXOManager {
  private db: Database;
  private readonly MIN_UTXO_VALUE = 10000; // 10k sats minimum

  async selectUTXOs(targetAmount: number): Promise<UTXO[]> {
    // Get all available UTXOs sorted by value (coin selection)
    const utxos = await this.db.query(`
      SELECT * FROM utxos 
      WHERE spent = false AND value >= ${this.MIN_UTXO_VALUE}
      ORDER BY value DESC
    `);

    const selected: UTXO[] = [];
    let total = 0;

    // Greedy selection algorithm
    for (const utxo of utxos) {
      selected.push(utxo);
      total += utxo.value;

      if (total >= targetAmount) {
        break;
      }
    }

    if (total < targetAmount) {
      throw new Error("Insufficient funds");
    }

    return selected;
  }

  async markAsSpent(utxos: UTXO[]): Promise<void> {
    // Prevent double-spending in concurrent environments
    await this.db.transaction(async (tx) => {
      for (const utxo of utxos) {
        await tx.query(
          `
          UPDATE utxos 
          SET spent = true, spent_at = NOW() 
          WHERE txid = ? AND vout = ? AND spent = false
        `,
          [utxo.txid, utxo.vout]
        );
      }
    });
  }
}

Challenge #2: Creating Inscriptions

Inscriptions require carefully crafted Bitcoin transactions with specific script structures.

Inscription Transaction Structure

import * as bitcoin from "bitcoinjs-lib";
import { Taptree } from "bitcoinjs-lib/src/types";

class InscriptionCreator {
  async createInscription(
    data: Buffer,
    contentType: string,
    destination: string
  ): Promise<string> {
    // Step 1: Create the inscription script
    const inscriptionScript = this.createInscriptionScript(data, contentType);

    // Step 2: Create Taproot script tree
    const scriptTree: Taptree = {
      output: inscriptionScript,
    };

    // Step 3: Generate Taproot address
    const { address, output } = bitcoin.payments.p2tr({
      internalPubkey: this.internalKey,
      scriptTree,
      network: bitcoin.networks.bitcoin,
    });

    // Step 4: Fund the inscription address
    const commitTx = await this.createCommitTransaction(address!);
    await this.broadcastTransaction(commitTx);

    // Step 5: Wait for confirmation
    await this.waitForConfirmation(commitTx);

    // Step 6: Create reveal transaction
    const revealTx = await this.createRevealTransaction(
      commitTx,
      inscriptionScript,
      destination
    );

    return await this.broadcastTransaction(revealTx);
  }

  private createInscriptionScript(data: Buffer, contentType: string): Buffer {
    return bitcoin.script.compile([
      this.internalKey,
      bitcoin.opcodes.OP_CHECKSIG,
      bitcoin.opcodes.OP_FALSE,
      bitcoin.opcodes.OP_IF,
      Buffer.from("ord"), // Ordinals protocol identifier
      bitcoin.opcodes.OP_1,
      Buffer.from(contentType),
      bitcoin.opcodes.OP_0,
      data, // The actual inscription data
      bitcoin.opcodes.OP_ENDIF,
    ]);
  }
}

Challenge #3: Fee Estimation and Management

Bitcoin fees can spike dramatically. We needed intelligent fee management.

Dynamic Fee Estimation

class FeeEstimator {
  private mempool: MempoolService;

  async estimateFee(priority: "low" | "medium" | "high"): Promise<number> {
    // Get current mempool state
    const fees = await this.mempool.getRecommendedFees();

    // Select based on priority
    const feeRate = {
      low: fees.hourFee, // ~1 hour
      medium: fees.halfHourFee, // ~30 min
      high: fees.fastestFee, // ~10 min
    }[priority];

    return feeRate;
  }

  async estimateInscriptionCost(
    dataSize: number,
    priority: "low" | "medium" | "high"
  ): Promise<{ commitFee: number; revealFee: number; total: number }> {
    const feeRate = await this.estimateFee(priority);

    // Commit tx is standard size
    const commitFee = feeRate * 250; // ~250 vbytes

    // Reveal tx size depends on inscription size
    const revealFee = feeRate * (150 + dataSize); // base + data

    return {
      commitFee,
      revealFee,
      total: commitFee + revealFee,
    };
  }
}

Fee Spike Protection

class FeeManager {
  private readonly MAX_FEE_RATE = 500; // sat/vB
  private readonly WARNING_THRESHOLD = 200;

  async checkFeeRateBeforeInscription(): Promise<void> {
    const currentFee = await this.estimator.estimateFee("medium");

    if (currentFee > this.MAX_FEE_RATE) {
      // Queue inscription for later
      await this.queue.add({
        status: "queued",
        reason: "high_fees",
        expectedFee: currentFee,
      });

      throw new Error("Fee rate too high, inscription queued");
    }

    if (currentFee > this.WARNING_THRESHOLD) {
      // Send alert to admin dashboard
      await this.alerts.send({
        type: "warning",
        message: `High fee rate: ${currentFee} sat/vB`,
      });
    }
  }
}

Challenge #4: Batch Processing

Individual inscriptions are expensive. We batched where possible.

Batch Inscription System

class BatchInscriptionProcessor {
  private queue: InscriptionRequest[] = [];
  private readonly BATCH_SIZE = 10;
  private readonly BATCH_INTERVAL = 5 * 60 * 1000; // 5 minutes

  async addToBatch(request: InscriptionRequest): Promise<string> {
    const batchId = uuidv4();

    this.queue.push({
      ...request,
      batchId,
      status: "pending",
    });

    // Return batch ID immediately
    await this.db.insertBatchRequest(request);

    // Process batch if full
    if (this.queue.length >= this.BATCH_SIZE) {
      await this.processBatch();
    }

    return batchId;
  }

  async processBatch(): Promise<void> {
    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0, this.BATCH_SIZE);

    try {
      // Create single parent inscription
      const parentInscription = await this.createParentInscription(batch);

      // Create child inscriptions referencing parent
      await Promise.all(
        batch.map((req) => this.createChildInscription(req, parentInscription))
      );

      // Update all as complete
      await this.db.updateBatchStatus(batch, "completed");
    } catch (error) {
      // Retry failed inscriptions individually
      await this.retryFailedInscriptions(batch);
    }
  }
}

Challenge #5: Real-Time Status Updates

Players want to see their NFT minting in real-time.

WebSocket Status Updates

class InscriptionStatusService {
  private wss: WebSocketServer;
  private subscribers = new Map<string, WebSocket[]>();

  async subscribeToInscription(
    inscriptionId: string,
    ws: WebSocket
  ): Promise<void> {
    const subs = this.subscribers.get(inscriptionId) || [];
    subs.push(ws);
    this.subscribers.set(inscriptionId, subs);

    // Send current status immediately
    const status = await this.getInscriptionStatus(inscriptionId);
    ws.send(JSON.stringify(status));
  }

  async broadcastUpdate(
    inscriptionId: string,
    status: InscriptionStatus
  ): Promise<void> {
    const subs = this.subscribers.get(inscriptionId) || [];

    const message = JSON.stringify({
      inscriptionId,
      status: status.status,
      txid: status.txid,
      confirmations: status.confirmations,
      estimatedCompletion: this.estimateCompletion(status),
    });

    subs.forEach((ws) => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(message);
      }
    });
  }

  private estimateCompletion(status: InscriptionStatus): Date {
    // Average block time is 10 minutes
    const blocksRemaining = 6 - status.confirmations;
    const minutesRemaining = blocksRemaining * 10;

    return new Date(Date.now() + minutesRemaining * 60 * 1000);
  }
}

The Admin Dashboard

Built with React and real-time updates:

// Dashboard component showing live inscription status
const InscriptionDashboard: React.FC = () => {
  const [inscriptions, setInscriptions] = useState<Inscription[]>([]);
  const [stats, setStats] = useState<Stats>();

  useEffect(() => {
    const ws = new WebSocket("wss://api.example.com/admin");

    ws.onmessage = (event) => {
      const update = JSON.parse(event.data);

      if (update.type === "inscription_update") {
        setInscriptions((prev) =>
          prev.map((i) => (i.id === update.id ? { ...i, ...update.data } : i))
        );
      }

      if (update.type === "stats_update") {
        setStats(update.data);
      }
    };

    return () => ws.close();
  }, []);

  return (
    <div className="dashboard">
      <StatsPanel stats={stats} />
      <InscriptionTable inscriptions={inscriptions} />
      <FeeMonitor />
      <AlertPanel />
    </div>
  );
};

Key Metrics We Tracked

interface PlatformMetrics {
  // Inscription metrics
  totalInscriptions: number;
  successRate: number;
  averageInscriptionTime: number;

  // Cost metrics
  totalFeesSpent: number;
  averageFeePerInscription: number;

  // Performance metrics
  queueLength: number;
  averageWaitTime: number;

  // Error tracking
  failedInscriptions: number;
  retryRate: number;
}

Production Results

After 9 months in production:

  • 10,000+ inscriptions created
  • 99.7% success rate
  • Average cost: 0.0003 BTC per inscription
  • Zero security incidents
  • <30 minute average inscription time

Lessons Learned

  1. UTXO Management is Critical: Proper UTXO selection can save 20-30% on fees
  2. Fee Estimation is Hard: Always have fallback strategies for fee spikes
  3. Batch When Possible: Reduced our costs by 40%
  4. Real-Time Updates Matter: Users need visibility into long-running processes
  5. Monitor Everything: Bitcoin transactions are irreversible—monitoring prevented costly mistakes

Key Takeaways

Building on Bitcoin is fundamentally different from Ethereum. The lack of smart contracts means building more infrastructure yourself, but the trade-off is Bitcoin’s unmatched security and permanence.

If you’re building with Bitcoin Ordinals:

  • Study UTXO management deeply
  • Build robust fee estimation
  • Implement comprehensive monitoring
  • Plan for Bitcoin’s 10-minute block times
  • Always test on testnet extensively

Building something with Bitcoin or Ordinals? I’d love to hear about your challenges. Reach out anytime.