Ludo API Integration — Frontend Connection, CORS & WebSocket

Integrating the Ludo API into a web application is a layered challenge. You need to handle authentication flows, make HTTP requests from the browser, establish and maintain WebSocket connections for real-time gameplay, configure CORS correctly for cross-origin requests, manage environment variables across development and production environments, and implement error boundaries that keep the user experience intact when things go wrong. This guide covers all of it with vanilla JavaScript — no framework dependencies — so you can adapt the patterns to React, Vue, or any other frontend library.

Environment Variable Management

The first decision in any integration is where your API key lives. In a browser context, you should never ship your server-side API key to the client. The standard pattern is a thin backend proxy: your frontend talks to your own server over same-origin HTTP, and your server talks to the Ludo API with the actual key. For local development, use a .env file loaded by your build tool (Vite, webpack, or CRA). For production, inject environment variables at build time or runtime through your hosting platform (Vercel, Netlify, Railway, etc.).

Bash / .env
# .env — Local development (add to .gitignore immediately)
# Vite: VITE_ prefix exposes vars to client code
VITE_API_BASE_URL=http://localhost:3000
VITE_WS_URL=wss://api.ludokingapi.site/ws

# These should ONLY be on your backend/proxy server
# Never expose LUDO_API_KEY to the browser
LUDO_API_KEY=lk_live_your_key_here
LUDO_API_SECRET=sk_live_your_secret_here

# CORS configuration
CORS_ORIGINS=http://localhost:5173,https://yourdomain.com

# ─── In your JavaScript/TypeScript ──────────────────────────────────────────

// config.js — centralized configuration
export const config = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL || "https://api.ludokingapi.site/v1",
  wsUrl: import.meta.env.VITE_WS_URL || "wss://api.ludokingapi.site/ws",
};

console.log("API Base URL:", config.apiBaseUrl);

When using Vite, environment variables must be prefixed with VITE_ to be exposed to client-side code. Variables without this prefix are only available on the server side. In production, set these values in your hosting platform's environment variable dashboard — never in a committed .env.production file that gets pushed to a repository.

Frontend HTTP Client with Fetch API

The Fetch API is built into every modern browser and handles HTTP requests without external dependencies. The key to a maintainable integration is abstracting fetch behind a typed client class that centralizes authentication headers, base URL composition, error transformation, and retry logic. This client becomes the single source of truth for all HTTP communication between your frontend and the Ludo API.

JavaScript
// ludo-fetch-client.js — Fetch-based HTTP client with error handling

class LudoFetchClient {
  constructor({ baseUrl, sessionToken = null }) {
    this.baseUrl = baseUrl;
    this.sessionToken = sessionToken;
  }

  // ─── Core fetch wrapper with authentication and error handling ─────────────
  async _fetch(endpoint, options = {}) {
    const url = `{this.baseUrl}/{endpoint}`;

    const headers = {
      "Content-Type": "application/json",
      ...(options.headers || {})
    };

    // Attach session token if available
    if (this.sessionToken) {
      headers["Authorization"] = `Bearer {this.sessionToken}`;
    }

    try {
      const response = await fetch(url, {
        method: options.method || "GET",
        headers,
        body: options.body ? JSON.stringify(options.body) : undefined,
        credentials: "same-origin",  // Send cookies for same-origin
        signal: options.signal   // Allow AbortController cancellation
      });

      const data = await response.json();

      if (!response.ok) {
        const error = new LudoAPIError(
          data.error?.message || `HTTP {response.status}`,
          response.status,
          data.error?.code
        );
        throw error;
      }

      return data;
    } catch (err) {
      if (err instanceof LudoAPIError) throw err;
      if (err.name === "AbortError") {
        throw new LudoAPIError("Request was cancelled", 0, "CANCELLED");
      }
      throw new LudoAPIError(
        `Network error: {err.message}",
        0,
        "NETWORK_ERROR"
      );
    }
  }

  // ─── Typed API methods ─────────────────────────────────────────────────────
  async registerPlayer(playerName, deviceId) {
    return this._fetch("/players", {
      method: "POST",
      body: { playerName, deviceId, platform: "web" }
    });
  }

  async getPlayer(playerId) {
    return this._fetch(`/players/{playerId}`);
  }

  async createRoom(maxPlayers = 4, gameMode = "classic") {
    return this._fetch("/rooms", {
      method: "POST",
      body: { maxPlayers, gameMode }
    });
  }

  async joinRoom(roomCode) {
    return this._fetch(`/rooms/{roomCode}/join`, {
      method: "POST"
    });
  }

  async getRoom(roomCode) {
    return this._fetch(`/rooms/{roomCode}`);
  }

  async getGameState(gameId) {
    return this._fetch(`/games/{gameId}/state`);
  }

  // ─── Token management ──────────────────────────────────────────────────────
  setSessionToken(token) {
    this.sessionToken = token;
    localStorage.setItem("ludo_session_token", token);
  }

  loadSessionToken() {
    this.sessionToken = localStorage.getItem("ludo_session_token");
    return this.sessionToken;
  }

  clearSessionToken() {
    this.sessionToken = null;
    localStorage.removeItem("ludo_session_token");
  }
}

// Custom error class with HTTP status and error code
class LudoAPIError extends Error {
  constructor(message, status, code) {
    super(message);
    this.name = "LudoAPIError";
    this.status = status;
    this.code = code;
  }
}

// ─── Usage example ────────────────────────────────────────────────────────────
const client = new LudoFetchClient({
  baseUrl: config.apiBaseUrl
});

// Load existing session or register new player
async function initPlayer() {
  const existingToken = client.loadSessionToken();

  if (existingToken) {
    console.log("Restored session", existingToken);
    return existingToken;
  }

  try {
    const result = await client.registerPlayer("Player1", "device-abc");
    client.setSessionToken(result.data.sessionToken);
    console.log("New player registered", result.data.playerId);
    return result.data.sessionToken;
  } catch (err) {
    console.error("Registration failed:", err.message);
    throw err;
  }
}

WebSocket Client — Complete Vanilla JS Implementation

The WebSocket connection is the backbone of real-time Ludo gameplay. It delivers dice rolls, piece movements, turn changes, and game end events with minimal latency. The vanilla JS WebSocket API is clean and well-supported, but a production-grade client needs reconnection logic, typed event routing, connection state tracking, and graceful degradation when the connection drops mid-game. The class below implements all of these patterns.

JavaScript
// ludo-ws-client.js — Production WebSocket client with reconnection and typed events

class LudoWebSocketClient {
  constructor({ wsUrl, sessionToken }) {
    this.wsUrl = wsUrl;
    this.sessionToken = sessionToken;
    this.socket = null;
    this.roomCode = null;
    this.gameId = null;

    // State tracking
    this._state = "disconnected"; // disconnected | connecting | connected | reconnecting
    this._reconnectAttempts = 0;
    this._maxReconnectAttempts = 10;
    this._reconnectDelay = 1000;  // Base delay in ms
    this._heartbeatInterval = null;
    this._handlers = {};
    this._messageQueue = [];

    // Callbacks for UI updates
    this.onStateChange = null;
    this.onError = null;
    this.onReconnecting = null;
  }

  // ─── Public API ────────────────────────────────────────────────────────────

  on(eventType, handler) {
    this._handlers[eventType] = handler;
  }

  off(eventType) {
    delete this._handlers[eventType];
  }

  connect(roomCode, gameId) {
    this.roomCode = roomCode;
    this.gameId = gameId;
    this._state = "connecting";
    this._setState("connecting");

    // Append auth token as query param for browsers that don't support WS headers
    const wsUrlWithAuth = `{this.wsUrl}?token={this.sessionToken}`;
    this.socket = new WebSocket(wsUrlWithAuth);

    this.socket.onopen = () => this._onOpen();
    this.socket.onmessage = (e) => this._onMessage(e);
    this.socket.onclose = (e) => this._onClose(e);
    this.socket.onerror = (e) => this._onError(e);
  }

  disconnect() {
    this._clearHeartbeat();
    this._maxReconnectAttempts = 0;  // Prevent auto-reconnect
    if (this.socket) {
      this.socket.close(1000, "Client initiated disconnect");
      this.socket = null;
    }
    this._state = "disconnected";
    this._setState("disconnected");
  }

  // ─── Send methods ───────────────────────────────────────────────────────────

  joinRoom() {
    this._send("room:join", { roomCode: this.roomCode });
  }

  rollDice() {
    this._send("game:roll-dice", { roomCode: this.roomCode });
  }

  movePiece(pieceId, targetPosition) {
    this._send("game:move-piece", {
      roomCode: this.roomCode,
      pieceId,
      targetPosition
    });
  }

  // ─── Internal methods ──────────────────────────────────────────────────────

  _send(eventType, data) {
    const message = JSON.stringify({ type: eventType, ...data });

    if (this.socket?.readyState === WebSocket.OPEN) {
      this.socket.send(message);
    } else {
      // Queue message to send after reconnect
      this._messageQueue.push(message);
    }
  }

  _flushMessageQueue() {
    while (this._messageQueue.length > 0) {
      const msg = this._messageQueue.shift();
      this.socket.send(msg);
    }
  }

  _onOpen() {
    console.log("[WS] Connected");
    this._state = "connected";
    this._reconnectAttempts = 0;
    this._setState("connected");
    this._startHeartbeat();
    this.joinRoom();
    this._flushMessageQueue();
  }

  _onMessage(event) {
    try {
      const { type, data, error } = JSON.parse(event.data);

      if (error) {
        console.error(`[WS] Server error: {error.message}`);
        if (this.onError) this.onError(error);
        return;
      }

      if (this._handlers[type]) {
        this._handlers[type](data);
      }
    } catch (err) {
      console.error("[WS] Failed to parse message:", err);
    }
  }

  _onClose(event) {
    console.log(`[WS] Closed (code: {event.code})`);
    this._clearHeartbeat();

    if (event.code === 1000) {
      // Clean disconnect — don't reconnect
      this._state = "disconnected";
      this._setState("disconnected");
      return;
    }

    this._scheduleReconnect();
  }

  _onError(event) {
    console.error("[WS] Error", event);
    if (this.onError) this.onError(new Error("WebSocket connection error"));
  }

  _scheduleReconnect() {
    if (this._reconnectAttempts >= this._maxReconnectAttempts) {
      console.error("[WS] Max reconnection attempts reached");
      this._state = "disconnected";
      this._setState("disconnected");
      if (this.onError) this.onError(new Error("Max reconnection attempts reached"));
      return;
    }

    const delay = this._reconnectDelay * Math.pow(2, this._reconnectAttempts);
    console.log(`[WS] Reconnecting in {delay}ms (attempt {this._reconnectAttempts + 1})`);
    this._state = "reconnecting";
    this._setState("reconnecting");
    if (this.onReconnecting) this.onReconnecting({ attempt: this._reconnectAttempts + 1, delay });

    setTimeout(() => {
      this._reconnectAttempts++;
      this.connect(this.roomCode, this.gameId);
    }, delay);
  }

  _startHeartbeat() {
    this._heartbeatInterval = setInterval(() => {
      if (this.socket?.readyState === WebSocket.OPEN) {
        this.socket.send(JSON.stringify({ type: "ping" }));
      }
    }, 30000);
  }

  _clearHeartbeat() {
    if (this._heartbeatInterval) {
      clearInterval(this._heartbeatInterval);
      this._heartbeatInterval = null;
    }
  }

  _setState(state) {
    if (this.onStateChange) this.onStateChange(state);
  }

  getState() {
    return this._state;
  }
}

// ─── Usage: wire up the WebSocket client to your game UI ───────────────────
const wsClient = new LudoWebSocketClient({
  wsUrl: config.wsUrl,
  sessionToken: client.loadSessionToken()
});

// Register typed event handlers
wsClient.on("room:joined", (data) => {
  console.log("Joined room with", data.players.length, "players");
  renderGameBoard(data.gameState);
});

wsClient.on("game:dice-rolled", (data) => {
  animateDiceRoll(data.value);
});

wsClient.on("game:turn-change", (data) => {
  highlightActivePlayer(data.currentPlayerId);
});

wsClient.on("game:piece-moved", (data) => {
  animatePieceMove(data.pieceId, data.from, data.to);
});

wsClient.on("game:ended", (data) => {
  showGameOver(data.winnerId, data.scores);
  wsClient.disconnect();
});

// Connection state UI updates
wsClient.onStateChange = (state) => {
  const statusEl = document.getElementById("connection-status");
  if (statusEl) statusEl.textContent = state;
};

wsClient.onReconnecting = ({ attempt, delay }) => {
  console.log(`Reconnecting... attempt {attempt} in {delay}ms`);
  showToast("Connection lost. Reconnecting...", "warning");
};

// Connect to the game room
wsClient.connect("ABCD12", null);

CORS Configuration — Frontend-Backend Proxy Setup

Cross-Origin Resource Sharing (CORS) is the browser mechanism that controls which domains can make requests to your API. If your frontend runs on https://yourgame.com but the Ludo API runs on https://api.ludokingapi.site, the browser blocks the request unless the API explicitly allows your origin. The correct pattern for production is a backend proxy: your frontend requests go to your own server (same-origin), and your server forwards them to the Ludo API. This gives you full control over CORS headers, rate limiting, request logging, and key rotation.

Node.js
// cors-proxy.js — Express middleware for CORS and API proxy
require('dotenv').config();

const express = require('express');
const axios = require('axios');
const app = express();

// ─── CORS Middleware ─────────────────────────────────────────────────────────

const allowedOrigins = (process.env.CORS_ORIGINS || "http://localhost:3000")
  .split(",")
  .map(o => o.trim());

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (allowedOrigins.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
  }

  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  res.setHeader("Access-Control-Allow-Credentials", "true");
  res.setHeader("Access-Control-Max-Age", "86400"); // Preflight cache for 24h

  if (req.method === "OPTIONS") {
    return res.sendStatus(204);
  }

  next();
});

// ─── API Proxy Routes ────────────────────────────────────────────────────────

const LUDO_API_BASE = "https://api.ludokingapi.site/v1";

// All /api/* requests are forwarded to the Ludo API with the server-side key
app.use("/api", express.json(), async (req, res) => {
  const targetUrl = `{LUDO_API_BASE}/{req.url}`;

  try {
    const response = await axios.request({
      method: req.method,
      url: targetUrl,
      data: req.body,
      headers: {
        "Authorization": `Bearer {process.env.LUDO_API_KEY}`,
        "Content-Type": "application/json"
      },
      timeout: 10000
    });
    res.status(response.status).json(response.data);
  } catch (err) {
    if (err.response) {
      res.status(err.response.status).json(err.response.data);
    } else {
      console.error("Proxy error:", err.message);
      res.status(502).json({ error: { code: "PROXY_ERROR", message: "Upstream request failed" } });
    }
  }
});

// ─── Security Middleware ─────────────────────────────────────────────────────

// Rate limiting per IP (use Redis in production)
const rateLimit = require('express-rate-limit');
app.use("/api", rateLimit({
  windowMs: 60 * 1000,  // 1 minute
  max: 100,            // 100 requests per minute per IP
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: { code: "RATE_LIMITED", message: "Too many requests. Please wait." } }
}));

// Body size limit to prevent abuse
app.use(express.json({ limit: "1mb" }));

app.listen(3000, () => console.log("CORS proxy running on :3000"));

Error Boundary Patterns

Robust error handling is the difference between a polished product and one that crashes silently or shows unhelpful error messages. Implement error boundaries at three levels: individual API calls (for retryable errors), the WebSocket connection (for reconnection flows), and global error handlers (for uncaught exceptions). This layered approach ensures that a failed API call doesn't bring down your game board, and a lost WebSocket connection doesn't freeze the UI permanently.

JavaScript
// error-handler.js — Layered error handling for Ludo game applications

// ─── Level 1: API Error Handler ─────────────────────────────────────────────

async function withErrorHandling(asyncFn, { onRetry, onPermanentError, maxRetries = 3 } = {}) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await asyncFn();
    } catch (err) {
      const isLastAttempt = attempt === maxRetries;

      // Classify error severity
      const isRetryable = [408, 429, 502, 503, 504].includes(err.status)
        || err instanceof TypeError;  // Network errors (no response)

      if (isLastAttempt || !isRetryable) {
        console.error(`[API] Permanent error after {attempt + 1} attempts:`, err.message);
        if (onPermanentError) await onPermanentError(err);
        return { error: err, permanent: true };
      }

      // Exponential backoff before retry
      const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
      console.warn(`[API] Retrying in {delay}ms (attempt {attempt + 1})...`);
      if (onRetry) await onRetry({ attempt: attempt + 1, delay, error: err });
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

// ─── Level 2: WebSocket Error Handler ──────────────────────────────────────

function setupWSErrorHandling(wsClient) {
  const errorOverlay = document.getElementById("error-overlay");

  wsClient.onError = (err) => {
    console.error("[WS] Unhandled error:", err);

    // Show non-intrusive toast for recoverable errors
    showToast("Connection issue. Attempting to reconnect...", "error", 5000);

    // Show overlay only for game-critical failures after max retries
    if (wsClient.getState() === "disconnected" && wsClient._reconnectAttempts >= wsClient._maxReconnectAttempts) {
      if (errorOverlay) {
        errorOverlay.innerHTML = `
          

Connection Lost

We couldn't reconnect to the game server.

`; errorOverlay.classList.add("error-overlay--visible"); } } }; } // ─── Level 3: Global Uncaught Exception Handler ───────────────────────────── window.addEventListener("error", (event) => { console.error("[Global] Uncaught error:", event.error); if (event.error instanceof LudoAPIError) { const userMessage = getUserFriendlyMessage(event.error); showToast(userMessage, "error"); event.preventDefault(); // Prevent default browser error handling } }); window.addEventListener("unhandledrejection", (event) => { console.error("[Global] Unhandled promise rejection:", event.reason); event.preventDefault(); }); // Map API error codes to user-friendly messages function getUserFriendlyMessage(err) { const messages = { UNAUTHORIZED: "Your session has expired. Please log in again.", ROOM_FULL: "This game room is full. Try joining another room.", ROOM_NOT_FOUND: "This room doesn't exist or has already ended.", INVALID_MOVE: "That move wasn't allowed. Please try a different piece.", NOT_YOUR_TURN: "It's not your turn yet.", RATE_LIMITED: "You're making requests too quickly. Please wait a moment.", NETWORK_ERROR: "Network connection issue. Please check your internet.", }; return messages[err.code] || "Something went wrong. Please try again."; } // ─── Usage ─────────────────────────────────────────────────────────────────── async function createRoomWithRetry() { const result = await withErrorHandling( () => client.createRoom(4), { onRetry: ({ attempt, delay }) => { showToast("Retrying... (attempt {attempt})", "warning"); }, onPermanentError: (err) => { showToast(getUserFriendlyMessage(err), "error", 8000); } } ); return result; }

Common Integration Errors and Fixes

  • CORS error: "No 'Access-Control-Allow-Origin' header" — Your frontend is calling the Ludo API directly. Set up a backend proxy (see the Express middleware above) so all requests go same-origin.
  • 401 Unauthorized after successful login — The session token expired or wasn't stored correctly. Verify localStorage.setItem was called and the token is attached to subsequent requests.
  • WebSocket reconnects but game state is stale — After reconnecting, request a full state sync: GET /games/{gameId}/state and re-render the board before resuming gameplay.
  • "Room not found" on valid room code — The room may have expired (TTL of 5 minutes for empty rooms). Create a new room and share the code immediately.
  • Duplicate event processing after reconnect — Use idempotency keys (event IDs) in your event handlers to deduplicate. Server should include a unique event ID on every message.
  • Environment variables undefined in production — Vite requires the VITE_ prefix. Webpack DefinePlugin must explicitly expose vars. Verify your hosting platform injects the variable at the correct stage (build vs runtime).

Putting It All Together — Complete Integration Flow

The full integration follows this lifecycle. Each step builds on the previous one, and the error handling from each layer ensures the user experience degrades gracefully when any individual component fails.

  1. Load config — Read environment variables (VITE_ prefixed) from .env
  2. Initialize client — Create LudoFetchClient and restore session token from localStorage
  3. Register or resume session — POST /players if no token, otherwise use existing token
  4. Proxy server setup — Your Express proxy runs on same-origin, forwarding requests to Ludo API with server-side key
  5. Update frontend baseUrl — Point to your proxy (http://localhost:3000), not the Ludo API directly
  6. Create or join room — REST calls for room lifecycle
  7. Upgrade to WebSocket — Connect LudoWebSocketClient for real-time events
  8. Game loop — Roll dice, move pieces, receive events via typed handlers
  9. Handle disconnection — Auto-reconnect with backoff, state sync after reconnect
  10. Game end — Display results, clean up WebSocket, return to lobby

Frequently Asked Questions

Need Help with Your Ludo API Integration?

From frontend connection patterns to CORS configuration and error handling, our team can help you get production-ready. Get in touch on WhatsApp.

Chat on WhatsApp