Game Development Guide
Build an HTML5 casino game for this RGS with one PHP class (the outcome engine) and one PixiJS frontend (the renderer). The platform handles sessions, wallet debits/credits, round persistence, player history and replay — your code only decides outcomes and draws them. The whole workflow is also exposed as an MCP server so AI agents can build games end-to-end.
Overview & architecture
The RGS (Remote Gaming Server) sits between the game and the casino's wallet. A game has two halves:
- a frontend (HTML/JS) — what the player sees and interacts with
- a backend outcome engine — a PHP class the RGS loads, which decides whether the player won and how much
One round of play
-
The casino launches your game URL with a
token(wallet session) andgame_id. -
The client SDK calls
load_game— the RGS validates the token against the wallet, opens a game session and returns bet configuration + balance. -
The player acts; the client SDK calls
playwithbet_amount+ your game-specific parameters. - The RGS runs your game class to decide the outcome, settles bet/win with the wallet (atomically where supported), persists the round for history and replay, and returns your payload plus the new balance and a fresh session.
- The client animates the result and calls
ack_game_session.
Amounts on the wire are decimal strings with the
currency's decimals ("998.70", ETH "0.000000000000000001"). Game
code returns its win as a BigDecimal computed from $context->bet
and never touches the wallet; game clients keep amounts as strings.
Components
Server-side Game SDK —
app/Sdk/
The abstraction every new game is built on. A game is one stateless PHP class; the SDK handles everything else.
| Component | Path | Role |
|---|---|---|
GameInterface |
app/Sdk/Contracts/GameInterface.php |
The contract: play(GameContext): GameResult |
GameContext |
app/Sdk/GameContext.php |
Input: player, bet, client params, prior state, RNG |
GameResult |
app/Sdk/GameResult.php |
Output: win amount, client payload, replay data, round state |
GameException |
app/Sdk/GameException.php |
Clean rejection — aborts before the player is charged |
Rng |
app/Sdk/Rng.php |
CSPRNG helpers — the only allowed randomness source |
GameRegistry |
app/Sdk/GameRegistry.php |
Maps game codes → classes (from config/games.php) |
GameKernel |
app/Sdk/GameKernel.php |
Runs SDK games, persists rounds, adapts to the wire format |
GameDispatcher |
app/Sdk/GameDispatcher.php |
Routes to the SDK kernel or the frozen legacy engine |
Client SDK —
resources/rgs-games/_shared/
| File | Role |
|---|---|
rgs-sdk.js |
The game client API: boot(), play(), ack(),
balance tracking, session rotation, replay mode, error normalization
|
rgs_bootstrap.js |
Launch-parameter handling (token, rgs_url, replay payloads) |
query_string.js |
Query-string helper |
GameMenuHtml.js |
The unified game menu: balance, bet, win, PLAY, HISTORY and sound. Created with
RgsSdk.createMenu() (DOM games) or GameMenuHtml.create() (PIXI games)
|
Your rendering layer (PixiJS) sits on top of this — the SDK talks to the server, PixiJS draws the results. See step 3.
RGS API — app/Actions/Rgs/, RgsController
One endpoint, POST /api/rgs, dispatched by the rquest parameter — the full
reference is below.
Wallet abstraction — app/Services/Wallet/
Games never touch money. The play pipeline settles every round through a wallet adapter selected by
the WALLET_ADAPTER environment variable:
| Adapter | Use |
|---|---|
platform |
The in-house platform wallet (combined bet+win call) |
local |
Dev-only in-process wallet for browser-testing games (refuses production) |
Data model
| Table | Meaning |
|---|---|
games |
Game catalogue: code (game_id), name, min/max bet, RTP, active flag |
game_sessions |
One per round-in-progress per player; rotates when a round completes |
game_rounds |
One per round: bet/win as exact decimals, win type, input/result/steps/state JSON — powers history and replay |
rgs_players, rgs_wallet_transactions |
External-player mapping, wallet audit + rollback data |
Tooling
| Tool | Purpose |
|---|---|
php artisan make:game <code> "<Name>" |
Scaffolds a complete runnable game (backend, frontend, blade view, registry entry, DB row) |
MCP server (php artisan mcp:start game-sdk) |
Lets AI agents build games: docs, reference code, scaffolding, validation, real play-testing, test runs |
Reference game Dice Duel (dd) |
app/Games/DiceDuelGame.php + resources/rgs-games/dd/ — the
template to imitate
|
| Test suite | Pest; game-test pattern in tests/Feature/Sdk/GameSdkTest.php |
Random Number Generator (RNG)
The RGS includes a certified-random module that all games must
use for outcome determination. The App\Sdk\Rng class wraps PHP's cryptographically
secure random number generator (random_int) and provides game-specific helpers.
Important: Games must never use
mt_rand(), rand(), or PHP's shuffle() directly. Always
use $context->rng so the RNG source stays swappable and auditable.
Accessing the RNG
The RNG is available in your game's play() method via the context object:
public function play(GameContext $context): array
{
$rng = $context->rng;
// Roll a die (1-6)
$roll = $rng->int(1, 6);
// Draw 20 unique numbers from 1-80 (keno)
$drawn = $rng->uniqueInts(1, 80, 20);
// Shuffle a deck of cards
$deck = $rng->deck();
}
RNG Methods
| Method | Description | Example |
|---|---|---|
int(min, max) |
Random integer between min and max (inclusive) | $rng->int(1, 6) → 1–6 |
chance(probability) |
Returns true with given probability (0.0–1.0) | $rng->chance(0.25) → 25% true |
pick(array) |
One random element from a non-empty array | $rng->pick(['a','b','c']) |
uniqueInts(min, max, count) |
Draw count distinct integers (lottery/keno style) | $rng->uniqueInts(1, 80, 20) |
shuffle(array) |
Cryptographically shuffled copy (Fisher-Yates) | $rng->shuffle($items) |
weightedPick(weights) |
Pick a key based on relative weights | $rng->weightedPick(['cherry'=>50, 'seven'=>5]) |
deck() |
Shuffled 52-card deck as 0–51 indexes | $rng->deck() |
Card deck encoding
The deck() method returns card indexes 0–51. To decode:
$cardIndex = 27; // Example card
$suit = intdiv($cardIndex, 13); // 0=spades, 1=hearts, 2=diamonds, 3=clubs
$rank = $cardIndex % 13; // 0=2, 1=3, ... 8=10, 9=J, 10=Q, 11=K, 12=A
Weighted picks for slots/symbols
Use weightedPick() for slot reels or any outcome where symbols have different
probabilities:
$symbol = $rng->weightedPick([
'cherry' => 50, // 50/56 ≈ 89.3%
'seven' => 5, // 5/56 ≈ 8.9%
'wild' => 1, // 1/56 ≈ 1.8%
]);
Security & compliance
-
CSPRNG backed — All methods use PHP's
random_int(), which sources entropy from the operating system's cryptographic random number generator. - Auditable — Centralizing RNG in one class makes it easy to audit, test, and potentially swap for a hardware RNG or third-party certified source.
- Deterministic testing — For unit tests, you can mock the Rng class to produce predictable sequences.
Build a game, step by step
Step 0 — Design the math first
- Game mechanics: which inputs the player gives, how the outcome is decided.
- Math model: paytable and RTP. Compute the theoretical RTP (Σ probability × payout) — you must state it at registration. It is typically 90–99%, but the RTP levels a game offers are your choice (e.g. 84 or 87 too).
- Round shape: single-step (slots, dice, keno) or multi-step (blackjack-style decisions mid-round)?
Step 1 — Scaffold
php artisan make:game hilo "Hi-Lo Cards" --rtp=96.5 --category=table
Creates a working 50/50 placeholder game, registered everywhere:
| Artifact | Path |
|---|---|
| Backend game class | app/Games/HiloGame.php |
| Frontend | resources/rgs-games/hilo/index.html + js/game.js |
| Blade view | resources/views/rgs-games/hilo.blade.php |
| Registry entry | config/games.php |
| Database row | games table (skip with --no-db) |
Never create these files by hand — always scaffold.
Step 2 — Backend: the PHP outcome module
The game backend is one stateless class in app/Games/ implementing
App\Sdk\Contracts\GameInterface. The RGS loads it via the registry in
config/games.php. The whole game is play():
public function play(GameContext $context): GameResult
{
// 1. Validate input (untrusted!) — GameException aborts before any charge
$guess = strtolower((string) $context->requireInput('guess'));
if (! in_array($guess, ['low', 'high'], true)) {
throw new GameException('guess must be "low" or "high"');
}
// 2. Decide the outcome — ONLY via $context->rng
$card = $context->rng->int(1, 13);
$won = ($guess === 'high') === ($card > 7);
$win = $won ? round($context->betAmount * 1.9, 2) : 0.0;
// 3. Return payload (for the client) + replay data (for history)
return GameResult::completed($win)
->payload(['card' => $card, 'won' => $won])
->input(['guess' => $guess])
->outcome(['card' => $card, 'won' => $won])
->step('draw', ['card' => $card])
->summary("Guessed {$guess}, drew {$card}.");
}
Full GameContext/GameResult/Rng reference:
Server SDK. Multi-step
games return GameResult::awaitingAction($state) instead — see
Multi-step games.
Step 3 — Frontend: PixiJS rendering on the client SDK
The frontend lives in resources/rgs-games/{code}/ and is served at
/games/{code}/index.html. It has exactly one job:
render what the server returns. It must never compute or
predict outcomes. All RGS communication goes through _shared/rgs-sdk.js — PixiJS is
the rendering layer on top.
Load order in index.html (shared scripts via relative paths, no hardcoded hosts):
<script src="https://cdn.jsdelivr.net/npm/pixi.js@8.x/dist/pixi.min.js"></script>
<script src="../_shared/query_string.js" onerror="this.remove()"></script>
<script src="../_shared/rgs_bootstrap.js"></script>
<script src="../_shared/rgs-sdk.js"></script>
<script src="../_shared/GameMenuHtml.js"></script>
<script src="js/game.js"></script>
The pattern — boot the SDK, build the PixiJS stage when the game config arrives, and animate server results:
var app, stage;
RgsSdk.boot({
onReady: function (game) {
// game.minBet / maxBet / betSteps / defaultBet / balance are decimal strings; game.currency, game.decimals
app = new PIXI.Application();
app.init({ resizeTo: window, background: '#0f0f23' }).then(function () {
document.body.appendChild(app.canvas);
buildTable(game); // draw the idle scene + bet UI
});
},
onReplay: function (replay) {
// Replay mode: render replay.input / replay.result / replay.steps.
// replay.winamount and replay.betamount are decimal strings (same as the play API).
renderReplay(replay);
},
onError: function (message) { showErrorOverlay(message); }
});
RgsSdk.onBalanceChange(function (balance) { hud.balance.text = RgsSdk.formatMoney(balance); });
function onSpinPressed(bet, guess) {
RgsSdk.play({ bet_amount: bet, guess: guess })
.then(function (res) {
// res = your GameResult payload + winamount, accountsum, new_game_session
animateOutcome(res, function () { RgsSdk.ack(); });
})
.catch(function (err) { showErrorOverlay(err.message); });
}
Frontend requirements:
-
Build the bet UI from
bet_steps/min_bet/max_bet/default_bet— never hardcode bets. If the wallet caps bets, the ladder arrives pre-clamped. -
Amounts are decimal strings. Send a
bet_stepsentry asbet_amountunchanged, check "can afford" withRgsSdk.compareAmounts(bet, RgsSdk.getBalance()) > 0, show amounts withRgsSdk.formatMoney— neverNumber(),parseFloat(),+or.toFixed()on an amount. -
Handle the error callback and rejected
play()promises: show a message, halt. -
Support replay mode (
onReplay) rendering purely from the persisted round data. -
The displayed balance always comes from RGS responses (
onBalanceChange), never from local arithmetic. -
Keep everything self-contained under the game folder (assets, sounds); load shared scripts
relatively (
../_shared/…). Vendor the PixiJS bundle into the game folder for production builds if offline serving is required.
Unified game menu
Every game shows the same bottom bar: balance, bet, win, PLAY, HISTORY and sound. Do not build
your own bet controls. Create it from onReady once the bet configuration arrives:
RgsSdk.boot({
onReady: function (game) {
var menu = RgsSdk.createMenu({
betOptions: game.betSteps,
bet: game.defaultBet,
currency: game.currency,
onPlay: function (bet) {
RgsSdk.play({ bet_amount: bet, guess: 'high' })
.then(function (res) { menu.setWin(res.winamount); animateOutcome(res, function () { RgsSdk.ack(); }); })
.catch(function (err) { showErrorOverlay(err.message); });
}
});
},
onReplay: function (replay) { renderReplay(replay); },
onError: function (message) { showErrorOverlay(message); }
});
- Balance updates itself from every RGS response; do not set it manually.
-
Layout: the bar is fixed to the bottom of the page. Leave room
for it with
menu.height(). - HISTORY and sound sit at the right side of the bar, away from the balance and bet.
-
Replay: hide the controls with
menu.showPlayButton(false)andmenu.showBetControls(false). -
PIXI games: call
GameMenuHtml.create({...})instead ofRgsSdk.createMenu(), set the bottom offset of#game-roottomenu.height(), and remove any in-canvas menu layout. Do not loadGameMenu.js.
Matching the menu to your game
The bar's colours, corner radii and font are CSS variables with the default dark look as fallback, so it can match your game's design without changing the menu. Set them from code, before or after creating the menu:
RgsSdk.setTheme({
panelBg: 'rgba(20, 8, 2, .85)', panelBorder: '#c9a14a', panelRadius: '18px',
labelColor: '#e8d5a3', valueColor: '#ffd36b', winColor: '#7cf29a', textColor: '#fff',
buttonBg: '#3a2410', buttonBorder: '#c9a14a', buttonColor: '#ffe9b0', buttonRadius: '50%',
buttonHoverBg: '#5a3818', buttonHoverBorder: '#ffd36b', buttonHoverColor: '#fff',
playBg: 'linear-gradient(135deg, #ffd36b, #c98a1a)', playHoverBg: 'linear-gradient(135deg, #ffe08f, #ffd36b)',
playColor: '#2a1300', playRadius: '999px',
font: '"Cinzel", Georgia, serif'
});
Or pass the same object as RgsSdk.createMenu({ theme: {...} }), or set the variables in
your own CSS: :root { --rgs-button-bg: #3a2410; --rgs-play-bg: …; }. Each key is the
variable in camelCase (buttonHoverBg → --rgs-button-hover-bg);
null puts one back to its default.
| Keys | Style |
|---|---|
panelBg, panelBorder, panelRadius |
The bar itself |
labelColor, valueColor, winColor, mutedColor, textColor |
BALANCE / BET / WIN labels, amounts, a win, a zero win, the bet value |
buttonBg, buttonBorder, buttonColor, buttonRadius |
− / + and the Home, History and Sound buttons (also the HISTORY / HOME corner buttons of games without the menu) |
buttonHoverBg, buttonHoverBorder, buttonHoverColor |
Their hover state |
playBg, playHoverBg, playColor, playRadius |
PLAY / SPIN (any CSS background, gradients included); playRadius also rounds the action buttons |
font |
The bar's font family (load the font yourself) |
Keep amounts and buttons clearly readable against panelBg.
Without PixiJS (DOM games)
Games that draw with HTML, CSS, SVG or Three.js use the same menu, without PixiJS and without
GameMenuHtml.create():
-
Load the four shared scripts (
query_string.js,rgs_bootstrap.js,rgs-sdk.js,GameMenuHtml.js) and skip the PixiJS script. - Call
RgsSdk.createMenu()fromonReady, as shown above. -
Keep the game's own root above the bar. The bar is fixed to the bottom, so set the root's
bottomtomenu.height()and repeat it on windowresize. - Render the table and results with your own DOM code. The menu handles only bet, PLAY, balance, win, HISTORY and sound.
var menu = null;
var gameRoot = document.getElementById('game-root');
function fitGameRoot() {
gameRoot.style.bottom = menu.height() + 'px';
}
RgsSdk.boot({
onReady: function (game) {
menu = RgsSdk.createMenu({
betOptions: game.betSteps,
bet: game.defaultBet,
currency: game.currency,
onPlay: function (bet) {
RgsSdk.play({ bet_amount: bet, guess: 'high' })
.then(function (res) { menu.setWin(res.winamount); renderResult(res); RgsSdk.ack(); })
.catch(function (err) { showError(err.message); });
}
});
fitGameRoot();
window.addEventListener('resize', fitGameRoot);
renderTable(game);
},
onReplay: function (replay) { renderReplay(replay); },
onError: showError
});
Load the menu from ../_shared/GameMenuHtml.js inside a game view. The same file is
served publicly at /games/common/GameMenuHtml.js on the RGS host, together with
rgs-sdk.js, rgs_bootstrap.js and query_string.js. Those
responses send Access-Control-Allow-Origin: *, so any origin can load them. Any other
file under /games/common/ returns 404.
<script src="/games/common/query_string.js" onerror="this.remove()"></script>
<script src="/games/common/rgs_bootstrap.js"></script>
<script src="/games/common/rgs-sdk.js"></script>
<script src="/games/common/GameMenuHtml.js"></script>
All menu options and methods are listed in the Client SDK reference.
Step 4 — Verify & test
-
Wiring — registry entry, DB row and files consistent?
(The MCP
validate-gametool checks this automatically.) -
Browser — set
WALLET_ADAPTER=localin.env, then open/games/hilo/index.html?token=dev&game_id=hilo. Every token is a separate player starting with 1000.00 EUR. Play wins, losses, insufficient balance, and try to break it. -
Automated — write a Pest feature test per the pattern in
tests/Feature/Sdk/GameSdkTest.php: full round viaPOST /api/rgs, payout amount on a win, invalid input → HTTP 400 with no wallet transaction. Run./vendor/bin/pest. -
Replay — open
play-test-round'shistory.replay_url(or/player/history?token=…and replay a round) and confirm it is redrawn from stored data exactly as played. See Verifying replay.
it('plays a round', function () {
$load = $this->postJson('/api/rgs', [
'rquest' => 'load_game', 'token' => 't', 'game_id' => 'hilo',
])->json();
$res = $this->postJson('/api/rgs', [
'rquest' => 'play',
'token' => 't',
'game_session' => $load['game_session'],
'bet_amount' => '1.00',
'guess' => 'high',
]);
$res->assertOk()->assertJsonPath('status', 'OK');
});
Step 5 — Release
-
Confirm the
gamesrow (name, min/max bet, RTP, category,is_active). ./vendor/bin/pinton changed files; full test suite green.-
Deploy — games are served via the
public/gamessymlink (php artisan games:linkin the deploy pipeline).
Sandbox mode (preview development)
The sandbox environment lets you develop and test games without wallet/platform registration. Games run with an isolated sandbox wallet (1000.00 EUR balance).
Creating a sandbox game
php artisan make:game hilo "Hi-Lo Cards" --sandbox --rtp=96.5 --category=table
This creates:
app/Games/Sandbox/HiloGame.php— backend classresources/rgs-games/_sandbox/hilo/— frontend filesresources/views/rgs-games/_sandbox/hilo.blade.php— blade view- Row in
sandbox_gamestable (status:pending)
Test immediately at
/sandbox/games/hilo/play.html — no token needed.
Distributed development with MCP
The RGS MCP server supports a distributed workflow where different AI agents or developers work on backend and frontend separately:
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────┐
│ RGS MCP Server │ │ Local AI / Dev │ │ Developer │
│ (Backend) │ │ (Frontend) │ │ (Human) │
└──────────┬──────────┘ └──────────┬──────────┘ └──────┬──────┘
│ │ │
1. scaffold-game ────────────────────┼───────────────────────┤
--sandbox │ │
│ │ │
│ 2. Build frontend │
│ (HTML/JS/CSS) │
│ │ │
│◄──────────────────────────┤ │
│ 3. upload-game-frontend │ │
│ (base64 ZIP) │ │
│ │ │
4. Preview URL ──────────────────────┼──────────────────────►│
/sandbox/games/{code}/play.html │ │
│ │ │
│ │ 5. Test & Approve
│ │ │
6. export-sandbox-game ◄─────────────┼───────────────────────┤
│ │ │
HTTP MCP Endpoint: POST /mcp/game-sdk
# Upload frontend (base64-encoded ZIP)
curl -X POST "https://rgs.cloudvlt.com/mcp/game-sdk" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "upload-game-frontend",
"arguments": {
"code": "mygame",
"zip_base64": "UEsDBBQ..."
}
}
}'
Frontend ZIP requirements:
- Must contain
index.htmlat root level - Should contain
js/game.jsfor game logic -
Use relative paths:
../_shared/sandbox_bootstrap.jsand../_shared/rgs-sdk.js
Filament admin — sandbox game management
The RGS Filament admin panel (/admin/sandbox-games) provides a UI for managing
sandbox games with an approval workflow:
- Preview — Opens the game in a new tab for testing
- Approve — Marks game as approved
- Reject — Marks game as rejected with a reason
- Export ZIP — Downloads production-ready bundle
- Delete — Removes game files and database record
| Permission | Description |
|---|---|
view_sandbox_games |
View the sandbox games list |
approve_sandbox_games |
Approve, reject, or reset game status |
delete_sandbox_games |
Delete sandbox games |
Exporting to production
When ready for production, export the game:
php artisan sandbox:export hilo
# Or via MCP: export-sandbox-game code=hilo
This creates a ZIP bundle with:
- Production-namespaced PHP class
- Frontend files with production paths
config-entry.txt— line to add toconfig/games.phpmigration.sql— INSERT forgamestable
Multi-step games
Games where the player makes decisions mid-round (blackjack: hit / stand / double) keep the round
open by returning awaitingAction:
public function play(GameContext $context): GameResult
{
if (! $context->isContinuation) {
// First call: the bet is taken now.
$deck = $context->rng->deck();
$hand = [array_shift($deck), array_shift($deck)];
return GameResult::awaitingAction([
'deck' => $deck,
'hand' => $hand,
'bet' => $context->betAmount, // remember the stake yourself
])
->payload(['hand' => $hand, 'actions' => ['hit', 'stand']])
->step('deal', ['hand' => $hand]);
}
// Continuation: betAmount is 0, your saved state comes back.
$state = $context->state;
return match ($context->action()) {
'hit' => $this->hit($context, $state),
'stand' => $this->stand($context, $state), // -> GameResult::completed(...)
default => throw new GameException('Illegal action'),
};
}
What the SDK does for you:
-
The session is marked resumable; the next
playcall routes back withisContinuation = trueand your$state. -
The stake is charged once, on the opening call; the win is
paid when you return
completed. -
One round row is kept and updated each step; each step's
->step()entries are added to the earlier ones, so the whole round replays. Record->input()and->outcome()on every step too. -
The client keeps the same
game_sessionuntil it receivesnew_game_session(automatic with the client SDK).
Game replay
Every completed round can be re-watched: the game frontend is
re-opened in a special replay mode and re-renders the round purely from the data persisted when it
was played — no game logic runs again, no wallet is touched, nothing is random. Replay powers the
player history page and admin round inspection, so recording good replay data is a hard
requirement, not a nicety. Every game must do both halves: the backend records the round, and the
frontend draws it in onReplay. The MCP tools check both
(see 4).
1. Recording — what the backend persists
On the GameResult of every play call — continuation
steps of multi-step games included — your game records:
| Call | Persisted as | What to put in it |
|---|---|---|
->input(array) |
game_rounds.input |
What the player chose (their guess, picked numbers, actions) |
->outcome(array) |
game_rounds.result |
What happened (dice, cards, totals, won/lost) |
->step(event, data) |
game_rounds.steps |
One entry per visual moment (deal, draw, spin, hit) — the animation script |
Design test: could someone re-render the round with only this data, without running
your game class? If not, record more. For multi-step games the SDK adds each step's entries to
the round's earlier ones (unless you carry them forward yourself with
->steps([...$context->steps, ...$new])), so the whole round replays, not just
the last action.
A call that records no input keeps only the client's own fields — never the player's token or
session — and a call with no outcome keeps the client payload. Neither is enough to replay a round:
validate-game and play-test-round report it as a failure.
2. Delivery — how a replay reaches the game
-
GET /player/history?token=…lists the player's rounds; each row carries a temporary signed replay URL (valid 2 hours). Replay URLs cannot be constructed by hand. -
The signed URL opens
GET /player/replay/{roundId}, which loads the stored round and embeds the game frontend, launching it withreplay=<roundId>plus a#replay_data=<base64url-JSON>fragment containing the full round payload. -
The same payload is also available raw (with a valid signature) at
GET /api/game-round/{roundId}/replay. -
A released game replays from
/games/{code}/play.html; a game still in the sandbox replays from/sandbox/games/{code}/play.html, whose bootstrap reads the same fragment. The game'sindex.htmlis the same in both.
3. Rendering — the onReplay callback
The client SDK detects the replay fragment during RgsSdk.boot(), decodes it, skips the
load_game handshake and fires onReplay(replay) instead of
onReady. The replay object:
| Field | Meaning |
|---|---|
replay.input |
What you stored via GameResult::input() |
replay.result |
What you stored via GameResult::outcome() |
replay.steps |
The recorded step list, in order |
replay.winamount |
Win amount as a decimal string (same format as the play API) |
replay.betamount |
Bet amount as a decimal string |
How Dice Duel renders it (resources/rgs-games/dd/js/game.js) — show a replay badge,
disable the controls, hide the live balance (there is none), and run the normal outcome animation
from the stored result:
function runReplay(replay) {
el.replayBadge.style.display = 'block';
setBusy(true); // no betting in replay
var result = replay.result || {};
var winAmount = replay.winamount || '0';
el.balance.parentNode.style.visibility = 'hidden';
if (result.dice) {
animateRoll(result.dice, function () {
showOutcome(!!result.won, winAmount, result.total);
});
}
}
Behaviour in replay mode:
-
RgsSdk.isReplay()returnstrue;RgsSdk.play()rejects with"Cannot play in replay mode"— the mode is read-only by construction. -
No RGS calls are made: there is no session, no live balance, no
ack(). Hide or freeze the bet UI and balance HUD. -
Reuse your normal rendering/animation path fed from
replay.result/replay.steps— a PixiJS frontend replays by running the same timeline it uses for live results, just sourced from the stored steps instead of aplay()response.
4. Verifying replay (definition of done)
-
validate-gamefails a game whose frontend has noonReplay, or whose backend never calls->input(),->outcome()or->step(). -
play-test-roundshows, per call, what the round recorded (recorded_for_history), lists replay data a call left out underhistory.problems(must be empty), and returnshistory.replay_url: a signed link that opens the last round in the replay viewer. -
Open
history.replay_urlin a browser: the round must be redrawn exactly as it was played — same cards/reels/multipliers, same win — with no bet or PLAY controls. The win shown must match the header of the replay page. -
For multi-step games, play a round through every action with
play-test-round'ssequence, replay it, and confirm every step re-renders in order. -
Without MCP: play a few rounds, open
/player/history?token=…and replay one. - Endpoint details: History & replay API; launch parameters: Game launch URL.
Using Dice Duel as a template
Dice Duel (code dd) is the reference implementation
every new game should imitate — a complete, shippable game in two small files:
| Half | File | What to copy from it |
|---|---|---|
| Backend | app/Games/DiceDuelGame.php |
Input validation with GameException, RNG usage, payout math,
payload/input/outcome/step/summary recording
|
| Frontend | resources/rgs-games/dd/index.html + js/game.js |
The complete client game loop in ~150 lines: boot → render config → play → animate → ack → repeat, plus replay mode and error handling |
The game itself: the player bets low or high, two dice are rolled; totals 2–6 are low, 8–12 are high, 7 pushes (stake returned). A correct guess pays 2.3×.
Workflow: template → your game
- Read both Dice Duel files (or use the MCP
read-reference-gametool). -
Scaffold your game:
php artisan make:game <code> "<Name>"— the scaffold is itself a minimal Dice-Duel-shaped game. -
Replace the outcome logic in
play()keeping the same structure: validate → RNG → payout → record. -
Replace the DOM rendering in
js/game.jswith your PixiJS stage, keeping the SDK calls (boot/play/ack/onBalanceChange/onReplay) exactly as the template uses them. -
Try it live:
/games/dd/index.html?token=dev&game_id=dd(requiresWALLET_ADAPTER=local).
Building with AI agents (MCP)
The repo ships an MCP (Model Context Protocol) server exposing the whole game-building workflow as tools, so an AI agent can scaffold, implement, validate and play-test a game end-to-end without human plumbing.
Connect via HTTP (Remote / Cloud)
The MCP server is available via HTTP at:
https://rgs.cloudvlt.com/mcp/game-sdk
Claude Desktop
Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"rgs-game-sdk": {
"url": "https://rgs.cloudvlt.com/mcp/game-sdk"
}
}
}
Restart Claude Desktop after editing.
Cursor / Windsurf
Add to .cursor/mcp.json or .windsurf/mcp.json in your project:
{
"mcpServers": {
"rgs-game-sdk": {
"url": "https://rgs.cloudvlt.com/mcp/game-sdk"
}
}
}
Test with curl
curl -X POST https://rgs.cloudvlt.com/mcp/game-sdk \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Connect via stdio (Local Development)
For local development, the MCP server is auto-wired for Claude Code via .mcp.json:
{
"mcpServers": {
"rgs-game-sdk": {
"command": "php",
"args": ["artisan", "mcp:start", "game-sdk-local"]
}
}
}
| Tool | Purpose |
|---|---|
read-docs |
This guide, the SDK reference, the API reference, READMEs |
read-reference-game |
Dice Duel source, scaffold stubs, the client SDK |
scaffold-game |
Runs make:game |
list-games |
All games with engine, limits, frontend status |
validate-game |
Checks registry/class/DB/frontend wiring, and that the game records and draws replays |
play-test-round |
Plays real rounds through the full pipeline (dev wallet) — verifies outcomes, payouts, input validation; shows what each round recorded for history and returns a replay link |
run-tests |
Runs the Pest suite |
The agent workflow
-
read-docs(topicgame-development, thengame-sdk) — learn components, process, requirements. -
read-reference-game— study Dice Duel (backend + frontend) before writing code. -
scaffold-game— generate the runnable skeleton. Never create the files by hand. -
Edit
app/Games/{Class}Game.php(PHP outcome module) andresources/rgs-games/{code}/js/game.js+index.html(PixiJS UI). validate-game— verify all wiring is consistent, replay support included.-
play-test-round— play real rounds, including invalid input (it must fail cleanly with no charge).history.problemsmust be empty; openhistory.replay_urland check the round is redrawn as played. -
run-tests— the whole suite must pass; add Pest tests pertests/Feature/Sdk/GameSdkTest.php.
Server implementation: app/Mcp/GameSdkServer.php. Start manually with
php artisan mcp:start game-sdk for any MCP-compatible client.
API reference — POST /api/rgs
One endpoint; the rquest parameter selects the method. (POST /api/api.php
is a legacy alias.) The client SDK wraps all of this — game code normally never calls these
directly.
📦 Postman Collection:
Download RGS API Collection
— Import into Postman to test all endpoints against https://rgs.cloudvlt.com.
Includes load_game, play (Dice Duel + Blackjack), get_accountsum, and ack_game_session with
auto-saved session variables.
Conventions
-
Requests:
POSTwithapplication/x-www-form-urlencodedor JSON bodies. -
Responses: JSON. Every success carries
"status": "OK"; every failure is HTTP 400 with{"status": "Failed", "msg": "reason"}. -
Money: decimal strings with exactly the currency's
decimals, in responses and requests (
"accountsum": "998.70"; seedecimalsonload_game). Send abet_stepsentry asbet_amountunchanged; never parse amounts into numbers. -
Auth: the wallet session
tokenfrom the launch URL authenticates every call. -
CORS:
/api/rgsanswers withAccess-Control-Allow-Origin: *.
Common parameters for every method:
| Param | Type | Required | Description |
|---|---|---|---|
rquest |
string | yes |
Method name: load_game, play, get_accountsum,
ack_game_session
|
token |
string | yes | Wallet session token from the launch URL |
auth Get Wallet Session Token
Before calling any RGS API method, you need a wallet session token. This token authenticates the player and is obtained by logging in via the platform API.
POST {platform_url}/api/portal/players/login
Content-Type: application/json
{
"email": "player@example.com",
"password": "secret",
"player_ip": "1.2.3.4",
"portal_id": 1
}
{
"status": "success",
"wallet_session_token": "1|abc123xyz...",
"player": {
"id": 42,
"email": "player@example.com",
"name": "John Doe",
…
}
}
The wallet_session_token is what you pass as token to all RGS API
calls. It's a Laravel Sanctum token that identifies the player session.
The game shows the balance from load_game's accountsum (a decimal
string), never from the login response.
💡 For development: When using
WALLET_ADAPTER=local, any token value works (e.g. token=dev). Each
unique token gets a fresh player with 1000.00 EUR balance.
rquest load_game
Validates the token against the wallet, opens a game session, and returns the game configuration. Must be called once before playing.
| Param | Type | Required | Description |
|---|---|---|---|
game_id |
string | yes | Game code (dd, …) — must match games.game_id |
POST /api/rgs
rquest=load_game&token=<wallet-token>&game_id=dd
{
"game_id": "dd",
"sname": "dd",
"name": "Dice Duel",
"game_session": "01JZWX3E5H8Q2R9T0V1W2X3Y4Z",
"player_name": "mrCat",
"currency": "EUR",
"decimals": 2,
"language": "en",
"accountsum": "1000.00",
"min_bet": "0.10",
"max_bet": "100.00",
"bet_steps": ["0.10", "0.20", "0.50", "1.00", "2.00", "5.00"],
"default_bet": "0.10",
"lines": 1,
"jurisdiction": "SE",
"rgs_features": {
"autoplay": { "enabled": false, "maxSpins": null, "lossLimit": true },
"turbo": false, "slamStop": false, "bonusBuy": false, "gambleFeature": true,
"jackpots": true, "parallelPlay": true, "maxStake": null, "minRoundMs": 3000,
"bigWin": true, "celebrateLowWins": true,
"realityCheck": { "enabled": true, "intervalMinutes": 60, "pausesPlay": false },
"sessionTime": true, "netPosition": true, "showRtp": false
},
"status": "OK"
}
| Field | Meaning |
|---|---|
game_session |
Session id to pass to every play call. One session = one round. |
decimals |
Decimal places of the currency (EUR 2, JPY 0, ETH 18); every amount is sent with this many decimals and bet steps that don't fit are dropped |
accountsum |
Player balance (decimal string) |
min_bet / max_bet / bet_steps /
default_bet
|
Bet configuration as decimal strings — clients must build their bet UI from these; the ladder arrives pre-clamped if the wallet caps bets |
lines |
Line count for slot-style games (1 otherwise) |
jurisdiction / rgs_features |
The player's jurisdiction and the game features it allows or requires — see jurisdiction features |
Failures: missing/invalid token or unknown game →
400 "Invalid token or game not available".
rquest play
Plays one round — or one step of a multi-step round. Runs the game's outcome logic, settles bet/win with the wallet, persists the round.
| Param | Type | Required | Description |
|---|---|---|---|
game_session |
string | yes |
The current session from load_game (or the last
new_game_session)
|
bet_amount |
decimal string | yes* |
Stake for this round. Send a bet_steps entry unchanged. *Ignored (treated as 0) when continuing an open multi-step
round.
|
game_id |
string | no | Game code (informational; the session already knows it) |
| game-specific… | any | per game |
Every other parameter is handed to the game class as
$context->input. Dice Duel: guess=low|high. Multi-step:
action=bet|hit|stand|…
|
POST /api/rgs
rquest=play&token=<t>&game_session=01JZ…&bet_amount=1.00&guess=high
{
"dice": [6, 4],
"total": 10,
"guess": "high",
"won": true,
"winamount": "2.30",
"status_id": 0,
"accountsum": "1001.30",
"multi_state": 0,
"new_game_session": "01JZWX4A7B8C9D0E1F2G3H4J5K",
"status": "OK"
}
| Field | Meaning |
|---|---|
| game payload |
Whatever the game returned via GameResult::payload() (here:
dice, total, guess, won)
|
winamount |
Win for this call (decimal string) |
accountsum |
Balance after settlement (decimal string) |
multi_state |
0 = round finished. 1 = round still open — the player must
act again
|
status_id |
0 finished, 3 awaiting player action |
new_game_session |
Present only when the round completed: use it
for the next round. While multi_state=1, keep sending the same
game_session.
|
Failures (all 400):
- invalid/expired session →
"Game session not found" -
game input rejected (
GameException) → the game's message — no wallet transaction occurs - insufficient funds → wallet error message; rollback semantics guarantee the player is refunded on partial failure
rquest get_accountsum
Balance refresh (e.g. on idle). Takes only the common parameters.
{ "accountsum": "998.70", "currency": "EUR", "status": "OK" }
rquest ack_game_session
The client confirms the round result was displayed to the player. Fire-and-forget bookkeeping; failures can be ignored by clients.
| Param | Type | Required |
|---|---|---|
game_session |
string | yes |
{ "msg": "game session acknowledged", "status": "OK" }
load_resume_game is deprecated — resuming works through
play itself: a resumable session routes the next play call back
into the open round.
GET /api/round/{id}/outcome
Returns a signed URL to view the visual replay of a specific game round, for sharing round
outcomes with the player. The caller must be the round's player: pass their token
as a query parameter or a Session-Token header (or use a signed URL valid for at
most 24 hours). No or an invalid token gives 401; another player's round gives
the same 404 as a round that does not exist. The replay_url expires
after 24 hours. Support staff use the backoffice replay instead.
| Param | Type | Description |
|---|---|---|
id |
integer | The game round ID (from game_rounds table) |
token |
string | The round player's session token (or send it as the Session-Token header) |
GET /api/round/739/outcome?token=…
{
"round_id": 739,
"replay_url": "https://rgsaws.cloudvlt.com/player/replay/739?expires=1767225600&signature=abc123...",
"expires_at": "2026-01-01T00:00:00+00:00"
}
Opening replay_url displays the game with the exact outcome of that round,
including all bets, cards dealt, and final result.
Game launch URL
Games are static frontends served at /games/{code}/index.html. The casino/aggregator
launches them with query parameters:
| Param | Required | Description |
|---|---|---|
token |
yes | Wallet session token for the player |
game_id |
yes | Game code, passed to load_game |
currency |
yes | Player's currency, ISO 4217 (USD, EUR, …) — see currencies & languages |
lang |
yes | Player's language: locale code (es_ES) or ISO 639-1 (es) |
rgs_url (alias api_url) |
no | Full RGS endpoint URL; defaults to <origin>/api/rgs |
device_id |
no | web | mobile |
resume_session |
no | Session id to resume |
replay + #replay_data= |
no | Replay mode — the game renders the encoded round instead of playing |
lobby_url |
no | The operator's lobby: the game shows a Home/Lobby button only when it is set, and opens it in the whole window |
showsplash |
no | false: open straight on the playable screen, no intro or "press to start" — see splash screen. true or absent: the game may show its splash |
https://rgsaws.cloudvlt.com/games/dd/index.html?token=abc123&game_id=dd¤cy=EUR&lang=es_ES&device_id=web
The player's jurisdiction is not a launch URL parameter: the operator
names it in the server-to-server launch API and the game gets its rules from load_game — see
jurisdiction features.
Currencies & languages
Every game must support the RGS defaults, set by the operator in the backoffice
(Configuration → Currencies / Languages): currencies
EUR, USD and languages
en_US, es_ES, ja_JP. A launch that names none uses
EUR / en_US.
-
The operator passes both on the launch URL:
?currency=USD&lang=es_ES.langmay be a locale code (es_ES) or ISO 639-1 (es); games accept both. A language or currency the RGS doesn't support falls back toEUR/en_US. -
Read them with the client SDK —
RgsSdk.getCurrency(),getLanguage(),getLanguageIso()andformatMoney(amount)— never by hardcoding. Once the game is loaded the wallet's currency wins over?currency=. -
Money formatting. The number of decimals depends on the currency, as set in the
backoffice (USD/EUR 2, JPY 0);
load_gamereturns it asdecimals(andcurrency_format). Amounts are formatted by the currency only — never by the player's language: each currency has its own symbol position, symbol spacing and thousands and decimal separators, set in the backoffice (symbol_position,symbol_space,thousands_separator,decimal_separatorincurrency_format), e.g.€1,234.50,R$ 1.234,50,5.000 ₫,¥1,234.formatMoneyapplies it — it pads to the currency's decimals and never rounds an exact amount; games that draw digits themselves useRgsSdk.getCurrencyFormat(). -
Every visible text comes from a translation table with every RGS default language; every amount is shown
with
formatMoney. Amounts on the wire are the same in every currency; the backend never converts. Dice Duel and the scaffolded frontend show the pattern. -
Test in the sandbox lobby
(currency and language selectors) or with the MCP tool
play-test-round(currency,lang).validate-gamechecks the frontend.
Jurisdiction features
Every player plays under a jurisdiction (backoffice: Configuration → Jurisdictions). The operator names it
in the launch API (jurisdiction, e.g. SE, UK), else the operator's
default applies (Wallets → Default jurisdiction), else Curaçao (CW, everything allowed). An
unknown or inactive code refuses the launch. It is fixed for the session, and load_game
returns it with its features:
"jurisdiction": "SE",
"rgs_features": {
"autoplay": { "enabled": false, "maxSpins": null, "lossLimit": true },
"turbo": false, "slamStop": false, "bonusBuy": false, "gambleFeature": true,
"jackpots": true, "parallelPlay": true, "maxStake": null, "minRoundMs": 3000,
"bigWin": true, "celebrateLowWins": true,
"realityCheck": { "enabled": true, "intervalMinutes": 60, "pausesPlay": false },
"sessionTime": true, "netPosition": true, "showRtp": false
}
Features are data, not code. Each feature is a row with a default (Configuration → Jurisdiction features) and each jurisdiction stores only where it differs. The operator adds features and changes rules in the backoffice, and a game that reads a feature follows the change without an update. Read every feature you support with the value your game would use without the rule:
if (RgsSdk.getFeature('autoplay.enabled', true)) showAutoplay(RgsSdk.getFeature('autoplay.maxSpins', null));
if ($context->input('buy') && ! $context->features->get('bonusBuy', true)) {
throw new GameException('Bonus buy is not available'); // before the player is charged
}
Current features:
| Key | Type | Default | The game must |
|---|---|---|---|
autoplay.enabled |
on/off | on | Offer autoplay only when on |
autoplay.maxSpins |
number or null | null | Run at most this many spins per autoplay (null: no limit) |
autoplay.lossLimit |
on/off | off | Ask for a loss limit before autoplay starts |
turbo |
on/off | on | Offer turbo / quick spin only when on |
slamStop |
on/off | on | Offer slam stop only when on |
bonusBuy |
on/off | on | Offer a bonus buy only when on, and refuse it on the server when off |
gambleFeature |
on/off | on | Offer a double-or-nothing gamble only when on, and refuse it on the server when off |
jackpots |
on/off | on | Offer jackpots only when on |
parallelPlay |
on/off | on | Not open several games or instances at once when off |
maxStake |
amount or null | null | Nothing: the RGS removes higher bet levels from bet_steps and refuses higher bets |
minRoundMs |
number | 0 | Start the next round no sooner than this many ms after the previous round started (the server does not check it) |
bigWin |
on/off | on | Show big-win celebrations only when on |
celebrateLowWins |
on/off | on | When off, not celebrate a win of at most the stake as a win |
realityCheck.enabled |
on/off | off | Show a reality check… |
realityCheck.intervalMinutes |
number or null | null | …every this many minutes |
realityCheck.pausesPlay |
on/off | off | …and block play until the player acknowledges it |
sessionTime |
on/off | off | Show the session time |
netPosition |
on/off | off | Show the session's net win/loss |
showRtp |
on/off | off | Show the game's RTP where the bet is placed |
Seeded jurisdictions:
| Code | Differs from the defaults |
|---|---|
CW Curaçao |
Nothing: everything allowed (also when no jurisdiction is given) |
SE Sweden |
No autoplay, turbo, slam stop or bonus buy; 3 s minimum round; reality check every 60 minutes; session time and net position shown |
UK United Kingdom |
No autoplay, turbo, slam stop, bonus buy or parallel play; 2.5 s minimum round; no celebrating wins of at most the stake; session time and net position shown |
DE Germany |
No autoplay, turbo, bonus buy, jackpots or parallel play; €1.00 maximum stake; 5 s minimum round; RTP shown |
GLI19 GLI-19 |
No autoplay |
MT, GG, DK, GI, RO, PH |
Nothing yet (to be confirmed) |
- Never read the jurisdiction from the URL: the player can edit it.
- In replay,
getFeature()andgetFeatures()return the replayed round's features. -
Verify:
validate-gamefails a sandbox frontend that offers autoplay, turbo, slam stop or bonus buy without reading the features, or that readsjurisdictionfrom the URL.play-test-roundtakesjurisdiction(e.g."SE") and reports thergs_featuresthe game got. In the sandbox lobby, the Jurisdiction dropdown launches the game in that jurisdiction.
Free rounds
Operators give players free rounds on a game: a number of rounds at a fixed stake, paid for by the
operator. The player stakes nothing; the round plays exactly like a paid round at that stake, and its win
is paid to the player's balance as cash. The client SDK and the kernel do almost all of it, so a
single-stake game supports free rounds with no code. The full feature reference (awards, their lifecycle,
settlement) is docs/FREE-ROUNDS.md; operators award them as described in the
operator guide.
What the player sees (built in)
- The offer when the game opens: the rounds, the stake per round, the expiry, the max win and any actions switched off, with PLAY FREE ROUNDS and, when the award allows it, NO THANKS. There is no "later": until the player answers, and until an accepted award is played, real-money play of that game is refused with
FREE_ROUNDS_PENDINGand the offer comes back. - A counter at the top while free rounds are played ("FREE ROUNDS 2/10 · WON €1.50"), each round's win shown as a free-round win.
- A summary at the end ("You won €3.00 in 10 free rounds"; "Maximum win reached" when the award's cap ended it).
- In the shared menu the bet buttons give way to the rounds left, Bet shows the award's stake and Win reads "Free round win". A replay of a free round is marked "FREE ROUND 2/10".
Backend
$context->betalready is the award's stake. Take the stake only from there, never from client input.$context->freeRound(awardId,roundNumber,totalRounds,bet) and$context->isFreeRound()are there for presentation only. Never change the math on them.- A game whose round can add stake after it opens (double, split, insurance, a bonus buy, a gamble) implements
App\Sdk\Contracts\HasFreeRoundRules. The kernel then refuses those actions in a free round before the game runs, and the offer and rules show the RTP without them:
public function freeRoundDisabledActions(): array { return ['double', 'split', 'insurance']; }
public function freeRoundRtp(?int $rtpLevel): string { return '99.10'; } // the RTP without them
A game whose math cannot settle against one fixed stake implements NoFreeRounds
(noFreeRoundsReason()). That is rare: table games take free rounds with
HasFreeRoundRules. Legacy engine games never get free rounds: their engine takes the
stake from the wallet itself.
Frontend
With the shared menu there is nothing to do. A game with its own bet controls (Dice Duel is the
reference) does this while RgsSdk.isFreeRound() is true:
function renderBet() {
var free = RgsSdk.isFreeRound();
betLabel.textContent = RgsSdk.formatMoney(free ? RgsSdk.getFreeRounds().bet : betSteps[betIndex]);
betDown.disabled = betUp.disabled = free; // the award's stake is fixed
}
RgsSdk.onFreeRoundsChange(renderBet); // offer accepted, last round played
// No balance check on a free round's stake; hide RgsSdk.getFreeRounds().disabledActions.
// RgsSdk.play() sends the award's stake whatever bet_amount it is given.
RgsSdk.getFreeRounds() returns {awardId, status, roundsTotal, roundsLeft,
roundsPlayed, bet, winnings, maxWin, maxWinReached, forfeitedRounds, expiresAt, rejectable,
disabledActions, rtp} or null. A game that draws its own free-round screens boots with
RgsSdk.boot({ freeRoundsUi: false, … }) and calls RgsSdk.acceptFreeRounds() /
RgsSdk.rejectFreeRounds() itself. Never cover the SDK's offer and counter.
Testing
play-test-round cannot play free rounds yet, validate-game does not check them,
and sandbox games cannot be given an award. Locally (WALLET_ADAPTER=local), create an award
for player dev in the backoffice (Promotions → Free Round Assignments) and open
/games/{code}/index.html?token=dev&game_id={code}: you should see the offer, the counter,
the free-round wins and the summary. Requirement T18 in docs/GAME-DEVELOPMENT.md lists what a
game must do; the API details are in docs/GAME-SDK.md → Free rounds.
Splash screen (showsplash)
The launch URL tells the game whether to show its splash screen. With
showsplash=false the game goes straight into the game:
the player lands on the playable screen, ready to bet, with no extra click.
| Launch URL | The game |
|---|---|
showsplash=false |
Opens on the playable screen: balance, bet and the play/spin/deal control |
showsplash=true, or absent (sandbox, local dev) |
May show its splash as usual |
What is skipped, what still shows
- Skipped: an intro animation or video, a title or logo screen, a "loading complete, tap to continue" or "press to start/play" screen, an introductory feature tour (keep help in the menu's rules).
- Still shown: a progress bar while assets load (the game opens as soon as they have), the SDK's free-round offer (a decision, not a splash), errors and anything a jurisdiction requires.
In the game
Read it with RgsSdk.showSplash(), never from the URL yourself: validate-game fails a frontend that reads showsplash.
RgsSdk.boot({
onReady: function (game) {
buildUi(game);
if (RgsSdk.showSplash()) {
showIntro(function () { showGame(); });
} else {
showGame(); // straight to the playable screen
}
}
});
Sound. Browsers play audio only after a user gesture, and a "press to start" button often exists just for that. Without it, start the game silent and turn sound on at the player's first tap (the first spin or bet). Don't add a start screen back for it.
Who sets it
The RGS adds showsplash to every launch URL. It follows the backoffice
Settings switch "Show splash screen" (off by default, so launches send
showsplash=false), and it is always false while the player has free rounds
waiting. Test a game with ?showsplash=false, e.g.
/sandbox/games/{code}/play.html?showsplash=false. Requirement T19 in
docs/GAME-DEVELOPMENT.md.
Max win
A game declares the most one round can pay (GLI-19 4.4.1(r)) by implementing
App\Sdk\Contracts\HasMaxWin: a multiple of the round's
total stake, the opening stake plus every action stake (double,
split, insurance). Use the top paytable award; it must be at least every award the game can pay.
class DiceDuelGame implements GameInterface, HasMaxWin
{
public function maxWinMultiplier(): string
{
return '2.3'; // a decimal string, never a float
}
}
- The kernel caps each round's win at multiplier × stake, settled exactly like the win; a capped round records
win_cap(multiplier,raw,paid) in its result. The game itself never caps, and never touches money. - The
max_winsection ofrules.jsonstates the same value (a test checks it). Aggregators get it asmaxWinBetMultiplierin the game feeds and when the game is registered. - Without
HasMaxWin,validate-gamewarns (it does not fail) and the RGS assumes the configured default (5000× unless set). Declare the real value: too high hides stakes needlessly, too low caps legitimate wins.
The operator's max win
An operator may set a max win per currency (backoffice → Wallets → Max win). The RGS enforces it; the game only respects what load_game sends.
- Bet ladder:
load_gameoffers no stake above the operator's max win ÷ the game's multiplier (rounded down), and sendsmax_round_stakeandmax_win_amount. With a max win of 5000 and a 500× game the highest bet is 10. When no bet fits, the launch is refused. Build the bet selector only frombet_steps/min_bet/max_bet. - At play: a round whose stake × multiplier exceeds the max win is refused with
BET_REJECTEDbefore the game runs (this catches a limit lowered after the load). - Actions that stake again (
HasActionStakes: double, split, insurance) are checked against the max win recorded when the round opened, before the wallet is charged. Grey such actions out withRgsSdk.canStake(cost); blackjack does. - Free rounds: an award whose stake × multiplier exceeds the max win is refused when it is assigned, and cancelled at
load_gameif the limit is lowered later.
The game never sees the operator max win and never lowers a win: an accepted round pays in full, capped only by its own multiplier. Details in docs/GAME-SDK.md → Max win.
Bonus buy
A bonus buy lets the player pay a price to enter a feature directly (for example, 10 free spins). The price is a multiple of the bet the player chose (the base bet); the round is one normal round whose stake is the price, and the feature plays on the base bet. The RGS prices, checks and charges every buy; the game only plays the feature.
Declare the options
class StarfallGame implements GameInterface, HasRtpLevels, HasBonusBuy, HasFreeRoundRules
{
public function bonusBuyOptions(): array
{
return [
new BonusBuyOption(
id: 'freespins',
name: '10 free spins',
priceMultiplier: '100', // price = base bet × 100
rtp: ['default' => '96.10', 96 => '96.10', 94 => '94.05'], // RTP per level
maxWinMultiplier: '50', // win cap = price × 50
),
];
}
public function freeRoundDisabledActions(): array
{
return [HasBonusBuy::ACTION]; // never in a free round
}
public function play(GameContext $context): GameResult
{
if ($context->isBonusBuy()) {
return $this->startFreeSpins($context, $context->baseBet); // same math as a natural trigger
}
// ... a normal spin on $context->bet
}
}
- Multipliers and RTPs are decimal strings.
rtpkeys are'default'or the game's RTP levels; a level without an RTP means the option is not offered at that level. - The feature starts on
$context->baseBet;$context->betis the price. The game reads the option from$context->bonusBuy, never a price or option from the input. - A bought feature starts from a fixed state: nothing carried over from earlier rounds may change its value.
- A feature of several steps continues with
rquest=playlike any multi-step round.
What the RGS does
- The offer:
load_gamesendsbonus_buy(ornull): the options at the session's RTP level and the exactpricesper option and bet step. - The buy:
rquest=bonus_buywithgame_session,optionandbet_amount(the base bet). The client never sends a price; the RGS charges base bet ×priceMultiplier, exactly, before the game runs. - Refused with
REQUEST_DECLINEDwhile a round is open, in a free round or with free rounds pending, in autoplay, where the jurisdiction switchesbonusBuyoff, or on a game without options. - Refused with
BET_REJECTED: an unknown option or one not offered at the RTP level, a base bet that is not offered, a price above the jurisdiction'sbonusBuyMaxPriceor the wallet's max bet, or a price ×maxWinMultiplierabove the operator's max win. - Win cap: the whole round pays at most price ×
maxWinMultiplier; the step that reaches it pays the rest, later steps pay 0.
Client SDK
const offer = RgsSdk.getBonusBuy(); // null when bonus buy is off
const price = RgsSdk.bonusBuyPrice(bet, 'freespins'); // the server's exact price
if (await RgsSdk.confirmBonusBuy(bet)) { // the standard confirmation dialog
const result = await RgsSdk.buyBonus('freespins', bet);
}
RgsSdk.createMenu({ onBuyBonus: () => { /* open your buy screen */ } });
buyBonus() is refused without a confirmation. Show the option, the price in the player's currency, its RTP and that the price buys one round.
Proving the RTP
validate-game (and publish-game) checks every option: its RTP inside the 99 % interval
of a quick simulation at each level, within ±1.0 percentage point of the game's RTP, an RTP for every level, refused in
free rounds, and a fixed starting state. Measure the real RTP with simulate-rtp (MCP) while building and
php artisan game:simulate {code} --option={id} --rtp-level={N} --rounds=50000000 for the math sheet:
a bought round is far more volatile than a spin.
Wallets
A bonus buy is a normal bet and win on every wallet: the price is one withdrawal, the wins are deposits.
A wallet API without tags gets plain calls. The platform wallet gets type: "bonus_buy" on the price and
type: "bonus_win" on the wins, also on a wallet set to one call per round (a bought round never uses
betandwin). Details in docs/BONUS-BUY.md.
Player history & replay
Everything a game records via GameResult::input() / outcome() / step() powers these
automatically. Replay links are temporary signed URLs.
| Endpoint | Data required | Purpose |
|---|---|---|
GET /player/history |
valid token (query), optional game, page |
HTML page listing only that player's rounds with replay links (2h signed); 401 without a valid token |
GET /player/replay/{roundId} |
valid signature, expiring within 24h | HTML page embedding the game in replay mode |
GET /api/game-round/{roundId}/replay |
valid signature, expiring within 24h |
Raw replay JSON: id, game_code, betamount,
winamount (decimal strings), input, result,
steps, created_at
|
The #replay_data= fragment handed to the game is base64url-encoded JSON of that
payload; the client SDK decodes it and calls the game's onReplay(replay) callback. All
money values (winamount, betamount) are decimal strings, consistent with the play
API. The full flow — recording, delivery, rendering — is described in
Game replay.
Health check
GET /api/health → 200 { "status": "ok" }
GET /up → 200 (framework liveness)
How operators integrate — launch API and wallet contract — is in the operator integration guide.
Server SDK reference (PHP)
Namespace App\Sdk. Your game implements one method:
interface GameInterface
{
public function play(GameContext $context): GameResult;
}
Game classes are resolved through the Laravel container (constructor injection works) and
must be stateless — all round state travels through
GameContext/GameResult.
GameContext (input)
| Property / method | Type | Meaning |
|---|---|---|
$context->playerId |
int |
Internal player id |
$context->gameCode |
string |
Your game code ('dd') |
$context->sessionId |
string |
Current game session ULID |
$context->currency |
string |
Player currency ('EUR') |
$context->betAmount |
float |
Stake for this call — 0.0 on continuation steps |
$context->input |
array |
All client parameters of the play call |
$context->input($key, $default) |
mixed |
Read one parameter |
$context->requireInput($key) |
mixed |
Read one parameter or throw GameException |
$context->action() |
string |
Multi-step action ('bet', 'hit', …), defaults
'bet'
|
$context->isContinuation |
bool |
true when resuming an open round |
$context->state |
?array |
State returned from the previous step (null on fresh rounds) |
$context->steps |
array |
Steps recorded so far this round |
$context->rng |
Rng |
The only allowed randomness source |
$context->features |
Features |
What the player's jurisdiction allows, read with ->get('bonusBuy', true); refuse a feature that is
off with GameException — see jurisdiction features
|
GameResult (output)
| Factory / setter | Meaning |
|---|---|
GameResult::completed(float $winAmount = 0) |
Round finished. Wallet settles; the client gets a fresh session. |
GameResult::awaitingAction(array $state, float $winAmount = 0) |
Round still open (multi-step games) |
->payload(array) |
Client-visible response data (winamount and status_id added
automatically)
|
->input(array) |
What the player chose — persisted for history/replay |
->outcome(array) |
What happened — persisted for history/replay |
->step(string $event, array $data = []) |
Append one replayable step |
->steps(array) |
Replace all steps (use to carry $context->steps forward) |
->summary(string) |
One human-readable line describing the round |
->winType(string) |
Override the recorded win type (default derived
win/push/no-win)
|
Rng
Backed by PHP's CSPRNG. Available as $context->rng:
| Method | Example |
|---|---|
int($min, $max) |
die roll: int(1, 6) |
chance($probability) |
chance(0.475) → bool |
pick($items) |
pick(['heads','tails']) |
uniqueInts($min, $max, $count) |
keno draw: uniqueInts(1, 80, 20) |
shuffle($items) |
shuffled copy, Fisher-Yates |
weightedPick($weights) |
slot reels: weightedPick(['cherry'=>50,'seven'=>5]) |
deck() |
shuffled 52-card deck as 0–51 indexes |
GameException
Throw it (or use requireInput()) to reject a request cleanly. The request aborts
before any wallet transaction — the player is never charged —
and the client receives HTTP 400, which the client SDK surfaces as a rejected promise.
Client SDK reference (JS)
resources/rgs-games/_shared/rgs-sdk.js
(ES5, no dependencies). Exactly one of the three boot callbacks fires.
| Function | Purpose |
|---|---|
RgsSdk.boot(options) |
Bootstrap + load_game handshake; onReady(game) /
onReplay(replay) / onError(message)
|
RgsSdk.play(params) |
Play a round/step; auto token, session, game_id, session rotation, balance
update. Returns a promise.
|
RgsSdk.ack() |
Acknowledge the shown result (fire-and-forget) |
RgsSdk.refreshBalance() |
Re-fetch balance (get_accountsum) |
RgsSdk.onBalanceChange(cb) |
Called with the new balance whenever it changes — bind your HUD once |
RgsSdk.getCurrency() |
Player's currency (the wallet's once loaded, else ?currency=), e.g. USD |
RgsSdk.getLanguage() / getLanguageIso() |
Player's language from ?lang=: locale code (es_ES) / ISO 639-1 (es) |
RgsSdk.formatMoney(amount) |
Amount in the player's currency, with symbol and separators per the currency's RGS
settings, never the player's language, e.g. €1,234.50,
R$ 1.234,50; takes a decimal string
|
RgsSdk.formatAmount(amount) |
The number with the currency's decimals and separators, no symbol
("1.234,50" in BRL); pads, never rounds. For display only — never send it
back, send the original string
|
RgsSdk.compareAmounts(a, b) |
Compares two amounts exactly as strings: −1, 0 or 1 — use it for "can afford" checks
(compareAmounts(bet, RgsSdk.getBalance()) > 0)
|
RgsSdk.getCurrencyFormat() |
For drawing amounts yourself: { code, symbol, decimals, symbolPosition, symbolSpace,
decimalSeparator, thousandsSeparator, example } — symbol, position, separators and
decimals from the currency's RGS settings
|
RgsSdk.getBalance() / getSession() / getGame() |
Current state accessors; getBalance() returns a decimal string |
RgsSdk.isReplay() |
true in replay mode |
RgsSdk.getFeature(key, default) / RgsSdk.getFeatures() |
The jurisdiction's features (rgs_features; in replay the round's) — see
jurisdiction features
|
RgsSdk.showSplash() |
false when the launch says showsplash=false: skip the intro and any "press to start" — see splash screen |
RgsSdk.canStake(extra, stakeSoFar?) |
Whether the open round may stake extra more (double, split, insurance) within the operator's max win (max_round_stake); true when there is none. Advisory: the RGS checks every action itself — see max win |
RgsSdk.createMenu(options) |
Creates the unified game menu once per page; returns null if
GameMenuHtml.js is not loaded. Options: betOptions, bet,
currency, soundEnabled, playLabel,
playingLabel, onPlay(bet), onBetChange(bet),
onSoundToggle(enabled)
|
RgsSdk.request(params) |
Low-level RGS call (adds token, normalizes errors) |
The game object passed to onReady: name,
balance, currency, decimals, minBet,
maxBet, betSteps, defaultBet, session;
balance, minBet, maxBet, betSteps and
defaultBet are decimal strings. maxRoundStake is the most one round may stake
under the operator's max win (a decimal string), or null when the operator sets none. The replay object passed to
onReplay: input, result, steps,
winamount, betamount (decimal strings).
Hard rules
These keep the RGS certifiable — treat them as non-negotiable requirements:
-
All randomness through
$context->rng. Neverrand(),mt_rand(),shuffle(), orarray_rand()in game code. -
Games never touch money. No wallet calls, no balance math
beyond computing
winAmountfrombetAmount. -
Stateless game classes. Round state only via
awaitingAction($state)→$context->state. -
Validate every client input — the client is untrusted.
Reject with
GameException; it aborts before the player is charged. -
Record replayable data
(
input/outcome/step) for every round. - The frontend never decides outcomes. It renders what the server returns; the displayed balance always comes from the RGS response.
-
Game code is a string stored in the
games.game_idcolumn (single source of truth). The game client must exist atresources/rgs-games/{code}/. - The stated RTP must match the implemented math model. New games must never use the legacy eval engine.
-
Follow the jurisdiction. Never offer a feature
RgsSdk.getFeature()reports as off, read each with a permissive default, and never read the jurisdiction from the URL — see jurisdiction features.
Quick reference
php artisan make:game <code> "<Name>" # scaffold a new game
./vendor/bin/pest # run all tests
WALLET_ADAPTER=local # .env — then open:
# /games/<code>/index.html?token=dev&game_id=<code>
php artisan mcp:start game-sdk # MCP server for AI agents
Blackjack Frontend Development
Complete guide for building a new blackjack frontend that integrates with the RGS.
🎮 Try the demo: Blackjack Pro — Full PixiJS implementation with login, game menu, and complete gameplay. 📥 Download game-pro.js
Game Variant: American Blackjack
The RGS implements American Blackjack with the following rules:
| Rule | Implementation |
|---|---|
| Decks | 1 deck (configurable) |
| Dealer hole card | Yes — dealer gets 2 cards upfront (one hidden) |
| Insurance | Available when dealer shows Ace |
| Peek for blackjack | Yes — insurance resolves immediately if dealer has BJ |
| Double down | Allowed on 9, 10, 11 only |
| Split | Allowed on pairs (same card type) |
| Dealer stands on | 17 (hard and soft) |
| Blackjack payout | 3:2 (1.5× bet) |
| Push | Bet returned |
What makes it American (vs European)
- Hole card dealt immediately — Dealer receives both cards at deal time
- Early insurance resolution — If dealer has blackjack after insurance, it pays immediately
- No "lose all" on dealer BJ — In European, splits/doubles lose everything if dealer has BJ; here insurance protects
Not implemented
Surrender, re-splitting, double after split, side bets (21+3, Perfect Pairs, etc.)
Blackjack API Reference
1. Initialize session — load_game
POST /api/rgs
rquest=load_game&token={token}&game_id=bj
Response includes game_session, accountsum, min_bet,
max_bet, bet_steps.
2. Deal (new round) — play with action=bet
POST /api/rgs
rquest=play&token={token}&game_session={session}&game_id=bj&bet_amount=10&continue=0&action=bet
3. Player actions — play with continue=1
POST /api/rgs
rquest=play&token={token}&game_session={session}&game_id=bj&continue=1&action={action}
Available actions
| Action | Description | When available |
|---|---|---|
bet |
Start new round with bet | Beginning of round (continue=0) |
hit |
Draw another card | Normal play |
stand |
Keep current hand | Normal play |
double |
Double bet, take one card, stand | Player score 9-11, first decision |
split |
Split pair into two hands | Two cards of same value |
insurance |
Side bet (half initial bet) | Dealer shows Ace |
hit_hand1 / stand_hand1 |
Actions for first split hand | After split |
hit_hand2 / stand_hand2 |
Actions for second split hand | After split |
Response fields
| Field | Type | Description |
|---|---|---|
status_id |
int | 0 = round complete, 3 = waiting for action |
actions |
string | Comma-separated available actions |
dealercards |
string | Comma-separated card indices (0-51) |
playercards |
string | Player cards (normal play) |
player_hand1 / player_hand2 |
string | Split hand cards |
dealer_value |
int | Dealer hand value |
player_value |
int | Player hand value |
result |
string | Outcome text (e.g., player wins, dealer busted) |
winamount |
string | Win amount (decimal string) |
accountsum |
string | Updated balance (decimal string) |
new_game_session |
string | New session for next round (when complete) |
Card mapping (0-51)
Cards are integers 0-51 representing a standard deck. The API returns card IDs in
dealercards and playercards fields.
| ♥ Hearts | ♦ Diamonds | ♣ Clubs | ♠ Spades | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ID | Card | Val | ID | Card | Val | ID | Card | Val | ID | Card | Val | |||
| 0 | 2♥ | 2 | 13 | 2♦ | 2 | 26 | 2♣ | 2 | 39 | 2♠ | 2 | |||
| 1 | 3♥ | 3 | 14 | 3♦ | 3 | 27 | 3♣ | 3 | 40 | 3♠ | 3 | |||
| 2 | 4♥ | 4 | 15 | 4♦ | 4 | 28 | 4♣ | 4 | 41 | 4♠ | 4 | |||
| 3 | 5♥ | 5 | 16 | 5♦ | 5 | 29 | 5♣ | 5 | 42 | 5♠ | 5 | |||
| 4 | 6♥ | 6 | 17 | 6♦ | 6 | 30 | 6♣ | 6 | 43 | 6♠ | 6 | |||
| 5 | 7♥ | 7 | 18 | 7♦ | 7 | 31 | 7♣ | 7 | 44 | 7♠ | 7 | |||
| 6 | 8♥ | 8 | 19 | 8♦ | 8 | 32 | 8♣ | 8 | 45 | 8♠ | 8 | |||
| 7 | 9♥ | 9 | 20 | 9♦ | 9 | 33 | 9♣ | 9 | 46 | 9♠ | 9 | |||
| 8 | 10♥ | 10 | 21 | 10♦ | 10 | 34 | 10♣ | 10 | 47 | 10♠ | 10 | |||
| 9 | J♥ | 10 | 22 | J♦ | 10 | 35 | J♣ | 10 | 48 | J♠ | 10 | |||
| 10 | Q♥ | 10 | 23 | Q♦ | 10 | 36 | Q♣ | 10 | 49 | Q♠ | 10 | |||
| 11 | K♥ | 10 | 24 | K♦ | 10 | 37 | K♣ | 10 | 50 | K♠ | 10 | |||
| 12 | A♥ | 1/11 | 25 | A♦ | 1/11 | 38 | A♣ | 1/11 | 51 | A♠ | 1/11 | |||
// JavaScript card decoding
function decodeCard(id) {
var suits = ['♥', '♦', '♣', '♠'];
var ranks = ['2','3','4','5','6','7','8','9','10','J','Q','K','A'];
return ranks[id % 13] + suits[Math.floor(id / 13)];
}
// decodeCard(10) → "Q♥" (Queen of Hearts)
// decodeCard(51) → "A♠" (Ace of Spades)
Result strings
| Result | Meaning |
|---|---|
ongoing |
Round in progress |
player wins |
Player wins |
player wins with blackjack |
Natural 21 (1.5× payout) |
dealer wins |
Dealer wins |
dealer busted |
Dealer over 21 |
player busted |
Player over 21 |
push |
Tie |
Integration Guide
Architecture
Step 1: Player login (Platform API)
POST /api/portal/players/login
{
"email": "player@example.com",
"password": "password123",
"portal_id": 1,
"player_ip": "127.0.0.1"
}
// Response includes wallet_session_token
Step 2: Build game launch URL
https://rgsaws.cloudvlt.com/games/bj/play.html?token={wallet_session_token}&game_id=bj&rgs_url=https%3A%2F%2Frgsaws.cloudvlt.com%2Fapi%2Frgs&device_id=web
Step 3: Frontend implementation
// API helper
function api(params) {
var body = Object.assign({}, params, { token: window.RGS_TOKEN });
return fetch(window.RGS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: Object.keys(body).map(function(k) {
return encodeURIComponent(k) + '=' + encodeURIComponent(body[k]);
}).join('&')
}).then(function(r) { return r.json(); });
}
// Load game
api({ rquest: 'load_game', game_id: 'bj' }).then(function(res) {
state.gameSession = res.game_session;
state.balance = res.accountsum; // decimal string — show it as is, never parseFloat
state.bet = res.default_bet; // a bet_steps entry; send it unchanged
});
// Deal
api({
rquest: 'play',
game_session: state.gameSession,
game_id: 'bj',
bet_amount: state.bet,
continue: 0,
action: 'bet'
}).then(handleResponse);
// Player action
api({
rquest: 'play',
game_session: state.gameSession,
game_id: 'bj',
continue: 1,
action: 'hit' // or 'stand', 'double', 'split', 'insurance'
}).then(handleResponse);
Step 4: Handle response
function handleResponse(res) {
// Update balance
state.balance = res.accountsum;
// Parse cards
state.dealerCards = res.dealercards.split(',').map(Number);
state.playerCards = res.playercards ? res.playercards.split(',').map(Number) : [];
// Check for split
if (res.player_hand1) {
state.split = true;
state.hand1 = res.player_hand1.split(',').map(Number);
state.hand2 = res.player_hand2.split(',').map(Number);
}
// Available actions
state.actions = (res.actions || '').split(',').filter(Boolean);
// Round complete?
if (parseInt(res.status_id) === 0) {
showOutcome(res.result, res.winamount);
state.gameSession = res.new_game_session; // For next round
} else {
showActionButtons(state.actions);
}
renderTable();
}
Interactive API Testers
Test the Blackjack API directly from this page:
Testing with cURL
# Load game
curl -X POST https://rgsaws.cloudvlt.com/api/rgs \
-d "rquest=load_game&token=1|abc123...&game_id=bj"
# Deal ($5 bet)
curl -X POST https://rgsaws.cloudvlt.com/api/rgs \
-d "rquest=play&token=1|abc123...&game_session=SESSION_ID&game_id=bj&bet_amount=5&continue=0&action=bet"
# Hit
curl -X POST https://rgsaws.cloudvlt.com/api/rgs \
-d "rquest=play&token=1|abc123...&game_session=SESSION_ID&game_id=bj&continue=1&action=hit"
# Stand
curl -X POST https://rgsaws.cloudvlt.com/api/rgs \
-d "rquest=play&token=1|abc123...&game_session=SESSION_ID&game_id=bj&continue=1&action=stand"
Full tutorial with platform setup, player creation, and deployment:
docs/BLACKJACK-FRONTEND-TUTORIAL.md
Multiplayer Blackjack
Real-time multiplayer blackjack with synchronized rounds. Up to 7 players can sit at a table and play simultaneously against the dealer. Uses WebSocket for instant updates.
Live Demo: Blackjack Pro — Login and select "MULTIPLAYER" from the game menu. Open in multiple browser tabs to simulate multiple players at the same table.
Game Flow
The following diagram shows the complete flow from joining a table to completing a round:
Authentication (every request)
Every multiplayer request, listing and creating tables included, is made on behalf of one player and must carry that player's game token:
Session-Token: <the player's game token>
-
The RGS works out the player from the token; a
player_idin the request is ignored. Seats, bets and wins belong to that player, and bets are charged to their wallet. -
Operator portals calling from their own server must
forward the player's token on every call. Without it (or with a service account or API
key instead) the answer is
401 {"error": "Invalid or missing token"}. -
A
401means no token or an expired one: ask the player to sign in or reload, rather than retrying the same request. -
Seat actions (leave, bet, action) only work for the token holding the seat; others get
403. Full details:docs/MULTIPLAYER-BLACKJACK-API.md(MCPread-docstopicmultiplayer-blackjack-api).
Tables API
List available tables
GET /api/multiplayer/blackjack/tables
Response:
{
"success": true,
"tables": [
{
"table_id": "table-1",
"name": "VIP Table 1",
"type": "vip",
"min_bet": 1.00,
"max_bet": 500.00,
"seat_count": 7,
"occupied_seats": 2,
"available_seats": 4,
"status": "waiting",
"current_phase": "betting"
}
]
}
Create a new table
POST /api/multiplayer/blackjack/tables
Session-Token: <the player's game token>
Content-Type: application/json
{
"name": "My Table", // optional, auto-generated if not provided
"type": "standard", // optional: standard, vip, turbo (default: standard)
"min_bet": 1.00, // optional, in currency units (default: 1.00)
"max_bet": 500.00, // optional, in currency units (default: 500.00)
"seat_count": 7 // optional: 1-7 (default: 7)
}
Response (201 Created):
{
"success": true,
"table": {
"table_id": "table-a1b2c3d4",
"name": "My Table",
"type": "standard",
"min_bet": 1.00,
"max_bet": 500.00,
"seat_count": 7,
"status": "waiting",
"current_phase": "betting"
}
}
Creates a new blackjack table with the specified configuration. The table is immediately available for players to join. All parameters are optional - sensible defaults are used.
Get table state
GET /api/multiplayer/blackjack/tables/{table_id}?player_id={player_id}
Response:
{
"success": true,
"table": {
"table_id": "table-1",
"name": "VIP Table 1",
"current_phase": "player_turn",
"current_seat": 2,
"dealer_cards": [8],
"dealer_value": null,
"seats": [
{
"seat_number": 1,
"player_id": "player_alice",
"player_name": "Alice",
"bet": 10.00,
"cards": [15, 22],
"hand_value": 17,
"status": "standing",
"is_current": false
},
{
"seat_number": 2,
"player_id": "player_bob",
"player_name": "Bob",
"bet": 25.00,
"cards": [0, 9],
"hand_value": 12,
"status": "playing",
"is_current": true,
"is_me": true,
"available_actions": ["hit", "stand", "double"]
}
]
}
}
Join a table
POST /api/multiplayer/blackjack/tables/{table_id}/join
Content-Type: application/json
{
"seat_number": 3,
"player_id": "player_charlie",
"player_name": "Charlie",
"token": "wallet-session-token"
}
Leave a table
POST /api/multiplayer/blackjack/tables/{table_id}/leave
Content-Type: application/json
{
"seat_number": 3,
"player_id": "player_charlie"
}
Place a bet
POST /api/multiplayer/blackjack/tables/{table_id}/bet
Content-Type: application/json
{
"seat_number": 3,
"bet_amount": 25.00,
"player_id": "player_charlie"
}
Player action
POST /api/multiplayer/blackjack/tables/{table_id}/action
Content-Type: application/json
{
"seat_number": 3,
"action": "hit",
"player_id": "player_charlie"
}
// Valid actions: hit, stand, double, split, insurance
Testing with cURL
# List tables
curl https://rgsaws.cloudvlt.com/api/multiplayer/blackjack/tables
# Create table
curl -X POST https://rgsaws.cloudvlt.com/api/multiplayer/blackjack/tables \
-H "Content-Type: application/json" \
-d '{"name":"Test Table","type":"standard","min_bet":5,"max_bet":500,"seat_count":6}'
# Join table
curl -X POST https://rgsaws.cloudvlt.com/api/multiplayer/blackjack/tables/{tableId}/join \
-H "Content-Type: application/json" \
-d '{"seat_number":1,"player_id":"test123","player_name":"Tester"}'
# Place bet
curl -X POST https://rgsaws.cloudvlt.com/api/multiplayer/blackjack/tables/{tableId}/bet \
-H "Content-Type: application/json" \
-d '{"seat_number":1,"bet_amount":25,"player_id":"test123"}'
# Perform action (hit/stand/double/split)
curl -X POST https://rgsaws.cloudvlt.com/api/multiplayer/blackjack/tables/{tableId}/action \
-H "Content-Type: application/json" \
-d '{"seat_number":1,"action":"hit","player_id":"test123"}'
Launch Game URL
https://rgsaws.cloudvlt.com/games/mbj/play.html?token={wallet_session_token}&game_id=mbj
Game Flow
| Phase | Description | Actions |
|---|---|---|
betting |
Players join table and place bets | join, leave, bet |
player_turn |
Each player acts in seat order (1→6) | hit, stand, double, split, insurance |
settling |
Dealer draws, hands are compared | — |
completed |
Results displayed, ready for new round | new-round |
Synchronized Round Flow
- Players join during
bettingphase and place bets - Round starts: dealer deals 2 cards to each player with a bet, plus 2 to dealer (1 face down)
- Players act in seat order (1→6). Only the current seat can take actions
- After all players finish, dealer reveals hole card and draws to 17+
- Hands are settled, winnings credited
- Table resets to
bettingfor next round
Real-time Updates (WebSocket)
The multiplayer blackjack uses Laravel Echo with Pusher/Reverb for real-time updates. All players at a table receive instant updates when any action occurs.
Channel
blackjack-table.{table_id}
Event: table.updated
// Broadcast payload
{
"table": { /* full table state */ },
"timestamp": "2026-09-18T14:30:00+00:00"
}
JavaScript Client Example
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: 'your-pusher-key',
cluster: 'mt1',
forceTLS: true
});
// Subscribe to table updates
Echo.channel(`blackjack-table.${tableId}`)
.listen('.table.updated', (e) => {
console.log('Table updated:', e.table);
updateUI(e.table);
});
6-Deck Shoe
Multiplayer tables use a 6-deck shoe (312 cards) that is automatically reshuffled when fewer than 20 cards remain. Card IDs use the same 0-51 mapping as single-player blackjack, repeated 6 times in the shoe.