@energy8platform/stake-bridge
v0.2.0
Published
Drop-in host-side wrapper that lets a game built against @energy8platform/game-sdk run on Stake Engine
Maintainers
Readme
@energy8platform/stake-bridge
Drop-in host-side wrapper that lets a game written against @energy8platform/game-sdk run on Stake Engine without modification.
Why
Stake's RGS returns the whole round (base + free spins + bonuses + multipliers) in a single /wallet/play response — pre-generated by the math SDK as a "book". Our game-sdk protocol streams events one PLAY_REQUEST at a time. StakeBridge bridges this gap so game code stays the same on both platforms.
Architecture
StakeBridge runs in-process with the game (no extra iframe layer): the game's own Vite/webpack build imports StakeBridge, the per-game adapter, and CasinoGameSDK together. They communicate through the in-memory channel the SDK already uses for devMode — there's no postMessage on Stake.
┌────────── Game bundle (single Vite build) ─────────────────────────────┐
│ │
│ main.ts: │
│ ├─ if URL has Stake params: │
│ │ new StakeBridge({ devMode: true, adapter, modeMap, gameId, … }) │
│ │ const sdk = new CasinoGameSDK({ devMode: true }) │
│ └─ else (Energy8 iframe): │
│ const sdk = new CasinoGameSDK() │
│ │
│ StakeBridge ←→ MemoryChannel ←→ CasinoGameSDK │
│ │ │
│ └─ fetch ──→ Stake RGS (/wallet/authenticate /play /balance │
│ /end-round, /bet/event) │
└─────────────────────────────────────────────────────────────────────────┘The game's runtime code is identical regardless of platform — it always talks to CasinoGameSDK. Only the host wiring differs.
What the bridge owns vs. what the adapter owns
| StakeBridge (this package) | BookAdapter (per game) |
|---|---|
| URL parsing (live: sessionID/rgs_url/lang/device; replay: replay/game/version/mode/event/amount/currency) | splitRound(book, ctx) → BookSegment[] |
| Authenticate / Play / EndRound / Event / Balance calls | resumeFrom(book, lastEvent, ctx) (optional) |
| Replay: GET /bet/replay/{game}/{version}/{mode}/{event} + book caching for "Play Again" | enrichConfig(config) (optional) |
| Money conversion (decimal ↔ minor units × 1 000 000) | |
| Segment cursor + creditPending lifecycle | |
| Bet validation against authenticate config | |
| Idle balance polling (skipped in replay) | |
| Auto /wallet/end-round before final segment of non-zero-payout rounds | |
| Mid-round recovery via /bet/event markers | |
| Retry with exponential backoff on idempotent endpoints (authenticate, balance, end-round, event, replay) — never on /wallet/play | |
| connection:lost / :restored events propagated through SDK | |
| Currency metadata (config.currency), social-mode flag, autoplay recommendations, disclaimer lines, jurisdiction flags surfaced via INIT.config | |
The adapter is the only game-specific piece.
Installation
npm install @energy8platform/game-sdk @energy8platform/stake-bridgeQuick start (Vite-built game)
// src/main.ts — single entry point for both Energy8 and Stake builds
import { CasinoGameSDK } from '@energy8platform/game-sdk';
import { runGame } from './game';
const params = new URLSearchParams(location.search);
const isStake = params.has('sessionID') && params.has('rgs_url');
if (isStake) {
const [{ StakeBridge }, { default: adapter }] = await Promise.all([
import('@energy8platform/stake-bridge'),
import('./stake-adapter'),
]);
new StakeBridge({
devMode: true,
adapter,
modeMap: { spin: 'BASE', buy_bonus: 'BONUS' },
gameId: 'sweet-bonanza',
});
}
const sdk = new CasinoGameSDK({ devMode: isStake });
await sdk.ready();
runGame(sdk);Vite splits the Stake import into its own chunk automatically — Energy8 builds don't ship the bridge code.
Writing a BookAdapter
// src/stake-adapter.ts
import type { BookAdapter, BookSegment } from '@energy8platform/stake-bridge';
interface MyBook {
events: Array<
| { type: 'reveal'; matrix: number[][]; win: number }
| { type: 'free_spin'; matrix: number[][]; win: number; multiplier?: number }
| { type: 'pick'; choices: string[] }
>;
}
const adapter: BookAdapter<MyBook> = {
splitRound(book, _ctx): BookSegment[] {
return book.events.map((evt, i, arr) => {
const isLast = i === arr.length - 1;
const next = !isLast ? arr[i + 1].type : null;
return {
action: evt.type === 'reveal' ? 'spin' : evt.type,
data: evt,
winThisSegment: 'win' in evt ? evt.win : 0,
nextActions: isLast
? ['spin', 'buy_bonus']
: [next === 'free_spin' ? 'free_spin' : next!],
progressMarker: `evt-${i}`,
};
});
},
// Optional: where to resume after a reconnect.
resumeFrom(book, lastEvent) {
if (!lastEvent) return 0;
const idx = book.events.findIndex((_, i) => `evt-${i}` === lastEvent);
return idx >= 0 ? idx + 1 : 0;
},
};
export default adapter;The bridge calls splitRound once per round and synthesises roundId, currency, gameId, balanceAfter, creditPending, totalWin, and the session object from segment positions.
new StakeBridge(options)
| Option | Type | Default | Description |
|---|---|---|---|
| devMode | boolean | false | Run in-process via MemoryChannel (recommended for Stake). When true, iframe is not required. |
| iframe | HTMLIFrameElement | — | The game iframe (only when devMode is false). |
| adapter | BookAdapter \| factory | (required) | The per-game adapter. Pass it directly via your bundler's import. |
| adapterUrl | string | — | Escape hatch — dynamic-import the adapter from a URL. Use only if you can't bundle it. |
| modeMap | { [action]: stakeMode, default? } | {} | Map our action names → Stake mode. Falls back to uppercased action. |
| gameId | string | '' | Game identifier surfaced on PlayResult.gameId. |
| assetsUrl | string | iframe.src (iframe mode) / '' (devMode) | Base URL surfaced to the game in INIT.assetsUrl. |
| url | string \| URL \| Location | window.location.href | Source for Stake URL params. |
| protocol | 'http' \| 'https' | 'https' | RGS protocol. |
| enforceBetLevels | boolean | true | Reject bets that aren't in betLevels. |
| targetOrigin | string | '*' | Forwarded to the underlying postMessage Bridge. Ignored when devMode is true. |
| balancePollMs | number | 60000 | Idle balance polling cadence. 0 disables. |
| debug | boolean | false | Verbose logging. |
Exports
import {
StakeBridge,
RGSClient, RGSError, API_MULTIPLIER, parseStakeUrl,
loadAdapter, defaultAdapterUrl, resolveAdapter,
} from '@energy8platform/stake-bridge';
import type {
StakeBridgeOptions, BookAdapter, BookSegment, RoundContext,
StakeRound, StakeUrlParams,
AdapterModule, AdapterFactoryOptions, ModeMap,
} from '@energy8platform/stake-bridge';Replay mode
Stake launches a historical round via a different URL pattern:
?replay=true&game=...&version=...&mode=...&event=...&rgs_url=...¤cy=...&amount=...StakeBridge auto-detects this and switches modes — same new StakeBridge(...) call covers both paths:
const params = new URLSearchParams(location.search);
const isStakeOrReplay =
(params.has('sessionID') && params.has('rgs_url')) ||
params.has('replay');
if (isStakeOrReplay) {
const [{ StakeBridge }, { default: adapter }] = await Promise.all([
import('@energy8platform/stake-bridge'),
import('./stake-adapter'),
]);
new StakeBridge({ devMode: true, adapter, modeMap, gameId, debug });
}In replay mode the bridge:
- skips
/wallet/authenticate(synthetic config:balance = 0,currencyandbetLevels = [amount]from URL) - on the first
play()callsGET /bet/replay/{game}/{version}/{mode}/{event}and caches the book — every subsequent "Play Again" replays the same book without another network round-trip - runs the cached book through your
BookAdapter.splitRoundexactly like a live round, so the game animates identically - never calls
/wallet/end-round,/bet/event, or balance polling - sets
INIT.config.replayMode = trueso the game can hide the balance, bet selector, autoplay and buy-bonus controls and surface a "Play / Play Again" CTA only
The game's only replay-specific code is reading config.replayMode and adjusting its UI.
Connection state events
Idempotent RGS calls (authenticate, balance, end-round, event, replay) auto-retry on network errors and 5xx with exponential backoff (default: 3 attempts, 200/600/1800 ms ±25% jitter). When the first attempt fails the bridge emits 'lost'; when a retry succeeds it emits 'restored'. Games subscribe via the SDK:
sdk.on('connectionStateChanged', ({ status, code }) => {
if (status === 'lost') showReconnectOverlay(code);
if (status === 'restored') hideReconnectOverlay();
});⚠️
/wallet/playis never retried. It is not idempotent — a timed-out request may have been processed server-side, so a blind retry could double-bill the player. On a play timeout the bridge surfaces anRGSErrorto the game; the recommended recovery is to wait for'restored', then re-callAuthenticateto discover whether the round actually started, and usegetState()to resume.
Currency, social, autoplay, disclaimer
These all flow through INIT.config so the game reads them once at boot. None of them are required — fields are simply absent on platforms that don't supply them.
| Field | Source | Notes |
|---|---|---|
| config.currency | Authenticate.balance.currency looked up against the bridge's currency table | Includes code, symbol, decimals, symbolAfter. Use formatAmount(value, meta) for display. |
| config.autoplay | Derived from jurisdiction.disabledAutoplay | undefined when autoplay is disabled or in replay mode; otherwise { maxCount, requiredStops }. Recommendations only — no hard enforcement. |
| config.socialMode | URL ?social=true ∨ jurisdiction.socialCasino | Game must wrap user-visible text via applySocialReplacements(...) exported from this package. |
| config.replayMode | URL ?replay=true | Tell the UI to render replay layout. |
| config.disclaimerLines | buildDisclaimer(...) from this package | Canonical Stake template (6 lines, current calendar year on the copyright). |
| config.demo | URL ?demo=true | Free-play / demo session. |
| config.jurisdiction | Authenticate.config.jurisdiction | Raw flags (disabledTurbo, displayRTP, minimumRoundDuration, …). |
import { formatAmount } from '@energy8platform/stake-bridge';
const { config } = await sdk.ready();
display.textContent = formatAmount(123.45, config.currency!);
// → '$123.45' for USD, '€123,45' for EUR (locale-dependent)Disclaimer
config.disclaimerLines is filled by default with Stake's official template — six sentences plus a copyright line — so games don't need to ship their own copy.
const { config } = await sdk.ready();
infoScreen.append(...config.disclaimerLines!.map((line) => p(line)));
// → Malfunction voids all wins and plays.
// A consistent internet connection is required. In the event of …
// The expected return is calculated over many plays.
// The game display is not representative of any physical device …
// Winnings are settled according to the amount received from the …
// TM and © 2026 Stake Engine.To override (e.g. localised wording or extra clauses), pass your own to buildDisclaimer({ override }) before constructing the bridge, or replace the lines in enrichConfig. The seven approval points are documented in src/disclaimer.ts.
Social mode
@energy8platform/stake-bridge ships Stake's full social-mode dictionary — 36 canonical replacements (bet→play, bonus buy→bonus / feature, …). When config.socialMode === true, run user-visible strings through applySocialReplacements:
import { applySocialReplacements } from '@energy8platform/stake-bridge';
const { config } = await sdk.ready();
const wrap = config.socialMode ? applySocialReplacements : (s: string) => s;
button.textContent = wrap('Place your bets');
// social=true → 'Come and play / join in the game'
// social=false → 'Place your bets'The helper sorts rules by length descending automatically, so 'bonus buy' resolves before 'buy' and 'pays out' before 'pays'. Casing is preserved (BET → PLAY, Bet → Play).
Demo mode
config.demo === true when the URL carries ?demo=true. Real balance is not affected. Games typically render a "DEMO" banner and may use a pre-set demo balance.
Stake's RGS — at a glance
Five POST endpoints, all driven by the URL parameters Stake passes to the iframe (sessionID, rgs_url, lang, device):
| Endpoint | Purpose |
|---|---|
| /wallet/authenticate | Start session → balance, bet config, jurisdiction flags, optional in-flight round. |
| /wallet/balance | Refresh balance (the bridge polls every 60 s while idle). |
| /wallet/play | Start a new round → balance + round{betID, payoutMultiplier, costMultiplier, state, active, mode}. state is the math-SDK book. |
| /wallet/end-round | Finalise a non-zero-payout round. Auto-skipped for zero-payout rounds (RGS auto-completes those). |
| /bet/event | Track in-progress markers for state recovery. |
Money is in integer minor units × 1_000_000. The bridge handles all conversion.
License
MIT
