Docs · SDK
Build a provably-fair game.
Variance Vault is a house-banked variance engine on HyperEVM: one shared LP pool banks every game anyone registers. @freeroll/sdk registers your game as an on-chain value tree, and the chain itselfchecks that the game can neither drain the pool nor cheat the player — so you inherit the pool's full liquidity without funding a cent of bankroll. No approval, no allowlist, no manual funding on testnet: minutes from an empty directory to a settled round you can verify on-chain.
The model
One pool banks every game.
Variance Vault has exactly one house: the protocol. A single shared LP pool is the counterparty to every round of every game anyone registers. When you build a game or a whole frontend on this SDK, you do notfund a bankroll, hold player deposits, run settlement infrastructure, or operate entropy — none of those are roles an integrator can hold. That is the point of integrating: your game draws on the pool's full liquidity from its first player, instead of whatever bankroll you could seed yourself. If a step in your build looks like "fund the house wallet" or "seed your pool," you have left the intended path.
What you own is the game. A game is a value tree: nodes are chance draws, player decisions, or payout leaves. You register the tree once; at registration the contract validates by backward induction that every node respects the house edge and that the game can never pay more than the pool reserved. Play streams rounds through a channel. At close, the contract replays a revealed random seed through your tree and settles the true net, clamped to that reservation.
Nobody — not you, not the player, not the protocol's own house operator — is trusted on any of this:
- edge
- every node passed the contract's own inequality at registration
- net
- recomputed on-chain from the seed; no signed number is settled
- seed
- provisioned by the house/beacon, revealed only atomically at close
- solvency
- the [−budget, +liability] clamp bounds the pool no matter the game or the rolls
This is why games need no gatekeeper: there is no way to register one that harms the pool or the player — the worst you can do is hand yourself the minimum-edge game. So createGameSet is permissionless. You own the set you create and can append to it freely.
Quickstart
Three steps to a verified round.
Testnet · HyperEVM chain 998 · free money. No wallet setup, no faucet ceremony, no account anywhere.
- 01
Get the SDK.
One package from npm, plus
viem(its only peer) and a TypeScript runner:mkdir my-game && cd my-game && npm init -y npm i @freeroll/sdk viem npm i -D tsx typescriptYour script imports from
@freeroll/sdk/v14and runs withnpx tsx. No repo access, API key, or account needed. - 02
Define, register, play, verify — one script.
The smallest game is a fair coin flip. This script mints a throwaway testnet key (or takes yours via
PLAYER_PK), funds it in one call to the protocol faucet — gas HYPE plus a $10 balance already deposited in the vault — defines the game, registers it into a fresh set you own, plays a $0.10 round, settles on-chain, and verifies the settle:import { readFileSync, writeFileSync } from "node:fs"; import { createPublicClient, defineChain, http, type Address, type Hex } from "viem"; import { generatePrivateKey } from "viem/accounts"; import { coinflipGame, createCasino, localSigner, registerGames, verifyChannel, type KeyValueStorage, } from "@freeroll/sdk/v14"; const RPC = "https://rpc.hyperliquid-testnet.xyz/evm"; const VAULT = "0x88CddADA26386d39BDf79B67Fd02d9C0274ED5F9" as Address; const HOUSE = "https://variance-vault-keeper.fly.dev/v14"; const FAUCET = "https://testnet.variancevault.xyz/api/faucet"; const CHAIN_ID = 998; const chain = defineChain({ id: CHAIN_ID, name: "hyperevm-testnet", nativeCurrency: { name: "HYPE", symbol: "HYPE", decimals: 18 }, rpcUrls: { default: { http: [RPC] } }, contracts: { multicall3: { address: "0xcA11bde05977b3631167028862bE2a173976CA11" }, }, }); // Patient retries: the public RPC rate-limits in roughly per-minute windows. // A slow backoff lets a full run ride out the limiter instead of dying mid-step. const publicClient = createPublicClient({ chain, transport: http(RPC, { retryCount: 6, retryDelay: 2_000 }), }); // File-backed storage: a crash resumes the channel, never strands escrow. function fileStorage(path: string): KeyValueStorage { let data: Record<string, string> = {}; try { data = JSON.parse(readFileSync(path, "utf8")); } catch {} const flush = () => writeFileSync(path, JSON.stringify(data)); return { get: (k) => (k in data ? data[k] : null), set: (k, v) => { data[k] = v; flush(); }, remove: (k) => { delete data[k]; flush(); }, }; } const storage = fileStorage("./.casino-store.json"); // Bring a key via PLAYER_PK, or let the script mint a throwaway testnet key // and persist it so re-runs keep the same wallet (testnet-only money). const PK = ((process.env.PLAYER_PK as Hex | undefined) ?? storage.get("quickstart:pk") ?? (() => { const pk = generatePrivateKey(); storage.set("quickstart:pk", pk); return pk; })()) as Hex; // The whole signing story: the SDK pre-flights gas/nonce/chainId itself, so a raw key // just signs and broadcasts. (A real wallet — Privy, MetaMask — slots in the same way: // pass its EIP-1193 provider + address as the Signer.) const signer = localSigner(PK, publicClient); // FUND — one POST to the protocol faucet: gas HYPE + a $10 balance already // deposited in the vault for you. Idempotent (a wallet holding a position // gets "Already has position"); 429 just means a recent call is landing. async function ensureFunded() { const res = await fetch(FAUCET, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ address: signer.account }), }); if (!res.ok && res.status !== 429) { throw new Error(`faucet ${res.status}: ${await res.text()} — fund ` + `${signer.account} manually (USDC: faucet.circle.com, gas: ` + "app.hyperliquid-testnet.xyz/drip) and re-run"); } for (let i = 0; i < 60; i++) { if ((await publicClient.getBalance({ address: signer.account })) > 0n) return; await new Promise((r) => setTimeout(r, 2_000)); } throw new Error("faucet drip never landed — re-run in a minute"); } async function main() { // 1. FUND (idempotent) + DEFINE — a fair coin flip (a value tree of // chance -> payout leaves). await ensureFunded(); const game = coinflipGame(); // 2. REGISTER (permissionless) — once. No gameSetId = mint a fresh set you // own; we persist the id so re-runs reuse the set instead of minting again. // (If you change your games, delete the store: sets are append-only, and // the casino refuses a local game that doesn't byte-match the chain.) let gameSetId = storage.get("quickstart:gameSetId") ? BigInt(storage.get("quickstart:gameSetId")!) : undefined; if (gameSetId === undefined) { const reg = await registerGames({ publicClient, signer, vault: VAULT, games: [game], }); gameSetId = reg.gameSetId; storage.set("quickstart:gameSetId", gameSetId.toString()); console.log("registered game set:", gameSetId.toString()); } else { console.log("reusing game set:", gameSetId.toString()); } // 3. CREATE the casino over your set. const closed: bigint[] = []; const casino = createCasino({ chainId: CHAIN_ID, publicClient, vault: VAULT, houseUrl: HOUSE, player: signer.account, signer: () => signer, gameSetId, games: [game], storage, onEvent: (phase, data) => { if (phase === "channel:closed" && typeof data?.channelId === "string") closed.push(BigInt(data.channelId)); }, }); // 4. TOP UP if needed. The faucet already seeded a $10 vault balance, so // this is normally skipped — it's the path for USDC you brought yourself // (approve + deposit in one call). if ((await casino.balanceMicro()) < 100_000n) { await casino.deposit(5_000_000n); } // 5. PLAY — a $0.10 flip. Warm rounds settle off-chain in ~200ms, and the // result is FINAL when play() resolves — there is no per-round settle tx. // (Sizing a real UI? game("coinflip").maxStakeMicro() is the largest stake // the pool can seat right now — see the Sizing section.) const r = await casino.game("coinflip").play(100_000n); const paid = r.outcome!.payoutMicro; console.log(paid > 0n ? "WIN" : "LOSS", "payout:", paid.toString()); // 6. LEAVE — one tx settles the whole session's net on-chain; then VERIFY // each closed channel purely from chain state. await casino.leave(); const tip = await publicClient.getBlockNumber(); for (const id of closed) { const v = await verifyChannel({ publicClient, vault: VAULT, channelId: id, fromBlock: tip - 10_000n, }); console.log(`verifyChannel(${id}) ->`, v.ok); const url = `https://testnet.variancevault.xyz/v14/channel/${id}`; console.log(" proof:", url); } } main().catch((e) => { console.error(e); process.exit(1); }); - 03
Run it.
npx tsx play.tsThe first round pays the cold channel open (~5–8s round-trip). Keep the same casino open and later rounds settle off-chain in ~
200ms; nothing touches the chain again until youleave(). The final line printsverifyChannel(id) -> true: your round, reconstructed and checked purely from chain state.Want more than the faucet's $10, or funding a wallet by hand? faucet.circle.com drips 20 native USDC per address per 2 hours on
HyperEVM Testnet, and the Hyperliquid testnet faucet covers gas — the script'scasino.depositpath takes it from there. The testnet vault's allowlist is off; any funded wallet plays immediately.The public RPC above is rate-limited and caps log queries — enough for this quickstart, but a full run leans on the script's slow retries and the final
verifyChannelcan take a minute or two riding out the limiter. If a run still dies onrate limited, wait a minute and re-run; every step resumes (the game set, the deposit, and any open channel are all picked back up). For big trees (blackjack-scale) or sustained play, pointRPCat your own key from any HyperEVM provider.
Sizing
The pool caps every round — quote it, don't guess.
The shared pool reserves at most a fixed slice of its equity as the largest net win any single round may risk. Your game's top multiplier is the divisor on that reservation: maxStake ≈ poolCap / (topMultiplier − 1). On the same pool, a 2× coin flip seats roughly forty times more stake per round than an 80× jackpot — variance is a design dial that trades away bet size. If your game carries a big top multiplier and your UI offers big fixed stakes, most of those options will simply refuse to seat.
So never hardcode stake options against a multiplier you chose in isolation. Quote the live ceiling and let the UI follow it:
// The largest stake this game can seat RIGHT NOW (micro-USDC):
// pool cap ∧ the player's balance ∧ the standing channel's walls.
const maxMicro = await casino.game("my-wheel").maxStakeMicro();
// e.g. gate a fixed stake picker against it:
const tiers = [250_000n, 1_000_000n, 5_000_000n]; // 25c / $1 / $5
const seatable = tiers.filter((t) => t <= maxMicro);
// Several games at once? ONE snapshot prices all of them — never loop
// maxStakeMicro() per game (it multiplies identical RPC reads by N):
const maxes = await casino.maxStakesMicro(["my-wheel", "coinflip"]);The figure moves with pool equity, the player's balance, and the standing channel's walls — treat it like a price. Read it before rendering a stake picker, refresh it on a slow poll, and clamp or disable options above it. A stake over the ceiling doesn't degrade gracefully on its own: the round refuses to seat. Two common designs that fall out naturally: scale one stake input to the ceiling (a max button), or keep fixed tiers and grey out the ones the pool can't seat right now.
Settlement
There is no per-round settle step.
A round is final the moment play() resolves. The outcome in r.outcomecame from the revealed seed the house was already bound to, and the player's signed round stream is exactly what the contract will replay. Show the result immediately — don't build a "settle this bet" button, poll for a per-bet settlement event, or hold the result pending a transaction. None of those exist in this model.
The chain sees only the session net: casino.leave()closes the channel and the contract replays the seed through your tree, settling every round in one transaction. Call it when the player cashes out or walks away — never per round. Between rounds the open channel is the player's position; casino.balanceMicro() already includes it.
Entropy
The one shared dependency.
Everything above is trustless except the source of randomness: for a round to be ungrindable, the seed must come from somewhere neither the player nor a colluding operator can predict. That source is the protocol's seed beacon — point houseUrl at https://variance-vault-keeper.fly.dev/v14and it provisions and reveals seeds for every integrator's games, the way every VRF consumer shares one VRF. This is not a default you can swap out: the contract only lets a channel bind a seed commit the beacon provisioned, precisely so that no integrator (or player) can ever self-deal entropy. Seeds are the one job the protocol keeps; games, frontends, and everything else are yours.
Node scripts and browser frontends both talk to the beacon directly — on testnet it accepts any origin, so a frontend on your own domain works without asking anyone.
Frontends
A frontend needs no backend.
Building a game site— many players, your own domain — changes nothing about the model. Each player brings (or is minted) their own wallet; each player's browser opens its own channel against the pool through the shared beacon; every bet is banked by the same shared liquidity. The whole thing can be a static page: no server, no database, no custody, no auth provider.
- Wallets: any EIP-1193 signer works. The zero-friction testnet pattern is a burner:
generatePrivateKey()in the browser, persisted tolocalStorage— the same trick the quickstart script uses. - Funding:POST each new player's address to the protocol faucet (
https://testnet.variancevault.xyz/api/faucet, CORS-open) — gas plus a $10 vault balance, rate-limited per address. - Play: one
createCasinoper player, over your registered game set,houseUrlat the beacon. Channels are per-player and cost nothing to switch games inside.
Do not route all your users through one server-held wallet. It recreates custody and a balance ledger you then have to build (and your rounds serialize on one channel) — per-player wallets get all of that from the chain for free.
Here is that whole model as one complete React page — burner wallet, faucet funding, live stake gating, instant results, cash-out. Paste it into any Vite/Next app (register your game once with the quickstart script above and fill in the two YOUR_* lines):
"use client"; // Next.js App Router; harmless in Vite/CRA
import { useEffect, useState } from "react";
import { createPublicClient, defineChain, http, type Address, type Hex } from "viem";
import { generatePrivateKey } from "viem/accounts";
import { coinflipGame, createCasino, localSigner, type Casino } from "@freeroll/sdk/v14";
const RPC = "https://rpc.hyperliquid-testnet.xyz/evm";
const VAULT = "0x88CddADA26386d39BDf79B67Fd02d9C0274ED5F9" as Address;
const HOUSE = "https://variance-vault-keeper.fly.dev/v14";
const FAUCET = "https://testnet.variancevault.xyz/api/faucet";
// YOUR_GAME_SET_ID: printed by your one-time registerGames run (quickstart step 2).
// YOUR_GAMES: the SAME array you registered, in the SAME order — the SDK byte-verifies
// it against the chain and refuses drift, so your UI can never lie about the odds.
const GAME_SET_ID = 0n; // <- YOUR_GAME_SET_ID
const GAMES = [coinflipGame()]; // <- YOUR_GAMES
const chain = defineChain({
id: 998, name: "hyperevm-testnet",
nativeCurrency: { name: "HYPE", symbol: "HYPE", decimals: 18 },
rpcUrls: { default: { http: [RPC] } },
contracts: { multicall3: { address: "0xcA11bde05977b3631167028862bE2a173976CA11" } },
});
const publicClient = createPublicClient({ chain, transport: http(RPC) });
// One burner wallet PER PLAYER, minted in their browser, persisted in localStorage.
// Lazy singleton so nothing touches localStorage during server rendering.
let _c: { casino: Casino; player: Address } | undefined;
function getCasino() {
if (_c) return _c;
const pk = (localStorage.getItem("my-casino:pk") ??
(() => { const k = generatePrivateKey(); localStorage.setItem("my-casino:pk", k); return k; })()) as Hex;
const signer = localSigner(pk, publicClient);
const casino = createCasino({
chainId: 998, publicClient, vault: VAULT, houseUrl: HOUSE,
player: signer.account, signer: () => signer,
gameSetId: GAME_SET_ID, games: GAMES,
// storage defaults to localStorage in a browser: a reload resumes the open channel.
});
return (_c = { casino, player: signer.account });
}
const TIERS = [100_000n, 1_000_000n, 5_000_000n]; // 10c / $1 / $5
const usd = (m: bigint) => `$${(Number(m) / 1e6).toFixed(2)}`;
export default function Page() {
const [balance, setBalance] = useState<bigint | null>(null);
const [maxStake, setMaxStake] = useState(0n);
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
// Fund on first visit (protocol faucet: gas + a $10 vault balance), then poll the
// balance AND the live stake ceiling. The ceiling is a price — it moves with the
// pool — so the stake buttons below follow it instead of hardcoding what fits.
useEffect(() => {
const { casino, player } = getCasino();
let on = true;
const tick = async () => {
const bal = await casino.balanceMicro().catch(() => null);
if (bal !== null && bal < 100_000n)
fetch(FAUCET, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ address: player }),
}).catch(() => {});
const maxes = await casino.maxStakesMicro(["coinflip"]).catch(() => null);
if (!on) return;
if (bal !== null) setBalance(bal);
if (maxes) setMaxStake(maxes.get("coinflip") ?? 0n);
};
tick();
const t = setInterval(tick, 5_000);
return () => { on = false; clearInterval(t); };
}, []);
// One click = one round. The result is FINAL when play() resolves — show it
// immediately; there is no per-round settle step. (The first click also opens the
// player's channel, ~5-8s once; every later round is ~200ms.)
const flip = async (stake: bigint) => {
setBusy(true); setNote("flipping…");
try {
const { casino } = getCasino();
const r = await casino.game("coinflip").play(stake);
const paid = r.outcome!.payoutMicro;
setNote(paid > 0n ? `WIN ${usd(paid)}` : "lost");
setBalance(await casino.balanceMicro());
} catch (e) {
setNote((e as Error).message); // SDK errors are written to be shown
} finally { setBusy(false); }
};
// Cash out: ONE tx settles the whole session's net on-chain and frees the escrow.
const cashOut = async () => {
setBusy(true); setNote("settling on-chain…");
try {
const { casino } = getCasino();
await casino.leave();
setBalance(await casino.balanceMicro());
setNote("settled");
} catch (e) { setNote((e as Error).message); } finally { setBusy(false); }
};
return (
<main style={{ fontFamily: "system-ui", padding: 32, display: "grid", gap: 12 }}>
<div>balance: {balance === null ? "…" : usd(balance)}</div>
<div style={{ display: "flex", gap: 8 }}>
{TIERS.map((t) => (
<button key={String(t)} disabled={busy || t > maxStake} onClick={() => flip(t)}>
flip {usd(t)}
</button>
))}
<button disabled={busy} onClick={cashOut}>cash out</button>
</div>
<div>{note}</div>
<div style={{ opacity: 0.6 }}>max per flip right now: {usd(maxStake)}</div>
</main>
);
}Reference
Addresses & entry points.
- vault (game-tree)
- 0x88CddADA26386d39BDf79B67Fd02d9C0274ED5F9
- USDC
- 0x2B3370eE501B4a559b57D449569354196457D8Ab
- chain id
- 998 (HyperEVM testnet)
- RPC
- https://rpc.hyperliquid-testnet.xyz/evm
- house / seed beacon
- https://variance-vault-keeper.fly.dev/v14
- protocol faucet
- POST { address } → https://testnet.variancevault.xyz/api/faucet
- USDC faucet (manual)
- faucet.circle.com → HyperEVM Testnet
SDK entry points (all from @freeroll/sdk/v14): defineGame / distGame / coinflipGame / minesGame / blackjackGame (author), registerGames (register), createCasino (play), verifyChannel (prove). The agent-betting quickstart lives at /docs/agents.