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
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,
}
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,
},
};
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();
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();
}
}
);
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();
});
}
}
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;
}
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()
}
}
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;
}
}
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
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>;
};
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} />;
});
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} />
);
});
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>
);
});
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>
);
});
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>
);
};
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>
);
};
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>
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>
);
};
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>
);
};
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.
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();
...
...
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();
});
}
...
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());
}
}
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
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)