Appearance
Game flow
The slot frontend is a state machine. Its current state determines which action can run. One lock serializes requests and presentation.
State and action order
text
connect → loadConfig → restore → loading/rules acknowledgements
│
├─ ready → player starts one paid round
├─ choice → player selects an original option index
├─ collect-or-continue → player collects or risks the win
└─ continuation → next required free slot step
│
request → render → timing → report → inspect new state
│
recovery on errorOperator pause, reality checks, expired sessions, and required dialogs block these transitions. Removing one block must not remove the others.
Inspect canCollect and pending choices before continuing. inProgress: true alone is not an instruction to send another request immediately.
Build order
- Implement launch and loading.
- Normalize settings. Calculate legal stakes.
- Restore the response from
loadConfig. - Implement slot rendering and rules.
- Connect operator events to the same action gates as local controls.
- Add freeplays, purchases, gamble, and RGS features.
- Exercise the release checks.
Slot controller
The following controller is a compilable reference for requests, choices, recovery, banking, and timing. It targets slots. Other verticals have different action contracts.
Its View.render callback is your renderer. Resolve that promise only after required presentation finishes. Implement operator notifications and dialog controls as described on their pages.
The caller supplies Policy after resolving the player's configuration. Do not construct that policy from permissive defaults. Refresh it when session settings change.
Download the controller. Save the error helper beside it. Download the launch-parameter helper.
ts
import { SdkRequestError } from './sdk-error';
import {
collect, decodeScenario, loadConfig, placeBet, reportAnimationEnd,
isFeatureChoice,
type IGameResult, type IGameRoundInfo, type IGameSettings,
type ILoadConfigConfig, type ILoadConfigReply, type IPlaceBetOptions,
type IPlaceBetReply, type ICollectReply, type ISessionOptions,
type IBalanceReply, type IFreePlayInfo, type TPlayerChoiceAward, type ITokenData,
} from '@hizi.io/engine-sdk';
export type Phase = 'ready' | 'choice' | 'collect-or-continue' |
'continuation' | 'recovery' | 'busy' | 'blocked';
export interface Snapshot {
result?: IGameResult;
round?: IGameRoundInfo;
balance?: IBalanceReply;
tokenData?: ITokenData;
freePlays?: IFreePlayInfo[];
amountToCollect?: number;
}
export interface Policy {
// Validated for this player, currency, vertical, and jurisdiction.
stakes: number[];
minSpinTimeMs: number;
buyEnabled: boolean;
gambleEnabled: boolean;
partialCollectEnabled: boolean;
maxPurchaseAmount?: number;
}
export interface View {
// Resolve only after all boards, wins, and feature transitions finish.
render(snapshot: Snapshot, resumed: boolean): Promise<void>;
}
// These dependencies also let tests model delayed or lost replies.
const defaultApi = { collect, loadConfig, placeBet, reportAnimationEnd };
const defaultClock = {
now: () => performance.now(),
sleep: (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)),
};
export function slotPhase(snapshot: Snapshot): Exclude<Phase, 'busy' | 'blocked'> {
const { result, round, amountToCollect } = snapshot;
if (round?.status && !['open', 'closed'].includes(round.status)) return 'recovery';
if (round?.status === 'closed') {
return result?.engineData.inProgress || result?.engineData.canCollect || result?.engineData.playerChoice?.length ? 'recovery' : 'ready';
}
if (!result) return round?.status === 'open' || (amountToCollect ?? 0) > 0 ? 'recovery' : 'ready';
const e = result.engineData;
if (e.canCollect) return 'collect-or-continue';
if (e.playerChoice?.length) return 'choice';
if (e.inProgress) return 'continuation';
if (round?.status === 'open' || (amountToCollect ?? 0) > 0) return 'recovery';
return 'ready';
}
export function roundBaseStake(snapshot: Snapshot): number {
// The RGS can record the full purchase price in BOTH round stake fields.
const stored = snapshot.result?.engineData.baseStake;
if (typeof stored === 'number' && stored > 0) return stored;
if (snapshot.result?.engineData.buyFeatureId) {
throw new Error('Bought round has no recorded base stake. Resolve its history.');
}
const stake = snapshot.round?.baseStake;
if (typeof stake === 'number' && stake > 0) return stake;
throw new Error('Round has no base stake. Recover its state.');
}
export function permittedStakes(
config: ILoadConfigConfig, settings: Partial<IGameSettings>,
limits: { min: number; max: number },
): number[] {
// The caller supplies finite, approved bounds. No invented fallback ladder.
if (!Number.isFinite(limits.min) || !Number.isFinite(limits.max) ||
limits.min <= 0 || limits.max < limits.min) throw new Error('Invalid stake limits');
const source = settings.customStakes?.length ? settings.customStakes : config.stakes;
if (!source?.length) throw new Error('This game needs a validated stake input or an explicit ladder');
return [...new Set(source)].filter(n => Number.isSafeInteger(n) &&
n >= limits.min && n <= limits.max).sort((a, b) => a - b);
}
export class SlotSession {
snapshot: Snapshot = {};
private busy = false;
private uncertain = true;
private blocks = new Set<string>();
constructor(
private session: ISessionOptions,
public config: ILoadConfigConfig,
public settings: Partial<IGameSettings>,
public policy: Policy,
private view: View,
private api = defaultApi,
private clock = defaultClock,
) {
if (!Number.isFinite(policy.minSpinTimeMs) || policy.minSpinTimeMs < 0) {
throw new Error('Invalid minimum spin time');
}
}
get phase(): Phase {
if (this.busy) return 'busy';
if (this.uncertain) return 'recovery';
if (this.blocks.size) return 'blocked';
return slotPhase(this.snapshot);
}
block(reason: string): void { this.blocks.add(reason); }
unblock(reason: string): void { this.blocks.delete(reason); }
// Replace credentials after a successful refresh. Recover before any new bet.
replaceSession(session: ISessionOptions): void {
if (this.busy) throw new Error('Wait for the current request');
this.session = session;
this.uncertain = true;
}
private assertPhase(...allowed: Phase[]): void {
if (!allowed.includes(this.phase)) throw new Error(`Action unavailable in ${this.phase}`);
}
private isWager(option: TPlayerChoiceAward): boolean {
return isFeatureChoice(option) && [
...this.config.wagerFeatures ?? [], ...this.config.wagerStakeFeatures ?? [],
].includes(option.feature);
}
private accept(reply: IPlaceBetReply | ICollectReply, collecting = false): void {
// A collect reply can carry a compressed scenario. collect() has no config option.
if (collecting && reply.result) decodeScenario(reply.result, this.config);
this.snapshot = {
result: reply.result,
round: reply.gameRoundInfo,
balance: reply.balance ?? this.snapshot.balance,
tokenData: reply.tokenData,
freePlays: this.snapshot.freePlays,
// A collect reply describes the requested amount, not guaranteed remaining funds.
amountToCollect: collecting ? (reply.gameRoundInfo?.status === 'closed' ? 0 : undefined) : reply.amountToCollect,
};
// IPlaceBetReply does not declare updated freeplays. Reload at the campaign boundary.
}
async restore(loaded?: ILoadConfigReply): Promise<void> {
if (this.busy) throw new Error('Wait for the current request');
this.busy = true;
this.uncertain = true;
try {
if (!loaded) {
const reply = await this.api.loadConfig(this.session);
if (!reply.success) throw new SdkRequestError(reply.error);
loaded = reply.result;
}
this.config = loaded.config;
this.snapshot = {
result: loaded.gameResult,
round: loaded.gameRoundInfo,
balance: loaded.balance,
tokenData: loaded.tokenData,
freePlays: loaded.freePlaysAvailable,
amountToCollect: loaded.amountToCollect,
};
await this.view.render(this.snapshot, true);
this.uncertain = false;
} finally { this.busy = false; }
}
private async bet(options: Omit<IPlaceBetOptions, 'backendURL' | 'token' | 'config'>): Promise<void> {
// Set the lock before the first await. A double click cannot start two requests.
this.busy = true;
const started = this.clock.now();
try {
const reply = await this.api.placeBet({ ...options, ...this.session, config: this.config });
if (!reply.success) throw new SdkRequestError(reply.error);
this.accept(reply.result);
await this.view.render(this.snapshot, false);
// Check elapsed time again after each wait. A timer is not a clock.
while (this.clock.now() - started < this.policy.minSpinTimeMs) {
await this.clock.sleep(this.policy.minSpinTimeMs - (this.clock.now() - started));
}
if (this.settings.reportAnimationEnd === true) {
const report = await this.api.reportAnimationEnd(this.session);
if (!report.success) throw new SdkRequestError(report.error);
}
} catch (error) {
// Never repeat a money request just because its response was lost.
this.uncertain = true;
throw error;
} finally { this.busy = false; }
}
async start(stake: number, featureId?: string): Promise<void> {
this.assertPhase('ready');
if (!this.policy.stakes.includes(stake)) throw new Error('Stake is unavailable');
const feature = featureId === undefined ? undefined : this.config.buyFeatures?.find(f => f.id === featureId);
if (featureId !== undefined && (!feature || !this.policy.buyEnabled)) throw new Error('Purchase is unavailable');
// The engine debits Math.ceil(targetPrice * stake), so ceil here too.
const cost = Math.ceil(stake * (feature?.price ?? 1));
if (!Number.isSafeInteger(cost) || cost <= 0) throw new Error('Invalid debit');
if (feature && this.policy.maxPurchaseAmount !== undefined && cost > this.policy.maxPurchaseAmount) {
throw new Error('Purchase exceeds its limit');
}
if (!this.snapshot.balance) throw new Error('Refresh the balance before a paid bet');
if (cost > this.snapshot.balance.totalBalance) throw new Error('Insufficient balance');
await this.bet({ stake, ...(featureId === undefined ? {} : { featureToBuy: featureId }) });
}
async ticket(ticket: IFreePlayInfo): Promise<void> {
this.assertPhase('ready');
const current = this.snapshot.freePlays?.find(t => t.currency === ticket.currency &&
t.stake === ticket.stake && t.feature === ticket.feature && t.count > 0);
if (!current || !this.policy.stakes.includes(current.stake)) throw new Error('Ticket is unavailable');
if (this.snapshot.balance && current.currency !== this.snapshot.balance.currency) throw new Error('Ticket currency differs');
await this.bet({ stake: current.stake, useTicket: true,
...(current.feature ? { useTicketFeatureType: current.feature } : {}) });
// Ticket counts are not guaranteed on placeBet replies. Refresh before another ticket.
this.snapshot.freePlays = undefined;
}
async choose(index: number): Promise<void> {
this.assertPhase('choice', 'collect-or-continue');
const choices = this.snapshot.result?.engineData.playerChoice ?? [];
const option = choices[index];
if (!Number.isInteger(index) || !option) throw new Error('Choice is unavailable');
if (this.isWager(option) && !this.policy.gambleEnabled) throw new Error('Gamble is disabled');
await this.bet({ playerChoiceIndex: index });
}
async continue(): Promise<void> {
this.assertPhase('continuation', 'collect-or-continue');
const e = this.snapshot.result!.engineData;
if (e.playerChoice?.length) throw new Error('A player choice is required');
if (!e.inProgress) throw new Error('No continuation exists');
// In slots, a collectable continuation risks the existing win.
if (e.canCollect && !this.policy.gambleEnabled) throw new Error('Gamble is disabled');
await this.bet({});
}
async cashOut(amount?: number): Promise<void> {
this.assertPhase('collect-or-continue');
if (amount !== undefined) {
const outstanding = this.snapshot.amountToCollect;
if (!this.policy.partialCollectEnabled || this.snapshot.result?.engineData.inProgress !== true || !Number.isSafeInteger(amount) || amount <= 0 ||
outstanding === undefined || amount >= outstanding) throw new Error('Invalid bank amount');
const base = roundBaseStake(this.snapshot);
const result = this.snapshot.result!;
const banked = result.engineData.bankedWin ?? 0;
const requested = Math.floor(amount / base * 100) / 100;
const nextBanked = Math.floor((banked + requested) * 100) / 100;
// These requests take a different engine path. Use explicit full collect instead.
if (nextBanked <= banked || nextBanked >= result.totalWin) throw new Error('Bank amount must protect only part of the at-risk win');
}
this.busy = true;
try {
const reply = await this.api.collect({ ...this.session, ...(amount === undefined ? {} : { amount }) });
if (!reply.success) throw new SdkRequestError(reply.error);
this.accept(reply.result, true);
// Do not add bankedWin to balance or subtract it from the outstanding amount.
await this.view.render(this.snapshot, false);
// Older replies omit result/round state. Reload instead of assuming a close.
if (!reply.result.result || reply.result.gameRoundInfo?.status !== 'closed') this.uncertain = true;
} catch (error) {
this.uncertain = true;
throw error;
} finally { this.busy = false; }
}
}Connect it to your frontend
loadSession() is the loader from getting started.
typescript
const { connection, session, loaded: reply } = await loadSession(location.search);
const game = new SlotSession(
session,
reply.config,
connection.gameSettings ?? {},
approvedPolicy,
{ render: (snapshot, resumed) => renderer.present(snapshot, { resumed }) },
);
await game.restore(reply);
// Complete Operator Interface setup, loading, and required acknowledgements.
// Then route every control through game.start, game.choose, or game.cashOut.approvedPolicy contains validated stakes, timing, permissions, and purchase limits. renderer is your game-specific view. They are application components, not SDK exports.
Use game.phase to enable buttons. Catch every rejected action at the UI boundary. Show a suitable error without resending its request.
A plain continuation can advance after its presentation finishes. Require player input for choice and collect-or-continue. An empty option array is not a choice prompt.
After a partial collect, call restore() to reload the outstanding amount before further decisions. Banking does not change the wallet balance.
A freeplay start calls ticket. Reload ticket availability before the next ticket. The response type does not guarantee updated ticket counts on every bet.
The example intentionally leaves a failed request in recovery. Call restore() after resolving connection or operator errors. A new session token requires replaceSession() first.
Boundaries of the example
The controller does not select a legal jurisdiction, provide translated rules, draw reels, or implement a casino wrapper. The linked guides specify those responsibilities. Test the combined frontend, not only this class.
The controller applies minimum timing to each bet step. It does not implement an approved dynamic timing scheme. It also does not infer a legacy bought round's missing base stake.
WebSocket support
HTTP works for ordinary slot requests. To use the SDK request transport:
typescript
import { enableWebSockets } from '@hizi.io/engine-sdk';
const socket = await enableWebSockets(connection.webSocketURL!);
// The SDK now sends loadConfig/placeBet/collect through this transport.
// On teardown or session replacement:
socket.close();Validate the optional URL before this call. Handle setup rejection. The helper uses one module-global transport; do not share it between independent player sessions.
A dropped connection causes in-flight requests to fail. The next SDK call attempts to reconnect. Only close() explicitly returns subsequent requests to HTTP. Reconnection does not authorize a repeat bet.
The returned handler exposes only close() and isConnected(). It does not expose the native socket or a broadcast callback. SDK 0.2.18 ignores messages without a matching request ID. Crash therefore needs the documented broadcast transport.
Other game types
Use each vertical's game guide. Blackjack requires legal actions and can add stakes. Mines requires picks. Crash continues on server time. Do not apply the slot's automatic continuation behavior to those games.