Solving Terni Lapilli: A 2000-Year-Old Game
I investigate how it's possible to apply optimisation techniques and group theory to the ancient game of Terni Lapilli. Terni Lapilli is a version of tic-tac-toe, and like tic-tac-toe it's possible to model optimal decision making. I used this analysis to explore various areas of mathematics I hadn't encountered yet, like group theory.
A Game Lost to Time
Scratched into the stone steps of ancient Roman forums, carved into Egyptian temple floors, etched onto medieval church benches—the same simple pattern appears across millennia: a circle divided into eight segments with a center point. This is Terni Lapilli (called Trias in Ancient Greek), a game that traces its roots to Egypt millennia ago.
This discovery prompted an investigation into how mathematical techniques could model optimal play in a constrained game space.
In my previous exploration of naval warfare and Warhammer 40k, I showed how mathematical modeling can reveal optimal strategies in complex games. Terni Lapilli presents a different challenge: it’s small enough that we might be able to solve it completely—to prove mathematically what happens when both players play perfectly. This blog post is about the different mathematical techniques I apply to understand this game.
The Rules
Terni Lapilli is played on a board with 9 positions: 8 arranged in a circle and 1 in the center. Two players (let’s call them Black and White) compete to get three of their pieces in a row.
The game has two phases:
Phase 1: Placement - Players alternate placing their three pieces on empty positions.
Phase 2: Movement - Once all pieces are placed, players alternate moving one piece to an adjacent empty position.
A player wins by getting three pieces in a row. There are 12 possible winning patterns:
Winning Patterns
There are 12 ways to win: 8 consecutive positions around the ring, and 4 through the center
Pattern Statistics
- Total winning patterns: 12
- Ring patterns (consecutive): 8
- Center patterns: 4
- Positions per pattern: 3
The patterns break down into two categories:
- 8 ring patterns: Three consecutive positions around the outer circle (like 0-1-2 or 5-6-7)
- 4 center patterns: Three positions through the center (like 0-8-4 or 2-8-6)
Try playing a quick game to get a feel for it. This is with an AI opponent who makes completely random moves. Like Stanley the monkey if you remember the old Chessmaster games.
Game Info
AI Opponent
Move History
No moves yet
This isn’t really good enough, so I thought I’d take a look at different approaches for building a stronger opponent.
Implementing an opponent
We must first define the board mathematically. Then we can implement heuristics—rules that score moves as good or bad. Finally, the minimax algorithm searches ahead through possible move sequences to find the best move.
Graph Theory: Understanding the Board
Before we can build an AI, we need to understand the board’s structure mathematically. The board is actually a graph, a collection of vertices (positions) and edges (legal moves).
Each position connects to its adjacent neighbors:
- Ring positions connect to their two ring neighbors plus the center
- The center connects to all 8 ring positions
const ADJACENCY = {
0: [1, 7, 8], // Position 0 connects to 1, 7, and center
1: [0, 2, 8],
// ... and so on
8: [0, 1, 2, 3, 4, 5, 6, 7] // Center connects to all
};
This graph structure defines which moves are legal. In game theory terms, we’re working with a finite, deterministic, perfect information game with a clearly defined state space.
Evaluating Positions Heuristically
We can score positions using a heuristic function that looks at all 12 winning patterns:
- Two pieces + empty space = threat → +500 points
- One piece + two empty spaces = potential → +10 points
- Opponent has the same = negative points
For example, if Black has pieces on positions 0 and 1, with position 2 empty, that’s a strong threat worth +500 points. If Black can move to position 2 next turn, they win.
Fine-tuning heuristics proved crucial—different scoring rules produce drastically different AI behavior.
Position Evaluation
Overall Score
+1040
Evaluating for black
Strong advantage
Scoring System
- +10000: Win
- +500: Strong threat (2 in a row)
- +10: Potential (1 piece positioned)
- Negative values: Opponent advantages
Significant Patterns
Potential Patterns (4)
The Minimax Algorithm
Once we can describe the game mathematically, we can apply an optimization algorithm to search for the best move. The minimax algorithm is a classic method for decision-making in two-player games. It assumes both players play optimally and minimizes possible loss for worst-case scenarios. In practice, humans rarely play optimally, but our analysis assumes perfect play.
The minimax algorithm works recursively:
- Base case: If we find a winning position or reach our search depth limit, evaluate it
- Recursive case:
- If it’s our turn, choose the move that maximizes our score
- If it’s opponent’s turn, they’ll choose the move that minimizes our score
- Propagate values back up the tree
Here’s the core algorithm:
function minimax(board, depth, isMaximizing) {
// Terminal states
if (gameWon(board)) return 10000;
if (depth === 0) return evaluatePosition(board);
const moves = getValidMoves(board);
if (isMaximizing) {
let maxScore = -Infinity;
for (const move of moves) {
const newBoard = applyMove(board, move);
const score = minimax(newBoard, depth - 1, false);
maxScore = Math.max(maxScore, score);
}
return maxScore;
} else {
let minScore = Infinity;
for (const move of moves) {
const newBoard = applyMove(board, move);
const score = minimax(newBoard, depth - 1, true);
minScore = Math.min(minScore, score);
}
return minScore;
}
}
Try playing against the minimax AI with depth 4 (looking 4 moves ahead):
Game Info
AI Opponent
Evaluation
Move History
No moves yet
You’ll notice this AI is much stronger. It creates threats, blocks your wins, and thinks several moves ahead.
Alpha-Beta Pruning: Making It Faster
Minimax explores every possible move sequence, which gets expensive quickly. With branching factor
Alpha-beta pruning is an optimization that eliminates branches that cannot affect the final decision:
function minimax(board, depth, alpha, beta, isMaximizing) {
// ... base cases ...
if (isMaximizing) {
for (const move of moves) {
score = minimax(newBoard, depth - 1, alpha, beta, false);
maxScore = Math.max(maxScore, score);
alpha = Math.max(alpha, score);
if (beta <= alpha) break; // Prune!
}
}
// ...
}
If we’ve already found a move with score X, and we’re exploring a branch where the opponent can force a score worse than X, we can stop searching that branch immediately.
Alpha-beta pruning typically reduces the effective branching factor by about
Transposition Tables: Remembering What We’ve Seen
Many different move sequences lead to the same board position. A transposition table (essentially a cache) stores positions we’ve already evaluated:
const cache = new Map();
function minimax(board, depth, alpha, beta, isMaximizing) {
const key = getBoardKey(board);
if (cache.has(key)) return cache.get(key);
// ... search logic ...
cache.set(key, result);
return result;
}
This is a form of dynamic programming—we’re breaking the problem into overlapping subproblems and storing solutions to avoid redundant work.
Symmetry: Exploiting Structure
Here’s a critical insight: the board has 8-fold rotational symmetry. Rotating the board by 45° gives an equivalent position:
X . . . . X . . .
. . . . . . . . .
. . . . . . . . X
This now gets us into an area of mathematics called group theory, a topic I’ve never come across before until now. In group theory terms, the board’s symmetries form a cyclic group with 8 rotations. Each rotation shifts pieces around the ring by one position (45°). Let me show all eight rotations of a single position:
Rotation 0°: Rotation 45°: Rotation 90°: Rotation 135°:
X . . . X . . . X . . .
. . . . . . . . . . . X
. . . . . . . . . . . .
Rotation 180°: Rotation 225°: Rotation 270°: Rotation 315°:
. . . . . . . . . . . .
. . . . . . . . . X . .
. . X . X . X . . . . .
All eight of these positions are essentially identical. The center position (marked 8) stays fixed under all rotations, while ring positions shift.
Why Only Rotations?
You might wonder: what about reflections? Tic-tac-toe typically uses both rotations and reflections as symmetries to reduce the search space. The solver implements rotational symmetry but not reflectional symmetry.
Technically, reflections are valid symmetries—mirroring a board position produces a strategically equivalent position. Implementing only rotations simplifies the canonicalization logic: adding reflections would double the symmetry group size (from 4 rotations to 8 symmetries: 4 rotations
Implementing Rotations
Rotating the board is simple. Ring positions shift by one, while the center stays fixed.
function rotatePosition(pos: number): number {
if (pos === 8) return 8; // Center is fixed
return (pos + 1) % 8; // Ring positions shift clockwise
}
function rotateBoard(board: Board): Board {
return board.map((piece, pos) =>
board[pos === 8 ? 8 : (pos + 7) % 8]
);
}
Canonical Forms and Search Space Reduction
We can use this to reduce our search space by ~8×. Instead of storing every position in our cache, we store only the canonical form—the lexicographically smallest rotation:
function getCanonicalForm(board) {
const rotations = getAllRotations(board);
const keys = rotations.map(b => getBoardKey(b));
keys.sort();
return keys[0]; // Smallest = canonical
}
This means our 1,680 positions collapse to roughly 1,680 ÷ 8 = 210 unique equivalence classes. Each equivalence class (or “orbit” in group theory terms) represents a set of rotationally identical positions. By storing only one representative from each class, we cut our memory usage and computation time by nearly 8×.
This is a powerful technique used in chess engines, Go programs, and many other game solvers.
The Complete Solution: Retrograde Analysis
Now for the main event: can we solve this game completely?
State Space Analysis
First, let’s count positions. In the 3v3 endgame (after all pieces are placed):
- Choose 3 positions for Black:
- Choose 3 positions for White:
- Total:
positions
With 8-fold symmetry, we have roughly
For comparison:
- Tic-tac-toe: ~6,000 positions
- Chess:
positions (unsolvable by brute force) - Terni Lapilli: ~200 positions (completely solvable!)
Retrograde Analysis Algorithm
Rather than searching forward from the starting position, we can work backwards from known winning positions:
- Generate all 3v3 positions (~210 after symmetry reduction)
- Mark terminal positions (positions with 3-in-a-row)
- Iterate backwards:
- If any move leads to our win → this position is winning
- If all moves lead to opponent win → this position is losing
- Otherwise, keep searching
- Repeat until convergence (all positions classified)
This builds a complete tablebase—a lookup table showing the outcome of every position.
function buildTablebase() {
const positions = generateAll3v3Positions();
const table = new Map();
// Mark immediate wins
for (const pos of positions) {
if (hasThreeInRow(pos)) {
table.set(getCanonical(pos), 'win');
}
}
// Retrograde iteration
while (changed) {
for (const pos of positions) {
if (table.has(pos)) continue;
const moves = getValidMoves(pos);
if (moves.some(m => table.get(applyMove(pos, m)) === 'win')) {
table.set(pos, 'win');
} else if (moves.every(m => table.get(applyMove(pos, m)) === 'loss')) {
table.set(pos, 'loss');
}
}
}
return table;
}
Solving from the Start
Once we have the endgame tablebase, we use minimax to search from the starting position. When we reach a 3v3 position, we look it up in the tablebase instead of using our heuristic.
Click below to solve the game completely (takes about 1 second):
Click "Solve Game" button above to see the complete solution.
The solver reveals the game-theoretic truth: with perfect play from both sides, the game has a definite outcome. Every position in the endgame is classified as either:
- Black wins with perfect play
- White wins with perfect play
- Draw with perfect play
Now try playing against perfect AI that uses this complete solution:
Game Info
AI Opponent
Evaluation
Move History
No moves yet
Conclusion
Two thousand years after Romans scratched this game into stone, mathematical analysis and computers now reveal its complete solution. Classical game theory, symmetry reduction, and retrograde analysis combine to prove the game’s outcome under perfect play—a technique equally powerful for chess engines, Go programs, and countless other games.
If you find errors or have suggestions for improvement, please share them.