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.

directory
# 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).

html
<div id="game" style="width:100%; height:720px;"></div>

3 Import & mount

One dynamic import(), one call. This is a complete standalone page:

html
<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>
That's the whole integration. With 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).
Serve the bundle same-origin as your page. Cross-origin ES-module 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

ResponsibilityOwner
Game logic, RNG, round state, fairness, session cookiesBetterPlay
Signed launch token + redirecting the player to start a sessionOperator
Wallet service holding real balances (/wallet/* endpoints we call)Operator
The game UI in the browser — hosted by us, or embedded by youEither

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-level loadGame()), 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)

sequence
  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   │                        │                         │
    │◀──────────────────────────────────────────────│                        │
Currency is represented differently on each layer. The wallet API (Part 1) uses ISO-style string codes — "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.
Part 1 · Server Integration

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.

FieldRequiredDescription
codeYesUnique slug, e.g. acme-casino. Becomes the JWT iss and the operator_id in every wallet envelope.
nameYesHuman-readable display name.
jwt_algYesHS256 (shared secret) or RS256 (you hold the private key, we hold the public key).
jwt_shared_secretif HS256The secret we use to verify your launch tokens.
jwt_public_keyif RS256PEM-encoded RSA public key — PUBLIC KEY (PKIX) or RSA PUBLIC KEY (PKCS#1).
wallet_base_urlYesBase URL of your wallet service, e.g. https://wallet.acme.com. A path prefix is allowed (see HMAC note in §3).
wallet_hmac_secretYesShared secret we use to HMAC-sign requests to your wallet.
wallet_timeout_msNoPer-call timeout. Defaults to 5000.
game_frontend_base_urlYesWhere 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_secretNoReserved for outbound webhooks — not yet active.
You get back Your 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.

jwt payload
{
  "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
}
Token rules we enforce — get these wrong and launch fails with INVALID_TOKEN
  • Lifetime exp − iat must be ≤ 10 minutes, and exp > iat.
  • iat must be within ±10 minutes of our server clock — keep your clock NTP-synced.
  • The token alg must match your registered algorithm exactly (no HS256↔RS256 mixing).
  • iss must equal your code; jti is single-use (replays are rejected).

Node.js example — mint the token (and build the redirect) with jsonwebtoken. This runs server-side only.

node.js
// 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)));
Never mint in the browser. Minting needs your HS256 secret (or RS256 private key) — if it reaches client code, anyone can forge a session for any player. Keep mintLaunchToken on your server and hand the browser only the finished redirect URL.

Step 2 — Redirect the browser

redirect
GET https://games.betterplay.example/launch
      ?token=<signed-jwt>
      &game=mines              // game id — see Game IDs (§ Part 2)
      &lang=en                 // optional
      &currency=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

  1. Verify the JWT signature, lifetime, skew, and single-use jti.
  2. Call your POST /wallet/auth to hydrate balance, currency, and RG limits.
  3. Mint our internal session JWT and set it as HTTP-only cookies (token, refresh_token).
  4. 302 the player to game_frontend_base_url?game=…. From here the game UI runs on the session cookie.
Launch failures are returned, not redirected. On error we respond with a JSON error envelope (see §4) so your integration sees the reason: 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:

HeaderDescription
X-TimestampRFC3339 millis UTC, e.g. 2024-01-15T10:30:00.000Z. This is the value you sign over. Reject if skew > ~30s.
X-Idempotency-KeyUUID. 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-SignatureHex HMAC-SHA256 over X-Timestamp + "POST" + path + raw_body.

Verify the signature by recomputing it and comparing in constant time:

signature scheme
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)
Two things that trip up implementers
  • <request path> is the full path of the received request, including any path component of your wallet_base_url. If your base URL is https://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 timestamp field is informational — always sign over the X-Timestamp header.

Worked example (verify your implementation against this)

hmac-sha256
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.

json
{
  "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.

json — success / error
// success
{ "status": "ok", "request_id": "550e8400-…", "data": { /* … */ } }

// error
{ "status": "error", "request_id": "550e8400-…",
  "error": { "code": "INSUFFICIENT_FUNDS", "message": "balance below requested bet" } }
POST /wallet/auth Required

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.

response data
{
  "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
  }
}
POST /wallet/balance Required

Read-only balance query (no money movement). data is null.

response data
{ "balance": "1490.00" }
POST /wallet/bet Required

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 → response data
// request.data
{ "transaction_id": "a3f4e812-…", "round_id": "b7c2d099-…",
  "game_id": "mines", "amount": "10.00" }

// response.data
{ "balance": "1480.00", "transaction_id": "a3f4e812-…" }
POST /wallet/win Required

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 → response data
// 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-…" }
POST /wallet/rollback Required

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 → response data
// request.data
{ "transaction_id": "d1a8b234-…", "original_transaction_id": "a3f4e812-…" }

// response.data
{ "balance": "1490.00", "transaction_id": "d1a8b234-…" }
POST /wallet/bet-win Required

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 → response data
// 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-…" }
Which endpoints does a game use? Instant-resolve games (single-request rounds) use /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.

CodeHTTPWhen to return it
INVALID_SIGNATURE401HMAC verification failed.
INVALID_TOKEN401Session / launch token invalid or expired.
PLAYER_NOT_FOUND404Unknown player id.
INSUFFICIENT_FUNDS402Balance below the requested bet.
BET_LIMIT_EXCEEDED422Bet above a per-player or per-game limit.
CURRENCY_NOT_SUPPORTED422Currency not enabled for this player.
DUPLICATE_TRANSACTION409Idempotency key reused with a different body.
TRANSACTION_NOT_FOUND404Rollback target does not exist.
PLAYER_FROZEN403Account frozen by responsible-gambling / KYC.
GAME_DISABLED403Game disabled for this operator.
INVALID_INPUT400Malformed request.
RATE_LIMITED429Throttled.
WALLET_UNAVAILABLE503Wallet temporarily down.
INTERNAL_ERROR500Catch-all. Unknown codes are treated as this.
Retry behaviour We retry wallet calls up to 3 times (exponential backoff, capped 2s) only on transport errors and 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.

operator config
{
  "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)
}
Defaults if you send nothing. All games enabled, no bet limits, every feature flag on. Note there are two responsible-gambling layers: the static rg_reality_check_minutes here, and the per-session rg_limits returned by /wallet/auth (§3).
CORS allowed origins. If you embed games in your own page (Part 2), the browser sends your bets cross-site to games-backend, so your page's origin must be on our CORS allowlist or the calls are blocked. The origin of your 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.

FeatureStatus
Outbound webhooks
webhook_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.)
Part 2 · Client Integration

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/.

directory
# 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
Everything is injected automatically. 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.

html
<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>
Using Vite as your host bundler? Add /* @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.

html
<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.

javascript
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();
How the embedded UI authenticates. Requests to 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.

javascript
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();
When to use which. Reach for 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 the onBeforeBet / onBeforeEvent Bearer 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_url defaults to the current page with connected=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 honors demoMode) and mounts, returning { instance, landing, launch(token), mount(), destroy() }.
  • loadLibraryBundle(url) — cached dynamic import() of the bundle, if you prefer a helper over the raw call.
No token minting in the browser. The library deliberately ships no JWT-minting helper. Minting a launch token requires your operator secret, which must never reach client code — mint it server-side (see §2 Launch & Auth) and hand the result to 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.

minesMines
plinkoPlinko
kenoKeno
wheelWheel
crashCrash
limboLimbo
blackjackBlackjack
blackjack-mpBlackjack MP
hiloHi-Lo
diceDice
coinflipCoin Flip
baccaratBaccarat
gamesBackendUrl convention. For all games except 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.
Backend game ids differ in two places. For the wallet 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.

CodeSymbolCodeSymbol
100USDT107ADA
101BTC108MATIC
102ETH109BT
103USDC110WT — Wasabi token
104BNB111LTC
105DOGE112TRON
106SOL113XRP
Only these codes (100–113) are renderable by the bundled games. Each game resolves the 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).
A currency only works for real bets if it's enabled server-side. A default games-backend enables 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

PropTypeRequiredDescription
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.
Crash has no demo mode. The demoMode prop is ignored by Crash — it always runs against the live round feed.

Multiplayer Blackjack  blackjack-mp

PropTypeRequiredDescription
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.
mbjOptions — all fields optional
{
  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.

javascript
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 valueconfig.themeVisual
(attribute not set) 'default' Lime / olive — default green palette
"b" 'b' Gold / dark luxury
"c" 'c' Vivid lime / dark slate
javascript
// 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', ... }
Pick one source of truth. 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.

javascript — fetched at runtime
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);
Shape of the JSON. The exported file is { "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.

javascript
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.

javascript
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.

html
<!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>
Runnable examples. The 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.