Quickstart
In about 30 minutes this guide produces a working game service on Cloud1x2 — a small Hi-Lo game running a real launch end to end.
The example uses Node 22 + plain JavaScript + Postgres, which keeps the boilerplate minimal. CGSP is language-agnostic — anything that looks like Node / npm syntax is incidental; only the HTTP and WebSocket calls are required.
What gets built
Section titled “What gets built”A Hi-Lo round looks like this:
- The round opens. The server reveals an anchor card (1–13).
- The player has a few seconds to bet higher or lower on the next card.
- The server reveals the second card.
- A correct guess pays 2×. Otherwise the bet loses.
The whole service fits in ~250 lines:
hilo-service/├── package.json├── src/│ ├── server.js HTTP + WebSocket entry│ ├── engine.js Pure round logic (state machine + card draws)│ ├── platform.js Outbound calls to the Cloud1x2 platform│ └── db.js Postgres pool + bet/round persistence└── migrations/ └── 001_init.sql Service-owned tables1. Prerequisites
Section titled “1. Prerequisites”-
Node 22+ and npm.
-
A Cloud1x2 platform running locally (clone the monorepo, follow its README). When ready, the platform’s internal API listens at
http://localhost:7000and Postgres atlocalhost:5432. -
A working directory for the service:
Terminal window mkdir hilo-service && cd hilo-service
2. Pick an engine type
Section titled “2. Pick an engine type”Choose a short lowercase identifier. It appears in the admin as the service slug
and shapes the database name (cloud1x2_hilo). The wire identity is always the
service_uid the platform assigns. This guide uses hilo.
3. Scaffold
Section titled “3. Scaffold”Initialize the project and install four dependencies:
npm init -ynpm pkg set type=modulenpm install express ws pg ulidexpress— HTTP serverws— WebSocket serverpg— Postgres clientulid— bet ID generation (see Bet UID Format)
Create the source tree:
mkdir src migrationstouch src/server.js src/engine.js src/platform.js src/db.jstouch migrations/001_init.sql4. The engine
Section titled “4. The engine”src/engine.js is the pure game logic — no HTTP, no DB. It produces rounds
and emits events; the server layer decides what to do with them.
import { EventEmitter } from 'node:events';
const drawCard = () => 1 + Math.floor(Math.random() * 13);
export function createEngine({ engineId, runtimeProfile }) { const ee = new EventEmitter(); let state = 'IDLE'; let anchor = null; const bets = new Map(); // playerId -> { betUid, amount, guess }
const startRound = () => { state = 'BETTING'; anchor = drawCard(); ee.emit('round_open', { engineId, anchor }); setTimeout(closeRound, runtimeProfile.roundWaitSeconds * 1000); };
const closeRound = () => { state = 'REVEALING'; const next = drawCard(); const settlements = []; for (const [playerId, bet] of bets) { const won = (bet.guess === 'higher' && next > anchor) || (bet.guess === 'lower' && next < anchor); settlements.push({ ...bet, playerId, won, winAmount: won ? bet.amount * 2 : 0 }); } ee.emit('round_close', { engineId, anchor, next, settlements }); bets.clear(); setTimeout(startRound, 2000); // 2s gap between rounds };
return { on: (...args) => ee.on(...args), placeBet: ({ playerId, betUid, amount, guess }) => { if (state !== 'BETTING') throw new Error('round not open'); bets.set(playerId, { betUid, amount, guess }); }, start: startRound, };}Two patterns worth noting:
- No database access inside the engine. Settlement is reported via
round_closeevents; the server layer decides whether to persist and call the wallet. - The runtime profile is passed in, not fetched. The server pulls it from the platform on reconcile and hands it to the engine.
5. CGSP control plane endpoints
Section titled “5. CGSP control plane endpoints”src/server.js exposes the HTTP control surface (what the platform calls
into) and the WebSocket gameplay surface (what browsers connect to).
// src/server.js (control plane portion)import express from 'express';import { createEngine } from './engine.js';import { fetchRuntimeProfile } from './platform.js';
const app = express();app.use(express.json());
const engines = new Map(); // engineId -> enginelet serviceUid = null; // learned from the first desired-state push
app.put('/internal/engines/desired-state', async (req, res) => { if (req.headers['x-internal-control-secret'] !== process.env.INTERNAL_CONTROL_SECRET) { return res.status(401).json({ ok: false }); } const { engines: desired, serviceUid: uid } = req.body; if (uid) serviceUid = uid; // self-discovery; never hardcode it const seen = new Set();
for (const e of desired) { seen.add(e.engineId); if (!engines.has(e.engineId)) { const profile = await fetchRuntimeProfile(e.runtimeProfileId); const eng = createEngine({ engineId: e.engineId, runtimeProfile: profile }); eng.on('round_close', (evt) => handleRoundClose(eng, e, evt)); engines.set(e.engineId, eng); eng.start(); } } for (const id of [...engines.keys()]) { if (!seen.has(id)) engines.delete(id); } res.json({ ok: true, protocolVersion: '1.1', applied: { count: desired.length } });});
app.get('/internal/engines', (_req, res) => { res.json({ protocolVersion: '1.1', engines: [...engines.keys()].map((id) => ({ engineId: id, status: 'running' })), });});
app.get('/internal/engines/:engineId/status', (req, res) => { const engineId = decodeURIComponent(req.params.engineId); if (!engines.has(engineId)) return res.status(404).json({ ok: false }); res.json({ engineId, status: 'running' });});
app.get('/health', (_req, res) => res.json({ ok: true }));
const PORT = process.env.PORT ?? 3030;const server = app.listen(PORT, () => console.log(`hilo-service on :${PORT}`));Three points to notice:
- Auth is a single header (
x-internal-control-secret). The secret is set in.env; the platform reads the same value from its config. - Reconciliation is idempotent. A missing engine gets created; an engine removed from the desired set gets dropped. The same payload may arrive repeatedly — the reconcile must be safe to repeat.
- The platform pushes every 60 s as a heartbeat (plus on every admin change), so eventual consistency is built in. No retry queue is needed.
6. WebSocket gameplay
Section titled “6. WebSocket gameplay”Same file, gameplay side:
// src/server.js (gameplay portion)import { WebSocketServer } from 'ws';import { ulid } from 'ulid';import { verifyLaunch, debit, credit, openLedger, settleLedger } from './platform.js';
const wss = new WebSocketServer({ server, path: '/socket' });
wss.on('connection', async (ws, req) => { const url = new URL(req.url, 'http://x'); const launchQs = Object.fromEntries(url.searchParams);
// 1. Verify the launch with the platform const session = await verifyLaunch(launchQs); if (!session.ok) { ws.send(JSON.stringify({ type: 'error', message: session.message })); return ws.close(); }
// 2. Find the engine for this game const engineId = pickEngineFor(session.gameExternalId); // your map const engine = engines.get(engineId); if (!engine) { ws.close(); return; }
// 3. Wire the player to the engine ws.playerId = session.customer ?? `anon-${ulid()}`; engine.on('round_open', (evt) => ws.send(JSON.stringify({ type: 'round_open', ...evt }))); engine.on('round_close', (evt) => { ws.send(JSON.stringify({ type: 'round_close', ...evt })); // Settle any of this player's bets const mine = evt.settlements.filter((s) => s.playerId === ws.playerId); for (const s of mine) settleBet(session, s); });
// 4. Accept bet messages ws.on('message', async (raw) => { const msg = JSON.parse(raw.toString()); if (msg.type !== 'place_bet') return; const betUid = `${serviceUid}-${ulid().toLowerCase()}`; if (!session.demo) await debit({ ...msg, betUid, customer: session.customer }); await openLedger({ betUid, amount: msg.amount, ...session }); engine.placeBet({ playerId: ws.playerId, betUid, amount: msg.amount, guess: msg.guess }); ws.send(JSON.stringify({ type: 'bet_accepted', betUid })); });});This is the minimum end-to-end loop: verify launch → wire player → accept bet → debit → settle. Real implementations add error handling, reconnects, rate limiting, and telemetry — see New Game Service and the Gameplay Protocol.
7. Outbound platform calls
Section titled “7. Outbound platform calls”src/platform.js defines the three categories of outbound HTTP. The shape is
the same every time:
const PLATFORM = process.env.PLATFORM_INTERNAL_BASE_URL; // http://localhost:7000
const post = async (path, body, secretHeader) => { const r = await fetch(`${PLATFORM}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...secretHeader }, body: JSON.stringify(body), }); return r.json();};
const wallet = (env) => ({ 'x-internal-wallet-secret': env.INTERNAL_WALLET_SECRET });const config = (env) => ({ 'x-internal-config-secret': env.INTERNAL_CONFIG_SECRET });const launch = (env) => ({ 'x-internal-verify-launch-secret': env.INTERNAL_VERIFY_LAUNCH_SECRET });
export const fetchRuntimeProfile = async (id) => { const r = await fetch(`${PLATFORM}/internal/runtime-profiles/${id}`, { headers: config(process.env) }); return r.json();};
export const verifyLaunch = (auth) => post('/internal/verify-launch?for=hilo', auth, launch(process.env));export const openLedger = (body) => post('/internal/consolidated/tx', body, wallet(process.env));export const settleLedger = (body) => post('/internal/consolidated/tx/settle', body, wallet(process.env));export const debit = (body) => post('/internal/wallet/debit', body, wallet(process.env));export const credit = (body) => post('/internal/wallet/credit', body, wallet(process.env));export const rollback = (body) => post('/internal/wallet/rollback', body, wallet(process.env));That is the entire outbound surface — the same auth-header pattern, the same body shapes, six functions. For the full payloads, see Config + Ledger and the Wallet API.
8. The database
Section titled “8. The database”Service-owned. The platform never touches these tables.
-- migrations/001_init.sqlCREATE TABLE IF NOT EXISTS schema_migrations ( id integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS hilo_rounds ( id bigserial PRIMARY KEY, engine_id text NOT NULL, anchor smallint NOT NULL, next_card smallint, status text NOT NULL, -- 'open' | 'settled' opened_at timestamptz NOT NULL DEFAULT now(), settled_at timestamptz);
CREATE TABLE IF NOT EXISTS hilo_bets ( bet_uid text PRIMARY KEY, round_id bigint NOT NULL REFERENCES hilo_rounds(id), customer text NOT NULL, guess text NOT NULL, -- 'higher' | 'lower' amount numeric(18, 2) NOT NULL, win_amount numeric(18, 2) NOT NULL DEFAULT 0, status text NOT NULL, -- 'pending' | 'won' | 'lost' currency char(3) NOT NULL, is_demo boolean NOT NULL DEFAULT false, placed_at timestamptz NOT NULL DEFAULT now());Create the role and database (one-time, with the platform’s superuser):
psql -U postgres -c "CREATE ROLE hilo_app LOGIN PASSWORD 'hilo_app';"psql -U postgres -c "CREATE DATABASE cloud1x2_hilo OWNER hilo_app;"psql postgres://hilo_app:hilo_app@localhost:5432/cloud1x2_hilo -f migrations/001_init.sql9. Run and register
Section titled “9. Run and register”Set the environment and start:
PORT=3030 \PLATFORM_INTERNAL_BASE_URL=http://localhost:7000 \INTERNAL_CONTROL_SECRET=dev \INTERNAL_WALLET_SECRET=dev \INTERNAL_CONFIG_SECRET=dev \INTERNAL_VERIFY_LAUNCH_SECRET=dev \DATABASE_URL=postgres://hilo_app:hilo_app@localhost:5432/cloud1x2_hilo \node src/server.jsRegister the service with the platform in the admin UI:
-
Services → New service:
- Slug:
hilo - Internal URL:
http://host.docker.internal:3030(orhttp://localhost:3030if the platform is not in a container)
- Slug:
-
Runtime Profiles → New profile for the
hiloservice:- Name:
default roundWaitSeconds:5
- Name:
-
Games → New game:
- Service:
hilo - Runtime profile:
default - External ID: any free integer (e.g.
9000)
- Service:
Within ~1 second the platform pushes a desired-state. The console logs the
reconciliation; the first round opens.
For the full registration walkthrough (exposing the service, secrets, real mode), see Register and Launch.
10. Smoke test
Section titled “10. Smoke test”Open the platform’s demo launch URL with gameId=9000 (or whatever was chosen).
The browser:
- Connects to
ws://localhost:3030/socketwith the launch query. - Receives
round_openevents. - Sends a
place_betmessage (higher / lower) within the window. - Receives
round_closewith the next card and the outcome.
The service logs should look like:
hilo-service on :3030[bootstrap] service_uid set: wp9dvdgp4wfqmxq4[reconcile] +1 engine: wp9dvdgp4wfqmxq4:p:1[engine] round_open wp9dvdgp4wfqmxq4:p:1 anchor=7[engine] bet placed customer=demo-3 guess=higher amount=10[engine] round_close wp9dvdgp4wfqmxq4:p:1 anchor=7 next=11[ledger] settle won=true winAmount=20That is a full launch loop in under 30 minutes.
What was built
Section titled “What was built”A complete CGSP service with:
- A reconciler that converges to the platform’s desired engine set.
- An engine producing rounds and accepting bets.
- Outbound clients for runtime config, ledger, and wallet.
- A service-owned database with rounds and bets.
- An end-to-end demo launch working.
Not covered yet: real-mode partner authentication, bet templates, localization, reconnection, and production reconciler edge cases. Those are the next step — New Game Service and Register and Launch.