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.).
# .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.
// 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.
// 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.
// 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.
// 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 = `
`;
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.setItemwas 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}/stateand 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.
- Load config — Read environment variables (VITE_ prefixed) from
.env - Initialize client — Create
LudoFetchClientand restore session token from localStorage - Register or resume session — POST /players if no token, otherwise use existing token
- Proxy server setup — Your Express proxy runs on same-origin, forwarding requests to Ludo API with server-side key
- Update frontend baseUrl — Point to your proxy (
http://localhost:3000), not the Ludo API directly - Create or join room — REST calls for room lifecycle
- Upgrade to WebSocket — Connect
LudoWebSocketClientfor real-time events - Game loop — Roll dice, move pieces, receive events via typed handlers
- Handle disconnection — Auto-reconnect with backoff, state sync after reconnect
- Game end — Display results, clean up WebSocket, return to lobby
Frequently Asked Questions
Yes — even in development, using a proxy is the right pattern. Configure your browser to allow
CORS during dev (via a browser extension or a local proxy), but structure your code as if it's going
through a proxy from day one. This means your frontend always calls /api/rooms (same-origin) rather than https://api.ludokingapi.site/v1/rooms. The proxy route maps one to the
other. This eliminates CORS headaches entirely and means you only change one URL (the proxy target)
when moving between dev, staging, and production environments.
Store the current game state in memory (and optionally localStorage for persistence) during
Normal gameplay. When the WebSocket reconnects, the server sends a room:sync event containing the full current game state. Use this to
re-render the board from scratch rather than trying to apply incremental missed events. In parallel,
process any queued messages that were sent during the brief disconnect window. The key is that your
event handlers must be idempotent — applying the same event twice (once from the queued message,
once from the sync state) shouldn't result in duplicate piece movements.
Absolutely. The fetch client and WebSocket client patterns in this guide are vanilla JavaScript,
meaning they work inside any framework. In React, wrap the LudoFetchClient in a custom hook (e.g., useLudoAPI) that returns the client methods and manages loading/error
state. In Vue, create a composable (Vue 3's equivalent of hooks). The WebSocket client integrates
with React's useEffect for connection lifecycle and state updates
via useState or a state management library. The error boundary
patterns apply at the component level with React 16+ Error Boundaries or Vue's errorCaptured hook.
Preflight failures happen when the OPTIONS request — sent automatically by the browser before
your actual POST/PUT request — doesn't return the correct CORS headers. Common causes: the proxy
server doesn't handle OPTIONS requests (add the if (req.method === "OPTIONS")
return res.sendStatus(204) check shown in the Express middleware above), the server is
down, or a middleware earlier in the chain (security headers, authentication) rejects the preflight
before CORS headers are set. Use your browser's Network tab to inspect the OPTIONS response and
verify that Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers are all present.
Instantiate separate LudoWebSocketClient instances for each room.
Each instance maintains its own socket, reconnect logic, and event handlers. Track room
subscriptions in a Map: const activeRooms = new Map() where the
key is the room code and the value is the client instance. When a player joins a room, create the
client and register room-specific event handlers. When they leave, call disconnect() and remove the instance from the map. This keeps
isolation clean — events from Room A never leak into Room B's handlers. Be mindful of browser
WebSocket connection limits (typically 200 per domain) if you expect players to be in many rooms
simultaneously.
Use a .env file per environment: .env.development, .env.staging, .env.production. In Vite,
load the appropriate one via the mode flag: vite build --mode staging
loads .env.staging and .env. In your CI/CD pipeline, set environment variables in the platform's
dashboard (Vercel, Railway, Render) — never commit production env files. For the server-side proxy,
use environment variables directly (no prefix needed) and inject them through your orchestrator or
CI/CD system. The critical rule: VITE_ prefixed variables are
embedded at build time (bundled into the JavaScript), so a rebuild is required after changing them.
Variables without the prefix are read at runtime.
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