diff --git a/src/assets/fonts.css b/src/assets/fonts.css
index 3f2b627..9bf872e 100644
--- a/src/assets/fonts.css
+++ b/src/assets/fonts.css
@@ -67,4 +67,9 @@
@font-face {
font-family: Reactor7;
src: url('fonts/Reactor7.ttf') format('truetype');
-}
\ No newline at end of file
+}
+
+@font-face {
+ font-family: minesweeper;
+ src: url('fonts/Minesweeper.woff2') format('woff2');
+}
diff --git a/src/assets/fonts/Minesweeper.woff2 b/src/assets/fonts/Minesweeper.woff2
new file mode 100644
index 0000000..732ca9c
Binary files /dev/null and b/src/assets/fonts/Minesweeper.woff2 differ
diff --git a/src/channels/index.ts b/src/channels/index.ts
index f55d4bd..8b9056b 100644
--- a/src/channels/index.ts
+++ b/src/channels/index.ts
@@ -15,5 +15,6 @@ import './dragon-warrior';
import './ff-shop';
import './template';
import './papers-please';
+import './minesweeper';
export * from './channels';
diff --git a/src/channels/minesweeper/Face.tsx b/src/channels/minesweeper/Face.tsx
new file mode 100644
index 0000000..0557396
--- /dev/null
+++ b/src/channels/minesweeper/Face.tsx
@@ -0,0 +1,29 @@
+import { css } from '@emotion/react';
+import { FACE_DIMENSION, FACE_ORDER } from './constants';
+
+import faces from './assets/faces.png';
+
+export type FaceType = (typeof FACE_ORDER)[number];
+
+function getFaceOffset(face: FaceType) {
+ const faceIndex = FACE_ORDER.indexOf(face);
+ return faceIndex * FACE_DIMENSION;
+}
+
+type FaceProps = {
+ face: FaceType;
+};
+
+export function Face({ face }: FaceProps) {
+ return (
+
+ );
+}
diff --git a/src/channels/minesweeper/Tile.tsx b/src/channels/minesweeper/Tile.tsx
new file mode 100644
index 0000000..62c232d
--- /dev/null
+++ b/src/channels/minesweeper/Tile.tsx
@@ -0,0 +1,25 @@
+import { css } from '@emotion/react';
+import { TILE_DIMENSION } from './constants';
+
+import tiles from './assets/tiles.png';
+
+export type TileData = {
+ id: string;
+ tileType: readonly [number, number];
+ isMine: boolean;
+};
+
+export function Tile({ tileType }: TileData) {
+ const [x, y] = tileType;
+
+ return (
+
+ );
+}
diff --git a/src/channels/minesweeper/assets/faces.png b/src/channels/minesweeper/assets/faces.png
new file mode 100644
index 0000000..7fb4363
Binary files /dev/null and b/src/channels/minesweeper/assets/faces.png differ
diff --git a/src/channels/minesweeper/assets/tiles.png b/src/channels/minesweeper/assets/tiles.png
new file mode 100644
index 0000000..a9934b8
Binary files /dev/null and b/src/channels/minesweeper/assets/tiles.png differ
diff --git a/src/channels/minesweeper/constants.ts b/src/channels/minesweeper/constants.ts
new file mode 100644
index 0000000..038fb6a
--- /dev/null
+++ b/src/channels/minesweeper/constants.ts
@@ -0,0 +1,42 @@
+export const FACE_DIMENSION = 24;
+export const FACE_ORDER = ['smile', 'smile_pressed', 'open_mouth', 'sunglasses', 'heart_eyes'] as const;
+
+export const MINE_CHANCE = 0.17;
+
+export const GRID_ROWS = 16;
+export const GRID_COLUMNS = 67;
+
+export const TILE_DIMENSION = 16;
+
+export const TILE_MAP = {
+ HIDDEN: [0, 0],
+ ONE: [0, 1],
+ TWO: [1, 1],
+ THREE: [2, 1],
+ FOUR: [3, 1],
+ FIVE: [4, 1],
+ SIX: [5, 1],
+ SEVEN: [6, 1],
+ EIGHT: [7, 1],
+ EMPTY: [1, 0],
+ FLAGGED: [2, 0],
+ QUESTION_MARK: [3, 0],
+} as const;
+
+export const mineNumberTiles = [
+ TILE_MAP.EMPTY,
+ TILE_MAP.ONE,
+ TILE_MAP.TWO,
+ TILE_MAP.THREE,
+ TILE_MAP.FOUR,
+ TILE_MAP.FIVE,
+ TILE_MAP.SIX,
+ TILE_MAP.SEVEN,
+ TILE_MAP.EIGHT,
+];
+
+export const MIN_REVEAL_DONATION = 20;
+/** maximum donation amount for determining reveal threshold */
+export const REVEAL_DONATION_CAP = 500;
+export const MIN_REVEALED_TILES = 1;
+export const MAX_REVEALED_TILES = 25;
diff --git a/src/channels/minesweeper/index.tsx b/src/channels/minesweeper/index.tsx
new file mode 100644
index 0000000..3f67836
--- /dev/null
+++ b/src/channels/minesweeper/index.tsx
@@ -0,0 +1,294 @@
+import { useEffect, useReducer, useRef, useState } from 'react';
+import { Global, css } from '@emotion/react';
+import styled from '@emotion/styled';
+import TweenNumber from '@gdq/lib/components/TweenNumber';
+import { ChannelProps, registerChannel } from '../channels';
+import { useListenFor, useReplicant } from 'use-nodecg';
+import type { Event, FormattedDonation, Total } from '@gdq/types/tracker';
+
+import { Face, type FaceType } from './Face';
+import { Tile, TileData } from './Tile';
+import { TILE_DIMENSION, GRID_COLUMNS, GRID_ROWS, TILE_MAP, MINE_CHANCE, MIN_REVEAL_DONATION } from './constants';
+import { createTileCluster, getTileRevealThreshold, random, randomFromArray, splitTileIndex } from './utils';
+import { usePreloadedReplicant } from '@gdq/lib/hooks/usePreloadedReplicant';
+import { cloneDeep } from 'lodash';
+
+registerChannel('Minesweeper', 132, Minesweeper, {
+ position: 'bottomRight',
+ handle: 'rshig',
+});
+
+type GridState = {
+ grid: TileData[][];
+ mines: string[];
+ nonMines: string[];
+};
+
+function generateInitialGridState(): GridState {
+ const state: GridState = { grid: [], mines: [], nonMines: [] };
+ for (let i = 0; i < GRID_ROWS; i++) {
+ state.grid[i] = [];
+ for (let j = 0; j < GRID_COLUMNS; j++) {
+ const isMine = Math.random() < MINE_CHANCE;
+ const id = `${i}:${j}`;
+ state.grid[i][j] = {
+ id,
+ isMine,
+ tileType: TILE_MAP.HIDDEN,
+ };
+ if (isMine) {
+ state.mines.push(id);
+ } else {
+ state.nonMines.push(id);
+ }
+ }
+ }
+ return state;
+}
+
+const stateReplicant = nodecg.Replicant('minesweeper-state', {
+ defaultValue: generateInitialGridState(),
+ persistent: true,
+});
+
+const actions = {
+ RESET: 'reset',
+ FLAG_TILE: 'flag',
+ REVEAL_TILES: 'reveal',
+ QUESTION_TILE: 'question',
+} as const;
+
+type GridAction =
+ | { type: typeof actions.FLAG_TILE }
+ | { type: typeof actions.RESET }
+ | { type: typeof actions.REVEAL_TILES; donationAmount: number }
+ | { type: typeof actions.QUESTION_TILE };
+
+function gridReducer(state: GridState, action: GridAction) {
+ switch (action.type) {
+ case actions.RESET: {
+ const newState = generateInitialGridState();
+ stateReplicant.value = newState;
+ return newState;
+ }
+ case actions.FLAG_TILE: {
+ if (state.mines.length > 0) {
+ const tileIndexStr = randomFromArray(state.mines);
+ const [rowIndex, tileIndex] = splitTileIndex(tileIndexStr);
+
+ const newGrid = [...state.grid];
+ newGrid[rowIndex][tileIndex].tileType = TILE_MAP.FLAGGED;
+
+ const newMines = state.mines.filter((mineIndex) => mineIndex !== tileIndexStr);
+
+ const newState = { ...state, grid: newGrid, mines: newMines };
+ stateReplicant.value = newState;
+ return newState;
+ }
+ return state;
+ }
+ case actions.REVEAL_TILES: {
+ if (state.nonMines.length > 0) {
+ const revealThreshold = Math.min(
+ // ensure the threashold doesn't exceed the number of nonMine tiles
+ getTileRevealThreshold(action.donationAmount),
+ state.nonMines.length,
+ );
+
+ let revealedTiles: TileData[] = [];
+ let grid = [...state.grid];
+ let nonMines = [...state.nonMines];
+
+ // ensures that bigger donations reveal more tiles
+ while (revealedTiles.length < revealThreshold) {
+ const tileIndexStr = randomFromArray(nonMines);
+ const tilesToReveal = createTileCluster(grid, tileIndexStr);
+ revealedTiles = [...revealedTiles, ...tilesToReveal];
+
+ grid = grid.map((row) => {
+ return row.map((tile) => {
+ const tileInRevealList = tilesToReveal.find((tileToReveal) => tileToReveal.id === tile.id);
+ return tileInRevealList || tile;
+ });
+ });
+
+ // remove any revealed tiles from the nonMines list so they can't be selected again
+ const revealedTileIds = tilesToReveal.map((revealedTile) => revealedTile.id);
+ nonMines = nonMines.filter((id) => !revealedTileIds.includes(id));
+ }
+
+ const newState = { ...state, grid, nonMines };
+ stateReplicant.value = newState;
+ return newState;
+ }
+ return state;
+ }
+ case actions.QUESTION_TILE: {
+ if (state.mines.length > 0) {
+ const tileIndexStr = randomFromArray(state.mines);
+ const [rowIndex, tileIndex] = splitTileIndex(tileIndexStr);
+
+ const newGrid = [...state.grid];
+ newGrid[rowIndex][tileIndex].tileType = TILE_MAP.QUESTION_MARK;
+
+ const newState = { ...state, grid: newGrid };
+ stateReplicant.value = newState;
+ return newState;
+ }
+ return state;
+ }
+ default: {
+ return state;
+ }
+ }
+}
+
+export function Minesweeper(props: ChannelProps) {
+ const [currentEvent] = usePreloadedReplicant('currentEvent');
+ const [total] = useReplicant('total', null);
+
+ const [gridState, dispatch] = useReducer(gridReducer, cloneDeep(stateReplicant.value!));
+
+ const [face, setFace] = useState('smile');
+ const faceChangeTimeout = useRef();
+
+ function changeFace(face: FaceType) {
+ clearTimeout(faceChangeTimeout.current);
+ setFace(face);
+ faceChangeTimeout.current = setTimeout(() => setFace('smile'), 2_500);
+ }
+
+ useListenFor('donation', (donation: FormattedDonation) => {
+ changeFace(donation.rawAmount < MIN_REVEAL_DONATION ? 'open_mouth' : 'sunglasses');
+
+ if (donation.rawAmount < MIN_REVEAL_DONATION && gridState.mines.length > 0) {
+ dispatch({ type: actions.FLAG_TILE });
+ } else {
+ dispatch({ type: actions.REVEAL_TILES, donationAmount: donation.rawAmount });
+ }
+ });
+
+ useListenFor('subscription', () => {
+ changeFace('heart_eyes');
+ });
+
+ useEffect(() => {
+ if (gridState.nonMines.length === 0) {
+ // reset the grid when we run out of hidden tiles to reveal
+ dispatch({ type: actions.RESET });
+ }
+ }, [gridState.nonMines]);
+
+ const flagTimeoutRef = useRef();
+ useEffect(() => {
+ function flagTiles(timeoutMS: number) {
+ // Add a question mark every 5-10 seconds
+ const newTimeout = random(5_000, 10_000);
+ flagTimeoutRef.current = setTimeout(() => {
+ dispatch({ type: actions.QUESTION_TILE });
+ flagTiles(newTimeout);
+ }, timeoutMS);
+ }
+
+ flagTiles(5_000);
+
+ return () => {
+ clearTimeout(flagTimeoutRef.current);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+ {currentEvent.shortname}
+
+
+
+
+ $
+
+
+
+
+ {gridState.grid.map((row) => {
+ return row.map((cell) => );
+ })}
+
+
+
+ );
+}
+
+const Container = styled.div`
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ margin: 0;
+ background: #c0c0c0;
+ border: 1px solid;
+ border-color: #fff #aca899 #aca899 #fff;
+`;
+
+const Wrapper = styled.div`
+ position: relative;
+ display: flex;
+ gap: 5px;
+ flex-direction: column;
+ height: 100%;
+ background-color: #bdbdbd;
+ padding: 5px;
+ border-left: 3px solid #fff;
+ border-top: 3px solid #fff;
+`;
+
+const Header = styled.header`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 5px;
+ height: 50px;
+ border: 2px solid;
+ border-color: #7d7d7d #fff #fff #7d7d7d;
+`;
+
+const Grid = styled.div`
+ flex: 1;
+ display: grid;
+ grid-template-rows: repeat(${GRID_ROWS}, ${TILE_DIMENSION}px);
+ grid-template-columns: repeat(${GRID_COLUMNS}, ${TILE_DIMENSION}px);
+ place-content: center;
+ border: 2px solid;
+ border-color: #7d7d7d #fff #fff #7d7d7d;
+`;
+
+const LCDContainer = styled.div`
+ flex: 1;
+ display: flex;
+`;
+
+const LCDText = styled.div`
+ font-family: minesweeper;
+ font-size: 32px;
+ text-transform: uppercase;
+ color: #ea3323;
+ background: #000;
+ border: 1px solid;
+ border-color: #808080 #fff #fff #808080;
+ padding: 1px;
+`;
diff --git a/src/channels/minesweeper/utils.ts b/src/channels/minesweeper/utils.ts
new file mode 100644
index 0000000..36bae3d
--- /dev/null
+++ b/src/channels/minesweeper/utils.ts
@@ -0,0 +1,110 @@
+import { TileData } from './Tile';
+import {
+ REVEAL_DONATION_CAP,
+ GRID_COLUMNS,
+ GRID_ROWS,
+ MAX_REVEALED_TILES,
+ MIN_REVEAL_DONATION,
+ MIN_REVEALED_TILES,
+ mineNumberTiles,
+} from './constants';
+
+export function random(min: number, max: number) {
+ return Math.floor(Math.random() * (max - min)) + min;
+}
+
+export function randomFromArray(arr: T[]) {
+ const randomIndex = random(0, arr.length);
+ return arr[randomIndex];
+}
+
+export function splitTileIndex(indexStr: string) {
+ return indexStr.split(':').map((index) => Number(index));
+}
+
+export function isTileInbounds(rowIndex: number, tileIndex: number) {
+ return rowIndex >= 0 && rowIndex < GRID_ROWS && tileIndex >= 0 && tileIndex < GRID_COLUMNS;
+}
+
+function getAdjacentTiles(rowIndex: number, tileIndex: number) {
+ return [
+ [rowIndex - 1, tileIndex],
+ [rowIndex, tileIndex - 1],
+ [rowIndex, tileIndex + 1],
+ [rowIndex + 1, tileIndex],
+ ];
+}
+
+function getSurroundingTiles(rowIndex: number, tileIndex: number) {
+ return [
+ [rowIndex - 1, tileIndex - 1],
+ [rowIndex - 1, tileIndex],
+ [rowIndex - 1, tileIndex + 1],
+ [rowIndex, tileIndex - 1],
+ [rowIndex, tileIndex + 1],
+ [rowIndex + 1, tileIndex - 1],
+ [rowIndex + 1, tileIndex],
+ [rowIndex + 1, tileIndex + 1],
+ ];
+}
+
+export function getMineCount(grid: TileData[][], tileId: string) {
+ const [rowIndex, tileIndex] = splitTileIndex(tileId);
+ const surroundingTiles = getSurroundingTiles(rowIndex, tileIndex);
+ const surroundingMineCount = surroundingTiles.reduce((mineCount, [r, t]) => {
+ if (!isTileInbounds(r, t)) {
+ return mineCount;
+ }
+ return mineCount + (grid[r][t].isMine ? 1 : 0);
+ }, 0);
+ return surroundingMineCount;
+}
+
+export function createTileCluster(grid: TileData[][], startingTileId: string) {
+ const visitedTiles: TileData[] = [];
+ const [rowIndex, tileIndex] = splitTileIndex(startingTileId);
+
+ function visitTile(rowIndex: number, tileIndex: number) {
+ const tile = grid[rowIndex]?.[tileIndex] as TileData | undefined;
+ // stop tile from being revealed (and cascading further)
+ // when the following conditions are met
+ if (
+ // not a valid tile
+ !tile ||
+ // the tile is a mine
+ tile.isMine ||
+ // has already been visisted
+ visitedTiles.find((visitedTile) => visitedTile.id === tile.id)
+ ) {
+ return;
+ }
+
+ const mineCount = getMineCount(grid, tile.id);
+ visitedTiles.push({
+ ...tile,
+ tileType: mineNumberTiles[mineCount],
+ });
+
+ // stop revealing once we hit a number tile
+ if (mineCount > 0) {
+ return;
+ }
+
+ const adjacentTiles = getAdjacentTiles(rowIndex, tileIndex);
+ adjacentTiles.forEach(([ajacentRowIndex, adjacentTileIndex]) => visitTile(ajacentRowIndex, adjacentTileIndex));
+ }
+
+ visitTile(rowIndex, tileIndex);
+
+ return visitedTiles;
+}
+
+export function getTileRevealThreshold(donationAmount: number) {
+ // cap the maximum donation amount
+ const amount = Math.min(donationAmount, REVEAL_DONATION_CAP);
+ // transforms donation range from $(MIN_DONATION_TO_REVEAL) - $(DONATION_REVEAL_CAP)
+ // to (MIN_REVEALED_TILES) to (MAX_REVEALED_TILES) revealed tile range
+ const scale = (MAX_REVEALED_TILES - MIN_REVEALED_TILES) / (REVEAL_DONATION_CAP - MIN_REVEAL_DONATION);
+ // find donation position in the new scale
+ return Math.ceil(amount * scale + MIN_REVEALED_TILES);
+}