@ayshrj/ludo.js
Version:
A TypeScript-based headless Ludo game engine for simulating game logic, AI moves, and game state management.
902 lines (799 loc) • 26 kB
Markdown
# @ayshrj/ludo.js
[](https://www.npmjs.com/package/@ayshrj/ludo.js)
[](https://www.npmjs.com/package/@ayshrj/ludo.js)
**@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
- [@ayshrj/ludo.js](#ayshrjludojs)
- [Table of Contents](#table-of-contents)
- [Installation](#installation)
- [Importing](#importing)
- [Import (as ESM)](#import-as-esm)
- [Import (as CommonJS)](#import-as-commonjs)
- [Quick Start Example](#quick-start-example)
- [Features](#features)
- [API](#api)
- [Constructor: `new Ludo(numberOfPlayers)`](#constructor-new-ludonumberofplayers)
- [`.rollDiceForCurrentPiece()`](#rolldiceforcurrentpiece)
- [`.selectToken(tokenIndex)`](#selecttokentokenindex)
- [`.bestMove()`](#bestmove)
- [`.reset()`](#reset)
- [`.getCurrentState()`](#getcurrentstate)
- [`.tokenPositions`](#tokenpositions)
- [`.validTokenIndices`](#validtokenindices)
- [`.ranking`](#ranking)
- [`.gameState`](#gamestate)
- [`.currentPiece`](#currentpiece)
- [Event Emitter: `"stateChange"`](#event-emitter-statechange)
- [`.players`](#players)
- [`initializeTokenPosition()`](#initializetokenposition)
- [Example: React Integration](#example-react-integration)
- [Advanced Usage Notes](#advanced-usage-notes)
- [License](#license)
## Installation
```bash
npm install @ayshrj/ludo.js
```
or
```bash
yarn add @ayshrj/ludo.js
```
## Importing
### Import (as ESM)
```js
import { Ludo } from '@ayshrj/ludo.js'
```
### Import (as CommonJS)
```js
const { Ludo } = require('@ayshrj/ludo.js')
```
## Quick Start Example
```ts
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)`
```ts
// 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:
```ts
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:
```ts
this.emit("stateChange", this.getCurrentState())
```
So you can do:
```ts
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).
```ts
/**
* 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
```tsx
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!