01Two integration models
The only question that changes anything: who decides the outcome. Pick per game, declared in its manifest.
| Model | Who decides the outcome | Who holds the money | Hosting |
|---|---|---|---|
| rabbit | Your provably-fair engine. The game only renders what you send. | You | Must be same-origin — the frame receives outcomes before the player sees them. |
| external | Our certified RNG, inside the game. | You. We never touch a ledger. | May be remote over HTTPS. |
In both models the platform is the trust boundary and the game frame is untrusted, including when the game is ours. The host checks every inbound message against the manifest origin, clamps stakes to the manifest range before they reach the ledger, and refuses any round the frame did not open in this session.
Registering a game is a manifest, not a code change
Drop a JSON entry and the game appears. No lobby edits, no build step. Every field
below is enforced: an id must be lowercase kebab-case, a
rabbit-engine game must be same-origin, and a declared edge
outside 0–0.10 is rejected outright.
// games.json — one entry per game
{
"id": "bank",
"name": "BANK",
"engine": "external",
"url": "games/bank/",
"origin": "self", // or an absolute https:// origin
"category": "originals",
"aspect": "9 / 16",
"minStakeCents": 10,
"maxStakeCents": 100000,
"edge": 0.04 // published house edge
}
02Quick start
Three calls cover a whole game. The SDK keeps balance, currency, tier and limits current, so you never track them yourself.
import { connect } from '/sdk/rr-sdk.js';
// resolves once the platform has sent the session
const rr = await connect('my-game');
// 1. commit a stake — resolves once the money has actually left
const round = await rr.bet({ stakeCents: 100, params: {} });
// 2. (optional) act mid-round, for games with a middle
let state = await rr.act(round.roundId, 'hit');
// 3. end the round. Safe to retry: settling twice returns the first result
const result = await rr.settle(round.roundId, {});
// result.multiplier, result.payoutCents, result.balanceCents
rr.onBalance(cents => paintBalance(cents)); // fires on every move
rr.resize(720); // tell the host our height
Mounting on the host side is a frame plus the broker. The host sets the frame's aspect ratio from the manifest, points it at the game, and brokers everything after that.
import { createHost } from './platform/host.js';
const host = createHost({
frame, // the <iframe> element
wallet, // your ledger: auth / balance / debit / credit
table, // your fair engine (rabbit-engine games only)
accountId,
serverSeed, clientSeed, sha256,
onEvent: e => log(e) // ready | bet | settle | error
});
await host.mount('bank');
03Catalogue
Six games registered today. House edge is measured, not estimated: each figure comes from a headless simulation of the finished build under optimal play, held out of sample.
| Game | Engine | House edge | RTP | Aspect | Notes |
|---|---|---|---|---|---|
| crash | rabbit | 1.0% | 99.0% | 16 / 10 | Cash-out curve. |
| plinko | external | 1.0% | 99.0% | 4 / 3 | Row count via params. |
| longbow | external | 3.6% | 96.4% | 16 / 10 | Skill duel. |
| bank | external | 4.0% | 96.0% | 9 / 16 | Portrait. Mid-round cash-out. |
| wick | external | 3.5% | 96.5% | 16 / 10 | Uniform across all 252 zones. Max win 250x. |
| paydirt | external | 4.0% | 96.0% | 16 / 10 | Gated by 23 assertions at 20,000 rounds per tier. |
Every figure here is measured against the shipped build
Not modelled, not intended: each one comes from simulating the finished game on seeds
held out of calibration. paydirt carries its own maths gate — 23 assertions
at 20,000 rounds per tier, checking the edge but also that payout parameters cannot
reach back and change which blocks broke, and that a late pick pays like the first one.
wick is uniform by construction rather than by measurement: zones are
priced at (1 − margin) / probability, and its multiplier cap sits above the fairest
quote the table can make, so no cell is quietly shaved.
04Round lifecycle
The ordering here is not stylistic. Each rule closes a way a game frame could otherwise be made to pay twice.
-
01
The stake leaves the balance before the outcome exists.
A game that renders a win before the debit landed can be made to pay twice.
rr:betresolves only after the money actually moved. -
02
The outcome is fixed at
rr:round, not atrr:settle.When the player stops changes what they are paid. It never changes what happened. For a climbing game, the curve was already decided when the bet was accepted.
-
03
Settling twice is a no-op that returns the first result.
Retries are safe by construction, so a dropped connection cannot double-credit.
-
04
The frame never sees what it is not entitled to.
In games with hidden state, the platform holds it and sends only the player's view. A frame holding the shoe would hold the dealer's hole card.
External-engine settlement
For external games the provider reports the multiplier and the platform does
the arithmetic on its own ledger — it trusts the number, never the math. A missing or
negative multiplier settles at zero.
05Message reference
Every message is { type, v: 1, ...payload }. The platform drops anything whose
event.origin does not match the manifest, and anything without v: 1.
| Type | Direction | Payload | When |
|---|---|---|---|
| rr:ready | game → host | { gameId } | Once, when the game has booted. |
| rr:bet | game → host | { requestId, stakeCents, params } | Player commits a stake. |
| rr:act | game → host | { requestId, roundId, action } | Player acts mid-round. |
| rr:settle | game → host | { requestId, roundId, choice } | Player finishes the round. |
| rr:resize | game → host | { height } | Natural height changed. |
| rr:init | host → game | { sessionId, currency, balanceCents, tier, limits, edge } | After rr:ready. |
| rr:balance | host → game | { balanceCents } | Any time the balance moves. |
| rr:round | host → game | { requestId, roundId, outcome, proof } | The bet was accepted. |
| rr:state | host → game | { requestId, roundId, state } | The hand after an action. |
| rr:result | host → game | { requestId, multiplier, payoutCents, balanceCents } | The round settled. |
| rr:error | host → game | { requestId, code, message } | Anything refused. |
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.
06Launch URL
For a remote external game the operator opens the launch URL in the frame.
Everything the session needs rides on the query string; there is no in-game wallet connect,
because the operator already knows the player.
https://games.example.com/bank/
?token=<session token>
&operator=<operator id>
¤cy=EUR // ISO 4217
&lang=es
&mode=real // real | demo — anything else is demo
&rgs=https://rgs.operator.com
| Parameter | Required | Meaning |
|---|---|---|
| token | real money | Session token. Authenticates the player against your platform. |
| operator | real money | Operator identifier, for routing and reporting. |
| currency | Optional | ISO 4217. Defaults to USD. Display only — amounts are always minor units. |
| lang | Optional | UI language. Defaults to en. |
| mode | Optional | real or demo. Any other value is treated as demo. |
| rgs | real money | Remote Game Server base URL — the wallet and outcome authority. |
Lobby bridge
A framed game also posts lifecycle events up to the operator shell, so the lobby can react without polling. Standalone, these are inert.
| Event | Payload | Use |
|---|---|---|
| balanceChanged | { cents } | Repaint the lobby balance. |
| roundStart | { roundId, stakeCents } | Session tracking, reality checks. |
| roundEnd | { roundId, winCents, multiplier, seed } | History, big-win feeds. |
| error | { code, message } | Surface a failure in the shell. |
| goToLobby | {} | Player asked to exit the game. |
| requestDeposit | {} | Player ran out of funds — open your cashier. |
07Wallet API
The half you build. Five endpoints on your side; our game server calls them and renders whatever your platform says. This is the entire server contract — there is nothing else to implement.
Direction matters
In this model you are the operator and we are the provider. Your wallet is the
authority on the player's money and we never hold a balance. If instead you want us to
host the wallet, that is the rabbit engine in section 01 and
this chapter is ours to implement, not yours.
Transport
| Detail | Value |
|---|---|
| Method | POST |
| Base URL | Yours. You give it to us at onboarding, one per environment. |
| Paths | /auth · /balance · /bet · /win · /rollback |
| Body | application/json |
| Identity header | x-operator: rabbitroll |
| Signature header | x-signature: <hex> |
| Amounts | Integer minor units. 1250 is 12.50, never 12.5. |
| Timeout we apply | 6000 ms by default, configurable per operator. |
Paths and field names are configurable per operator. If your wallet already speaks
txId and amount, say so and we remap
on our side — that is a config entry, not a change to your platform and not a fork of ours.
Signing
x-signature is HMAC-SHA256, hex, keyed with the shared secret,
over the raw request body exactly as received. Do not parse and re-encode before
verifying: key order and whitespace change the bytes, and a re-encode is the single most
common reason a correct integration fails its first signed call.
// verify — Node, and the two lines that matter
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest();
const given = Buffer.from(String(header || ''), 'hex');
// length check first: timingSafeEqual throws on a mismatch
return given.length === expected.length && timingSafeEqual(given, expected);
}
// Express: keep the raw bytes, do not let a JSON parser eat them
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
The five verbs
| Verb | We send | You answer | Meaning |
|---|---|---|---|
| auth | token, currency | playerId, balanceCents, currency | Resolve the launch token to a player. Called once when the game mounts. |
| balance | playerId, currency | balanceCents, currency | Read. No side effects, ever. |
| bet | playerId, amountCents, transactionId, roundId, currency |
balanceCents, transactionId | The stake leaves the player. Refuse with
INSUFFICIENT_FUNDS rather than going negative. |
| win | playerId, amountCents, transactionId, roundId, currency |
balanceCents, transactionId | The payout returns. A zero payout is not sent at all. |
| rollback | transactionId, roundId, currency | voided: true | Reverse a prior transaction by its id. See the four rules below. |
// a stake, on the wire
POST /bet
x-operator: rabbitroll
x-signature: 9f2c…
{ "playerId": "p_8842", "amountCents": 250, "currency": "EUR",
"transactionId": "rr_9c1f4a_bet", "roundId": "rr_9c1f4a" }
// 200 — applied
{ "ok": true, "balanceCents": 9750, "transactionId": "rr_9c1f4a_bet" }
// 402 — refused, and a refusal is a clean answer, not an error
{ "ok": false, "error": "INSUFFICIENT_FUNDS" }
The four rules that decide whether money stays correct
Everything above is shape. These four are behaviour, and every one of them exists because it is a way real integrations lose money.
-
01
The same
transactionIdis the same move, exactly once.We retry. Networks duplicate. A repeated id must return the first result and must not apply again. Answer the replay with the same
balanceCentsyou answered the first time. -
02
A replay carrying a different amount is a conflict, never a correction.
Answer
409 TRANSACTION_CONFLICT. If you instead treat it as an update you have built a way to rewrite settled money by resending an id. -
03
A
winmay arrive before itsbet.Accept it. Networks reorder, and the round state is ours, not yours. Do not hold the payout waiting for a stake you have not seen; you are the purse, not the referee.
-
04
Rollback must be safe to send twice, and safe for something you never saw.
Both answer
200. Rolling back an unknown id succeeds and does nothing; rolling back twice applies once; and after a rollback the original id must not be re-applied. This is what lets a timed-out round reach a settled state instead of retrying forever.
A timeout is not a failure
If your wallet does not answer, we do not know whether you applied it. We do not guess: we
send a rollback under the same id, which rule 04 makes safe either
way. That is why rule 04 is not optional — without it, a slow response turns into a stake
taken with no payout and a player who is right to complain.
08Sandbox & proof
You should not have to discover your wallet's edge cases in production, and neither should we. Both sides are testable before a single real bet.
Test your wallet against our client
We run a harness that drives a real provider client through the failure paths a live integration eventually hits: a call that times out mid-round, a duplicated stake, a payout that overtakes its stake, a double rollback, and an operator that double-charges on purpose. You do not run this and you do not need our repository: give us a staging URL and a throwaway secret, and we run it against your wallet and send you the report. If you would rather run it yourself, ask and we will send you the harness.
# what we run on our side, against the endpoint you give us
MOCK_URL=https://staging.yourcasino.example/wallet \
MOCK_SECRET=$YOUR_TEST_SECRET npm run drills
What it asserts is the balance afterwards, not that the calls returned 200. A wallet that answers cheerfully and leaves the player 2.50 short still fails.
Test your side before we exist
We also ship a mock operator: a wallet that enforces the same four rules and that can be ordered to misbehave, so you can build against something that times out and duplicates on demand rather than against a happy path. Ask for it and we send you a single file with no dependencies; it runs on any machine with Node and talks to nothing outside it.
npm run mock:operator # a wallet on :4310
curl -X POST localhost:4310/_control -d '{"timeoutNext":1}'
curl -X POST localhost:4310/_control -d '{"latencyMs":800,"rejectWins":true}'
curl localhost:4310/_state # every transaction it has seen
What we run on every change
On our side, one command runs four suites and 73 checks: the wallet contract, an adversarial attack suite, the failure drills, and our client against the mock. Nothing ships red. We will run it in front of you on a call, and the output below is what it prints.
npm run verify
1/4 wallet contract 39 passed, 0 failed
2/4 attack suite 15 held, 0 breached
3/4 failure drills 12 passed, 0 failed
4/4 adapter check 7 passed, 0 failed
Credentials for the sandbox
Onboarding gives you a provider id, a test secret and a demo-balance wallet, in an environment with its own secret that never touches production. Everything in section 07 behaves identically there; only the authority behind the outcome changes.
09Errors
Refusals arrive as rr:error against the requestId that caused them,
so the pending call rejects rather than hanging. The SDK times out after 10 seconds.
| Code | Means | Game should |
|---|---|---|
| insufficient_funds | Balance below the stake. | Offer a lower stake, or emit requestDeposit. |
| stake_out_of_range | Outside the manifest min/max. | Clamp the stepper to the limits from rr:init. |
| unknown_game | No manifest with that id. | Fail loudly — a registration problem, not a runtime one. |
| round_not_found | Round not open in this session. | Never act on a round the frame did not open. |
| already_settled | Round already closed. | Treat as success and read the first result. |
| disabled | Game switched off. | Return the player to the lobby. |
10Requirements
Frame
Give the frame the manifest's aspect and let the game drive height through
rr:resize when it needs to. Games are built mobile-first;
bank is portrait (9:16) and the rest are landscape. Allow
autoplay so audio can arm on first gesture.
Money
All amounts are integer minor units — cents, never floats. The platform clamps stakes to the manifest range before the ledger sees them. Credits are keyed by a reference derived from the round, which is what makes retries idempotent.
Origin
origin is not decoration. Every message from a frame is checked against it;
without that check, any page the frame navigated to could post a "pay me" message and be
believed. Remote origins must be HTTPS. A rabbit-engine game must be
same-origin, because it receives outcomes before the player sees them.
Fairness
Every game ships a verification page: commit hash before the round, seeds revealed after, and an independent client-side recomputation of the result. Round history is queryable in the game; the authoritative ledger stays yours.
11Security model
The wallet is where money can be taken, so it is the part written to be attacked rather than merely used. This section states what we guarantee, what we do not, and what your side has to hold up.
What the wallet guarantees
| Guarantee | How it is enforced |
|---|---|
| A game cannot name a player | The account comes from the launch session, never from the request body. A provider that sends someone else's player id is ignored, because the field is not read. |
| A provider cannot touch another provider's transactions | Transaction ids are namespaced per provider. Ids are chosen by you, so across two providers they collide by accident and can be made to collide on purpose. An id you do not own is an id you cannot name. |
| A refund follows the transaction, not the caller | A rollback whose original transaction belongs to another player is refused with
TRANSACTION_NOT_YOURS, even inside the same provider. |
| A signature is checked before anything else | Verified over the raw body, in constant time, before the session is resolved and before the ledger is touched. An unsigned or mis-signed call never reaches money. |
| The same id is the same move, exactly once | A replay returns the first result. A replay carrying a different amount is a conflict, never a correction — that is a fraud signal, not a retry. |
| Amounts are integers or they are refused | No rounding, no coercion. 10.5 and
"1e9" are rejected rather than interpreted. Negative
stakes and negative payouts are rejected. |
| Sessions expire and are bound to one provider | A token seen in a launch URL is useless to anyone else, and useless to its owner after the TTL. |
| The balance cannot be raced below zero | Concurrent stakes against one balance settle one-at-a-time; the second is refused rather than both being applied. |
These are tested, not asserted
Every line above corresponds to an attack in an adversarial suite that runs on every change. Each case is written as an attack that must be refused: forged signatures, a signature valid for a different provider, a stolen launch token, negative and fractional and string amounts, replay with an inflated amount, rollback of another provider's transaction, rollback aimed at another player, pre-emptively burning a rival's transaction id, expired sessions, overdraft, an unregistered caller, and a concurrent double-spend. Alongside it runs a failure suite that asserts the player's balance is exact after timeouts, duplicates, reordering and an operator that double-charges on purpose.
Two of these were real
The cross-provider rollback and the cross-player refund were live defects found by writing the attacks rather than by reading the code. Both are fixed and both now have a test that fails if they come back. We would rather tell you that than publish a page claiming the code was correct all along.
What we do not guarantee, and you must
-
01
The frame is untrusted, including ours.
Validate
event.originon every message and never accept a stake amount, a payout or a result from the iframe. The frame asks; your platform decides. Anything a player can reach with a debugger is a request, not a fact. -
02
The shared secret is the whole perimeter.
Anyone holding it can move that provider's money. At least 24 characters, out of source control, rotatable without a deploy, and different per environment. Tell us immediately if a staging secret ever touched production.
-
03
A timeout is not a failure.
If a wallet call times out, the transaction may or may not have been applied. The only safe recovery is a rollback under the same id, which is defined to be safe for a transaction we never saw and safe to send twice. Treating a timeout as "did not happen" is how a stake gets kept without a payout.
-
04
Reconcile, do not trust.
Every money move is one ledger entry with a stable reference, so your totals and ours are comparable per round, per game and per day. A mismatch should page someone. Our own suite deliberately includes an operator that double-charges, precisely so that reconciliation is the thing that catches it.
-
05
Rate limits, transport and egress are yours.
TLS on the wallet endpoint, a rate limit per provider, and an allowlist of our egress addresses if your risk team wants one. We will give you static addresses on request. HMAC proves who signed, not how often they may call.
12Before real money
What exists today is a complete, playable integration on a demo balance. Three things sit between that and taking real bets, and none of them are code we can write alone.
-
01
Certified RNG and a signed RGS.
For real money the outcome must be computed and signed by a certified server — GLI, iTech Labs or BMM — with the client only playing back the result. Our deterministic simulation and provably-fair seed are the fairness proof and the exact model such a server runs; today it runs client-side in demo.
-
02
Game math certification.
Paytable, RTP and max win are locked and certified per jurisdiction. The measured figures in the catalogue are ours, not a lab's, and
wickandpaydirthave none yet. -
03
Responsible gaming and jurisdiction.
Session, loss and time limits, reality checks, RTP disclosure and a jurisdiction gate. The session already carries a limits object; enforcement is the operator's.
What you can do today
Mount any game against a demo wallet and exercise the full round lifecycle — bet, act, settle, error paths, retries. The wallet contract you integrate against in demo is the same one real money uses; only the authority behind the outcome changes.