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
- The casino launches your game URL with a
token(wallet session) andgame_id. - The client SDK calls
load_game— the RGS validates the token against the wallet, opens a game session and returns bet configuration + balance. - The player acts; the client SDK calls
playwithbet_amount+ your game-specific parameters. - The RGS runs your game class to decide the outcome, settles bet/win with the wallet (atomically where supported), persists the round for history and replay, and returns your payload plus the new balance and a fresh session.
- The client animates the result and calls
ack_game_session.
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.
| 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 |
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) |
qtech | QTech Game Provider API v1.11 (see docs/QTECH-INTEGRATION.md) |
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 in cents, 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 |
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:
| 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="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
- Wiring — registry entry, DB row and files consistent? (The MCP
validate-gametool checks this automatically.) - Browser — set
WALLET_ADAPTER=localin.env, then open/games/hilo/index.html?token=dev&game_id=hilo. Every token is a separate player starting with 1000.00 EUR. Play wins, losses, insufficient balance, and try to break it. - Automated — write a Pest feature test per the pattern in
tests/Feature/Sdk/GameSdkTest.php: full round viaPOST /api/rgs, payout amount on a win, invalid input → HTTP 400 with no wallet transaction. Run./vendor/bin/pest. - Replay — open
/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
gamesrow (name, min/max bet, RTP, category,is_active). ./vendor/bin/pinton changed files; full test suite green.- Deploy — games are served via the
public/gamessymlink (php artisan games:linkin the deploy pipeline).
Multi-step games
Games where the player makes decisions mid-round (blackjack: hit / stand / double) keep the round open by returning awaitingAction:
public function play(GameContext $context): GameResult
{
if (! $context->isContinuation) {
// First call: the bet is taken now.
$deck = $context->rng->deck();
$hand = [array_shift($deck), array_shift($deck)];
return GameResult::awaitingAction([
'deck' => $deck,
'hand' => $hand,
'bet' => $context->betAmount, // remember the stake yourself
])
->payload(['hand' => $hand, 'actions' => ['hit', 'stand']])
->step('deal', ['hand' => $hand]);
}
// Continuation: betAmount is 0, your saved state comes back.
$state = $context->state;
return match ($context->action()) {
'hit' => $this->hit($context, $state),
'stand' => $this->stand($context, $state), // -> GameResult::completed(...)
default => throw new GameException('Illegal action'),
};
}
What the SDK does for you:
- The session is marked resumable; the next
playcall routes back withisContinuation = trueand your$state. - The stake is charged once, on the opening call; the win is paid when you return
completed. - One round row is kept and updated each step — carry steps forward with
->steps([...$context->steps, ...$newSteps]). - The client keeps the same
game_sessionuntil it receivesnew_game_session(automatic with the client SDK).
Game replay
Every completed round can be re-watched: the game frontend is
re-opened in a special replay mode and re-renders the round purely from the data persisted when it
was played — no game logic runs again, no wallet is touched, nothing is random. Replay powers the
player history page, 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:
| 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. 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
GET /player/history?token=…lists the player's rounds; each row carries a temporary signed replay URL (2 hours; QTechgame-resultlinks are valid 24 hours). Replay URLs cannot be constructed by hand.- The signed URL opens
GET /player/replay/{roundId}, which loads the stored round and embeds the game frontend, launching it withreplay=<roundId>plus a#replay_data=<base64url-JSON>fragment containing the full round payload. - The same payload is also available raw (with a valid signature) at
GET /api/game-round/{roundId}/replay.
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 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()returnstrue;RgsSdk.play()rejects with"Cannot play in replay mode"— the mode is read-only by construction.- No RGS calls are made: there is no session, no live balance, no
ack(). Hide or freeze the bet UI and balance HUD. - Reuse your normal rendering/animation path fed from
replay.result/replay.steps— a PixiJS frontend replays by running the same timeline it uses for live results, just sourced from the stored steps instead of aplay()response.
4. Verifying replay (definition of done)
- 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:
| Half | File | What to copy from it |
|---|---|---|
| Backend | app/Games/DiceDuelGame.php | Input validation with GameException, RNG usage, payout math, payload/input/outcome/step/summary recording |
| Frontend | resources/rgs-games/dd/index.html + js/game.js | The complete client game loop in ~150 lines: boot → render config → play → animate → ack → repeat, plus replay mode and error handling |
The game itself: the player bets low or high, two dice are rolled; totals 2–6 are low, 8–12 are high, 7 pushes (stake returned). A correct guess pays 2.3×.
Workflow: template → your game
- Read both Dice Duel files (or use the MCP
read-reference-gametool). - Scaffold your game:
php artisan make:game <code> "<Name>"— the scaffold is itself a minimal Dice-Duel-shaped game. - Replace the outcome logic in
play()keeping the same structure: validate → RNG → payout → record. - Replace the DOM rendering in
js/game.jswith your PixiJS stage, keeping the SDK calls (boot/play/ack/onBalanceChange/onReplay) exactly as the template uses them. - Try it live:
/games/dd/index.html?token=dev&game_id=dd(requiresWALLET_ADAPTER=local).
Building with AI agents (MCP)
The repo ships an MCP (Model Context Protocol) server exposing the whole game-building
workflow as tools, so an AI agent can scaffold, implement, validate and play-test a game
end-to-end without human plumbing. It is auto-wired for Claude Code via .mcp.json:
{
"mcpServers": {
"rgs-game-sdk": {
"command": "php",
"args": ["artisan", "mcp:start", "game-sdk"]
}
}
}
| Tool | Purpose |
|---|---|
read-docs | This guide, the SDK reference, QTech docs, 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 |
play-test-round | Plays real rounds through the full pipeline (dev wallet) — verifies outcomes, payouts, input validation |
run-tests | Runs the Pest suite |
The agent workflow
read-docs(topicgame-development, thengame-sdk) — learn components, process, requirements.read-reference-game— study Dice Duel (backend + frontend) before writing code.scaffold-game— generate the runnable skeleton. Never create the files by hand.- Edit
app/Games/{Class}Game.php(PHP outcome module) andresources/rgs-games/{code}/js/game.js+index.html(PixiJS UI). validate-game— verify all wiring is consistent.play-test-round— play real rounds, including invalid input (it must fail cleanly with no charge).run-tests— the whole suite must pass; add Pest tests pertests/Feature/Sdk/GameSdkTest.php.
Server implementation: app/Mcp/GameSdkServer.php. Start manually with
php artisan mcp:start game-sdk for any MCP-compatible client.
API reference — POST /api/rgs
One endpoint; the rquest parameter selects the method. (POST /api/api.php
is a legacy alias.) The client SDK wraps all of this — game code normally never calls these directly.
Conventions
- Requests:
POSTwithapplication/x-www-form-urlencodedor JSON bodies. - Responses: JSON. Every success carries
"status": "OK"; every failure is HTTP 400 with{"status": "Failed", "msg": "reason"}. - Money: decimal strings with 2 decimals on the wire (
"accountsum": "998.70"); bets sent as decimal numbers. Internally integer cents. - Auth: the wallet session
tokenfrom the launch URL authenticates every call. - CORS:
/api/rgsanswers withAccess-Control-Allow-Origin: *.
Common parameters for every method:
| Param | Type | Required | Description |
|---|---|---|---|
rquest | string | yes | Method name: load_game, play, get_accountsum, ack_game_session |
token | string | yes | Wallet session token from the launch URL |
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",
"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"
}
| Field | Meaning |
|---|---|
game_session | Session id to pass to every play call. One session = one round. |
accountsum | Player balance (decimal string) |
min_bet / max_bet / bet_steps / default_bet | Bet configuration — 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) |
Failures: missing/invalid token or unknown game → 400 "Invalid token or game not available".
rquest play
Plays one round — or one step of a multi-step round. Runs the game's outcome logic, settles bet/win with the wallet, persists the round.
| Param | Type | Required | Description |
|---|---|---|---|
game_session | string | yes | The current session from load_game (or the last new_game_session) |
bet_amount | decimal | yes* | Stake for this round. *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.3,
"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) |
accountsum | Balance after settlement |
multi_state | 0 = round finished. 1 = round still open — the player must act again |
status_id | 0 finished, 3 awaiting player action |
new_game_session | Present only when the round completed: use it for the next round. While multi_state=1, keep sending the same game_session. |
Failures (all 400):
- invalid/expired session →
"Game session not found" - game input rejected (
GameException) → the game's message — no wallet transaction occurs - insufficient funds → wallet error message; rollback semantics guarantee the player is refunded on partial failure
rquest get_accountsum
Balance refresh (e.g. on idle). Takes only the common parameters.
{ "accountsum": "998.70", "currency": "EUR", "status": "OK" }
rquest ack_game_session
The client confirms the round result was displayed to the player. Fire-and-forget bookkeeping; failures can be ignored by clients.
| Param | Type | Required |
|---|---|---|
game_session | string | yes |
{ "msg": "game session acknowledged", "status": "OK" }
load_resume_game is deprecated — resuming works through play itself: a resumable session routes the next play call back into the open round.
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 |
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 |
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.
| Endpoint | Data required | Purpose |
|---|---|---|
GET /player/history | token (query), optional game, page | HTML page listing the player's rounds with replay links (2h signed) |
GET /player/replay/{roundId} | valid signature | HTML page embedding the game in replay mode |
GET /api/game-round/{roundId}/replay | valid signature | Raw 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 / 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 |
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.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:
- All randomness through
$context->rng. Neverrand(),mt_rand(),shuffle(), orarray_rand()in game code. - Games never touch money. No wallet calls, no balance math beyond computing
winAmountfrombetAmount. - Stateless game classes. Round state only via
awaitingAction($state)→$context->state. - Validate every client input — the client is untrusted. Reject with
GameException; it aborts before the player is charged. - Record replayable data (
input/outcome/step) for every round. - The frontend never decides outcomes. It renders what the server returns; the displayed balance always comes from the RGS response.
- Game code is 2–16 lowercase alphanumeric chars and must match in
config/games.php,games.game_id, andresources/rgs-games/{code}/. - 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:
| Rule | Implementation |
|---|---|
| Decks | 1 deck (configurable) |
| Dealer hole card | Yes — dealer gets 2 cards upfront (one hidden) |
| Insurance | Available when dealer shows Ace |
| Peek for blackjack | Yes — insurance resolves immediately if dealer has BJ |
| Double down | Allowed on 9, 10, 11 only |
| Split | Allowed on pairs (same card type) |
| Dealer stands on | 17 (hard and soft) |
| Blackjack payout | 3:2 (1.5× bet) |
| Push | Bet returned |
What makes it American (vs European)
- Hole card dealt immediately — Dealer receives both cards at deal time
- Early insurance resolution — If dealer has blackjack after insurance, it pays immediately
- No "lose all" on dealer BJ — In European, splits/doubles lose everything if dealer has BJ; here insurance protects
Not implemented
Surrender, re-splitting, double after split, side bets (21+3, Perfect Pairs, etc.)
Blackjack API Reference
1. Initialize session — load_game
POST /api/rgs
rquest=load_game&token={token}&game_id=bj
Response includes game_session, accountsum, min_bet, max_bet, bet_steps.
2. Deal (new round) — play with action=bet
POST /api/rgs
rquest=play&token={token}&game_session={session}&game_id=bj&bet_amount=10&continue=0&action=bet
3. Player actions — play with continue=1
POST /api/rgs
rquest=play&token={token}&game_session={session}&game_id=bj&continue=1&action={action}
Available actions
| Action | Description | When available |
|---|---|---|
bet | Start new round with bet | Beginning of round (continue=0) |
hit | Draw another card | Normal play |
stand | Keep current hand | Normal play |
double | Double bet, take one card, stand | Player score 9-11, first decision |
split | Split pair into two hands | Two cards of same value |
insurance | Side bet (half initial bet) | Dealer shows Ace |
hit_hand1 / stand_hand1 | Actions for first split hand | After split |
hit_hand2 / stand_hand2 | Actions for second split hand | After split |
Response fields
| Field | Type | Description |
|---|---|---|
status_id | int | 0 = round complete, 3 = waiting for action |
actions | string | Comma-separated available actions |
dealercards | string | Comma-separated card indices (0-51) |
playercards | string | Player cards (normal play) |
player_hand1 / player_hand2 | string | Split hand cards |
dealer_value | int | Dealer hand value |
player_value | int | Player hand value |
result | string | Outcome text (e.g., player wins, dealer busted) |
winamount | float | Win amount (decimal) |
accountsum | float | Updated balance |
new_game_session | string | New 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
| 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) → 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