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

Victory Detection and Game Rules Implementation for Tic-Tac-Toe in LWC

So, you've built the core of your Tic-Tac-Toe game using LWC. Players can click on cells, Xs and Os are rendered, and it's starting to feel like an actual game. But here comes the twist - without proper game rules and victory detection, all you've got is a fancy digital Etch A Sketch ​

In this chapter, we're dialing things up. We're adding real game logic - where the app knows when someone wins, highlights the winning line, and even declares a draw when the board fills up. That means new logic, a few smart tweaks to our components, and of course, a way to show players that something awesome just happened. Let's break it all down.

What Changed from the Previous Version?

If you've been following along (and I hope you have!), you'll recognize the main components: gameContainer, boardComponent, cellComponent, and our trusty logic in gameLogic.js. In this step, we aren't reinventing the wheel - we're just adding that sweet, sweet game intelligence.

Specifically, we're introducing:

  • A win-checking system (calculateWinner) and draw detection.
  • A way to visually highlight winning cells.
  • UI feedback: who's the winner or if it's a draw.
  • Smarter board updates - because once someone wins, that board should lock down!

Let's look at what's new or updated.

​ The Game Logic: gameLogic.js

Here's where the magic starts. We've added two key functions:

// Check for a winner among all possible lines
export function calculateWinner(cells) {
    const 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]
    ];

    for (let [a, b, c] of lines) {
        if (cells[a].value && cells[a].value === cells[b].value && cells[a].value === cells[c].value) {
            return {
                winner: cells[a].value,
                winningLine: [a, b, c]
            };
        }
    }

    return null;
}

// If the board is full and no one has won, it's a draw
export function isDraw(cells) {
    return cells.every(cell => cell.value) && !calculateWinner(cells);
}

export function generateInitialBoard() {
    return Array.from({ length: 9 }, (_, index) => ({
        id: index,
        value: ''
    }));
}

What's happening here?
We're checking all possible winning combinations - horizontal, vertical, diagonal. If three of the same values (X or O) line up, we return the winner and the indices of the winning cells.

The isDraw function is a backup plan. If the board is full and there's no winner, it's a draw. Plain and simple.

​ Smarter gameContainer

This component is now the brains of the operation. We added state tracking for:

  • Whether the game is over
  • The current message (who's up next, who won, etc.)
  • The winning cells (for highlighting)
import {LightningElement, track} from 'lwc';
import {calculateWinner, generateInitialBoard, isDraw} from 'c/gameLogic';

export default class GameContainer extends LightningElement {
    @track cells = generateInitialBoard(); // Create fresh board
    @track gameOver = false; // Prevents moves after game ends
    @track message = 'Current Player: X'; // Dynamic status message
    @track winningLine = []; // Highlighted line when a player wins

    currentPlayer = 'X'; // Track whose turn it is

    handleCellClick(event) {
        if (this.gameOver) return; // Don't allow clicks if game is over

        const {cellId} = event.detail;
        const targetCell = this.cells[cellId];

        if (targetCell.value) return; // Ignore already filled cells
        
        // Update board immutably
        this.cells = this.cells.map(cell =>
            cell.id === cellId ? {...cell, value: this.currentPlayer} : cell
        );

        // Check if this move wins the game
        const result = calculateWinner(this.cells);
        if (result) {
            this.winningLine = result.winningLine;
            this.gameOver = true;
            this.message = `Player ${result.winner} wins!`;
            return;
        }

        // Check for draw
        if (isDraw(this.cells)) {
            this.gameOver = true;
            this.message = 'It\'s a draw!';
            return;
        }

        // No winner or draw? Switch turn
        this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X';
        this.message = `Current Player: ${this.currentPlayer}`;
    }
}

Let's pause for a sec.
Notice how we now use @track for multiple state values? We need LWC to rerender when these things change - especially the board and message.

Also, calculateWinner and isDraw control the flow. Once a win or draw is detected, gameOver flips, and we stop accepting new moves. Boom. Solid.

​ Highlighting the Winning Line – boardComponent

Okay, it's one thing to say β€œPlayer X wins.” But what if we could show it? Now the boardComponent maps through the cells and tags the ones that are part of a winning combination:

export default class BoardComponent extends LightningElement {
    @api cells = [];
    @api winningLine = []; // IDs of winning cells to highlight

    get rows() {
        const rows = [];
        for (let i = 0; i < this.cells.length; i += 3) {
            const rowCells = this.cells.slice(i, i + 3).map(cell => ({
                ...cell,
                isWinning: this.winningLine.includes(cell.id) // Flag winning cells
            }));

            rows.push({
                id: `row-${i / 3}`,
                cells: rowCells
            });
        }
        return rows;
    }

    handleCellClick(event) {
        const cellId = event.target.cellId;
        this.dispatchEvent(new CustomEvent('cellclick', {detail: {cellId}}));
    }
}

The real trick here is isWinning, which gets passed down to each cellComponent. It's a boolean that's true only if the cell is in the winning line.

​ Visual Feedback – cellComponent

Our cells now shine-literally. Check this out:

export default class CellComponent extends LightningElement {
    @api cellId;
    @api value;
    @api isWinning; // Received from boardComponent

    handleClick() {
        if (!this.value) {
            this.dispatchEvent(
                new CustomEvent('click', {
                    bubbles: true,
                    composed: true
                })
            );
        }
    }

    get cellClass() {
        // Add 'winning' class for victory highlight
        return `slds-m-around_xxx-small cell ${this.isWinning ? 'winning' : ''}`;
    }
}
.cell {
    height: 60px;
    border: 1px solid #ccc;
    text-align: center;
    font-size: 24px;
    line-height: 60px;
    cursor: pointer;
    user-select: none;
}

.cell.winning {
    background-color: #d4f5d4;
    font-weight: bold;
    border: 2px solid green;
}

Result? When you win, your three-in-a-row lights up in green. It's instant feedback and gives that satisfying yes! A moment players crave.

No Changes? No Mention.

Just to clarify - we didn't touch ticTacToe or the base layout. Those are still doing their job just fine. The focus here is on logic, state, and feedback. All killer, no filler.

Wrapping It Up

With these changes, your Tic-Tac-Toe app isn't just playable - it's smart. It knows when someone wins, it knows when it's a draw, and it knows when to stop taking input. Most importantly, it feels complete. A game without rules is just chaos. Now? You've brought order.

Your users will love the immediate feedback, the green glow of victory, and the clear game messages. And from a dev's point of view, your logic is clean, modular, and easy to build upon.

What's next? Well, how about letting users rewind and replay moves? Stay tuned - we're diving into Move History in the next article.

Continue Reading