Games Integration
Guide
Everything an operator needs to launch and run BetterPlay casino games — both the server-to-server integration (player launch, wallet, balances) and the in-browser integration (mounting the game UI). Part 1 is for your backend team; Part 2 is for your frontend team.
★ Quick Start — Simplest Integration
The fastest way to get a game on screen: serve the bundle, drop in a container, and call mountOperatorGame() — three steps. Pass demoMode: true to run with no backend; to go live, drop it and hand in a sessionToken from your server launch (Part 1 §2).
1 Serve the bundle
Host the /wasabi/ folder (bundle + manifest + assets) on your static server or CDN, under any path. It's fully self-contained — nothing else to host.
# serve this one folder — same origin as the page that mounts the game
/wasabi/wasabi-games.es.js # the library ES bundle
/wasabi/manifest.json # asset manifest (CSS injection)
/wasabi/chunks/ # per-game JS chunks (game art/audio inlined as data URIs)
/wasabi/assets/ # CSS + fonts & CSS-referenced images
2 Add a sized container
The game fills its container — give it a definite height (not just min-height, or it collapses to zero).
<div id="game" style="width:100%; height:720px;"></div>
3 Import & mount
One dynamic import(), one call. This is a complete standalone page:
<script type="module">
const origin = location.origin;
const wasabiUrl = `${origin}/wasabi/wasabi-games.es.js`;
const manifestUrl = `${origin}/wasabi/manifest.json`;
const { mountOperatorGame } = await import(/* @vite-ignore */ wasabiUrl);
await mountOperatorGame(document.getElementById('game'), {
game: 'dice', // any id from Game IDs
backendUrl: 'https://api.yourplatform.com/games/api', // library appends /dice
cdnUrl: 'https://cdn.wasabi.casino', // currency icons / avatars
demoMode: true, // ← runs on mock data, no backend
session: { player: 'demo-1', currency: 101, balance: 1000, minBet: 0.10, maxBet: 500 },
library: { bundle: wasabiUrl, manifest: manifestUrl },
callbacks: {
onBet: (e) => console.log('bet', e),
onRoundFinished: (e) => console.log('done', e),
onServerError: (e) => console.error('error', e),
},
});
</script>
demoMode: true the game runs against built-in mock responses — no backend, no auth. To go live: remove demoMode and put a sessionToken (minted by your backend's /launch, never in the browser) into session — it's sent as Authorization: Bearer on every game call. currency is a numeric code (101 = BTC).
import() needs CORS; the simplest setup serves /wasabi/ from the same origin as the page. Need more control over props, theming, or the launch round-trip? See How to Import & Run a Game and the sections below.
· How It Fits Together
BetterPlay owns all game logic, round state, and provably-fair RNG. You own player identity and the money. Two integration surfaces connect the two sides.
Who builds what
| Responsibility | Owner |
|---|---|
| Game logic, RNG, round state, fairness, session cookies | BetterPlay |
| Signed launch token + redirecting the player to start a session | Operator |
Wallet service holding real balances (/wallet/* endpoints we call) | Operator |
| The game UI in the browser — hosted by us, or embedded by you | Either |
Two ways to integrate the UI
- Hosted launch — your backend mints a launch token and redirects the player to
GET /launch. We hydrate the session against your wallet, set a session cookie, and serve the game. Lowest effort; covered in §2. - Embedded library — you import our JS bundle and mount games into your own page with
mountOperatorGame()(or the lower-levelloadGame()), injecting your own auth token. Maximum control over layout; covered in Part 2.
Both models talk to the same game REST API and rely on the same wallet service underneath. You can use one for some games and the other for the rest.
End-to-end money flow (a single round)
Player Operator Backend BetterPlay Games Operator Wallet
│ │ │ │
│ open game │ │ │
│─────────────────────▶│ mint launch JWT │ │
│ 302 to /launch?token=… │ │
│◀─────────────────────│ │ │
│ GET /launch?token=…&game=mines │ │
│──────────────────────────────────────────────▶│ verify JWT │
│ │ POST /wallet/auth (HMAC signed) │
│ │ │────────────────────────▶│
│ │ balance + currency + rg_limits │
│ │ │◀────────────────────────│
│ 302 → game UI (+ session cookie set) │ │
│◀──────────────────────────────────────────────│ │
│ play a round (bet) │ │ │
│──────────────────────────────────────────────▶│ │
│ │ POST /wallet/bet-win (HMAC, idempotent) │
│ │ │────────────────────────▶│
│ │ new balance │ │
│ │ │◀────────────────────────│
│ result + balance │ │ │
│◀──────────────────────────────────────────────│ │
"USDT", "BTC". The browser library (Part 2) takes a numeric currency code — e.g. 100 = USDT, 101 = BTC (full list in Currency Codes). They are not interchangeable; map between them in your integration layer.
Backend & Wallet
What your backend team builds: operator registration, the player launch token, and the wallet service we call for every debit and credit. This is where real money lives.
1 Registration
Operators are provisioned manually — there is no self-service signup API in the current release. Send BetterPlay the values below; we create your tenant (an operators row plus a default operator_configs row) and return your operator_id.
| Field | Required | Description |
|---|---|---|
code | Yes | Unique slug, e.g. acme-casino. Becomes the JWT iss and the operator_id in every wallet envelope. |
name | Yes | Human-readable display name. |
jwt_alg | Yes | HS256 (shared secret) or RS256 (you hold the private key, we hold the public key). |
jwt_shared_secret | if HS256 | The secret we use to verify your launch tokens. |
jwt_public_key | if RS256 | PEM-encoded RSA public key — PUBLIC KEY (PKIX) or RSA PUBLIC KEY (PKCS#1). |
wallet_base_url | Yes | Base URL of your wallet service, e.g. https://wallet.acme.com. A path prefix is allowed (see HMAC note in §3). |
wallet_hmac_secret | Yes | Shared secret we use to HMAC-sign requests to your wallet. |
wallet_timeout_ms | No | Per-call timeout. Defaults to 5000. |
game_frontend_base_url | Yes | Where we 302 the player after a successful hosted launch. Its origin is auto-added to the CORS allowlist for embedded play (add extra embed origins via allowed_origins in §5). |
webhook_url / webhook_hmac_secret | No | Reserved for outbound webhooks — not yet active. |
operator_id (UUID), confirmed code, and the environment base URLs (sandbox + production). Keep your secrets server-side only.
2 Launch & Player Auth
A player session starts when your backend mints a short-lived signed JWT and redirects the browser to GET /launch. The token is the authentication — we verify it, hydrate the session against your wallet, set a session cookie, and 302 to the game.
Step 1 — Mint a launch JWT
Sign with the algorithm you registered. All five core claims below are required — a missing iat is the most common cause of a rejected launch.
{
"iss": "acme-casino", // REQUIRED — must equal your operator code
"sub": "player_abc123", // REQUIRED — your player id (what your wallet knows)
"jti": "1f3c9a…unique", // REQUIRED — single-use nonce (replay-protected)
"iat": 1700000000, // REQUIRED — issued-at, Unix seconds
"exp": 1700000300, // REQUIRED — expiry; exp > iat and (exp − iat) ≤ 600s
"currency": "USDT", // optional — default currency for the session
"language": "en", // optional
"country": "US" // optional
}
- Lifetime
exp − iatmust be ≤ 10 minutes, andexp > iat. iatmust be within ±10 minutes of our server clock — keep your clock NTP-synced.- The token
algmust match your registered algorithm exactly (no HS256↔RS256 mixing). issmust equal yourcode;jtiis single-use (replays are rejected).
Node.js example — mint the token (and build the redirect) with jsonwebtoken. This runs server-side only.
// npm i jsonwebtoken
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
const OPERATOR_CODE = 'acme-casino'; // your registered code → becomes iss
const JWT_SECRET = process.env.LAUNCH_JWT_SECRET; // the HS256 shared secret you registered
// Mint a single-use launch token for one player. Server-side only.
function mintLaunchToken(playerId, { currency = 'USDT', language = 'en', country } = {}) {
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: OPERATOR_CODE, // must equal your operator code
sub: playerId, // your player id (what your wallet knows)
jti: randomUUID(), // single-use nonce — replay-protected
iat: now, // issued-at (keep your clock NTP-synced)
exp: now + 300, // 5 min — must be ≤ 600s and > iat
currency, language, // optional
...(country ? { country } : {}) // optional
};
return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256' });
// RS256 operator? sign with your PRIVATE key instead:
// jwt.sign(payload, privateKeyPem, { algorithm: 'RS256' })
}
// Build the launch URL and 302 the player's browser to it.
function launchUrl(playerId, game) {
const u = new URL('https://games.betterplay.example/launch');
u.searchParams.set('token', mintLaunchToken(playerId));
u.searchParams.set('game', game); // see Game IDs
u.searchParams.set('return_url', 'https://acme.com/lobby');
return u.toString();
}
// Express: app.get('/play/:game', (req, res) =>
// res.redirect(launchUrl(req.user.id, req.params.game)));
mintLaunchToken on your server and hand the browser only the finished redirect URL.
Step 2 — Redirect the browser
GET https://games.betterplay.example/launch
?token=<signed-jwt>
&game=mines // game id — see Game IDs (§ Part 2)
&lang=en // optional
¤cy=USDT // optional, overrides the JWT claim
&mode=real // optional; "demo" not yet active (see §6)
&return_url=https://acme.com/lobby // optional
There is no /v1 or /api prefix on /launch.
Step 3 — What we do
- Verify the JWT signature, lifetime, skew, and single-use
jti. - Call your
POST /wallet/authto hydrate balance, currency, and RG limits. - Mint our internal session JWT and set it as HTTP-only cookies (
token,refresh_token). 302the player togame_frontend_base_url?game=…. From here the game UI runs on the session cookie.
INVALID_TOKEN (bad / expired / replayed / oversized lifetime), PLAYER_FROZEN (your /wallet/auth rejected the player), or GAME_DISABLED (tenant inactive).
3 Wallet API
You implement a small HTTP service that holds player balances. BetterPlay calls it (server-to-server, HMAC-signed) for authentication and every money movement. All six endpoints are POST with a JSON body.
Transport & security
Every request is POST {wallet_base_url}{path} with Content-Type: application/json and these headers:
| Header | Description |
|---|---|
X-Timestamp | RFC3339 millis UTC, e.g. 2024-01-15T10:30:00.000Z. This is the value you sign over. Reject if skew > ~30s. |
X-Idempotency-Key | UUID. For mutating calls it equals the transaction_id in the body (bet_transaction_id for bet-win). Same key on a retry ⇒ replay the original response. |
X-Signature | Hex HMAC-SHA256 over X-Timestamp + "POST" + path + raw_body. |
Verify the signature by recomputing it and comparing in constant time:
secret = wallet_hmac_secret
msg = X-Timestamp + "POST" + <request path> + <raw body bytes> // concatenated, NO separator
sig = hex( HMAC_SHA256(secret, msg) )
reject with INVALID_SIGNATURE unless constant_time_equal(sig, X-Signature)
<request path>is the full path of the received request, including any path component of yourwallet_base_url. If your base URL ishttps://wallet.acme.com/api, the signed path for a bet is/api/wallet/bet— not/wallet/bet.- Sign the raw body bytes exactly as received. Do not re-serialize the JSON first (key order / whitespace would change the bytes and break the signature). The body's own
timestampfield is informational — always sign over theX-Timestampheader.
Worked example (verify your implementation against this)
secret = "whsec_demo_secret"
X-Timestamp = "2024-01-15T10:30:00.000Z"
method = "POST"
path = "/wallet/bet"
body = '{"amount":"10.00"}'
signing string = 2024-01-15T10:30:00.000ZPOST/wallet/bet{"amount":"10.00"}
X-Signature = 5d6c52374a9223c04f6277f7d2df839231716a2be978444ffdd25eb290e6bbb1
Common request envelope
Every request body has this outer shape; data varies per endpoint.
{
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2024-01-15T10:30:00.000Z",
"operator_id": "acme-casino", // your CODE, not the UUID
"player": {
"id": "player_abc123",
"currency": "USDT",
"session_token": "eyJ…" // present only on /wallet/auth
},
"data": { /* per-endpoint, see below */ },
"metadata": { // optional analytics, may be omitted
"gameName": "mines", "winChance": 0.97, "winMultiplier": 2.5
}
}
Common response envelope
Respond with status: "ok" and a data object, or status: "error" with a coded error. All money amounts are decimal strings (e.g. "10.00") — never floats.
// success
{ "status": "ok", "request_id": "550e8400-…", "data": { /* … */ } }
// error
{ "status": "error", "request_id": "550e8400-…",
"error": { "code": "INSUFFICIENT_FUNDS", "message": "balance below requested bet" } }
Called once at launch. Validates the player's session token and returns their profile + balance. data is null (the token is in player.session_token). Returning PLAYER_FROZEN here aborts the launch.
{
"player_id": "player_abc123",
"currency": "USDT",
"balance": "1500.00",
"country": "US", // optional
"language": "en", // optional
"rg_limits": { // optional — omit for no limits
"max_bet": "100.00",
"session_limit_minutes": 60
}
}
Read-only balance query (no money movement). data is null.
{ "balance": "1490.00" }
Debit the player for a bet. Idempotent on transaction_id — a repeated id returns the original response, never a second debit. Errors: INSUFFICIENT_FUNDS, BET_LIMIT_EXCEEDED, CURRENCY_NOT_SUPPORTED, PLAYER_FROZEN.
// request.data
{ "transaction_id": "a3f4e812-…", "round_id": "b7c2d099-…",
"game_id": "mines", "amount": "10.00" }
// response.data
{ "balance": "1480.00", "transaction_id": "a3f4e812-…" }
Credit the player on a win. bet_transaction_id links the credit back to its bet for your reconciliation. Idempotent on transaction_id.
// request.data
{ "transaction_id": "c9e1f034-…", "round_id": "b7c2d099-…",
"game_id": "mines", "amount": "27.50", "bet_transaction_id": "a3f4e812-…" }
// response.data
{ "balance": "1507.50", "transaction_id": "c9e1f034-…" }
Reverse a prior bet or win after a game error, timeout, or disconnect. transaction_id is a new idempotency key for the rollback; original_transaction_id identifies what is being undone. Idempotent. Return TRANSACTION_NOT_FOUND when the original is unknown — do not silently succeed.
// request.data
{ "transaction_id": "d1a8b234-…", "original_transaction_id": "a3f4e812-…" }
// response.data
{ "balance": "1490.00", "transaction_id": "d1a8b234-…" }
A single atomic debit + credit for instant-resolve games — one-request rounds like dice, limbo, plinko, wheel, keno, and coinflip. payout_amount: "0" means the player lost. This endpoint is required: for those games we always call /wallet/bet-win with no automatic fallback to bet + win, so a 404 here breaks them. Idempotent on bet_transaction_id.
// request.data
{ "bet_transaction_id": "a3f4e812-…", "credit_transaction_id": "c9e1f034-…",
"round_id": "b7c2d099-…", "game_id": "dice",
"bet_amount": "10.00", "payout_amount": "19.80" } // payout_amount "0" = loss
// response.data
{ "balance": "1509.80", "transaction_id": "c9e1f034-…" }
/wallet/bet-win. Multi-step games — mines, blackjack, hi-lo, crash — use /wallet/bet, then /wallet/win on payout, with /wallet/rollback if a round can't be settled. Implement all six.
4 Error Codes
Use these exact code strings in your error envelopes. Codes are matched case-insensitively and hyphens are treated as underscores, but stick to the canonical spelling.
| Code | HTTP | When to return it |
|---|---|---|
INVALID_SIGNATURE | 401 | HMAC verification failed. |
INVALID_TOKEN | 401 | Session / launch token invalid or expired. |
PLAYER_NOT_FOUND | 404 | Unknown player id. |
INSUFFICIENT_FUNDS | 402 | Balance below the requested bet. |
BET_LIMIT_EXCEEDED | 422 | Bet above a per-player or per-game limit. |
CURRENCY_NOT_SUPPORTED | 422 | Currency not enabled for this player. |
DUPLICATE_TRANSACTION | 409 | Idempotency key reused with a different body. |
TRANSACTION_NOT_FOUND | 404 | Rollback target does not exist. |
PLAYER_FROZEN | 403 | Account frozen by responsible-gambling / KYC. |
GAME_DISABLED | 403 | Game disabled for this operator. |
INVALID_INPUT | 400 | Malformed request. |
RATE_LIMITED | 429 | Throttled. |
WALLET_UNAVAILABLE | 503 | Wallet temporarily down. |
INTERNAL_ERROR | 500 | Catch-all. Unknown codes are treated as this. |
5xx. A 4xx is terminal and never retried — so a 4xx on a bet will not re-debit the player. Make sure transient failures surface as 5xx / WALLET_UNAVAILABLE, and deterministic rejections as 4xx.
5 Operator Config
Optionally send us a config to restrict which games appear, set per-currency bet limits, and toggle features. The game UI reads it from GET /me/operator-config (authenticated) to filter the lobby and clamp the bet form.
{
"enabled_games": ["dice", "mines", "plinko", "limbo", "crash"],
"currencies": {
"USDT": { "min_bet": "0.10", "max_bet": "500.00" },
"BTC": { "min_bet": "0.00001", "max_bet": "0.1" }
},
"feature_flags": {
"autoplay": true, "turbo": true, "chat": false, "sound_default": true
},
"rg_reality_check_minutes": 60,
"allowed_origins": ["https://staging.acme.com"] // extra CORS origins (see note)
}
rg_reality_check_minutes here, and the per-session rg_limits returned by /wallet/auth (§3).
game_frontend_base_url is allowed automatically; list any additional embed origins (staging, a second brand domain) in allowed_origins as full origins (https://host[:port], scheme included, no path). Changes take effect within ~60s. Hosted-launch-only operators (no embedding) can ignore this.
6 Not Yet Available
Two surfaces appear in the schema and launch params but are not wired end-to-end in v1. Don't build against them yet — they won't fire.
| Feature | Status |
|---|---|
Outbound webhookswebhook_url · webhook_hmac_secret | The fields exist on the operator record, but there is no sender in v1 — no round.completed / player.bigwin / game.error callbacks are delivered. Leave the fields empty until this ships. |
Demo mode&mode=demo on /launch | The param is forwarded to the frontend, but the wallet router does not yet switch to the internal demo wallet — treat as not active. (The browser library's own demoMode in Part 2 is a separate, working offline-UI demo.) |
Embedding the Game UI
What your frontend team builds if you embed games in your own page instead of using the hosted launch redirect. Mount any game into a DOM element with plain JavaScript — no Svelte, no build step, no npm install. Just serve the files and call loadGame().
· File Layout
The library ships as a self-contained ES bundle. Serve these files from your web server under any path — this guide uses /wasabi/.
# Serve this one folder from your static file server / CDN
/wasabi/wasabi-games.es.js # ← the library ES bundle
/wasabi/manifest.json # ← Vite asset manifest (for CSS injection)
/wasabi/chunks/ # ← per-game JS chunks (game art/audio inlined as data URIs)
/wasabi/assets/ # ← CSS + fonts & CSS-referenced images
loadGame() reads manifest.json and injects the required CSS <link> tags. Assets the game code imports (icons, SVGs, audio) are inlined as base64 data URIs inside the per-game JS chunks, so they need no hosting at all; CSS plus the assets referenced from CSS (fonts, large background images) are content-hashed under /wasabi/assets/ and load themselves via the injected <link>. Just serve the /wasabi/ folder as-is — no stylesheets to include and no asset folders to copy.
· How to Import & Run a Game
Three steps: import the bundle once at startup, give the game a sized container, then call loadGame(). The example mounts Mines, but every game takes the same prop shape.
1 Import the Bundle
Use a dynamic import() pointed at the runtime URL. The browser caches the module after the first load — call this once at startup.
<script type="module">
const wasabiUrl = `${location.origin}/wasabi/wasabi-games.es.js`;
const manifestUrl = `${location.origin}/wasabi/manifest.json`;
const { loadGame, preloadGame } = await import(wasabiUrl);
</script>
/* @vite-ignore */ inside the import() call to prevent Vite from trying to statically resolve an external runtime URL:await import(/* @vite-ignore */ wasabiUrl)
2 Prepare the Container
The game fills its container 100%. Give it a definite height — games that use height: 100% internally will collapse to zero against an auto-height parent.
<div
id="game-container"
style="width: 100%; height: 700px; min-height: 600px;"
></div>
3 Mount a Game
loadGame() injects CSS then mounts the game component into the target element. It returns a { destroy } handle.
const container = document.getElementById('game-container');
const instance = await loadGame(
container,
'mines', // game id — see Game IDs section below
{
gameName: 'mines',
balance: 1000,
currency: 101, // numeric currency code — 101 = BTC (see Currency Codes)
maxBetAmount: 500,
minBetAmount: 0.10,
user: { id: 'user-123', username: 'alice' },
config: {
gamesBackendUrl: 'https://api.betterplay.io/games/api/mines',
cdnUrl: 'https://cdn.betterplay.io',
apiTimeout: 10000,
bettingTimeMs: 10000,
},
callbacks: {
onBet: (e) => console.log('bet placed', e),
onRoundFinished: (e) => console.log('round done', e),
onServerError: (e) => console.error('error', e),
// Intercept outgoing HTTP before a bet — must return requestConfig
onBeforeBet: (betRequest, requestConfig) => {
requestConfig.headers['Authorization'] = `Bearer ${myToken}`;
return requestConfig;
},
}
},
{ manifestUrl } // tells the library where to find CSS chunks
);
// Unmount and clean up when done
instance.destroy();
gamesBackendUrl carry the session cookie set by the hosted launch and any header you add. If you are not using the launch redirect, inject your session token via onBeforeBet / onBeforeEvent (shown above) so each game call is authenticated.
· Shortcut: mountOperatorGame()
The same bundle ships an operator helper that wraps the call above. Pass a flat session instead of hand-building user / config / the auth interceptor: mountOperatorGame() loads the CSS, wires your sessionToken as a Bearer header onto every game call, derives gamesBackendUrl as backendUrl/<game>, clears any previously-mounted instance, and returns the same { destroy } handle.
const { mountOperatorGame } = await import(wasabiUrl);
const instance = await mountOperatorGame(container, {
game: 'mines',
backendUrl: 'https://api.betterplay.io/games/api', // library appends /mines
cdnUrl: 'https://cdn.betterplay.io',
session: {
player: 'user-123',
currency: 101, // numeric currency code — 101 = BTC
balance: 1000,
minBet: 0.10,
maxBet: 500,
sessionToken: myToken, // sent as Authorization: Bearer on every game call
},
library: { bundle: wasabiUrl, manifest: manifestUrl },
callbacks: {
onBet: (e) => console.log('bet placed', e),
onRoundFinished: (e) => console.log('round done', e),
onServerError: (e) => console.error('error', e),
},
});
// Unmount and clean up when done
instance.destroy();
mountOperatorGame() for the common case — it removes the auth-interceptor and prop-assembly boilerplate. Drop to loadGame() when you need full control over the raw prop shape. Both return the same { destroy } handle, and any callbacks you supply (including your own onBeforeBet / onBeforeEvent) override the auto-wired ones.
· Operator Connect Helpers
The bundle exports the rest of the connect flow as composable primitives, so you don't re-implement the launch round-trip yourself. All are tree-shakable named exports of wasabi-games.es.js.
createAuthInterceptor(token)— builds theonBeforeBet/onBeforeEventBearer interceptor (a no-op when no token is given, so the session cookie is used instead).buildLaunchUrl(opts)/redirectToLaunch(opts)— assemble (or assemble & navigate to)GET /launch?token=…&game=…with a token your backend minted.return_urldefaults to the current page withconnected=1.detectLaunchLanding()— call on the page the launch redirect returns to; reports{ isLanding, params }so you know whether to auto-mount.connectOperator(container, opts)— the one-call orchestrator: detects a launch landing (or honorsdemoMode) and mounts, returning{ instance, landing, launch(token), mount(), destroy() }.loadLibraryBundle(url)— cached dynamicimport()of the bundle, if you prefer a helper over the raw call.
redirectToLaunch() / connectOperator().launch().
· Configs
The game catalog, the props every game takes, per-game extras, lifecycle callbacks, and the backend config object — everything you pass to loadGame().
· Game IDs
Pass the ID as the second argument to loadGame() and as the gameName prop. These twelve ship in the standard browser bundle.
blackjack-mp, append the game ID to the base URL:gamesBackendUrl: `https://api.betterplay.io/games/api/${gameName}`For
blackjack-mp, use the bare base URL with no suffix — the service appends its own path internally.
game_id field and enabled_games (Part 1), use the backend catalog strings. They match the client ids above except: the client blackjack-mp is multiplayer_blackjack on the backend. The backend also serves additional ids not in the standard bundle — roulette, russian_roulette, dice_fight, dice_bank.
· Currency Codes
The browser library's currency prop is a numeric code; the wallet API (Part 1) and operator config use the string symbol. These are the codes the bundled games can render (the client's currencyCodeMap) — map between code and symbol with this table.
| Code | Symbol | Code | Symbol |
|---|---|---|---|
100 | USDT | 107 | ADA |
101 | BTC | 108 | MATIC |
102 | ETH | 109 | BT |
103 | USDC | 110 | WT — Wasabi token |
104 | BNB | 111 | LTC |
105 | DOGE | 112 | TRON |
106 | SOL | 113 | XRP |
currency code to its icon/asset through the client currencyCodeMap. The backend CurrencyCode enum also defines DAI (114) and fiat USD / JPY / EUR (1112–1114), but the standard bundle does not map those — passing one to a game throws at mount (Cannot read properties of undefined).
BTC (101) only; whitelabel operators enable their currencies via operator_configs.currencies plus the backend currency list. WT (110) is the Wasabi own-brand token used by the hosted showcase. Sending a code that isn't enabled is rejected at bet time with CURRENCY_NOT_SUPPORTED.
· Default Props
The props every game accepts — passed in the third argument to loadGame(). The seven marked Required must be present; mounting throws without minBetAmount / maxBetAmount. Game-specific extras are in Per-Game Props.
| Prop | Type | Required | Description |
|---|---|---|---|
gameName |
string |
Yes | Game identifier string — same value as the second argument to loadGame(). |
balance |
number |
Yes | Current user balance displayed in the UI. |
currency |
number |
Yes | Numeric currency code — e.g. 101 = BTC, 100 = USDT (see Currency Codes). The wallet API (Part 1) uses the string symbol instead. |
maxBetAmount |
number |
Yes | Maximum bet the UI will allow. |
minBetAmount |
number |
Yes | Minimum bet the UI will allow. |
user |
{ id, username } |
Yes | Authenticated user object. Pass an empty object {} for anonymous / demo. |
config |
object |
Yes | Backend URLs and feature flags. See config object. |
callbacks |
object |
Yes | Lifecycle hooks. See Callbacks. |
demoMode |
boolean |
No | When true, disables real bets and uses mock responses. Default false. (Ignored by crash — see Per-Game Props.) |
houseEdge |
number |
No | Override the displayed house-edge fraction. Default 0.99. |
rushGameId |
number |
No | Rush prize-pool round id — a Wasabi-only feature. Leave unset for standard play. |
windowId |
string |
No | Unique id for this game window. Tags wallet / API calls so several open games can be told apart. |
realTimeProps |
object |
No | Advanced — pre-seed live session state (userId, username, balance, isAuthenticated, …). Usually left unset. |
· Per-Game Props
Two games take extra props on top of the Default Props above. Every other game — mines, plinko, keno, wheel, limbo, dice, hilo, blackjack, coinflip, baccarat — uses the default props only.
Crash crash
| Prop | Type | Required | Description |
|---|---|---|---|
websocketUrl |
string |
No | Crash live-feed WebSocket URL. May instead be supplied as config.crashWebsocketUrl (see Crash Game). |
bettingTimeMs |
number |
No | Betting-window length in ms. Default 6800. Must match the server-side window. Also accepted as config.bettingTimeMs. |
demoMode prop is ignored by Crash — it always runs against the live round feed.
Multiplayer Blackjack blackjack-mp
| Prop | Type | Required | Description |
|---|---|---|---|
onModuleReady |
function |
Yes* | Receives the game module once initialised: (module) => …. Use it to inject auth and join a table — the game never auto-joins, so it's effectively required (see Multiplayer Blackjack). |
showPanel |
boolean |
No | Show the betting panel. Default true. |
tableBgUrl |
string |
No | Custom table background image URL. |
mbjOptions |
object |
No | Advanced client tuning (fields below). The HTTP / WS base URLs are derived from config.gamesBackendUrl and cannot be overridden here. |
{
timeoutMs: 30000, // request timeout (default 30000)
heartbeatMs: 25000, // WS heartbeat interval (default 25000)
debug: false, // verbose client logging
reconnect: { maxAttempts: 10, baseDelayMs: 1000, maxDelayMs: 30000 },
onUnauthenticated: () => {} // called when the WS session is rejected
}
Yes* Not enforced by the type system, but the table won't load until you call module.service.joinTable() from onModuleReady.
· Callbacks
Passed inside the callbacks prop. onBet and onServerError are required; all others are optional.
| Callback | Required | Description |
|---|---|---|
onBet(event) |
Yes | Fires immediately after a bet is submitted. event contains gameName, request, response, timestamp. |
onServerError(error) |
Yes | Fires on any server or network error. error contains type, message, code, timestamp. |
onRoundFinished(event) |
No | Fires when a round fully resolves. Extends onBet event with finishedTimestamp, durationMs, and result (multiplier, payout, etc.). |
onBeforeBet(betRequest, requestConfig) |
No | Interceptor called before the bet HTTP request fires. Mutate requestConfig (headers, params, etc.) and return it. Can be async. |
onBeforeEvent(eventRequest, requestConfig) |
No | Same as onBeforeBet but for in-round events (hit/stand in Blackjack, cashout in Crash, etc.). |
onShareBet(event) |
No | Fires when the user taps the share button. event contains betId, gameName, and a pre-rendered preview object. |
· config Object
Passed as the config prop. Controls backend endpoints, timeouts, and visual theme.
| Field | Type | Required | Description |
|---|---|---|---|
gamesBackendUrl |
string |
Yes | Game backend REST base URL. Append the game ID as a path suffix for all games except blackjack-mp. |
cdnUrl |
string |
Yes | CDN base URL for dynamic runtime assets (avatars, overlays). |
apiTimeout |
number |
Yes | HTTP request timeout in milliseconds. |
bettingTimeMs |
number |
Yes | Duration of the betting window in milliseconds. |
crashWebsocketUrl |
string |
No | WebSocket URL for the Crash game. Required only when mounting Crash. |
walletWebsocketUrl |
string |
No | WebSocket URL for real-time wallet balance updates. |
theme |
'default' | 'b' | 'c' |
No | Color theme. Must match the data-theme attribute set on the document root. See Theming. |
platformOriginUrl |
string |
No | Your platform's origin URL, used for postMessage communication. |
enableDebug |
boolean |
No | Enables verbose debug logging to the browser console. |
· Preloading
Call preloadGame() right after importing the bundle to warm the browser cache. The JS chunks for each game load in the background so the first click feels instant.
const { loadGame, preloadGame } = await import(wasabiUrl);
// Fire and forget — no need to await
['mines', 'plinko', 'keno', 'crash', 'wheel'].forEach(preloadGame);
· Theming
Set data-theme on <html> (or any ancestor of the container) for app-wide theming. Pass theme in config only to pin a single game to its own theme — see the note below.
| data-theme value | config.theme | Visual |
|---|---|---|
| (attribute not set) | 'default' |
Lime / olive — default green palette |
"b" |
'b' |
Gold / dark luxury |
"c" |
'c' |
Vivid lime / dark slate |
// App-wide (recommended): set data-theme only. The game — and its
// modals/toasts — inherit it and re-theme live. Don't also pass config.theme.
document.documentElement.dataset.theme = 'b';
// Per-game: pin ONE game to its own theme (then don't also flip <html>)
config: { theme: 'b', ... }
config.theme sets data-theme on the game's own wrapper, which overrides any <html data-theme> on its subtree. For live / app-wide theming, set <html data-theme> only and omit config.theme — otherwise the game stays pinned to its mount-time theme while portal'd UI (modals, toasts) follows <html>, and the two visibly diverge. Reach for config.theme only to lock a single game to a theme different from the host page.
· Applying a Skin
A skin is a flat map of CSS custom-property overrides produced by the Theme Editor and exported as game-theme-params.json. Pass the general object as the skin prop to replace colors, icons, fonts, and brand assets (including the footer logo) without rebuilding the bundle. Fetch it at runtime so each tenant gets its own skin without a redeploy.
const theme = await fetch(`/themes/${tenant}/dice.json`)
.then((r) => r.json())
.catch(() => ({ general: {} })); // fallback to no overrides on fetch failure
await loadGame(el, 'dice', { ...session, skin: theme.general }, { manifestUrl });
// Swap the skin live (e.g. tenant switcher) — no remount needed
handle.setSkin(theme.general);
{ "general": { "--token": "value", … } }. Pass json.general directly as skin — no transformation needed. Token values are CSS strings: hex colors, px sizes, and url(data:image/…) data URIs for uploaded assets (icons, brand logo).
· Crash Game
Crash requires a WebSocket URL and a betting window duration in addition to the standard props.
await loadGame(container, 'crash', {
gameName: 'crash',
balance: 1000,
currency: 101, // 101 = BTC
maxBetAmount: 500,
minBetAmount: 0.10,
user: { id: 'user-123' },
config: {
gamesBackendUrl: 'https://api.betterplay.io/games/api/crash',
crashWebsocketUrl: 'wss://api.betterplay.io/games/api/crash/ws',
bettingTimeMs: 6800, // must match server-side betting window
cdnUrl: 'https://cdn.betterplay.io',
apiTimeout: 10000,
},
callbacks: { /* ... */ }
}, { manifestUrl });
· Multiplayer Blackjack
After mounting, the game exposes its internal module via onModuleReady. You must call module.service.joinTable(tableId) — the game never auto-joins a table.
await loadGame(container, 'blackjack-mp', {
gameName: 'blackjack-mp',
balance: 1000,
currency: 101, // 101 = BTC
maxBetAmount: 500,
minBetAmount: 0.10,
user: { id: 'user-123', username: 'alice' },
config: {
gamesBackendUrl: 'https://api.betterplay.io/games/api', // no game suffix!
cdnUrl: 'https://cdn.betterplay.io',
apiTimeout: 10000,
bettingTimeMs: 10000,
},
// Called once the game module is initialised
onModuleReady: (module) => {
// Optional: inject auth header into the module's HTTP client
module.http.interceptors.request.use((cfg) => ({
...cfg,
headers: { ...cfg.headers, Authorization: `Bearer ${myToken}` }
}));
// Join the first available table
(async () => {
const tables = await module.tables.listTables();
if (tables.length > 0) {
await module.service.joinTable(tables[0].id);
}
})();
},
callbacks: { /* ... */ }
}, { manifestUrl });
· Full Minimal Example
A complete standalone HTML page that mounts a game on load.
<!DOCTYPE html>
<html lang="en" data-theme="b">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Casino</title>
</head>
<body>
<div id="game" style="width:100%; height:700px;"></div>
<script type="module">
const origin = location.origin;
const manifestUrl = `${origin}/wasabi/manifest.json`;
const { loadGame, preloadGame } =
await import(`${origin}/wasabi/wasabi-games.es.js`);
// Warm cache for other games while the user is on the page
['plinko', 'keno', 'crash'].forEach(preloadGame);
const instance = await loadGame(
document.getElementById('game'),
'mines',
{
gameName: 'mines',
balance: 1000,
currency: 101, // 101 = BTC
maxBetAmount: 500,
minBetAmount: 0.10,
user: { id: 'user-123', username: 'alice' },
config: {
gamesBackendUrl: 'https://api.betterplay.io/games/api/mines',
cdnUrl: 'https://cdn.betterplay.io',
apiTimeout: 10000,
bettingTimeMs: 10000,
theme: 'b',
},
callbacks: {
onBet: (e) => console.log('bet', e),
onRoundFinished: (e) => console.log('finished', e),
onServerError: (e) => console.error('error', e),
onBeforeBet: (_req, cfg) => {
cfg.headers['Authorization'] = 'Bearer <token>';
return cfg;
},
},
},
{ manifestUrl }
);
// instance.destroy() to unmount
</script>
</body>
</html>
games-as-lib-examples package ships complete React and Vue host apps that mount games through mountOperatorGame() and handle the launch round-trip — the same flow shown above, in a real framework. Both share one framework-agnostic glue file (src/lib/gamesLib.js) that calls the connect helpers directly; start there to copy a working integration.