First battle
Three calls: read the catalog, open a lobby, fill it. The third one starts the battle.
1. The catalog
export async function listGames(): Promise<Game[]> {
const { games } = await call<{ games: Game[] }>('/games')
return games
}
Rendered on the server, so the key never leaves it:
export default async function LobbyPage() {
const [games, openBattles] = await Promise.all([listGames(), listBattles('OPEN')])
// …
}
Two fields need care when rendered:
{game.rtp > 0 ? `RTP ${game.rtp}%` : 'RTP unknown'}
rtp: 0 means the game has no profile, not an RTP of zero. Render it as unknown.
status: "planned" means a recording recipe exists but the game has not been verified end to
end. It appears in the catalog so an operator can see it coming; treat it as not ready to
sell.
2. Open a lobby
const battle = await createBattle({
gameId,
seatsTotal,
entryAmount: '25.50',
currency: 'BRL',
creatorPlayerRef: playerRef,
creatorGameUrl: creatorGameUrl(body.gameUrl, playerRef),
})
Four details in that call matter.
entryAmount is a string. Representing money as a float introduces rounding errors.
SlotBattle normalises the value, so "25.50" comes back as "25.5"; compare amounts by
parsing them as decimals, never by string equality.
There is no tenant_id. It comes from the API key. Sending one is a 400, because the
decoder rejects unknown fields rather than ignoring them.
seatsTotal is 2 to 8. Anything else is a 400 with seats_total must be 2..8. The demo
offers the sizes players ask for, named by players per side, so a 2×2 is four seats:
export const SEAT_PRESETS = [
{ label: '1×1', seats: 2, hint: 'Head to head' },
{ label: '2×2', seats: 4, hint: 'Two a side' },
{ label: '3×3', seats: 6, hint: 'Three a side' },
{ label: '4×4', seats: 8, hint: 'Full table — the maximum' },
]
creatorGameUrl decides whether the battle can start at all. The demo resolves it in
three steps, all before the battle exists:
export async function resolveCreatorGameUrl(input: {
fromForm?: string
configured?: string
mint: () => Promise<string>
}): Promise<string> {
const typed = input.fromForm?.trim()
if (typed) return typed
const configured = input.configured?.trim()
if (configured) return configured
const minted = (await input.mint()).trim()
if (!minted) {
throw new Error('The demo-session mint returned an empty launch URL.')
}
return minted
}
Supplying a URL makes seat 0 READY. Omitting it leaves seat 0 FILLED, which looks correct
but means the table can never be all-READY, so the battle never starts. The demo always
supplies one: the lobby form's Your game URL field wins, then DEMO_CREATOR_GAME_URL, and
failing both it mints a session with POST /games/{id}/demo-sessions.
That last step is the same provider mint SlotBattle performs for bot seats, exposed over HTTP.
It costs a scope of its own, demo:mint.
In production a human player's session belongs to the casino: it comes out of the same login
that let the player deposit. Bots get theirs minted from the casino's provider credentials
because a bot has no login. Seat 0 is a person, which is why this demo asks for a real URL
first and mints only as a last resort, and why the route is gated behind its own scope rather
than folded into battles:write.
A key issued before this route existed does not carry demo:mint. If the mint answers 403
naming the scope, the key needs reissuing, from the console or by your host.
The demo has no placeholder fallback. When no session can be obtained, it refuses to open the battle, under its own error code:
return Response.json(
{
code: 'creator_session_unavailable',
message: `No game session for your seat: ${detail}`,
},
{ status },
)
It does not pass SlotBattle's own code through, because the same status would mean two
different things: unavailable from POST /battles means the instance is at capacity and
retrying is the right advice, while a failed mint is a host-side setting that no amount of
waiting fixes. Same status, different code, so the form can give the right advice.
An unreachable URL, such as a .invalid placeholder, does not read as a failure until it is
costly. The battle opens, every bot seat records normally, and about a minute later the whole
battle ends FAILED: seat 0 loaded nothing (net::ERR_NAME_NOT_RESOLVED →
seat_browser_failed), and the start barrier requires every seat.
SlotBattle points a real headless browser at whatever URL it is handed. Refusing before the battle exists costs a form error instead of a wasted recording.
game_url is a live session URLAccepted on input, never echoed back. SlotBattle encrypts it at rest and never lets it cross a process boundary: not in a response, a log, an event or a webhook. Do not log it on your side either.
The response, for a 2×2 and therefore four seats:
{
"id": "btl_ClzIJ4ONnmwci6D87J9O",
"status": "OPEN",
"entry_amount": "25.5",
"ws_url": "ws://127.0.0.1:8071/ws?battle_id=btl_ClzIJ4ONnmwci6D87J9O",
"seats": [
{ "seat_ref": "usr_s0", "player_ref": "alice", "status": "READY" },
{ "seat_ref": "usr_s1", "player_ref": "", "status": "EMPTY" },
{ "seat_ref": "usr_s2", "player_ref": "", "status": "EMPTY" },
{ "seat_ref": "usr_s3", "player_ref": "", "status": "EMPTY" }
]
}
Seat 0 is READY because we supplied the URL. The others are EMPTY.
Note the ws_url port. The live feed runs on its own listener (8071 by default), not the
REST port your SLOTBATTLE_BASE_URL points at. An instance with no
SLOTBATTLE_PUBLIC_WS_BASE_URL configured derives ws_url from the request host and hands
back the REST port instead, where nothing accepts the upgrade. Every client then receives a
URL that refuses to connect, and nothing logs an error. Use the ws_url the API returns
rather than assembling one yourself.
3. Fill the table
const battle = await (await fetch('/api/battles', { /* … */ })).json()
// Fill the rest of the table. With every seat READY, the battle starts by
// itself — there is no start endpoint.
await fetch(`/api/battles/${battle.id}/bot-seats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerRef }),
})
router.push(`/battles/${battle.id}`)
Two calls rather than one, because they are two acts with two different scopes:
battles:write opens the lobby, bots:write seats the bots.
A real casino would let players join between those two calls, which is what a lobby is for. The demo skips straight to bots so that something happens immediately.
Bots get demo game sessions minted through the provider's backend, so bot-seats needs real
SoftSwiss credentials, and there is no sandbox. Without them this call fails and the battle
stays OPEN until its lobby window expires.
Everything else in this tutorial works regardless.
Handle the errors that will actually happen
function explain(code: string, message: string): string {
switch (code) {
case 'game_not_allowed':
return "That game is not in this casino's allowlist. If you just allowed it, wait up to 30 seconds for the cache."
case 'conflict':
return 'That player is already in another active battle. One active battle per player, per casino.'
case 'unavailable':
return 'The instance is at capacity. Try again in a moment.'
case 'forbidden':
return `Refused: ${message}. Your API key may be missing a scope.`
default:
return `${code}: ${message}`
}
}
Branch on code, never on message. Codes are stable; messages may be reworded.
Of these, only unavailable is worth retrying. The others are deterministic: retrying a
conflict produces another conflict.