DEV Community

Cover image for Provably Fair Gaming: Building Cryptographic RNG Verification with VAP-GAM

Provably Fair Gaming: Building Cryptographic RNG Verification with VAP-GAM

TL;DR: Players suspect online games are rigged. Operators claim they're fair. Neither side can prove anything. VAP-GAM (Gaming Application Module) provides a cryptographic framework combining VRF, Commit-Reveal, and Merkle trees so players can verify fairness themselves—no trust required.


The Trust Gap: A $200 Billion Problem

The global online gaming market exceeds $200 billion annually. Yet a fundamental problem persists: players cannot verify that games are fair.

Consider online mahjong. When you draw a tile, how do you know:

  • The tile wasn't selected to disadvantage you specifically?
  • The algorithm isn't favoring paying players?
  • Your "bad luck streak" isn't artificially induced?

You don't. You can't. The RNG is a black box.

┌─────────────────────────────────────────────────────────┐
│                    THE TRUST GAP                        │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  PLAYER SUSPICIONS          OPERATOR CLAIMS            │
│  ─────────────────          ───────────────            │
│  • "Tiles are rigged"       • "RNG is certified"       │
│  • "Paying users win more"  • "We don't manipulate"    │
│  • "Losing streaks are      • "It's just probability"  │
│     manufactured"                                       │
│                                                         │
│                    ┌─────────────┐                      │
│                    │  NO PROOF   │                      │
│                    │  EITHER WAY │                      │
│                    └─────────────┘                      │
│                                                         │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Some platforms (like Mahjong Soul) publish MD5 hashes to prove tiles weren't changed mid-game. This is a start—but it only proves no mid-game tampering. It doesn't prove the initial seed was fair.

VAP-GAM solves this completely.


What is VAP-GAM?

VAP-GAM is a domain-specific profile within the Verifiable AI Provenance (VAP) Framework. While VCP targets algorithmic trading and PAP targets public administration, GAM addresses online gaming fairness.

                    VAP Framework
                         │
     ┌───────┬───────┬───┴───┬───────┬───────┐
     ▼       ▼       ▼       ▼       ▼       ▼
   [VCP]   [DVP]   [MAP]   [EIP]   [PAP]   [GAM]
  Finance  Auto   Medical Energy  Public  Gaming
   v1.0   Planned Planned Planned Planned  v0.1
Enter fullscreen mode Exit fullscreen mode

Core Technologies

VAP-GAM combines four cryptographic primitives:

Technology Purpose What It Proves
VRF (Verifiable Random Function) Generate random seeds Seed was generated correctly
Commit-Reveal Lock in results before game Results were pre-determined
Merkle Tree Chain all game events No events were modified
Zero-Knowledge Proofs Prove algorithm properties Algorithm is fair (without revealing it)

Part 1: Verifiable Random Functions (VRF)

The Problem with Traditional RNG

Standard RNG approaches have fatal flaws:

# PROBLEMATIC: Operator can regenerate until favorable
import random
random.seed(some_secret)
tiles = random.shuffle(all_tiles)  # Operator controls the outcome
Enter fullscreen mode Exit fullscreen mode

The operator knows the seed. They can:

  1. Generate thousands of seeds
  2. Simulate games with each seed
  3. Pick the seed that favors the house

Even "certified" RNGs don't solve this—certification only verifies the algorithm, not the seed selection.

How VRF Works

A Verifiable Random Function produces random output that anyone can verify but only the key holder can produce.

VRF_prove(secret_key, input) → (output, proof)
VRF_verify(public_key, input, output, proof) → true/false
Enter fullscreen mode Exit fullscreen mode

Key properties:

  1. Uniqueness: For a given input, only one valid output exists
  2. Pseudorandomness: Output is indistinguishable from random
  3. Verifiability: Anyone with the public key can verify

TypeScript Implementation

Using the @stablelib/ed25519 and @noble/hashes libraries:

import { sha512 } from '@noble/hashes/sha512';
import * as ed25519 from '@stablelib/ed25519';

interface VRFOutput {
  seed: Uint8Array;      // The random seed (32 bytes)
  proof: Uint8Array;     // VRF proof (64 bytes)
  publicKey: Uint8Array; // For verification
}

class VRFGenerator {
  private secretKey: Uint8Array;
  public publicKey: Uint8Array;

  constructor(secretKey?: Uint8Array) {
    if (secretKey) {
      this.secretKey = secretKey;
      this.publicKey = ed25519.extractPublicKey(secretKey);
    } else {
      const keypair = ed25519.generateKeyPair();
      this.secretKey = keypair.secretKey;
      this.publicKey = keypair.publicKey;
    }
  }

  /**
   * Generate a verifiable random seed for a game round.
   * 
   * @param gameId - Unique game identifier
   * @param roundNumber - Round/hand number
   * @param playerEntropy - Optional player-contributed randomness
   */
  generateSeed(
    gameId: string,
    roundNumber: number,
    playerEntropy?: string
  ): VRFOutput {
    // Construct deterministic input
    const input = this.constructInput(gameId, roundNumber, playerEntropy);

    // Sign the input (this is our VRF proof)
    const proof = ed25519.sign(this.secretKey, input);

    // Hash the proof to get the seed (deterministic extraction)
    const seed = sha512(proof).slice(0, 32);

    return {
      seed,
      proof,
      publicKey: this.publicKey
    };
  }

  private constructInput(
    gameId: string,
    roundNumber: number,
    playerEntropy?: string
  ): Uint8Array {
    const data = `VAP-GAM:v1:${gameId}:${roundNumber}:${playerEntropy || ''}`;
    return new TextEncoder().encode(data);
  }

  /**
   * Verify a VRF output (can be done by anyone).
   */
  static verify(
    publicKey: Uint8Array,
    gameId: string,
    roundNumber: number,
    seed: Uint8Array,
    proof: Uint8Array,
    playerEntropy?: string
  ): boolean {
    // Reconstruct input
    const data = `VAP-GAM:v1:${gameId}:${roundNumber}:${playerEntropy || ''}`;
    const input = new TextEncoder().encode(data);

    // Verify signature
    if (!ed25519.verify(publicKey, input, proof)) {
      return false;
    }

    // Verify seed derivation
    const expectedSeed = sha512(proof).slice(0, 32);
    return this.constantTimeEqual(seed, expectedSeed);
  }

  private static constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
    if (a.length !== b.length) return false;
    let result = 0;
    for (let i = 0; i < a.length; i++) {
      result |= a[i] ^ b[i];
    }
    return result === 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters

With VRF:

  1. The operator cannot regenerate seeds—the proof would fail
  2. Players can verify the seed was generated correctly
  3. The seed is deterministic given the inputs—no wiggle room

Part 2: Commit-Reveal Scheme

VRF proves the seed was generated correctly. But how do players know the seed was generated before they made decisions?

Enter Commit-Reveal.

The Protocol

┌─────────────────────────────────────────────────────────────┐
│                    COMMIT-REVEAL PHASES                      │
├──────────────────────────┬──────────────────────────────────┤
│                          │                                   │
│  PHASE 1: COMMIT         │  PHASE 2: REVEAL                 │
│  (Before game starts)    │  (After game ends)               │
│  ─────────────────────   │  ──────────────────              │
│                          │                                   │
│  1. Generate VRF seed    │  1. Publish seed                 │
│  2. Compute commitment:  │  2. Publish nonce                │
│     H(seed || nonce)     │  3. Publish VRF proof            │
│  3. Display commitment   │  4. Players verify:              │
│     to players           │     H(seed||nonce) == commitment │
│                          │                                   │
└──────────────────────────┴──────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

TypeScript Implementation

import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { randomBytes } from '@noble/hashes/utils';

interface Commitment {
  hash: string;          // SHA-256 commitment (displayed to players)
  timestamp: number;     // When commitment was made
  gameId: string;
}

interface Reveal {
  seed: Uint8Array;
  nonce: Uint8Array;
  vrfProof: Uint8Array;
  vrfPublicKey: Uint8Array;
}

class CommitRevealGame {
  private pendingCommitments: Map<string, {
    seed: Uint8Array;
    nonce: Uint8Array;
    vrfOutput: VRFOutput;
  }> = new Map();

  private vrfGenerator: VRFGenerator;

  constructor(vrfSecretKey?: Uint8Array) {
    this.vrfGenerator = new VRFGenerator(vrfSecretKey);
  }

  /**
   * PHASE 1: Generate commitment before game starts.
   * 
   * The commitment hash is displayed to all players.
   * They can record it before making any decisions.
   */
  commit(gameId: string, roundNumber: number): Commitment {
    // Generate VRF seed
    const vrfOutput = this.vrfGenerator.generateSeed(gameId, roundNumber);

    // Generate random nonce (prevents rainbow table attacks)
    const nonce = randomBytes(32);

    // Compute commitment: H(seed || nonce)
    const preimage = new Uint8Array(vrfOutput.seed.length + nonce.length);
    preimage.set(vrfOutput.seed);
    preimage.set(nonce, vrfOutput.seed.length);

    const commitmentHash = bytesToHex(sha256(preimage));

    // Store for later reveal
    this.pendingCommitments.set(gameId, {
      seed: vrfOutput.seed,
      nonce,
      vrfOutput
    });

    return {
      hash: commitmentHash,
      timestamp: Date.now(),
      gameId
    };
  }

  /**
   * PHASE 2: Reveal after game ends.
   * 
   * Players can now verify:
   * 1. The commitment matches H(seed || nonce)
   * 2. The VRF proof is valid
   * 3. The game outcome matches the seed
   */
  reveal(gameId: string): Reveal | null {
    const pending = this.pendingCommitments.get(gameId);
    if (!pending) return null;

    // Clean up
    this.pendingCommitments.delete(gameId);

    return {
      seed: pending.seed,
      nonce: pending.nonce,
      vrfProof: pending.vrfOutput.proof,
      vrfPublicKey: pending.vrfOutput.publicKey
    };
  }

  /**
   * Client-side verification (runs in player's browser).
   */
  static verifyGame(
    commitment: Commitment,
    reveal: Reveal,
    roundNumber: number
  ): { valid: boolean; reason?: string } {
    // Step 1: Verify commitment matches reveal
    const preimage = new Uint8Array(reveal.seed.length + reveal.nonce.length);
    preimage.set(reveal.seed);
    preimage.set(reveal.nonce, reveal.seed.length);

    const recomputedHash = bytesToHex(sha256(preimage));

    if (recomputedHash !== commitment.hash) {
      return { 
        valid: false, 
        reason: 'Commitment hash mismatch - seed was changed after commit!' 
      };
    }

    // Step 2: Verify VRF proof
    const vrfValid = VRFGenerator.verify(
      reveal.vrfPublicKey,
      commitment.gameId,
      roundNumber,
      reveal.seed,
      reveal.vrfProof
    );

    if (!vrfValid) {
      return { 
        valid: false, 
        reason: 'VRF proof invalid - seed was not generated correctly!' 
      };
    }

    return { valid: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

Part 3: Deterministic Game Logic

With a verified seed, we need deterministic game logic. Given the same seed, the game must produce identical results every time.

Example: Mahjong Tile Wall Generation

/**
 * Deterministic Mahjong tile wall generator.
 * 
 * Given the same seed, this will always produce
 * the same tile arrangement.
 */
class MahjongWallGenerator {
  // Standard Riichi Mahjong tile set (136 tiles)
  private static readonly TILE_SET = [
    // Man (Characters) 1-9, 4 copies each
    ...Array(4).fill(null).flatMap(() => 
      [1,2,3,4,5,6,7,8,9].map(n => `${n}m`)
    ),
    // Pin (Circles) 1-9, 4 copies each
    ...Array(4).fill(null).flatMap(() => 
      [1,2,3,4,5,6,7,8,9].map(n => `${n}p`)
    ),
    // Sou (Bamboo) 1-9, 4 copies each
    ...Array(4).fill(null).flatMap(() => 
      [1,2,3,4,5,6,7,8,9].map(n => `${n}s`)
    ),
    // Honor tiles: East, South, West, North (4 each)
    ...Array(4).fill(null).flatMap(() => ['E','S','W','N']),
    // Dragon tiles: White, Green, Red (4 each)
    ...Array(4).fill(null).flatMap(() => ['Wh','Gr','Rd'])
  ];

  /**
   * Generate a shuffled tile wall from a seed.
   * Uses Fisher-Yates shuffle with seeded PRNG.
   */
  static generateWall(seed: Uint8Array): string[] {
    const tiles = [...this.TILE_SET];
    const rng = new SeededRNG(seed);

    // Fisher-Yates shuffle (deterministic)
    for (let i = tiles.length - 1; i > 0; i--) {
      const j = rng.nextInt(i + 1);
      [tiles[i], tiles[j]] = [tiles[j], tiles[i]];
    }

    return tiles;
  }

  /**
   * Verify a claimed tile wall matches the seed.
   */
  static verifyWall(seed: Uint8Array, claimedWall: string[]): boolean {
    const expectedWall = this.generateWall(seed);

    if (expectedWall.length !== claimedWall.length) return false;

    for (let i = 0; i < expectedWall.length; i++) {
      if (expectedWall[i] !== claimedWall[i]) return false;
    }

    return true;
  }
}

/**
 * Seeded PRNG using ChaCha20.
 * Deterministic: same seed → same sequence.
 */
class SeededRNG {
  private state: Uint8Array;
  private counter: number = 0;

  constructor(seed: Uint8Array) {
    // Expand seed to ChaCha20 state
    this.state = sha256(seed);
  }

  /**
   * Generate next random integer in [0, max).
   */
  nextInt(max: number): number {
    // Generate next block
    const block = sha256(
      new Uint8Array([...this.state, ...this.intToBytes(this.counter++)])
    );

    // Convert first 4 bytes to integer
    const value = (block[0] << 24) | (block[1] << 16) | 
                  (block[2] << 8) | block[3];

    // Modulo (note: slight bias for simplicity; production should use rejection sampling)
    return Math.abs(value) % max;
  }

  private intToBytes(n: number): Uint8Array {
    return new Uint8Array([
      (n >> 24) & 0xff,
      (n >> 16) & 0xff,
      (n >> 8) & 0xff,
      n & 0xff
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Complete Verification Flow

// === PLAYER-SIDE VERIFICATION ===

async function verifyMahjongGame(
  commitment: Commitment,      // Recorded before game
  reveal: Reveal,              // Published after game
  roundNumber: number,
  actualTileSequence: string[] // What actually happened in game
): Promise<void> {

  console.log('🔍 Starting game verification...\n');

  // Step 1: Verify cryptographic commitment
  const cryptoCheck = CommitRevealGame.verifyGame(
    commitment, 
    reveal, 
    roundNumber
  );

  if (!cryptoCheck.valid) {
    console.log('❌ FRAUD DETECTED:', cryptoCheck.reason);
    return;
  }
  console.log('✅ Step 1: Commitment verified (seed was locked before game)');

  // Step 2: Verify VRF seed generation
  console.log('✅ Step 2: VRF proof verified (seed was generated correctly)');

  // Step 3: Regenerate tile wall from seed
  const expectedWall = MahjongWallGenerator.generateWall(reveal.seed);

  // Step 4: Compare with actual game
  const wallMatches = MahjongWallGenerator.verifyWall(
    reveal.seed, 
    actualTileSequence
  );

  if (!wallMatches) {
    console.log('❌ FRAUD DETECTED: Tile sequence does not match seed!');
    return;
  }
  console.log('✅ Step 3: Tile wall matches seed');

  console.log('\n🎉 VERIFICATION COMPLETE: Game was provably fair!');
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Merkle Tree Event Chain

Games have many events beyond initial RNG: draws, discards, calls, scoring. We need to prove none of these were tampered with.

Merkle Tree Structure

                    [Root Hash]
                    /          \
           [Hash 01]            [Hash 23]
           /      \              /      \
      [Hash 0]  [Hash 1]    [Hash 2]  [Hash 3]
         |         |           |         |
      Event 0   Event 1     Event 2   Event 3
      (Start)   (Draw)      (Discard) (Call)
Enter fullscreen mode Exit fullscreen mode

Python Implementation

import hashlib
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime
import json

@dataclass
class GameEvent:
    """A single event in a game session."""
    event_id: str
    event_type: str  # GAME_START, DRAW, DISCARD, CALL, RON, TSUMO, etc.
    timestamp_ns: int
    player_id: str
    payload: dict

    def to_bytes(self) -> bytes:
        """Canonical serialization for hashing."""
        data = {
            'event_id': self.event_id,
            'event_type': self.event_type,
            'timestamp_ns': self.timestamp_ns,
            'player_id': self.player_id,
            'payload': self.payload
        }
        # RFC 8785 JSON Canonicalization
        return json.dumps(data, sort_keys=True, separators=(',', ':')).encode()

@dataclass 
class MerkleNode:
    """Node in the Merkle tree."""
    hash: bytes
    left: Optional['MerkleNode'] = None
    right: Optional['MerkleNode'] = None
    event: Optional[GameEvent] = None  # Only for leaf nodes

class GameMerkleTree:
    """
    Merkle tree for game event integrity.

    Provides:
    - Tamper detection for any event
    - Compact proof of inclusion
    - Efficient verification
    """

    LEAF_PREFIX = b'\x00'   # Domain separation (RFC 6962)
    NODE_PREFIX = b'\x01'

    def __init__(self):
        self.events: List[GameEvent] = []
        self.root: Optional[MerkleNode] = None

    def add_event(self, event: GameEvent) -> str:
        """Add event and return its leaf hash."""
        self.events.append(event)
        leaf_hash = self._hash_leaf(event)
        self._rebuild_tree()
        return leaf_hash.hex()

    def get_root(self) -> Optional[str]:
        """Get current Merkle root."""
        if self.root is None:
            return None
        return self.root.hash.hex()

    def get_proof(self, event_index: int) -> List[dict]:
        """
        Generate inclusion proof for an event.

        Returns list of sibling hashes needed to recompute root.
        """
        if event_index >= len(self.events):
            raise ValueError("Event index out of range")

        proof = []
        nodes = self._get_leaf_nodes()

        idx = event_index
        while len(nodes) > 1:
            sibling_idx = idx ^ 1  # XOR to get sibling
            if sibling_idx < len(nodes):
                proof.append({
                    'hash': nodes[sibling_idx].hash.hex(),
                    'position': 'right' if idx % 2 == 0 else 'left'
                })

            # Move up the tree
            nodes = self._compute_parent_level(nodes)
            idx //= 2

        return proof

    @staticmethod
    def verify_proof(
        event: GameEvent,
        proof: List[dict],
        expected_root: str
    ) -> bool:
        """
        Verify an event is included in the tree.

        Can be run by anyone with the event and proof.
        """
        current_hash = GameMerkleTree._hash_leaf_static(event)

        for step in proof:
            sibling_hash = bytes.fromhex(step['hash'])

            if step['position'] == 'right':
                combined = GameMerkleTree.NODE_PREFIX + current_hash + sibling_hash
            else:
                combined = GameMerkleTree.NODE_PREFIX + sibling_hash + current_hash

            current_hash = hashlib.sha256(combined).digest()

        return current_hash.hex() == expected_root

    def _hash_leaf(self, event: GameEvent) -> bytes:
        """Hash a leaf node with domain separation."""
        return hashlib.sha256(self.LEAF_PREFIX + event.to_bytes()).digest()

    @staticmethod
    def _hash_leaf_static(event: GameEvent) -> bytes:
        """Static version for verification."""
        return hashlib.sha256(
            GameMerkleTree.LEAF_PREFIX + event.to_bytes()
        ).digest()

    def _rebuild_tree(self):
        """Rebuild tree from events."""
        if not self.events:
            self.root = None
            return

        nodes = self._get_leaf_nodes()

        while len(nodes) > 1:
            nodes = self._compute_parent_level(nodes)

        self.root = nodes[0]

    def _get_leaf_nodes(self) -> List[MerkleNode]:
        """Create leaf nodes from events."""
        return [
            MerkleNode(
                hash=self._hash_leaf(event),
                event=event
            )
            for event in self.events
        ]

    def _compute_parent_level(self, nodes: List[MerkleNode]) -> List[MerkleNode]:
        """Compute parent level of tree."""
        parents = []

        for i in range(0, len(nodes), 2):
            left = nodes[i]
            right = nodes[i + 1] if i + 1 < len(nodes) else left

            combined = self.NODE_PREFIX + left.hash + right.hash
            parent_hash = hashlib.sha256(combined).digest()

            parents.append(MerkleNode(
                hash=parent_hash,
                left=left,
                right=right
            ))

        return parents
Enter fullscreen mode Exit fullscreen mode

Usage Example

# === GAME SESSION ===

tree = GameMerkleTree()

# Game start event
start_event = GameEvent(
    event_id="evt_001",
    event_type="GAME_START",
    timestamp_ns=1704067200000000000,
    player_id="system",
    payload={
        "commitment": "a1b2c3d4...",
        "players": ["player_1", "player_2", "player_3", "player_4"],
        "rules": "riichi_4p"
    }
)
tree.add_event(start_event)

# Draw event
draw_event = GameEvent(
    event_id="evt_002",
    event_type="DRAW",
    timestamp_ns=1704067200100000000,
    player_id="player_1",
    payload={"tile": "5m", "position": 0}
)
tree.add_event(draw_event)

# Discard event
discard_event = GameEvent(
    event_id="evt_003",
    event_type="DISCARD",
    timestamp_ns=1704067200200000000,
    player_id="player_1", 
    payload={"tile": "1p"}
)
tree.add_event(discard_event)

# Get root for anchoring
merkle_root = tree.get_root()
print(f"Merkle Root: {merkle_root}")

# Generate proof for draw event
proof = tree.get_proof(1)  # Index 1 = draw event
print(f"Inclusion Proof: {proof}")

# === VERIFICATION (by player or auditor) ===

is_valid = GameMerkleTree.verify_proof(
    event=draw_event,
    proof=proof,
    expected_root=merkle_root
)
print(f"Verification: {'✅ Valid' if is_valid else '❌ Invalid'}")
Enter fullscreen mode Exit fullscreen mode

Part 5: GAM Conformance Levels

VAP-GAM defines three progressive levels of compliance:

GAM-1: Basic Integrity

What it proves: No mid-session tampering

Requirement Implementation
Event hashing SHA-256 per event
Tamper detection Merkle tree
Signature Ed25519 on root
// GAM-1 Minimum Implementation
interface GAM1Compliance {
  eventHashing: 'SHA-256';
  merkleTree: boolean;
  signature: 'Ed25519';
  anchorFrequency: '24h' | 'session_end';
}
Enter fullscreen mode Exit fullscreen mode

GAM-2: Verifiable Randomness

What it proves: Random was pre-determined AND legitimate

Requirement Implementation
All GAM-1
VRF Ed25519-based VRF
Commit-Reveal SHA-256 commitment
Player entropy Optional contribution
// GAM-2 adds VRF and Commit-Reveal
interface GAM2Compliance extends GAM1Compliance {
  vrf: {
    algorithm: 'ECVRF-ED25519-SHA512-Elligator2';
    publicKeyPublished: boolean;
  };
  commitReveal: {
    commitmentAlgorithm: 'SHA-256';
    revealTiming: 'game_end';
  };
}
Enter fullscreen mode Exit fullscreen mode

GAM-3: Full Provenance

What it proves: RNG algorithm itself is fair

Requirement Implementation
All GAM-2
External entropy DRAND, blockchain, etc.
Zero-Knowledge Proofs Algorithm fairness
Third-party audit Published results
// GAM-3 adds ZKP and external entropy
interface GAM3Compliance extends GAM2Compliance {
  externalEntropy: {
    source: 'drand' | 'blockchain' | 'beacon';
    inclusionProof: boolean;
  };
  zkProofs: {
    algorithmFairness: boolean;
    rateVerification: boolean;  // For gacha: prove actual drop rates
  };
}
Enter fullscreen mode Exit fullscreen mode

Part 6: Zero-Knowledge Proofs for Algorithm Fairness

The final frontier: proving an algorithm is fair without revealing it.

The Gacha Problem

Gacha games claim "3% SSR drop rate." But:

  • Is the rate actually 3%?
  • Is it 3% for everyone, or lower for big spenders?
  • Is there a pity system, and does it work as advertised?

With ZKP, operators can prove these claims mathematically.

Conceptual ZK Circuit

┌─────────────────────────────────────────────────────────────┐
│                    ZK FAIRNESS PROOF                         │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  PUBLIC INPUTS:                                              │
│  - Claimed drop rate: 3%                                     │
│  - Number of rolls: 1,000,000                               │
│  - Merkle root of all roll results                          │
│                                                              │
│  PRIVATE INPUTS (not revealed):                             │
│  - Actual algorithm implementation                          │
│  - All individual roll seeds                                │
│  - User-specific parameters (if any)                        │
│                                                              │
│  PROOF STATEMENT:                                            │
│  "I know an algorithm and seeds such that:                  │
│   1. Each seed generates a roll via the algorithm            │
│   2. The algorithm's expected rate is exactly 3%             │
│   3. All rolls are included in the Merkle tree              │
│   4. No user-specific rate modification exists"             │
│                                                              │
│  OUTPUT: Valid / Invalid                                     │
│                                                              │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Simplified Implementation Sketch

Using a ZK-SNARK library (conceptual):

# NOTE: This is pseudocode for illustration.
# Real implementation requires a ZK framework like Circom/snarkjs.

class GachaFairnessProof:
    """
    ZK proof that gacha rates are as claimed.
    """

    def __init__(self, claimed_rate: float, rolls: List[Roll]):
        self.claimed_rate = claimed_rate
        self.rolls = rolls

    def generate_proof(self) -> dict:
        """
        Generate ZK proof of fairness.

        Proves:
        1. Algorithm produces claimed rate in expectation
        2. No player-specific rate manipulation
        3. All rolls follow the same algorithm
        """

        # Build Merkle tree of all rolls
        roll_tree = MerkleTree([r.to_bytes() for r in self.rolls])

        # Count SSR outcomes
        ssr_count = sum(1 for r in self.rolls if r.rarity == 'SSR')
        total = len(self.rolls)
        actual_rate = ssr_count / total

        # In real ZK: prove rate is within statistical bounds
        # without revealing individual rolls

        return {
            'merkle_root': roll_tree.root,
            'total_rolls': total,
            'claimed_rate': self.claimed_rate,
            'proof': self._generate_zksnark_proof()
        }

    @staticmethod
    def verify(proof: dict) -> bool:
        """
        Verify the fairness proof.

        Anyone can verify without seeing individual rolls.
        """
        # Verify ZK-SNARK
        return verify_snark(proof['proof'], [
            proof['merkle_root'],
            proof['total_rolls'],
            proof['claimed_rate']
        ])
Enter fullscreen mode Exit fullscreen mode

Real-World Application: Online Mahjong

Let's put it all together for a complete mahjong implementation:

// === COMPLETE VAP-GAM MAHJONG IMPLEMENTATION ===

class ProvablyFairMahjong {
  private vrfGenerator: VRFGenerator;
  private commitReveal: CommitRevealGame;
  private eventTree: GameMerkleTree;

  constructor(operatorSecretKey: Uint8Array) {
    this.vrfGenerator = new VRFGenerator(operatorSecretKey);
    this.commitReveal = new CommitRevealGame(operatorSecretKey);
    this.eventTree = new GameMerkleTree();
  }

  /**
   * Start a new game.
   * Returns commitment that players should record.
   */
  async startGame(gameId: string, players: string[]): Promise<{
    commitment: Commitment;
    publicKey: string;
  }> {
    // Generate and commit to tile wall seed
    const commitment = this.commitReveal.commit(gameId, 1);

    // Record game start event
    this.eventTree.addEvent({
      eventId: `${gameId}_start`,
      eventType: 'GAME_START',
      timestampNs: Date.now() * 1_000_000,
      playerId: 'system',
      payload: {
        commitment: commitment.hash,
        players,
        rules: 'riichi_4p'
      }
    });

    return {
      commitment,
      publicKey: bytesToHex(this.vrfGenerator.publicKey)
    };
  }

  /**
   * Record a game event (draw, discard, etc.)
   */
  recordEvent(event: GameEvent): string {
    return this.eventTree.addEvent(event);
  }

  /**
   * End game and reveal all cryptographic material.
   */
  async endGame(gameId: string): Promise<{
    reveal: Reveal;
    merkleRoot: string;
    tileWall: string[];
  }> {
    // Reveal VRF seed and proof
    const reveal = this.commitReveal.reveal(gameId);

    if (!reveal) {
      throw new Error('No pending game with this ID');
    }

    // Generate the tile wall (for transparency)
    const tileWall = MahjongWallGenerator.generateWall(reveal.seed);

    // Get final Merkle root
    const merkleRoot = this.eventTree.getRoot();

    return {
      reveal,
      merkleRoot,
      tileWall
    };
  }
}

// === PLAYER VERIFICATION CLIENT ===

class PlayerVerifier {
  /**
   * Complete verification of a mahjong game.
   */
  static async verifyGame(
    commitment: Commitment,
    reveal: Reveal,
    merkleRoot: string,
    claimedTileWall: string[],
    events: GameEvent[]
  ): Promise<VerificationReport> {
    const report: VerificationReport = {
      commitmentValid: false,
      vrfValid: false,
      tileWallValid: false,
      eventChainValid: false,
      overallResult: 'FAILED'
    };

    // 1. Verify commitment
    const commitCheck = CommitRevealGame.verifyGame(commitment, reveal, 1);
    report.commitmentValid = commitCheck.valid;
    if (!commitCheck.valid) {
      report.failureReason = commitCheck.reason;
      return report;
    }

    // 2. VRF is implicitly verified in commitment check
    report.vrfValid = true;

    // 3. Verify tile wall matches seed
    report.tileWallValid = MahjongWallGenerator.verifyWall(
      reveal.seed,
      claimedTileWall
    );
    if (!report.tileWallValid) {
      report.failureReason = 'Tile wall does not match VRF seed';
      return report;
    }

    // 4. Verify event Merkle tree
    const reconstructedTree = new GameMerkleTree();
    for (const event of events) {
      reconstructedTree.addEvent(event);
    }
    report.eventChainValid = reconstructedTree.getRoot() === merkleRoot;
    if (!report.eventChainValid) {
      report.failureReason = 'Event chain has been tampered with';
      return report;
    }

    report.overallResult = 'PASSED';
    return report;
  }
}

interface VerificationReport {
  commitmentValid: boolean;
  vrfValid: boolean;
  tileWallValid: boolean;
  eventChainValid: boolean;
  overallResult: 'PASSED' | 'FAILED';
  failureReason?: string;
}
Enter fullscreen mode Exit fullscreen mode

Target Domains Beyond Mahjong

VAP-GAM applies to any game with probabilistic elements:

Domain Key RNG Elements GAM Benefits
Online Poker Deck shuffle, community cards Prove fair dealing
Gacha Games Drop rates, pity systems Prove actual rates
Battle Royale Loot spawns, circle placement Prove no favoritism
Card Games (TCG) Pack contents, matchmaking Prove pack fairness
Esports Map selection, spawn points Prove competitive integrity
Casino Games Slots, roulette, blackjack Regulatory compliance

Roadmap

Version Target Features
v0.1 2025 Q1 Draft specification (current)
v0.5 2025 Q2 Reference implementation + test suite
v1.0 2025 Q3 Official release + SDK
v1.1 2025 Q4 Gacha support + Unity SDK

Getting Started

  1. Read the specification: veritaschain.org/vap/gam
  2. Review the code examples in this article
  3. Try the reference implementation (coming Q2 2025)
  4. Contact us: technical@veritaschain.org

Dependencies

# TypeScript
npm install @stablelib/ed25519 @noble/hashes

# Python
pip install pynacl
Enter fullscreen mode Exit fullscreen mode

Conclusion

The era of "trust us, our RNG is fair" is ending. Players deserve—and will increasingly demand—cryptographic proof.

VAP-GAM provides:

  • VRF: Proves seed generation was correct
  • Commit-Reveal: Proves results were pre-determined
  • Merkle Trees: Proves no event tampering
  • ZKP (GAM-3): Proves algorithm fairness without revealing it

The question isn't whether provably fair gaming will become standard—it's who will implement it first.

From "Please trust us" to "Please verify."


"Verify, Don't Trust"

— VeritasChain Standards Organization


Resources:

License: CC BY 4.0

Tags: #gamedev #cryptography #typescript #python #provablyfair #rng #blockchain

Top comments (0)