- Make intelligent moves (not just random ones)
- Try to win if there’s a winning move available
- Block the player if they're about to win
- Make the next best move if no immediate threats or wins are on the board
- Respond quickly and seamlessly after the player's move
Let’s face it - playing Tic-Tac-Toe solo without an opponent is like dancing alone at a party. Sure, you can do it, but it’s a lot more fun when someone (or something) is trying to beat you. In this part of our series, we’re stepping up our game - literally - by adding a CPU opponent that can challenge the player. Yep, your humble little LWC Tic-Tac-Toe app is about to get a lot smarter.
If you've been following along, you already have a fully functioning game board, move tracking, victory detection, and a UI that's pretty sleek. Now it's time to make our app think for itself. Get ready to dive into the logic and component changes that enable the CPU to make strategic moves and keep the pressure on.
The Big Picture: What We’re Building
Before diving into the code, here’s what we want our CPU opponent to do:
This isn't Deep Blue. It's more like "Mildly Clever Yellow" - but that’s good enough for Tic-Tac-Toe.
New Module: aiService.js
First things first - we need a dedicated service to calculate the CPU's move. Here's the new file we’re introducing:
import { calculateWinner } from 'c/gameLogic';
export function getBestMove(cells, cpuSide, playerSide) {
// Try to win: Check if the CPU can win in the next move
for (let i = 0; i < cells.length; i++) {
if (!cells[i].value) { // If the cell is empty
const clone = [...cells]; // Create a copy of the current board state
clone[i] = { ...clone[i], value: cpuSide }; // Simulate the CPU making a move
if (calculateWinner(clone)) return i; // If this move results in a win, return the index
}
}
// Block opponent: Check if the player can win in the next move and block them
for (let i = 0; i < cells.length; i++) {
if (!cells[i].value) { // If the cell is empty
const clone = [...cells]; // Create a copy of the current board state
clone[i] = { ...clone[i], value: playerSide }; // Simulate the player making a move
if (calculateWinner(clone)) return i; // If this move results in a win for the player, return the index
}
}
// Pick first empty: If no winning or blocking move is found, pick the first available cell
return cells.find(cell => !cell.value).id; // Find the first empty cell and return its id
}
What’s going on here?
- We simulate each possible move the CPU could make to see if it wins
- If no win is found, we simulate the player’s moves to block them
- Still nothing? We just take the first available cell. Classic “easy mode” strategy
Updating Game Logic: gameContainer.js
Here’s where most of the magic happens. We’ve updated gameContainer
to support CPU logic, handle delays between turns, and maintain a smooth experience. Below is the full updated file, followed by a breakdown of what changed.
import {api, LightningElement, track} from 'lwc';
import {calculateWinner, generateInitialBoard, isDraw} from 'c/gameLogic';
import {getBestMove} from 'c/aiService';
export default class GameContainer extends LightningElement {
@api playerSide;
@api cpuSide;
@track history = [generateInitialBoard()];
@track stepNumber = 0;
@track winningLine = [];
@track message = 'Current Player: X';
@track gameOver = false;
currentPlayer = 'X';
// Determines if the game is against the CPU by checking if `cpuSide` is set
get isAgainstCPU() {
return !!this.cpuSide;
}
get currentCells() {
return this.history[this.stepNumber];
}
// Lifecycle hook: If the CPU is the starting player, trigger its turn after the component is initialized
connectedCallback() {
if (this.currentPlayer === this.cpuSide) {
setTimeout(() => this.cpuTurn(), 300); // Delay to simulate CPU thinking
}
}
get isUndoDisabled() {
return this.stepNumber === 0;
}
get isRedoDisabled() {
return this.stepNumber === this.history.length - 1;
}
handleCellClick(event) {
// Prevent further actions if the game is over or it's not the player's turn
if (this.gameOver || this.currentPlayer !== this.playerSide) return;
const { cellId } = event.detail;
this.makeMove(cellId, this.currentPlayer);
// If the game is against the CPU and it's now the CPU's turn, trigger the CPU's move
if (!this.gameOver && this.isAgainstCPU && this.currentPlayer === this.cpuSide) {
setTimeout(() => this.cpuTurn(), 300); // Delay to simulate CPU thinking
}
}
// Handles the CPU's turn by calculating the best move and making it
cpuTurn() {
const current = this.currentCells;
const bestMove = getBestMove(current, this.cpuSide, this.playerSide); // Get the optimal move for the CPU
this.makeMove(bestMove, this.cpuSide);
}
makeMove(cellId, player) {
const current = this.currentCells;
if (current[cellId].value) return;
const historyUntilNow = this.history.slice(0, this.stepNumber + 1);
const newCells = current.map(cell =>
cell.id === cellId ? {...cell, value: player} : {...cell}
);
const result = calculateWinner(newCells);
let gameOver = false;
let winningLine = [];
if (result) {
winningLine = result.winningLine;
gameOver = true;
} else if (isDraw(newCells)) {
gameOver = true;
}
this.history = [...historyUntilNow, newCells];
this.stepNumber = historyUntilNow.length;
this.winningLine = winningLine;
this.gameOver = gameOver;
if (gameOver) {
this.message = result ? `Player ${result.winner} wins!` : `It's a draw!`;
} else {
this.currentPlayer = player === 'X' ? 'O' : 'X';
this.message = `Current Player: ${this.currentPlayer}`;
}
}
handleUndo() {
if (this.stepNumber < 1) return;
const stepsBack = this.gameOver ? 1 : 2;
this.stepNumber = Math.max(0, this.stepNumber - stepsBack);
this.updateGameStateFromStep();
}
handleRedo() {
if (this.stepNumber >= this.history.length - 1) return;
this.stepNumber++;
this.updateGameStateFromStep();
}
handleReset() {
this.dispatchEvent(new CustomEvent('resetgame'));
}
updateGameStateFromStep() {
const cells = this.history[this.stepNumber];
const result = calculateWinner(cells);
this.gameOver = !!result || isDraw(cells);
this.winningLine = result ? result.winningLine : [];
this.currentPlayer = this.stepNumber % 2 === 0 ? 'X' : 'O';
if (result) {
this.message = `Player ${result.winner} wins!`;
} else if (this.gameOver) {
this.message = `It's a draw!`;
} else {
this.message = `Current Player: ${this.currentPlayer}`;
}
// If the game is not over and it's the CPU's turn, trigger the CPU's move
if (!this.gameOver && this.isAgainstCPU && this.currentPlayer === this.cpuSide) {
setTimeout(() => this.cpuTurn(), 300); // Delay to simulate CPU thinking
}
}
}
What Changed
- New property:
cpuSide
tells the component which side the CPU is playing - CPU awareness: The game checks
isAgainstCPU
to decide whether to trigger CPU moves - CPU awareness: The game checks
isAgainstCPU
to decide whether to trigger CPU moves - Automatic CPU turn:
connectedCallback()
andmakeMove()
now support letting the CPU respond after the player - New AI logic: We now call
getBestMove()
to determine CPU strategy - Undo logic: Going back in time while playing against CPU reverts both your move and the CPU's
Supporting Changes in the Start Screen
To let the user choose sides and implicitly enable CPU mode, we enhanced the startScreenComponent
.
<template>
<div class="slds-p-around_medium slds-text-align_center">
<h2 class="slds-text-heading_medium slds-m-bottom_medium">
Choose your side
</h2>
<div class="slds-m-bottom_medium">
<lightning-button-group>
<lightning-button
label="X"
variant={isXSelected}
onclick={handleSideChangeX}>
</lightning-button>
<lightning-button
label="O"
variant={isOSelected}
onclick={handleSideChangeO}>
</lightning-button>
</lightning-button-group>
</div>
<lightning-button
variant="brand"
label="Start Game"
onclick={startGame}
class="slds-m-top_medium">
</lightning-button>
</div>
</template>
import { LightningElement, track } from 'lwc';
export default class StartScreenComponent extends LightningElement {
@track selectedSide = 'X';
handleSideChangeX() {
this.selectedSide = 'X';
}
handleSideChangeO() {
this.selectedSide = 'O';
}
get isXSelected() {
return this.selectedSide === 'X' ? 'brand' : 'neutral';
}
get isOSelected() {
return this.selectedSide === 'O' ? 'brand' : 'neutral';
}
startGame() {
const detail = {
playerSide: this.selectedSide,
cpuSide: this.selectedSide === 'X' ? 'O' : 'X'
};
this.dispatchEvent(new CustomEvent('gamestart', { detail }));
}
}
Why This Matters
- The component now emits both
playerSide
andcpuSide
, enabling CPU play without toggles or extra configuration - If we want to support multiplayer later, we could omit
cpuSide
entirely, and the game would run in player-vs-player mode by default
Wrapping It Up
We gave our Tic-Tac-Toe game a brain. A simple brain, sure - but enough to challenge a human player and make the game more engaging. With minimal updates to the architecture, and the addition of a clean helper service, we've introduced strategic thinking into our app.
By isolating the AI logic and enhancing existing event-driven mechanics, we've future-proofed the structure too. Want to upgrade to minimax later? Swap the logic in aiService.js
. Want a harder difficulty? Add a difficulty selector on the start screen. The groundwork is all here.
In the next article, we’ll make things prettier and more satisfying with animations, transitions, and polish that turns this toy into something users genuinely enjoy.
Until then, go play a few rounds and try to beat the CPU. Just don’t get too frustrated if it blocks all your clever moves - it’s doing its job.