Programming

Tic-Tac-Toe Meets Math: Avoiding Hard-Coded Win Conditions

Published August 28, 2026 JavaScript Discrete Mathematics

Choosing the right representation can turn eight special cases into one general rule. A small Tic-Tac-Toe project showed me how much mathematics can hide inside ordinary loops, indexes, and conditionals.

A familiar game, approached differently

Tic-Tac-Toe is one of the first games many of us build while learning to program. The board is small, the rules are familiar, and there are only eight ways to win: three rows, three columns, and two diagonals.

That makes it tempting to list every winning combination in the code. For a 3×3 game, that is perfectly reasonable—and may even be the simplest production solution.

But I wanted to solve a slightly different problem: could the program recognize a win from the structure of the board instead of being told every winning arrangement?

That question turned a small JavaScript project into an unexpected exercise in algebra, summation, matrix-style indexing, and predicate logic.

Give each state a number

The board begins as a two-dimensional array. Instead of storing "X" and "O", I represent X as 1, O as -1, and an empty cell as 0.

JavaScriptBoard representation
const board = [
  [0, 0, 0],
  [0, 0, 0],
  [0, 0, 0]
];

This one decision does most of the algorithmic work. A line filled by X adds up to 3; one filled by O adds up to -3. Every incomplete or mixed line produces a value between those extremes.

For a row occupied entirely by X:

\[1 + 1 + 1 = 3\]

The same row occupied by O becomes:

\[-1 + -1 + -1 = -3\]

For a board with width \(n\), a completed line therefore satisfies:

\[\left|\text{sum of line}\right| = n\]

For a normal 3×3 board, \(3 \Rightarrow X\) and \(-3 \Rightarrow O\). The absolute value tells us whether the line is complete, while the sign tells us who won. One test now works for both players.

Rows and columns are the same idea

To check a horizontal win, add the values in each row:

JavaScriptHorizontal wins
function checkHorizontalWin(board) {
  for (const row of board) {
    const total = row.reduce((sum, cell) => sum + cell, 0);

    if (Math.abs(total) === row.length) {
      return total > 0 ? "X wins!" : "O wins!";
    }
  }

  return false;
}

Mathematically, the same operation can be written as:

\[\left|\sum_{j=0}^{n-1} B_{r,j}\right| = n\]

Here, \(B\) represents the board, \(r\) is the row being examined, and \(j\) moves through its columns. For a 3×3 game, that becomes:

\[|B_{r,0} + B_{r,1} + B_{r,2}| = 3\]

My first version checked specifically for totals of 3 and -3. Once I recognized the rule the code was expressing, I could replace those magic numbers with the row length.

Vertical detection uses the same rule in another direction. Instead of fixing the row and moving through its columns, we fix the column and move through the rows:

\[\left|\sum_{i=0}^{n-1} B_{i,c}\right| = n\]

These are not unrelated win conditions; they are the same mathematical property measured along different dimensions.

That reframes the problem. Instead of asking, “Which combinations should I check?” we can ask, “What property does every winning line share?”

Diagonals live in their indexes

Rows and columns are straightforward because one index remains fixed. Diagonals become easy once we identify the relationship between their indexes.

On the diagonal running from top-left to bottom-right, the row and column indexes are equal:

\[i = j\]

For a 3×3 board, those positions are:

\[(0,0),\ (1,1),\ (2,2)\]

On the opposite diagonal, the indexes add up to one less than the board width:

\[i + j = n - 1\]

On a 3×3 board, those positions are:

\[(0,2),\ (1,1),\ (2,0)\]

JavaScriptDiagonal wins
function checkDiagonalWin(board) {
  let leftToRight = 0;
  let rightToLeft = 0;

  for (let i = 0; i < board.length; i++) {
    leftToRight += board[i][i];
    rightToLeft += board[i][board.length - 1 - i];
  }

  for (const total of [leftToRight, rightToLeft]) {
    if (Math.abs(total) === board.length) {
      return total > 0 ? "X wins!" : "O wins!";
    }
  }

  return false;
}

The complete diagonal conditions can be written as:

\[\left|\sum_{i=0}^{n-1} B_{i,i}\right| = n\]

and:

\[\left|\sum_{i=0}^{n-1} B_{i,n-1-i}\right| = n\]

I did not begin with equations. I began by looking at the indexes and noticing their patterns. The notation came afterward and gave a name to what the code was already doing.

A draw is a logic problem

Detecting a full board uses a different kind of mathematics. There is no useful total to calculate; we only need to know whether an empty cell remains.

Because empty positions are represented by zero, the board is full when:

\[\forall i,j,\; B_{i,j} \neq 0\]

In plain English: for every row and column position, the value is not zero.

JavaScriptFull-board check
function isBoardFull(board) {
  for (const row of board) {
    for (const cell of row) {
      if (cell === 0) return false;
    }
  }

  return true;
}

The early return checks the inverse condition:

\[\exists i,j \text{ such that } B_{i,j} = 0\]

If even one such position exists, the board is not full. If the search finishes without finding one—and no player has won—the result is a draw.

The code looks like two loops and an if statement. Its mathematical description is predicate logic.

The math was already there

What surprised me most was how little this felt like “doing math.” I was asking ordinary programming questions: How can one algorithm work for both players? What do all winning lines have in common? Which cells belong to a diagonal? How can I tell whether any empty squares remain?

Those practical questions naturally led to signed numbers, absolute values, summations, coordinate relationships, and universal and existential conditions. The programming problem gave the mathematics something concrete to describe.

For example, this loop:

JavaScriptColumn summation
for (let i = 0; i < board.length; i++) {
  total += board[i][column];
}

implements:

\[\sum_{i=0}^{n-1} B_{i,c}\]

Similarly, i + j === board.length - 1 is not an arbitrary programming condition. It is the equation describing every coordinate on the board’s opposite diagonal.

The broader lesson

Hard-coding all eight Tic-Tac-Toe wins would have worked. Avoiding that approach was valuable because it forced me to find the underlying properties of a win.

The complete model can be summarized in a few rules:

\[X = 1,\qquad O = -1,\qquad \text{Empty} = 0\]

Winning lines satisfy:

\[|\text{line sum}| = n\]

Diagonal membership is determined by:

\[i = j\]

or:

\[i + j = n - 1\]

And a full board satisfies:

\[\forall i,j,\; B_{i,j} \neq 0\]

The game is small, but the lesson transfers well. A collection of special cases can often be replaced by a property shared by every valid case. A thoughtful data representation can simplify the algorithm that operates on it. And code made of loops, indexes, and conditionals may already be expressing mathematical ideas—even when it looks nothing like classroom math.

I did not add mathematics to the program afterward. The mathematics was there all along.