ship
SalesForce Simplified

Your Go-To Resource for Streamlined Solutions and Expert Guidance

mountains
Empower Your Business
Dive deep into the world of CRM excellence, where innovation meets practicality, and transform your Salesforce experience with Forceshark's comprehensive resources

Adding a CPU Opponent to Tic-Tac-Toe Game in LWC

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:

  • 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

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() and makeMove() 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 and cpuSide, 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.