Gameplay Protocol
Gameplay is Surface 4: Browser → Service over WebSocket. The flow:
- An operator requests a launch URL from Cloud1x2.
- The operator embeds it in an iframe on its own site/app.
- The iframe reaches the Cloud1x2 launcher, which proxies the traffic to the game service.
- The game service serves the game frontend (HTML + JS bundle) — the iframe the player sees.
- The frontend opens a WebSocket to the same service’s
/socket.ioendpoint.
The protocol has three parts: the handshake (auth → verify-launch → session), lifecycle/round events (service → client), and action events (client → service).
Transport: Socket.IO
Section titled “Transport: Socket.IO”The reference implementations use Socket.IO (auto-reconnect, transport fallback, rooms, handshake middleware). The platform frontend expects Socket.IO; plain WebSocket means writing a browser client too.
import { Server } from 'socket.io';import express from 'express';import http from 'node:http';
const app = express();const server = http.createServer(app);const io = new Server(server, { path: '/socket.io', cors: { origin: '*' } }); // restrict in prod
io.use(async (socket, next) => { /* handshake — below */ });io.on('connection', (socket) => { /* after handshake — below */ });
server.listen(3030);The default path is /socket.io; the launcher proxies game services on this
path. Changing it is discouraged.
Handshake
Section titled “Handshake”On connect, the io.use middleware runs: merge auth + query.launchQs, call
POST /internal/verify-launch, populate socket.data on success, or
next(new Error('reason')) on failure (the client receives connect_error).
auth vs. launchQs
Section titled “auth vs. launchQs”Two sources arrive because:
auth— a JS object the browser SPA built at init; it can be stale.query.launchQs— straight from the URL, so it is current and authoritative.
Merge rules:
| Field | Behavior |
|---|---|
gameId | Fill from launchQs if absent in auth. |
demo | If present in launchQs, overwrite (fix a stale auth.demo). |
currency | If present in launchQs, overwrite. |
customer, token, organizationId | Fill from launchQs if absent. |
locale / lang | Fill from launchQs if absent; on liveTest=true, overwrite (translation preview). |
Demo strips currency (profile demo currency is authoritative); real mode strips
currency when launchQs is empty (fail closed).
verify-launch call
Section titled “verify-launch call”const verifyR = await fetch( `${PLATFORM_INTERNAL_BASE_URL}/internal/verify-launch?for=${encodeURIComponent(myServiceUid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-internal-verify-launch-secret': process.env.INTERNAL_VERIFY_LAUNCH_SECRET, }, body: JSON.stringify(mergedAuth), },).then((r) => r.json());
if (!verifyR.ok) { return next(new Error(verifyR.message ?? 'launch failed'));}myServiceUid is learned from the first desired-state push. See
Launch Verification for the response shape.
Populate socket.data
Section titled “Populate socket.data”Copy every field from the (flat) verify-launch response onto socket.data:
Object.assign(socket.data, verifyR); // gameExternalId, engineType, skinKey, demo, // currency, walletBalance, partnerId, customer, // token, locale, betMin, betMax, maxWin, quickBets, ...next();Bet limits (betMin, betMax, maxWin, quickBets) come from verify-launch at
session start, but the operator can change them at runtime. They are constant
within a round (snapshot it), but must be re-fetched before each new round
— see § session payload.
Error scenarios
Section titled “Error scenarios”Errors surface as connect_error on the client (the verify-launch message,
verbatim):
| Message | Meaning |
|---|---|
"Unknown gameId" | No such gameId on the platform |
"Missing mandatory params: ..." | A required real-mode field is missing |
"Currency not allowed for this partner" | Not on the allowlist |
"Partner suspended" / "Operator suspended" | Account suspended |
"Wallet currency mismatch" | Launch currency ≠ partner authenticate currency |
"Partner wallet auth failed: ..." | Real-mode authenticate failed |
session — the first emit
Section titled “session — the first emit”After a successful handshake, emit session with everything the client needs to
render, then the current round state:
io.on('connection', (socket) => { socket.emit('session', { ok: true, demo: socket.data.demo, playerKey: socket.data.playerDisplay, organizationId: socket.data.organizationId, balance: socket.data.walletBalance ?? 0, currency: socket.data.currency, walletCurrency: socket.data.walletCurrency, gameId: socket.data.gameExternalId, skinKey: socket.data.skinKey, engineType: myServiceUid, betMin: socket.data.betMin, betMax: socket.data.betMax, maxWin: socket.data.maxWin, quickBets: socket.data.quickBets, showBalance: socket.data.showBalance !== false, locale: socket.data.locale, language: socket.data.locale, translations: { /* skin locale strings */ }, });
const engine = pickEngineFor(socket.data.gameExternalId); if (engine) socket.emit('gameUpdate', engine.getStatePayload());
socket.on('placeBet', /* ... */); socket.on('cancelBet', /* ... */); socket.on('setLanguage', /* ... */);});Runtime Profile vs. Bet Template
Section titled “Runtime Profile vs. Bet Template”The session payload combines two platform records with different owners and
refresh cadences:
- Runtime Profile (partner-owned) — engine behavior: RTP, bot counts, timings.
- Bet Template (operator-owned, currency-aware) — UI behavior:
betMin,betMax,maxWin,quickBets. The same game under one partner can differ per operator and per currency.
| Field | Source | Refresh |
|---|---|---|
engineType, gameId, skinKey | Game record | Fixed at registration |
demo, currency, walletCurrency, balance, playerKey, organizationId | verify-launch | Session start |
showBalance, locale, language, translations | Skin + locale | Session start |
Engine config (rtpTarget, timings, …) | Runtime Profile | Each round |
betMin, betMax, maxWin, quickBets | Bet Template | Each round |
Per-round refresh rule
Section titled “Per-round refresh rule”Before starting a new round, the service must re-read the player-facing fields of the Runtime Profile and the Bet Template, because the operator/partner may have changed them. Within a round the values are a snapshot; across rounds the refresh is mandatory.
| Refresh | Endpoint | Side effect |
|---|---|---|
| Runtime Profile | GET /internal/runtime-profiles/:id | none (config secret) |
| Bet Template | GET /internal/bet-templates (v1.1) | none (config secret) |
The reference services fetch the bet template in the engine’s
onBeforeRoundStart hook (cached per operator+currency, ~5s TTL), write it to
socket.data, optionally emit a limits-updated event, and fall back to the last
snapshot on error. Do not re-call verify-launch for this (deprecated — it
triggers operator wallet authenticate and TOKEN_TIMEOUT); use
GET /internal/bet-templates.
Round-snapshot vs. round-transition
Section titled “Round-snapshot vs. round-transition”| Question | Answer |
|---|---|
Can maxWin change mid-round? | No — a round starts from a snapshot. |
betMax was 100, the operator lowered it to 75 mid-round — what happens? | The current round continues at 100; the next round starts at 75. |
| Does bot count change mid-round? | No — the bot pool is fixed at engine spawn. |
Incoming events (client → server)
Section titled “Incoming events (client → server)”placeBet
Section titled “placeBet”{ "amount": 10, "guess": "higher" }amount is generic; the rest is engine-specific (guess for Hi-Lo; bets[] for
roulette; none for crash). Server-side handler:
socket.on('placeBet', async (data) => { const eng = pickEngineFor(socket.data.gameExternalId); if (!eng || eng.getStatus() !== 'BETTING') { return socket.emit('betRejected', { message: 'Not in betting phase' }); } const amount = Number(data.amount); if (!Number.isFinite(amount) || amount <= 0) { return socket.emit('betRejected', { message: 'Invalid amount' }); } if (amount < socket.data.betMin || amount > socket.data.betMax) { return socket.emit('betRejected', { message: `Stake must be between ${socket.data.betMin} and ${socket.data.betMax}`, }); } if (engineAlreadyHasBetFromThisPlayer(eng, socket)) { return socket.emit('betRejected', { message: 'Already have a bet this round' }); } if (socket.data.demo) { if (socket.data.walletBalance < amount) { return socket.emit('betRejected', { message: 'Insufficient balance' }); } socket.data.walletBalance -= amount; } else { const debitR = await debit({ /* see Wallet API */ }); if (!debitR.httpOk) { const expired = /token|expired|session/i.test(debitR.data?.status ?? ''); socket.emit('betRejected', { message: expired ? 'Session expired. Please login again.' : 'Wallet debit failed', }); if (expired) { socket.emit('platformError', { message: 'Session expired.' }); socket.disconnect(true); } return; } socket.data.walletBalance = debitR.data.balance; } // ... open the ledger, engine.placeBet(), persist to the service DB socket.emit('betPlaced', { balance: socket.data.walletBalance, amount });});cancelBet
Section titled “cancelBet”Only while the round is still BETTING. Cancel → rollback (real mode) + ledger
cancelled. Emits betCancelled { amount, balance }.
setLanguage
Section titled “setLanguage”{ "lang": "tr" }Loads the skin translations for the new locale and emits
languageChanged { ok, language, translations }.
Engine-specific actions
Section titled “Engine-specific actions”Each engine can add its own: cashOut (crash), lockIn (prediction), spin
(slot). Hi-Lo needs none beyond placeBet’s guess. Naming convention: lower
camelCase.
Outgoing events (server → client)
Section titled “Outgoing events (server → client)”Lifecycle:
| Event | When |
|---|---|
session | First emit after the handshake |
gameUpdate | Engine state changed (current round snapshot) |
gameSuspendUpdate | A game was suspended/resumed by an admin |
partnerGameRevoked | The game left the partner allowlist; the session should end |
platformError | Irrecoverable system error; suggest disconnect |
Round events (engine-defined, Hi-Lo example):
| Event | Meaning |
|---|---|
round_open | Round opened, betting accepted ({ roundId, anchor, openMs, closeAt }) |
round_close | Round closed, result announced ({ roundId, anchor, nextCard }) |
Action responses:
| Event | When |
|---|---|
betPlaced | placeBet succeeded ({ balance, amount }) |
betRejected | placeBet rejected ({ message }) |
betCancelled | cancelBet succeeded ({ amount, balance }) |
cashOutSuccess / cashOutRejected | cashOut result (crash) |
languageChanged | After setLanguage ({ ok, language, translations }) |
balanceUpdate | Balance refreshed after a credit |
Conventions: messages are JSON; amounts are numbers matching the operator balance; event names are consistent within a service (the platform frontend is reflective — it writes received events straight to its store).
Engine bucketing and rooms
Section titled “Engine bucketing and rooms”Games sharing a runtime profile share one engine instance, so use one Socket.IO room per engine:
function engineRoomFor(gameExternalId) { const eng = pickEngineFor(gameExternalId); return `engine:${eng.engineId}`; // e.g. "engine:wp9dvdgp4wfqmxq4:p:42"}
socket.join(engineRoomFor(socket.data.gameExternalId));io.to('engine:wp9dvdgp4wfqmxq4:p:42').emit('round_open', { anchor: 7 });Players on different gameExternalIds that fall into the same bucket see the same
round (only the skin/UI differs).
Reconnection
Section titled “Reconnection”Socket.IO reconnects automatically. A reconnect is a new socket — the old
socket.data is gone; the client re-runs the handshake. Do not re-send an
active bet (the engine holds the state); emitting session + gameUpdate
restores the UI. Open bets are restored from the database so a bet placed before
the drop still receives its outcome.
Disconnect
Section titled “Disconnect”socket.on('disconnect', (reason) => { // 1. Drop the player reference from the engine (if kept) // 2. Leave any active bet alone — settlement still runs; DB persistence suffices // 3. Log: socket_disconnect engineId=... reason=...});Do not auto-cancel a bet on disconnect — the round still settles, the DB is
written, and the wallet moves; a returning player sees the outcome. (Exception: a
still-pending bet before the round starts may be cancelled — an engine-specific
choice.) Suspend-kick (round-start-check) disconnects suspended operators’
players at round end — see Config + Ledger.
Full reference pattern
Section titled “Full reference pattern”import { Server } from 'socket.io';import express from 'express';import http from 'node:http';import { mergeAuth, applyCurrencyRules } from './launchAuth.js';import { verifyLaunch, debit, rollback } from './platform.js';import { engines, pickEngineFor, engineRoomFor } from './engines.js';
const app = express();const server = http.createServer(app);const io = new Server(server, { path: '/socket.io', cors: { origin: '*' } });
io.use(async (socket, next) => { try { const authIn = socket.handshake.auth ?? {}; const launchQs = socket.handshake.query?.launchQs ?? ''; const verifyAuth = applyCurrencyRules(mergeAuth(authIn, launchQs)); const r = await verifyLaunch(verifyAuth); if (!r.ok) return next(new Error(r.message ?? 'launch failed')); Object.assign(socket.data, r); next(); } catch (e) { next(new Error('handshake error')); }});
io.on('connection', (socket) => { socket.emit('session', { /* see session emit */ }); socket.join(engineRoomFor(socket.data.gameExternalId)); const eng = pickEngineFor(socket.data.gameExternalId); if (eng) socket.emit('gameUpdate', eng.getStatePayload());
socket.on('placeBet', async (data) => { /* see placeBet */ }); socket.on('cancelBet', async () => { /* rollback + cancel */ }); socket.on('setLanguage', async (data) => { /* languageChanged */ }); socket.on('disconnect', (reason) => { /* cleanup, keep bets */ });});
server.listen(3030);Browser client (reference / debug only)
Section titled “Browser client (reference / debug only)”The platform launcher provides the production client; this is only for debugging.
import { io } from 'socket.io-client';
const socket = io('https://gameplay.cloud1x2.net', { path: '/socket.io', query: { launchQs: window.location.search.slice(1) }, transports: ['polling', 'websocket'], // polling first → WS upgrade auth: { gameId: 9000, demo: true, currency: 'USD', token: '...', customer: '...', organizationId: 1, trader: 'cx-12' },});
socket.on('connect_error', (e) => console.error('handshake', e.message));socket.on('session', (s) => console.log('session', s));socket.on('round_open', (r) => console.log('round', r));socket.on('betRejected', (e) => console.warn('rejected', e.message));document.querySelector('#bet-btn').onclick = () => socket.emit('placeBet', { amount: 10, guess: 'higher' });Common mistakes
Section titled “Common mistakes”| Error | Cause | Fix |
|---|---|---|
connect_error: launch failed | verify-launch rejected | Pass verifyR.message to next(new Error(...)) verbatim |
socket.data empty | Listeners defined before io.use ran | Define listeners inside io.on('connection') |
Duplicate session emit | Re-emitting on reconnect | Emit once per connection; a reconnect is a new connection |
| Two bets in one round | No active-bet check in placeBet | Check engineFindBetForPlayer() |
| Bet lost on disconnect | disconnect handler deleting the bet | Only clean up the socket; leave the bet |
| Room broadcast misses a player | Socket did not join the room | socket.join(engineRoomFor(...)) on connection |
Related
Section titled “Related”- Launch Verification — the verify-launch gateway
- Wallet API — debit / credit / rollback
- Bet UID Format — minting bet UIDs