EECS 498 APSD P1
Minesweeper Rubric
Overview
- Grid Representation
- Cell Access and Bounds Checking
- Board Abstraction
- Initialization of Game State
- Decoupled File I/O and Command-Line Arguments
- Decoupled User Interaction
- Parsing and Validation
- Recursive Reveal Implementation
- Printing the Board
- Meaningful Variable and Function Names
1 Grid Representation
This grid of cells may be represented as a 2D array of cells, for example:
type MinesweeperContents = "mine" | "empty";
type MinesweeperStatus = "hidden" | "revealed" | "flagged";
// GOOD
interface MinesweeperCell {
contents: MinesweeperContents;
status: MinesweeperStatus;
adjacentMines: number; // Invariant: must be in range [0,8]
};
// NOT GOOD
interface MinesweeperCell {
contents: string; // may be "*" or " " or "1", "2", etc.
is_revealed: boolean;
is_flagged: boolean;
adjacentMines: number;
};
// Grid is stored as MinesweeperCell[][]
We didn’t penalize directly for representations that used multiple, separate 2D arrays.
The representation of a cell should prohibit malformed or invalid states:
- String literal unions or enums should be used. (Both are ok, but the former is more idiomatic in TS.)
- It should be impossible to represent an invalid cell, e.g. a both revealed and flagged.
These should be enforced at compile time where possible (rather than with runtime assertions).
An exception is the number of adjacent mines, which should likely just be stored as a number. While a literal union type like 0 | 1 | 2 ... could be used, Typescript’s handling of arithmetic operations on them isn’t completely sound and so this might actually create a false sense of security. Instead, we can treat the range of acceptable values as a representation invariant and check it with assertions in key places.
The cell contents and number of mines should not be packed into a single value (e.g. a string “*”, “ “, “1”, “2”, … or a number 0, 1, 2, … with -1 representing a mine). We now have increased complexity of parsing/unparsing, and there’s lurking obscurity if we ever need to adjust the number (e.g. new features that add/remove mines during a game). Wouldn’t it be surprising if we have “5” mines stored, and + 1 mines yields “51”?
2 Cell Access and Bounds Checking
Access to cells via indexing should be done through a single function with assertions to verify preconditions that the coordinates are in bounds. No other code should use [][] directly to access individual cells by coordinate. For example:
private cell_at(x: number, y: number): MinesweeperCell {
assert(this.in_bounds(x,y));
return this.grid[y]![x]!; // ! assertions fine since x, y are verified in-bounds
}
Conveniently, this lets you centralize the ! assertions rather than having to write them all over the place.
The cell access function should use assertions rather than throwing exceptions or returning optional (T | undefined) values. If internal code goes out of bounds, that’s a bug in the program and should fail fast with an assertion error. User input (e.g. coordinates entered by the player) should be validated before calling these functions. This is the “Look Before You Leap” (LBYL) approach, which tends to be cleaner when you can truly check preconditions in advance. As a bonus, any code calling cell_at can rely on receiving a valid cell without having to handle undefined or catch exceptions.
There are a few basic ways to handle mutability of cells:
- The
MinesweeperCelltype itself is immutable (all propertiesreadonly), and an update to the grid just replaces the whole cell object, for example through asetCell(x, y, cell)function. In this case, make sure not to hold aliases anywhere else to grid cells, since they may be replaced. - The cells are at least partially mutable, e.g. the
statusproperty can be updated in place. In this case, you should be careful that your minesweeper game abstraction does not allow external access to cells that would allow mutation outside of its control (e.g. returning references to cells from apublicmethod).
A potentially elegant solution is to create a separate generic Grid<T> class, plus a parallel ImmutableGridView<T> interface that provides an immutable view of the data. You can use the regular Grid<T> for internal mutable access, but only expose the ImmutableGridView<T> to external code. Given Typescript’s structural typing, this works smoothly. For example:
class Grid<T extends {}> {
private readonly cells: T[][];
public readonly width: number;
public readonly height: number;
// ...
public at(x: number, y: number): T {
assert(this.inBounds(x,y));
return this.cells[y]![x]!;
}
}
export interface ImmutableGridView<T extends {}> {
readonly width: number;
readonly height: number;
inBounds(x: number, y: number): boolean;
at(x: number, y: number): Readonly<T>; // shallow readonly on T, but fine for our purposes
}
class MinesweeperGame {
private _board: Grid<MinesweeperCell>; // MinesweeperCell is mutable internally
// If there is a need to provide read only public access to cells,
// for example for an external UI, we can return an ImmutableGridView:
public get board(): ImmutableGridView<MinesweeperCell> {
return this._board;
}
}
3 Game and/or Board Abstraction
Several operations related to the game and/or board are worth abstracting:
- Finding neighboring cells
- Counting adjacent mines
- Revealing cells
- Flagging cells
- Checking win/loss conditions
These are relatively deep abstractions and will make the code easier to read and maintain (as opposed to a single large play_game() function with everything mixed together).
You may choose to create a separate Board abstraction or class that encapsulates some of these operations, but it is probably not necessary and may not improve design. In particular, if there is such an abstraction, there should generally be a one-way dependency of the “game” on the “board” to avoid coupling between the two that increases complexity.
4 Initialization of Game State
After construction, the game object should be in a fully valid, playable state. Avoid designs where a game is constructed in a partially initialized state and then requires additional setup calls before it can be used. This can lead to bugs where the game is used before it’s ready.
// GOOD - game is fully initialized after construction
const game = new MinesweeperGame(width, height, numMines);
game.playGame(rl); // ready to play immediately
// NOT GOOD - game requires additional setup after construction
const game = new MinesweeperGame();
game.setDimensions(width, height);
game.placeMines(numMines); // what if someone forgets this step?
game.playGame(rl);
If your game supports restarting (e.g. a reset() method), the initialization logic should not be duplicated between the constructor and the reset method. Duplicated code is easy to get out of sync - you fix a bug in one place and forget the other.
// OK - shared initialization helper
class MinesweeperGame {
constructor(width: number, height: number, numMines: number) {
this.width = width;
this.height = height;
this.numMines = numMines;
this.initializeGameState();
}
public reset(): void {
this.initializeGameState(); // reuses the same logic
}
private initializeGameState(): void {
this.board = /* create new board with random mines */;
this.state = "playing";
this.cellsRevealed = 0;
}
}
// NOT GOOD - duplicated initialization logic
class MinesweeperGame {
constructor(width: number, height: number, numMines: number) {
this.width = width;
this.height = height;
this.numMines = numMines;
this.board = /* create new board with random mines */;
this.state = "playing";
this.cellsRevealed = 0;
}
public reset(): void {
// Copy-pasted from constructor - easy to forget to update both!
this.board = /* create new board with random mines */;
this.state = "playing";
this.cellsRevealed = 0;
}
}
That said, the simplest approach is likely to not support resetting at all unless you have a compelling need. At this point, it’s fine to just have the “driver” create a new game instance when starting over. This avoids the duplication problem entirely.
5 Decoupled File I/O and Command-Line Arguments
The game abstraction should be decoupled from command-line argument (CLA) parsing and file I/O. It should live a happy life, not having to worry about those details. This makes for a cleaner abstraction, with several pragmatic benefits:
- Testability: Unit tests can create game instances with specific, controlled data without dependence on
process.argvor the file system. - Reusability: The game class can be used in different contexts (CLI, web app, GUI) without modification.
- Single Responsibility: The game class focuses on game logic, not I/O concerns.
For example:
// GOOD - main handles CLA parsing and file I/O
async function main() {
const args = process.argv.slice(2);
const [width, height, numMines] = /* parse command-line args */;
const game = new MinesweeperGame(width, height, numMines);
await game.play();
}
class MinesweeperGame {
constructor(width: number, height: number, numMines: number) {
// initialize game state
}
}
// NOT GOOD - game class interfaces with argv directly
class MinesweeperGame {
constructor() {
const args = process.argv.slice(2);
// parse command-line args
// initialize game state
}
}
// STILL NOT GOOD - game class depends on CLA format and parsing, even if not on process.argv directly
class MinesweeperGame {
constructor(args: string[]) {
// parse command-line args
// initialize game state
}
}
However, it might feel like there should be a designated function that handles creating the game from command-line arguments, rather than just presuming “the main function” does it. In this case, static factory methods can implement each of the specific ways to create the class, while keeping the constructor focused on the core initialization. For example:
class MinesweeperGame {
// Constructor takes pre-parsed, validated data
private constructor(board: Grid<MinesweeperCell>, numMines: number) { /* ... */ }
public static createRandom(width: number, height: number, numMines: number): MinesweeperGame {
const board = /* initialize random board based on seed */;
return new MinesweeperGame(board, numMines);
}
// Factory for parsing and creating from CLAs
public static createFromCommandLineArgs(args: string[]): MinesweeperGame {
const [width, height, numMines] = /* parse args */;
return MinesweeperGame.createRandom(width, height, numMines);
}
}
You don’t necessarily need to use this specific pattern, and there are other approaches that will work well for combined games in project 2 - but think about how to ensure that creating each game instance is cleanly separated from parsing CLAs and doing file I/O.
6 Decoupled User Interaction
If your game class has a method that drives the game loop and interacts with the user (e.g. playGame()), it should receive the input facility as a parameter rather than creating it internally. This will allow a single interface instance to be shared across different games.
// GOOD - readline interface is passed in
class MinesweeperGame {
public async playGame(rl: readline.Interface): Promise<void> {
// ...
const input = await rl.question("Enter move: ");
// ...
}
}
// NOT GOOD - game creates its own readline interface
class MinesweeperGame {
public async playGame(): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// ...
const input = await rl.question("Enter move: ");
// ...
}
}
By passing in the readline.Interface, the caller controls the input source. This makes it possible to have a main program that creates a single shared interface and passes it to whichever game the user selects.
You will also want to start thinking about how to completely decouple the user interface from the game logic itself, for example by having the game class provide methods to get the current board state and methods to apply moves, without any direct user interaction. The game itself should also not be concerned with how to print or display itself. This will make it easier to implement different user interfaces (CLI, GUI, web) in the future.
7 Parsing and Validation
Parsing and validation logic should be cleanly written and avoid deep nesting. Use early returns to handle invalid cases and keep the “happy path” at the top level. Extract helper functions where appropriate, and reuse existing predicates (e.g. use inBounds(x, y) rather than re-implementing the bounds check).
// GOOD - clear structure, early returns, reuses inBounds helper
// Parsing: convert raw input string to structured data (or undefined if malformed)
function parseMove(input: string): { action: string, x: string, y: string } | undefined {
const parts = input.trim().split("");
if (parts.length !== 3) return undefined;
const action = parts[0];
if (action !== "reveal" && action !== "flag") return undefined;
const x = letterToIndex(parts[1]);
const y = Number(parts[2]);
if (isNaN(x) || isNaN(y)) return undefined;
return { action, x, y };
}
// Validation: check that parsed data represents a valid move (lives in game class)
class MinesweeperGame {
public isValidMove(x: number, y: number): boolean {
if (!this.inBounds(x, y)) return false;
if (this.getCell(x, y).status === "revealed") return false;
return true;
}
}
// NOT GOOD - deeply nested, duplicates bounds logic
function handleInput(input: string) {
const parts = input.split(" ");
if (parts.length === 3) {
if (parts[0] === "reveal" || parts[0] === "flag") {
const x = letterToIndex(parts[1]);
const y = Number(parts[2]);
if (!isNaN(y)) {
if (x >= 0 && x < width && y >= 0 && y < height) { // duplicated bounds check
// finally do the thing...
}
}
}
}
}
Note that semantic validation (e.g. “is this cell in bounds?”, “is it valid to reveal this cell right now?”) can reasonably live in the game class itself, since it relates to game rules and state.
8 Recursive Reveal Implementation
When revealing a cell that has zero adjacent mines, the game should automatically reveal all adjacent cells, and continue recursively for any of those that also have zero adjacent mines.
The recursive reveal logic should be clean and well-structured:
// GOOD - clear base cases, uses helper for neighbors
public revealCell(x: number, y: number): void {
const cell = this.getCell(x, y);
if (cell.status !== "hidden") return;
// reveal this cell
if (cell.contents === "mine") {
// ...
return;
}
// reveal neighbors if no adjacent mines
if (cell.adjacentMines === 0) {
for (const [nx, ny] of this.neighbors(x, y)) {
// reveal neighbors too
}
}
}
// NOT GOOD - duplicated neighbor logic, unclear structure
public revealCell(x: number, y: number): void {
if (this.grid[y][x].status !== "revealed") {
this.grid[y][x].status = "revealed";
if (this.grid[y][x].contents !== "mine") {
if (this.grid[y][x].adjacentMines === 0) {
// Manually iterate over all 8 neighbors with bounds checks inline
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < this.width && ny >= 0 && ny < this.height) {
this.revealCell(nx, ny);
}
}
}
}
}
else {
// handle mine hit
}
}
}
Key points:
- Use a
neighbors()helper rather than inline iteration with manual bounds checks - Clear base case at the top (cell already revealed or flagged)
- Recursive case is clearly conditional on
adjacentMines === 0
Note that an implementation does not need to be literally recursive - a non-recursive implementation of depth-first search or breadth-first search is fine (and actually better in terms of avoiding stack overflow). However, the “recursive” nature of the operation should still be clear in the structure of the code.
9 Printing the Board
The code that prints the board should clearly represent the current state of each cell. It’s reasonable to use a helper function that converts a cell to its display character to keep the printing logic clean:
function cellToString(cell: MinesweeperCell): string {
if (cell.status === "hidden") return "_";
if (cell.status === "flagged") return "F";
if (cell.contents === "mine") return "*";
return cell.adjacentMines === 0 ? " " : cell.adjacentMines.toString();
}
Showing Hidden Cells on Game Over
When the player hits a mine, the board should be printed with all cells revealed (so the player can see where the mines were). However, you should not actually mutate all cells to “revealed” status - instead, pass an option to the print function:
// GOOD - option controls display without mutating game state
public printBoard(options: { showHidden?: boolean } = {}): void {
for (const row of this.board) {
const rowStr = row.map(cell => cellToString(cell, options.showHidden)).join(" ");
console.log(rowStr);
}
}
function cellToString(cell: MinesweeperCell, showHidden: boolean = false): string {
if (cell.status === "hidden" && !showHidden) return "_";
// ... rest of logic treats cell as if revealed
}
// NOT GOOD - mutates game state just for display purposes
public printBoardRevealed(): void {
for (const cell of this.allCells()) {
cell.status = "revealed"; // permanently changes game state!
}
this.printBoard();
}
Why not mutate the cells? Mutating game state for display purposes conflates two concerns and can cause bugs. For example, if you later add a “rewind” feature or want to show statistics about flagged vs. unflagged mines, the original state is lost.
10 Meaningful Variable and Function Names
Names should be descriptive and convey intent. A reader should be able to understand what a variable holds or what a function does without reading its implementation.
// GOOD - clear, descriptive names
const cellsRevealed: number;
const adjacentMines: number;
function revealCell(x: number, y: number): void;
function isValidMove(x: number, y: number): boolean;
// NOT GOOD - vague or cryptic names
const count: number; // count of what?
const n: number; // what does n represent?
function process(x: number, y: number): void; // process how?
function check(x: number, y: number): boolean; // check what?
Avoid abbreviations unless they are universally understood (e.g. x, y for coordinates is fine). Single-letter names are acceptable for loop indices or very short scopes, but not for class fields or function parameters with significant meaning. Local variables within short functions may use shorter names if the context is clear.
Function names should typically be verbs or verb phrases (revealCell, toggleFlag, countAdjacentMines), while variable names should be nouns or noun phrases (cellsRevealed, adjacentMines, gameState).