4
\$\begingroup\$

I wrote a Tic-Tac-Toe game that unlike the normal TTT only saves the last 3 moves. That means, that the first move will be removed on the 4th move and so on.

To not necessarily extend the description I do not post my full requirements. The important requirements to understand the code for review are:

  • It must be responsive for FHD to UHD desktop monitor screens either in landscape or portrait mode.
  • It must not support screen readers. In the reference, partial hints were added to introduce accessibility in a later lesson.
  • It must support Chrome and Firefox with updates of 1st of June 2024 or newer.
  • Code must be readable enough to be understandable without comments
  • Code must be updatable to support later addition of other game options (like adding a chess clock)

My requests for review are:

  • Is the code readable
  • Can conventions be recognized (Students know what conventions are and should spot at least some of them)
  • HTML semantics
  • simplicity
  • no unnecessary repetitive code

const BUTTON_LABELS = [
  'Oben Links',
  'Oben Mitte',
  'Oben Rechts',
  'Mitte Links',
  'Zentrum',
  'Mitte Rechts',
  'Unten Links',
  'Unten Mitte',
  'Unten Rechts'
];
const PLAYERS = ['X', 'O'];
const WIN_PATTERN = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8],
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8],
  [0, 4, 8],
  [2, 4, 6]
];

let moves = {
  X: [],
  O: []
};
let turn;

window.addEventListener('DOMContentLoaded', init);

function init() {
  randomPlayer();
  BUTTON_LABELS.forEach((label, index) => {
    const BUTTON = document.createElement('button');
    BUTTON.addEventListener('click', move);
    BUTTON.ariaLabel = label;
    BUTTON.dataset.id = index;
    BOARD.appendChild(BUTTON);
  })
  announceTurn();
  NEW_GAME.addEventListener('click', resetGame);
}

function randomPlayer() {
  turn = Math.floor(Math.random() * PLAYERS.length)
}

function move() {
  if (!this.textContent.length) {
    this.textContent = PLAYERS[turn];
    this.classList.add(PLAYERS[turn]);
    saveMove(this.dataset.id);
  }
}

function saveMove(dataId) {
  moves[PLAYERS[turn]].push(dataId);
  deleteMove([PLAYERS[turn]]);
}

function deleteMove(PLAYER) {
  while (moves[PLAYER].length > 3) {
    const FIELD = document.querySelector(`[data-id="${moves[PLAYER][0]}"]`);
    FIELD.className = '';
    FIELD.textContent = '';
    moves[PLAYER].shift();
  }
  checkForWinner();
}

function checkForWinner() {
  let winner = victoryCheck();
  if (winner) {
    announceWinner(winner);
    lockBoard();
  } else {
    switchPlayer();
  }
}

function victoryCheck() {
  let winner = false;
  WIN_PATTERN.forEach(pattern => {
    let line = [];
    pattern.forEach(field => line.push(document.querySelector(`[data-id="${field}"]`).textContent));
    if (line[0].length && line.every(val => val === line[0])) {
      winner = line[0];
    }
  });
  return winner;
}

function announceWinner(player) {
  document.querySelectorAll(`.${player}`).forEach(btn => btn.classList.add('winner'));
  NOTICE.textContent = `Spieler ${player} gewinnt!`;
  NOTICE.className = `${player}`;
}

function lockBoard() {
  BOARD.dataset.turn = 'none';
  document.querySelectorAll('#BOARD button').forEach(btn => btn.disabled = true);
}

function switchPlayer() {
  turn = (turn + 1) % PLAYERS.length;
  announceTurn();
}

function announceTurn() {
  let player = PLAYERS[turn];
  BOARD.dataset.turn = player;
  NOTICE.textContent = `Spieler ${player} ist am Zug.`;
  NOTICE.className = `${player}`;
}

function resetGame() {
  randomPlayer();
  const BUTTONS = document.querySelectorAll('#BOARD button');
  BUTTONS.forEach(btn => {
    btn.removeAttribute('class');
    btn.disabled = false;
    btn.textContent = '';
  })
  for (let key in moves) {
    moves[key] = [];
  }
  announceTurn();
}
:root {
  --blue: rgb(40, 100, 140);
  --border-thickness: 0.2rem;
  --border-radius: 0.4rem;
  --gray: rgb(85, 85, 85);
  --green: rgb(100, 179, 43);
  --red: rgb(222, 39, 79);
}

* {
  box-sizing: border-box;
  color: var(--gray);
  font-family: Arial, Helvetica, sans-serif;
}

body {
  align-items: center;
  display: flex;
  flex-direction: column;
  height: 100vh;
  justify-content: space-between;
  margin: 0;
  padding: 0.5rem;
}

main {
  display: flex;
  flex-direction: column;
  gap: 1em;
}

button {
  border: var(--border-thickness) solid var(--gray);
  border-radius: var(--border-radius);
  background-color: white;
  color: var(--gray);
  &:hover {
    border-color: var(--green);
    color: var(--green);
    cursor: pointer;
  }
}

#NOTICE {
  font-size: 2em;
  text-align: center;
  &.X {
    color: var(--blue);
  }
  &.O {
    color: var(--red);
  }
}

#BOARD {
  --highlight: var(--gray);
  display: grid;
  gap: 0.5em;
  grid-template-columns: repeat(3, 20vmin);
  &[data-turn="X"] {
    --highlight: var(--blue)
  }
  &[data-turn="O"] {
    --highlight: var(--red)
  }
  button {
    align-items: center;
    aspect-ratio: 1;
    display: flex;
    font-size: 15vmin;
    justify-content: center;
    &.X {
      border-color: var(--blue);
      color: var(--blue);
    }
    &.O {
      border-color: var(--red);
      color: var(--red);
    }
    &.winner {
      animation: 0.8s linear infinite alternate winner;
      border-color: var(--green);
      color: var(--green);
    }
    &:not([class]):hover {
      border-color: var(--highlight);
    }
    &:disabled:hover {
      cursor: not-allowed;
    }
  }
}

@keyframes winner {
  from {
    opacity: 1;
  }
  to {
    opacity: 0.4;
  }
}

menu {
  padding: 0;
  text-align: center;
}

#NEW_GAME {
  font-size: 2em;
  margin: 0;
  padding: 0.5em 1em;
}
<!DOCTYPE html>
<html lang="de">

<head>
  <meta charset="UTF-8">
  <title>Tic Tac Toe Advanced</title>
  <link rel="stylesheet" href="tictactoe.css">
  <script src="tictactoe.js"></script>
</head>

<body>
  <header>
    <h1>Tic Tac Toe Advanced</h1>
  </header>
  <main>
    <output id="NOTICE"></output>
    <div id="BOARD"></div>
    <menu>
      <button id="NEW_GAME">Neues Spiel</button>
    </menu>
  </main>
  <footer>
    &copy; 2024 - Name des Autors
  </footer>
</body>

</html>

\$\endgroup\$
5
  • \$\begingroup\$ Is this a homework assignment? \$\endgroup\$
    – Mast
    Commented Jul 26 at 20:10
  • \$\begingroup\$ "saves only the last 3 moves" -> I don't understand. This is very unusual, unconventional you can say. The requirements needs to explain why; the intended effect on game play. Once understood then that magic number "3" can be given an informative name \$\endgroup\$
    – radarbob
    Commented Jul 26 at 22:44
  • \$\begingroup\$ @radarbob as described. Every player can only have 3 moves on the board. Any further move (4th move) will remove the oldest move so that only 3 moves remain on the board. The code is working, you can test it out yourself. \$\endgroup\$
    – tacoshy
    Commented Jul 27 at 12:13
  • \$\begingroup\$ @Mast not at all. I intend to use this code as reference solution for my students. \$\endgroup\$
    – tacoshy
    Commented Jul 27 at 12:14
  • 1
    \$\begingroup\$ Mostly off topic: "It must not support screen readers" suggests that you're intentionally breaking screen readers. Better would be "It does not attempt to support screen readers". \$\endgroup\$
    – Teepeemm
    Commented Sep 4 at 13:22

1 Answer 1

6
\$\begingroup\$

Functions should generally be actions, and should accurately advertise what they do. So randomPlayer should be called setRandomPlayer, randomizePlayer, setRandomTurn or something like that. It mutates state, so it's not a getter as randomPlayer implies. victoryCheck is a noun--checkVictory is a verb.

It's misleading that saveMove is also responsible for deleting a move, and deleteMove also checks for the winner, and that checkForWinner also switches players and updates the UI. Instead of chaining and nesting those unrelated calls, flatten them into a series of sequential operations. Pseudocode:

const move = () => {
  saveMove();
  deleteMove();
  const winner = checkVictory();

  if (winner) {
    announceWinner(winner);
    lockBoard();
  } else {
    switchPlayer();
    announceTurn();
  }
};

This keeps functions single-responsibility, understandable, debuggable and reusable, making it possible to delete a move without necessarily checking a winner when you do. It's also very readable: I can see a bird's eye view of all of the steps move() entails, although the details are tucked away in helper functions, each of which does one unsurprising thing with no hidden behavior (I don't have to examine the nested function code to discover that saveMove() will also delete moves, switch players and update the UI).

Consider inlining single-use functions that are just a line or two and have a single caller. saveMove could be inlined into save.

Separate the user interface and the game logic. The litmus test here is whether you can swap the UI out to, say, a console application rather than a browser application and not have to adjust the core game engine logic.

Don't store game state in the UI, like this.textContent.length. Store all state in JS, in the board, then simply unidirectionally render that state to the UI whenever an update is required. Don't dip into the UI to pull data back out to the engine--it gets hard to keep track of it when it's spread out in multiple places. Event handlers are the controller layer, connecting the model (business logic with no knowledge of how it's displayed) and the view (basically, user interactions through the DOM). Keep these layers loosely coupled which makes them easier to debug, test, reuse and extend with new features.

For example, if you want to add a computer opponent using minimax or a Monte Carlo method, you'll need to essentially run a bunch of headless simulations of the game. These background simulations would be a headache to run if they also messed with the user interface. You'll also need the board state encapsulated in an object rather than in the global scope to facilitate copying and multi-use.

Avoid SCREAM_CASE except for maybe global constants. Function-scoped vars should be const camelCase = ....

Always use const instead of let, except when you need to reassign a variable, which you shouldn't need to do often. let moves should be const moves, let key in moves should be const key in moves.

Call Object.freeze() on any objects you don't want to modify, like BUTTON_LABELS and so forth.

CSS uses kebab-case naming, not SCREAM_CASE. id="NEW_GAME" should be id="new-game". Prefer classes over ids, generally.

Watch out for layout shift on small screens. When I click to make a move, the layout moves sideways back and forth on my screen which is jarring.


Here's a suggested rewrite. There's room for improvement; I didn't touch CSS other than naming conventions, the controller DOM interaction could use tightening, and more validation and generalization can be done.

The benefits are that there's clear separation between the UI and the game logic, functions do what they say they do and are more or less single-responsibility, no state is stored in the UI, and the board is ready to be copied and used for creating a computer opponent.

const randInt = (n) => Math.floor(Math.random() * n);

const choice = (a) => a[randInt(a.length)];

class TicTacToeBoard {
  #moves;
  #maxMoves = 3;
  #players = Object.freeze(["X", "O"]);
  #wins = Object.freeze([
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ]);

  constructor() {
    this.#moves = {
      X: [],
      O: [],
    };
    this.currentPlayer = choice(this.#players);
  }

  get players() {
    return this.#players;
  }

  get moves() {
    return Object.freeze(this.#moves);
  }

  get currentMoves() {
    return this.#moves[this.currentPlayer];
  }

  move(square) {
    for (const player in this.#moves) {
      if (this.#moves[player].includes(square)) {
        return false;
      }
    }

    this.currentMoves.push(square);

    if (this.currentMoves.length > this.#maxMoves) {
      this.currentMoves.shift();
    }

    return true;
  }

  switchSides() {
    const i = this.#players.indexOf(this.currentPlayer);
    this.currentPlayer = this.#players[(i + 1) % this.#players.length];
  }

  currentPlayerWon() {
    return (
      this.currentMoves.length >= this.#wins[0].length &&
      this.#wins.some((pattern) =>
        pattern.every((square) => this.currentMoves.includes(square)),
      )
    );
  }
}

class TicTacToeGame {
  #buttonLabels = Object.freeze([
    "Oben Links",
    "Oben Mitte",
    "Oben Rechts",
    "Mitte Links",
    "Zentrum",
    "Mitte Rechts",
    "Unten Links",
    "Unten Mitte",
    "Unten Rechts",
  ]);

  constructor() {
    this.setElements();
    this.newGame();
  }

  setElements() {
    const $ = document.querySelector.bind(document);
    this.elements = {
      newGameButton: $("#new-game"),
      boardContainer: $("#board"),
      noticeContainer: $("#notice"),
      buttons: [],
    };
  }

  newGame() {
    this.elements.boardContainer.textContent = "";
    this.elements.noticeContainer.textContent = "";
    this.board = new TicTacToeBoard();
    this.enabled = true;
    this.addButtons();
    this.elements.newGameButton.addEventListener("click", () =>
      this.newGame(),
    );
    this.renderBoard();
  }

  handleMove(square) {
    if (!this.enabled || !this.board.move(square)) {
      return;
    }

    if (this.board.currentPlayerWon()) {
      this.enabled = false;
      this.renderBoard();
      this.renderWin();
    } else {
      this.board.switchSides();
      this.renderBoard();
    }
  }

  renderBoard() {
    const player = this.board.currentPlayer;
    this.elements.noticeContainer.textContent = `Spieler ${player} ist am Zug.`;
    this.elements.noticeContainer.className = player;
    this.elements.buttons.forEach((e, i) => {
      this.board.players.forEach((player) => e.classList.remove(player));
      e.textContent = "";

      for (const player in this.board.moves) {
        if (this.board.moves[player].includes(i)) {
          e.textContent = player;
          e.classList.add(player);
        }
      }
    });
  }

  renderWin() {
    const player = this.board.currentPlayer;
    this.elements.buttons.forEach((btn, i) => {
      btn.disabled = true;

      if (this.board.currentMoves.includes(i)) {
        btn.classList.add("winner");
      }
    });
    this.elements.noticeContainer.textContent = `Spieler ${player} gewinnt!`;
    this.elements.noticeContainer.className = player;
  }

  addButtons() {
    this.elements.buttons = this.#buttonLabels.map((label, square) => {
      const button = document.createElement("button");
      button.addEventListener("click", () => this.handleMove(square));
      button.ariaLabel = label;
      this.elements.boardContainer.appendChild(button);
      return button;
    });
  }
}

new TicTacToeGame();
:root {
  --blue: rgb(40, 100, 140);
  --border-thickness: 0.2rem;
  --border-radius: 0.4rem;
  --gray: rgb(85, 85, 85);
  --green: rgb(100, 179, 43);
  --red: rgb(222, 39, 79);
}

* {
  box-sizing: border-box;
  color: var(--gray);
  font-family: Arial, Helvetica, sans-serif;
}

body {
  align-items: center;
  display: flex;
  flex-direction: column;
  height: 100vh;
  justify-content: space-between;
  margin: 0;
  padding: 0.5rem;
}

main {
  display: flex;
  flex-direction: column;
  gap: 1em;
}

button {
  border: var(--border-thickness) solid var(--gray);
  border-radius: var(--border-radius);
  background-color: white;
  color: var(--gray);
  &:hover {
    border-color: var(--green);
    color: var(--green);
    cursor: pointer;
  }
}

#notice {
  font-size: 2em;
  text-align: center;
  &.X {
    color: var(--blue);
  }
  &.O {
    color: var(--red);
  }
}

#board {
  --highlight: var(--gray);
  display: grid;
  gap: 0.5em;
  grid-template-columns: repeat(3, 20vmin);
  &[data-turn="X"] {
    --highlight: var(--blue);
  }
  &[data-turn="O"] {
    --highlight: var(--red);
  }
  button {
    align-items: center;
    aspect-ratio: 1;
    display: flex;
    font-size: 15vmin;
    justify-content: center;
    &.X {
      border-color: var(--blue);
      color: var(--blue);
    }
    &.O {
      border-color: var(--red);
      color: var(--red);
    }
    &.winner {
      animation: 0.8s linear infinite alternate winner;
      border-color: var(--green);
      color: var(--green);
    }
    &:not([class]):hover {
      border-color: var(--highlight);
    }
    &:disabled:hover {
      cursor: not-allowed;
    }
  }
}

@keyframes winner {
  from {
    opacity: 1;
  }
  to {
    opacity: 0.4;
  }
}

menu {
  padding: 0;
  text-align: center;
}

#new-game {
  font-size: 2em;
  margin: 0;
  padding: 0.5em 1em;
}
<main>
  <output id="notice"></output>
  <div id="board"></div>
  <menu>
    <button id="new-game">Neues Spiel</button>
  </menu>
</main>
<footer>&copy; 2024 - Name des Autors</footer>

\$\endgroup\$
9
  • \$\begingroup\$ Avoid SCREAM_CASE except for maybe global constants -> Upper Case all constant names is conventional (but not universal). Limiting this to only global constants makes comprehension harder and increases possibility for coding errors. Further, it is also name-encoding variables' scope which is a really bad idea and certainly not remotely conventional. \$\endgroup\$
    – radarbob
    Commented Jul 26 at 22:39
  • \$\begingroup\$ The trouble is, const is a misleading keyword--it doesn't create a constant if it's a mutable object. Not all const variables, especially locally-scoped ones, should be SCREAM_CASE, like BUTTON in OP's code. Setting all variables you simply can't reassign to SCREAM_CASE is unconventional and a clear overuse of SCREAM_CASE, which should be reserved for genuine, frozen, true global constants so they're differentiated from local variables. But even that's debatable--I generally avoid SCREAM_CASE unless it's an established practice in the codebase I'm contributing to. \$\endgroup\$
    – ggorlen
    Commented Jul 26 at 22:49
  • 1
    \$\begingroup\$ Yes const can be misunderstood. The reference to the given object is constant. The internal state of that object is not. \$\endgroup\$
    – radarbob
    Commented Jul 26 at 22:55
  • \$\begingroup\$ Thank you for your answer as it has some useful hints. The company has very strict rules (conventions) when to use and how to use constants and variables. I didn't went into details here. The variables and constants have been chosen according those rules. I specifically didn't used class in js as it is a step to early for the students ( they will be 2-4 weeks into coding at that point. But I definitely find it useful for further tasks. \$\endgroup\$
    – tacoshy
    Commented Jul 27 at 12:17
  • 3
    \$\begingroup\$ ES6 JS which we're talking about definitely has classes. I even used them in this answer. Who cares if it's syntactical sugar? They still operate much like normal classes for all intents and purposes, and certainly for all intents and purposes here. There's no mention in this whole thread of "by reference". I'm not sure how that's relevant. I don't understand the purpose of your comment or how it pertains to any of the topics at hand here. \$\endgroup\$
    – ggorlen
    Commented Jul 29 at 4:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.