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?
- True Ownership: Data lives on the most secure blockchain
- Permanence: Can’t be taken down or modified
- Uniqueness: Each satoshi is individually numbered
- 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
- Game Server: Validates achievements and triggers rewards
- Ordinals Service: Handles inscription creation and broadcasting
- Wallet Service: Manages Bitcoin addresses and UTXOs
- 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
- UTXO Management is Critical: Proper UTXO selection can save 20-30% on fees
- Fee Estimation is Hard: Always have fallback strategies for fee spikes
- Batch When Possible: Reduced our costs by 40%
- Real-Time Updates Matter: Users need visibility into long-running processes
- 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.