Skip to content

Gameplay Protocol

Gameplay is Surface 4: Browser → Service over WebSocket. The flow:

  1. An operator requests a launch URL from Cloud1x2.
  2. The operator embeds it in an iframe on its own site/app.
  3. The iframe reaches the Cloud1x2 launcher, which proxies the traffic to the game service.
  4. The game service serves the game frontend (HTML + JS bundle) — the iframe the player sees.
  5. The frontend opens a WebSocket to the same service’s /socket.io endpoint.

The protocol has three parts: the handshake (auth → verify-launch → session), lifecycle/round events (service → client), and action events (client → service).

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.

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

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:

FieldBehavior
gameIdFill from launchQs if absent in auth.
demoIf present in launchQs, overwrite (fix a stale auth.demo).
currencyIf present in launchQs, overwrite.
customer, token, organizationIdFill from launchQs if absent.
locale / langFill 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).

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.

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.

Errors surface as connect_error on the client (the verify-launch message, verbatim):

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

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', /* ... */);
});

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.
FieldSourceRefresh
engineType, gameId, skinKeyGame recordFixed at registration
demo, currency, walletCurrency, balance, playerKey, organizationIdverify-launchSession start
showBalance, locale, language, translationsSkin + localeSession start
Engine config (rtpTarget, timings, …)Runtime ProfileEach round
betMin, betMax, maxWin, quickBetsBet TemplateEach round

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.

RefreshEndpointSide effect
Runtime ProfileGET /internal/runtime-profiles/:idnone (config secret)
Bet TemplateGET /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.

QuestionAnswer
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.
{ "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 });
});

Only while the round is still BETTING. Cancel → rollback (real mode) + ledger cancelled. Emits betCancelled { amount, balance }.

{ "lang": "tr" }

Loads the skin translations for the new locale and emits languageChanged { ok, language, translations }.

Each engine can add its own: cashOut (crash), lockIn (prediction), spin (slot). Hi-Lo needs none beyond placeBet’s guess. Naming convention: lower camelCase.

Lifecycle:

EventWhen
sessionFirst emit after the handshake
gameUpdateEngine state changed (current round snapshot)
gameSuspendUpdateA game was suspended/resumed by an admin
partnerGameRevokedThe game left the partner allowlist; the session should end
platformErrorIrrecoverable system error; suggest disconnect

Round events (engine-defined, Hi-Lo example):

EventMeaning
round_openRound opened, betting accepted ({ roundId, anchor, openMs, closeAt })
round_closeRound closed, result announced ({ roundId, anchor, nextCard })

Action responses:

EventWhen
betPlacedplaceBet succeeded ({ balance, amount })
betRejectedplaceBet rejected ({ message })
betCancelledcancelBet succeeded ({ amount, balance })
cashOutSuccess / cashOutRejectedcashOut result (crash)
languageChangedAfter setLanguage ({ ok, language, translations })
balanceUpdateBalance 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).

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

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.

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.

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);

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' });
ErrorCauseFix
connect_error: launch failedverify-launch rejectedPass verifyR.message to next(new Error(...)) verbatim
socket.data emptyListeners defined before io.use ranDefine listeners inside io.on('connection')
Duplicate session emitRe-emitting on reconnectEmit once per connection; a reconnect is a new connection
Two bets in one roundNo active-bet check in placeBetCheck engineFindBetForPlayer()
Bet lost on disconnectdisconnect handler deleting the betOnly clean up the socket; leave the bet
Room broadcast misses a playerSocket did not join the roomsocket.join(engineRoomFor(...)) on connection