Provably Fair

Every Rugbet round is locked with a commitment-and-reveal scheme. The server commits to a secret seed before settlement, reveals it after the round finishes, and exposes the transcript needed to verify the result yourself.

How Provably Fair Works

  1. Commitment. Before the round can be verified, Rugbet stores the SHA-256 hash of a secret server seed. Once published, that commitment cannot be changed without changing the hash.
  2. Reveal. After settlement, Rugbet reveals the original seed. If SHA256(reveal) matches the commitment, you know the seed was locked in before the result was exposed.
  3. Transcript. The modal also exposes the round transcript and its hash. That transcript is the ordered record of moves and resolved values used to settle the round.
  4. Reproduction. With the reveal plus the transcript, you can reproduce the same deterministic result locally and confirm the modal data is internally consistent.

How To Verify

Each verification flow uses the same core values from the Provably Fair information:

  • Commitment, the pre-round SHA-256 hash of the server seed.
  • Reveal, the server seed disclosed after settlement.
  • Transcript JSON, the canonical event payload for the round.
  • Transcript Hash, the SHA-256 hash of that canonical transcript.
  • Initial Number for Higher / Lower, the opening number locked in the round config.

Paste those values into the browser-console snippets below. Each snippet checks the commitment, recomputes the transcript hash, and then validates the game-specific result math. Higher / Lower also checks the separately exposed initial number.

Single-Player Games

Lasers and The Ascent both resolve each move deterministically from the revealed seed. The transcript records the exact move payloads and resolved values, so you only need the reveal plus the transcript to replay the round logic step by step.

Lasers (Higher / Lower)

This snippet verifies the commitment, recomputes the transcript hash, and replays every guess using the same per-move `hl:next` seed labels and published odds formula. Copy the locked Initial Number from the fairness data and paste it into the snippet so the opening state is verified independently of the transcript.

(async () => {
  const commitment = '<PASTE_COMMITMENT_HERE>'.toLowerCase()
  const reveal = '<PASTE_REVEAL_HERE>'
  const transcriptHash = '<PASTE_TRANSCRIPT_HASH_HERE>'.toLowerCase()
  const initialNumber = Number('<PASTE_INITIAL_NUMBER_HERE>')
  const transcript = JSON.parse(`<PASTE_TRANSCRIPT_JSON_HERE>`)

  const HL_MIN_NUMBER = 1
  const HL_MAX_NUMBER = 100
  const HL_NUMBER_RANGE = 100
  const HL_NUMBER_MAX_INDEX = 99
  const HL_ODDS_DENOMINATOR = 100
  const HL_RTP_PERCENT = 96

  const normalizeJsonValue = (value) => {
    if (
      value === null ||
      typeof value === 'string' ||
      typeof value === 'number' ||
      typeof value === 'boolean'
    ) {
      return value
    }

    if (Array.isArray(value)) return value.map(normalizeJsonValue)

    if (typeof value === 'object') {
      return Object.fromEntries(
        Object.entries(value)
          .filter(([, entry]) => entry !== undefined)
          .sort(([left], [right]) => left.localeCompare(right))
          .map(([key, entry]) => [key, normalizeJsonValue(entry)]),
      )
    }

    return String(value)
  }

  const canonicalJsonStringify = (value) => JSON.stringify(normalizeJsonValue(value))
  const sha256Hex = async (value) => {
    const bytes = new TextEncoder().encode(value)
    const buffer = await crypto.subtle.digest('SHA-256', bytes)
    return Array.from(new Uint8Array(buffer))
      .map((byte) => byte.toString(16).padStart(2, '0'))
      .join('')
  }

  const getDeterministicInt = async (seed, label, maxExclusive) => {
    if (maxExclusive <= 0) return 0

    const digest = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(`${seed}:${label}`),
    )
    const view = new DataView(digest)
    return view.getUint32(0, false) % maxExclusive
  }

  const getOddsFromWinCount = (winCount) => {
    if (winCount <= 0) return 0

    const rawNumerator = Math.floor(
      (HL_NUMBER_RANGE * HL_RTP_PERCENT + Math.floor(winCount / 2)) / winCount,
    )
    const numerator = Math.max(HL_ODDS_DENOMINATOR, rawNumerator)
    return numerator / HL_ODDS_DENOMINATOR
  }

  const getOddsForGuess = (number, guess) => {
    const index = Math.max(0, Math.min(HL_NUMBER_MAX_INDEX, number - HL_MIN_NUMBER))
    const lowerWins = index
    const higherWins = HL_NUMBER_MAX_INDEX - index
    return guess === 'higher' ? getOddsFromWinCount(higherWins) : getOddsFromWinCount(lowerWins)
  }

  const revealHash = await sha256Hex(reveal)
  const transcriptHashFromPayload = await sha256Hex(canonicalJsonStringify(transcript))

  let currentNumber = initialNumber
  let currentMultiplier = 1
  const guessChecks = []

  for (const event of transcript) {
    if (event?.moveType !== 'GUESS') continue

    const nextNumber =
      HL_MIN_NUMBER +
      (await getDeterministicInt(reveal, `${event.moveIndex}:hl:next`, HL_NUMBER_RANGE))
    const guess = event?.movePayload?.guess
    const wasCorrect =
      (guess === 'higher' && nextNumber > currentNumber) ||
      (guess === 'lower' && nextNumber < currentNumber)
    const oddsMultiplier = getOddsForGuess(currentNumber, guess)
    const nextMultiplier = wasCorrect ? currentMultiplier * oddsMultiplier : currentMultiplier

    guessChecks.push({
      moveIndex: event.moveIndex,
      nextNumberMatches: nextNumber === event?.resolvedValue?.nextNumber,
      wasCorrectMatches: wasCorrect === event?.resolvedValue?.wasCorrect,
      oddsMultiplierMatches:
        Math.abs(oddsMultiplier - Number(event?.resolvedValue?.oddsMultiplier ?? 0)) < 1e-9,
      nextMultiplierMatches:
        Math.abs(nextMultiplier - Number(event?.resolvedValue?.nextMultiplier ?? 0)) < 1e-9,
    })

    currentNumber = nextNumber
    currentMultiplier = nextMultiplier
  }

  console.log({
    commitmentMatches: revealHash === commitment,
    transcriptHashMatches: transcriptHashFromPayload === transcriptHash,
    initialNumberMatches:
      transcript[0]?.resolvedValue?.currentNumber === undefined
        ? true
        : transcript[0]?.resolvedValue?.currentNumber === initialNumber,
    allGuessesMatch: guessChecks.every((entry) =>
      Object.values(entry).every((value) => value === true || typeof value === 'number'),
    ),
    guessChecks,
  })
})()

The Ascent (Tower)

This snippet verifies the commitment, recomputes the transcript hash, then replays each row hazard using the same `tower:hazard` seed labels. It also recomputes the cumulative multiplier using the live 96% RTP opening-row rule.

(async () => {
  const commitment = '<PASTE_COMMITMENT_HERE>'.toLowerCase()
  const reveal = '<PASTE_REVEAL_HERE>'
  const transcriptHash = '<PASTE_TRANSCRIPT_HASH_HERE>'.toLowerCase()
  const transcript = JSON.parse(`<PASTE_TRANSCRIPT_JSON_HERE>`)

  const TOWER_RTP_MULTIPLIER = 0.96

  const normalizeJsonValue = (value) => {
    if (
      value === null ||
      typeof value === 'string' ||
      typeof value === 'number' ||
      typeof value === 'boolean'
    ) {
      return value
    }

    if (Array.isArray(value)) return value.map(normalizeJsonValue)

    if (typeof value === 'object') {
      return Object.fromEntries(
        Object.entries(value)
          .filter(([, entry]) => entry !== undefined)
          .sort(([left], [right]) => left.localeCompare(right))
          .map(([key, entry]) => [key, normalizeJsonValue(entry)]),
      )
    }

    return String(value)
  }

  const canonicalJsonStringify = (value) => JSON.stringify(normalizeJsonValue(value))
  const sha256Hex = async (value) => {
    const bytes = new TextEncoder().encode(value)
    const buffer = await crypto.subtle.digest('SHA-256', bytes)
    return Array.from(new Uint8Array(buffer))
      .map((byte) => byte.toString(16).padStart(2, '0'))
      .join('')
  }

  const getDeterministicInt = async (seed, label, maxExclusive) => {
    if (maxExclusive <= 0) return 0

    const digest = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(`${seed}:${label}`),
    )
    const view = new DataView(digest)
    return view.getUint32(0, false) % maxExclusive
  }

  const getWinProbability = (tilesPerRow) => {
    if (tilesPerRow <= 1) return 0
    return (tilesPerRow - 1) / tilesPerRow
  }

  const getRowMultiplier = (rowIndex, tilesPerRow) => {
    const fairRowMultiplier = 1 / getWinProbability(tilesPerRow)
    return rowIndex === 0 ? fairRowMultiplier * TOWER_RTP_MULTIPLIER : fairRowMultiplier
  }

  const getCumulativeMultiplier = (rowIndex, tilesPerRowByRow) => {
    let multiplier = 1

    for (let index = 0; index <= rowIndex; index += 1) {
      multiplier *= getRowMultiplier(index, tilesPerRowByRow[index])
    }

    return Math.round(multiplier * 100) / 100
  }

  const revealHash = await sha256Hex(reveal)
  const transcriptHashFromPayload = await sha256Hex(canonicalJsonStringify(transcript))

  const rowChecks = []
  const tilesPerRowByRow = []

  for (const event of transcript) {
    if (event?.moveType !== 'GUESS') continue

    const row = Number(event?.resolvedValue?.row ?? event.moveIndex)
    const tilesPerRow = Number(event?.resolvedValue?.tilesPerRow ?? 0)
    const guessIndex = Number(event?.movePayload?.guessIndex)
    const hazardIndex = await getDeterministicInt(reveal, `${event.moveIndex}:tower:hazard`, tilesPerRow)
    const wasCorrect = guessIndex !== hazardIndex

    tilesPerRowByRow[row] = tilesPerRow

    const nextMultiplier = wasCorrect
      ? getCumulativeMultiplier(row, tilesPerRowByRow)
      : Number(event?.resolvedValue?.currentMultiplier ?? 1)

    rowChecks.push({
      moveIndex: event.moveIndex,
      hazardIndexMatches: hazardIndex === event?.resolvedValue?.hazardIndex,
      wasCorrectMatches: wasCorrect === event?.resolvedValue?.wasCorrect,
      nextMultiplierMatches:
        Math.abs(nextMultiplier - Number(event?.resolvedValue?.nextMultiplier ?? 0)) < 1e-9,
    })
  }

  console.log({
    commitmentMatches: revealHash === commitment,
    transcriptHashMatches: transcriptHashFromPayload === transcriptHash,
    allRowsMatch: rowChecks.every((entry) =>
      Object.values(entry).every((value) => value === true || typeof value === 'number'),
    ),
    rowChecks,
  })
})()

These published single-player verifiers reflect the current Lasers and The Ascent formulas only. Older rounds settled under earlier RTP settings may not reproduce with these snippets.

Multiplayer Games

Rug Rise locks the round outcome from the committed seed and exposes a public transcript of bets, moves, and the final round result. You can verify the same reveal produces the same crash multiplier regardless of who played the round.

Rug Rise (Crash)

This snippet verifies the commitment, recomputes the transcript hash, and derives the crash multiplier from the reveal using the same current 2% house-edge and two-decimal floor clamp used in settlement.

This published verifier reflects the current Crash formula only. Older rounds settled under earlier RTP settings may not reproduce with this snippet.

(async () => {
  const commitment = '<PASTE_COMMITMENT_HERE>'.toLowerCase()
  const reveal = '<PASTE_REVEAL_HERE>'
  const transcriptHash = '<PASTE_TRANSCRIPT_HASH_HERE>'.toLowerCase()
  const transcript = JSON.parse(`<PASTE_TRANSCRIPT_JSON_HERE>`)

  const HOUSE_EDGE = 0.02
  const MIN_MULTIPLIER = 1
  const MAX_MULTIPLIER = 100

  const normalizeJsonValue = (value) => {
    if (
      value === null ||
      typeof value === 'string' ||
      typeof value === 'number' ||
      typeof value === 'boolean'
    ) {
      return value
    }

    if (Array.isArray(value)) return value.map(normalizeJsonValue)

    if (typeof value === 'object') {
      return Object.fromEntries(
        Object.entries(value)
          .filter(([, entry]) => entry !== undefined)
          .sort(([left], [right]) => left.localeCompare(right))
          .map(([key, entry]) => [key, normalizeJsonValue(entry)]),
      )
    }

    return String(value)
  }

  const canonicalJsonStringify = (value) => JSON.stringify(normalizeJsonValue(value))
  const sha256Hex = async (value) => {
    const bytes = new TextEncoder().encode(value)
    const buffer = await crypto.subtle.digest('SHA-256', bytes)
    return Array.from(new Uint8Array(buffer))
      .map((byte) => byte.toString(16).padStart(2, '0'))
      .join('')
  }

  const revealHash = await sha256Hex(reveal)
  const transcriptHashFromPayload = await sha256Hex(canonicalJsonStringify(transcript))

  const hashBytes = new Uint8Array(
    await crypto.subtle.digest('SHA-256', new TextEncoder().encode(reveal)),
  )

  let randomInt = 0n
  for (let index = 0; index < 8; index += 1) {
    randomInt = (randomInt << 8n) | BigInt(hashBytes[index])
  }

  const uniformDenominator = 2 ** 53
  const mask53 = (1n << 53n) - 1n
  let uniform = Number(randomInt & mask53) / uniformDenominator
  if (uniform <= 0) uniform = 1 / uniformDenominator

  const rawMultiplier = (1 - HOUSE_EDGE) / uniform
  const crashMultiplier = Math.floor(
    Math.min(MAX_MULTIPLIER, Math.max(MIN_MULTIPLIER, rawMultiplier)) * 100,
  ) / 100

  console.log({
    commitmentMatches: revealHash === commitment,
    transcriptHashMatches: transcriptHashFromPayload === transcriptHash,
    crashMultiplier,
  })
})()

On-Chain Commitments

Publishing commitment hashes to Solana before play is still planned. When that is live, this page will be updated with the exact on-chain verification flow as well.