Fanorona-Telo with Minimax AI

Build a browser-based Malagasy board game with a Minimax AI opponent.

Introduction

30 Second Summary

Board games have been testing human strategy for thousands of years. Fanorona-Telo is a traditional Malagasy alignment game where two players compete to line up three pieces on a 3x3 board with restricted movement paths.

In this project, you will build a complete browser-based Fanorona-Telo game with a 3-level AI opponent powered by Minimax with Alpha-Beta pruning, smooth CSS animations, undo/redo history, and deployment to GitHub Pages.

What You'll Build

A zero-dependency single-page application featuring an SVG game board, graph-based adjacency rules, and an AI engine that searches thousands of game states per move.

By the end of this project, you'll have:

  • A game engine with strict graph-based adjacency rules and two-phase gameplay (placement and movement).
  • A 3-level AI opponent using random, greedy, and Minimax with Alpha-Beta pruning strategies with visible performance metrics.
  • A polished, responsive UI with GPU-accelerated CSS animations, three game modes, and full undo/redo deployed on GitHub Pages.
  • Secret Mission: Add an automated game replay feature that replays the entire match move-by-move with synchronized animations.

Are there any prerequisites?

You should be comfortable with JavaScript fundamentals (functions, arrays, objects), basic HTML/CSS, and have a GitHub account. No external libraries or frameworks are needed for this project.

Before We Start

Before We Start...

Set Up the Project Structure

Fanorona-Telo is a modular JavaScript game split into four distinct files. The game engine handles rules and state, the AI engine powers the opponent, the UI layer renders the board, and the main controller ties everything together.

In this step, you'll scaffold the project folder, start a local development server so you can preview changes instantly, and push everything to GitHub for version control.

In this step, get ready to:
  • Create the project folder with all source files.
  • Set up a local development server using Live Server.
  • Initialize Git and push to a GitHub repository.
Create the project folder and files

Every file in this project has a specific job. You'll create them all upfront so the structure is clear from the start.

Windows users

All terminal commands in this project use bash syntax. If you're on Windows, open Git Bash instead of Command Prompt or PowerShell. Press the Windows key, type Git Bash, and press Enter.

  • Open your terminal and move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder, move into it, and create the js/ subdirectory by running these commands:
mkdir fanorona-telo
cd fanorona-telo
mkdir js
  • Create all the project files by running these commands:
touch index.html style.css .nojekyll
touch js/game.js js/ai.js js/ui.js js/main.js

What does each file do?

  • index.html is the single HTML page for the game UI.
  • style.css holds all styling, animations, and responsive layout.
  • js/game.js contains the game engine (board state, rules, undo/redo).
  • js/ai.js contains the AI opponent logic (random, greedy, minimax).
  • js/ui.js handles DOM rendering, SVG drawing, and animations.
  • js/main.js is the application controller that wires modes and turns together.
  • .nojekyll tells GitHub Pages to skip Jekyll processing and serve your files directly.
  • Confirm the files exist by running:
ls -a
ls js

The first listing should show index.html, style.css, .nojekyll, and the js folder. The second listing should show game.js, ai.js, ui.js, and main.js.

touch: command not found?

This happens on Windows if you're using PowerShell or Command Prompt instead of Git Bash. Close the current terminal and open Git Bash instead.

help me fix a 'command not found' error when running touch on Windows

Open the project in VS Code and start Live Server

A local development server lets you preview the game in your browser and auto-refreshes whenever you save a file. You'll use the Live Server extension for this.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the fanorona-telo folder, then click Open.

Now install the Live Server extension.

  • Open the Extensions view by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Live Server in the search box.
  • Find Live Server by Ritwick Dey (it has millions of installs).
  • Click Install.

Start the server to verify everything works.

  • In the VS Code Explorer sidebar, click on index.html to open it in the editor.
  • Click the Go Live button in the bottom status bar of VS Code.

Your default browser should open and display a blank white page. This is expected because index.html is empty.

Don't see the Go Live button?

Make sure you opened a folder in VS Code (not just a single file). Live Server requires a workspace folder to serve. Also confirm the extension installed successfully by checking the Extensions view for a green checkmark next to Live Server.

help me get the Go Live button to appear in VS Code

Initialize Git and push to GitHub

Version control lets you track every change and deploy to GitHub Pages later. You'll create the initial commit and push it to a remote repository now.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Initialize the repository and create your first commit by running these commands:
git init
git add .
git commit -m "Initial project structure"

What do these commands do?

  • git init creates a new Git repository in the current folder.
  • git add . stages all files for the first commit.
  • git commit -m "Initial project structure" saves a snapshot of your project with a descriptive message.

Now create a remote repository on GitHub to store your code online.

  • Open your browser and go to github.com.
  • In the upper-right corner, click the + icon and select New repository.
  • Enter fanorona-telo as the repository name.
  • Leave all initialization options unchecked (no README, no .gitignore, no license).
  • Click Create repository.
  • On the Quick Setup page that appears, copy the HTTPS URL for your new repository.
  • Back in VS Code's terminal, connect your local repository to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/your-username/fanorona-telo.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local repository to the GitHub remote.
  • git branch -M main renames your default branch to main (the GitHub standard).
  • git push -u origin main uploads your commit to GitHub and sets the upstream tracking branch.
  • Refresh your GitHub repository page in the browser.

You should see your files listed: index.html, style.css, .nojekyll, and the js/ folder with its four files.

Push failed or permission denied?

If you see a permission error, you may need to authenticate with GitHub. Run git push -u origin main again and follow the browser prompt to sign in. If you're asked for a username and password, use a personal access token instead of your password.

help me fix a permission denied error when pushing to GitHub

Your project skeleton is live on GitHub and serving locally in the browser. Next up, you'll build the full game board UI with SVG and a polished dark theme.

Build the Game Board UI

Your project files exist but they're all blank. Before you can implement game logic or AI, you need a visible game board the player can interact with.

In this step, you'll build the complete front-end: a semantic HTML layout, a CSS design system with GPU-accelerated animations, and a JavaScript rendering module that draws the Fanorona-Telo board using SVG.

In this step, get ready to:
  • Structure the full HTML layout with game controls, board container, and performance panel.
  • Implement CSS custom properties, piece animations, and responsive styling.
  • Draw the SVG board with 9 intersections and connecting lines in JavaScript.
Structure the HTML layout

The HTML defines every UI element the game needs: a settings panel for mode selection, an SVG container for the board, control buttons, a status display, and a performance metrics panel.

  • Switch to the index.html tab in VS Code and paste the following code:
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Fanorona-Telo</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="app">
        <!-- Game header with title and subtitle -->
        <header class="header">
            <h1 class="title">Fanorona-Telo</h1>
            <p class="subtitle">Jeu d'alignement traditionnel malgache</p>
        </header>

        <!-- Settings panel: game mode and AI difficulty selectors -->
        <div class="settings">
            <div class="setting-group">
                <label for="game-mode">Mode</label>
                <select id="game-mode">
                    <option value="hvh">Humain vs Humain</option>
                    <option value="hva" selected>Humain vs IA</option>
                    <option value="ava">IA vs IA (Demo)</option>
                </select>
            </div>
            <div class="setting-group">
                <label for="ai-difficulty">Difficulte IA</label>
                <select id="ai-difficulty">
                    <option value="easy">Facile</option>
                    <option value="medium">Moyen</option>
                    <option value="hard" selected>Difficile</option>
                </select>
            </div>
        </div>

        <!-- Main game area: status, board, and controls -->
        <div class="game-container">
            <div class="status" id="status">Joueur 1 - Phase de placement</div>

            <div class="board-wrapper">
                <!-- SVG draws the lines and intersections -->
                <svg class="board-svg" id="board-svg" viewBox="0 0 300 300"></svg>
                <!-- Pieces are positioned as div overlays -->
                <div class="pieces-layer" id="pieces-layer"></div>
            </div>

            <div class="controls">
                <button class="btn" id="btn-new">Nouvelle Partie</button>
                <button class="btn" id="btn-undo" disabled>Annuler</button>
                <button class="btn" id="btn-redo" disabled>Refaire</button>
                <button class="btn btn-stop hidden" id="btn-stop">Stop Demo</button>
            </div>
        </div>

        <!-- AI performance metrics display -->
        <div class="perf-panel" id="perf-panel">
            <div class="perf-title">Performance IA</div>
            <div class="perf-row">
                <span>Temps de calcul:</span>
                <span id="perf-time">- ms</span>
            </div>
            <div class="perf-row">
                <span>Noeuds explores:</span>
                <span id="perf-nodes">-</span>
            </div>
        </div>
    </div>

    <!-- Load modules in dependency order -->
    <script src="js/game.js"></script>
    <script src="js/ai.js"></script>
    <script src="js/ui.js"></script>
    <script src="js/main.js"></script>
</body>
</html>

What does this HTML do?

The page is divided into four main sections. The header shows the game title. The settings div holds two dropdowns for choosing game mode and AI difficulty.

The board-wrapper uses an SVG element for lines and intersections, with a separate pieces-layer div overlaid on top for animated piece elements. The perf-panel displays AI calculation time and nodes explored after each AI move.

Scripts load in dependency order at the bottom: game engine first, then AI, then UI rendering, then the main controller.

  • Save index.html.
  • Check your browser at localhost. You should see the text content (title, dropdowns, buttons) rendered with default browser styling on a white background.

Seeing a blank page?

Make sure Live Server is still running. Check that the file is saved. If you see nothing at all, open DevTools (F12) and look for red errors in the Console tab.

help me debug why my index.html isn't showing in the browser

Style the board with CSS

The CSS uses custom properties for a consistent dark theme and defines GPU-accelerated keyframe animations for piece placement, movement, and win highlights.

  • Switch to style.css and paste the following code:
:root {
    --bg-primary: #1a1a2e;
    --bg-secondary: #16213e;
    --bg-card: #0f3460;
    --accent: #e94560;
    --accent-light: #ff6b6b;
    --text-primary: #ffffff;
    --text-secondary: #a0a0b0;
    --player1: #4ecdc4;
    --player2: #ff6b6b;
    --line-color: #a0a0b0;
    --intersection-color: #2a2a4a;
    --highlight: #ffd93d;
    --transition-speed: 300ms;
    --board-size: min(80vw, 400px);
}

/* Reset and base layout */
* { margin: 0; padding: 0; box-sizing: border-box; }

body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 20px; }

.app { width: 100%; max-width: 500px; display: flex; flex-direction: column; align-items: center; gap: 20px; }

.title { font-size: 2rem; font-weight: 700; background: linear-gradient(135deg, var(--player1), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }

/* Piece base styling with GPU-accelerated transforms */
.piece { position: absolute; width: 36px; height: 36px; border-radius: 50%; transform: translate(-50%, -50%) scale(1); transition: transform var(--transition-speed) ease-in-out, box-shadow 0.2s; pointer-events: auto; cursor: pointer; will-change: transform; }

.piece.placing { animation: place-piece 0.3s ease-out forwards; }

.piece.winning { animation: win-pulse 0.6s ease-in-out infinite; }

/* Keyframe animations for piece lifecycle */
@keyframes place-piece { from { transform: translate(-50%, -50%) scale(0); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }

@keyframes win-pulse { 0%, 100% { transform: translate(-50%, -50%) scale(1); filter: brightness(1); } 50% { transform: translate(-50%, -50%) scale(1.2); filter: brightness(1.3); } }

@keyframes pulse-target { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }

/* Valid move indicator on board intersections */
.board-svg circle.intersection.valid-target { fill: var(--highlight); opacity: 0.6; animation: pulse-target 1s infinite; }

.hidden { display: none; }

/* Mobile responsive adjustments */
@media (max-width: 480px) { :root { --board-size: 85vw; } .title { font-size: 1.5rem; } .piece { width: 30px; height: 30px; } }

What does this CSS do?

The :root block defines all theme colors and sizes as CSS custom properties. Changing one variable updates every element that uses it.

The .piece class uses will-change: transform and translate(-50%, -50%) for GPU-accelerated positioning. Three keyframe animations handle the piece lifecycle: place-piece scales from 0 to 1 on placement, win-pulse creates a pulsing glow on winning pieces, and pulse-target highlights valid move destinations.

  • Save style.css.
  • Refresh your browser. You should see the page with a dark navy background, the title Fanorona-Telo in a gradient teal-to-red color, and the controls centered on the page.

Page still has a white background?

Check that the <link rel="stylesheet" href="style.css"> line is in your index.html head. Also verify both files are saved in the same fanorona-telo/ folder (not a subfolder).

help me figure out why my CSS file isn't loading

Draw the SVG board with JavaScript

The Fanorona-Telo board is a 3x3 grid of intersections connected by horizontal, vertical, and diagonal lines (diagonals pass through the center only). The UI module renders this board into the SVG element and manages all piece rendering and animations.

  • Switch to js/ui.js and paste the following code:
'use strict';

const UI = (function () {
    // 9 intersection positions on the 300x300 SVG viewBox
    const POSITIONS = [
        { x: 50, y: 50 },
        { x: 150, y: 50 },
        { x: 250, y: 50 },
        { x: 50, y: 150 },
        { x: 150, y: 150 },
        { x: 250, y: 150 },
        { x: 50, y: 250 },
        { x: 150, y: 250 },
        { x: 250, y: 250 }
    ];

    // Board connections: horizontal, vertical, and diagonals through center
    const LINES = [
        [0, 1], [1, 2], [3, 4], [4, 5], [6, 7], [7, 8],
        [0, 3], [3, 6], [1, 4], [4, 7], [2, 5], [5, 8],
        [0, 4], [4, 8], [2, 4], [4, 6]
    ];

    // DOM references cached after init
    let boardSvg, piecesLayer, statusEl, perfTimeEl, perfNodesEl;
    let onIntersectionClick = null;
    let onPieceClick = null;

    function init(callbacks) {
        boardSvg = document.getElementById('board-svg');
        piecesLayer = document.getElementById('pieces-layer');
        statusEl = document.getElementById('status');
        perfTimeEl = document.getElementById('perf-time');
        perfNodesEl = document.getElementById('perf-nodes');
        onIntersectionClick = callbacks.onIntersectionClick;
        onPieceClick = callbacks.onPieceClick;
        drawBoard();
    }

    function drawBoard() {
        boardSvg.innerHTML = '';

        // Draw connecting lines between intersections
        for (let i = 0; i < LINES.length; i++) {
            const [a, b] = LINES[i];
            const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
            line.setAttribute('x1', POSITIONS[a].x);
            line.setAttribute('y1', POSITIONS[a].y);
            line.setAttribute('x2', POSITIONS[b].x);
            line.setAttribute('y2', POSITIONS[b].y);
            boardSvg.appendChild(line);
        }

        // Draw clickable intersection circles
        for (let i = 0; i < 9; i++) {
            const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
            circle.setAttribute('cx', POSITIONS[i].x);
            circle.setAttribute('cy', POSITIONS[i].y);
            circle.setAttribute('r', '15');
            circle.classList.add('intersection');
            circle.dataset.index = i;
            circle.addEventListener('click', () => {
                if (onIntersectionClick) onIntersectionClick(i);
            });
            boardSvg.appendChild(circle);
        }
    }

    function render(state) {
        piecesLayer.innerHTML = '';
        const wrapperRect = piecesLayer.getBoundingClientRect();
        const scaleX = wrapperRect.width / 300;
        const scaleY = wrapperRect.height / 300;

        for (let i = 0; i < 9; i++) {
            if (state.board[i] !== null) {
                const piece = document.createElement('div');
                piece.classList.add('piece', 'player' + state.board[i]);
                piece.dataset.index = i;
                piece.style.left = (POSITIONS[i].x * scaleX) + 'px';
                piece.style.top = (POSITIONS[i].y * scaleY) + 'px';

                if (state.selectedPiece === i) {
                    piece.classList.add('selected');
                }

                piece.addEventListener('click', (e) => {
                    e.stopPropagation();
                    if (onPieceClick) onPieceClick(i);
                });

                piecesLayer.appendChild(piece);
            }
        }
    }

    function renderWithPlaceAnimation(state, placedIndex) {
        render(state);
        const piece = piecesLayer.querySelector(`[data-index="${placedIndex}"]`);
        if (piece) {
            piece.classList.add('placing');
        }
    }

    function renderWithMoveAnimation(state, fromIndex, toIndex, callback) {
        const wrapperRect = piecesLayer.getBoundingClientRect();
        const scaleX = wrapperRect.width / 300;
        const scaleY = wrapperRect.height / 300;

        // Find the piece currently at the 'from' position
        const movingPiece = piecesLayer.querySelector(`[data-index="${fromIndex}"]`);
        if (!movingPiece) {
            render(state);
            if (callback) callback();
            return;
        }

        // Calculate pixel delta for translate animation
        const deltaX = (POSITIONS[toIndex].x - POSITIONS[fromIndex].x) * scaleX;
        const deltaY = (POSITIONS[toIndex].y - POSITIONS[fromIndex].y) * scaleY;

        movingPiece.style.transition = 'transform 300ms ease-in-out';
        movingPiece.style.transform = `translate(calc(-50% + ${deltaX}px), calc(-50% + ${deltaY}px)) scale(1)`;

        // After animation completes, re-render at final state
        setTimeout(() => {
            render(state);
            if (callback) callback();
        }, 320);
    }

    function highlightValidTargets(targets) {
        const circles = boardSvg.querySelectorAll('.intersection');
        circles.forEach(c => c.classList.remove('valid-target'));
        targets.forEach(idx => {
            const circle = boardSvg.querySelector(`[data-index="${idx}"]`);
            if (circle) circle.classList.add('valid-target');
        });
    }

    function clearHighlights() {
        boardSvg.querySelectorAll('.intersection').forEach(c => c.classList.remove('valid-target'));
    }

    function showWin(winLine, state) {
        render(state);
        winLine.forEach(idx => {
            const piece = piecesLayer.querySelector(`[data-index="${idx}"]`);
            if (piece) piece.classList.add('winning');
        });
    }

    function setStatus(text) {
        statusEl.textContent = text;
    }

    function updatePerf(timeMs, nodes) {
        perfTimeEl.textContent = timeMs.toFixed(2) + ' ms';
        perfNodesEl.textContent = nodes.toLocaleString();
    }

    function resetPerf() {
        perfTimeEl.textContent = '- ms';
        perfNodesEl.textContent = '-';
    }

    function getPositions() {
        return POSITIONS;
    }

    return {
        init,
        render,
        renderWithPlaceAnimation,
        renderWithMoveAnimation,
        highlightValidTargets,
        clearHighlights,
        showWin,
        setStatus,
        updatePerf,
        resetPerf,
        getPositions
    };
})();

What does this module do?

The UI module is wrapped in an IIFE (Immediately Invoked Function Expression) to keep all internal variables private. Only the returned object is accessible from outside.

POSITIONS maps each of the 9 board intersections to an (x, y) coordinate inside the 300x300 SVG viewBox. LINES defines 16 connections: 6 horizontal, 6 vertical, and 4 diagonals. All 4 diagonals pass through the center (position 4).

drawBoard() creates SVG line and circle elements using createElementNS (required for SVG). The render() function places piece divs as absolutely-positioned overlays, scaling coordinates to match the actual board size. Movement animations use CSS transform: translate3d() transitions for smooth GPU-accelerated motion.

  • Save js/ui.js.
  • Refresh your browser. You won't see the board lines yet because UI.init() hasn't been called (that happens when js/main.js is implemented in a later step). Open DevTools (F12) and in the Console, type typeof UI to verify it returns "object".

Console shows 'UI is not defined'?

Check that your <script src="js/ui.js"> tag is present in index.html and that the file path matches exactly. Also verify js/ui.js is inside the js/ subfolder, not in the project root.

help me debug why my UI module isn't loading in the browser

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: index.html, style.css, and js/ui.js.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point:

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Fanorona-Telo</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="app">
        <header class="header">
            <h1 class="title">Fanorona-Telo</h1>
            <p class="subtitle">Jeu d'alignement traditionnel malgache</p>
        </header>
        <div class="settings">
            <div class="setting-group">
                <label for="game-mode">Mode</label>
                <select id="game-mode">
                    <option value="hvh">Humain vs Humain</option>
                    <option value="hva" selected>Humain vs IA</option>
                    <option value="ava">IA vs IA (Demo)</option>
                </select>
            </div>
            <div class="setting-group">
                <label for="ai-difficulty">Difficulte IA</label>
                <select id="ai-difficulty">
                    <option value="easy">Facile</option>
                    <option value="medium">Moyen</option>
                    <option value="hard" selected>Difficile</option>
                </select>
            </div>
        </div>
        <div class="game-container">
            <div class="status" id="status">Joueur 1 - Phase de placement</div>
            <div class="board-wrapper">
                <svg class="board-svg" id="board-svg" viewBox="0 0 300 300"></svg>
                <div class="pieces-layer" id="pieces-layer"></div>
            </div>
            <div class="controls">
                <button class="btn" id="btn-new">Nouvelle Partie</button>
                <button class="btn" id="btn-undo" disabled>Annuler</button>
                <button class="btn" id="btn-redo" disabled>Refaire</button>
                <button class="btn btn-stop hidden" id="btn-stop">Stop Demo</button>
            </div>
        </div>
        <div class="perf-panel" id="perf-panel">
            <div class="perf-title">Performance IA</div>
            <div class="perf-row">
                <span>Temps de calcul:</span>
                <span id="perf-time">- ms</span>
            </div>
            <div class="perf-row">
                <span>Noeuds explores:</span>
                <span id="perf-nodes">-</span>
            </div>
        </div>
    </div>
    <script src="js/game.js"></script>
    <script src="js/ai.js"></script>
    <script src="js/ui.js"></script>
    <script src="js/main.js"></script>
</body>
</html>
:root {
    --bg-primary: #1a1a2e;
    --bg-secondary: #16213e;
    --bg-card: #0f3460;
    --accent: #e94560;
    --accent-light: #ff6b6b;
    --text-primary: #ffffff;
    --text-secondary: #a0a0b0;
    --player1: #4ecdc4;
    --player2: #ff6b6b;
    --line-color: #a0a0b0;
    --intersection-color: #2a2a4a;
    --highlight: #ffd93d;
    --transition-speed: 300ms;
    --board-size: min(80vw, 400px);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 20px; }
.app { width: 100%; max-width: 500px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.title { font-size: 2rem; font-weight: 700; background: linear-gradient(135deg, var(--player1), var(--accent)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.piece { position: absolute; width: 36px; height: 36px; border-radius: 50%; transform: translate(-50%, -50%) scale(1); transition: transform var(--transition-speed) ease-in-out, box-shadow 0.2s; pointer-events: auto; cursor: pointer; will-change: transform; }
.piece.placing { animation: place-piece 0.3s ease-out forwards; }
.piece.winning { animation: win-pulse 0.6s ease-in-out infinite; }
@keyframes place-piece { from { transform: translate(-50%, -50%) scale(0); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
@keyframes win-pulse { 0%, 100% { transform: translate(-50%, -50%) scale(1); filter: brightness(1); } 50% { transform: translate(-50%, -50%) scale(1.2); filter: brightness(1.3); } }
@keyframes pulse-target { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
.board-svg circle.intersection.valid-target { fill: var(--highlight); opacity: 0.6; animation: pulse-target 1s infinite; }
.hidden { display: none; }
@media (max-width: 480px) { :root { --board-size: 85vw; } .title { font-size: 1.5rem; } .piece { width: 30px; height: 30px; } }
'use strict';

const UI = (function () {
    const POSITIONS = [
        { x: 50, y: 50 },
        { x: 150, y: 50 },
        { x: 250, y: 50 },
        { x: 50, y: 150 },
        { x: 150, y: 150 },
        { x: 250, y: 150 },
        { x: 50, y: 250 },
        { x: 150, y: 250 },
        { x: 250, y: 250 }
    ];

    const LINES = [
        [0, 1], [1, 2], [3, 4], [4, 5], [6, 7], [7, 8],
        [0, 3], [3, 6], [1, 4], [4, 7], [2, 5], [5, 8],
        [0, 4], [4, 8], [2, 4], [4, 6]
    ];

    let boardSvg, piecesLayer, statusEl, perfTimeEl, perfNodesEl;
    let onIntersectionClick = null;
    let onPieceClick = null;

    function init(callbacks) {
        boardSvg = document.getElementById('board-svg');
        piecesLayer = document.getElementById('pieces-layer');
        statusEl = document.getElementById('status');
        perfTimeEl = document.getElementById('perf-time');
        perfNodesEl = document.getElementById('perf-nodes');
        onIntersectionClick = callbacks.onIntersectionClick;
        onPieceClick = callbacks.onPieceClick;
        drawBoard();
    }

    function drawBoard() {
        boardSvg.innerHTML = '';

        for (let i = 0; i < LINES.length; i++) {
            const [a, b] = LINES[i];
            const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
            line.setAttribute('x1', POSITIONS[a].x);
            line.setAttribute('y1', POSITIONS[a].y);
            line.setAttribute('x2', POSITIONS[b].x);
            line.setAttribute('y2', POSITIONS[b].y);
            boardSvg.appendChild(line);
        }

        for (let i = 0; i < 9; i++) {
            const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
            circle.setAttribute('cx', POSITIONS[i].x);
            circle.setAttribute('cy', POSITIONS[i].y);
            circle.setAttribute('r', '15');
            circle.classList.add('intersection');
            circle.dataset.index = i;
            circle.addEventListener('click', () => {
                if (onIntersectionClick) onIntersectionClick(i);
            });
            boardSvg.appendChild(circle);
        }
    }

    function render(state) {
        piecesLayer.innerHTML = '';
        const wrapperRect = piecesLayer.getBoundingClientRect();
        const scaleX = wrapperRect.width / 300;
        const scaleY = wrapperRect.height / 300;

        for (let i = 0; i < 9; i++) {
            if (state.board[i] !== null) {
                const piece = document.createElement('div');
                piece.classList.add('piece', 'player' + state.board[i]);
                piece.dataset.index = i;
                piece.style.left = (POSITIONS[i].x * scaleX) + 'px';
                piece.style.top = (POSITIONS[i].y * scaleY) + 'px';

                if (state.selectedPiece === i) {
                    piece.classList.add('selected');
                }

                piece.addEventListener('click', (e) => {
                    e.stopPropagation();
                    if (onPieceClick) onPieceClick(i);
                });

                piecesLayer.appendChild(piece);
            }
        }
    }

    function renderWithPlaceAnimation(state, placedIndex) {
        render(state);
        const piece = piecesLayer.querySelector(`[data-index="${placedIndex}"]`);
        if (piece) {
            piece.classList.add('placing');
        }
    }

    function renderWithMoveAnimation(state, fromIndex, toIndex, callback) {
        const wrapperRect = piecesLayer.getBoundingClientRect();
        const scaleX = wrapperRect.width / 300;
        const scaleY = wrapperRect.height / 300;

        const movingPiece = piecesLayer.querySelector(`[data-index="${fromIndex}"]`);
        if (!movingPiece) {
            render(state);
            if (callback) callback();
            return;
        }

        const deltaX = (POSITIONS[toIndex].x - POSITIONS[fromIndex].x) * scaleX;
        const deltaY = (POSITIONS[toIndex].y - POSITIONS[fromIndex].y) * scaleY;

        movingPiece.style.transition = 'transform 300ms ease-in-out';
        movingPiece.style.transform = `translate(calc(-50% + ${deltaX}px), calc(-50% + ${deltaY}px)) scale(1)`;

        setTimeout(() => {
            render(state);
            if (callback) callback();
        }, 320);
    }

    function highlightValidTargets(targets) {
        const circles = boardSvg.querySelectorAll('.intersection');
        circles.forEach(c => c.classList.remove('valid-target'));
        targets.forEach(idx => {
            const circle = boardSvg.querySelector(`[data-index="${idx}"]`);
            if (circle) circle.classList.add('valid-target');
        });
    }

    function clearHighlights() {
        boardSvg.querySelectorAll('.intersection').forEach(c => c.classList.remove('valid-target'));
    }

    function showWin(winLine, state) {
        render(state);
        winLine.forEach(idx => {
            const piece = piecesLayer.querySelector(`[data-index="${idx}"]`);
            if (piece) piece.classList.add('winning');
        });
    }

    function setStatus(text) {
        statusEl.textContent = text;
    }

    function updatePerf(timeMs, nodes) {
        perfTimeEl.textContent = timeMs.toFixed(2) + ' ms';
        perfNodesEl.textContent = nodes.toLocaleString();
    }

    function resetPerf() {
        perfTimeEl.textContent = '- ms';
        perfNodesEl.textContent = '-';
    }

    function getPositions() {
        return POSITIONS;
    }

    return {
        init,
        render,
        renderWithPlaceAnimation,
        renderWithMoveAnimation,
        highlightValidTargets,
        clearHighlights,
        showWin,
        setStatus,
        updatePerf,
        resetPerf,
        getPositions
    };
})();

Why diagonals through center only?

In Fanorona-Telo, the center is a "strong" intersection that connects to all 8 other positions. Corners and edges are "weak" intersections with limited connections. This means diagonal movement only exists between the center and a corner. Two corners can never connect directly.

This adjacency structure is what makes the game strategically interesting. Controlling the center gives a massive mobility advantage.

Your game board UI is complete. Next up, you'll build the game engine that enforces placement rules, movement adjacency, and win detection.

Implement the Game Engine

Your board looks great, but right now it's just a static picture. For the game to actually work, you need an engine that knows the rules of Fanorona-Telo.

The engine is responsible for tracking whose turn it is, validating every move against the board's adjacency graph, detecting wins, and managing an undo/redo history. You'll build it as a self-contained JavaScript module in js/game.js.

In this step, get ready to:
  • Define the adjacency graph and game state data structures.
  • Implement placement and movement rules with win detection.
  • Build an undo/redo system with full state history.
Define the adjacency graph and game state

Fanorona-Telo's board has 9 intersections, but not every position connects to every other. The center connects to all 8 positions, while corners and edges only connect to 3 neighbours each.

Why a graph instead of a grid?

A simple 3x3 grid would let pieces move to any adjacent cell. But in Fanorona-Telo, diagonal movement only exists through the center intersection. Modeling the board as an explicit adjacency graph enforces this rule structurally, so invalid moves become impossible by definition.

  • Open js/game.js in VS Code and add the IIFE module shell with the board data model:
'use strict';

const Game = (function () {
    // Adjacency graph: each index lists the positions it connects to
    const ADJACENCY = [
        [1, 3, 4],
        [0, 2, 4],
        [1, 5, 4],
        [0, 6, 4],
        [0, 1, 2, 3, 5, 6, 7, 8],
        [2, 8, 4],
        [3, 7, 4],
        [6, 8, 4],
        [5, 7, 4]
    ];

    // All 8 possible three-in-a-row winning lines
    const WIN_LINES = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8],
        [0, 3, 6], [1, 4, 7], [2, 5, 8],
        [0, 4, 8], [2, 4, 6]
    ];

    // Module-level state variables
    let state;
    let history;
    let redoStack;
    let movesLog;

What does this code do?

The ADJACENCY array defines which positions each intersection can reach. Index 4 (the center) connects to all 8 other positions. Corners like index 0 only connect to their two adjacent edges (1 and 3) plus the center (4).

The WIN_LINES array lists all 8 ways to win: 3 rows, 3 columns, and 2 diagonals. The four module-level variables will hold the current game state, the history stack for undo, the redo stack, and a log of all moves played.

You won't see a change in the browser yet. The next chunk adds the state management functions that bring this data model to life.

  • Directly below the let movesLog; line, add the state management functions:
    // Creates a fresh game state object
    function createState() {
        return {
            board: Array(9).fill(null),
            currentPlayer: 1,
            phase: 1,
            piecesPlaced: { 1: 0, 2: 0 },
            winner: null,
            selectedPiece: null
        };
    }

    // Deep-copies a state to avoid mutation
    function cloneState(s) {
        return {
            board: [...s.board],
            currentPlayer: s.currentPlayer,
            phase: s.phase,
            piecesPlaced: { ...s.piecesPlaced },
            winner: s.winner,
            selectedPiece: s.selectedPiece
        };
    }

    // Resets everything for a new game
    function init() {
        state = createState();
        history = [];
        redoStack = [];
        movesLog = [];
    }

    // Returns a safe copy of the current state
    function getState() {
        return cloneState(state);
    }

    // Returns a copy of all moves played so far
    function getMovesLog() {
        return [...movesLog];
    }

What does this code do?

  • createState() builds a blank game: 9 empty cells, player 1 starts, phase 1 (placement), no pieces placed yet.
  • cloneState() creates a snapshot of any state object so the undo system can store past states without them being overwritten.
  • init() wipes everything clean for a new game. The other modules will call this at startup and when the player clicks New Game.

The game state is still internal. The next substep adds functions that actually enforce the rules.

Implement game logic

Fanorona-Telo has two phases. In Phase 1, players take turns placing pieces on empty intersections. After all 6 pieces are placed (3 per player), Phase 2 begins and players slide pieces along the adjacency graph.

  • Below getMovesLog(), add the win detection and legal move generation functions:
    // Checks if a player has three in a row, returns the winning line or null
    function checkWin(board, player) {
        for (let i = 0; i < WIN_LINES.length; i++) {
            const [a, b, c] = WIN_LINES[i];
            if (board[a] === player && board[b] === player && board[c] === player) {
                return WIN_LINES[i];
            }
        }
        return null;
    }

    // Generates all valid moves for a player in a given phase
    function getLegalMoves(boardState, player, phase, piecesPlaced) {
        const moves = [];
        if (phase === 1) {
            // Placement: any empty intersection is valid
            for (let i = 0; i < 9; i++) {
                if (boardState[i] === null) {
                    moves.push({ type: 'place', to: i });
                }
            }
        } else {
            // Movement: slide own pieces to adjacent empty intersections
            for (let i = 0; i < 9; i++) {
                if (boardState[i] === player) {
                    const neighbors = ADJACENCY[i];
                    for (let j = 0; j < neighbors.length; j++) {
                        if (boardState[neighbors[j]] === null) {
                            moves.push({ type: 'move', from: i, to: neighbors[j] });
                        }
                    }
                }
            }
        }
        return moves;
    }

    // Convenience wrapper using the module's internal state
    function getLegalMovesForCurrent() {
        return getLegalMoves(state.board, state.currentPlayer, state.phase, state.piecesPlaced);
    }

What does this code do?

  • checkWin() iterates through all 8 winning lines and returns the line array if three matching pieces are found. This powers both the win announcement and the pulsing animation on the winning pieces.
  • getLegalMoves() branches on the game phase. In Phase 1, every empty cell is a legal placement. In Phase 2, it checks the ADJACENCY graph to find valid slides. This enforces the rule that corners cannot move directly to other corners.
  • getLegalMovesForCurrent() is a shorthand the AI will call heavily later.

No visible change yet. The next chunk adds the critical applyMove() function that actually modifies the game state.

  • Below getLegalMovesForCurrent(), add the function that executes a move and updates the game state:
    // Executes a move, updates state, checks for win/phase transition
    function applyMove(move) {
        history.push(cloneState(state));
        redoStack = [];

        if (move.type === 'place') {
            state.board[move.to] = state.currentPlayer;
            state.piecesPlaced[state.currentPlayer]++;
            movesLog.push({ ...move, player: state.currentPlayer });

            const winLine = checkWin(state.board, state.currentPlayer);
            if (winLine) {
                state.winner = state.currentPlayer;
                return { success: true, win: true, winLine: winLine };
            }

            // Transition to Phase 2 after all 6 pieces are placed
            if (state.piecesPlaced[1] === 3 && state.piecesPlaced[2] === 3) {
                state.phase = 2;
            }

            state.currentPlayer = state.currentPlayer === 1 ? 2 : 1;
            return { success: true, win: false };
        }

        if (move.type === 'move') {
            state.board[move.to] = state.board[move.from];
            state.board[move.from] = null;
            movesLog.push({ ...move, player: state.currentPlayer });

            const winLine = checkWin(state.board, state.currentPlayer);
            if (winLine) {
                state.winner = state.currentPlayer;
                return { success: true, win: true, winLine: winLine };
            }

            state.currentPlayer = state.currentPlayer === 1 ? 2 : 1;
            return { success: true, win: false };
        }

        return { success: false };
    }

What does this code do?

Every time a move executes, the current state is cloned onto the history stack first. This makes undo possible. The redo stack is cleared because a new move invalidates any future you could redo.

For placements, the function updates the board, increments the piece counter, checks for a win, and transitions to Phase 2 when both players have placed 3 pieces. For movements, it swaps the board cells and checks for a win. In both cases it toggles the current player at the end.

Still no visible output, but the core rules are now in place. The last substep closes the module with undo/redo support.

Implement the undo/redo system

Undo/redo works by storing complete game state snapshots on each move. Undo pops from the history stack. Redo pops from the redo stack. Any new move clears the redo stack.

  • Below applyMove(), add the undo/redo functions, the utility helpers, the return statement, and close the IIFE:
    // Reverts the last move
    function undo() {
        if (history.length === 0) return false;
        redoStack.push(cloneState(state));
        state = history.pop();
        movesLog.pop();
        return true;
    }

    // Re-applies a previously undone move
    function redo() {
        if (redoStack.length === 0) return false;
        history.push(cloneState(state));
        state = redoStack.pop();
        return true;
    }

    function canUndo() {
        return history.length > 0;
    }

    function canRedo() {
        return redoStack.length > 0;
    }

    function isGameOver() {
        return state.winner !== null;
    }

    // Creates a hypothetical board without mutating state (used by AI)
    function simulateMove(board, move, player) {
        const newBoard = [...board];
        if (move.type === 'place') {
            newBoard[move.to] = player;
        } else {
            newBoard[move.to] = newBoard[move.from];
            newBoard[move.from] = null;
        }
        return newBoard;
    }

    return {
        ADJACENCY,
        WIN_LINES,
        init,
        getState,
        getMovesLog,
        checkWin,
        getLegalMoves,
        getLegalMovesForCurrent,
        applyMove,
        undo,
        redo,
        canUndo,
        canRedo,
        isGameOver,
        simulateMove,
        cloneState
    };
})();

What does this code do?

  • undo() saves the current state to the redo stack, then restores the previous state from history. It also pops the last entry from movesLog to keep the move record accurate.
  • simulateMove() creates a hypothetical board for a given move without touching the real game state. The AI engine will use this heavily to explore possible futures.
  • The return object exposes only the public API. The internal state, history, and redoStack variables remain private to the IIFE closure.
  • Save js/game.js.
  • Open your browser's DevTools console (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and type:
Game.init(); Game.getState();
  • You should see an object with a board array of 9 nulls, currentPlayer: 1, and phase: 1. This confirms the engine loaded and initialised correctly.

Seeing an error in the console?

  • If you see Game is not defined, check that the IIFE ends with })(); and that you saved the file.
  • If you see a syntax error, check for missing commas in the ADJACENCY or WIN_LINES arrays.
  • Make sure js/game.js is loaded before js/ui.js in your HTML. The script tag order in index.html matters.

Help me debug a Game is not defined error in my Fanorona-Telo project

You can also test the rules by running a quick placement in the console:

Game.init(); Game.applyMove({type:'place', to:4}); Game.getState();

You should see board[4] set to 1 and currentPlayer switched to 2. The engine is enforcing turn alternation.

✔️ Awesome, I've got everything!

Great. Double check you've saved js/game.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete js/game.js file. Compare yours against it:

'use strict';

const Game = (function () {
    const ADJACENCY = [
        [1, 3, 4],
        [0, 2, 4],
        [1, 5, 4],
        [0, 6, 4],
        [0, 1, 2, 3, 5, 6, 7, 8],
        [2, 8, 4],
        [3, 7, 4],
        [6, 8, 4],
        [5, 7, 4]
    ];

    const WIN_LINES = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8],
        [0, 3, 6], [1, 4, 7], [2, 5, 8],
        [0, 4, 8], [2, 4, 6]
    ];

    let state;
    let history;
    let redoStack;
    let movesLog;

    function createState() {
        return {
            board: Array(9).fill(null),
            currentPlayer: 1,
            phase: 1,
            piecesPlaced: { 1: 0, 2: 0 },
            winner: null,
            selectedPiece: null
        };
    }

    function cloneState(s) {
        return {
            board: [...s.board],
            currentPlayer: s.currentPlayer,
            phase: s.phase,
            piecesPlaced: { ...s.piecesPlaced },
            winner: s.winner,
            selectedPiece: s.selectedPiece
        };
    }

    function init() {
        state = createState();
        history = [];
        redoStack = [];
        movesLog = [];
    }

    function getState() {
        return cloneState(state);
    }

    function getMovesLog() {
        return [...movesLog];
    }

    function checkWin(board, player) {
        for (let i = 0; i < WIN_LINES.length; i++) {
            const [a, b, c] = WIN_LINES[i];
            if (board[a] === player && board[b] === player && board[c] === player) {
                return WIN_LINES[i];
            }
        }
        return null;
    }

    function getLegalMoves(boardState, player, phase, piecesPlaced) {
        const moves = [];
        if (phase === 1) {
            for (let i = 0; i < 9; i++) {
                if (boardState[i] === null) {
                    moves.push({ type: 'place', to: i });
                }
            }
        } else {
            for (let i = 0; i < 9; i++) {
                if (boardState[i] === player) {
                    const neighbors = ADJACENCY[i];
                    for (let j = 0; j < neighbors.length; j++) {
                        if (boardState[neighbors[j]] === null) {
                            moves.push({ type: 'move', from: i, to: neighbors[j] });
                        }
                    }
                }
            }
        }
        return moves;
    }

    function getLegalMovesForCurrent() {
        return getLegalMoves(state.board, state.currentPlayer, state.phase, state.piecesPlaced);
    }

    function applyMove(move) {
        history.push(cloneState(state));
        redoStack = [];

        if (move.type === 'place') {
            state.board[move.to] = state.currentPlayer;
            state.piecesPlaced[state.currentPlayer]++;
            movesLog.push({ ...move, player: state.currentPlayer });

            const winLine = checkWin(state.board, state.currentPlayer);
            if (winLine) {
                state.winner = state.currentPlayer;
                return { success: true, win: true, winLine: winLine };
            }

            if (state.piecesPlaced[1] === 3 && state.piecesPlaced[2] === 3) {
                state.phase = 2;
            }

            state.currentPlayer = state.currentPlayer === 1 ? 2 : 1;
            return { success: true, win: false };
        }

        if (move.type === 'move') {
            state.board[move.to] = state.board[move.from];
            state.board[move.from] = null;
            movesLog.push({ ...move, player: state.currentPlayer });

            const winLine = checkWin(state.board, state.currentPlayer);
            if (winLine) {
                state.winner = state.currentPlayer;
                return { success: true, win: true, winLine: winLine };
            }

            state.currentPlayer = state.currentPlayer === 1 ? 2 : 1;
            return { success: true, win: false };
        }

        return { success: false };
    }

    function undo() {
        if (history.length === 0) return false;
        redoStack.push(cloneState(state));
        state = history.pop();
        movesLog.pop();
        return true;
    }

    function redo() {
        if (redoStack.length === 0) return false;
        history.push(cloneState(state));
        state = redoStack.pop();
        return true;
    }

    function canUndo() {
        return history.length > 0;
    }

    function canRedo() {
        return redoStack.length > 0;
    }

    function isGameOver() {
        return state.winner !== null;
    }

    function simulateMove(board, move, player) {
        const newBoard = [...board];
        if (move.type === 'place') {
            newBoard[move.to] = player;
        } else {
            newBoard[move.to] = newBoard[move.from];
            newBoard[move.from] = null;
        }
        return newBoard;
    }

    return {
        ADJACENCY,
        WIN_LINES,
        init,
        getState,
        getMovesLog,
        checkWin,
        getLegalMoves,
        getLegalMovesForCurrent,
        applyMove,
        undo,
        redo,
        canUndo,
        canRedo,
        isGameOver,
        simulateMove,
        cloneState
    };
})();

Your game engine is complete. It knows every legal move, detects wins instantly, and can rewind history at will. Next up, you'll build the AI opponent that uses this engine to think ahead and challenge the player.

Implement the AI Engine

Your game engine knows the rules and can detect wins. Now you need an opponent that can actually play against the human.

In this step, you will build three AI difficulty levels inside js/ai.js. Each level uses a progressively more sophisticated strategy, from random moves all the way up to a Minimax search with Alpha-Beta pruning.

In this step, get ready to:
  • Implement an easy AI that picks random legal moves.
  • Implement a medium AI with a greedy win/block/center strategy.
  • Implement a hard AI using Minimax with Alpha-Beta pruning and a heuristic evaluation function.
Build the easy AI (random moves)

The easiest AI just needs to pick any legal move at random. This creates an unpredictable but beatable opponent.

  • Open js/ai.js in VS Code and add the AI module structure with the random move function:
'use strict';

const AI = (function () {
    // Track how many positions the AI evaluates (for performance display)
    let nodesExplored = 0;

    function getNodesExplored() {
        return nodesExplored;
    }

    // Easy AI: pick a random legal move
    function getRandomMove(state) {
        const moves = Game.getLegalMoves(state.board, state.currentPlayer, state.phase, state.piecesPlaced);
        if (moves.length === 0) return null;
        return moves[Math.floor(Math.random() * moves.length)];
    }

What does this code do?

The AI module uses the same IIFE pattern as Game. It wraps all AI logic in a self-executing function so nothing leaks into the global scope.

The nodesExplored counter tracks how many board positions the AI evaluates during its search. This feeds into the performance panel the learner built in the UI.

The getRandomMove function asks the Game module for all legal moves, then picks one at random using Math.random().

You won't see a change yet. The module needs all its functions and a closing bracket before it becomes available.

Build the medium AI (greedy strategy)

A greedy AI follows a simple priority list: win immediately if possible, block the opponent from winning, take the center, or fall back to a random move.

  • In js/ai.js, add the getMediumMove function directly below getRandomMove:
    // Medium AI: win > block > center > random
    function getMediumMove(state) {
        const player = state.currentPlayer;
        const opponent = player === 1 ? 2 : 1;
        const moves = Game.getLegalMoves(state.board, player, state.phase, state.piecesPlaced);
        if (moves.length === 0) return null;

        // Priority 1: take an immediate win
        for (let i = 0; i < moves.length; i++) {
            const newBoard = Game.simulateMove(state.board, moves[i], player);
            if (Game.checkWin(newBoard, player)) {
                return moves[i];
            }
        }

        // Priority 2: block opponent from winning next turn
        const opponentMoves = Game.getLegalMoves(state.board, opponent, state.phase, state.piecesPlaced);
        for (let i = 0; i < opponentMoves.length; i++) {
            const newBoard = Game.simulateMove(state.board, opponentMoves[i], opponent);
            if (Game.checkWin(newBoard, opponent)) {
                for (let j = 0; j < moves.length; j++) {
                    if (moves[j].to === opponentMoves[i].to) {
                        return moves[j];
                    }
                }
            }
        }

        // Priority 3: take center if available
        const centerMoves = moves.filter(m => m.to === 4);
        if (centerMoves.length > 0) return centerMoves[0];

        // Fallback: random
        return moves[Math.floor(Math.random() * moves.length)];
    }

What does this code do?

The medium AI uses Game.simulateMove to test each possible move without changing the real game state. It simulates placing or moving a piece, then checks if that board state is a win.

The blocking logic works by checking what the opponent could do next turn. If any opponent move creates a win, the AI plays to that same position first.

Center (position 4) is prioritized because it connects to all 8 other positions on the Fanorona-Telo board, making it the strongest strategic position.

You still won't see a visible change. The next substep closes the module and lets you test everything in the console.

Build the hard AI (Minimax with Alpha-Beta pruning)

The hard AI uses adversarial search to look multiple moves ahead. It assumes both players play optimally, then picks the move that leads to the best guaranteed outcome.

  • Below getMediumMove, add the heuristic evaluation function that scores non-terminal board positions:
    // Heuristic evaluation for non-terminal states
    function evaluate(board, aiPlayer) {
        const humanPlayer = aiPlayer === 1 ? 2 : 1;

        // Terminal states get extreme scores
        if (Game.checkWin(board, aiPlayer)) return 100;
        if (Game.checkWin(board, humanPlayer)) return -100;

        let score = 0;

        // Center control: +3 for AI, -3 for human
        if (board[4] === aiPlayer) score += 3;
        else if (board[4] === humanPlayer) score -= 3;

        // Two-in-a-row threats: +5 for AI threats, -5 for human threats
        for (let i = 0; i < Game.WIN_LINES.length; i++) {
            const [a, b, c] = Game.WIN_LINES[i];
            const line = [board[a], board[b], board[c]];
            const aiCount = line.filter(x => x === aiPlayer).length;
            const humanCount = line.filter(x => x === humanPlayer).length;
            const emptyCount = line.filter(x => x === null).length;

            if (aiCount === 2 && emptyCount === 1) score += 5;
            if (humanCount === 2 && emptyCount === 1) score -= 5;
        }

        // Mobility: difference in available moves
        const aiMobility = Game.getLegalMoves(board, aiPlayer, 2, { 1: 3, 2: 3 }).length;
        const humanMobility = Game.getLegalMoves(board, humanPlayer, 2, { 1: 3, 2: 3 }).length;
        score += (aiMobility - humanMobility);

        return score;
    }

Why does the AI need a heuristic?

When the search reaches its depth limit without finding a win or loss, it needs a way to compare board positions. The heuristic assigns a numeric score based on three factors.

  • Center control (+3/-3): position 4 connects to all others, so holding it is strategically dominant.
  • Alignment threats (+5/-5): having 2 pieces in a winning line with the third spot empty means you are one move from winning.
  • Mobility (+1/-1 per move): more available moves means more flexibility and fewer dead ends.

The evaluate function is called by the minimax search below whenever it hits its depth limit.

  • Below evaluate, add the minimax search, the hard move dispatcher, the public getMove function, and close the module:
    // Minimax with alpha-beta pruning
    function minimax(board, depth, alpha, beta, isMaximizing, aiPlayer, phase, piecesPlaced) {
        nodesExplored++;
        const humanPlayer = aiPlayer === 1 ? 2 : 1;

        // Terminal checks with depth bonus (prefer faster wins)
        if (Game.checkWin(board, aiPlayer)) return 100 - (6 - depth);
        if (Game.checkWin(board, humanPlayer)) return -100 + (6 - depth);

        // Depth limit reached: use heuristic
        if (depth === 0) return evaluate(board, aiPlayer);

        const currentPlayer = isMaximizing ? aiPlayer : humanPlayer;
        const currentPhase = (piecesPlaced[1] >= 3 && piecesPlaced[2] >= 3) ? 2 : phase;
        const moves = Game.getLegalMoves(board, currentPlayer, currentPhase, piecesPlaced);

        if (moves.length === 0) return evaluate(board, aiPlayer);

        if (isMaximizing) {
            let maxEval = -Infinity;
            for (let i = 0; i < moves.length; i++) {
                const newBoard = Game.simulateMove(board, moves[i], currentPlayer);
                const newPiecesPlaced = { ...piecesPlaced };
                if (moves[i].type === 'place') {
                    newPiecesPlaced[currentPlayer]++;
                }
                const evalScore = minimax(newBoard, depth - 1, alpha, beta, false, aiPlayer, phase, newPiecesPlaced);
                maxEval = Math.max(maxEval, evalScore);
                alpha = Math.max(alpha, evalScore);
                if (beta <= alpha) break;
            }
            return maxEval;
        } else {
            let minEval = Infinity;
            for (let i = 0; i < moves.length; i++) {
                const newBoard = Game.simulateMove(board, moves[i], currentPlayer);
                const newPiecesPlaced = { ...piecesPlaced };
                if (moves[i].type === 'place') {
                    newPiecesPlaced[currentPlayer]++;
                }
                const evalScore = minimax(newBoard, depth - 1, alpha, beta, true, aiPlayer, phase, newPP);
                minEval = Math.min(minEval, evalScore);
                beta = Math.min(beta, evalScore);
                if (beta <= alpha) break;
            }
            return minEval;
        }
    }

    // Hard AI: uses minimax to find the best move
    function getHardMove(state) {
        nodesExplored = 0;
        const aiPlayer = state.currentPlayer;
        const moves = Game.getLegalMoves(state.board, aiPlayer, state.phase, state.piecesPlaced);
        if (moves.length === 0) return null;

        // Deeper search in placement phase (fewer moves), shallower in movement
        const maxDepth = state.phase === 1 ? 8 : 6;
        let bestMove = moves[0];
        let bestScore = -Infinity;

        for (let i = 0; i < moves.length; i++) {
            const newBoard = Game.simulateMove(state.board, moves[i], aiPlayer);
            const newPiecesPlaced = { ...state.piecesPlaced };
            if (moves[i].type === 'place') {
                newPiecesPlaced[aiPlayer]++;
            }
            const score = minimax(newBoard, maxDepth - 1, -Infinity, Infinity, false, aiPlayer, state.phase, newPiecesPlaced);
            if (score > bestScore) {
                bestScore = score;
                bestMove = moves[i];
            }
        }

        return bestMove;
    }

    // Public dispatcher: routes to the correct difficulty
    function getMove(state, difficulty) {
        nodesExplored = 0;
        switch (difficulty) {
            case 'easy': return getRandomMove(state);
            case 'medium': return getMediumMove(state);
            case 'hard': return getHardMove(state);
            default: return getRandomMove(state);
        }
    }

    return {
        getMove,
        getNodesExplored
    };
})();

How does Minimax with Alpha-Beta pruning work?

Minimax explores a tree of all possible future moves. The maximizing player (AI) picks the move with the highest score. The minimizing player (human) picks the lowest. This assumes optimal play from both sides.

Alpha-Beta pruning skips branches that cannot possibly influence the final decision. When beta <= alpha, the current branch is "pruned" because a better option already exists elsewhere in the tree. This dramatically reduces the number of nodes explored.

The depth bonus (100 - (6 - depth) for wins) makes the AI prefer faster wins and slower losses. A win found at depth 5 scores higher than one found at depth 2, encouraging aggressive play.

Why different depths for each phase?

In Phase 1 (placement), at most 6 total placements remain. A depth of 8 guarantees the AI searches the entire placement tree.

In Phase 2 (movement), the game can theoretically loop forever as pieces slide back and forth. Limiting depth to 6 prevents the search from running too long while still looking far enough ahead to play strategically.

  • Save js/ai.js.
  • Open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
  • Test the AI by running these commands in the console:
Game.init();
AI.getMove(Game.getState(), 'easy');

You should see an object like {type: 'place', to: 3} (the exact position will vary since Easy mode is random). This confirms the AI module loaded and can communicate with the Game module.

Seeing 'AI is not defined' in the console?

Check that your IIFE closing is correct. The file must end with })(); on its own line. A missing parenthesis or semicolon will prevent the module from loading.

Also check for syntax errors earlier in the file. The console's error message will show the line number. Compare that section with the code above.

Help me debug my AI module

✔️ Awesome, I've got everything!

Great. Double check you have saved js/ai.js and your console test returned a move object.

ⓧ I'd like to double check the full code

'use strict';

const AI = (function () {
    let nodesExplored = 0;

    function getNodesExplored() {
        return nodesExplored;
    }

    function getRandomMove(state) {
        const moves = Game.getLegalMoves(state.board, state.currentPlayer, state.phase, state.piecesPlaced);
        if (moves.length === 0) return null;
        return moves[Math.floor(Math.random() * moves.length)];
    }

    function getMediumMove(state) {
        const player = state.currentPlayer;
        const opponent = player === 1 ? 2 : 1;
        const moves = Game.getLegalMoves(state.board, player, state.phase, state.piecesPlaced);
        if (moves.length === 0) return null;

        for (let i = 0; i < moves.length; i++) {
            const newBoard = Game.simulateMove(state.board, moves[i], player);
            if (Game.checkWin(newBoard, player)) {
                return moves[i];
            }
        }

        const opponentMoves = Game.getLegalMoves(state.board, opponent, state.phase, state.piecesPlaced);
        for (let i = 0; i < opponentMoves.length; i++) {
            const newBoard = Game.simulateMove(state.board, opponentMoves[i], opponent);
            if (Game.checkWin(newBoard, opponent)) {
                for (let j = 0; j < moves.length; j++) {
                    if (moves[j].to === opponentMoves[i].to) {
                        return moves[j];
                    }
                }
            }
        }

        const centerMoves = moves.filter(m => m.to === 4);
        if (centerMoves.length > 0) return centerMoves[0];

        return moves[Math.floor(Math.random() * moves.length)];
    }

    function evaluate(board, aiPlayer) {
        const humanPlayer = aiPlayer === 1 ? 2 : 1;

        if (Game.checkWin(board, aiPlayer)) return 100;
        if (Game.checkWin(board, humanPlayer)) return -100;

        let score = 0;

        if (board[4] === aiPlayer) score += 3;
        else if (board[4] === humanPlayer) score -= 3;

        for (let i = 0; i < Game.WIN_LINES.length; i++) {
            const [a, b, c] = Game.WIN_LINES[i];
            const line = [board[a], board[b], board[c]];
            const aiCount = line.filter(x => x === aiPlayer).length;
            const humanCount = line.filter(x => x === humanPlayer).length;
            const emptyCount = line.filter(x => x === null).length;

            if (aiCount === 2 && emptyCount === 1) score += 5;
            if (humanCount === 2 && emptyCount === 1) score -= 5;
        }

        const aiMobility = Game.getLegalMoves(board, aiPlayer, 2, { 1: 3, 2: 3 }).length;
        const humanMobility = Game.getLegalMoves(board, humanPlayer, 2, { 1: 3, 2: 3 }).length;
        score += (aiMobility - humanMobility);

        return score;
    }

    function minimax(board, depth, alpha, beta, isMaximizing, aiPlayer, phase, piecesPlaced) {
        nodesExplored++;
        const humanPlayer = aiPlayer === 1 ? 2 : 1;

        if (Game.checkWin(board, aiPlayer)) return 100 - (6 - depth);
        if (Game.checkWin(board, humanPlayer)) return -100 + (6 - depth);

        if (depth === 0) return evaluate(board, aiPlayer);

        const currentPlayer = isMaximizing ? aiPlayer : humanPlayer;
        const currentPhase = (piecesPlaced[1] >= 3 && piecesPlaced[2] >= 3) ? 2 : phase;
        const moves = Game.getLegalMoves(board, currentPlayer, currentPhase, piecesPlaced);

        if (moves.length === 0) return evaluate(board, aiPlayer);

        if (isMaximizing) {
            let maxEval = -Infinity;
            for (let i = 0; i < moves.length; i++) {
                const newBoard = Game.simulateMove(board, moves[i], currentPlayer);
                const newPiecesPlaced = { ...piecesPlaced };
                if (moves[i].type === 'place') {
                    newPiecesPlaced[currentPlayer]++;
                }
                const evalScore = minimax(newBoard, depth - 1, alpha, beta, false, aiPlayer, phase, newPiecesPlaced);
                maxEval = Math.max(maxEval, evalScore);
                alpha = Math.max(alpha, evalScore);
                if (beta <= alpha) break;
            }
            return maxEval;
        } else {
            let minEval = Infinity;
            for (let i = 0; i < moves.length; i++) {
                const newBoard = Game.simulateMove(board, moves[i], currentPlayer);
                const newPiecesPlaced = { ...piecesPlaced };
                if (moves[i].type === 'place') {
                    newPiecesPlaced[currentPlayer]++;
                }
                const evalScore = minimax(newBoard, depth - 1, alpha, beta, true, aiPlayer, phase, newPiecesPlaced);
                minEval = Math.min(minEval, evalScore);
                beta = Math.min(beta, evalScore);
                if (beta <= alpha) break;
            }
            return minEval;
        }
    }

    function getHardMove(state) {
        nodesExplored = 0;
        const aiPlayer = state.currentPlayer;
        const moves = Game.getLegalMoves(state.board, aiPlayer, state.phase, state.piecesPlaced);
        if (moves.length === 0) return null;

        const maxDepth = state.phase === 1 ? 8 : 6;
        let bestMove = moves[0];
        let bestScore = -Infinity;

        for (let i = 0; i < moves.length; i++) {
            const newBoard = Game.simulateMove(state.board, moves[i], aiPlayer);
            const newPiecesPlaced = { ...state.piecesPlaced };
            if (moves[i].type === 'place') {
                newPiecesPlaced[aiPlayer]++;
            }
            const score = minimax(newBoard, maxDepth - 1, -Infinity, Infinity, false, aiPlayer, state.phase, newPiecesPlaced);
            if (score > bestScore) {
                bestScore = score;
                bestMove = moves[i];
            }
        }

        return bestMove;
    }

    function getMove(state, difficulty) {
        nodesExplored = 0;
        switch (difficulty) {
            case 'easy': return getRandomMove(state);
            case 'medium': return getMediumMove(state);
            case 'hard': return getHardMove(state);
            default: return getRandomMove(state);
        }
    }

    return {
        getMove,
        getNodesExplored
    };
})();

Your AI engine is complete with three distinct difficulty levels. Next up, you will wire the UI interactions, animations, and game modes together so the AI can actually play against a human on the board.

Wire Up UI, Animations, and Game Modes

Your game engine knows the rules, your AI can think several moves ahead, and your UI module can draw pieces on the board. The missing piece is the conductor that connects them all together.

In this step, you will write the application controller in js/main.js. This module wires click events to game logic, triggers AI responses after human moves, orchestrates smooth CSS animations, and manages the three game modes (Human vs Human, Human vs AI, AI vs AI).

In this step, get ready to:
  • Wire interaction handlers that translate board clicks into game moves with animated feedback.
  • Implement a game mode controller that manages Human vs Human, Human vs AI, and AI vs AI demo play.
  • Deploy the finished game to GitHub Pages as a live static site.
Set up the App controller and initialization logic

The App module is an IIFE (Immediately Invoked Function Expression) that acts as the application's main controller. It holds private state, grabs DOM references, and exposes an init() function that bootstraps everything when the page loads.

  • In VS Code, open js/main.js and paste the following code:
'use strict';

const App = (function () {
    // Private state variables
    let gameMode = 'hva';
    let aiDifficulty = 'hard';
    let isProcessing = false;
    let demoInterval = null;
    let selectedPieceIndex = null;

    // Cache DOM element references
    const modeSelect = document.getElementById('game-mode');
    const difficultySelect = document.getElementById('ai-difficulty');
    const btnNew = document.getElementById('btn-new');
    const btnUndo = document.getElementById('btn-undo');
    const btnRedo = document.getElementById('btn-redo');
    const btnStop = document.getElementById('btn-stop');

    function init() {
        Game.init();
        UI.init({
            onIntersectionClick: handleIntersectionClick,
            onPieceClick: handlePieceClick
        });

        modeSelect.addEventListener('change', (e) => {
            gameMode = e.target.value;
            newGame();
        });
        difficultySelect.addEventListener('change', (e) => {
            aiDifficulty = e.target.value;
        });
        btnNew.addEventListener('click', newGame);
        btnUndo.addEventListener('click', handleUndo);
        btnRedo.addEventListener('click', handleRedo);
        btnStop.addEventListener('click', stopDemo);

        newGame();
    }

    function newGame() {
        stopDemo();
        Game.init();
        selectedPieceIndex = null;
        isProcessing = false;
        UI.clearHighlights();
        UI.resetPerf();
        updateUI();

        if (gameMode === 'ava') {
            startDemo();
        }
    }

    function updateUI() {
        const state = Game.getState();
        UI.render(state);
        updateStatus();
        btnUndo.disabled = !Game.canUndo();
        btnRedo.disabled = !Game.canRedo();
        btnStop.classList.toggle('hidden', gameMode !== 'ava');
    }

    function updateStatus() {
        const state = Game.getState();
        if (state.winner) {
            const winnerName = getPlayerName(state.winner);
            UI.setStatus(winnerName + ' gagne !');
            return;
        }
        const playerName = getPlayerName(state.currentPlayer);
        const phaseText = state.phase === 1 ? 'Phase de placement' : 'Phase de deplacement';
        UI.setStatus(playerName + ' - ' + phaseText);
    }

    function getPlayerName(player) {
        if (gameMode === 'hvh') {
            return 'Joueur ' + player;
        }
        if (gameMode === 'hva') {
            return player === 1 ? 'Humain' : 'IA';
        }
        return 'IA ' + player;
    }

What does this code do?

This is the top half of the App module. It sets up the foundation:

  • gameMode tracks which of the 3 modes is active. isProcessing prevents clicks while an animation or AI computation is in progress.
  • init() bootstraps the Game and UI modules, wires all event listeners, and starts a new game.
  • newGame() resets every piece of state and kicks off a demo if AI vs AI mode is selected.
  • updateStatus() reads the game state and displays the current player name and phase in the status bar.

Do not save yet. You will add the remaining functions before the file is complete.

Handle player interactions

The UI module already renders the board and fires callbacks when pieces or intersections are clicked. Now you need the functions that respond to those clicks, validate the move, and trigger the correct animation.

  • Directly below the getPlayerName function, paste the following interaction handlers:
    function handleIntersectionClick(index) {
        if (isProcessing || Game.isGameOver()) return;
        const state = Game.getState();

        // Block clicks when it's the AI's turn
        if (gameMode === 'hva' && state.currentPlayer === 2) return;

        if (state.phase === 1) {
            // Placement phase: click empty intersection to place
            if (state.board[index] !== null) return;
            const move = { type: 'place', to: index };
            executeHumanMove(move);
        } else if (state.phase === 2 && selectedPieceIndex !== null) {
            // Movement phase: click adjacent empty intersection to move
            if (state.board[index] !== null) return;
            const adj = Game.ADJACENCY[selectedPieceIndex];
            if (!adj.includes(index)) return;
            const move = { type: 'move', from: selectedPieceIndex, to: index };
            selectedPieceIndex = null;
            UI.clearHighlights();
            executeMoveWithAnimation(move);
        }
    }

    function handlePieceClick(index) {
        if (isProcessing || Game.isGameOver()) return;
        const state = Game.getState();

        if (gameMode === 'hva' && state.currentPlayer === 2) return;
        if (state.phase !== 2) return;
        if (state.board[index] !== state.currentPlayer) return;

        // Select this piece and highlight its valid destinations
        selectedPieceIndex = index;
        const stateWithSelection = Game.getState();
        stateWithSelection.selectedPiece = index;
        UI.render(stateWithSelection);

        const adj = Game.ADJACENCY[index];
        const validTargets = adj.filter(i => state.board[i] === null);
        UI.highlightValidTargets(validTargets);
    }

    function executeHumanMove(move) {
        const result = Game.applyMove(move);
        if (!result.success) return;

        if (move.type === 'place') {
            const state = Game.getState();
            UI.renderWithPlaceAnimation(state, move.to);
        }

        afterMove(result);
    }

    function executeMoveWithAnimation(move) {
        isProcessing = true;
        const result = Game.applyMove(move);
        if (!result.success) {
            isProcessing = false;
            return;
        }

        const state = Game.getState();
        UI.renderWithMoveAnimation(state, move.from, move.to, () => {
            isProcessing = false;
            afterMove(result);
        });
    }

What does this code do?

These four functions form the interaction layer:

  • handleIntersectionClick handles both phases. In placement, it places a piece. In movement, it moves the previously selected piece to the clicked intersection (only if it is adjacent per the adjacency graph).
  • handlePieceClick selects a piece in Phase 2 and highlights its valid movement targets with a pulsing yellow animation.
  • executeMoveWithAnimation sets isProcessing = true to block further input during the 300ms slide animation, then calls afterMove once the animation completes.
Wire up AI response, demo mode, and undo/redo

The final section ties the game modes together. After every human move, the controller checks if the AI should respond. In demo mode, two AI players take turns automatically with visible delays.

  • Below executeMoveWithAnimation, paste the game flow logic and close the IIFE:
    function afterMove(result) {
        updateStatus();
        btnUndo.disabled = !Game.canUndo();
        btnRedo.disabled = !Game.canRedo();

        if (result.win) {
            const state = Game.getState();
            UI.showWin(result.winLine, state);
            return;
        }

        // In Human vs AI mode, trigger AI response after human moves
        if (gameMode === 'hva' && !Game.isGameOver()) {
            const state = Game.getState();
            if (state.currentPlayer === 2) {
                isProcessing = true;
                setTimeout(() => playAI(), 300);
            }
        }
    }

    function playAI() {
        const state = Game.getState();
        const startTime = performance.now();
        const move = AI.getMove(state, aiDifficulty);
        const endTime = performance.now();
        const elapsed = endTime - startTime;

        // Display computation metrics in the performance panel
        UI.updatePerf(elapsed, AI.getNodesExplored());

        if (!move) {
            isProcessing = false;
            return;
        }

        const result = Game.applyMove(move);
        if (!result.success) {
            isProcessing = false;
            return;
        }

        const newState = Game.getState();
        if (move.type === 'place') {
            UI.renderWithPlaceAnimation(newState, move.to);
            isProcessing = false;
            afterMove(result);
        } else {
            UI.renderWithMoveAnimation(newState, move.from, move.to, () => {
                isProcessing = false;
                afterMove(result);
            });
        }
    }

    function startDemo() {
        btnStop.classList.remove('hidden');
        isProcessing = true;
        playDemoTurn();
    }

    function playDemoTurn() {
        if (Game.isGameOver()) {
            isProcessing = false;
            return;
        }

        const state = Game.getState();
        const startTime = performance.now();
        const move = AI.getMove(state, aiDifficulty);
        const endTime = performance.now();

        UI.updatePerf(endTime - startTime, AI.getNodesExplored());

        if (!move) {
            isProcessing = false;
            return;
        }

        const result = Game.applyMove(move);
        if (!result.success) {
            isProcessing = false;
            return;
        }

        const newState = Game.getState();
        if (move.type === 'place') {
            UI.renderWithPlaceAnimation(newState, move.to);
            updateStatus();
            btnUndo.disabled = !Game.canUndo();

            if (result.win) {
                UI.showWin(result.winLine, newState);
                isProcessing = false;
                return;
            }

            // Wait 800ms before next demo move for visibility
            demoInterval = setTimeout(playDemoTurn, 800);
        } else {
            UI.renderWithMoveAnimation(newState, move.from, move.to, () => {
                updateStatus();
                btnUndo.disabled = !Game.canUndo();

                if (result.win) {
                    UI.showWin(result.winLine, newState);
                    isProcessing = false;
                    return;
                }

                demoInterval = setTimeout(playDemoTurn, 800);
            });
        }
    }

    function stopDemo() {
        if (demoInterval) {
            clearTimeout(demoInterval);
            demoInterval = null;
        }
        isProcessing = false;
        btnStop.classList.add('hidden');
    }

    function handleUndo() {
        if (isProcessing || gameMode === 'ava') return;
        if (Game.undo()) {
            selectedPieceIndex = null;
            UI.clearHighlights();
            updateUI();
            // In HvA mode, undo both the AI move and the human move
            if (gameMode === 'hva' && Game.getState().currentPlayer === 2) {
                Game.undo();
                updateUI();
            }
        }
    }

    function handleRedo() {
        if (isProcessing || gameMode === 'ava') return;
        if (Game.redo()) {
            selectedPieceIndex = null;
            UI.clearHighlights();
            updateUI();
        }
    }

    document.addEventListener('DOMContentLoaded', init);

    return { init, newGame };
})();

What does this code do?

This is the game flow engine:

  • afterMove() runs after every move. If someone won, it triggers the pulsing win animation. In Human vs AI mode, it schedules the AI's response with a 300ms delay so the UI has time to update.
  • playAI() measures computation time with performance.now() and updates the performance panel with milliseconds elapsed and nodes explored.
  • playDemoTurn() runs recursively with 800ms setTimeout delays so you can watch both AIs play. The stopDemo() function clears the timeout and halts the loop.
  • handleUndo() in Human vs AI mode calls Game.undo() twice to revert both the AI's move and your own, bringing you back to your last decision point.
  • Save js/main.js.
  • Return to your browser. The game should immediately start in Human vs AI mode. Click any intersection on the board to place your first piece.

You should see your piece appear with a scale-in animation. After about 300ms, the AI places its piece.

Board not responding to clicks?

  • Open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Check for any JavaScript errors. A typo in a function name will prevent the module from loading.
  • Confirm the script tags in index.html load in this exact order: game.js, ai.js, ui.js, main.js. Each module depends on the one before it.
  • Make sure you have the DOMContentLoaded event listener at the bottom of your App IIFE. Without it, init() never fires.

Help me debug why my Fanorona-Telo board is not responding to clicks.

  • Test all three modes: switch the Mode dropdown to Humain vs Humain and play a few moves alternating clicks.
  • Switch to IA vs IA (Demo) and watch both AIs play automatically with visible pauses between moves.
  • Switch back to Humain vs IA and verify the performance panel updates after each AI move (showing time in ms and nodes explored).
  • Click Annuler a few times to confirm undo reverts both your move and the AI's response.

How do the animations work?

The animations are driven entirely by CSS, triggered by class names that the UI module adds:

  • Placement: adding the placing class triggers a scale(0) to scale(1) keyframe animation (200ms).
  • Movement: updating transform: translate3d() on the piece element triggers a GPU-accelerated CSS transition (300ms ease-in-out).
  • Win highlight: the winning class applies an infinite pulsing keyframe that scales the winning pieces up and brightens them.

✔️ Awesome, I've got everything!

Great. Make sure you have saved js/main.js and confirmed all three game modes work.

ⓧ I'd like to double check the full code

Here is the complete js/main.js file:

'use strict';

const App = (function () {
    let gameMode = 'hva';
    let aiDifficulty = 'hard';
    let isProcessing = false;
    let demoInterval = null;
    let selectedPieceIndex = null;

    const modeSelect = document.getElementById('game-mode');
    const difficultySelect = document.getElementById('ai-difficulty');
    const btnNew = document.getElementById('btn-new');
    const btnUndo = document.getElementById('btn-undo');
    const btnRedo = document.getElementById('btn-redo');
    const btnStop = document.getElementById('btn-stop');

    function init() {
        Game.init();
        UI.init({
            onIntersectionClick: handleIntersectionClick,
            onPieceClick: handlePieceClick
        });

        modeSelect.addEventListener('change', (e) => {
            gameMode = e.target.value;
            newGame();
        });
        difficultySelect.addEventListener('change', (e) => {
            aiDifficulty = e.target.value;
        });
        btnNew.addEventListener('click', newGame);
        btnUndo.addEventListener('click', handleUndo);
        btnRedo.addEventListener('click', handleRedo);
        btnStop.addEventListener('click', stopDemo);

        newGame();
    }

    function newGame() {
        stopDemo();
        Game.init();
        selectedPieceIndex = null;
        isProcessing = false;
        UI.clearHighlights();
        UI.resetPerf();
        updateUI();

        if (gameMode === 'ava') {
            startDemo();
        }
    }

    function updateUI() {
        const state = Game.getState();
        UI.render(state);
        updateStatus();
        btnUndo.disabled = !Game.canUndo();
        btnRedo.disabled = !Game.canRedo();
        btnStop.classList.toggle('hidden', gameMode !== 'ava');
    }

    function updateStatus() {
        const state = Game.getState();
        if (state.winner) {
            const winnerName = getPlayerName(state.winner);
            UI.setStatus(winnerName + ' gagne !');
            return;
        }
        const playerName = getPlayerName(state.currentPlayer);
        const phaseText = state.phase === 1 ? 'Phase de placement' : 'Phase de deplacement';
        UI.setStatus(playerName + ' - ' + phaseText);
    }

    function getPlayerName(player) {
        if (gameMode === 'hvh') {
            return 'Joueur ' + player;
        }
        if (gameMode === 'hva') {
            return player === 1 ? 'Humain' : 'IA';
        }
        return 'IA ' + player;
    }

    function handleIntersectionClick(index) {
        if (isProcessing || Game.isGameOver()) return;
        const state = Game.getState();

        if (gameMode === 'hva' && state.currentPlayer === 2) return;

        if (state.phase === 1) {
            if (state.board[index] !== null) return;
            const move = { type: 'place', to: index };
            executeHumanMove(move);
        } else if (state.phase === 2 && selectedPieceIndex !== null) {
            if (state.board[index] !== null) return;
            const adj = Game.ADJACENCY[selectedPieceIndex];
            if (!adj.includes(index)) return;
            const move = { type: 'move', from: selectedPieceIndex, to: index };
            selectedPieceIndex = null;
            UI.clearHighlights();
            executeMoveWithAnimation(move);
        }
    }

    function handlePieceClick(index) {
        if (isProcessing || Game.isGameOver()) return;
        const state = Game.getState();

        if (gameMode === 'hva' && state.currentPlayer === 2) return;
        if (state.phase !== 2) return;
        if (state.board[index] !== state.currentPlayer) return;

        selectedPieceIndex = index;
        const stateWithSelection = Game.getState();
        stateWithSelection.selectedPiece = index;
        UI.render(stateWithSelection);

        const adj = Game.ADJACENCY[index];
        const validTargets = adj.filter(i => state.board[i] === null);
        UI.highlightValidTargets(validTargets);
    }

    function executeHumanMove(move) {
        const result = Game.applyMove(move);
        if (!result.success) return;

        if (move.type === 'place') {
            const state = Game.getState();
            UI.renderWithPlaceAnimation(state, move.to);
        }

        afterMove(result);
    }

    function executeMoveWithAnimation(move) {
        isProcessing = true;
        const result = Game.applyMove(move);
        if (!result.success) {
            isProcessing = false;
            return;
        }

        const state = Game.getState();
        UI.renderWithMoveAnimation(state, move.from, move.to, () => {
            isProcessing = false;
            afterMove(result);
        });
    }

    function afterMove(result) {
        updateStatus();
        btnUndo.disabled = !Game.canUndo();
        btnRedo.disabled = !Game.canRedo();

        if (result.win) {
            const state = Game.getState();
            UI.showWin(result.winLine, state);
            return;
        }

        if (gameMode === 'hva' && !Game.isGameOver()) {
            const state = Game.getState();
            if (state.currentPlayer === 2) {
                isProcessing = true;
                setTimeout(() => playAI(), 300);
            }
        }
    }

    function playAI() {
        const state = Game.getState();
        const startTime = performance.now();
        const move = AI.getMove(state, aiDifficulty);
        const endTime = performance.now();
        const elapsed = endTime - startTime;

        UI.updatePerf(elapsed, AI.getNodesExplored());

        if (!move) {
            isProcessing = false;
            return;
        }

        const result = Game.applyMove(move);
        if (!result.success) {
            isProcessing = false;
            return;
        }

        const newState = Game.getState();
        if (move.type === 'place') {
            UI.renderWithPlaceAnimation(newState, move.to);
            isProcessing = false;
            afterMove(result);
        } else {
            UI.renderWithMoveAnimation(newState, move.from, move.to, () => {
                isProcessing = false;
                afterMove(result);
            });
        }
    }

    function startDemo() {
        btnStop.classList.remove('hidden');
        isProcessing = true;
        playDemoTurn();
    }

    function playDemoTurn() {
        if (Game.isGameOver()) {
            isProcessing = false;
            return;
        }

        const state = Game.getState();
        const startTime = performance.now();
        const move = AI.getMove(state, aiDifficulty);
        const endTime = performance.now();

        UI.updatePerf(endTime - startTime, AI.getNodesExplored());

        if (!move) {
            isProcessing = false;
            return;
        }

        const result = Game.applyMove(move);
        if (!result.success) {
            isProcessing = false;
            return;
        }

        const newState = Game.getState();
        if (move.type === 'place') {
            UI.renderWithPlaceAnimation(newState, move.to);
            updateStatus();
            btnUndo.disabled = !Game.canUndo();

            if (result.win) {
                UI.showWin(result.winLine, newState);
                isProcessing = false;
                return;
            }

            demoInterval = setTimeout(playDemoTurn, 800);
        } else {
            UI.renderWithMoveAnimation(newState, move.from, move.to, () => {
                updateStatus();
                btnUndo.disabled = !Game.canUndo();

                if (result.win) {
                    UI.showWin(result.winLine, newState);
                    isProcessing = false;
                    return;
                }

                demoInterval = setTimeout(playDemoTurn, 800);
            });
        }
    }

    function stopDemo() {
        if (demoInterval) {
            clearTimeout(demoInterval);
            demoInterval = null;
        }
        isProcessing = false;
        btnStop.classList.add('hidden');
    }

    function handleUndo() {
        if (isProcessing || gameMode === 'ava') return;
        if (Game.undo()) {
            selectedPieceIndex = null;
            UI.clearHighlights();
            updateUI();
            if (gameMode === 'hva' && Game.getState().currentPlayer === 2) {
                Game.undo();
                updateUI();
            }
        }
    }

    function handleRedo() {
        if (isProcessing || gameMode === 'ava') return;
        if (Game.redo()) {
            selectedPieceIndex = null;
            UI.clearHighlights();
            updateUI();
        }
    }

    document.addEventListener('DOMContentLoaded', init);

    return { init, newGame };
})();
Commit your code and deploy to GitHub Pages

The game is fully functional. Time to push the final code to GitHub and configure GitHub Pages so anyone can play it from a public URL.

  • Stage and commit all your changes by running these commands in your terminal:
git add .
git commit -m "Complete game with AI and animations"
  • Push to your remote repository by running:
git push origin main
  • Open your repository on GitHub in your browser.
  • Click Settings in the top navigation bar.
  • In the left sidebar, click Pages (under the Code and automation section).
  • Under Build and deployment, set the Source to Deploy from a branch.
  • Select the main branch and / (root) folder, then click Save.

Why the .nojekyll file?

GitHub Pages normally processes your files through Jekyll (a static site generator). The empty .nojekyll file in your project root tells GitHub to skip that processing and serve your HTML, CSS, and JavaScript files directly.

After a minute or two, your site will be live at https://YOUR-USERNAME.github.io/fanorona-telo/. You can find the exact URL on the Pages settings page once the deployment completes.

Getting a 404 on your Pages URL?

  • Confirm index.html is at the root of your repository, not inside a subfolder.
  • Check that you selected / (root) as the folder in Pages settings (not /docs).
  • Wait 1-2 minutes for the deployment to finish. The Pages settings page shows a green checkmark once it is ready.

Help me troubleshoot my GitHub Pages 404 error for my Fanorona-Telo project.

Your Fanorona-Telo game is now live on the internet. Anyone with the link can play against the Minimax AI directly in their browser.

Secret mission

Add Game Replay

Your game is fully playable, but what if you could watch an entire match unfold again after it ends? Add a replay feature that stores every move and plays them back with synchronized animations. This requires coordinating timing, state management, and UI lockout across your entire codebase.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and deploys to GitHub Pages for free. There are no ongoing costs regardless of what you choose below.

Resources you used:

  • Local project folder fanorona-telo/ on your Desktop.
  • GitHub repository with GitHub Pages enabled.

Keep everything running

No action needed. Choose this if you want to keep your Fanorona-Telo game live on GitHub Pages and continue iterating on it.

  • Your GitHub Pages site stays live indefinitely at no cost.
  • Your local fanorona-telo/ folder remains on your Desktop for future edits.

Pause - I'll come back to this later

Since this project has no running processes or paid resources, pausing is the same as keeping everything. Your GitHub Pages site stays live for free.

  • Stop Live Server in VS Code by clicking the Go Live button in the status bar (or close VS Code entirely).
  • Everything else persists at zero cost until you return.

Delete - I don't want to use this again

Remove all project resources completely.

  • Delete the local project folder. On macOS/Linux, run:
rm -rf ~/Desktop/fanorona-telo

On Windows, run:

Remove-Item -Recurse -Force ~\Desktop\fanorona-telo
  • Delete the GitHub repository to remove both the repo and its GitHub Pages site.
  • Go to your repository page on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll to the bottom of the General page until you see the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

You just built a fully playable Fanorona-Telo game from scratch, complete with a graph-based game engine, a 3-level AI opponent, and smooth CSS animations. The entire application runs in the browser with zero dependencies.

You've learned how to:

  • Build a complete Fanorona-Telo game engine with strict graph-based adjacency rules, two-phase gameplay (placement and movement), and full undo/redo state management.
  • Implement 3 AI difficulty levels including Minimax with Alpha-Beta pruning and a heuristic evaluation function that scores center control, alignment threats, and mobility.
  • Create a polished, responsive UI with GPU-accelerated CSS animations for piece placement and movement, and deploy it as a zero-dependency SPA on GitHub Pages.
  • Secret Mission: Added an automated game replay feature that replays every move sequentially with synchronized animations after a win.

Ready to quiz yourself?