Shell bridge
There's no client SDK for the operator side — implement the listener yourself against the documented message contract below.
Message contract
Every message is wrapped in a small envelope so bridge traffic can be told apart from unrelated postMessage activity on the page:
type Envelope<T> = { source: 'moose-platform-game-bridge', payload: T }Inbound (game → shell), payload is one of:
| Type | Fields |
|---|---|
GAME_LOADED | balanceMinor |
BET_START | amountMinor, balanceMinor |
BET_END | outcome: 'win' | 'loss' | 'declined' | 'rolled_back', amountMinor, balanceMinor, winAmountMinor? (present when outcome === 'win') |
BALANCE_EXHAUSTED | — |
EXIT_GAME | — |
SESSION_REVOKED | reason? |
There's no outbound (shell → game) direction today — the bridge only carries the game reporting up to your shell. balanceMinor is required on every event above that carries it, not just BET_END: GAME_LOADED lets you render the player's balance the moment the game opens, instead of showing nothing until their first bet settles.
BET_START/BET_END are a two-phase reveal, not two separate network moments — a game typically settles a whole round (including any win) in one server call, so there's no mid-round checkpoint for it to report. BET_START fires once the round's result is already known server-side and the game is about to start revealing it (e.g. playing a reel/cascade animation) — its balanceMinor is the balance immediately after this wager's own debit, but before any win is applied, so you can show the debit the moment it happens without the animation being spoiled by an early win reveal. BET_END fires once that reveal has finished, carrying the final, fully-settled balance — together you can reconcile balance_at_bet_start ± winAmountMinor = balance_at_bet_end for every round. A declined bet skips BET_START entirely (nothing was wagered) and goes straight to BET_END with outcome: 'declined'. The game client learns the balance from its own wallet call to the platform, which in turn came from your wallet callback API's response; there's no separate balance push to your shell outside these events.
SESSION_REVOKED is distinct from EXIT_GAME: the player didn't choose to leave — either you ended their session via POST /v1/operator/players/kick while the game was still open, or the platform's own risk control did so server-side, and the game found out through its own means (its next wallet call 401ing, or — if the provider operates one — its own server push channel). reason is whatever the game client chose to report, not something you supplied on the kick call — show it if present, but don't depend on a specific value.
Example
Self-contained — no package import required:
const BRIDGE_SOURCE = 'moose-platform-game-bridge'
function isBridgeEnvelope(data: unknown): data is { source: string; payload: unknown } {
return (
typeof data === 'object' &&
data !== null &&
(data as { source?: unknown }).source === BRIDGE_SOURCE
)
}
const gameOrigin = 'https://games.example' // the provider's exact origin — never "*"
const iframe = document.querySelector('iframe')!
window.addEventListener('message', (event) => {
if (event.origin !== gameOrigin) return
if (event.source !== iframe.contentWindow) return
if (!isBridgeEnvelope(event.data)) return
const gameEvent = event.data.payload as {
type: 'GAME_LOADED' | 'BET_START' | 'BET_END' | 'BALANCE_EXHAUSTED' | 'EXIT_GAME' | 'SESSION_REVOKED'
amountMinor?: number
balanceMinor?: number
winAmountMinor?: number
outcome?: 'win' | 'loss' | 'declined' | 'rolled_back'
reason?: string
}
switch (gameEvent.type) {
case 'GAME_LOADED':
// gameEvent.balanceMinor — render it now, don't wait for the first BET_END
// hide your own loading spinner
break
case 'BET_START':
// gameEvent.amountMinor, gameEvent.balanceMinor (balance after this wager's debit, before any win)
// the round's outcome is already known server-side at this point — this just marks
// "the reveal is starting" for your own UI
break
case 'BET_END':
// gameEvent.outcome, gameEvent.amountMinor, gameEvent.balanceMinor, gameEvent.winAmountMinor
// update any balance display you render outside the iframe
break
case 'BALANCE_EXHAUSTED':
// show a top-up prompt
break
case 'EXIT_GAME':
// remove/hide the iframe, show your lobby
// Only fires if you didn't pass lobbyUrl at launch — see "Returning
// to the lobby" in launching-games.md. With lobbyUrl set, the game
// navigates the top window there directly instead of sending this.
break
case 'SESSION_REVOKED':
// remove/hide the iframe, show "you were logged out" (gameEvent.reason)
break
}
})Every inbound message is checked against three things before it's trusted: the exact gameOrigin, that event.source is the iframe you're bound to, and a bridge-specific envelope marker — so unrelated postMessage traffic on the page (browser extensions, other embeds) is ignored, not mistakenly processed.