DEV Community

Cover image for Creating Minesweeper with ReactJS and Mobx
Mykhailo Horoshko
Mykhailo Horoshko

Posted on

Creating Minesweeper with ReactJS and Mobx

Hello,

I want to share with you my implementation of Minesweeper using React and Mobx. As you may know, Minesweeper is a popular Microsoft game where players have to open all cells without mines.

Let's recall what is a Mobx. Mobx is a simple, scalable state management library. It helps easily rerender react elements.

First of all, we need to set up our react environment. Here we will use the create-react-app package, this package helps us to set up our environment with little configurations. This package has a lot of pros and cons. I think, that the main advantage is that you don't need to install or configure tools like webpack or Babel. Also, you can use the already predefined template in your app. You can find a list of available templates by searching for cra-template-* on npm. And the main disadvantage is that there is a wide range of issues. Nevertheless, you can simply use eject script, and then configure webpack as you want.

So, let's create our environment using yarn with a typescript template:

yarn create react-app minesweeper --template typescript
Enter fullscreen mode Exit fullscreen mode

Let's create our mobx root store src/app/store.ts file. Firstly we need to add the GameType enum

export enum GameType {
  easy,
  medium,
  hard,
  custom,
}
Enter fullscreen mode Exit fullscreen mode

Then, we will add game options. The easiest game will have 9x9 cells and 10 bombs, medium - 16x16 and 40 bombs, and hard - 16x30 and 99 bombs.

type GameOptions = {
  rows: number;
  columns: number;
  bombs: number;
};

const gameOptions: Record<GameType, GameOptions> = {
  [GameType.easy]: {
    rows: 9,
    columns: 9,
    bombs: 10,
  },
  [GameType.medium]: {
    rows: 16,
    columns: 16,
    bombs: 40,
  },
  [GameType.hard]: {
    rows: 16,
    columns: 30,
    bombs: 99,
  },
  [GameType.custom]: {
    rows: 16,
    columns: 30,
    bombs: 99,
  },
};
Enter fullscreen mode Exit fullscreen mode

For the custom game, we will use hard properties as initial values. Setup our root store

class RootStore {
  cells: Cell[] = [];
  gameType: GameType | null = null;
  timer: Timer | null = null;
  initalPress = true;
  inProgress = false;
  winner: boolean | null = null;
  rows: number = 0;
  columns: number = 0;
  bombs: number = 0;

  constructor() {
    makeObservable(this, {
      cells: observable,
      gameType: observable,
      inProgress: observable,
      bombsLeft: computed,
      timer: observable,
      initalPress: observable,
      startGame: action,
      allCellsWithoutBombPressed: computed,
      winner: observable,
      rows: observable,
      columns: observable,
      bombs: observable,
      informWinner: action,
      informLooser: action,
    });

    reaction(
      () => this.allCellsWithoutBombPressed,
      (value) => {
        if (value) {
          this.informWinner();
        }
      }
    );
  }

  get allCellsWithoutBombPressed() {
    return (
      this.cells.length > 0 &&
      this.cells.filter((cell) => !cell.hasBomb).every((cell) => cell.pressed)
    );
  }

  get bombsLeft() {
    ...
  }

  drawGrid() {
    this.cells = new Array(this.rows * this.columns)
      .fill(null)
      .map(() => new Cell());
  }

  assignBombs(ignoreCell: Cell) {
    const totalCells = this.rows * this.columns;

    const set = new Set();

    while (set.size < this.bombs) {
      const index = Math.floor(Math.random() * totalCells);
      if (ignoreCell !== this.cells[index]) {
        set.add(index);
      }
    }

    let prevRow: Cell[] | null = null;
    let row = this.cells.slice(0, this.columns);

    for (let i = 0; i < this.rows; i++) {
      let nextRow: Cell[] | null = null;

      if (i < this.rows - 1) {
        nextRow = this.cells.slice(
          (i + 1) * this.columns,
          (i + 2) * this.columns
        );
      }
      for (let j = 0; j < this.columns; j++) {
        const key = i * this.columns + j;
        const cell = this.cells[key];
        cell.hasBomb = set.has(key);

        if (prevRow) {
          if (prevRow[j - 1]) cell.siblings.push(prevRow[j - 1]);
          cell.siblings.push(prevRow[j]);
          if (prevRow[j + 1]) cell.siblings.push(prevRow[j + 1]);
        }

        if (row[j - 1]) {
          cell.siblings.push(row[j - 1]);
        }

        if (row[j + 1]) {
          cell.siblings.push(row[j + 1]);
        }

        if (nextRow) {
          if (nextRow[j - 1]) cell.siblings.push(nextRow[j - 1]);
          cell.siblings.push(nextRow[j]);
          if (nextRow[j + 1]) cell.siblings.push(nextRow[j + 1]);
        }
      }
      prevRow = row;
      if (nextRow) {
        row = nextRow;
      }
    }
  }

  initializeGame(ingoreCell: Cell) {
    this.initalPress = false;
    this.timer?.start();
    this.assignBombs(ingoreCell);
  }

  startGame(type: GameType, options?: GameOptions) {
    this.inProgress = true;
    this.gameType = type;
    this.winner = null;
    this.initalPress = true;
    this.timer?.stop();
    this.timer = new Timer();

    const { rows, columns, bombs } = options ?? gameOptions[type];
    this.rows = rows;
    this.columns = columns;
    this.bombs = bombs;

    this.drawGrid();
  }

  informWinner() {
    ...
  }

  informLooser() {
    ...
  }
}

export const store = new RootStore();
Enter fullscreen mode Exit fullscreen mode

We will use mobx reaction to check if all cells without mine are revealed. Reactions are used to make side effects in your model. As you can see getter allCellsWithoutBombPressed returns a bool value.

reaction takes two functions: the data function is tracked and returns the data that is used as input for the second, effect function. It is important to note that the side effect only reacts to the data accessed in the data function, which might be less than the data used in the effect function.

So, now we can inform the player when he wins the game.

reaction(
      () => this.allCellsWithoutBombPressed,
      (value) => {
        if (value) {
          this.informWinner();
        }
      }
);
Enter fullscreen mode Exit fullscreen mode

Next, we need to implement the Cell class and add functionality to our cell.

Create src/app/cell.ts

export class Cell {
  pressed = false;
  hasBomb = false;
  marked = false;
  neighbours: Cell[] = [];
  constructor() {
    makeObservable(this, {
      pressed: observable,
      hasBomb: observable,
      press: action,
      bombsAmount: computed,
      neighbours: observable,
      marked: observable,
    });
  }

  press() {
    this.pressed = true;
    this.marked = false;

    if (this.bombsAmount === 0 && !this.hasBomb) {
      this.revealEmptyNeighbours()
    }
  }

  toggleMark() {
    this.marked = !this.marked;
  }

  get bombsAmount() {
    return this.neighbours.filter((cell) => cell.hasBomb).length;
  }

  revealEmptyNeighbours() {
    this.neighbours.forEach((cell) => {
      if (cell.pressed || cell.hasBomb) {
        return;
      }

      cell.press();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Each cell has a neighbours array to store cells next to the current cell.

We need to keep track of how many neighbours have mine. For this, we will use the bombsAmount getter. This number we will show when revealing the cell.

get bombsAmount() {
    return this.neighbours.filter((cell) => cell.hasBomb).length;
}
Enter fullscreen mode Exit fullscreen mode

When we press on a cell we set pressed to true then we need to reveal empty neighbours if this cell doesn't have neighbours with mines. revealEmptyNeighbours method will run press for each neighbour

press() {
    this.pressed = true;
    this.marked = false;

    if (this.bombsAmount === 0 && !this.hasBomb) {
      this.revealEmptyNeighbours()
    }
}
Enter fullscreen mode Exit fullscreen mode

And our game needs a timer.
Create src/app/timer.ts

export class Timer {
  secondsPassed = 0;
  interval: NodeJS.Timer | null = null;
  startedAt: number = 0;

  constructor() {
    makeAutoObservable(this);
  }

  increaseTimer() {
    this.secondsPassed = (performance.now() - this.startedAt) / 1000;
  }

  start() {
    this.startedAt = performance.now();
    this.interval = setInterval(() => {
      this.increaseTimer();
    }, 1000);
  }

  stop() {
    if (this.interval) {
      clearInterval(this.interval);
    }
    this.increaseTimer();
    return this.secondsPassed;
  }
}
Enter fullscreen mode Exit fullscreen mode

The timer is a simple class that has start, and stop methods. When we start the timer then we initialise startedAt and interval fields. We use the performance application interface to determine how many seconds passed since the timer has started.

For our game, we need a header panel that shows how many mines we need to find a new game button and timer

header

So, let's create components for them. As you can see a mine counter and timer have the same styles, we can create a component that receives a number and shows only three characters when the number is more than 999 and fill 0 when characters are less than 3. Next, create our Count component, padStart method helps to handle this behaviour.

const displayCount = (count: number) => {
  const strCount = count.toString();
  if (strCount.length > 3) {
    return "999";
  }

  return strCount.padStart(3, "0");
};

export interface Props extends PropsWithChildren {
  count: number;
}

export const Count = ({ count }: Props) => {
  return <div className={styles.Count}>{displayCount(count)}</div>;
};
Enter fullscreen mode Exit fullscreen mode

We will use the helper function displayCount that returns a string representation of the number. Then reuse this component for our needs.

Create src/components/bombsCount/BombsCount.tsx and connect our component to the store using observer from mobx-react-lite. It will subscribe for store changes

import { observer } from "mobx-react-lite";
import { store } from "../../app/store";
import { Count } from "../count/Count";

export const BombsCount = observer(() => {
  return <Count count={store.bombsLeft} />;
});
Enter fullscreen mode Exit fullscreen mode

We will use bombsLeft from our root store. We need to wrap our component with observer HoC and in simple words it will render component whenever bombsLeft getter is changed. More information you can read here

The observer HoC automatically subscribes React components to any observables that are used during rendering. As a result, components will automatically re-render when relevant observables change. It also makes sure that components don't re-render when there are no relevant changes. So, observables that are accessible by the component, but not actually read, won't ever cause a re-render.

You can use as many observable components as you want.

Next, create src/components/Timer.tsx component. It will show how many seconds passed after starting the game.

import { observer } from "mobx-react-lite";
import { store } from "../../app/store";
import { Count } from "../count/Count";

export const Timer = observer(() => {
  return (
    <Count count={store.timer ? Math.floor(store.timer.secondsPassed) : 0} />
  );
});
Enter fullscreen mode Exit fullscreen mode

Then, create src/components/NewGameButton.tsx. It will show win or lose smile and start new game on click with the same properties.

export const NewGameButton = observer(() => {
  return (
    <button
      className={clsx(styles.root, {
        [styles.winner]: store.winner,
        [styles.loser]: store.winner === false,
      })}
      onClick={() => {
        store.startGame(store.gameType!, {
          rows: store.rows,
          columns: store.columns,
          bombs: store.bombs,
        });
      }}
    ></button>
  );
});
Enter fullscreen mode Exit fullscreen mode

Now, create src/components/board/Board.tsx. We can place our cells using css grid templates

export const Board = observer(() => {
  return (
    <div
      className={styles.Board}
      style={{
        gridTemplateRows: `repeat(${store.rows}, 30px)`,
        gridTemplateColumns: `repeat(${store.columns}, 30px)`,
      }}
    >
      {store.cells.map((cell, i) => (
        <Cell key={i} cell={cell} />
      ))}
    </div>
  );
});
Enter fullscreen mode Exit fullscreen mode

Now, we have header and cells, then we can create gaming layout

src/layout/game/Game.tsx

export interface Props extends PropsWithChildren {
  type: GameType;
}

export const Game = ({ type }: Props) => {
  useEffect(() => {
    store.startGame(type);
  }, [type]);

  return (
    <div className="Game">
      <header className="Game__header">
        <div className="bombs_counter">
          <BombsCount />
        </div>
        <div className="new_game">
          <NewGameButton />
        </div>
        <div className="timer">
          <Timer />
        </div>
      </header>
      <div className="Game__content">
        <Board />
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here, we will start the game when component property type is changed.

Then, create src/layout/layout/Layout.tsx

export const Layout = () => {
  return (
    <div>
      <nav className="nav">
        <ul>
          <li>
            <CustomLink to="/easy">Easy</CustomLink>
          </li>
          <li>
            <CustomLink to="/medium">Medium</CustomLink>
          </li>
          <li>
            <CustomLink to="/hard">Hard</CustomLink>
          </li>
          <li>
            <CustomLink to="/custom">Custom</CustomLink>
          </li>
        </ul>
      </nav>

      <Outlet />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Outlet component helps us to render child routes.

CustomLink component is Link from with custom style decoration for our links. We will underline active link.

Now, we can add routing to render specific game type on different routes. We will use react-router-dom v6. In App.tsx add

<Routes>
  <Route path="/" element={<Layout />}>
    <Route index element={<Navigate to="easy" />} />
    <Route path="easy" element={<Game type={GameType.easy} />} />
    <Route path="medium" element={<Game type={GameType.medium} />} />
    <Route path="hard" element={<Game type={GameType.hard} />} />
    <Route path="custom" element={<CustomGame />} />
  </Route>
  <Route path="*" element={<Navigate to="easy" />} />
</Routes>
Enter fullscreen mode Exit fullscreen mode

We will have /easy, /medium, /hard and /custom routes. As element we render Game component with specific type property. For /custom route we need to have additional form to let player enter rows, columns, bombs for game.

Now create form for custom game src/components/customGameForm/CustomGameForm.tsx

interface FormElements extends HTMLFormControlsCollection {
  rows: HTMLInputElement;
  columns: HTMLInputElement;
  bombs: HTMLInputElement;
}

export const CustomGameForm = () => {
  const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();

    const formElements = e.currentTarget.elements as FormElements;

    const rows = parseInt(formElements.rows.value, 10);
    const columns = parseInt(formElements.columns.value, 10);
    const bombs = parseInt(formElements.bombs.value, 10);

    if (rows * columns <= bombs) {
      alert("Bombs must be less then square count");
      return;
    }

    store.startGame(GameType.custom, {
      rows,
      columns,
      bombs,
    });
  };

  return (
    <form onSubmit={handleSubmit} className={styles.root}>
      <input
        className={styles.input}
        type="number"
        name="rows"
        id="rows"
        placeholder="Rows"
        defaultValue={customGameOptions.rows}
        min={1}
      />
      <input
        className={styles.input}
        type="number"
        name="columns"
        id="columns"
        placeholder="Columns"
        defaultValue={customGameOptions.columns}
        min={1}
      />
      <input
        className={styles.input}
        type="number"
        name="bombs"
        id="bombs"
        placeholder="Bombs"
        defaultValue={customGameOptions.bombs}
        min={1}
        max={999}
      />
      <input className={styles.input} type="submit" />
    </form>
  );
};

Enter fullscreen mode Exit fullscreen mode

Here we create a form component and assign onSubmit function. Fields we can get from the event argument e.currentTarget.elements property. We define interface FormElements and write our field names.

Then we can create src/layout/customGame/CustomGame.tsx and use form there

export const CustomGame = () => {
  return (
    <div>
      <CustomGameForm />
      <Game type={GameType.custom} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

custom game

Finally we can run yarn start and play game. There is one issue in custom game. When we type rows more then 50 and columns more than 50 and start game we receive Maximum call stack size exceeded error.

max call stack error

The error means that our call stack is full of functions. Let see what happend when we click a cell. We call cell.press() method, then call revealEmptyNeighbours, the method runs press for each sibling and repeat while all empty siblings will be revealed. Therefore, when we have a lot of cells stack looks like

press();
revealEmptyNeighbours();
press();
revealEmptyNeighbours();
press();
...
Enter fullscreen mode Exit fullscreen mode
...
press() {
    this.pressed = true;
    this.marked = false;

    if (this.bombsAmount === 0 && !this.hasBomb) {
      this.revealEmptyNeighbours();
    }
}

revealEmptyNeighbours() {
    this.neighbours.forEach((cell) => {
      if (cell.pressed || cell.hasBomb) {
        return;
      }

      cell.press();
    });
}
...
Enter fullscreen mode Exit fullscreen mode

We can fix this by using setTimeout. Let wrap this.revealEmptyNeighbours(); into setTimeout.

press() {
    this.pressed = true;
    this.marked = false;

    if (this.bombsAmount === 0 && !this.hasBomb) {
      setTimeout(() => this.revealEmptyNeighbours());
    }
}
Enter fullscreen mode Exit fullscreen mode

Instead of recursively adding functions to stack, we will run revealEmptyNeighbours() when our synchronous code has been executed. Now, we can play custom game with 100x100 or even 200x200 without any errors.

Here is screenshot custom game 100x100 with 200 mines

100x100 custom game

Mobx is really powerful state management library. I would recommend you to use it when you have a lot of custom events, side effects in web app. For more information you can read about mobx understanding reactivity.

If you are still here, thank you for reading. Hope you like it. It is my first article, so fill free to write some feedback or any comments. The code is available in my public repo.

Top comments (0)