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, PixiJS-rendered) — 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                    RGS (this app)                   Casino
┌──────────────────┐    POST     ┌──────────────────────────┐   ┌───────────┐
│ /games/{code}/   │  /api/rgs   │ RgsController             │   │  Wallet   │
│  index.html      │ ──────────► │  ├ load_game              │   │  (QTech,  │
│  js/game.js      │             │  ├ play ──► GameDispatcher │──►│  platform,│
│  _shared/        │ ◀────────── │  └ get_accountsum    │    │   │  local)   │
│   rgs-sdk.js     │    JSON     │                     ▼    │   └───────────┘
└──────────────────┘             │        YOUR GAME CLASS    │
                                 │   (App\Sdk GameInterface) │
                                 └──────────────────────────┘

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.

Money is handled in integer cents internally; the wire format uses decimal strings. Game code never touches either — you return a float winAmount and the SDK does the rest.

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.

ComponentPathRole
GameInterfaceapp/Sdk/Contracts/GameInterface.phpThe contract: play(GameContext): GameResult
GameContextapp/Sdk/GameContext.phpInput: player, bet, client params, prior state, RNG
GameResultapp/Sdk/GameResult.phpOutput: win amount, client payload, replay data, round state
GameExceptionapp/Sdk/GameException.phpClean rejection — aborts before the player is charged
Rngapp/Sdk/Rng.phpCSPRNG helpers — the only allowed randomness source
GameRegistryapp/Sdk/GameRegistry.phpMaps game codes → classes (from config/games.php)
GameKernelapp/Sdk/GameKernel.phpRuns SDK games, persists rounds, adapts to the wire format
GameDispatcherapp/Sdk/GameDispatcher.phpRoutes to the SDK kernel or the frozen legacy engine

Client SDK — resources/rgs-games/_shared/

FileRole
rgs-sdk.jsThe game client API: boot(), play(), ack(), balance tracking, session rotation, replay mode, error normalization
rgs_bootstrap.jsLaunch-parameter handling (token, rgs_url, replay payloads)
query_string.jsQuery-string helper

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:

AdapterUse
platformThe in-house platform wallet (combined bet+win call)
qtechQTech Game Provider API v1.11 (see docs/QTECH-INTEGRATION.md)
localDev-only in-process wallet for browser-testing games (refuses production)

Data model

TableMeaning
gamesGame catalogue: code (game_id), name, min/max bet, RTP, active flag
game_sessionsOne per round-in-progress per player; rotates when a round completes
game_roundsOne per round: bet/win in cents, win type, input/result/steps/state JSON — powers history and replay
rgs_players, rgs_wallet_transactionsExternal-player mapping, wallet audit + rollback data

Tooling

ToolPurpose
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 suitePest; game-test pattern in tests/Feature/Sdk/GameSdkTest.php

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 and it should typically be 90–99%.
  • 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:

ArtifactPath
Backend game classapp/Games/HiloGame.php
Frontendresources/rgs-games/hilo/index.html + js/game.js
Blade viewresources/views/rgs-games/hilo.blade.php
Registry entryconfig/games.php
Database rowgames 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="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 / currency
        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 is in CENTS — divide by 100.
        renderReplay(replay);
    },
    onError: function (message) { showErrorOverlay(message); }
});

RgsSdk.onBalanceChange(function (balance) { hud.balance.text = balance.toFixed(2); });

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

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 /player/history?token=…, replay a round, confirm it renders from stored data.
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.0,
        '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).

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 — carry steps forward with ->steps([...$context->steps, ...$newSteps]).
  • 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, admin round inspection, and the QTech game-result verification endpoint, so recording good replay data is a hard requirement, not a nicety.

1. Recording — what the backend persists

During play(), your game records the round on the GameResult:

CallPersisted asWhat to put in it
->input(array)game_rounds.inputWhat the player chose (their guess, picked numbers, actions)
->outcome(array)game_rounds.resultWhat happened (dice, cards, totals, won/lost)
->step(event, data)game_rounds.stepsOne 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. Multi-step games must carry steps forward with ->steps([...$context->steps, ...$new]) so the whole round replays, not just the last action.

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 (2 hours; QTech game-result links are valid 24 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.

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:

FieldMeaning
replay.inputWhat you stored via GameResult::input()
replay.resultWhat you stored via GameResult::outcome()
replay.stepsThe recorded step list, in order
replay.winamountWin amount in cents — divide by 100 for display

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) / 100;   // history sends cents

    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)

  • Play a few rounds against the local wallet, open /player/history?token=dev, and replay one — it must render correctly from stored data alone.
  • For multi-step games, replay a round with several actions and confirm every step re-renders in order.
  • 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:

HalfFileWhat to copy from it
Backendapp/Games/DiceDuelGame.phpInput validation with GameException, RNG usage, payout math, payload/input/outcome/step/summary recording
Frontendresources/rgs-games/dd/index.html + js/game.jsThe 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. It is auto-wired for Claude Code via .mcp.json:

{
  "mcpServers": {
    "rgs-game-sdk": {
      "command": "php",
      "args": ["artisan", "mcp:start", "game-sdk"]
    }
  }
}
ToolPurpose
read-docsThis guide, the SDK reference, QTech docs, READMEs
read-reference-gameDice Duel source, scaffold stubs, the client SDK
scaffold-gameRuns make:game
list-gamesAll games with engine, limits, frontend status
validate-gameChecks registry/class/DB/frontend wiring
play-test-roundPlays real rounds through the full pipeline (dev wallet) — verifies outcomes, payouts, input validation
run-testsRuns 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.
  6. play-test-round — play real rounds, including invalid input (it must fail cleanly with no charge).
  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.

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 2 decimals on the wire ("accountsum": "998.70"); bets sent as decimal numbers. Internally integer cents.
  • 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:

ParamTypeRequiredDescription
rqueststringyesMethod name: load_game, play, get_accountsum, ack_game_session
tokenstringyesWallet session token from the launch URL

rquest load_game

Validates the token against the wallet, opens a game session, and returns the game configuration. Must be called once before playing.

ParamTypeRequiredDescription
game_idstringyesGame 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",
  "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,
  "status": "OK"
}
FieldMeaning
game_sessionSession id to pass to every play call. One session = one round.
accountsumPlayer balance (decimal string)
min_bet / max_bet / bet_steps / default_betBet configuration — clients must build their bet UI from these; the ladder arrives pre-clamped if the wallet caps bets
linesLine count for slot-style games (1 otherwise)

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.

ParamTypeRequiredDescription
game_sessionstringyesThe current session from load_game (or the last new_game_session)
bet_amountdecimalyes*Stake for this round. *Ignored (treated as 0) when continuing an open multi-step round.
game_idstringnoGame code (informational; the session already knows it)
game-specific…anyper gameEvery 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.3,
  "status_id": 0,
  "accountsum": "1001.30",
  "multi_state": 0,
  "new_game_session": "01JZWX4A7B8C9D0E1F2G3H4J5K",
  "status": "OK"
}
FieldMeaning
game payloadWhatever the game returned via GameResult::payload() (here: dice, total, guess, won)
winamountWin for this call (decimal)
accountsumBalance after settlement
multi_state0 = round finished. 1 = round still open — the player must act again
status_id0 finished, 3 awaiting player action
new_game_sessionPresent 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.

ParamTypeRequired
game_sessionstringyes
{ "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.

Game launch URL

Games are static frontends served at /games/{code}/index.html. The casino/aggregator launches them with query parameters:

ParamRequiredDescription
tokenyesWallet session token for the player
game_idyesGame code, passed to load_game
rgs_url (alias api_url)noFull RGS endpoint URL; defaults to <origin>/api/rgs
device_idnoweb | mobile
resume_sessionnoSession id to resume
replay + #replay_data=noReplay mode — the game renders the encoded round instead of playing
https://rgs.example.com/games/dd/index.html?token=abc123&game_id=dd&device_id=web

Player history & replay

Everything a game records via GameResult::input() / outcome() / step() powers these automatically. Replay links are temporary signed URLs.

EndpointData requiredPurpose
GET /player/historytoken (query), optional game, pageHTML page listing the player's rounds with replay links (2h signed)
GET /player/replay/{roundId}valid signatureHTML page embedding the game in replay mode
GET /api/game-round/{roundId}/replayvalid signatureRaw replay JSON: id, game_code, bet_cents, win_cents, winamount (cents), 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. Note replay.winamount is in cents. The full flow — recording, delivery, rendering — is described in Game replay.

Health check

GET /api/health   → 200 { "status": "ok" }
GET /up           → 200 (framework liveness)

QTech platform inbound endpoints (/api/qtech/game-launch, /api/qtech/game-result) and the outbound platform-wallet contract are documented in docs/API.md and docs/QTECH-INTEGRATION.md.

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 / methodTypeMeaning
$context->playerIdintInternal player id
$context->gameCodestringYour game code ('dd')
$context->sessionIdstringCurrent game session ULID
$context->currencystringPlayer currency ('EUR')
$context->betAmountfloatStake for this call — 0.0 on continuation steps
$context->inputarrayAll client parameters of the play call
$context->input($key, $default)mixedRead one parameter
$context->requireInput($key)mixedRead one parameter or throw GameException
$context->action()stringMulti-step action ('bet', 'hit', …), defaults 'bet'
$context->isContinuationbooltrue when resuming an open round
$context->state?arrayState returned from the previous step (null on fresh rounds)
$context->stepsarraySteps recorded so far this round
$context->rngRngThe only allowed randomness source

GameResult (output)

Factory / setterMeaning
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:

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

FunctionPurpose
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.getBalance() / getSession() / getGame()Current state accessors
RgsSdk.isReplay()true in replay mode
RgsSdk.request(params)Low-level RGS call (adds token, normalizes errors)

The game object passed to onReady: name, balance, currency, minBet, maxBet, betSteps, defaultBet, session. The replay object passed to onReplay: input, result, steps, winamount (cents).

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 2–16 lowercase alphanumeric chars and must match in config/games.php, games.game_id, and resources/rgs-games/{code}/.
  8. The stated RTP must match the implemented math model. New games must never use the legacy eval engine.

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. View the source at resources/rgs-games/bj/js/game-pro.js

Game Variant: American Blackjack

The RGS implements American Blackjack with the following rules:

RuleImplementation
Decks1 deck (configurable)
Dealer hole cardYes — dealer gets 2 cards upfront (one hidden)
InsuranceAvailable when dealer shows Ace
Peek for blackjackYes — insurance resolves immediately if dealer has BJ
Double downAllowed on 9, 10, 11 only
SplitAllowed on pairs (same card type)
Dealer stands on17 (hard and soft)
Blackjack payout3:2 (1.5× bet)
PushBet 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

ActionDescriptionWhen available
betStart new round with betBeginning of round (continue=0)
hitDraw another cardNormal play
standKeep current handNormal play
doubleDouble bet, take one card, standPlayer score 9-11, first decision
splitSplit pair into two handsTwo cards of same value
insuranceSide bet (half initial bet)Dealer shows Ace
hit_hand1 / stand_hand1Actions for first split handAfter split
hit_hand2 / stand_hand2Actions for second split handAfter split

Response fields

FieldTypeDescription
status_idint0 = round complete, 3 = waiting for action
actionsstringComma-separated available actions
dealercardsstringComma-separated card indices (0-51)
playercardsstringPlayer cards (normal play)
player_hand1 / player_hand2stringSplit hand cards
dealer_valueintDealer hand value
player_valueintPlayer hand value
resultstringOutcome text (e.g., player wins, dealer busted)
winamountfloatWin amount (decimal)
accountsumfloatUpdated balance
new_game_sessionstringNew session for next round (when complete)

Card mapping (0-51)

Cards are integers 0-51 representing a standard deck:

// JavaScript card decoding
function getCardInfo(cardIndex) {
    var suits = ['hearts', 'diamonds', 'clubs', 'spades'];
    var ranks = ['2','3','4','5','6','7','8','9','10','J','Q','K','A'];
    return {
        suit: suits[Math.floor(cardIndex / 13)],
        rank: ranks[cardIndex % 13]
    };
}

Result strings

ResultMeaning
ongoingRound in progress
player winsPlayer wins
player wins with blackjackNatural 21 (1.5× payout)
dealer winsDealer wins
dealer bustedDealer over 21
player bustedPlayer over 21
pushTie

Integration Guide

Architecture

Portal (Frontend)  →  RGS (Game Logic)  →  Platform (Wallet)
     │                      │                      │
     │ 1. Login             │                      │
     │──────────────────────────────────────────────▶
     │                      │                      │
     │ 2. Get Token         │                      │
     │◀──────────────────────────────────────────────
     │                      │                      │
     │ 3. Launch Game       │                      │
     │─────────────────────▶│                      │
     │                      │ 4. Validate Token    │
     │                      │─────────────────────▶│
     │                      │ 5. Bet/Win           │
     │                      │─────────────────────▶│

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://rgs.example.com/games/bj/play.html?token={wallet_session_token}&game_id=bj&rgs_url=https%3A%2F%2Frgs.example.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 = parseFloat(res.accountsum);
});

// Deal
api({
    rquest: 'play',
    game_session: state.gameSession,
    game_id: 'bj',
    bet_amount: 10,
    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 = parseFloat(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, parseFloat(res.winamount));
        state.gameSession = res.new_game_session;  // For next round
    } else {
        showActionButtons(state.actions);
    }

    renderTable();
}

Testing with cURL

# Load game
curl -X POST https://rgs.test/api/rgs \
  -d "rquest=load_game&token=1|abc123...&game_id=bj"

# Deal ($5 bet)
curl -X POST https://rgs.test/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://rgs.test/api/rgs \
  -d "rquest=play&token=1|abc123...&game_session=SESSION_ID&game_id=bj&continue=1&action=hit"

# Stand
curl -X POST https://rgs.test/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