@ayshrj/ludo.js
Version:
A TypeScript-based headless Ludo game engine for simulating game logic, AI moves, and game state management.
563 lines (502 loc) • 17.7 kB
text/typescript
import { EventEmitter } from "events";
import {
Color,
Block,
TokenPositions,
GameState,
LudoGameState,
} from "./types";
/**
* Generate a random integer between min and max, inclusive.
* @param min - The lower bound (inclusive).
* @param max - The upper bound (inclusive).
* @returns A random integer between min and max.
*/
function random(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Generate initial position of the tokens
*/
export function initializeTokenPosition(): TokenPositions {
return {
red: [-1, -1, -1, -1],
green: [-1, -1, -1, -1],
yellow: [-1, -1, -1, -1],
blue: [-1, -1, -1, -1],
};
}
/**
* The Ludo class extends EventEmitter so you can listen to "stateChange" events.
*/
export class Ludo extends EventEmitter {
/** The 15x15 board, with `Block` or `null` if not used. */
board!: (Block | null)[][];
/** The positions of all tokens for each color. */
tokenPositions!: TokenPositions;
/** The color currently taking its turn. */
currentPiece!: Color;
/** Which colors have completely finished (all tokens at final), in order. */
ranking: Color[] = [];
/** The current dice roll value, or null if not rolled yet. */
currentDiceRoll: number | null = null;
/** The previous dice roll value (for reference in UI, etc.). */
lastDiceRoll: number | null = null;
/** Token indices (0..3) that are valid to move with the current dice roll. */
validTokenIndices: number[] = [];
/** Number of consecutive sixes rolled by the current player. */
currentConsecutiveSixes = 0;
/** Track length from index 0 to 56. */
readonly TRACK_LENGTH = 57;
/** Indices on the track considered "safe" (cannot be captured). */
readonly safeZones: number[] = [0, 8, 13, 21, 26, 34, 39, 47];
/** A status message about the current board state (e.g. "Blue captured a token"). */
currentBoardStatus = "";
/** The overall game state. */
gameState: GameState = "playerHasToRollADice";
/** Color-specific paths for each player's tokens ([row, col] coordinates). */
colorPaths!: Record<Color, [number, number][]>;
/** The active player colors in this game (e.g. ["blue","red","green"]). */
players: Color[] = [];
/**
* Create a new Ludo game with the specified number of players (2..4).
* Emits a "stateChange" event whenever the internal state changes.
*/
constructor(private numberOfPlayers: 2 | 3 | 4 = 4) {
super();
// Decide which colors to use
if (numberOfPlayers === 2) {
this.players = ["blue", "green"];
} else if (numberOfPlayers === 3) {
this.players = ["blue", "red", "green"];
} else {
this.players = ["blue", "red", "green", "yellow"];
}
this.reset();
}
/**
* Emit the current state to all listeners of "stateChange".
*/
private emitStateChange(): void {
this.emit("stateChange", this.getCurrentState());
}
/**
* Reset the entire board, token positions, ranking, etc.
* Randomly selects which color starts.
*/
reset(): void {
// Create a 15x15 board (null means no block data)
this.board = Array.from({ length: 15 }, () =>
Array.from({ length: 15 }, () => null)
);
// Initialize token positions (all at -1 = "home")
this.tokenPositions = initializeTokenPosition();
this.ranking = [];
this.currentDiceRoll = null;
this.lastDiceRoll = null;
this.validTokenIndices = [];
this.currentConsecutiveSixes = 0;
this.currentBoardStatus = "";
this.gameState = "playerHasToRollADice";
// Randomly pick which color starts
const randomIndex = random(0, this.players.length - 1);
this.currentPiece = this.players[randomIndex];
// Define home positions for each color
const homePositions: Record<Color, [number, number][]> = {
red: [
[1, 1],
[1, 4],
[4, 1],
[4, 4],
],
green: [
[1, 10],
[1, 13],
[4, 10],
[4, 13],
],
yellow: [
[10, 10],
[10, 13],
[13, 10],
[13, 13],
],
blue: [
[10, 1],
[10, 4],
[13, 1],
[13, 4],
],
};
// Mark home squares for active players
for (const color of this.players) {
homePositions[color].forEach(([r, c]) => {
this.board[r][c] = { isHome: color };
});
}
// Build the base "red" path of length 57
const redPath: [number, number][] = [];
for (let c = 1; c <= 5; c++) redPath.push([6, c]);
for (let r = 5; r >= 0; r--) redPath.push([r, 6]);
for (let c = 7; c <= 8; c++) redPath.push([0, c]);
for (let r = 1; r <= 5; r++) redPath.push([r, 8]);
for (let c = 9; c <= 14; c++) redPath.push([6, c]);
for (let r = 7; r <= 8; r++) redPath.push([r, 14]);
for (let c = 13; c >= 9; c--) redPath.push([8, c]);
for (let r = 9; r <= 14; r++) redPath.push([r, 8]);
for (let c = 7; c >= 6; c--) redPath.push([14, c]);
for (let r = 13; r >= 9; r--) redPath.push([r, 6]);
for (let c = 5; c >= 0; c--) redPath.push([8, c]);
for (let c = 0; c <= 6; c++) redPath.push([7, c]);
// Rotate redPath for other colors
const paths: Record<Color, [number, number][]> = {
red: redPath,
green: redPath.map((coord) => this.rotateCoord(coord, 90)),
yellow: redPath.map((coord) => this.rotateCoord(coord, 180)),
blue: redPath.map((coord) => this.rotateCoord(coord, 270)),
};
this.colorPaths = paths;
// Mark track indices & special flags on the board
for (const color of this.players) {
const colorPath = paths[color];
colorPath.forEach(([r, c], index) => {
if (!this.board[r][c]) {
this.board[r][c] = {};
}
// Mark the track index
if (color === "red") this.board[r][c]!.redTrack = index;
if (color === "green") this.board[r][c]!.greenTrack = index;
if (color === "blue") this.board[r][c]!.blueTrack = index;
if (color === "yellow") this.board[r][c]!.yellowTrack = index;
// Safe zones
if (this.safeZones.includes(index)) {
this.board[r][c]!.isSafeZone = true;
}
// Starting position => index=0
if (index === 0) {
this.board[r][c]!.isStartingPosition = color;
this.board[r][c]!.isSafeZone = true;
}
// On path to final => 51..55
if (index >= 51 && index <= 55) {
this.board[r][c]!.isOnPathToFinalPosition = color;
}
// Final => index=56
if (index === 56) {
this.board[r][c]!.isFinalPosition = color;
}
});
}
// Emit new state
this.emitStateChange();
}
/**
* Rotate (r,c) by 90/180/270 around center (7,7).
*/
private rotateCoord(
[r, c]: [number, number],
angle: 90 | 180 | 270
): [number, number] {
const center = 7;
if (angle === 90) {
return [center + (c - center), center - (r - center)];
} else if (angle === 180) {
return [14 - r, 14 - c];
} else {
// 270
return [center - (c - center), center + (r - center)];
}
}
/**
* Roll the dice for the current player, if allowed.
* Automatically checks for consecutive sixes & skip turn if needed.
*/
rollDiceForCurrentPiece(): number {
if (this.gameState !== "playerHasToRollADice") {
this.currentBoardStatus = `Invalid action. Current state: ${this.gameState}.`;
this.emitStateChange();
return -1;
}
if (this.currentDiceRoll !== null) {
this.currentBoardStatus =
"Already rolled. You must move a token or wait/pass.";
this.emitStateChange();
return this.currentDiceRoll;
}
const rollValue = random(1, 6);
this.currentDiceRoll = rollValue;
this.lastDiceRoll = rollValue;
// Handle consecutive sixes
if (rollValue === 6) {
this.currentConsecutiveSixes++;
if (this.currentConsecutiveSixes === 3) {
this.currentBoardStatus = `Three consecutive sixes. Turn skipped for ${this.currentPiece}.`;
this.resetTurnState(false);
this.nextTurn();
this.emitStateChange();
return rollValue;
}
} else {
this.currentConsecutiveSixes = 0;
}
// Determine valid moves
this.validTokenIndices = this.getValidMoves(this.currentPiece, rollValue);
if (this.validTokenIndices.length === 0) {
this.currentBoardStatus = `No valid moves for ${this.currentPiece} (rolled ${rollValue}). Passing turn.`;
this.resetTurnState(false);
this.nextTurn();
this.emitStateChange();
} else {
this.gameState = "playerHasToSelectAPosition";
this.emitStateChange();
}
return rollValue;
}
/**
* The user/bot picks which token to move (0..3), if valid.
*/
selectToken(tokenIndex: number): void {
if (this.gameState !== "playerHasToSelectAPosition") {
this.currentBoardStatus = `Invalid action. State: ${this.gameState}`;
this.emitStateChange();
return;
}
if (this.currentDiceRoll === null) {
this.currentBoardStatus = "You must roll before selecting a token.";
this.emitStateChange();
return;
}
if (!this.validTokenIndices.includes(tokenIndex)) {
this.currentBoardStatus = "That token is not a valid choice.";
this.emitStateChange();
return;
}
const roll = this.currentDiceRoll;
const currentPos = this.tokenPositions[this.currentPiece][tokenIndex];
let newPos: number;
// If at home (-1), need a 6 to move out
if (currentPos === -1) {
if (roll !== 6) {
this.currentBoardStatus = "Cannot leave home without rolling a 6.";
this.emitStateChange();
return;
}
newPos = 0;
} else {
if (currentPos + roll > this.TRACK_LENGTH - 1) {
this.currentBoardStatus = "Move would go beyond final square. Invalid.";
this.emitStateChange();
return;
}
newPos = currentPos + roll;
}
// Move the token
this.tokenPositions[this.currentPiece][tokenIndex] = newPos;
// Check collisions/captures (unless final)
let captures = 0;
if (newPos !== 56 && !this.safeZones.includes(newPos)) {
captures = this.handleCollisions(newPos, this.currentPiece);
}
// Check if this token just finished
if (newPos === 56) {
const allDone = this.tokenPositions[this.currentPiece].every(
(p) => p === 56
);
if (allDone && !this.ranking.includes(this.currentPiece)) {
this.ranking.push(this.currentPiece);
}
}
this.resetTurnState(false);
// If you captured or rolled a 6, same player's turn
if (captures > 0) {
this.currentBoardStatus = `${this.currentPiece} captured ${captures} token(s). Roll again!`;
this.gameState = "playerHasToRollADice";
this.emitStateChange();
return;
}
if (roll === 6) {
this.currentBoardStatus = `${this.currentPiece} rolled a 6. Roll again!`;
this.gameState = "playerHasToRollADice";
this.emitStateChange();
return;
}
// Otherwise go to next turn
this.nextTurn();
this.emitStateChange();
}
/**
* Return valid token indices for a given dice roll.
*/
private getValidMoves(color: Color, roll: number): number[] {
const positions = this.tokenPositions[color];
const valid: number[] = [];
for (let i = 0; i < 4; i++) {
const pos = positions[i];
// If already at final, skip
if (pos === 56) continue;
// If at home
if (pos === -1) {
if (roll === 6) valid.push(i);
} else {
if (pos + roll <= 56) valid.push(i);
}
}
return valid;
}
/**
* Check if any opponent is on newPos. If so, capture them (send home).
* Returns number of captures.
*/
private handleCollisions(newPos: number, movingColor: Color): number {
let captures = 0;
const [destR, destC] = this.colorPaths[movingColor][newPos];
for (const color of this.players) {
if (color === movingColor) continue;
for (let i = 0; i < 4; i++) {
const oppPos = this.tokenPositions[color][i];
if (oppPos < 0 || oppPos === 56) continue; // home or finished
const [r2, c2] = this.colorPaths[color][oppPos];
if (r2 === destR && c2 === destC) {
this.tokenPositions[color][i] = -1; // send home
captures++;
}
}
}
return captures;
}
/**
* Proceed to the next player's turn, or end the game if all finished.
*/
private nextTurn() {
if (this.ranking.length >= this.players.length) {
this.gameState = "gameFinished";
this.currentBoardStatus = "Game Over! All players finished.";
return;
}
const idx = this.players.indexOf(this.currentPiece);
this.currentPiece = this.players[(idx + 1) % this.players.length];
this.currentConsecutiveSixes = 0;
this.gameState = "playerHasToRollADice";
this.currentBoardStatus = `Now it's ${this.currentPiece}'s turn to roll.`;
}
/**
* Reset the current dice roll & valid token indices. Optionally clear lastDiceRoll.
*/
private resetTurnState(clearLastDiceRoll = true) {
this.currentDiceRoll = null;
this.validTokenIndices = [];
if (clearLastDiceRoll) {
this.lastDiceRoll = null;
}
}
/**
* Returns a snapshot of the current state, used by emitStateChange() or for UI.
*/
getCurrentState(): LudoGameState {
return {
turn: this.currentPiece,
tokenPositions: this.tokenPositions,
ranking: this.ranking,
boardStatus: this.currentBoardStatus,
diceRoll: this.currentDiceRoll,
lastDiceRoll: this.lastDiceRoll,
gameState: this.gameState,
players: this.players,
};
}
/**
* A basic AI heuristic for picking the best token to move given currentDiceRoll.
* Returns -1 if no moves.
*/
bestMove(): number {
if (this.currentDiceRoll === null) {
console.warn("bestMove called but no dice roll available");
return -1;
}
const roll = this.currentDiceRoll;
const valid = this.getValidMoves(this.currentPiece, roll);
if (valid.length === 0) return -1;
// Some scoring weights
const WEIGHTS = {
CAPTURE_BONUS: 50,
LEAVE_HOME_BONUS: 35,
LAND_SAFE_ZONE_BONUS: 25,
APPROACH_FINAL_BONUS: 15,
REACH_FINAL_BONUS: 100,
DISTANCE_ADVANCE_FACTOR: 0.5,
RISK_PENALTY_NEAR_OPPONENT: 40,
};
let bestScore = Number.NEGATIVE_INFINITY;
let bestIndex = valid[0];
for (const i of valid) {
const currentPos = this.tokenPositions[this.currentPiece][i];
const newPos = currentPos === -1 ? 0 : currentPos + roll;
let score = 0;
// Bonus for leaving home
if (currentPos === -1 && roll === 6) {
score += WEIGHTS.LEAVE_HOME_BONUS;
}
// Safe zone landing
if (this.safeZones.includes(newPos)) {
score += WEIGHTS.LAND_SAFE_ZONE_BONUS;
}
// Potential captures
if (newPos !== 56 && !this.safeZones.includes(newPos)) {
const [destR, destC] = this.colorPaths[this.currentPiece][newPos];
let captures = 0;
for (const oppColor of this.players) {
if (oppColor === this.currentPiece) continue;
for (let oppI = 0; oppI < 4; oppI++) {
const oppPos = this.tokenPositions[oppColor][oppI];
if (oppPos < 0 || oppPos === 56) continue;
const [r2, c2] = this.colorPaths[oppColor][oppPos];
if (r2 === destR && c2 === destC) captures++;
}
}
if (captures > 0) {
score += captures * WEIGHTS.CAPTURE_BONUS;
}
}
// Approaching final or finishing
if (newPos >= 51 && newPos < 56) {
score += WEIGHTS.APPROACH_FINAL_BONUS;
}
if (newPos === 56) {
score += WEIGHTS.REACH_FINAL_BONUS;
}
// Distance factor
score += newPos * WEIGHTS.DISTANCE_ADVANCE_FACTOR;
// Risk penalty
if (newPos < 56) {
const [myR, myC] = this.colorPaths[this.currentPiece][newPos];
let riskyOpponents = 0;
for (const oppColor of this.players) {
if (oppColor === this.currentPiece) continue;
for (let oppI = 0; oppI < 4; oppI++) {
const oppPos = this.tokenPositions[oppColor][oppI];
if (oppPos < 0 || oppPos === 56) continue;
for (let diceCheck = 1; diceCheck <= 6; diceCheck++) {
const testPos = oppPos + diceCheck;
if (testPos <= 56) {
const [r2, c2] = this.colorPaths[oppColor][testPos];
if (r2 === myR && c2 === myC) {
riskyOpponents++;
break;
}
}
}
}
}
if (riskyOpponents > 0) {
score -= riskyOpponents * WEIGHTS.RISK_PENALTY_NEAR_OPPONENT;
}
}
// Pick the best
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
return bestIndex;
}
}