EECS 498 APSD Project 2

Project 2 Rubric

This is the rubric used for the initial review on project 2. The same rubric items will be used for the final revisions grading as well.

Initial Feedback

You’ll receive feedback and scores for each rubric item via a GitHub issue posted on your original, individual project 2 repository.

IMPORTANT NOTE: The grading and feedback for project 2 is based on your project 3 release, p3-initial-deliverables. We chose to grade this more recent version so that the feedback is more relevant to your current codebase.

Project Revisions

Based on your feedback, make revisions to the current codebase in your group repository. It’s fine if you’re still in the middle of implementing parts of future projects, and the code doesn’t need to be in a runnable state. We’ll reassess the same rubric items as they apply to the current project.

Your group should work together to address feedback from initial grading. For any rubric items where you didn’t get full points, address the underlying design issues in each rubric item in that context. Some may already have been resolved naturally with changes you’ve made for more recent projects and won’t require additional work. Others may require refactoring individual implementations or wider-ranging design choices. Of course, you’ll also want to double check even those items where you got full points originally to make sure your code still meets the criteria!

Your group will submit your revisions together via a GitHub release. This allows you to tag a specific commit as your final submission.

  1. Ensure all your code is committed and pushed to your repository.
  2. Go to your repository on GitHub.
  3. Click on “Releases” in the right sidebar (or navigate to https://github.com/eecs498-software-design/<your-repo>/releases).
  4. Click “Draft a new release”.
  5. Create a new tag for the release named p2-final-revisions.
  6. Set the release title to “Project 2: Final Revisions”.
  7. You don’t need to put anything specific in the release description.
  8. Click “Publish release”.

Make sure to create your release before 11:59pm on the deadline.

Grading Details

Copied below are the relevant project grading details from course syllabus.

Initial Review and Final Grading (3% + 3% course grade)

The course faculty and staff will review student codebases (i.e. the state of the codebase represented in your submission pull request) and grade them according to a rubric. The rubric is specific to each project and the relevant programming techniques and design principles covered in the course. Each rubric item is evaluated on a 3 point scale:

Our intent is that the initial review and final grading for each project will use the same rubric and each individual item is generally graded with the same rigor, but the points needed for full credit are different. For example, you needed 22 out of 30 points for full credit on initial grading. You'll need a higher score for full credit when the revisions are graded. (Because this is the first offering of the course, we will need to calibrate the exact numbers as we go.)

Note that your score for a rubric item on the initial review indicates an evaluation at that point in time, but does not necessarily guarantee a commensurate evaluation on the final review - since your codebase is evolving, you might need to pay close attention to a particular area to maintain a high quality of design and implementation. We'll do our best to give helpful feedback on particular areas to watch for.

Project 2 Rubric

Overview

  1. TUI Screen/Menu Abstraction
  2. General Structure of TUI
  3. Player/Login Representation
  4. Package Dependencies
  5. Control of Puzzle Game Flow
  6. Puzzle Game Display
  7. Game Configuration
  8. Move Validation
  9. Save/Load
  10. Database Layer

1 TUI Screen/Menu Abstraction

(Note: The items for “TUI Interface Abstraction” and “Prefer Async Readline Over PromptSync” were inadvertently included here in an initial version of the rubric. They were intended to be in section 2 and have now been moved there.)

The text-based user interface should have a clean abstraction for screens and menus. Adding a new screen or menu option should be straightforward and not require modifying existing code in many places.

Screen Abstraction

A screen should be represented as a reusable unit - either an object with a method to interact with the user, or a function that handles interaction:

// GOOD - screen as an object with interact method
interface TUIScreen {
  interact(cli: TUIInterface): Promise<void>;
}

const LOGIN_SCREEN: TUIScreen = {
  async interact(cli) {
    const player_id = await cli.prompt("Enter player ID: ");
    // ...
  }
};

// Also GOOD - screen as a function
type TUIScreenFn = (cli: TUIInterface) => Promise<void>;

async function loginScreen(cli: TUIInterface): Promise<void> {
  const player_id = await cli.prompt("Enter player ID: ");
  // ...
}

// NOT GOOD - hardcoded state machine without abstraction
async function main() {
  let state = "main_menu";
  while (true) {
    if (state === "main_menu") { /* ... */ }
    else if (state === "play_sudoku") { /* ... */ }
    else if (state === "play_minesweeper") { /* ... */ }
    // ... dozens more else-if branches
  }
}

There are several plausible variants on this, but the essential idea is that the TUI should be structured around reusable screen/menu abstractions, rather than a monolithic function with hardcoded state transitions.

Menu Choice Representation

Menu choices should encapsulate the display text together with the action it triggers. This coupling is desirable - the text describes what the action does, so they belong together.

However, avoid coupling the display key (e.g., “1”, “2”, “q”) directly to the action handling logic. The mapping from keys to actions should be managed by a menu abstraction, so that reordering menu options or changing keys doesn’t require modifying the action logic:

// GOOD - Menu class manages key-to-action mapping
interface MenuChoice {
  text: string;
  action: (() => Promise<void>) | TUIScreen;
}

const MAIN_MENU = new Menu({
  title: "Main Menu",
}, {
  // Keys are managed by Menu, not hardcoded in action logic
  "1": { text: "Play Sudoku", action: SUDOKU_CONFIG_SCREEN },
  "2": { text: "Play Minesweeper", action: MINESWEEPER_CONFIG_SCREEN },
  "q": { text: "Quit", action: ACTION_QUIT },
});
// Reordering just means changing the keys here, not elsewhere

// NOT GOOD - display keys hardcoded into action handling
console.log("1: Play Sudoku");
console.log("2: Play Minesweeper");
console.log("q: Quit");
const choice = await prompt("Enter choice: ");
if (choice === "1") { /* play sudoku */ }      // "1" hardcoded in two places!
else if (choice === "2") { /* play minesweeper */ }
else if (choice === "q") { /* quit */ }

The Problem with Semantic Coupling

The pattern of prompting for a menu choice and then using if/switch statements to match the choice to its effect is a form of semantic coupling. The menu display code and the action-handling code must agree on which strings/numbers mean what, but the compiler provides no feedback if they become out of sync.

This approach has several problems:

When the prompt and the handling code are right next to each other (low distance), these issues are less severe - you can see both pieces together. But if the prompt is in one function and the handling is in another (high distance), the coupling becomes much more problematic.

The solution - bundling each menu choice together with its action - eliminates semantic coupling entirely. Each option is defined once, with its display text and effect together. There’s no separate place that needs to “know” what each menu option means.

Conditional Menu Options

This approach also makes it easier to add conditional menu options (e.g., “Continue saved game” only appears when a save exists). There are two reasonable approaches:

Option A: Menu logic determines visibility

The menu definition includes logic to determine whether each option should be displayed:

// GOOD - menu definition includes visibility logic
const menu = new Menu({
  "1": { text: "New Game", action: NEW_GAME_SCREEN },
  "2": () => hasSavedGame() 
    ? { text: "Continue", action: RESTORE_SCREEN }
    : false,  // don't show this option
});

Option B: Menu item knows if it’s valid

Each menu item can report whether it should be displayed, and the menu queries items before rendering:

// GOOD - menu item knows its own validity
interface MenuChoice {
  text: string;
  action: (() => Promise<void>) | TUIScreen;
  isAvailable?: () => boolean;  // optional - defaults to true
}

const CONTINUE_OPTION: MenuChoice = {
  text: "Continue",
  action: RESTORE_SCREEN,
  isAvailable: () => hasSavedGame(),
};

// Menu queries each item before displaying
class Menu {
  render() {
    for (const [key, choice] of this.choices) {
      if (choice.isAvailable?.() ?? true) {
        console.log(`${key}: ${choice.text}`);
      }
    }
  }
}

Both approaches encapsulate the conditional logic cleanly, rather than scattering if statements throughout the menu display code.

2 General Structure of TUI

Beyond the specific abstractions for screens and menus, this section evaluates the overall structure and organization of the TUI code.

TUI Interface Abstraction

It is reasonable (but not strictly required) to define an explicit interface that encapsulates the details of text-based interaction:

// GOOD - explicit interface abstracts I/O details
interface TUIInterface {
  clear(): void;
  print(str: string): void;
  prompt(prompt_str: string): Promise<string>;
}

// Implementation wraps Node's readline
function createNodeTUI(rl: readline.Interface): TUIInterface {
  return {
    clear: () => console.clear(),
    print: (str) => console.log(str),
    prompt: (str) => rl.question(str),
  };
}

This abstraction would make it easier to swap out the underlying I/O mechanism (e.g., for testing or a different runtime). However, it’s also reasonable to assume the TUI always uses Node’s readline package, so this level of abstraction is not strictly required.

The readline interface (or TUIInterface abstraction) should either be:

The key point is that a single shared interface should be used across all screens and menus. Each screen/menu should NOT create, open, and close its own separate readline interface.

// GOOD - TUI interface passed to screens
async function run_tui(cli: TUIInterface, initial_screen: TUIScreen): Promise<void> {
  await initial_screen.interact(cli);
}

// Also GOOD - singleton readline interface in local-cli
let _cli: TUIInterface | undefined;

export function getCLI(): TUIInterface {
  if (!_cli) {
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    _cli = createNodeTUI(rl);
  }
  return _cli;
}

// Usage in screens - all share the same interface
const LOGIN_SCREEN: TUIScreen = {
  async interact() {
    const cli = getCLI();  // gets shared singleton
    const player_id = await cli.prompt("Enter player ID: ");
    // ...
  }
};

// NOT GOOD - each screen creates its own interface
const LOGIN_SCREEN: TUIScreen = {
  async interact() {
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    const player_id = await rl.question("Enter player ID: ");
    rl.close();
  }
};

Async Functions vs Classes

Both approaches work well for representing screens and menus:

// GOOD - async functions with control flow
async function mainMenu(cli: TUIInterface): Promise<void> {
  while (true) {
    const choice = await showMenu(cli, MAIN_MENU_OPTIONS);
    if (choice === "quit") return;
    await handleChoice(choice, cli);
  }
}

// Also GOOD - classes with interact methods
class MainMenuScreen implements TUIScreen {
  async interact(cli: TUIInterface): Promise<void> {
    // ...
  }
}

The key is that each screen/menu controls its own flow through control flow constructs (loops, conditionals, function calls), enabling relatively deep abstractions.

Depth of Abstractions

Screens should encapsulate their own behavior, controlling their own prompts and flow. A good design lets screens handle multiple prompts internally without needing to return control to a central coordinator:

// GOOD - screen controls its own flow, can prompt multiple times
interface TUIScreen {
  interact(cli: TUIInterface): Promise<void>;
}

class ConfigScreen implements TUIScreen {
  async interact(cli: TUIInterface): Promise<void> {
    // Screen handles its entire flow internally
    const width = await cli.prompt("Enter width: ");
    const height = await cli.prompt("Enter height: ");
    const mines = await cli.prompt("Enter mine count: ");
    // validate, create config, etc.
  }
}

// App just calls interact - doesn't manage screen internals
async function runApp(cli: TUIInterface): Promise<void> {
  await configScreen.interact(cli);  // screen handles its own prompts
  await gameScreen.interact(cli);
}

Avoid designs where a centralized controller forces screens to return after each prompt, requiring them to track state machines:

// NOT GOOD - centralized controller forces state machine pattern
class AppController {
  private screens: Map<string, Screen> = new Map();
  private currentScreen: string = "config";
  
  async run(cli: TUIInterface): Promise<void> {
    while (true) {
      const screen = this.screens.get(this.currentScreen)!;
      // Controller does ALL prompting - screens can't prompt themselves
      const prompt = screen.getPrompt();
      const input = await cli.prompt(prompt);
      const nextScreen = screen.handleInput(input);  // must return immediately
      this.currentScreen = nextScreen;
    }
  }
}

class ConfigScreen implements Screen {
  private state: "width" | "height" | "mines" = "width";
  private width?: number;
  private height?: number;
  
  getPrompt(): string {
    // Screen must track state machine because it can't prompt multiple times
    switch (this.state) {
      case "width": return "Enter width: ";
      case "height": return "Enter height: ";
      case "mines": return "Enter mine count: ";
    }
  }
  
  handleInput(input: string): string {
    // Convoluted state tracking just to gather 3 values
    switch (this.state) {
      case "width":
        this.width = parseInt(input);
        this.state = "height";
        return "config";  // stay on this screen
      case "height":
        this.height = parseInt(input);
        this.state = "mines";
        return "config";
      case "mines":
        // finally have all values
        return "game";  // move to next screen
    }
  }
}

The state machine approach adds complexity without benefit - it prevents encapsulating the control flow for logical units (e.g. prompt for all of the config inputs in a sequence) and instead forces the unit to be a single prompt, which just doesn’t end up fitting. Screens become harder to read and maintain because the natural sequential flow is obscured by explicit state tracking.

This is not to say that centralized control or a common pattern of prompting is inherently bad. For example, a single unified abstraction specifically for controlling play through a puzzle game (after initial configuration) may indeed work well across each game and be a good candidate for a shared abstraction since they all have a similar display, prompt, parse, attempt action, etc. flow.

Consistency Across Puzzles

When multiple puzzle games share similar structure (config → play → save), aim for consistent organization:

// GOOD - consistent structure across puzzles
// Each puzzle has: ConfigScreen, GameTUI, similar method names
class SudokuTUI {
  async play(cli: TUIInterface): Promise<void> { /* ... */ }
  render(): void { /* ... */ }
}

class MinesweeperTUI {
  async play(cli: TUIInterface): Promise<void> { /* ... */ }
  render(): void { /* ... */ }
}

// NOT GOOD - inconsistent structure
class SudokuUI {
  async runGameLoop(): Promise<void> { /* ... */ }
  displayBoard(): void { /* ... */ }
}

class MinesweeperHandler {
  async startPlaying(): Promise<void> { /* ... */ }
  printCurrentState(): void { /* ... */ }
}

Consistency reduces cognitive load for maintainers and makes it easier to refactor toward shared abstractions (e.g., generics) in the future.

As much as you can do to make the structure of the games consistent with each other will be helpful. For example, if they all have common tasks like displaying a welcome message, rendering the current state, parsing moves, and handling game over conditions, having a common structure and similar names for those kinds of things is going to be helpful. In a future project, you may end up needing to merge into a single abstraction that can play through different sorts of games, and consistency now will make that refactoring easier.

Consistency and de-duplication also matters when adding new features to the game flow. For example, if you wanted to add undo/redo functionality for moves, you would want to implement that once in a shared game loop abstraction rather than separately in each game’s driver code. Consistent structure makes this kind of refactoring much easier.

Avoid Long Delegation Chains

When tracing through code, avoid excessively long chains of function calls that make it hard to understand what’s happening:

// NOT GOOD - long chain, hard to follow
// runApp -> initializeSession -> authenticateUser -> fetchUserData -> ...
async function runApp() {
  const session = await initializeSession(storage);
  // ...
}

async function initializeSession(storage: StorageHelper) {
  return await authenticateUser(storage);
}

async function authenticateUser(storage: StorageHelper) {
  return await storage.fetchUserData(userId);
}

// GOOD - more direct, easier to follow
async function runApp(cli: TUIInterface) {
  const player = await loginScreen.interact(cli);  // directly handles login flow
  // ...
}

Each intermediate function in the bad example adds little value - it just delegates to another function. This makes it hard to trace what’s actually happening. Prefer deeper abstractions (where each function does meaningful work) over shallow wrappers that just pass data along.

Threading Dependencies (readline, player, etc.)

There are two reasonable approaches for making shared resources available:

// Approach A: Parameter passing
// More explicit, but can get tedious with many parameters
async function playGame(cli: TUIInterface, player: Player): Promise<void> {
  await configScreen.interact(cli, player);
  // ...
}

// Approach B: Singleton or centralized access
// Less parameter threading, but creates implicit dependencies
async function playGame(): Promise<void> {
  const cli = getCLI();  // singleton
  const player = Player.currentPlayer();  // singleton
  await configScreen.interact();
  // ...
}

Either approach may be acceptable in certain contexts depending on relevant tradeoffs. For simpler applications, parameter passing works fine. For more complex TUIs with many screens, a singleton or centralized TUI class can reduce boilerplate.

Prefer Async Readline Over PromptSync

If you are currently using the prompt-sync package, we recommend migrating to the promise-based asynchronous readline package instead. The prompt-sync package is not as robust and doesn’t integrate well with Node’s event loop. The async readline package works better with async/await patterns and is the standard approach for terminal I/O in Node.js applications. A single readline interface can be shared across the entire TUI, whether managed as a singleton or passed as a parameter to each function that needs it (or to TUI classes via dependency injection in a constructor).

If you do use prompt-sync for any reason, at minimum ensure the instance is created once and reused, not created fresh each time.

Naming and Organization

Use clear, descriptive names that communicate purpose:

// GOOD - clear names
async function showGameConfigMenu(cli: TUIInterface): Promise<GameConfig> { /* ... */ }
async function showResumeGameMenu(cli: TUIInterface): Promise<SavedGame> { /* ... */ }

// NOT GOOD - unclear distinction
async function gameMenu(cli: TUIInterface): Promise<void> { /* ... */ }
async function puzzleMenu(cli: TUIInterface): Promise<void> { /* ... */ }
// What's the difference between "game" and "puzzle" here?

3 Player/Login Representation

The player should be loaded once and stored so their information is readily available without repeated database queries.

Singleton or Centralized Access

The current player should be accessible without needing to pass it through every function. A singleton pattern or member of a central TUI class works well.

A singleton is a reasonable fit for this context because the assumption of a single global player has low volatility in a text-based interface - even if we support switching between players, there is only one player navigating the TUI at a time. Switching players simply changes which player the singleton represents, rather than requiring multiple player objects to exist simultaneously.

Note that singletons are not always the right choice - in contexts where you need to support multiple instances, where testability requires isolating state between tests, or where hidden dependencies would make code harder to reason about, explicit parameter passing (effectively dependency injection) is preferable. For this project’s TUI, however, the singleton approach is pragmatic and avoids the tedium of threading the player through every function call.

That said, if the distance is low and the player only needs to be passed through a short chain of functions, parameter passing is acceptable and may even be preferable for its explicitness. The singleton approach becomes more valuable when there is high distance - when the player information needs to be threaded through a long chain of function calls to reach where it’s ultimately used.

// GOOD - player singleton with static access
class Player {
  private static current_player: Player | undefined;
  
  public readonly player_id: string;
  public readonly display_name: string;
  
  public static currentPlayer(): Player | undefined {
    return Player.current_player;
  }
  
  public static async loginAs(player_id: string, display_name: string): Promise<Player> {
    const player = new Player(player_id, display_name);
    Player.current_player = player;
    return player;
  }
  
  public static logout(): void {
    Player.current_player = undefined;
  }
}

// Usage in menus - clean, no need to pass player around
const MAIN_MENU = new Menu({
  title: () => `=== Main Menu === [${Player.currentPlayer()?.player_id ?? "<Guest>"}]`,
}, { /* ... */ });

// NOT GOOD - passing player through every function
// Creates lots of coupling and is tedious to maintain
async function showMainMenu(player: Player | undefined, rl: readline.Interface) {
  const choice = await showPuzzleMenu(player, rl);  // tedious threading
  if (choice === "1") await playSudoku(player, rl);
  // ...
}

Avoid Repeated Database Queries

Player information should be cached after login, not re-fetched on every access.

If passing the player through parameters, pass the full player object (or at least all needed information), not just the player ID. Passing only the ID forces receiving functions to query the database again to get player details, which is wasteful:

// NOT GOOD - only passing player ID, requiring database lookups
async function showMainMenu(player_id: string) {
  const player = await db.getPlayerById(player_id);  // wasteful lookup!
  console.log(`Welcome, ${player.display_name}`);
}

// GOOD - pass full player object
async function showMainMenu(player: Player) {
  console.log(`Welcome, ${player.display_name}`);  // no lookup needed
}

With the singleton approach, all player information is available without repeated queries:

// GOOD - load once, cache in Player object
public static async loginAs(player_id: string, display_name: string): Promise<Player> {
  const player = new Player(player_id, display_name);
  player._autosave = await Autosave.loadByPlayerId(player_id);  // load once
  Player.current_player = player;
  return player;
}

// NOT GOOD - query database every time player info is needed
function getPlayerName() {
  const player = await db.getPlayerById(current_player_id);  // wasteful!
  return player.display_name;
}

4 Package Dependencies

Dependencies between packages should have clear boundaries:

Allowed Dependencies:

// GOOD - app layer imports from both database and puzzle packages
// In apps/local-cli/src/sudoku_tui.ts
import { getRandomPuzzleByDifficulty } from "@repo/database/sudoku_puzzles";
import { SudokuGame, SudokuConfig } from "@repo/puzzle/sudoku";

// App layer bridges between database and puzzle
const db_result = await getRandomPuzzleByDifficulty(difficulty);
const config: SudokuConfig = {
  difficulty: db_result.difficulty,
  starter: parseStarterBoard(db_result.starting_board),
  solution: parseSolutionBoard(db_result.solution_board),
};
const game = SudokuGame.create(config);

Forbidden Dependencies:

// NOT GOOD - puzzle directly queries database
// In packages/puzzle/src/sudoku/sudoku.ts
import { getPuzzleById } from "@repo/database/sudoku_puzzles";  // WRONG!

class SudokuGame {
  static async loadFromDatabase(puzzle_id: number) {
    const puzzle = await getPuzzleById(puzzle_id);  // puzzle shouldn't know about DB
    // ...
  }
}

// NOT GOOD - puzzle imports from app layer
// In packages/puzzle/src/minesweeper/minesweeper.ts
import { getCLI } from "@repo/local-cli/TUI";  // WRONG!

class MinesweeperGame {
  printBoard() {
    getCLI().print(this.boardToString());  // puzzle shouldn't know about TUI
  }
}

Discouraged Dependencies:

While it may seem convenient for the database package to import types from other packages (e.g., SudokuConfig from puzzle, or Player from local-cli), this is discouraged for several reasons:

  1. Unnecessary coupling: TypeScript’s structural typing means that as long as the shapes match, types don’t need to be explicitly shared. The database layer can define its own return types based on the schema (e.g., via Kysely’s code generation), and the app layer can transform these to other types as needed.

  2. Hidden fields: If a database function uses selectAll() but annotates its return type as an imported type like SudokuConfig, the actual returned object may contain additional fields (e.g., puzzle_id, created_at) that are hidden from TypeScript’s view. This creates a “type lie” where the runtime object has more data than the type indicates, reducing visibility for the app layer and potentially causing confusion about what data is actually being passed around.

// GOOD - database defines its own types, doesn't import from puzzle
// In packages/database/src/sudoku_puzzles.ts
// Return type is inferred from schema, not imported from puzzle package
export async function getRandomPuzzleByDifficulty(difficulty: number) {
  return DATABASE.selectFrom('sudoku_puzzles')
    .selectAll()
    .where('difficulty', '=', difficulty)
    .executeTakeFirst();
  // Inferred return type includes ALL columns: { puzzle_id, difficulty, starting_board, solution_board, created_at, ... }
  // App layer has full visibility into what's being returned
}

// DISCOURAGED - database imports types from puzzle package
// In packages/database/src/sudoku_puzzles.ts
import { SudokuConfig } from "@repo/puzzle/sudoku";  // Avoid this

export async function getRandomPuzzle(): Promise<SudokuConfig | undefined> {
  const result = await DATABASE.selectFrom('sudoku_puzzles').selectAll().executeTakeFirst();
  return result;  // Returns object with puzzle_id, created_at, etc. but type hides them!
  // App layer doesn't see that extra fields are being passed around
}

This is especially important for migrations and seeds - these should never depend on types from other packages. If those types change after migrations have already been applied to database instances, the migration code either won’t compile anymore, or the historical record of what the migration achieved becomes misrepresented.

// NOT GOOD - migration uses types from puzzle package
// In packages/database/src/migrations/002_add_autosave.ts
import { SudokuState } from "@repo/puzzle/sudoku";  // WRONG!

export async function up(db: Kysely<any>): Promise<void> {
  await db.schema
    .alterTable('players')
    .addColumn('autosave', 'text')  // stores JSON of game state
    .execute();
  
  // Type annotation references puzzle package
  const defaultState: SudokuState = { /* ... */ };
  // ...
}

// Later, SudokuState changes (adds/removes fields)...
// Now this migration won't compile! But it was already applied to production.
// Or worse: the migration file is "fixed" to match new types, but that doesn't
// match what actually happened when it ran on existing databases.

// GOOD - migration is self-contained, no external type dependencies
export async function up(db: Kysely<any>): Promise<void> {
  await db.schema
    .alterTable('players')
    .addColumn('autosave', 'text')
    .execute();
  // Migration only deals with schema, not application types
}

The same applies to seeds:

// NOT GOOD - seed uses types from puzzle package
// In packages/database/src/seeds/001_sudoku_puzzles.ts
import { SudokuConfig } from "@repo/puzzle/sudoku";  // WRONG!

export async function seed(db: Kysely<any>): Promise<void> {
  const puzzles: SudokuConfig[] = [
    { difficulty: 1, starter: [...], solution: [...] },
  ];
  await db.insertInto('sudoku_puzzles').values(puzzles).execute();
}

// GOOD - seed uses database column types directly
export async function seed(db: Kysely<any>): Promise<void> {
  const puzzles = [
    { difficulty: 1, starting_board: "53..7....", solution_board: "534678912..." },
    // Uses database column names, not puzzle package types
  ];
  await db.insertInto('sudoku_puzzles').values(puzzles).execute();
}

By keeping the database package independent, we avoid coupling that creates real problems down the line.

The app layer is responsible for:

The puzzle layer is responsible for:

The database layer is responsible for:

5 Control of Puzzle Game Flow

The game loop should be separated from core game logic. This is a key separation of concerns: the puzzle abstraction manages game state and rules, while external code controls the flow of play (prompting for input, deciding when to display, handling the game loop, etc.).

Why is this separation important? Game flow looks very different depending on context:

If game flow is encapsulated inside the puzzle abstraction, we either lose this flexibility or would have to greatly increase complexity to get it.

// NOT GOOD - puzzle class handles its own I/O directly
// and is coupled to the specific node readline interface
class SudokuGame {
  public async play() {
    const rl = readline.createInterface(/* ... */);
    while (!this.is_solved()) {
      console.log(this.boardToString());
      const input = await rl.question("Enter move: ");
      // ...
    }
  }
}

// ALSO NOT GOOD - generic prompt/print are passed via dependency
// injection, but the puzzle class still drives the game loop and is
// limited to a prompt/print flow that is not flexible
class SudokuGame {
  public async play(prompt: (s: string) => Promise<string>, print: (s: string) => void) {
    while (!this.is_solved()) {
      print(this.boardToString());
      const input = await prompt("Enter your move: ");
      // ...
    }
  }
}

// Problems:
// - Can't unit test without mocking prompt/print
// - Assumes prompt/print paradigm (doesn't work for GUI)
// - Game flow is locked inside the puzzle class

Even with generic prompt/print functions injected via dependency injection, the puzzle class is still coupled to the concept of a prompt/print text-based flow. The existence of a game loop that alternates between displaying and prompting is itself a UI concern. A graphical interface might update reactively based on user clicks, with no explicit “prompt” at all.

The key insight is that the puzzle class should be blissfully unaware that anyone is playing it in any capacity. It just manages game state and rules. External code (TUI, GUI, tests) decides when to query state, when to display, and when to request moves.

Instead, the core puzzle abstraction manages state and rules, while external code (TUI, GUI, tests) controls the flow of play.

// GOOD - Core puzzle class has no game loop, just state management
class SudokuGame {
  public getState(): SudokuState { /* ... */ }
  public getBoard(): ReadonlyGrid<SudokuCell> { /* ... */ }
  public performAction(action: SudokuAction): void { /* ... */ }
}

// TUI class drives the loop
class SudokuTUI {
  private readonly game: SudokuGame;
  
  public async play_game(prompt: (str: string) => Promise<string>): Promise<void> {
    while (!this.game.getState().is_solved) {
      this.print_board();
      const input = await prompt("Enter your move: ");
      // parse input, then:
      this.game.performAction({ action_kind: "place_number", x, y, number });
    }
  }
}

// Unit test - no loop, just direct calls
test("placing number updates cell", () => {
  const game = SudokuGame.create(config);
  game.performAction({ action_kind: "place_number", x: 0, y: 0, number: 5 });
  expect(game.getBoard().at(0, 0).number).toBe(5);
});

A Note on Dependency Direction

Pragmatically, the dependency direction will likely be that the abstraction managing game flow (e.g., SudokuTUI) depends on the abstraction managing game logic (e.g., SudokuGame). This matches the natural direction of control.

In theory, one could use dependency injection to provide a “move source” to the core puzzle class, with the puzzle registering to receive events whenever a move is made (inversion of control). This would technically reverse the dependency direction so that game logic depends on game flow. However, combining dependency injection with inversion of control in this way is like a double negative - it doesn’t really buy us anything compared to the more straightforward approach where the direction of control and dependency match each other.

6 Puzzle Game Display

Code for displaying the game board, formatting output, and stringifying game elements should live in the TUI layer, not in core game classes.

Display Logic in TUI

// GOOD - display logic in TUI class
class MinesweeperTUI {
  public print_board(options: { show_hidden?: boolean } = {}): void {
    const board = this.game.getBoard();
    const rows = board.rows().map(
      (row, R) => `${R} | ${row.map(cell => stringify_cell(cell, options)).join(" ")} |`
    );
    console.log(rows.reverse().join("\n"));
  }
}

// Helper in TUI module, not in puzzle
function stringify_cell(cell: MinesweeperCell, options: { show_hidden?: boolean }): string {
  if (cell.status === "hidden" && !options.show_hidden) return "_";
  if (cell.status === "flagged") return "F";
  if (cell.contents === "mine") return "*";
  return cell.adjacentMines === 0 ? " " : cell.adjacentMines.toString();
}

Core Game Class Exposes Data, Not Strings

The game class should return structured data that the TUI can format:

// GOOD - game returns structured data
class SudokuGame {
  public getBoard(): ReadonlyGrid<SudokuCell> {
    return this.board.readonly_view();
  }
}

// TUI formats the data
class SudokuTUI {
  public print_board(): void {
    const board = this.game.getBoard();
    for (const row of board.rows()) {
      console.log(row.map(cell => stringify_cell(cell)).join(" "));
    }
  }
}

// NOT GOOD - game returns pre-formatted strings
class SudokuGame {
  public getBoardString(): string {
    return this.board.rows()
      .map(row => row.map(c => c.number?.toString() ?? ".").join(" "))
      .join("\n");  // display concern leaked into game class
  }
}

Why Separate Display?

// With separated display, the same game works for CLI and web:

// CLI
class MinesweeperTUI {
  print_board() { /* uses console.log, ASCII art */ }
}

// Web (future project)
class MinesweeperWebView {
  render() { /* uses React/Vue, SVG graphics */ }
}

// Both use the same:
const game = MinesweeperGame.create(config);
game.performAction({ action_kind: "reveal", x: 3, y: 2 });
const state = game.getState();  // same state, different display

Triggering Display Updates

There are multiple reasonable ways for the UI to know when to update display:

  1. Active rendering: TUI checks getState() after each action (simple, works well for TUI)
  2. Inversion of control: Game emits events that UI subscribes to (more flexible, good for GUIs)
// Simple approach: TUI polls state after each action
class SudokuTUI {
  public async play_game(prompt: (str: string) => Promise<string>): Promise<void> {
    while (!this.game.getState().is_solved) {
      this.print_board();  // display current state
      const input = await prompt("Enter your move: ");
      this.game.performAction(parseAction(input));
      // loop back to print_board() which will show updated state
    }
  }
}

// Alternative: Inversion of control with events
type SudokuEventHandler = {
  onStateChange?: (state: SudokuState) => void;
  onGameWon?: () => void;
};

class SudokuGame {
  private handlers: SudokuEventHandler = {};
  
  public subscribe(handlers: SudokuEventHandler): void {
    this.handlers = { ...this.handlers, ...handlers };
  }
  
  public performAction(action: SudokuAction): void {
    // ... apply action ...
    this.handlers.onStateChange?.(this.getState());
    if (this.checkSolved()) {
      this.handlers.onGameWon?.();
    }
  }
}

// GUI subscribes to events for reactive updates
const game = SudokuGame.create(config);
game.subscribe({
  onStateChange: (state) => renderBoard(state),
  onGameWon: () => showVictoryModal(),
});

Either approach is acceptable. The key point is that the puzzle class provides state and the UI decides how/when to display it.

7 Game Configuration

There are a few goals here:

We can’t just encapsulate all of this into a single game class, since the configuration process (and what to do if it fails) is separate from the game logic itself and differs depending on the context. So, we have two options:

Option 1: Low Distance - Encapsulate Validity Check with Creation

If the code that obtains configuration values and the code that creates the game are close together (low distance), we can encapsulate validity checking directly into game construction.

Approach A: Factory returns union type
A static factory method validates and returns either a game or an error. Note the private constructor ensures the factory is the only entry point:

// GOOD for low distance - factory validates and returns game or error
class MinesweeperGame {
  private constructor(
    private readonly width: number,
    private readonly height: number,
    private readonly num_mines: number
  ) {}

  public static create(
    width: number, height: number, num_mines: number
  ): MinesweeperGame | Error {
    if (width <= 0) return new Error("Width must be positive.");
    if (height <= 0) return new Error("Height must be positive.");
    if (num_mines < 0 || num_mines >= width * height) return new Error("Invalid mine count.");
    return new MinesweeperGame(width, height, num_mines);
  }
}

// Usage - validation happens inside create(), handled immediately
const gameOrError = MinesweeperGame.create(w, h, n);
if (gameOrError instanceof Error) {
  console.log(`Invalid: ${gameOrError.message}`);
  return;
}
// gameOrError is now MinesweeperGame

Approach B: Constructor throws exception
Alternatively, a public constructor can throw on invalid input. In TypeScript (and most languages), constructors cannot return a union type, so throwing is the standard way to signal failure from a constructor:

// Also GOOD for low distance - constructor throws on invalid config
class MinesweeperGame {
  constructor(
    private readonly width: number,
    private readonly height: number,
    private readonly num_mines: number
  ) {
    if (width <= 0) throw new Error("Width must be positive.");
    if (height <= 0) throw new Error("Height must be positive.");
    if (num_mines < 0 || num_mines >= width * height) throw new Error("Invalid mine count.");
  }
}

// Usage - must catch exception
try {
  const game = new MinesweeperGame(w, h, n);
  // use game...
} catch (e) {
  console.log(`Invalid: ${(e as Error).message}`);
  return;
}

Both approaches work well when configuration and game creation happen in the same place (e.g., a single function that prompts the user and creates the game). The low distance makes it easy for the programmer to remember to handle the error case.

Option 2: High Distance - Parse, Don’t Validate

If configuration values are obtained in one place but the game is created elsewhere (high distance), we need a different approach. The “parse, don’t validate” principle applies here.

“Validation” generally refers to checking raw input and then conditionally allowing it to be used. The problem is that the consuming code still has access to the raw input type, so there’s nothing preventing it from being used without validation:

// NOT GOOD for high distance - validation returns boolean, raw type still usable
function isValidConfig(width: number, height: number, num_mines: number): boolean {
  return width > 0 && height > 0 && num_mines >= 0 && num_mines < width * height;
}

class MinesweeperGame {
  public static create(width: number, height: number, num_mines: number): MinesweeperGame {
    // hope the caller checked isValidConfig() first!
  }
}

// Easy to forget the validation check - both lines compile!
if (isValidConfig(w, h, n)) {
  const game = MinesweeperGame.create(w, h, n);  // works
}
const game2 = MinesweeperGame.create(w, h, n);  // also compiles - oops!

This relies on behavioral coupling (“I hope somebody checked this before calling me”) which has low feedback and is easy to get wrong at high distance.

“Parsing”, on the other hand, transforms raw input into a new type that represents valid data. The consuming function requires this parsed type, so the type system enforces that validation happened.

Critically, the parsing function should be the recognized factory for creating instances of the parsed type. The type definition, its invariants, and the parsing function should all be part of the same encapsulation unit. This means:

// GOOD for high distance - parsing produces a new type
// These should be defined together in the same module (e.g., packages/puzzle/src/minesweeper/config.ts)

interface MinesweeperConfig {
  readonly width: number;
  readonly height: number;
  readonly num_mines: number;
}

// This is THE factory for MinesweeperConfig - the recognized way to create one
function parseMinesweeperConfig(
  width: number, height: number, num_mines: number
): MinesweeperConfig | Error {
  if (width <= 0) return new Error("Width must be positive.");
  if (height <= 0) return new Error("Height must be positive.");
  if (num_mines < 0 || num_mines >= width * height) return new Error("Invalid mine count.");
  return { width, height, num_mines };  // returns the validated type
}

class MinesweeperGame {
  public static create(config: MinesweeperConfig): MinesweeperGame {
    // guaranteed valid - can't get a MinesweeperConfig without parsing
  }
}

// Type system enforces that parsing happened
const configOrError = parseMinesweeperConfig(w, h, n);
if (configOrError instanceof Error) { /* handle error */ return; }
const game = MinesweeperGame.create(configOrError);  // only way to call create()

For more complex configuration parsing and validation, a schema validation library like Zod can work well and you are welcome to use it. Zod’s .parse() and .safeParse() methods align directly with the “parse, don’t validate” principle - they transform raw input into validated types. It’s not required for project 2 grading, though we’ll eventually ask you to use it in future projects for other purposes and it works well here too.

This relies on structural coupling (via the type system) which has high feedback and makes it impossible to accidentally skip validation, even at high distance.

Note: Returning a union with Error is used here for semantic clarity and is one valid approach. Alternatives include returning a union with a plain string (simpler), a discriminated union like { success: true, value: T } | { success: false, error: string }, or throwing an exception (familiar but less explicit about expected failure). Any of these are acceptable.

Factory Convention
Note that plain TypeScript interfaces don’t prevent someone from creating an invalid MinesweeperConfig object literal directly. We rely on the convention that all config values are created through the recognized parsing factory, not constructed manually. This is a reasonable trade-off for this project. (For extra safety, you could use “branded types” to make it impossible to create a config without going through the factory, but that’s beyond our scope here.)

The key point is that the parsing factory is the way to create config objects - it’s not just a helper function buried in a TUI class. It should be exported alongside the type definition so that any context (TUI, tests, GUI) can use it.

This is a quirk of TypeScript’s structural type system, where any object with the right shape satisfies an interface. In languages with nominal typing (e.g., Java, C#, Rust), you’d define MinesweeperConfig as a class with a private constructor, and the factories would be the only way to create instances - the compiler would enforce this, not just convention.

Summary

Both options encapsulate the validity invariant within a type - Option 1 in the game type itself (MinesweeperGame), Option 2 in a separate config type (MinesweeperConfig). Instead of code everywhere having to check that a validity invariant holds (or hoping someone else already did), we encapsulate the invariant: the factory checks the invariant once, and possession of the resulting type proves validity.

Single Config Object vs Multiple Parameters

When creating a game, pass a single configuration object rather than multiple separate parameters:

// NOT GOOD - multiple parameters, easy to mix up order
class MinesweeperGame {
  constructor(width: number, height: number, num_mines: number, seed?: number) { /* ... */ }
}
const game = new MinesweeperGame(10, 8, 15);  // is that width x height or height x width?

// GOOD - single config object, clear and extensible
interface MinesweeperConfig {
  readonly width: number;
  readonly height: number;
  readonly num_mines: number;
  readonly seed?: number;
}

class MinesweeperGame {
  public static create(config: MinesweeperConfig): MinesweeperGame { /* ... */ }
}
const game = MinesweeperGame.create({ width: 10, height: 8, num_mines: 15 });

A single config object:

Note that not everything needs to be part of the config object. For example, in Spelling Bee, the word dictionary could be considered part of the game configuration, or it could be considered part of the overall system environment that the game runs in. If you treat it as a system resource rather than game-specific configuration, it doesn’t need to be bundled into the config type. Use judgment about what logically belongs together as “configuration for this specific game instance” versus “shared resources available to all games.”

The key insight is that we need at least one mitigating factor for coupling:

Prefer Option 2 for This Project

Option 1 is simpler (fewer types to define), but Option 2 has advantages when:

Since this project involves save/load and multiple configuration sources, Option 2 is generally preferred.

Transforming Between Formats

When configuration comes from different sources (argv-style user input, database, etc.), use separate parsing functions for each:

// GOOD - transform database format to game config format
function parseSudokuFromDatabase(dbRow: SudokuPuzzleRow): SudokuConfig | Error {
  if (dbRow.starting_board.length !== 81) return new Error("Invalid starter length");
  if (dbRow.solution_board.length !== 81) return new Error("Invalid solution length");
  
  return {
    difficulty: dbRow.difficulty,
    starter: [...dbRow.starting_board].map(ch => ch === "." ? undefined : Number(ch)),
    solution: [...dbRow.solution_board].map(ch => Number(ch)),
  };
}

Generally speaking, these should be external functions that are separate from the game class (not static methods), since they represent a transformation between two different layers (database format vs. game config) and may involve logic that doesn’t belong in the core game abstraction and would introduce an inappropriate dependency in the wrong direction (i.e. the game now needs to know about the database schema). The game class should just define the config type it needs, and external code can handle transforming from various sources into that config type.

8 Move Parsing and Validation

The previous section introduced the “parse, don’t validate” principle for game configuration: transform raw input into a validated type, so the type system guarantees validity. How does this apply to move validation?

Move validation introduces a key difference: dependence on mutable game state. Game configuration is validated once at creation time - the config values don’t change during the game. But whether a move is valid depends on the current state: you can’t reveal an already-revealed cell, can’t place a number in an occupied Sudoku cell, can’t guess a word you’ve already guessed, etc.

This creates two distinct phases:

  1. Parsing (format): Convert raw input (e.g., a string) to a structured action type. This CAN be enforced by types - once you have a MinesweeperAction, you know it’s a structurally valid action.

  2. Validation (state): Check whether the action is valid for the current game state. This CANNOT be fully enforced by types, because game state is mutable.

Why can’t we encode state-dependent validation in types? Even if we created a ValidatedMove type after checking the game state, we couldn’t prevent code from storing that move and applying it later - after the game state has changed. The type system tracks the structure of data, not when it was validated relative to changing state.

The consequence: the game must own state-dependent validation. We can parse moves externally (in the TUI or other contexts), but the game must validate moves against its current state when they’re applied.

There are also a few challenges to keep in mind:

The approach: parse input externally to produce an action type, then pass the action to the game for state-dependent validation. The type system enforces the parsing step; the game enforces validity against the current state.

Parsing vs. Validation

Parsing converts raw input to a structured action type (the format). Validation checks whether the action is valid for the current game state.

Just as with configuration, the action type should be defined in the puzzle package. The primary way to create an action is to construct an object with the right shape - the action type itself is the contract, and any context (TUI, GUI, tests) can create actions directly:

// GOOD - action type defined in puzzle package
// (e.g., packages/puzzle/src/minesweeper/actions.ts)

type MinesweeperAction =
  | { readonly action_kind: "reveal"; readonly x: number; readonly y: number }
  | { readonly action_kind: "flag"; readonly x: number; readonly y: number };

// Game accepts action objects - doesn't care how they were constructed
class MinesweeperGame {
  public performAction(action: MinesweeperAction): ActionResult {
    // Game validates against current state
    // ...
  }
}

// TUI constructs an action object after prompting the user
const action: MinesweeperAction = { action_kind: "reveal", x: 3, y: 2 };
game.performAction(action);

// Test constructs an action object directly
game.performAction({ action_kind: "flag", x: 0, y: 0 });

// GUI constructs from click coordinates
game.performAction({ action_kind: "reveal", x: clickedCol, y: clickedRow });

Where Should String Parsing Live?

The critical principle is that the action type must be defined in the puzzle package - it’s the interface contract that all contexts use. Where the string parser lives (if you have one) is a reasonable design choice with different tradeoffs.

Option A: Parser in Puzzle Package

If there’s a canonical string notation for actions (like chess’s “e2e4”) or you want multiple contexts to share the same format, put the parser alongside the action type:

// MAYBE Parser alongside the action type, if the string format is a canonical notation
// (e.g., in packages/puzzle/src/minesweeper/actions.ts)
// Assume a canonical string format like "reveal 3 2"
function parseMinesweeperAction(input: string): MinesweeperAction | undefined {
  const parts = input.split(" ");
  if (parts.length !== 3) return undefined;
  const command = parts[0];
  const x = parseInt(parts[1]!);
  const y = parseInt(parts[2]!);
  if (isNaN(x) || isNaN(y)) return undefined;
  if (command === "reveal") return { action_kind: "reveal", x, y };
  if (command === "flag") return { action_kind: "flag", x, y };
  return undefined;
}

This approach is useful when:

Option B: Parser in TUI

If the string format is purely a text UI concern (e.g., “r 3 2” is just how this particular CLI chose to encode actions), putting the parser in the TUI is reasonable:

// Parser in TUI class - fine if format is TUI-specific
class MinesweeperTUI {
  private parseUserInput(input: string): MinesweeperAction | undefined {
    // This format ("r 3 2") is specific to this CLI's UX choices
    const parts = input.split(" ");
    // ... same parsing logic ...
  }
}

This approach makes sense when:

The Key Point

Either location works - what matters is that the action type is the interchange format. The type system enforces that parsed input becomes a MinesweeperAction before reaching the game, regardless of where the parsing happens.

Action Types as Interchange Format

Define clear action types that represent structurally valid moves. These serve as the boundary between parsing (external) and validation (game-internal) - the type system enforces that parsing happened, and the game takes it from there.

Pass the entire action object to the game, not deconstructed parameters:

// NOT GOOD - deconstructed parameters, loses type safety
class SudokuGame {
  public placeNumber(x: number, y: number, num: number): void { /* ... */ }
  public clearCell(x: number, y: number): void { /* ... */ }
  public toggleNote(x: number, y: number, num: number): void { /* ... */ }
}
// Caller must know which function to call and how to decode the action
if (action.kind === "place") game.placeNumber(action.x, action.y, action.num);
else if (action.kind === "clear") game.clearCell(action.x, action.y);
// ...

// GOOD - unified action interface
type SudokuAction = 
  | { readonly action_kind: "place_number"; readonly x: number; readonly y: number; readonly number: number }
  | { readonly action_kind: "clear_cell"; readonly x: number; readonly y: number }
  | { readonly action_kind: "toggle_note"; readonly x: number; readonly y: number; readonly number: number };

class SudokuGame {
  public performAction(action: SudokuAction): ActionResult { /* ... */ }
}
// Caller just passes the parsed action
game.performAction(action);

A unified performAction interface:

Here’s a more complete example:

// GOOD - well-defined action types
interface MinesweeperRevealAction {
  readonly action_kind: "reveal";
  readonly x: number;
  readonly y: number;
}

interface MinesweeperFlagAction {
  readonly action_kind: "flag";
  readonly x: number;
  readonly y: number;
}

type MinesweeperAction = MinesweeperRevealAction | MinesweeperFlagAction;

// Game accepts actions, validates internally
class MinesweeperGame {
  public performAction(action: MinesweeperAction): void {
    switch (action.action_kind) {
      case "reveal":
        // Attempt to reveal - game logic will validate whether this is allowed
        this.reveal_cell(action.x, action.y);
        break;
      case "flag":
        // Attempt to toggle flag - game logic will validate whether this is allowed
        this.toggle_flag(action.x, action.y);
        break;
    }
  }
}

Handling Invalid Moves

Since state-dependent validation happens inside the game, the game decides what to do when a move violates the current state. Options include returning an error or silently ignoring:

// GOOD - game rejects/ignores invalid moves
private reveal_cell(x: number, y: number): void {
  const cell = this.board.at(x, y);
  if (cell.is_revealed || cell.is_flagged) return;  // silently ignore invalid move
  // ... reveal logic
}

// Also GOOD - return result indicating success/failure
public performAction(action: MinesweeperAction): ActionResult {
  // ...
  if (cell.is_revealed) return { success: false, reason: "already_revealed" };
  // ...
}

9 Save/Load

Save and load functionality involves two distinct concerns that should be separated:

Two Acceptable Approaches

There are two reasonable ways for puzzles to expose their state for saving:

Approach A: Expose State Object

The puzzle exposes its state as a structured object via something like a .getState() method (or in a functional approach, it’s possible the state would already be unencapsulated), and external code handles serialization (e.g., JSON.stringify). This works well when the state doesn’t include implementation details that should remain encapsulated.

// Puzzle exposes structured state
interface SpellingBeeState {
  readonly displayed_letters: readonly string[];
  readonly words_found: readonly string[];
  readonly points: number;
}

class SpellingBeeGame {
  public readonly config: SpellingBeeConfig;
  public getState(): SpellingBeeState { /* ... */ }
  
  public static restore(config: SpellingBeeConfig, state: SpellingBeeState): SpellingBeeGame {
    // reconstruct game from saved state
  }
}

// External code serializes when saving
await player.setAutosave({
  kind: "spelling_bee",
  config: game.config,
  state: JSON.stringify(game.getState()),  // structured object, serialized by app layer
});

Approach B: Memento Pattern

The puzzle handles serialization internally, returning a JSON string. The puzzle completely “owns” the format, which is useful if some internal state (e.g., private members that are implementation details) needs to be saved but shouldn’t be exposed as a structured object. Because JSON is a well-recognized format, and only the puzzle class knows what the format contains, direct coupling to this string is acceptable.

// Puzzle handles its own serialization
class SpellingBeeGame {
  public readonly config: SpellingBeeConfig;
  
  public saveToJson(): string {
    return JSON.stringify({
      displayed_letters: this.displayed_letters,
      words_found: [...this.words_found],
      points: this.points,
      // can include private implementation details here
    });
  }
  
  public static restoreFromJson(config: SpellingBeeConfig, json: string): SpellingBeeGame {
    const state = JSON.parse(json);
    // reconstruct game from parsed state
  }
}

The drawback of the memento pattern is that external code cannot easily inspect the saved state (e.g., if a GUI wanted to show a “preview” of the saved game). However, that functionality is not in scope for this project, so either approach is acceptable.

What to Avoid

External code should not serialize the entire game object directly:

// NOT GOOD - external code serializes entire game object
await player.setAutosave(JSON.stringify(this.game));
// Problems:
// - Might include private/irrelevant data
// - Coupling to internal structure at a distance
// - What if game has non-serializable members (Set, Map, functions)?

Save Format and Discriminated Union

Saves should include a discriminator to identify the puzzle type:

// GOOD - discriminated union for save types
interface MinesweeperSave {
  readonly kind: "minesweeper";
  readonly config: MinesweeperConfig;
  readonly state: MinesweeperState;
}

interface SpellingBeeSave {
  readonly kind: "spelling_bee";
  readonly config: SpellingBeeConfig;
  readonly state: SpellingBeeState;
}

interface SudokuSave {
  readonly kind: "sudoku";
  readonly config: SudokuConfig;
  readonly state: SudokuState;
}

type PuzzleSave = MinesweeperSave | SpellingBeeSave | SudokuSave;

Factory Methods for Restoration

Don’t overload constructors with union types for new vs. restored games. Use separate factory methods:

// GOOD - separate factories for new and restored games
class SudokuGame {
  private constructor(config: SudokuConfig, board: Grid<SudokuCell>) { /* ... */ }
  
  public static create(config: SudokuConfig): SudokuGame {
    // initialize new game from config
  }
  
  public static restore(config: SudokuConfig, state: SudokuState): SudokuGame {
    // rebuild game from saved state
  }
}

// NOT GOOD - constructor overloaded with union type
class SudokuGame {
  constructor(input: SudokuConfig | SudokuSaveData) {
    if ("state" in input) { /* restore */ }
    else { /* create new */ }
  }
}

Triggering Save Operations

Autosave functionality needs to be triggered when game state changes. The key design question is: where does the save logic live, and who initiates it?

NOT GOOD: Direct Database Coupling

The game runner directly imports and calls database functions:

// NOT GOOD - game runner directly coupled to database
import { saveGameState } from "@repo/database/players";

class SudokuTUI {
  async play(): Promise<void> {
    while (!this.game.getState().is_solved) {
      // ... game loop ...
      await saveGameState(playerId, { kind: "sudoku", state: this.game.getState() });
    }
  }
}

This creates coupling from each game runner to the database. If you want to save somewhere else (network, file), you’d need to modify every game runner.

GOOD: Dependency Injection

The autosave capability is injected into the TUI class:

// GOOD - autosave capability injected as dependency
type AutosaveFunc = (save: PuzzleSave) => Promise<void>;

class SudokuTUI {
  private game: SudokuGame;
  private autosave?: AutosaveFunc;  // optional - only present if logged in

  constructor(game: SudokuGame, autosave?: AutosaveFunc) {
    this.game = game;
    this.autosave = autosave;
  }

  public async play(): Promise<void> {
    while (!this.game.getState().is_solved) {
      if (this.autosave) {
        await this.autosave({ kind: "sudoku", config: this.game.config, state: this.game.getState() });
      }
      // ... game loop continues
    }
  }
}

// When creating the TUI, inject the capability if a player is logged in
const player = Player.currentPlayer();
const sudokuTUI = new SudokuTUI(
  game,
  player ? (save) => player.setAutosave(save) : undefined
);

This makes the dependency explicit and allows different save strategies without modifying the TUI class.

Alternative: Inversion of Control (Events)

Another reasonable approach is for the game to emit events when state changes, with observers handling saving. This fully decouples the game from save behavior:

// ALSO GOOD - game emits events, observers handle saving
type GameEventHandler<TState> = (state: TState) => void;

class SudokuGame {
  private onStateChange: GameEventHandler<SudokuState>[] = [];

  public subscribe(handler: GameEventHandler<SudokuState>): void {
    this.onStateChange.push(handler);
  }

  private notifyStateChange(): void {
    const state = this.getState();
    for (const handler of this.onStateChange) {
      handler(state);
    }
  }

  public performAction(action: SudokuAction): void {
    // ... apply action ...
    this.notifyStateChange();  // emit event after state changes
  }
}

// Autosave subscribes to game events - autosave depends on game, not vice versa
class Autosaver {
  constructor(game: SudokuGame, config: SudokuConfig, player: Player) {
    game.subscribe((state) => {
      player.setAutosave({ kind: "sudoku", config, state });
    });
  }
}

// Wire up at game start - if no player, simply don't create an autosaver
const game = SudokuGame.create(config);
const player = Player.currentPlayer();
if (player) {
  new Autosaver(game, config, player);
}
// Game doesn't know or care whether it's being observed

This approach is well-motivated when you want flexibility in how (or whether) saves happen. The game is completely decoupled from save behavior - you could have autosave, manual save, or nothing at all, without changing the game class. Either dependency injection or inversion of control is a good approach here - use whichever fits your design more naturally.

10 Database Layer

All database access should go through a dedicated database layer interface. External code should not use Kysely (or any ORM/query builder) directly.

Encapsulated Database Functions

// GOOD - database access encapsulated in dedicated functions
// In packages/database/src/players.ts
export async function getPlayerById(player_id: string) {
  return DATABASE.selectFrom('players')
    .selectAll()
    .where('player_id', '=', player_id)
    .executeTakeFirst();
}

export async function insertPlayer(new_player: Insertable<Schema['players']>) {
  return DATABASE.insertInto('players')
    .values(new_player)
    .execute();
}

// Usage in app - uses database functions, not raw Kysely
const player = await getPlayerById(player_id);
await insertPlayer({ player_id, display_name });

// NOT GOOD - Kysely used directly in app code
import { DATABASE } from "@repo/database";
const player = await DATABASE.selectFrom('players')
  .where('player_id', '=', player_id)
  .executeTakeFirst();  // leaking implementation detail

Why Encapsulate?

Puzzles Don’t Access Database

The puzzle package should never import from or depend on the database package:

// NOT GOOD - puzzle imports from database
// In packages/puzzle/src/sudoku.ts
import { getPuzzleById } from "@repo/database/sudoku_puzzles";  // WRONG!

class SudokuGame {
  static async loadPuzzle(id: number) {
    const puzzle = await getPuzzleById(id);  // puzzle shouldn't know about DB
  }
}