Guide

Overview & architecture Components Random Number Generator Build a game, step by step 0. Design the math 1. Scaffold 2. Backend (PHP module) 3. Frontend (PixiJS) 4. Verify & test 5. Release Sandbox mode (preview) Creating a sandbox game Distributed dev with MCP Filament admin Exporting to production Multi-step games Game replay Dice Duel as a template Building with AI agents (MCP)

API reference

Conventions Get wallet token load_game play get_accountsum ack_game_session round outcome Game launch URL Currencies & languages Jurisdiction features Free rounds History & replay Health check

SDK reference

Server SDK (PHP) Client SDK (JS) Hard rules

Game tutorials

Blackjack Game variant API reference Integration guide

Multiplayer

Multiplayer Blackjack Tables API Game flow Real-time updates

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
Player's Browser
🌐 Frontend
PixiJS Game
rgs-sdk.js
GameMenuHtml.js
POST /api/rgs
▶
◀
JSON response
RGS Server
⚡ RGS Core
RgsController
load_game →
play →
Your Game Class
GameInterface
bet + win
▶
◀
balance
Casino Platform
💰 Wallet
Platform API
Local (dev)
Browser (PixiJS + SDK)
RGS (Laravel + Game Logic)
Wallet (External API)

One round of play

  1. The casino launches your game URL with a token (wallet session) and game_id.
  2. The client SDK calls load_game — the RGS validates the token against the wallet, opens a game session and returns bet configuration + balance.
  3. The player acts; the client SDK calls play with bet_amount + your game-specific parameters.
  4. 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.
  5. 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_steps entry as bet_amount unchanged, check "can afford" with RgsSdk.compareAmounts(bet, RgsSdk.getBalance()) > 0, show amounts with RgsSdk.formatMoney — never Number(), 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) and menu.showBetControls(false).
  • PIXI games: call GameMenuHtml.create({...}) instead of RgsSdk.createMenu(), set the bottom offset of #game-root to menu.height(), and remove any in-canvas menu layout. Do not load GameMenu.js.

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():

  1. Load the four shared scripts (query_string.js, rgs_bootstrap.js, rgs-sdk.js, GameMenuHtml.js) and skip the PixiJS script.
  2. Call RgsSdk.createMenu() from onReady, as shown above.
  3. Keep the game's own root above the bar. The bar is fixed to the bottom, so set the root's bottom to menu.height() and repeat it on window resize.
  4. 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

  1. Wiring — registry entry, DB row and files consistent? (The MCP validate-game tool checks this automatically.)
  2. Browser — set WALLET_ADAPTER=local in .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.
  3. Automated — write a Pest feature test per the pattern in tests/Feature/Sdk/GameSdkTest.php: full round via POST /api/rgs, payout amount on a win, invalid input → HTTP 400 with no wallet transaction. Run ./vendor/bin/pest.
  4. Replay — open play-test-round's history.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 games row (name, min/max bet, RTP, category, is_active).
  • ./vendor/bin/pint on changed files; full test suite green.
  • Deploy — games are served via the public/games symlink (php artisan games:link in 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 class
  • resources/rgs-games/_sandbox/hilo/ — frontend files
  • resources/views/rgs-games/_sandbox/hilo.blade.php — blade view
  • Row in sandbox_games table (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.html at root level
  • Should contain js/game.js for game logic
  • Use relative paths: ../_shared/sandbox_bootstrap.js and ../_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 to config/games.php
  • migration.sql — INSERT for games table

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 play call routes back with isContinuation = true and 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_session until it receives new_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

  1. 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.
  2. The signed URL opens GET /player/replay/{roundId}, which loads the stored round and embeds the game frontend, launching it with replay=<roundId> plus a #replay_data=<base64url-JSON> fragment containing the full round payload.
  3. The same payload is also available raw (with a valid signature) at GET /api/game-round/{roundId}/replay.
  4. 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's index.html is 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() returns true; 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 a play() response.

4. Verifying replay (definition of done)

  • validate-game fails a game whose frontend has no onReplay, or whose backend never calls ->input(), ->outcome() or ->step().
  • play-test-round shows, per call, what the round recorded (recorded_for_history), lists replay data a call left out under history.problems (must be empty), and returns history.replay_url: a signed link that opens the last round in the replay viewer.
  • Open history.replay_url in 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's sequence, 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

  1. Read both Dice Duel files (or use the MCP read-reference-game tool).
  2. Scaffold your game: php artisan make:game <code> "<Name>" — the scaffold is itself a minimal Dice-Duel-shaped game.
  3. Replace the outcome logic in play() keeping the same structure: validate → RNG → payout → record.
  4. Replace the DOM rendering in js/game.js with your PixiJS stage, keeping the SDK calls (boot/play/ack/onBalanceChange/onReplay) exactly as the template uses them.
  5. Try it live: /games/dd/index.html?token=dev&game_id=dd (requires WALLET_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

  1. read-docs (topic game-development, then game-sdk) — learn components, process, requirements.
  2. read-reference-game — study Dice Duel (backend + frontend) before writing code.
  3. scaffold-game — generate the runnable skeleton. Never create the files by hand.
  4. Edit app/Games/{Class}Game.php (PHP outcome module) and resources/rgs-games/{code}/js/game.js + index.html (PixiJS UI).
  5. validate-game — verify all wiring is consistent, replay support included.
  6. play-test-round — play real rounds, including invalid input (it must fail cleanly with no charge). history.problems must be empty; open history.replay_url and check the round is redrawn as played.
  7. run-tests — the whole suite must pass; add Pest tests per tests/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.

🧪 API Tester POST https://rgsaws.cloudvlt.com/api/rgs

Conventions

  • Requests: POST with application/x-www-form-urlencoded or 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"; see decimals on load_game). Send a bet_steps entry as bet_amount unchanged; never parse amounts into numbers.
  • Auth: the wallet session token from the launch URL authenticates every call.
  • CORS: /api/rgs answers with Access-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.

🔑 Get Wallet Session Token

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".

▶ Try it

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
▶ Try it

rquest get_accountsum

Balance refresh (e.g. on idle). Takes only the common parameters.

{ "accountsum": "998.70", "currency": "EUR", "status": "OK" }
▶ Try it

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.

▶ Try it

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&currency=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. lang may 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 to EUR / en_US.
  • Read them with the client SDK — RgsSdk.getCurrency(), getLanguage(), getLanguageIso() and formatMoney(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_game returns it as decimals (and currency_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_separator in currency_format), e.g. €1,234.50, R$ 1.234,50, 5.000 ₫, ¥1,234. formatMoney applies it — it pads to the currency's decimals and never rounds an exact amount; games that draw digits themselves use RgsSdk.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-game checks 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() and getFeatures() return the replayed round's features.
  • Verify: validate-game fails a sandbox frontend that offers autoplay, turbo, slam stop or bonus buy without reading the features, or that reads jurisdiction from the URL. play-test-round takes jurisdiction (e.g. "SE") and reports the rgs_features the 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_PENDING and 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->bet already 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_win section of rules.json states the same value (a test checks it). Aggregators get it as maxWinBetMultiplier in the game feeds and when the game is registered.
  • Without HasMaxWin, validate-game warns (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_game offers no stake above the operator's max win ÷ the game's multiplier (rounded down), and sends max_round_stake and max_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 from bet_steps / min_bet / max_bet.
  • At play: a round whose stake × multiplier exceeds the max win is refused with BET_REJECTED before 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 with RgsSdk.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_game if 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. rtp keys 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->bet is 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=play like any multi-step round.

What the RGS does

  • The offer: load_game sends bonus_buy (or null): the options at the session's RTP level and the exact prices per option and bet step.
  • The buy: rquest=bonus_buy with game_session, option and bet_amount (the base bet). The client never sends a price; the RGS charges base bet × priceMultiplier, exactly, before the game runs.
  • Refused with REQUEST_DECLINED while a round is open, in a free round or with free rounds pending, in autoplay, where the jurisdiction switches bonusBuy off, 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's bonusBuyMaxPrice or the wallet's max bet, or a price × maxWinMultiplier above 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:

  1. All randomness through $context->rng. Never rand(), mt_rand(), shuffle(), or array_rand() in game code.
  2. Games never touch money. No wallet calls, no balance math beyond computing winAmount from betAmount.
  3. Stateless game classes. Round state only via awaitingAction($state) → $context->state.
  4. Validate every client input — the client is untrusted. Reject with GameException; it aborts before the player is charged.
  5. Record replayable data (input/outcome/step) for every round.
  6. The frontend never decides outcomes. It renders what the server returns; the displayed balance always comes from the RGS response.
  7. Game code is a string stored in the games.game_id column (single source of truth). The game client must exist at resources/rgs-games/{code}/.
  8. The stated RTP must match the implemented math model. New games must never use the legacy eval engine.
  9. 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.

▶ Try it

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}
▶ Try it

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

Portal
🌐 Frontend
Player Login
Game Lobby
Game iframe
3. Launch Game
▶
RGS
⚡ Game Server
load_game
play (bet/hit/stand)
Blackjack Logic
4. Validate + Bet/Win
▶
◀
balance
Platform
💰 Wallet
Player Auth
Balance
Transactions
1. Player logs in via Portal → Platform
2. Platform returns wallet_session_token
3. Portal launches game with token
4. RGS validates token & settles bets

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:

▶ Try it
▶ Try it

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:

Portal/Player RGS API WebSocket Other Players GET /tables Return table list POST /tables/{id}/join {seat, player_id, name} Create GameSession Assign seat Broadcast: joined See player joined BETTING PHASE POST /bet {amount} Record bet Broadcast: bet placed See bet amount ALL BETS PLACED Deal initial cards phase → player_turn Broadcast: cards dealt current_seat = 1 PLAYER TURN PHASE POST /action {hit | stand | double} Execute action Update hand/move next Broadcast: action new cards/values See action result Wait for turn ↺ Repeat for each player SETTLING PHASE Dealer plays Calculate results phase → completed Broadcast: results win/lose/push COMPLETED POST /new-round Reset → betting Broadcast: new round Ready to bet again
Player Request
RGS Processing
WebSocket Broadcast
Other Players

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_id in 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 401 means 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 (MCP read-docs topic multiplayer-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

  1. Players join during betting phase and place bets
  2. Round starts: dealer deals 2 cards to each player with a bet, plus 2 to dealer (1 face down)
  3. Players act in seat order (1→6). Only the current seat can take actions
  4. After all players finish, dealer reveals hole card and draws to 17+
  5. Hands are settled, winnings credited
  6. Table resets to betting for 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.