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

Building Move History Functionality in a Tic-Tac-Toe LWC App

So far, our Tic-Tac-Toe game has come a long way. We've created a dynamic board, wired up click handling, and implemented core rules like detecting wins and draws. But there’s one thing that’s still missing to really level-up the experience: move history.

Let’s face it - anyone who has played a digital game knows how handy an Undo button is. Whether you fat-fingered a move or just want to think a few turns ahead, it’s a game-changer. In this article, we’ll take our Lightning Web Components (LWC) Tic-Tac-Toe app to the next level by building a complete move history system - including Undo, Redo, and Reset functionality. Let’s dive in.

Why Move History Matters?

Picture this: you’re mid-game, and you accidentally click the wrong square. Without an undo option, you’re stuck. Now imagine you’re coding the game - adding undo/redo means thinking like a time traveler: we don’t just track the present, but every step that got us here.

This means:

  • Capturing each board state after a move
  • Keeping track of which move we're currently viewing
  • Letting players jump backward (Undo) or forward (Redo) through the timeline
  • Resetting everything with a clean slate

And all of this while keeping the UI responsive and accurate.

Overview of What Changed

In the previous article, our GameContainer component was fairly straightforward. It tracked a flat cells array, the current player, and basic game status (win/draw). However, it had no concept of time - once a move was made, it was final.

Now, we’ve refactored the game state to store a history array. Each element in that array is a snapshot of the board after a move. We also track the current step we're on and use that to derive everything else, from whose turn it is to whether a move is valid.

Let’s explore the updated and newly introduced components.

 Updated gameContainer 

<template>
    <lightning-card title="Tic-Tac-Toe">
        <div class="slds-p-around_medium slds-text-align_center">
            <p class="slds-text-heading_small">{message}</p>
        </div>
        <div class="slds-p-horizontal_x-small">
            <c-board-component
                    cells={currentCells}
                    winning-line={winningLine}
                    oncellclick={handleCellClick}>
            </c-board-component>
        </div>
        <!-- Button group for game controls: Undo, Redo, and Reset -->
        <div class="slds-p-around_medium slds-text-align_center">
            <lightning-button-group>
                <lightning-button label="Undo" onclick={handleUndo} disabled={isUndoDisabled}></lightning-button>
                <lightning-button label="Redo" onclick={handleRedo} disabled={isRedoDisabled}></lightning-button>
                <lightning-button label="Reset" onclick={handleReset}></lightning-button>
            </lightning-button-group>
        </div>
    </lightning-card>
</template>
import {LightningElement, track} from 'lwc';
import {calculateWinner, generateInitialBoard, isDraw} from 'c/gameLogic';

export default class GameContainer extends LightningElement {
    @track history = [generateInitialBoard()];
    @track stepNumber = 0;
    @track winningLine = [];
    @track message = 'Current Player: X';
    @track gameOver = false;

    currentPlayer = 'X';

    get currentCells() {
        return this.history[this.stepNumber];
    }

    get isUndoDisabled() {
        return this.stepNumber === 0;
    }

    get isRedoDisabled() {
        return this.stepNumber === this.history.length - 1;
    }

    handleCellClick(event) {
        if (this.gameOver) return;

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

        if (targetCell.value) return;

        // Trim history if we went back and now make a new move
        const historyUntilNow = this.history.slice(0, this.stepNumber + 1);

        const newCells = current.map(cell =>
            cell.id === cellId ? {...cell, value: this.currentPlayer} : {...cell}
        );

        const result = calculateWinner(newCells);
        let gameOver = false;
        let winningLine = [];

        if (result) {
            winningLine = result.winningLine;
            gameOver = true;
        } else if (isDraw(newCells)) {
            gameOver = true;
        }

        // Add the new board state to the history array. This allows tracking of all moves 
        // and enables undo/redo functionality
        this.history = [...historyUntilNow, newCells];
        // Update the step number to reflect the latest move. This ensures the current state 
        // is correctly indexed in the history array.
        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 = this.currentPlayer === 'X' ? 'O' : 'X';
            this.message = `Current Player: ${this.currentPlayer}`;
        }
    }

    handleUndo() {
        if (this.stepNumber === 0) return;
        // Navigate one step back in the history when undoing a move. This allows the user 
        // to revert to a previous game state
        this.stepNumber--;
        this.updateGameStateFromStep();
    }

    handleRedo() {
        if (this.stepNumber >= this.history.length - 1) return;
        // Navigate one step forward in the history when redoing a move. This allows the user 
        // to reapply a previously undone move
        this.stepNumber++;
        this.updateGameStateFromStep();
    }

    handleReset() {
        // Reset the history to the initial state when the game is reset. This clears all 
        // previous moves and starts the game fresh
        this.history = [generateInitialBoard()];
        this.stepNumber = 0;
        this.winningLine = [];
        this.gameOver = false;
        this.currentPlayer = 'X';
        this.message = 'Current Player: X';
    }

    updateGameStateFromStep() {
        // Retrieve the board state corresponding to the current step. This ensures the UI
        // reflects the correct game state based on the history
        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}`;
        }
    }
}

​ Key Differences From Previous Version

  • History tracking was added via @track history and @track stepNumber
  • Instead of a single cells array, the board is now derived using get currentCells()
  • Undo, Redo, and Reset buttons let users time-travel through moves
  • Game state (like winner, current player, and messages) updates based on the move history instead of being tightly coupled to a single board state

No Changes for BoardComponent or CellComponent

Even though our game logic got a major overhaul, the BoardComponent and CellComponent files remain untouched in this update - which is awesome because it shows how well we decoupled UI from game state. Their job is still just to render the board and respond to clicks.

The Logic Behind History

Here’s a quick analogy: think of the game as a playlist of board states. Every time a move is made, we add a new song to the playlist. Undoing is like hitting the Previous button. Redo? You guessed it - Next.

And just like a playlist, if you rewind and then add a new song (make a new move), the old “future” is lost. That’s exactly why we slice the history array when a new move is made from the middle of the timeline.

const historyUntilNow = this.history.slice(0, this.stepNumber + 1);

Conclusion

Adding move history and undo/redo support is one of those upgrades that instantly makes your app feel more polished and professional. It gives users a sense of control and invites them to explore and strategize without fear of making a mistake.

In this update, we evolved the way we handle state: moving from a simple board array to a full-fledged history tracking system. And we did it while keeping our UI components clean and reusable.

In the next article, we'll take on an even more exciting challenge - adding a CPU opponent so that you can play against the machine. It's going to be fun, a little tricky, and a great opportunity to explore more advanced logic inside your LWC apps.

Ready to give your app some brains? Let’s go.