ludo_engine 0.1.0
ludo_engine: ^0.1.0 copied to clipboard
Ludo rules and computer players in pure Dart: twelve house rules, AI personalities, deterministic turns and JSON for apps, servers and tests.
ludo_engine #
The rules and computer players of Simple Ludo, as pure Dart. No Flutter, no Firebase, no dependencies: it runs in an app, on a server, in a test or on the command line.
- Immutable and deterministic. Give it a game state and an action; get back the next state and a list of events saying what happened. The same state, action and dice always give the same result.
- Twelve house rules, from safe squares and stacking to backward captures and catch-up sixes.
- Computer players with personalities (peaceful, average, vicious) that can shift with how the game treats them, scored with plain integers so you can tune them.
- Fair online play. Every state carries a sequence number for rejecting stale actions, and everything turns into JSON and back.
Quick start #
import 'package:ludo_engine/ludo_engine.dart';
void main() {
final rng = SeededRng(7);
var state = Engine.newGame(
rules: const RuleSet(safeSquares: true),
seats: [
Seat.ai(id: 'ama', name: 'Ama', colour: 0, personality: Personality.vicious),
Seat.ai(id: 'esi', name: 'Esi', colour: 2, personality: Personality.pacifist),
],
);
while (!state.isOver) {
final result = Engine.apply(state, AiPlayer.decide(state, rng), rng);
for (final event in result.events) {
if (event is Captured) print('${state.seats[event.by].name} captured a piece!');
}
state = result.state;
}
print('Winner: ${state.seats[state.finishOrder.first].name}');
}
example/main.dart plays a full four-player game and prints every roll and move: dart run example/main.dart.
Playing a game #
A turn has two steps. In Phase.roll the current player rolls; in Phase.move they pick one of the moves the roll allows, or pass.
// Roll. The engine asks rng for the die, unless a house rule decides it.
var result = Engine.apply(state, RollAction(state.turn), rng);
state = result.state;
// Choose. Engine.currentMoves lists what the roll allows.
if (state.phase == Phase.move) {
final move = Engine.currentMoves(state).first;
state = Engine.apply(state, MoveAction(state.turn, piece: move.piece, to: move.to), rng).state;
}
When a roll leaves no legal move, the engine says so with a NoMoves event and passes the turn on by itself. An action out of turn or against the rules throws IllegalActionException, so a server can apply players' actions as they come.
For a human player, show Engine.currentMoves(state) and send the one they pick. For a computer player, AiPlayer.decide(state, rng) returns the action to take: a roll, a move or a pass.
Dice come from any LudoRng. SeededRng is a small, fast generator (mulberry32) for reproducible games and tests. In an app, wrap dart:math's Random, or Random.secure() on a server.
The board #
The board is the classic 15 × 15 cross, with four colours, 0 to 3, starting 13 squares apart. Each piece's position is its progress from its own start:
| Progress | Where |
|---|---|
Progress.base (-100) |
Waiting in its corner |
| 0 to 50 | On the shared track, 0 being its start square |
| 51 to 55 | Its own coloured lane, where nothing can capture it |
Progress.home (56) |
Home, in the centre |
BoardGeometry turns progress into grid cells for drawing (cellOf), and gives the cells a piece passes through on a move (hops), for animating it a step at a time.
House rules #
RuleSet holds the options a host chooses. The defaults are the commonest way to play.
| Rule | Default | What it does |
|---|---|---|
safeSquares |
off | Start squares, and the square 8 steps after each, are safe from capture |
threeSixesForfeit |
off | A third six in a row ends the turn without a move |
stacking |
off | Your own pieces may share a square |
exactRollHome |
on | Reaching home needs the exact number; when off, overshooting lands home |
backwardCaptures |
on | A piece may move backwards, but only to capture |
startAssist |
on | Each player's first roll is a six |
catchUpAssist |
off | With no pieces in play, the next roll is a six |
extraTurnOnCapture |
off | Capturing earns another roll |
extraTurnOnHome |
off | Bringing a piece home earns another roll |
adaptiveAi |
on | Computer players' personalities shift with how the game treats them |
hardcoreAi |
off | Computer players always take their best move instead of a weighted pick |
endWhenHumansDone |
on | The game ends once every human has finished |
A six always earns another roll.
Computer players #
Each computer seat has a Personality:
- Pacifist never captures, except when bringing a piece out lands on someone (a move it can't avoid).
- Average Joe weighs captures like any other move and takes them when they pay.
- Vicious values captures far more, especially of whoever is winning. With
hardcoreAiit captures whenever it can.
With adaptiveAi, every seat has a reputation: capturing lowers it and passing up a capture raises it, and a computer player's personality follows its own reputation. A peaceful player that keeps getting kicked turns vicious.
AiPlayer.rank(state) scores every legal move. The score adds up AiWeights: progress, leaving base, reaching home, capturing (by personality), danger where a piece lands, escaping a threat, and more. All are integers, and AiWeights.fromJson lets you tune them without a release (Simple Ludo reads them from Remote Config). Without hardcoreAi, the computer picks with a weighted chance rather than always taking the top move, so it plays like a person.
When a player leaves an online game, Engine.aiTakeover(state, seat) hands their seat to the computer.
Events #
Engine.apply returns a StepResult with the new state and the events that led there, in order:
Rolled, NoMoves, Forfeited, Moved (with the cells hopped through), Captured, ReachedHome, PlayerFinished, Passed, TurnChanged and GameEnded.
Play them back one after another to animate a turn. Every event has toJson and GameEvent.fromJson, so a server can send them to players.
Saving and sending games #
GameState, Seat, RuleSet, GameAction and GameEvent all have toJson and fromJson. A state's seq goes up with every action: send the seq you acted on with each action and reject any that don't match, and players can never act on an out-of-date board.
Tested against another engine #
fixtures/ holds whole seeded games, recorded with their actions, events and final states. The tests replay them, and Simple Ludo's TypeScript server engine replays the same files, so the two can never disagree about a rule. If you change the rules on purpose, regenerate them with dart run tool/gen_fixtures.dart.
Licence #
MIT. See LICENSE.