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.

cURL
# 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.

cURL
# 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.

cURL
# 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.

JSON
{
  "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.

cURL
# 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).

cURL
# 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.

cURL
# 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.

cURL
# 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.

YAML
# 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

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