Important: This documentation covers Yarn 1 (Classic).
For Yarn 2+ docs and migration guide, see yarnpkg.com.

Package detail

@ayshrj/ludo.js

ayshrj1.7kMIT1.0.10TypeScript support: included

A TypeScript-based headless Ludo game engine for simulating game logic, AI moves, and game state management.

ludo, board game, typescript, game, ai

readme

@ayshrj/ludo.js

npm npm

@ayshrj/ludo.js is a TypeScript library for simulating the classic Ludo board game. It handles:

  • Board Setup (2, 3, or 4 players)
  • Dice Rolling (automatic skip after three consecutive sixes)
  • Token Movement (including leaving home on a 6)
  • Capturing Opponents on non-safe squares
  • Safe Zones & Final Squares
  • Ranking (tracks order in which players finish)
  • Lightweight AI with bestMove()
  • EventEmitter-based Updates (automatically emit "stateChange" with the latest state)
  • Comprehensive State Tracking for easy integration into any UI

Table of Contents


Installation

npm install @ayshrj/ludo.js

or

yarn add @ayshrj/ludo.js

Importing

Import (as ESM)

import { Ludo } from '@ayshrj/ludo.js'

Import (as CommonJS)

const { Ludo } = require('@ayshrj/ludo.js')

Quick Start Example

import { Ludo } from '@ayshrj/ludo.js'

// Create a Ludo game with 4 players
const game = new Ludo(4)

// Subscribe to state changes (optional)
game.on("stateChange", (state) => {
  console.log("STATE CHANGE:", state)
})

// Roll the dice
const diceValue = game.rollDiceForCurrentPiece()
console.log(`Dice: ${diceValue}`)

// If there's a valid token, select it
if (game.validTokenIndices.length > 0) {
  game.selectToken(game.validTokenIndices[0])
}

// Or use the basic AI
const best = game.bestMove()
if (best >= 0) game.selectToken(best)

Features

  1. 2, 3, or 4 Players
    Colors are chosen from ["blue","red","green","yellow"] based on the requested number of players.

  2. Dice Rolling
    Rolls 1..6. Three consecutive sixes skip your turn.

  3. Movement & Safe Zones
    Leave home only on a 6. Safe squares cannot be captured.

  4. Capturing
    If you land on an opponent in a non-safe zone, that opponent’s token goes home (-1).

  5. Final & Ranking
    Index 56 is the final square; finishing all 4 tokens places you in ranking.

  6. Simple AI
    bestMove() returns a recommended token index.

  7. Event Emitter
    The Ludo class extends Node.js’s EventEmitter. Each time the internal state changes (after rolls, moves, etc.), it emits "stateChange" with the updated LudoGameState.


API

Constructor: new Ludo(numberOfPlayers)

// 2 players => uses ["blue","green"]
// 3 players => uses ["blue","red","green"]
// 4 players => uses ["blue","red","green","yellow"]
const game = new Ludo(4)

.rollDiceForCurrentPiece()

Rolls a 6-sided die and updates currentDiceRoll, lastDiceRoll, and validTokenIndices. Automatically skips turn on three consecutive sixes or if there are no valid moves.


.selectToken(tokenIndex)

Moves the chosen token for the current color using the last dice roll. Handles captures, final squares, turn passing, etc.


.bestMove()

A basic heuristic that returns the “best” token index or -1 if none.


.reset()

Completely reinitializes the board and picks a new starting player randomly.


.getCurrentState()

Returns a snapshot of the entire state:

interface LudoGameState {
  turn: Color;
  tokenPositions: TokenPositions;
  ranking: Color[];
  boardStatus: string;
  diceRoll: number | null;
  lastDiceRoll: number | null;
  gameState: GameState;
  players: Color[];
}

.tokenPositions

A record of each color’s four token positions (-1 for home, 0..56 on track).


.validTokenIndices

Which tokens (0..3) the current color can move on their turn.
Updated when .rollDiceForCurrentPiece() is called.


.ranking

Colors finish in order. If ranking.length equals number of players, the game is complete.


.gameState

One of:

  • "playerHasToRollADice"
  • "playerHasToSelectAPosition"
  • "gameFinished"

.currentPiece

Which color’s turn it is right now.


Event Emitter: "stateChange"

Every time the game state changes (e.g., after a roll or move), the library calls:

this.emit("stateChange", this.getCurrentState())

So you can do:

game.on("stateChange", (state) => {
  // React or update UI automatically with the new state
})

.players

The array of active colors, in turn order. For example, with 3 players it might be ["blue","red","green"].


initializeTokenPosition()

A utility function that initializes the token positions for all colors. Each token starts at -1 (home).

/**
 * Initializes the token positions for all colors.
 * @returns {TokenPositions} An object with token positions for each color.
 * @example
 * const tokenPositions = initializeTokenPosition();
 * // Returns:
 * // {
 * //   red: [-1, -1, -1, -1],
 * //   green: [-1, -1, -1, -1],
 * //   yellow: [-1, -1, -1, -1],
 * //   blue: [-1, -1, -1, -1]
 * // }
 */
function initializeTokenPosition(): TokenPositions;

Example: React Integration

import React, { useState, useEffect, useCallback, useRef } from "react";
import {
  Ludo,
  Color,
  LudoGameState,
  initializeTokenPosition,
} from "@ayshrj/ludo.js";

// Available color sets for 2/3/4 players:
const COLOR_SETS: Record<2 | 3 | 4, Color[]> = {
  2: ["blue", "green"],
  3: ["blue", "red", "green"],
  4: ["blue", "red", "green", "yellow"],
};

const LudoPage: React.FC = () => {
  const [playerCount, setPlayerCount] = useState<2 | 3 | 4>(4);
  const [playerTypes, setPlayerTypes] = useState<
    Record<Color, "human" | "bot">
  >({
    blue: "human",
    red: "human",
    green: "human",
    yellow: "human",
  });
  const [ludo, setLudo] = useState<Ludo | null>(null);
  const [gameState, setGameState] = useState<LudoGameState>({
    turn: "blue",
    ranking: [],
    tokenPositions: initializeTokenPosition(),
    boardStatus: "",
    diceRoll: null,
    lastDiceRoll: null,
    gameState: "playerHasToRollADice",
    players: [],
  });
  const [showSetupModal, setShowSetupModal] = useState<boolean>(true);
  const [showResetModal, setShowResetModal] = useState<boolean>(false);
  const [botThinking, setBotThinking] = useState<boolean>(false);
  const botTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    if (!ludo) return;
    const handleStateChange = (newState: LudoGameState) => {
      setGameState(newState);
    };
    ludo.on("stateChange", handleStateChange);
    return () => {
      ludo.off("stateChange", handleStateChange);
    };
  }, [ludo]);

  const s = gameState;
  const isGameFinished =
    s.ranking.length >= s.players.length && s.players.length > 0;

  const startNewGame = useCallback(() => {
    const chosenColors = COLOR_SETS[playerCount];
    const newLudo = new Ludo(playerCount);
    newLudo.players = chosenColors;
    newLudo.reset();
    setGameState(newLudo.getCurrentState());
    setLudo(newLudo);
    setShowSetupModal(false);
    setShowResetModal(false);
  }, [playerCount]);

  useEffect(() => {
    if (!ludo || isGameFinished) return;
    if (s.gameState !== "playerHasToRollADice") return;

    const botTurn = playerTypes[s.turn] === "bot";
    if (!botTurn || botThinking) return;

    setBotThinking(true);
    botTimeoutRef.current = setTimeout(() => {
      ludo.rollDiceForCurrentPiece();
      setBotThinking(false);
    }, 1200);
  }, [
    ludo,
    s.gameState,
    s.turn,
    s.players,
    botThinking,
    isGameFinished,
    playerTypes,
  ]);

  useEffect(() => {
    if (!ludo || isGameFinished) return;
    if (s.gameState !== "playerHasToSelectAPosition" || s.diceRoll === null)
      return;

    const botTurn = playerTypes[s.turn] === "bot";
    if (!botTurn || botThinking) return;

    setBotThinking(true);
    botTimeoutRef.current = setTimeout(() => {
      const best = ludo.bestMove();
      if (best >= 0) {
        ludo.selectToken(best);
      }
      setBotThinking(false);
    }, 800);
  }, [
    ludo,
    s.gameState,
    s.turn,
    s.players,
    s.diceRoll,
    botThinking,
    isGameFinished,
    playerTypes,
  ]);

  const handleRollDice = (): void => {
    if (!ludo || isGameFinished) return;
    const { turn } = ludo.getCurrentState();
    if (playerTypes[turn] === "bot") return;
    setTimeout(() => {
      ludo.rollDiceForCurrentPiece();
    }, 1000);
  };

  const handleTokenClick = (color: Color, tokenIndex: number): void => {
    if (!ludo || isGameFinished) return;
    const st = ludo.getCurrentState();
    if (color !== st.turn) return;
    if (playerTypes[color] === "bot") return;
    if (!ludo.validTokenIndices.includes(tokenIndex)) return;
    ludo.selectToken(tokenIndex);
  };

  const colorMap: Record<Color, string> = {
    red: "#FF0000",
    green: "#00FF00",
    yellow: "#FFFF00",
    blue: "#0000FF",
  };

  function getColorOffset(index: number, totalColors: number) {
    const offset = 10;
    if (totalColors === 1) return { x: 0, y: 0 };
    if (totalColors === 2)
      return index === 0
        ? { x: -offset, y: -offset }
        : { x: offset, y: offset };
    if (totalColors === 3) {
      if (index === 0) return { x: -offset, y: -offset };
      if (index === 1) return { x: offset, y: -offset };
      if (index === 2) return { x: offset, y: offset };
    }
    if (totalColors === 4) {
      if (index === 0) return { x: -offset, y: -offset };
      if (index === 1) return { x: offset, y: -offset };
      if (index === 2) return { x: -offset, y: offset };
      if (index === 3) return { x: offset, y: offset };
    }
    return { x: 0, y: 0 };
  }

  const startingPositions: Record<Color, [number, number][]> = {
    red: [
      [1, 1],
      [1, 4],
      [4, 1],
      [4, 4],
    ],
    green: [
      [1, 10],
      [1, 13],
      [4, 10],
      [4, 13],
    ],
    blue: [
      [10, 1],
      [10, 4],
      [13, 1],
      [13, 4],
    ],
    yellow: [
      [10, 10],
      [10, 13],
      [13, 10],
      [13, 13],
    ],
  };

  const getTokensAtSquare = (
    rowIndex: number,
    colIndex: number
  ): { token: Color; index: number }[] => {
    if (!ludo) return [];
    const tokens: { token: Color; index: number }[] = [];
    s.players.forEach((color) => {
      s.tokenPositions[color].forEach((pos, i) => {
        if (pos !== -1) {
          const [r2, c2] = ludo.colorPaths[color][pos];
          if (r2 === rowIndex && c2 === colIndex) {
            tokens.push({ token: color, index: i });
          }
        } else {
          const [hr, hc] = startingPositions[color][i];
          if (hr === rowIndex && hc === colIndex) {
            tokens.push({ token: color, index: i });
          }
        }
      });
    });
    return tokens;
  };

  const groupTokensByColor = (
    tokens: { token: Color; index: number }[]
  ): { token: Color; total: number }[] => {
    const counts: Record<Color, number> = {
      red: 0,
      green: 0,
      blue: 0,
      yellow: 0,
    };
    tokens.forEach(({ token }) => {
      counts[token]++;
    });
    return Object.entries(counts)
      .filter(([, cnt]) => cnt > 0)
      .map(([color, total]) => ({ token: color as Color, total }));
  };

  let statusMessage = "No game in progress.";
  if (ludo) {
    if (isGameFinished) {
      statusMessage = `Game finished! Final ranking: ${s.ranking.join(" -> ")}`;
    } else {
      statusMessage = `${s.turn.toUpperCase()}'s turn.`;
      if (s.lastDiceRoll !== null) {
        statusMessage += ` (Last roll: ${s.lastDiceRoll})`;
      }
      if (s.boardStatus) {
        statusMessage += ` — ${s.boardStatus}`;
      }
    }
  }

  const isDiceDisabled =
    !ludo ||
    isGameFinished ||
    s.gameState !== "playerHasToRollADice" ||
    playerTypes[s.turn] === "bot" ||
    botThinking;

  const modalStyle: React.CSSProperties = {
    position: "fixed",
    top: "50%",
    left: "50%",
    transform: "translate(-50%, -50%)",
    backgroundColor: "white",
    padding: "20px",
    borderRadius: "8px",
    boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
    zIndex: 1000,
  };

  const buttonStyle: React.CSSProperties = {
    padding: "10px 20px",
    borderRadius: "4px",
    border: "none",
    cursor: "pointer",
    fontSize: "16px",
  };

  const selectStyle: React.CSSProperties = {
    padding: "5px",
    borderRadius: "4px",
    border: "1px solid #ccc",
    fontSize: "16px",
  };

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        padding: "16px",
        height: "100vh",
      }}
    >
      {/* Game Status */}
      <div
        style={{
          width: "100%",
          textAlign: "center",
          fontSize: "18px",
          fontWeight: "500",
        }}
      >
        {statusMessage}
      </div>

      {/* Board */}
      {ludo ? (
        <div
          style={{
            width: "100%",
            overflow: "hidden",
            aspectRatio: "1/1",
            backgroundColor: "#f0f0f0",
            borderRadius: "8px",
          }}
        >
          <div
            style={{
              display: "grid",
              width: "100%",
              height: "100%",
              gridTemplateColumns: "repeat(15, 1fr)",
              gridTemplateRows: "repeat(15, 1fr)",
            }}
          >
            {ludo.board.map((row, rowIndex) =>
              row.map((col, colIndex) => {
                const rawTokens = getTokensAtSquare(rowIndex, colIndex);
                const groupedTokens = groupTokensByColor(rawTokens);
                return (
                  <div
                    key={`${rowIndex}-${colIndex}`}
                    style={{
                      position: "relative",
                      display: "flex",
                      alignItems: "center",
                      justifyContent: "center",
                      borderBottom: "1px solid #ccc",
                      borderRight: "1px solid #ccc",
                      backgroundColor: col === null ? "#f8f8f8" : "",
                      ...(col?.isOnPathToFinalPosition
                        ? {
                            backgroundColor:
                              colorMap[col.isOnPathToFinalPosition],
                          }
                        : {}),
                      ...(col?.isStartingPosition
                        ? { backgroundColor: colorMap[col.isStartingPosition] }
                        : {}),
                      ...(col?.isFinalPosition
                        ? { backgroundColor: colorMap[col.isFinalPosition] }
                        : {}),
                    }}
                  >
                    {groupedTokens.map(({ token, total }, i) => {
                      const isCurrentPlayerStack =
                        token === s.turn &&
                        rawTokens.some(
                          (t) =>
                            t.token === token &&
                            ludo.validTokenIndices.includes(t.index)
                        );
                      const onCircleClick = () => {
                        const validToken = rawTokens.find(
                          (t) =>
                            t.token === token &&
                            ludo.validTokenIndices.includes(t.index)
                        );
                        if (validToken) {
                          handleTokenClick(validToken.token, validToken.index);
                        }
                      };
                      const offset = getColorOffset(i, groupedTokens.length);
                      return (
                        <div
                          key={`${token}-${i}`}
                          onClick={onCircleClick}
                          style={{
                            position: "absolute",
                            transform: `translate(${offset.x}px, ${offset.y}px)`,
                            width: `${40 + total * 10}%`,
                            height: `${40 + total * 10}%`,
                            backgroundColor: colorMap[token],
                            borderRadius: "50%",
                            display: "flex",
                            alignItems: "center",
                            justifyContent: "center",
                            color: "white",
                            fontSize: "12px",
                            fontWeight: "bold",
                            cursor: "pointer",
                            border:
                              isCurrentPlayerStack && !isGameFinished
                                ? "2px solid white"
                                : "none",
                          }}
                        >
                          {total > 1 ? total : ""}
                        </div>
                      );
                    })}
                    {col?.isSafeZone &&
                      !col?.isStartingPosition &&
                      !col?.isFinalPosition && (
                        <div
                          style={{
                            position: "absolute",
                            left: "50%",
                            top: "50%",
                            transform: "translate(-50%, -50%)",
                            zIndex: 1,
                            display: "flex",
                            alignItems: "center",
                            justifyContent: "center",
                            height: "100%",
                            width: "100%",
                          }}
                        >
                          <div
                            style={{
                              width: "50%",
                              height: "50%",
                              border: "1px dashed #800080",
                              borderRadius: "50%",
                              backgroundColor: "#e0e0ff",
                            }}
                          />
                        </div>
                      )}
                  </div>
                );
              })
            )}
          </div>
        </div>
      ) : (
        <div
          style={{
            width: "100%",
            aspectRatio: "1/1",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            backgroundColor: "#f0f0f0",
            borderRadius: "8px",
          }}
        >
          No Game Yet
        </div>
      )}

      {/* Ranking/Turn Info & Controls */}
      <div
        style={{
          width: "100%",
          display: "flex",
          flexDirection: "column",
          gap: "8px",
        }}
      >
        {s.ranking.length > 0 && !isGameFinished && (
          <div style={{ textAlign: "center" }}>
            Current Ranking: {s.ranking.join(" -> ")}
          </div>
        )}
        {isGameFinished && (
          <div style={{ textAlign: "center" }}>
            Final Ranking: {s.ranking.join(" -> ")}
          </div>
        )}
        {!isGameFinished && ludo && (
          <button
            style={{
              ...buttonStyle,
              backgroundColor: "#0000FF",
              color: "white",
              width: "100%",
            }}
            onClick={handleRollDice}
            disabled={isDiceDisabled}
          >
            Roll Dice
          </button>
        )}
        <button
          style={{
            ...buttonStyle,
            backgroundColor: "#00FF00",
            color: "white",
            width: "100%",
          }}
          onClick={() => setShowResetModal(true)}
        >
          Reset
        </button>
      </div>

      {/* Setup Modal */}
      {showSetupModal && !ludo && (
        <div style={modalStyle}>
          <h2 style={{ marginBottom: "16px" }}>Ludo Setup</h2>
          <div
            style={{ display: "flex", flexDirection: "column", gap: "16px" }}
          >
            <div
              style={{
                display: "grid",
                gridTemplateColumns: "repeat(5, 1fr)",
                alignItems: "center",
                gap: "8px",
              }}
            >
              <label style={{ gridColumn: "span 3" }} htmlFor="playerCount">
                Number of Players:
              </label>
              <select
                id="playerCount"
                value={playerCount}
                onChange={(e) => setPlayerCount(+e.target.value as 2 | 3 | 4)}
                style={selectStyle}
              >
                <option value={2}>2</option>
                <option value={3}>3</option>
                <option value={4}>4</option>
              </select>
            </div>
            {COLOR_SETS[playerCount].map((color) => (
              <div
                style={{
                  display: "grid",
                  gridTemplateColumns: "repeat(5, 1fr)",
                  alignItems: "center",
                  gap: "8px",
                }}
                key={color}
              >
                <label
                  style={{ gridColumn: "span 3" }}
                  htmlFor={`sel-${color}`}
                >
                  {color}:
                </label>
                <select
                  id={`sel-${color}`}
                  value={playerTypes[color]}
                  onChange={(e) =>
                    setPlayerTypes((prev) => ({
                      ...prev,
                      [color]: e.target.value as "human" | "bot",
                    }))
                  }
                  style={selectStyle}
                >
                  <option value="human">Human</option>
                  <option value="bot">Bot</option>
                </select>
              </div>
            ))}
            <button
              style={{
                ...buttonStyle,
                backgroundColor: "#00FF00",
                color: "white",
                width: "100%",
              }}
              onClick={startNewGame}
            >
              Start
            </button>
          </div>
        </div>
      )}

      {/* Reset Confirmation Modal */}
      {showResetModal && (
        <div style={modalStyle}>
          <h2 style={{ marginBottom: "16px" }}>Reset Game</h2>
          <div
            style={{ display: "flex", flexDirection: "column", gap: "16px" }}
          >
            <p>Are you sure you want to start a new Ludo game?</p>
            <div style={{ display: "flex", gap: "8px" }}>
              <button
                style={{
                  ...buttonStyle,
                  backgroundColor: "#ccc",
                  color: "black",
                  width: "50%",
                }}
                onClick={() => setShowResetModal(false)}
              >
                Cancel
              </button>
              <button
                style={{
                  ...buttonStyle,
                  backgroundColor: "#00FF00",
                  color: "white",
                  width: "50%",
                }}
                onClick={() => {
                  setShowResetModal(false);
                  setLudo(null);
                  setShowSetupModal(true);
                }}
              >
                Start
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

export default LudoPage;

Advanced Usage Notes

  1. Skipping / Passing
    If .rollDiceForCurrentPiece() yields no valid moves, it auto-passes to the next player.
  2. Consecutive Sixes
    Rolling three in a row ends your turn immediately.
  3. Event-Driven Updates
    Subscribing to "stateChange" means you can react to changes without manually querying the state after every call.

License

MIT License. Feel free to modify or contribute!