Skip to content

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.

A Hi-Lo round looks like this:

  1. The round opens. The server reveals an anchor card (1–13).
  2. The player has a few seconds to bet higher or lower on the next card.
  3. The server reveals the second card.
  4. 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 tables
  • 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:7000 and Postgres at localhost:5432.

  • A working directory for the service:

    Terminal window
    mkdir hilo-service && cd hilo-service

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.

Initialize the project and install four dependencies:

Terminal window
npm init -y
npm pkg set type=module
npm install express ws pg ulid
  • express — HTTP server
  • ws — WebSocket server
  • pg — Postgres client
  • ulid — bet ID generation (see Bet UID Format)

Create the source tree:

Terminal window
mkdir src migrations
touch src/server.js src/engine.js src/platform.js src/db.js
touch migrations/001_init.sql

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.

src/engine.js
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_close events; 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.

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 -> engine
let 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.

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.

src/platform.js defines the three categories of outbound HTTP. The shape is the same every time:

src/platform.js
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.

Service-owned. The platform never touches these tables.

-- migrations/001_init.sql
CREATE 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):

Terminal window
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.sql

Set the environment and start:

Terminal window
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.js

Register the service with the platform in the admin UI:

  1. Services → New service:

    • Slug: hilo
    • Internal URL: http://host.docker.internal:3030 (or http://localhost:3030 if the platform is not in a container)
  2. Runtime Profiles → New profile for the hilo service:

    • Name: default
    • roundWaitSeconds: 5
  3. Games → New game:

    • Service: hilo
    • Runtime profile: default
    • External ID: any free integer (e.g. 9000)

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.

Open the platform’s demo launch URL with gameId=9000 (or whatever was chosen). The browser:

  • Connects to ws://localhost:3030/socket with the launch query.
  • Receives round_open events.
  • Sends a place_bet message (higher / lower) within the window.
  • Receives round_close with 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=20

That is a full launch loop in under 30 minutes.

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.