2026-04-2215 min read

Building a Crash Game with React and WebSockets

Step-by-step guide to building a real-time crash game from scratch — multiplier curves, auto-cashout, and WebSocket state synchronization.

#crash-game#react#websockets#tutorial

What We're Building

A real-time crash game where players place bets before a multiplier starts climbing. The multiplier increases exponentially until it "crashes" at a random point. Players must cash out before the crash to win their bet multiplied by the current value. If the multiplier crashes before they cash out, they lose their bet.

This tutorial covers the full stack: React frontend with live multiplier animation, Node.js game server with WebSocket communication, and a provably fair crash point generator.

Server Architecture

The game server manages the game lifecycle through four phases: WAITING (accepting bets), RUNNING (multiplier climbing), CRASHED (round over), and COOLDOWN (brief pause before next round).

Here's the core game loop:

import { WebSocketServer, WebSocket } from 'ws';

interface GameState {
  phase: 'waiting' | 'running' | 'crashed' | 'cooldown';
  multiplier: number;
  crashPoint: number;
  startTime: number;
  bets: Map<string, { amount: number; cashedOut: boolean; cashoutAt?: number }>;
}

const TICK_RATE = 50; // 20 updates per second
const WAITING_TIME = 5000;
const COOLDOWN_TIME = 3000;

let state: GameState = {
  phase: 'waiting',
  multiplier: 1.0,
  crashPoint: 0,
  startTime: 0,
  bets: new Map(),
};

function startRound() {
  state.phase = 'running';
  state.multiplier = 1.0;
  state.crashPoint = generateCrashPoint(); // provably fair
  state.startTime = Date.now();

  const tick = () => {
    const elapsed = (Date.now() - state.startTime) / 1000;
    // Exponential growth: e^(0.06t) gives a smooth curve
    state.multiplier = Math.pow(Math.E, 0.06 * elapsed);
    state.multiplier = Math.floor(state.multiplier * 100) / 100;

    broadcast({
      type: 'tick',
      multiplier: state.multiplier,
    });

    if (state.multiplier >= state.crashPoint) {
      crash();
      return;
    }

    setTimeout(tick, TICK_RATE);
  };

  broadcast({ type: 'round_start' });
  tick();
}

function crash() {
  state.phase = 'crashed';
  // Settle all bets that didn't cash out
  for (const [playerId, bet] of state.bets) {
    if (!bet.cashedOut) {
      // Player loses
      settlebet(playerId, 0);
    }
  }
  broadcast({
    type: 'crashed',
    crashPoint: state.crashPoint,
  });

  setTimeout(() => {
    state.phase = 'cooldown';
    state.bets.clear();
    setTimeout(() => {
      state.phase = 'waiting';
      broadcast({ type: 'accepting_bets' });
      setTimeout(startRound, WAITING_TIME);
    }, COOLDOWN_TIME);
  }, 2000);
}

WebSocket Communication

The server broadcasts game state to all connected clients and handles individual player actions (place bet, cash out). Here's the message protocol:

// Server -> Client messages:
// { type: 'accepting_bets' }
// { type: 'round_start' }
// { type: 'tick', multiplier: 2.34 }
// { type: 'crashed', crashPoint: 5.67 }
// { type: 'cashout_confirmed', multiplier: 3.21, profit: 221 }

// Client -> Server messages:
// { type: 'place_bet', amount: 100 }
// { type: 'cashout' }

wss.on('connection', (ws: WebSocket) => {
  // Send current state on connect
  ws.send(JSON.stringify({
    type: 'state',
    phase: state.phase,
    multiplier: state.multiplier,
  }));

  ws.on('message', (data: string) => {
    const msg = JSON.parse(data);
    const playerId = getPlayerId(ws);

    switch (msg.type) {
      case 'place_bet':
        if (state.phase !== 'waiting') return;
        if (msg.amount < 1 || msg.amount > 10000) return;
        state.bets.set(playerId, {
          amount: msg.amount,
          cashedOut: false,
        });
        break;

      case 'cashout':
        if (state.phase !== 'running') return;
        const bet = state.bets.get(playerId);
        if (!bet || bet.cashedOut) return;
        bet.cashedOut = true;
        bet.cashoutAt = state.multiplier;
        const profit = bet.amount * state.multiplier;
        ws.send(JSON.stringify({
          type: 'cashout_confirmed',
          multiplier: state.multiplier,
          profit: Math.floor(profit * 100) / 100,
        }));
        break;
    }
  });
});

React Frontend

The frontend connects to the WebSocket server and renders the multiplier with a smooth animation. The key challenge is making the multiplier feel smooth despite receiving updates at 20fps.

'use client';

import { useState, useEffect, useRef, useCallback } from 'react';

type Phase = 'waiting' | 'running' | 'crashed' | 'cooldown';

export default function CrashGame() {
  const [phase, setPhase] = useState<Phase>('waiting');
  const [multiplier, setMultiplier] = useState(1.0);
  const [betAmount, setBetAmount] = useState(100);
  const [hasBet, setHasBet] = useState(false);
  const wsRef = useRef<WebSocket | null>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const ws = new WebSocket('ws://localhost:8080');
    wsRef.current = ws;

    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);

      switch (msg.type) {
        case 'accepting_bets':
          setPhase('waiting');
          setMultiplier(1.0);
          setHasBet(false);
          break;
        case 'round_start':
          setPhase('running');
          break;
        case 'tick':
          setMultiplier(msg.multiplier);
          drawGraph(msg.multiplier);
          break;
        case 'crashed':
          setPhase('crashed');
          setMultiplier(msg.crashPoint);
          break;
        case 'cashout_confirmed':
          // Show profit animation
          break;
      }
    };

    return () => ws.close();
  }, []);

  const placeBet = useCallback(() => {
    wsRef.current?.send(JSON.stringify({
      type: 'place_bet',
      amount: betAmount,
    }));
    setHasBet(true);
  }, [betAmount]);

  const cashOut = useCallback(() => {
    wsRef.current?.send(JSON.stringify({ type: 'cashout' }));
  }, []);

  return (
    <div className="bg-slate-900 rounded-xl p-6">
      <canvas ref={canvasRef} className="w-full h-64 mb-4" />

      <div className="text-center mb-6">
        <span className={`text-5xl font-bold ${
          phase === 'crashed' ? 'text-red-500' : 'text-cyan-400'
        }`}>
          {multiplier.toFixed(2)}x
        </span>
      </div>

      {phase === 'waiting' && !hasBet && (
        <button onClick={placeBet}
          className="w-full py-3 bg-cyan-600 rounded-lg font-bold">
          Bet {betAmount}
        </button>
      )}

      {phase === 'running' && hasBet && (
        <button onClick={cashOut}
          className="w-full py-3 bg-green-600 rounded-lg font-bold">
          Cash Out @ {multiplier.toFixed(2)}x
        </button>
      )}
    </div>
  );
}

Auto-Cashout Feature

A critical UX feature is auto-cashout — players set a target multiplier and the system automatically cashes them out when it's reached. This must be handled server-side to prevent network latency issues where a player misses their cashout because of a slow connection.

The server checks auto-cashout targets on every tick and processes them before broadcasting the multiplier update. This ensures fairness regardless of client connection quality.

Production Considerations

Before deploying a crash game to production:

- **Rate limiting**: Prevent bet spam and cashout spam per WebSocket connection. - **Reconnection handling**: Players who disconnect mid-round should be able to reconnect and see their active bet. - **Anti-fraud**: Detect and block collusion, bot behavior, and suspicious betting patterns. - **Load testing**: Verify the server handles your target concurrent user count with consistent tick rates. - **Audit trail**: Log every bet, cashout, and crash event with timestamps for regulatory compliance. - **Graceful degradation**: If Redis or the database goes down, the game should pause rather than lose bets.

Need help building this?

I build production iGaming platforms. Let's talk about your project.

Get In Touch