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
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.
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.
And all of this while keeping the UI responsive and accurate.
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.
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}`;
}
}
}
@track history
and @track stepNumber
cells
array, the board is now derived using get currentCells()
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.
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);
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.