This component was built as a small experiment in combining a complete game-tree search with a reactive user interface. Tic-tac-toe is simple enough that every possible board state can be explored, so the minimax algorithm fits naturally. The intention was to keep the logic straightforward and self-contained, while letting LemonadeJS handle UI updates without additional abstractions or complex lifecycle handling.
I have chosen to keep minimax free of shared mutable state and avoid mutating the board during evaluation. Many examples modify the board in place and then undo moves later, but this easily introduces subtle bugs, especially once each cell starts holding more properties. Since the tic-tac-toe board is only nine cells, cloning before each simulated move is cheap and keeps each branch isolated. A minimal helper for this looks like:
const cloneBoard = (board) => {
return board.map(cell => ({ ...cell }));
};
Minimax evaluates terminal positions, alternates between maximizing and minimizing layers, and returns scores that reflect wins, losses, and draws. At the root depth, it also returns the best move, so the same function is used for both simulation and decision selection. Around this core, the component defines helpers for detecting winners, applying moves, handling ties, and checking for valid positions. LemonadeJS takes care of rendering changes automatically whenever properties like this.board, this.player, or this.text update, using a simple template that loops over the board and binds click events.
The final code keeps logic and rendering clearly separated. LemonadeJS provides the reactive surface, and minimax supplies consistent game behaviour. Together, they form a compact example of how a deterministic algorithm and a small reactive framework can work side by side without introducing unnecessary structure. The full implementation used in this setup is shown below.
If you are interested in playing with the code, the next logical step is to create a level dropdown so users can choose between Easy, Medium, or Hard, to make the game not too impossible to win.
Full implementation
Install Lemoandejs
npm install lemonadejs
Source code
import lemonade from 'lemonadejs';
import './style.css';
function Tictactoe() {
let text = [
'can start the game',
'turn to play',
'won the game',
'no winners this time',
];
const cloneBoard = (board) => {
return board.map((cell) => ({ ...cell }));
};
const getValidMovesFromBoard = (board) => {
let data = [];
board.forEach((b, index) => {
if (!b.player) {
data.push(index);
}
});
return data;
};
const minimax = (board, depth, isMaximizing, currentPlayer) => {
// Terminal state?
if (hasWinner(board)) {
const score = isMaximizing ? -10 + depth : 10 - depth;
return depth === 0 ? { score, move: -1 } : score;
}
const validMoves = getValidMovesFromBoard(board);
if (!validMoves.length) {
return depth === 0 ? { score: 0, move: -1 } : 0; // Draw
}
const opponent = currentPlayer === 'o' ? 'x' : 'o';
let bestScore = isMaximizing ? -Infinity : Infinity;
let bestMove = -1;
for (const move of validMoves) {
// Clone board and apply move
const newBoard = cloneBoard(board);
newBoard[move].player = currentPlayer;
// Recursion
const score = minimax(newBoard, depth + 1, !isMaximizing, opponent);
// Compare
if (isMaximizing) {
if (score > bestScore) {
bestScore = score;
bestMove = move;
}
} else {
if (score < bestScore) {
bestScore = score;
bestMove = move;
}
}
}
return depth === 0 ? { score: bestScore, move: bestMove } : bestScore;
};
// Check the board to see if there is a winner
const hasWinner = (board) => {
return [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8], // Rows
[0, 3, 6],
[1, 4, 7],
[2, 5, 8], // Columns
[0, 4, 8],
[2, 4, 6], // Diagonals
].some(
([a, b, c]) =>
board[a].player &&
board[a].player === board[b].player &&
board[b].player === board[c].player
);
};
const play = (index) => {
// Selected the board
this.board[index].player = this.player;
// Update instructions
this.text = text[1];
// Is there a winner?
if (hasWinner(this.board)) {
// Switch the text element to refers the winner.
this.text = text[2];
// There is a winner
this.winner = true;
// We have a winner
alert(this.title.textContent);
} else {
// Switch player's turn.
this.player = this.player === 'o' ? 'x' : 'o';
}
};
const tie = () => {
// Tie
this.text = text[3];
// No player
this.player = '';
// It is a tie
alert(this.title.textContent);
};
/**
* Click event handler
* @param {MouseEvent} e
*/
const click = (e) => {
// Valid item to be clicked - only SPAN
if (e.target.tagName === 'SPAN') {
if (this.winner) {
alert(this.title.textContent);
} else {
// No one picked the position yet
if (!e.target.textContent) {
// Get the position of the element clicked
let index = Array.prototype.indexOf.call(
e.target.parentNode.children,
e.target
);
// User Play
play(index);
// Computer turn
if (!hasWinner(this.board)) {
setTimeout(() => {
let result = minimax(this.board, 0, true, this.player);
if (result.move !== -1) {
play(result.move);
} else {
tie();
}
}, 200);
}
}
}
}
};
/**
* Reset the game variables
*/
const reset = () => {
// Player 0 (o) and Player 1 (x)
this.player = 'o';
// Update the instruction to the user
this.text = text[0];
// Property to define if already reached a winner.
this.winner = false;
// Reset the board with the 9 positions
this.board = [{}, {}, {}, {}, {}, {}, {}, {}, {}];
};
// Start the game
reset();
// Game template
return (render) => render`<div class="tictactoe">
<h4 :ref="this.title">${this.player} ${this.text}</h4>
<div lm-loop="${this.board}" class="board" onclick=${click}>
<span>{{this.player}}</span>
</div><br/>
<input type="button" onclick=${reset} value="Reset the game" />
</div>`;
}
lemonade.render(Tictactoe, document.querySelector('#app'));
Play Now
https://stackblitz.com/edit/vitejs-vite-akskxpkr
Resources
More tools and information:
Top comments (0)