Tic-Tac-Toe Game

Build a Vue.js Tic-Tac-Toe with reactive turns, win detection, and reset controls

Time to implement the project: ~ 5-8 hours

  • Vue.js
  • Reactivity
  • Component Basics
  • Event Handling
  • Computed State
  • Bulma

In this beginner Vue.js project, you will build a playable Tic-Tac-Toe game with an interactive 3×3 board. Each cell must respond to clicks, place the current player’s symbol, and prevent overwriting already chosen squares. The game must alternate turns automatically and display whose turn is active in a clear status area.

You will implement win detection that checks rows, columns, and diagonals after every move. When a player wins, the UI must announce the winner and lock the board so no extra moves can be made. If the board fills without a winner, the game must display a draw state. A reset button should restart the game instantly by resetting reactive data. Use Bulma to style the board, buttons, and status display with clean spacing and consistent UI.

What You Learn From This Game

This project teaches Vue’s reactive mindset through a simple, controlled game loop. You will manage a small state model (board cells, active player, game status) and render the UI directly from that state. Every click produces a predictable state transition: place a mark, evaluate the result, then switch turns or finish the game.

Tic-Tac-Toe also trains clean computed logic. Win detection is a focused exercise in deriving outcomes from reactive data, which is considered a core skill for building interactive UI components in Vue.

Prerequisites and What You Should Know

You should be comfortable: creating Vue components, binding data in templates, and handling user events. The project expects basic JavaScript logic and the ability to style a small UI using a framework.

  • Vue 3 basics: template syntax, reactive state, and event binding
  • Understanding of arrays and basic conditional logic in JavaScript
  • Comfort using computed values for derived UI state
  • Basic familiarity with Bulma classes for layout and buttons
  • Ability to keep UI state consistent after user actions

Core Requirements for a Correct Game

A solid Tic-Tac-Toe implementation is judged by correctness and clarity. Moves must be valid, turns must switch consistently, and the game must end properly when a win or draw occurs. These requirements focus on reactive data flow rather than complex visuals.

Requirement Explanation
Clickable 3×3 board Interactive cells confirm event handling and reactive rendering are wired correctly.
Turn switching logic Automatic player changes demonstrate controlled state transitions after each move.
Win detection after every move Checking outcomes continuously ensures the game ends at the correct moment.
Draw detection Draw states show you handle full-board conditions without incorrect winner announcements.
Board lock on game end Preventing additional moves keeps game rules intact and avoids confusing UI behavior.
Reset button using reactive state Reset proves you can reinitialize state cleanly without manual DOM clearing.
Clear status UI Status messaging makes the game understandable and improves the overall user experience.

Tips for a Clean Vue Implementation

Keep the state model simple: an array of nine cells, a currentPlayer value, and a gameStatus that signals “playing,” “win,” or “draw.” Win detection works best as a pure function that receives the board and returns a winner or null. In Vue, computed values are a strong fit for derived status messages and end-of-game detection. Avoid spreading logic across the template - keep decision-making in script and render the result. When game logic stays pure, reactive rendering stays predictable.

  • Use a single click handler that exits early if the cell is filled or the game ended
  • Define winning line combinations once and reuse them for every check
  • Render the status message from reactive state so it updates instantly
  • Reset by rebuilding the board array, not by mutating each cell manually
  • Use Bulma button and grid utilities to keep the UI clean with minimal custom CSS
  • Test edge cases: last-move win, full-board draw, and rapid double clicks

Common Mistakes When Building a Tic-Tac-Toe Game

1. Storing each board cell as a separate variable

A Tic-Tac-Toe board has only nine cells, so beginners often create nine separate variables such as cell1, cell2, and cell3. This feels simple at first, but it makes the game harder to reset, harder to render with v-for, and much harder to check for wins. Vue works best when the UI is rendered from a clear state structure, not from many disconnected variables.

Problematic approach:


          const cell1 = ref("");
          const cell2 = ref("");
          const cell3 = ref("");
          const cell4 = ref("");
          const cell5 = ref("");
          const cell6 = ref("");
          const cell7 = ref("");
          const cell8 = ref("");
          const cell9 = ref("");

          function resetGame() {
            cell1.value = "";
            cell2.value = "";
            cell3.value = "";
            cell4.value = "";
            cell5.value = "";
            cell6.value = "";
            cell7.value = "";
            cell8.value = "";
            cell9.value = "";
          }

This approach creates repetitive logic. Every feature now needs to know about nine individual variables instead of one board.

Better approach:


          const board = ref([
            "", "", "",
            "", "", "",
            "", "", ""
          ]);

          function resetGame() {
            board.value = [
              "", "", "",
              "", "", "",
              "", "", ""
            ];
          }

Template example:


          <button
            v-for="(cell, index) in board"
            :key="index"
            type="button"
            class="button is-large tic-cell"
            @click="makeMove(index)"
          >
            {{ cell }}
          </button>

Pay attention to: Keep the board as one array. This makes rendering, click handling, win detection, draw detection, and reset logic much cleaner.

2. Switching turns before checking the winner

A very common logic bug happens when the app changes the current player immediately after a move and only then checks who won. The board may be correct, but the status message becomes wrong. For example, player X completes a winning row, the app switches to O, and then displays “O wins.”

Problematic code:


          const currentPlayer = ref("X");
          const winner = ref("");

          function makeMove(index) {
            board.value[index] = currentPlayer.value;

            currentPlayer.value = currentPlayer.value === "X" ? "O" : "X";

            const result = findWinner(board.value);

            if (result) {
              winner.value = currentPlayer.value;
            }
          }

The bug is subtle: the winning move was made by the previous player, but the code stores the next player as the winner.

Better approach:


          function makeMove(index) {
            if (board.value[index] || winner.value) return;

            board.value[index] = currentPlayer.value;

            const result = findWinner(board.value);

            if (result) {
              winner.value = result;
              return;
            }

            if (isDraw.value) {
              return;
            }

            currentPlayer.value = currentPlayer.value === "X" ? "O" : "X";
          }

Computed status message:


          const statusMessage = computed(() => {
            if (winner.value) {
              return `Player ${winner.value} wins!`;
            }

            if (isDraw.value) {
              return "It's a draw!";
            }

            return `Player ${currentPlayer.value}'s turn`;
          });

Pay attention to: The correct order is: place the mark, check winner, check draw, then switch turns. This keeps the game state and UI message aligned.

3. Writing win detection as many hardcoded if statements

Tic-Tac-Toe has only eight winning combinations, but writing them as separate if statements makes the function noisy and easy to break. Beginners often forget a diagonal, copy the wrong index, or make the code difficult to reuse for highlighting the winning line.

Problematic code:


          function findWinner(board) {
            if (board[0] === board[1] && board[1] === board[2]) {
              return board[0];
            }

            if (board[3] === board[4] && board[4] === board[5]) {
              return board[3];
            }

            if (board[6] === board[7] && board[7] === board[8]) {
              return board[6];
            }

            // More repeated conditions...
          }

This version also has another bug: if all three cells are empty strings, the condition technically matches. You must check that the first cell contains a real symbol.

Better approach:


          const winningLines = [
            [0, 1, 2],
            [3, 4, 5],
            [6, 7, 8],
            [0, 3, 6],
            [1, 4, 7],
            [2, 5, 8],
            [0, 4, 8],
            [2, 4, 6]
          ];

          function findWinner(board) {
            for (const [a, b, c] of winningLines) {
              if (board[a] && board[a] === board[b] && board[a] === board[c]) {
                return board[a];
              }
            }

            return "";
          }

Optional winning line highlight:


          const winningLine = computed(() => {
            return winningLines.find(([a, b, c]) => {
              return (
                board.value[a] &&
                board.value[a] === board.value[b] &&
                board.value[a] === board.value[c]
              );
            }) || [];
          });

          function isWinningCell(index) {
            return winningLine.value.includes(index);
          }

Pay attention to: Store winning combinations once and reuse them. This keeps the logic short, testable, and ready for UI improvements such as highlighting the winning cells.

4. Allowing invalid moves after the game is finished

A Tic-Tac-Toe game should stop accepting moves once a player wins or the board is full. A common beginner mistake is checking only whether a cell is empty, but not whether the game has already ended. This allows extra marks after the winner is announced, which makes the board inconsistent and confusing.

Problematic code:


          function makeMove(index) {
            if (board.value[index]) {
              return;
            }

            board.value[index] = currentPlayer.value;
            currentPlayer.value = currentPlayer.value === "X" ? "O" : "X";
          }

This blocks overwriting a filled cell, but it does not block new moves after a win or draw.

Better approach:


          const winner = computed(() => {
            return findWinner(board.value);
          });

          const isDraw = computed(() => {
            return !winner.value && board.value.every((cell) => cell !== "");
          });

          const isGameOver = computed(() => {
            return Boolean(winner.value) || isDraw.value;
          });

          function makeMove(index) {
            if (board.value[index] || isGameOver.value) {
              return;
            }

            board.value[index] = currentPlayer.value;

            if (!isGameOver.value) {
              currentPlayer.value = currentPlayer.value === "X" ? "O" : "X";
            }
          }

Template protection:


          <button
            v-for="(cell, index) in board"
            :key="index"
            type="button"
            class="button is-large tic-cell"
            :disabled="Boolean(cell) || isGameOver"
            @click="makeMove(index)"
          >
            {{ cell }}
          </button>

Pay attention to: Protect the game rules in both places: the function should ignore invalid moves, and the UI should visually disable unavailable cells.

5. Resetting the visible board but forgetting related game state

Resetting the game is not only clearing nine cells. You also need to reset the current player, winner state, draw state, winning line, and sometimes score or round data. Beginners often clear the board but leave old status values behind. The result is a new empty board that still says “Player X wins” or remains locked.

Problematic code:


          function resetGame() {
            board.value = [
              "", "", "",
              "", "", "",
              "", "", ""
            ];
          }

This clears the cells, but it does not reset the full game flow if the app stores winner or status separately.

Better approach:


          const createEmptyBoard = () => [
            "", "", "",
            "", "", "",
            "", "", ""
          ];

          const board = ref(createEmptyBoard());
          const currentPlayer = ref("X");
          const winner = ref("");
          const winningLine = ref([]);

          function resetGame() {
            board.value = createEmptyBoard();
            currentPlayer.value = "X";
            winner.value = "";
            winningLine.value = [];
          }

Round-based variation:


          const score = reactive({
            X: 0,
            O: 0
          });

          function startNextRound() {
            board.value = createEmptyBoard();
            currentPlayer.value = winner.value === "X" ? "O" : "X";
            winner.value = "";
            winningLine.value = [];
          }

          function restartMatch() {
            resetGame();
            score.X = 0;
            score.O = 0;
          }

Pay attention to: Decide whether your button means “new round” or “restart match.” A clean beginner project should make reset behavior predictable and avoid stale reactive values.

By completing this project, you'll gain a solid understanding of building interactive game logic with Vue.js using reactive data and computed state. You will practice turn-based updates, win and draw detection, and clean resets, all rendered through a predictable Vue component. This foundation prepares you for larger Vue interfaces that rely on event-driven state and reusable UI components.

Reference Implementations Worth Studying

Minimal Vue starter reference:
codingwithjustin - Vue TicTacToe

This repository is useful as a simple starting reference because it keeps the project small and close to a standard Vue CLI structure. It includes a familiar setup with src, components, App.vue, and basic development commands such as install, serve, build, and lint. That makes it easier for beginners to understand where a small Vue game can live inside a normal project folder.

Pay particular attention to:

  • How a small game can be organized inside a standard Vue project structure.
  • Where component logic belongs when the project has only one main interaction.
  • How you could improve the base version with clearer state naming and computed status messages.
  • How simple projects still benefit from clean file organization.
  • What parts of the app are enough for practice and what parts need polish for a portfolio version.

Use this implementation as a beginner-friendly orientation point. It is most valuable for understanding the basic project shape before adding stronger win detection, disabled states, draw handling, and cleaner reset logic.

More feature-rich game logic reference:
RF-Fahad-Islam - Vue Tic Tac Toe

This implementation is more ambitious because it goes beyond a basic two-player board. The project describes player-vs-player and player-vs-computer modes, multiple difficulty levels, reactive state with Vue 3 Composition API, score tracking, round handling, winning-square highlighting, and structured win-condition logic.

When studying the code, focus on:

  • How the board can be represented as a 3×3 matrix while win detection uses flat index combinations.
  • How current player, game end, difficulty level, used boxes, left boxes, scores, and winning cells are separated as game utilities.
  • How computer move logic changes the project from a simple UI exercise into a logic exercise.
  • How difficulty levels can be built from simple strategy rules such as random move, block opponent, or try to win.
  • How highlighting winning cells improves feedback after the game ends.

This repository is a strong reference if you want your Tic-Tac-Toe project to feel more complete. Do not add every feature immediately; first build the correct two-player game, then extend it with score tracking, computer mode, and difficulty levels.

Alternative multiplayer direction:
andreaselia - Tic Tac Vue

This repository is useful as an alternative direction because it treats Tic-Tac-Toe as a multiplayer game rather than only a local browser game. It is built with Vue.js v3 and is tagged around Node.js, multiplayer behavior, Socket.io, Vuex, and Tic-Tac-Toe. That makes it a good comparison point if you want to understand how a simple game can become a real-time application.

While reviewing this project, examine:

  • How multiplayer requirements change the structure of a beginner game.
  • How a socket connection can synchronize moves between players.
  • Why shared game state becomes more important when two clients interact with the same board.
  • How Vuex-style state management can support a multiplayer flow.
  • What extra edge cases appear when moves come from another player instead of a local click.

Use this implementation as an advanced comparison, not as the first version to copy. For the main beginner project, focus on local correctness first. Multiplayer becomes valuable only after the basic game rules are stable and easy to reason about.

© 2026 ReadyToDev.Pro. All rights reserved.

Methodology

Privacy Policy

Terms & Conditions