Rugbet

Provably Fair

Provably fair. Verify every settled round.

Before a round starts, Rugbet commits to the server seed. After the round settles, it reveals the seed and transcript so you can verify the result.

How Provably Fair Works

  1. Commitment. Before a round starts, Rugbet stores the SHA-256 hash of the server seed. Once published, that commitment cannot be changed without changing the hash.
  2. Reveal. After the round settles, Rugbet reveals the original seed. If SHA256(reveal) matches the commitment, you can verify that the revealed seed matches the pre-round commitment.
  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 the round settles.
  • 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 Climb 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 = 0
  const HL_MAX_NUMBER = 100
  const HL_NUMBER_RANGE = 101
  const HL_NUMBER_MAX_INDEX = 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 getDeterministicNumber = async (seed, label) => {
    const limit = Math.floor(2 ** 32 / HL_NUMBER_RANGE) * HL_NUMBER_RANGE
    for (let retry = 0; ; retry += 1) {
      const drawLabel = retry === 0 ? label : `${label}:retry:${retry}`
      const digest = await crypto.subtle.digest(
        'SHA-256',
        new TextEncoder().encode(`${seed}:${drawLabel}`),
      )
      const value = new DataView(digest).getUint32(0, false)
      if (value < limit) return HL_MIN_NUMBER + value % HL_NUMBER_RANGE
    }
  }

  const getLegacyNumber = async (seed, label) => {
    const digest = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(`${seed}:${label}`),
    )
    return 1 + new DataView(digest).getUint32(0, false) % 100
  }

  const getLegacyOdds = (number, guess) => {
    const wins = guess === 'higher' ? 100 - number : number - 1
    if (wins <= 0) return 0
    return Math.max(100, Math.floor((9700 + Math.floor(wins / 2)) / wins)) / 100
  }

  const getOddsFromWinCount = (winCount) =>
    winCount > 0 ? HL_NUMBER_MAX_INDEX / winCount : 0

  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
  let correctGuessCount = 0
  let previousFraction = null
  const guessChecks = []
  const cashoutChecks = []

  for (const event of transcript) {
    if (event?.moveType === 'CASHOUT' && event?.resolvedValue?.cashoutRevealNumber !== undefined) {
      const draw = typeof event.resolvedValue.payoutNumerator === 'string'
        ? getDeterministicNumber
        : getLegacyNumber
      const revealedNumber = await draw(reveal, `${event.moveIndex}:hl:next`)
      cashoutChecks.push({
        moveIndex: event.moveIndex,
        revealMatches: revealedNumber === event.resolvedValue.cashoutRevealNumber,
      })
      continue
    }
    if (event?.moveType !== 'GUESS') continue

    const guess = event?.movePayload?.guess
    if (guess !== 'higher' && guess !== 'lower') continue
    const currentRules = typeof event?.resolvedValue?.payoutNumerator === 'string'
    const nextNumber = currentRules
      ? await getDeterministicNumber(reveal, `${event.moveIndex}:hl:next`)
      : await getLegacyNumber(reveal, `${event.moveIndex}:hl:next`)
    const wasCorrect =
      (guess === 'higher' && nextNumber > currentNumber) ||
      (guess === 'lower' && nextNumber < currentNumber)
    const oddsMultiplier = currentRules
      ? getOddsForGuess(currentNumber, guess)
      : getLegacyOdds(currentNumber, guess)
    const nextMultiplier = currentRules
      ? Number(event.resolvedValue.payoutNumerator) /
        Number(event.resolvedValue.payoutDenominator)
      : wasCorrect ? currentMultiplier * oddsMultiplier : currentMultiplier
    const expectedResult = wasCorrect
      ? 'CORRECT'
      : nextNumber === currentNumber && currentRules ? 'TIE' : 'INCORRECT'
    correctGuessCount += Number(wasCorrect)
    const fraction = currentRules
      ? {
          numerator: BigInt(event.resolvedValue.payoutNumerator),
          denominator: BigInt(event.resolvedValue.payoutDenominator),
        }
      : null
    const winCount = guess === 'higher' ? 100 - currentNumber : currentNumber
    const startingFraction = event.moveIndex === 0
      ? { numerator: 1n, denominator: 1n }
      : previousFraction
    const fractionTransitionMatches = !fraction || !startingFraction ||
      (wasCorrect
        ? fraction.numerator === startingFraction.numerator * 100n &&
          fraction.denominator === startingFraction.denominator * BigInt(winCount)
        : fraction.numerator === startingFraction.numerator &&
          fraction.denominator === startingFraction.denominator)

    guessChecks.push({
      moveIndex: event.moveIndex,
      currentNumberMatches: currentNumber === event?.resolvedValue?.currentNumber,
      nextNumberMatches: nextNumber === event?.resolvedValue?.nextNumber,
      wasCorrectMatches: wasCorrect === event?.resolvedValue?.wasCorrect,
      resultMatches: expectedResult === event?.result,
      correctGuessCountMatches: !currentRules ||
        correctGuessCount === event?.resolvedValue?.correctGuessCount,
      fractionTransitionMatches,
      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
    previousFraction = fraction
  }

  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,
    allCashoutsMatch: cashoutChecks.every((entry) => entry.revealMatches),
    cashoutChecks,
  })
})()

Climb (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 from fair row odds at the current theoretical 100% RTP target.

(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_PERCENT = 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 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 getCumulativeMultiplier = (rowIndex, tilesPerRowByRow) => {
    let numerator = BigInt(TOWER_RTP_PERCENT) * 10n
    let denominator = 1n

    for (let index = 0; index <= rowIndex; index += 1) {
      const tiles = BigInt(tilesPerRowByRow[index])
      numerator *= tiles
      denominator *= tiles - 1n
    }

    return Number(numerator / denominator) / 1000
  }

  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 Climb formulas only. Older rounds settled under earlier RTP settings may not reproduce with these snippets.

Multiplayer Games

Rise uses server-side committed round data 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.

Rise (Crash)

This snippet verifies the commitment, recomputes the transcript hash, and derives the crash multiplier from the reveal using the same current 0% 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
  const MIN_MULTIPLIER = 1
  const MAX_MULTIPLIER = 250

  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 Audit Links

For supported Solana-funded rounds, the app may also show an on-chain audit link to a round record or settlement transaction alongside the server proof.