Skip to content

Crash transport

Crash requires requests and server broadcasts on the same WebSocket connection. The engine binds the live round to that connection.

SDK limitation

SDK 0.2.18 enableWebSockets exposes only close and isConnected. Its message handler ignores messages without a pending request ID. It discards Crash broadcasts.

Do not read an imaginary handler.socket or open a second socket only for events. Use this transport for all requests in the Crash session. It replaces enableWebSockets.

Download the transport.

ts
import { API_RETURNCODES, defaultNetworkTimeout, type TNetworkResponse } from '@hizi.io/engine-sdk';

type Envelope = {
  requestId?: string;
  status?: number;
  headers?: Record<string, string>;
  reply?: Record<string, unknown>;
  event?: string;
  payload?: unknown;
};

// One connection carries both requests and server events. This replaces enableWebSockets.
export class GameSocket {
  private nextId = 0;
  private pending = new Map<string, {
    resolve: (value: TNetworkResponse<unknown>) => void;
    timer: ReturnType<typeof setTimeout>;
  }>();

  private constructor(private socket: WebSocket, onEvent: (event: string, payload: unknown) => void) {
    socket.addEventListener('message', message => {
      const frame = typeof message.data === 'string' ? message.data
        : message.data instanceof ArrayBuffer ? new TextDecoder('utf-8').decode(message.data)
        : null;
      if (frame === null) return;
      let data: Envelope;
      try { data = JSON.parse(frame); } catch { return; }
      if (!data || typeof data !== 'object') return;
      // Crash events can arrive before the matching placeBet reply.
      if (typeof data.event === 'string') { onEvent(data.event, data.payload); return; }
      if (!data.requestId) return;
      const pending = this.pending.get(data.requestId);
      if (!pending) return;
      this.pending.delete(data.requestId);
      clearTimeout(pending.timer);
      if (typeof data.status === 'number' && data.status >= 200 && data.status < 300) {
        pending.resolve({ success: true, result: data.reply ?? {} });
        return;
      }
      const headers = Object.fromEntries(Object.entries(data.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]));
      let text = headers['x-h-error-msg'];
      const encoded = headers['x-h-error-msg-base64'];
      if (encoded) {
        try { text = new TextDecoder().decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0))); }
        catch { text = text ?? 'Invalid error text'; }
      }
      pending.resolve({ success: false, error: {
        code: String(headers['x-h-error-id'] ?? data.reply?.errorId ?? data.reply?.code ?? API_RETURNCODES.UNEXPECTED),
        message: String(text ?? data.reply?.errorText ?? data.reply?.message ?? 'Request failed'),
        ...(data.reply?.passThroughData === undefined ? {} : { passThroughData: data.reply.passThroughData }),
      } });
    });
    socket.addEventListener('close', () => this.failPending());
    socket.addEventListener('error', () => this.failPending());
  }

  static async connect(url: string, onEvent: (event: string, payload: unknown) => void): Promise<GameSocket> {
    const socket = new WebSocket(url);
    // The router sends replies as binary. Without this the browser delivers a Blob,
    // String(blob) is "[object Blob]", and every reply and event is discarded.
    socket.binaryType = 'arraybuffer';
    const transport = new GameSocket(socket, onEvent);
    await new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => { socket.close(); reject(new Error('Socket connection timed out')); }, defaultNetworkTimeout);
      const failed = () => { clearTimeout(timer); reject(new Error('Socket connection failed')); };
      socket.addEventListener('error', failed, { once: true });
      socket.addEventListener('close', failed, { once: true });
      socket.addEventListener('open', () => {
        clearTimeout(timer);
        socket.removeEventListener('error', failed);
        socket.removeEventListener('close', failed);
        resolve();
      }, { once: true });
    });
    return transport;
  }

  async request<T>(body: Record<string, unknown>): Promise<TNetworkResponse<T>> {
    if (this.socket.readyState !== WebSocket.OPEN) return this.networkError();
    const requestId = `frontend-${++this.nextId}`;
    return new Promise(resolve => {
      const timer = setTimeout(() => {
        this.pending.delete(requestId);
        resolve(this.networkError());
      }, defaultNetworkTimeout);
      this.pending.set(requestId, { timer, resolve: value => resolve(value as TNetworkResponse<T>) });
      try {
        this.socket.send(JSON.stringify({ cmd: 'game', data: {
          body, requestId, method: 'POST', headers: { 'Content-Type': 'application/json' },
        } }));
      } catch {
        clearTimeout(timer);
        this.pending.delete(requestId);
        resolve(this.networkError());
      }
    });
  }

  private networkError(): TNetworkResponse<never> {
    return { success: false, error: {
      code: String(API_RETURNCODES.NETWORKERROR),
      message: 'Connection interrupted. Reconcile the round before another action.',
    } };
  }

  private failPending(): void {
    for (const pending of this.pending.values()) {
      clearTimeout(pending.timer);
      pending.resolve(this.networkError());
    }
    this.pending.clear();
  }

  close(): void { this.failPending(); this.socket.close(); }
}

Start a round

typescript
import {
  CRASH_EVENTS, crashPlaceBetData, crashCollectData,
  type IPlaceBetReply, type ICollectReply,
} from '@hizi.io/engine-sdk';
import { GameSocket } from './game-socket';

const socket = await GameSocket.connect(connection.webSocketURL!, (event, payload) => {
  // Validate the event payload against the Crash types before rendering.
  // Retain roundHash, bet hashes, cashout states, and settlement events.
  crashView.handle(event, payload);
});

const opening = crashPlaceBetData([{ stake: selectedStake, autoCashOutMultiplier: 2 }]);
const response = await socket.request<IPlaceBetReply>({
  ...opening,
  op: 'placeBet', token: connection.token, stake: selectedStake,
});
if (!response.success) {
  // Stop new actions. Reconnect and reconcile; do not repeat the opening bet.
}

// Manual cashout, only after the server identifies this bet:
const cashout = crashCollectData(betHash);
const collected = await socket.request<ICollectReply>({
  ...cashout,
  op: 'collect', token: connection.token,
});

The event handler must exist before the opening request. Events can arrive before its reply. Route them by round and bet identity. Do not treat a late event from a previous round as the current result.

The transport does not automatically retry or reconnect. On disconnection, disable actions. Open a new connection and use the Crash resume request. Do not send another opening stake.

Do not use SlotSession for Crash. A timer or animation freeze cannot pause its server round. Display a reconnecting state instead of a simulated live cashout value.

Request and event differences

Request replies contain requestId, HTTP-like status, headers, and reply. Broadcasts contain event and payload.

Handle roundStarted, roundResumed, roundTick, betCashedOut, roundEnded, and balanceUpdated. Their types and state transitions appear in the Crash guide.

A successful cashout uses the server's multiplier and amount. The local curve estimates presentation only. Do not calculate a payable cashout from the displayed multiplier.

Before a new round, reconcile all submitted bets and the final balance. Do not assume roundEnded alone contains the complete wallet update.

Use normal SDK loadConfig over HTTP before opening this transport, or send its raw operation through request. For a raw reply, apply decodeScenario with the returned config to resumed results. Keep one session owner for the connection and request lock.