Ludo REST API — Architecture, Authentication & Production Patterns
A Ludo game backend that serves thousands of concurrent players demands more than bare CRUD endpoints. This guide covers the full production architecture: URL versioning strategy, cursor-based pagination for large game histories, two authentication patterns (API key for server-to-server calls and JWT for user sessions), idempotency keys to safely retry move submissions, OpenAPI 3.0 specification for automated client generation, and caching headers that keep leaderboards fast without stale data. Every concept is paired with runnable code and real-world trade-offs.
API Versioning Strategy
Versioning is the contract between your API and its clients. When you change field names, alter response shapes, or remove endpoints, existing client apps break silently unless you version first. The most pragmatic approach for public and private game APIs is URL path versioning — placing the version number directly in the path: /v1/, /v2/. This makes the active version immediately visible in logs, makes routing unambiguous at the load balancer level, and requires no special client configuration beyond updating the base URL.
Header-based versioning (Accept: application/vnd.ludoking.v2+json) is theoretically purer because the URL stays clean, but it complicates browser testing, load balancer rules, and CDN caching. URL versioning wins for game APIs where simplicity and debuggability matter more than REST purity. Reserve /v1/ for the initial stable release, then promote endpoints to /v2/ only when a breaking change is unavoidable — renaming the diceValue field to dice_result, for instance, or changing the leaderboard response from an array to a paginated object.
# v1 endpoint — classic response shape
curl -X GET "https://api.ludokingapi.site/v1/players/plr_a1b2c3d4" \
-H "Authorization: Bearer YOUR_TOKEN"
# v2 endpoint — paginated with cursor token
curl -X GET "https://api.ludokingapi.site/v2/players/plr_a1b2c3d4/matches?perPage=20" \
-H "Authorization: Bearer YOUR_TOKEN"
# Version negotiation via Accept header (optional)
curl -X GET "https://api.ludokingapi.site/players/plr_a1b2c3d4" \
-H "Accept: application/vnd.ludoking.v2+json"
Maintain old versions for a deprecation window — typically 6 to 12 months — and return a Deprecation: true response header plus a Sunset header with the exact date. This gives mobile app developers time to release an update before the old endpoint disappears. A X-API-Versions-Supported header lets clients discover available versions programmatically.
Authentication Patterns: API Key vs JWT
Different clients need different auth schemes. Server-to-server integrations (a tournament platform posting results to your API) use long-lived API keys because there's no user context to encode into a token. User-facing clients (mobile apps, web frontends) use short-lived JWTs because they encode the player identity, permissions, and expiry in a stateless, verifiable token.
API Key Authentication
API keys are ideal for backend integrations where the client is a trusted service. The key is passed in the X-API-Key header, never in the URL (URL parameters get logged in server access logs, proxies, and browser history). Store the key hash in your database — never store or log the plain key. Validate it on every request and associate it with a set of scopes: games:read, games:write, rooms:manage, leaderboard:read. A tournament server might only need games:write, while a leaderboard widget only needs leaderboard:read.
# Server-to-server: API key in header
curl -X POST "https://api.ludokingapi.site/v1/games/gme_xyz/complete" \
-H "X-API-Key: lk_api_a1b2c3d4e5f6g7h8i9j0" \
-H "Content-Type: application/json" \
-d '{
"winnerId": "plr_a1b2c3d4",
"scores": [
{ "playerId": "plr_a1b2c3d4", "rank": 1, "points": 50 },
{ "playerId": "plr_wxyz", "rank": 2, "points": 30 }
],
"duration": 245,
"mode": "classic"
}'
# Response
# {
# "success": true,
# "data": {
# "gameId": "gme_xyz",
# "status": "completed",
# "recordedAt": "2026-03-21T14:22:00Z"
# }
# }
JWT Authentication
JWTs (JSON Web Tokens) are self-contained: they carry the player ID, display name, rank, and expiry without requiring a database lookup on every request. A JWT has three Base64URL-encoded parts separated by dots: header (algorithm), payload (claims), and signature. The server verifies the signature with a shared secret or public key. Tokens typically expire in 1 hour for access tokens and 7 days for refresh tokens. See the Node.js implementation guide for the full middleware and refresh token flow.
# Client auth: JWT in Bearer header
curl -X GET "https://api.ludokingapi.site/v1/rooms/RND42" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJwbHJfYTFiMmMzZDQiLCJ
uaWQiOiJQcml5YU0iLCJyYW5rIjoiR29sZCIsImV4cCI6MTc0MTkzMjQ0MH0.sig"
# JWT payload decoded:
# {
# "sub": "plr_a1b2c3d4",
# "name": "PriyaM",
# "rank": "Gold",
# "iat": 1741932400,
# "exp": 1741936000
# }
Error Response Schema
Every error response follows a consistent JSON schema that machines can parse and humans can read. The top-level success field is always false for errors. The error object contains a machine-readable code (use SCREAMING_SNAKE_CASE), the human-readable message, the HTTP status as an integer, an optional details array for field-level validation errors, and an optional requestId for support correlation.
{
"success": false,
"error": {
"code": "INVALID_MOVE",
"status": 400,
"message": "Cannot move piece — not your turn",
"details": [
{
"field": "currentTurn",
"value": "plr_wxyz",
"issue": "Expected player plr_a1b2c3d4 but got plr_wxyz"
}
],
"requestId": "req_7f8a9b2c"
},
"meta": {
"timestamp": "2026-03-21T14:30:00Z",
"version": "v1"
}
}
// Validation error with multiple field issues
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"status": 422,
"message": "Request validation failed",
"details": [
{ "field": "roomCode", "value": null, "issue": "roomCode is required" },
{ "field": "maxPlayers", "value": 7, "issue": "maxPlayers must be between 2 and 4" },
{ "field": "turnTimeLimit", "value": 0, "issue": "turnTimeLimit must be positive" }
],
"requestId": "req_abc123"
}
}
// Error middleware (Node.js Express)
const errorHandler = (err, req, res, next) => {
const requestId = req.headers['x-request-id'] || uuid.v4();
console.error({ requestId, err });
if (err.name === 'ValidationError') {
return res.status(422).json({
success: false,
error: { code: 'VALIDATION_ERROR', status: 422, message: err.message, details: err.details, requestId }
});
}
if (err.name === 'NotFoundError') {
return res.status(404).json({
success: false,
error: { code: 'NOT_FOUND', status: 404, message: err.message, requestId }
});
}
return res.status(500).json({
success: false,
error: { code: 'INTERNAL_ERROR', status: 500, message: 'An unexpected error occurred', requestId }
});
};
Cursor-Based Pagination
Offset-based pagination (?page=5) has a fundamental problem in a live game environment: between page 3 and page 4, another player could finish a game, shifting all ranks by one. The player who was at rank 47 now appears at rank 48 — they either get skipped or duplicated when the client fetches the next page. Cursor-based pagination solves this by bookmarking the exact read position using an opaque token.
The cursor encodes the sort key of the last item seen (typically the database ID or a composite of timestamp + ID). When the client sends that cursor back, the server resumes exactly where it left off, regardless of concurrent writes. Cursors should be Base64URL-encoded JSON to keep them compact and URL-safe. Include hasMore boolean and nextCursor in every paginated response so clients know when to stop.
# Page 1 — no cursor, server uses default sort (rank DESC, id ASC)
curl "https://api.ludokingapi.site/v1/leaderboards/global?perPage=50" \
-H "Authorization: Bearer YOUR_TOKEN"
# Paginated response
# {
# "success": true,
# "data": [
# { "rank": 1, "playerId": "plr_aaa", "name": "ChampionX", "wins": 891, "score": 44550 },
# { "rank": 2, "playerId": "plr_bbb", "name": "DiceKing", "wins": 742, "score": 37100 },
# ... (50 items)
# ],
# "meta": {
# "perPage": 50,
# "nextCursor": "eyJpZCI6InBocl95enkiLCJyYW5rIjoyfQ==",
# "hasMore": true
# }
# }
# Page 2 — resume from cursor
curl "https://api.ludokingapi.site/v1/leaderboards/global?perPage=50&cursor=eyJpZCI6InBocl95enkiLCJyYW5rIjoyfQ==" \
-H "Authorization: Bearer YOUR_TOKEN"
# Final page — hasMore becomes false
# {
# "success": true,
# "data": [...],
# "meta": {
# "perPage": 50,
# "nextCursor": null,
# "hasMore": false
# }
# }
Cursor encoding is straightforward: {"id":"plr_yzz","rank":2} becomes eyJpZCI6InBocl95enoiLCJyYW5rIjoyfQ== after Base64URL encoding. On the server side, decode the cursor and translate it to a database query: WHERE (rank, id) < (:lastRank, :lastId) ORDER BY rank DESC, id ASC. For databases without compound cursor support, use WHERE created_at < :cursorTimestamp AND id < :cursorId.
Rate Limit Headers
Rate limiting protects your Ludo API from abuse, bot scripts, and accidental infinite loops in client code. Every response includes standard rate limit headers so clients can implement exponential backoff intelligently. The key headers are X-RateLimit-Limit (total requests allowed), X-RateLimit-Remaining (requests left in the window), X-RateLimit-Reset (Unix timestamp when the window resets), and Retry-After (seconds to wait, only present on 429 responses).
# Normal response — rate limit headers present
curl -I "https://api.ludokingapi.site/v1/leaderboards/global" \
-H "Authorization: Bearer YOUR_TOKEN"
# HTTP/1.1 200 OK
# X-RateLimit-Limit: 100
# X-RateLimit-Remaining: 87
# X-RateLimit-Reset: 1741933200
# X-RateLimit-Window: 15m
# Cache-Control: public, max-age=60, stale-while-revalidate=300
# Rate limited response — client should back off
# HTTP/1.1 429 Too Many Requests
# Retry-After: 45
# X-RateLimit-Limit: 100
# X-RateLimit-Remaining: 0
# X-RateLimit-Reset: 1741933200
# Content-Type: application/json
# {
# "success": false,
# "error": {
# "code": "RATE_LIMIT_EXCEEDED",
# "status": 429,
# "message": "Rate limit exceeded. Retry after 45 seconds.",
# "retryAfter": 45
# }
# }
Implement tiered rate limits by endpoint type: public read endpoints like leaderboards allow 100 requests per 15 minutes, authenticated read endpoints allow 200 requests per 15 minutes, and write endpoints like move submission allow only 30 requests per 15 minutes to prevent spamming. Track rate limits per API key or per JWT subject in Redis for distributed environments.
Idempotency for Safe Retries
Network failures happen. A mobile player submits a move, the server processes it correctly, but the HTTP response times out before reaching the client. The client retries — and now you've submitted the same move twice. Idempotency keys solve this. The client generates a unique UUID for each mutating request (POST, PUT, DELETE), sends it in the Idempotency-Key header, and the server stores the request key + response for 24 hours. If the same key arrives again, the server returns the cached response without re-processing.
# Submit move with idempotency key
curl -X POST "https://api.ludokingapi.site/v1/games/gme_xyz/moves" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Idempotency-Key: 7f4e2a8c-1d3f-4e9b-a2c5-6d7e8f9a0b1c" \
-H "Content-Type: application/json" \
-d '{
"pieceId": "piece_2",
"targetPosition": 8,
"diceValue": 6
}'
# Response — move accepted
# {
# "success": true,
# "data": {
# "moveId": "mov_abc123",
# "pieceId": "piece_2",
# "fromPosition": 2,
# "toPosition": 8,
# "captured": false
# }
# }
# Retry with same key — returns cached response (200 OK, not 201)
# {
# "success": true,
# "data": {
# "moveId": "mov_abc123",
# "pieceId": "piece_2",
# "fromPosition": 2,
# "toPosition": 8,
# "cached": true
# }
# }
Store idempotency records in Redis with a TTL of 24 hours. The key format: idempotency:{playerId}:{idempotencyKey}. For moves specifically, returning a cached: true flag on replayed requests tells the client "your move was already applied" without ambiguity. Only support idempotency on POST, PUT, and DELETE — GET requests are naturally idempotent and don't need it.
Caching Headers Strategy
HTTP caching headers let you reduce server load dramatically by instructing browsers, CDNs, and proxy servers to cache responses. The Cache-Control header is the primary directive. For a Ludo game API, the caching strategy varies by endpoint type. Public leaderboards change every few minutes but not every second — cache them at the CDN layer with a 60-second max-age and a 5-minute stale-while-revalidate window so readers always get a fast response even if the cache is slightly behind. Player profiles change infrequently — cache them for 5 minutes with a 15-minute stale window. Game state and active room status should never be cached since they change in real time — use Cache-Control: no-store.
# Leaderboard — CDN cacheable, stale-while-revalidate for freshness
curl -I "https://api.ludokingapi.site/v1/leaderboards/global?perPage=100" \
-H "Authorization: Bearer YOUR_TOKEN"
# Cache-Control: public, max-age=60, stale-while-revalidate=300
# ETag: "leaderboard-v3-1741932000"
# Vary: Authorization, Accept-Encoding
# Player profile — browser cacheable with revalidation
# Cache-Control: private, max-age=300, must-revalidate
# ETag: "player-plr_a1b2c3d4-v7"
# Active game state — NEVER cache
# Cache-Control: no-store, no-cache, must-revalidate
# Pragma: no-cache
# Static game history (completed games) — long cache
# Cache-Control: public, max-age=3600, immutable
# ETag: "game-gme_xyz-v1"
Use ETags alongside caching: the server computes a hash of the response content and sends it as an ETag header. On subsequent requests, the client sends If-None-Match: "etag-value". If the content hasn't changed, the server returns 304 Not Modified with no body — saving bandwidth. This is especially valuable for game history endpoints where responses can be several kilobytes.
OpenAPI 3.0 Specification Skeleton
The OpenAPI Specification (OAS) is a machine-readable contract for your REST API. Generating an OpenAPI spec from your code (or writing it manually) enables automated client SDK generation, interactive API documentation via Swagger UI or Redoc, and contract testing in CI/CD pipelines. Below is the core skeleton with paths for the essential Ludo resources.
# openapi.yaml — LudoKingAPI v1
openapi: 3.0.3
info:
title: LudoKingAPI
version: 1.0.0
description: REST API for Ludo multiplayer game backends
contact:
name: LudoKingAPI Support
url: https://ludokingapi.site
email: support@ludokingapi.site
servers:
- url: https://api.ludokingapi.site/v1
description: Production
- url: https://staging-api.ludokingapi.site/v1
description: Staging
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
schemas:
Error:
type: object
properties:
success: { type: boolean, enum: [false] }
error:
type: object
properties:
code: { type: string, example: "INVALID_MOVE" }
status: { type: integer, example: 400 }
message: { type: string }
details: { type: array, items: { $ref: '#/components/schemas/ValidationDetail' } }
requestId: { type: string }
ValidationDetail:
type: object
properties:
field: { type: string }
value: {}
issue: { type: string }
PaginationMeta:
type: object
properties:
perPage: { type: integer }
nextCursor: { type: string, nullable: true }
hasMore: { type: boolean }
Player:
type: object
properties:
id: { type: string }
name: { type: string }
rank: { type: string }
wins: { type: integer }
gamesPlayed: { type: integer }
createdAt: { type: string, format: date-time }
Game:
type: object
properties:
id: { type: string }
roomCode: { type: string }
status: { type: string, enum: [waiting, in_progress, completed, abandoned] }
mode: { type: string, enum: [classic, quick, tournament] }
players: { type: array, items: { $ref: '#/components/schemas/Player' } }
currentTurn: { type: string }
createdAt: { type: string, format: date-time }
paths:
/players/{playerId}:
get:
summary: Get player profile
security: [{ BearerAuth: [] }]
parameters:
- name: playerId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Player profile
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data: { $ref: '#/components/schemas/Player' }
'404':
description: Player not found
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
/games/{gameId}/moves:
post:
summary: Submit a move
security: [{ BearerAuth: [] }]
parameters:
- name: gameId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [pieceId, targetPosition, diceValue]
properties:
pieceId: { type: string }
targetPosition: { type: integer }
diceValue: { type: integer, minimum: 1, maximum: 6 }
responses:
'201':
description: Move recorded
headers:
X-RateLimit-Remaining: { schema: { type: integer } }
'400':
description: Invalid move
content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
'429':
description: Rate limited
headers:
Retry-After: { schema: { type: integer, description: Seconds to wait } }
REST Resource Design Summary
Consistent URL patterns make the API discoverable. Resources map directly to game concepts: players, rooms, games, moves, leaderboards, and tournaments. Each resource supports standard HTTP verbs with predictable semantics. The nesting hierarchy follows ownership: moves belong to games, games belong to rooms, but leaderboards exist independently since they aggregate across all games.
| Resource | URL Pattern | Methods | Caching Strategy |
|---|---|---|---|
| Player | /players/{id} |
GET, PUT | Private, 5min, must-revalidate |
| Room | /rooms/{code} |
GET, POST, DELETE | No-store (dynamic) |
| Game | /games/{id} |
GET, POST | No-store for active, immutable for completed |
| Move | /games/{id}/moves |
POST, GET | No-store |
| Leaderboard | /leaderboards/{type} |
GET | Public, 60s, stale-while-revalidate=300 |
| Tournament | /tournaments/{id} |
GET, POST | Public, 30s |
How It Works: Full Request Lifecycle
Understanding the full lifecycle of a single API request clarifies why every piece above matters. When a player taps "Roll Dice" on their mobile app, the app sends a POST to /v1/games/gme_xyz/moves with a JWT Bearer token and an idempotency key. The load balancer terminates TLS and routes to a Node.js server. The server's JWT middleware verifies the token's signature and expiry, attaching req.user with the player identity. The rate limiter checks if this player has exceeded their 30-request-per-15-minute limit on write endpoints — if so, it returns 429 with a Retry-After header.
If the rate limit passes, the idempotency middleware checks Redis for the idempotency key. If found, it returns the cached response immediately without touching the game engine. If not found, the game engine validates the move (is it this player's turn? does the target position match the dice roll? does the path clear?), applies it to the game state, stores the result in the database, saves the idempotency record in Redis, and broadcasts the update to all players in the room via Socket.IO. The REST response returns 201 with the move details, rate limit headers, and the ETag. The entire round-trip typically takes 20–80ms on a well-tuned server.
Common Mistakes
Trusting client-reported game state. A client that says "I rolled a 6 and moved to position 12" is not authoritative. The server must generate or validate the dice roll and recompute the target position. Otherwise, a modified client can teleport pieces across the board.
Using offset pagination on mutable datasets. If you're using ?page=3&perPage=20 on a leaderboard, concurrent game completions mean the same request can return different results across calls. Use cursor-based pagination from day one.
Returning 200 for all errors. Always use the correct HTTP status code. Clients build logic around specific codes: 401 means re-authenticate, 429 means back off, 422 means show a validation error. Returning everything as 200 with success: false forces clients to parse the body for every response, breaking standard HTTP client behavior.
Storing API keys in plain text. Hash API keys with bcrypt or Argon2 before storing them. If your database leaks, plain-text keys give attackers immediate API access. A hash lookup on every request is slightly slower than a plain-text lookup, but the security trade-off is essential.
No idempotency on move submission. Without idempotency keys, network retries create duplicate moves. A player who taps "Roll" twice (thinking the first tap didn't register) submits two moves if there's no idempotency guard.
Frequently Asked Questions
Use JWTs for mobile apps and web frontends. The JWT encodes the player's identity, session expiry, and permissions directly in the token — the server verifies it cryptographically without a database round-trip on every request. API keys are for server-to-server integrations where there's no end-user context (a tournament platform posting results, a webhook handler receiving game events). Mixing them up — using an API key for a user-facing app — means anyone who extracts the key from the app binary has full API access under that key's scope.
Cursor pagination with proper indexing performs well even at millions of records. The key is indexing the sort columns in the same order as the cursor: for ORDER BY rank DESC, id ASC, a composite index on (rank DESC, id ASC) makes cursor resume a single index range scan — O(log n) for the first page, then O(1) for subsequent pages since you're scanning forward from the bookmark. Offset pagination, by contrast, does a full scan to skip n rows on every page, degrading linearly with page number.
30 requests per 15 minutes is a good baseline for move submission. A legitimate player makes at most one move every 30 seconds (the turn time limit), and even rapid-fire games with 10-second turns yield at most 6 moves per minute. 30 moves per 15 minutes gives headroom for retries and re-joins without allowing the throughput needed for automated cheating or DoS. Tune based on your actual turn time limits — a 10-second blitz mode might allow 60 requests per 15 minutes, while a 60-second casual mode can use 15.
Yes, but compute the ETag from the collection's state rather than the response bytes. Hash the updatedAt timestamp of the most recently modified item in the collection plus the cursor (if present). When any item in the leaderboard changes, the ETag changes and clients re-fetch. This gives you 304 Not Modified responses on unchanged pages, cutting response body to zero bytes. Be sure to include Vary: Authorization because the same URL with different auth tokens may return different data.
24 hours covers the worst-case retry scenario for mobile clients. A player submits a move, the network drops for 30 minutes, and when connectivity returns the app retries with the same idempotency key. The 24-hour window also covers clients that queue requests while offline and then batch-submit them. Beyond 24 hours, the probability of a genuine new request colliding with an old idempotency key (generated from a UUID v4) is astronomically low, so retention beyond 24 hours adds storage cost without practical benefit.
Use WebSocket for real-time gameplay and REST for everything else. A REST POST for every dice roll and piece move incurs connection overhead (TLS handshake, HTTP headers) per action — easily 500–1000ms total latency on a cold connection. WebSocket eliminates this by keeping a persistent connection open throughout the game session. REST remains appropriate for game creation, move history retrieval, leaderboard queries, player profile updates, and tournament management. See the real-time API guide for the full WebSocket event reference.
Get a Production-Ready Ludo REST API
Full API with JWT auth, cursor pagination, rate limiting, idempotency, and WebSocket support — deployed and ready in your infrastructure or ours.
Chat on WhatsApp