EECS 498 APSD P1
Sudoku 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
- Puzzle Library and Encodings
- Printing the Board with Colors
- Meaningful Variable and Function Names
1 Grid Representation
The 9x9 grid of cells may be represented as a 2D array of cells, for example:
// GOOD - clear representation with undefined for empty cells
interface SudokuCell {
digit: number | undefined; // undefined means empty, 1-9 when filled
isStarter: boolean;
solution_digit: number;
};
// ALSO GOOD - using a string type
interface SudokuCell {
symbol: string | undefined; // undefined means empty, "1"-"9" when filled
isStarter: boolean;
solution_symbol: string;
};
// NOT GOOD - magic values for empty cells
interface SudokuCell {
digit: number; // 0 means empty, 1-9 when filled
// ...
};
interface SudokuCell {
symbol: "."; // "." means empty, "1"-"9" when filled
// ...
};
// Grid is stored as SudokuCell[][]
A few notes on the choice of type for digits:
- A literal union type like
1 | 2 | ... | 9is acceptable but not required. A plainnumberworks fine. - A
stringtype is also reasonable. The digits in Sudoku are treated as nominal symbols rather than numeric values - you never do arithmetic on them. This makes strings a natural fit, and would generalize to variants that use letters or other symbols. - Avoid magic values like
0or"."for empty cells. Usingundefinedmakes the “empty” state explicit and lets the type system help catch bugs. While"."might be convenient for printing, it’s an obscure data representation - and in the long-run, where we could anticipate different encodings and/or UIs, the"."will likely cuase more headaches.
Another approach is to store the solution, starter positions, and player’s entries as separate grids rather than combining them into a single cell type:
class SudokuGame {
private solution: number[][]; // the correct answer
private starters: boolean[][]; // which cells are pre-filled
private playerBoard: (number | undefined)[][]; // player's current entries
}
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): SudokuCell {
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
SudokuCelltype 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
digitproperty can be updated in place. In this case, you should be careful that your sudoku 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 SudokuGame {
private _board: Grid<SudokuCell>; // SudokuCell 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<SudokuCell> {
return this._board;
}
}
3 Game and/or Board Abstraction
Several operations related to the game and/or board are worth abstracting:
- Placing a digit in a cell
- Checking if the puzzle is complete
- Printing the board
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 SudokuGame(puzzle, solution);
game.playGame(rl); // ready to play immediately
// NOT GOOD - game requires additional setup after construction
const game = new SudokuGame();
game.loadPuzzle(puzzleString);
game.loadSolution(solutionString); // 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 SudokuGame {
constructor(puzzle: SudokuPuzzleEncoding) {
this.puzzle = puzzle;
this.initializeGameState();
}
public reset(): void {
this.initializeGameState(); // reuses the same logic
}
private initializeGameState(): void {
this.board = /* create board from puzzle, marking starters */;
this.state = "playing";
}
}
// NOT GOOD - duplicated initialization logic
class SudokuGame {
constructor(puzzle: SudokuPuzzleEncoding) {
this.puzzle = puzzle;
this.board = /* create board from puzzle, marking starters */;
this.state = "playing";
}
public reset(): void {
// Copy-pasted from constructor - easy to forget to update both!
this.board = /* create board from puzzle, marking starters */;
this.state = "playing";
}
}
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 difficulty = /* parse command-line arg */;
const puzzleData = fs.readFileSync("sudoku_puzzles.txt", "utf-8");
const puzzle = selectRandomPuzzle(puzzleData, difficulty);
const game = new SudokuGame(puzzle);
await game.play(rl);
}
class SudokuGame {
constructor(puzzle: SudokuPuzzleEncoding) {
// initialize game state from parsed data
}
}
// NOT GOOD - game class handles file I/O directly
class SudokuGame {
constructor(difficulty: number) {
const puzzleData = fs.readFileSync("sudoku_puzzles.txt", "utf-8");
// parse and select puzzle
// initialize game state
}
}
// NOT GOOD - game class still depends on file format
class SudokuGame {
constructor(puzzleFileContents: string, difficulty: number) {
// parse puzzle file contents
// initialize game state
}
}
// NOT GOOD - game class handles CLA parsing directly
class SudokuGame {
constructor() {
const args = process.argv.slice(2);
const difficulty = /* parse command-line arg */;
// ...
}
}
// STILL NOT GOOD - game class depends on CLA format and parsing, even if not on process.argv directly
class SudokuGame {
constructor(args: string[]) {
// parse command-line args
// initialize game state
}
}
However, it might feel like there should be a designated function/class that handles creating a Sudoku games based on a library of puzzles loaded from a file, 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 SudokuGame {
// Constructor takes pre-parsed, validated data
public constructor(puzzle: SudokuPuzzleEncoding) {
// ...
}
}
class SudokuLibrary {
private allPuzzles: SudokuPuzzleEncoding[];
private puzzlesByDifficulty: Map<number, SudokuPuzzleEncoding[]>;
private constructor(puzzles: SudokuPuzzleEncoding[]) {
this.allPuzzles = puzzles;
this.puzzlesByDifficulty = /* build map from puzzles */;
}
public static loadFromFile(path: string): SudokuLibrary {
const puzzleData = fs.readFileSync("sudoku_puzzles.txt", "utf-8");
const puzzles = /* parse puzzleData into SudokuPuzzleEncoding[] */;
return new SudokuLibrary(puzzles);
}
public getRandomPuzzleByDifficulty(difficulty: number): SudokuPuzzleEncoding {
// return random puzzle with given difficulty
}
}
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 SudokuGame {
public async playGame(rl: readline.Interface): Promise<void> {
while (this.state === "playing") {
this.printBoard();
const input = await rl.question("Enter move: ");
// ...
}
}
}
// NOT GOOD - game creates its own readline interface
class SudokuGame {
public async playGame(): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
while (this.state === "playing") {
// ...
}
}
}
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
function parseMove(input: string): { x: number, y: number, digit: number } | undefined {
const parts = input.trim().split(" ");
if (parts.length !== 3) return undefined;
const [x, y, digit] = parts.map(Number);
if (isNaN(x) || isNaN(y) || isNaN(digit)) return undefined;
return { x, y, digit };
}
// Validation: check that parsed data represents a valid move (lives in game class)
class SudokuGame {
public isValidMove(x: number, y: number, digit: number): boolean {
if (!this.inBounds(x, y)) return false;
if (this.getCell(x, y).isStarter) return false; // can't overwrite starter
if (digit < 1 || digit > 9) return false;
return true;
}
}
// NOT GOOD - deeply nested, duplicates bounds logic
function handleInput(input: string) {
const parts = input.split(" ");
if (parts.length === 3) {
const x = Number(parts[0]);
const y = Number(parts[1]);
const digit = Number(parts[2]);
if (!isNaN(x) && !isNaN(y) && !isNaN(digit)) {
if (x >= 0 && x < 9 && y >= 0 && y < 9) { // duplicated bounds check
if (digit >= 1 && digit <= 9) {
// finally do the thing...
}
}
}
}
}
Note that semantic validation (e.g. “is this cell in bounds?”, “is this a starter cell?”) can reasonably live in the game class itself, since it relates to game rules and state.
8 Puzzle Library and Encodings
The puzzle file contains multiple puzzles, each with a difficulty level, starting board encoding, and solution encoding.
You should define a clear interface for puzzle encodings, for example:
interface SudokuPuzzleEncoding {
difficulty: number;
puzzle: string; // 81-character string encoding the starting board
solution: string; // 81-character string encoding the solution
}
There are many ways to do this. The puzzle and solution fields could also be stored as string[], (number | undefined)[], or even converted directly to SudokuCell[][] if you want to share the type with the game’s board. Any of these are potentially reasonable - the key is having a clear, typed structure rather than passing around raw strings or untyped objects.
The “puzzle library” can be handled in the main program code and doesn’t necessarily need a separate formal abstraction for P1 grading purposes (although this may be quite nice as you move to later projects). But, puzzles should be stored in a reasonable way so that they can be looked up by difficulty, e.g. in a Map<number, SudokuPuzzleEncoding[]> or an SudokuPuzzleEncoding[][] indexed by difficulty (given that the difficulties are integers).
The library should be an external resource that exists independently of any particular game, and the puzzle file should be read once when the program starts, not each time a new game is created. Thus, the puzzle library should not be created and/or owned by a game instance. The game receives a single puzzle encoding and doesn’t need to know about the library or other puzzles.
// GOOD - file read once at startup, library passed to game creation
function main() {
const library = loadPuzzleLibrary("sudoku_puzzles.txt");
const encoding = library.selectRandomPuzzle(difficulty);
const game = SudokuGame.createFromEncoding(encoding);
game.play();
}
// NOT GOOD - file read inside game constructor or factory
class SudokuGame {
public constructor(difficulty: number) {
const contents = fs.readFileSync("sudoku_puzzles.txt", "utf-8"); // reads file every time!
// ...
}
}
// ALSO NOT GOOD - game owns or creates the library
class SudokuGame {
private library: SudokuLibrary; // why does one game need all puzzles?
constructor(difficulty: number) {
this.library = loadPuzzleLibrary("sudoku_puzzles.txt");
// ...
}
}
9 Printing the Board with Colors
The board display requires different colors based on cell state:
- Blue: Starter cells (part of the original puzzle)
- White: Player entries that are correct
- Red: Player entries that are incorrect
This logic should be cleanly organized. One good approach is to use a helper function:
// GOOD - helper determines color based on cell state
function cellToColoredString(cell: SudokuCell, correctDigit: number): string {
// ...
}
This keeps the color logic separate from the printing logic, making both easier to read and maintain.
The iteration structure and printing of the 3x3 subgrids should also be cleanly organized, avoiding deeply nested loops or overly complex conditionals.
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 starterCount: number;
const currentDigit: number;
function placeDigit(x: number, y: number, digit: number): void;
function isValidMove(x: number, y: number, digit: number): boolean;
function isBoardComplete(): boolean;
// NOT GOOD - vague or cryptic names
const count: number; // count of what?
const d: number; // what does d represent?
function set(x: number, y: number, digit: number): void; // set what?
function check(x: number, y: number, digit: number): boolean; // check what?
function done(): boolean; // done with 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 (placeDigit, isCorrect, isBoardComplete), while variable names should be nouns or noun phrases (starterCount, currentDigit, gameState).