Skip to main content

What is Provably Fair?

Provably fair is a cryptographic verification system that allows players to verify that game outcomes are not manipulated. Using a combination of server seeds, client seeds, and nonces, the system generates verifiable random numbers that determine case opening results.
Every case opening in Cajas is provably fair - you can verify that the server couldn’t have manipulated the outcome.

Core Concepts

Server Seed

Random value generated by the server, kept secret until after the game

Client Seed

Random value chosen by the player (or auto-generated)

Nonce

Sequential counter incremented with each game

How It Works

1

Seed Initialization

When a user first plays, the system generates:
  • Server Seed: 64 random hex characters (256 bits)
  • Client Seed: 32 random hex characters (128 bits)
  • Nonce: Starts at 0
2

Hashed Server Seed

The server seed is hashed with SHA-256 and shown to the player before any game. This proves the server committed to a value before the outcome.
3

Calculate Outcome

When opening a case:
  1. Increment nonce by 1
  2. Combine: HMAC-SHA256(serverSeed, clientSeed + "-" + nonce)
  3. Convert to decimal between 0 and 1
  4. Map to item based on probabilities
4

Record & Verify

The game result is stored with all seeds and nonce. Players can later verify the calculation matched the outcome.

Implementation

Seed Generation

The system uses Node.js crypto module for cryptographically secure randomness:
lib/provably-fair.ts
import crypto from 'crypto'

/**
 * Generates a random server seed (64 hex characters)
 */
export function generateServerSeed(): string {
  return crypto.randomBytes(32).toString('hex')
}

/**
 * Generates a random client seed (32 hex characters)
 */
export function generateClientSeed(): string {
  return crypto.randomBytes(16).toString('hex')
}

/**
 * Hashes the server seed using SHA256
 * This is shown to users BEFORE the game
 */
export function hashSeed(seed: string): string {
  return crypto.createHash('sha256').update(seed).digest('hex')
}
Server seeds use 32 bytes (256 bits) to match SHA-256’s security level. Client seeds use 16 bytes (128 bits) as they’re chosen by players.

Roll Calculation

The core algorithm that determines outcomes:
lib/provably-fair.ts
/**
 * Calculates the roll result (0 to 1)
 * Using HMAC-SHA256(server_seed, client_seed + "-" + nonce)
 */
export function calculateRollResult(
  serverSeed: string, 
  clientSeed: string, 
  nonce: number
): number {
  const message = `${clientSeed}-${nonce}`
  const hmac = crypto.createHmac('sha256', serverSeed)
  hmac.update(message)
  const hex = hmac.digest('hex')
  
  // Take the first 8 characters (4 bytes) -> 32-bit integer
  const subHex = hex.substring(0, 8)
  const decimal = parseInt(subHex, 16)
  
  // Max value of 8 hex chars is 0xFFFFFFFF = 4294967295
  const MAX_VAL = 0xFFFFFFFF
  
  // Return a float between 0 and 1
  return decimal / (MAX_VAL + 1)
}
HMAC (Hash-based Message Authentication Code) is used because:
  • Key separation: Server seed acts as a secret key
  • Prevents manipulation: Client can’t reverse-engineer server seed
  • Industry standard: Same approach used by Stake.com, CSGORoll, etc.

Winner Selection

Convert the 0-1 roll value to an actual item:
lib/provably-fair.ts
/**
 * Select winning item based on probabilities
 */
export function getWinningItem<T extends { probability: number }>(
  items: T[], 
  roll: number
): T {
  const totalWeight = items.reduce(
    (sum, item) => sum + (item.probability || 0), 
    0
  )
  
  let currentThreshold = 0
  const target = roll * totalWeight
  
  for (const item of items) {
    currentThreshold += (item.probability || 0)
    if (target < currentThreshold) {
      return item
    }
  }
  
  // Fallback to last item
  return items[items.length - 1]
}

Database Schema

User Seeds Table

Each user has one active seed pair:
create table public.user_seeds (
  user_id uuid references auth.users not null primary key,
  server_seed text not null,
  client_seed text not null,
  nonce bigint not null default 0,
  created_at timestamp with time zone default now() not null,
  updated_at timestamp with time zone default now() not null
)

Game Rolls Audit Log

Every game is recorded for verification:
create table public.game_rolls (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users not null,
  case_id uuid references public.cases not null,
  server_seed text not null,
  client_seed text not null,
  nonce bigint not null,
  roll_result bigint not null,
  item_won_id uuid references public.items not null,
  created_at timestamp with time zone default now() not null
)
The server_seed is stored in plaintext in game_rolls. This is intentional - it allows verification after the fact. The security comes from showing the hash before the game.

API Endpoints

Get Current Seeds

Retrieve the user’s current seeds (with server seed hashed):
GET /api/provably-fair/seed

Response:
{
  "server_seed_hash": "a3f5b1c2d4e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
  "client_seed": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "nonce": 42
}

Rotate Seeds

Generate new seeds (usually after revealing old server seed):
POST /api/provably-fair/seed
{
  "client_seed": "custom-seed-from-user" // optional
}

Response:
{
  "server_seed_hash": "new-hash...",
  "client_seed": "new-client-seed",
  "nonce": 0  // reset to 0
}

Case Opening with Fairness Data

Opening a case returns fairness proof:
app/api/cases/open/route.ts
export async function POST(request: Request) {
  const { caseId } = await request.json()
  
  // Fetch user's seeds
  let { data: seeds } = await supabase
    .from('user_seeds')
    .select('*')
    .eq('user_id', user.id)
    .single()
  
  // Calculate winner deterministically
  const nonce = seeds.nonce + 1
  const rollValue = calculateRollResult(
    seeds.server_seed, 
    seeds.client_seed, 
    nonce
  )
  
  const winnerItem = getWinningItem(caseItems, rollValue)
  
  // Update nonce
  await supabase
    .from('user_seeds')
    .update({ nonce: nonce })
    .eq('user_id', user.id)
  
  // Record for audit
  await supabase.from('game_rolls').insert({
    user_id: user.id,
    case_id: caseId,
    server_seed: seeds.server_seed,
    client_seed: seeds.client_seed,
    nonce: nonce,
    roll_result: Math.floor(rollValue * 1000000),
    item_won_id: winnerItem.id
  })
  
  return NextResponse.json({
    winner: winnerItem,
    fairness: {
      server_seed_hash: hashSeed(seeds.server_seed),
      client_seed: seeds.client_seed,
      nonce: nonce,
      roll_value: rollValue
    }
  })
}

Verification Example

How a player would verify a game:
// After revealing the server seed, verify the outcome
const crypto = require('crypto')

const serverSeed = "revealed-after-game"
const clientSeed = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d"
const nonce = 42

// Calculate roll
const message = `${clientSeed}-${nonce}`
const hmac = crypto.createHmac('sha256', serverSeed)
hmac.update(message)
const hex = hmac.digest('hex')

const subHex = hex.substring(0, 8)
const decimal = parseInt(subHex, 16)
const MAX_VAL = 0xFFFFFFFF
const roll = decimal / (MAX_VAL + 1)

console.log('Roll value:', roll)
// Now check if this roll matches the item you won

Security Properties

Server shows SHA-256 hash of server seed before any client input. This proves the server committed to a value before knowing the outcome.
Player can set their own client seed, ensuring they contributed to the randomness. Server cannot predict the outcome without knowing client’s seed.
Same inputs always produce same output. This means outcomes can be verified after revealing the server seed.
HMAC-SHA256 is a well-studied algorithm. Breaking it would require finding SHA-256 collisions, considered computationally infeasible.

Best Practices

Critical: Never reveal the server seed until after it’s been used for all games. Revealing it early would let players predict outcomes.
Recommended Flow:
  1. Show server_seed_hash to player
  2. Player (optionally) sets custom client_seed
  3. Play games, incrementing nonce each time
  4. When player wants to verify, reveal the server_seed
  5. Rotate to new server_seed for future games

Nonce Incrementing

The nonce prevents reusing the same random value:
// Game 1: nonce = 1
HMAC-SHA256(server_seed, "client_seed-1") = outcome_1

// Game 2: nonce = 2  
HMAC-SHA256(server_seed, "client_seed-2") = outcome_2

// Different nonce = different outcome, even with same seeds

Future Enhancements

Seed History

UI to view past server seeds and verify old games

Custom Client Seeds

Allow players to set their own client seed before playing

Automatic Rotation

Auto-rotate server seed every N games or time period

Verification Page

Built-in calculator to verify outcomes without code

Learn More

Case Opening

See how provably fair integrates with case opening

Industry Standards

Read about provably fair algorithms on Wikipedia

Build docs developers (and LLMs) love