# Rabbit Roll game protocol v1 A game is an iframe. It never touches the ledger, never sees a seed before a round, and never decides what it gets paid. It asks, the platform answers. Every message is `{ type, v: 1, ...payload }` over `postMessage`. The platform rejects any message whose `event.origin` does not match the manifest, and any message without `v: 1`. ## Registering Add a manifest. Nothing else. ```json { "id": "my-game", "name": "My Game", "engine": "rabbit", "url": "/games/my-game/", "origin": "self", "category": "originals", "aspect": "16 / 10", "minStakeCents": 10, "maxStakeCents": 100000 } ``` `engine: "rabbit"` means the platform decides outcomes from the provably-fair engine and the game renders them. It must be same-origin, because it receives outcomes before the player sees them. `engine: "external"` means the provider's own certified RNG decides. The platform is only the wallet. Those games may be remote, over https. ## Game to platform | type | payload | when | | --- | --- | --- | | `rr:ready` | `{ gameId }` | once, when the game has booted | | `rr:bet` | `{ requestId, stakeCents, params }` | player commits a stake | | `rr:act` | `{ requestId, roundId, action }` | player acts mid-round (blackjack) | | `rr:settle` | `{ requestId, roundId, choice }` | player finishes the round | | `rr:resize` | `{ height }` | the game's natural height changed | `params` is game-specific and opaque to the platform, except that the engine validates and clamps anything it uses (mine count, plinko rows, dice target). `choice` is how the player ended it: `{ cashedAt }` for a climbing game, `{ picked: [...] }` for a board game, `{}` for an instant one. ## Platform to game | type | payload | when | | --- | --- | --- | | `rr:init` | `{ sessionId, currency, balanceCents, tier, limits, edge }` | after `rr:ready` | | `rr:balance` | `{ balanceCents }` | any time the balance moves | | `rr:round` | `{ requestId, roundId, outcome, proof }` | the bet was accepted | | `rr:state` | `{ requestId, roundId, state }` | the hand after an action | | `rr:result` | `{ requestId, multiplier, payoutCents, balanceCents }` | the round settled | | `rr:error` | `{ requestId, code, message }` | anything refused | Error codes: `insufficient_funds`, `stake_out_of_range`, `unknown_game`, `round_not_found`, `already_settled`, `disabled`. ## Games with a middle Most games are open then settle. Blackjack has a middle: the player hits, stands or doubles, and cards come off a shoe that was already fixed at `rr:bet`. The platform holds the shoe and never sends it. A frame that held the shoe would hold the dealer's hole card and every card still to come. `rr:state` returns only what the player is entitled to see: their own cards, and the dealer's up card until they stand. ```js const round = await rr.bet({ stakeCents: 100, params: {} }); let state = await rr.act(round.roundId, 'hit'); if (state.state.canHit) state = await rr.act(round.roundId, 'stand'); const result = await rr.settle(round.roundId, {}); ``` ## The order that matters 1. The stake leaves the balance **before** the outcome exists. A game that renders a win before the debit landed is a game that can be made to pay twice. 2. The outcome is fixed at `rr:round`, not at `rr:settle`. When the player stops changes what they are paid, never what happened. 3. Settling twice is a no-op that returns the first result. Retries are safe. ## Minimal game ```html ``` That is the whole integration surface. --- # Seamless wallet v1 (server side) The postMessage protocol above is for games we host. A third-party provider will not implement it: they expect the operator to implement **their** wallet API. So we implement the standard one, and they call us. Run it: `node server.mjs` ## Headers on every call | Header | Value | | --- | --- | | `x-provider` | the provider id we issued | | `x-token` | the launch token from `POST /launch` | | `x-signature` | hex HMAC-SHA256 of the exact JSON body, keyed with the shared secret | ## Endpoints | Endpoint | Body | Returns | | --- | --- | --- | | `POST /wallet/auth` | `{}` | `balanceCents`, `currency`, `playerRef`, `gameId` | | `POST /wallet/balance` | `{}` | `balanceCents` | | `POST /wallet/bet` | `{ transactionId, amountCents }` | `balanceCents`, `replayed` | | `POST /wallet/win` | `{ transactionId, amountCents }` | `balanceCents`, `replayed` | | `POST /wallet/rollback` | `{ transactionId }` | `balanceCents`, `reversed` | Errors: `INVALID_SIGNATURE` 401, `INVALID_TOKEN` 401, `TOKEN_EXPIRED` 401, `INSUFFICIENT_FUNDS` 402, `MALFORMED_REQUEST` 400, `TRANSACTION_CONFLICT` 409, `PROVIDER_DISABLED` 403. ## The behaviour that matters `transactionId` is the contract. The same id is the same money move and applies exactly once, however many times the network repeats it. 1. A replayed id returns the first result. It does not re-apply. 2. A replay with a **different amount** is refused with `TRANSACTION_CONFLICT`. That is a provider bug or an attack, never a correction. 3. A **win may arrive before its bet**. It is accepted and credited, because the provider owns round state and we are the purse, not the referee. 4. Rolling back an id we never saw **succeeds and does nothing**, so a provider retrying a rollback can always reach a settled state. 5. Rolling back twice applies once, and a rolled-back id cannot be re-applied. 6. Amounts must be integer cents as a JSON **number**. `10.5` and `"1e3"` are both refused rather than coerced: silent rounding hides an integration bug behind drift in the ledger. 7. The provider never learns the account id. It gets an opaque, expiring, revocable token scoped to one game. ## Launching a game ``` POST /launch { "accountId": "...", "providerId": "dev", "gameId": "acme-slot" } -> { "token": "...", "expiresAt": 1700000000000 } ``` Open the provider's launch URL with that token. Every wallet call they make carries it back.