Sudoku Game: Core Domain & State Engine Explained

by Henrik Larsen 50 views

Hey guys! Let's dive into the core domain types and state engine for our Sudoku game. This is a crucial step in building out the gameplay logic, and we'll be focusing on the nuts and bolts of how the game works under the hood. We're aiming to create a solid foundation that we can build upon as we add more features and functionality.

Acceptance Criteria Breakdown

Before we get into the technical details, let's break down the acceptance criteria outlined in MVP ยง9.1. This will give us a clear understanding of what we need to accomplish.

  • Initial Game State: Given a puzzle fixture โ†’ When creating a new game โ†’ Then initial state includes givens, empty cells, difficulty, lives. This means that when a new game is created from a predefined puzzle, the game state should correctly initialize the known numbers (givens), the empty cells, the difficulty level, and the number of lives the player has.
  • Action Handling: Given actions (place, note, erase) โ†’ When applied โ†’ Then state updates deterministically. This is all about making sure that when a player interacts with the game (placing a number, making a note, or erasing a cell), the game state updates predictably and consistently. This deterministic behavior is key to a smooth and fair gameplay experience.
  • Solved Board Detection: Given solved board โ†’ When checked โ†’ Then isSolved returns true. We need to implement a mechanism to correctly determine when the Sudoku puzzle has been solved. This will involve checking the game state against the Sudoku rules to ensure that all rows, columns, and 3x3 boxes contain the numbers 1 through 9 without repetition.

It's important to note that UI wiring and persistence are out of scope for this particular task. We're focusing solely on the core game logic and state management. This means we won't be worrying about how the game is displayed to the user or how the game state is saved and loaded. Those aspects will be addressed in later stages.

Tech Notes: Files and Structure

To organize our code, we'll be creating three new files:

  • app/game/types.ts: This file will define the core data types and interfaces for our game, such as the representation of a cell, the game board, and the game state.
  • app/game/state.ts: This file will contain the logic for managing the game state, including creating a new game, applying actions, and checking if the puzzle is solved.
  • app/game/rules.ts: This file will encapsulate the Sudoku rules and provide functions for validating moves and checking the game state against those rules.

This separation of concerns will help us keep our code organized, maintainable, and testable.

Diving Deeper: Core Domain Types (app/game/types.ts)

Let's start by thinking about the core data structures we'll need. First and foremost, we need a way to represent a single cell on the Sudoku grid. A cell can either be empty, contain a number (1-9), or contain notes made by the player. We also need to know if a cell is a given, meaning it was part of the initial puzzle and cannot be changed by the player.

So, we can define a Cell type like this (in TypeScript, of course):

interface Cell {
 value: number | null; // null represents an empty cell
 isGiven: boolean;
 notes: number[]; // Array of notes (numbers 1-9)
}

This Cell interface gives us the basic building block for our Sudoku board. Now, how do we represent the entire board? A Sudoku board is a 9x9 grid of cells, so we can represent it as a 2D array of Cell objects:

type Board = Cell[][];

Next, we need to define the overall game state. This will include the board, the difficulty level, the number of lives the player has, and any other information we need to track during the game.

interface GameState {
 board: Board;
 difficulty: string; // e.g., "easy", "medium", "hard"
 lives: number;
 isSolved: boolean;
}

This GameState interface is the heart of our game logic. It holds all the information necessary to represent the current state of the game. The difficulty property allows us to adjust the complexity of the puzzles, while the lives property adds a layer of challenge and consequence for incorrect moves. The isSolved flag will be used to track whether the player has successfully completed the puzzle.

By carefully defining these core types, we're setting ourselves up for success in the next steps. A well-defined data model makes it much easier to write clean, efficient, and maintainable code. It also provides a clear and consistent interface for interacting with the game state.

Key Considerations for Domain Types:

  1. Immutability: Think about whether our types should be immutable. Immutability can make reasoning about state changes easier and prevent accidental modifications. If we decide to go with immutability, we'll need to use techniques like creating new objects instead of modifying existing ones.
  2. Data Validation: We might want to add validation to our types to ensure that the data is always in a valid state. For example, we could add checks to ensure that the value of a cell is always between 1 and 9, or that the notes array only contains unique numbers within that range. This can help prevent bugs and make our code more robust.
  3. Serialization: Consider how our types will be serialized and deserialized if we decide to persist the game state. This will be important when we eventually implement the persistence layer. We might need to add methods or use libraries to handle serialization and deserialization.

State Engine (app/game/state.ts)

Now that we have our core types defined, let's move on to the state engine. The state engine is responsible for managing the game state and applying actions to it. This is where the core gameplay logic resides.

First, we'll need a function to create a new game from a puzzle fixture. A puzzle fixture is simply a representation of the initial Sudoku grid, typically represented as a string or a 2D array of numbers. This function will take a puzzle fixture and a difficulty level as input and return a new GameState object.

function createNewGame(puzzle: number[][], difficulty: string): GameState {
 const board: Board = puzzle.map((row, rowIndex) =>
 row.map((value, colIndex) => ({
 value: value === 0 ? null : value, // 0 represents an empty cell
 isGiven: value !== 0,
 notes: [],
 }))
 );

 return {
 board,
 difficulty,
 lives: 3, // Initial number of lives
 isSolved: false,
 };
}

In this createNewGame function, we are taking a 2D array of numbers as the puzzle input. We then map over this array to create our Board, which is a 2D array of Cell objects. If a value in the puzzle is 0, we treat it as an empty cell (value: null). Otherwise, we create a cell with the given value and mark it as a given (isGiven: true). We also initialize the notes array to be empty for each cell. The initial number of lives is set to 3, and the game is initially marked as not solved (isSolved: false).

Next, we need a function to apply actions to the game state. Actions represent the player's moves, such as placing a number, making a note, or erasing a cell. We can define an Action type like this:

type Action =
 | { type: 'PLACE'; row: number; col: number; value: number }
 | { type: 'NOTE'; row: number; col: number; value: number }
 | { type: 'ERASE'; row: number; col: number };

This Action type is a discriminated union, which means it can be one of several different types, each with its own properties. The PLACE action represents placing a number in a cell, the NOTE action represents making a note, and the ERASE action represents erasing a cell.

Now, we can define a function to apply an action to the game state:

function applyAction(state: GameState, action: Action): GameState {
 switch (action.type) {
 case 'PLACE':
 // Implement logic to place a number
 break;
 case 'NOTE':
 // Implement logic to add or remove a note
 break;
 case 'ERASE':
 // Implement logic to erase a cell
 break;
 default:
 return state; // Invalid action, return the original state
 }

 return state; // Return the updated state
}

This applyAction function takes the current GameState and an Action as input. It uses a switch statement to determine the type of action and then applies the appropriate logic. For each action type (PLACE, NOTE, ERASE), we'll need to implement the specific logic to update the game state. This might involve checking if the move is valid, updating the cell's value or notes, and potentially decrementing the number of lives if the move is incorrect. Finally, we return the updated GameState. It's crucial that this function updates the state deterministically, meaning that given the same initial state and action, it should always produce the same resulting state. This is fundamental for game stability and predictability.

We'll also need a function to check if the puzzle is solved. This function will take the GameState as input and return a boolean indicating whether the puzzle is solved.

function isSolved(state: GameState): boolean {
 // Implement logic to check if the puzzle is solved
 return false; // Placeholder
}

The isSolved function is a critical part of our game logic. It needs to accurately determine whether the Sudoku puzzle has been solved according to the rules of the game. This typically involves checking that each row, column, and 3x3 subgrid contains all the numbers from 1 to 9 without any duplicates. We'll delve into the specific implementation details in the next section when we discuss the rules engine. For now, this placeholder simply returns false.

Key Considerations for the State Engine:

  1. Immutability: As mentioned earlier, immutability is a key consideration for the state engine. Applying actions should create a new GameState object instead of modifying the existing one. This can be achieved using techniques like object spread or libraries like Immer.
  2. Error Handling: We need to think about how to handle invalid actions. For example, what happens if the player tries to place a number in a cell that already has a value, or if the player tries to place a number that violates the Sudoku rules? We might want to throw exceptions or return error codes to indicate that an action failed.
  3. Performance: The state engine should be efficient, especially when applying actions. We want to avoid performance bottlenecks that could lead to a laggy gameplay experience. This might involve optimizing our data structures or algorithms.

Rules Engine (app/game/rules.ts)

Now, let's talk about the rules engine. The rules engine is responsible for enforcing the Sudoku rules and validating moves. This is where we'll implement the logic to check if a number can be placed in a cell without violating the Sudoku rules, and to determine if the puzzle is solved.

First, we'll need a function to check if a number is valid in a given cell. This function will take the GameState, the row, the column, and the number as input and return a boolean indicating whether the number is valid.

function isValidMove(state: GameState, row: number, col: number, value: number): boolean {
 // Implement logic to check if the move is valid
 return true; // Placeholder
}

The isValidMove function is the gatekeeper for player actions. It determines whether a proposed move adheres to the fundamental rules of Sudoku. This involves checking if the same number already exists in the same row, column, or 3x3 subgrid as the target cell. By encapsulating this logic in the rules engine, we ensure that the game remains fair and consistent. The current implementation is a placeholder, simply returning true. We will delve into the specific validation logic shortly.

This function will need to check the following:

  • Row: Check if the number already exists in the same row.
  • Column: Check if the number already exists in the same column.
  • 3x3 Box: Check if the number already exists in the 3x3 box that the cell belongs to.

If the number does not exist in any of these places, then the move is valid. Otherwise, the move is invalid.

Next, we can implement the logic for checking if the puzzle is solved. This involves iterating over the board and checking that each row, column, and 3x3 box contains the numbers 1 through 9 without repetition. This is where we'll flesh out the isSolved function we mentioned earlier.

function isSolved(state: GameState): boolean {
 const { board } = state;

 // Check rows
 for (let i = 0; i < 9; i++) {
 const rowValues = new Set<number>();
 for (let j = 0; j < 9; j++) {
 const value = board[i][j].value;
 if (value === null) return false; // If any cell is empty, it's not solved
 if (rowValues.has(value)) return false; // Duplicate in row
 rowValues.add(value);
 }
 }

 // Check columns
 for (let j = 0; j < 9; j++) {
 const colValues = new Set<number>();
 for (let i = 0; i < 9; i++) {
 const value = board[i][j].value;
 if (colValues.has(value)) return false; // Duplicate in column
 colValues.add(value);
 }
 }

 // Check 3x3 boxes
 for (let boxRow = 0; boxRow < 3; boxRow++) {
 for (let boxCol = 0; boxCol < 3; boxCol++) {
 const boxValues = new Set<number>();
 for (let i = boxRow * 3; i < boxRow * 3 + 3; i++) {
 for (let j = boxCol * 3; j < boxCol * 3 + 3; j++) {
 const value = board[i][j].value;
 if (boxValues.has(value)) return false; // Duplicate in box
 boxValues.add(value);
 }
 }
 }
 }

 return true; // All checks passed, the puzzle is solved
}

In this isSolved function, we iterate over the board to check rows, columns, and 3x3 boxes. For each row, column, and box, we use a Set to keep track of the numbers we've seen. If we encounter a duplicate number or an empty cell (indicated by null), we immediately return false, as the puzzle is not solved. If we make it through all the checks without finding any violations, we return true, indicating that the puzzle is indeed solved. This method is efficient because the Set data structure allows for quick checks for the existence of a number.

Key Considerations for the Rules Engine:

  1. Performance: The rules engine should be efficient, as it will be called frequently during gameplay. We want to avoid performance bottlenecks that could lead to a laggy gameplay experience. This might involve optimizing our algorithms or using caching techniques.
  2. Testability: The rules engine should be easily testable. We want to be able to write unit tests to verify that the rules are being enforced correctly. This might involve breaking the rules engine into smaller, more modular functions.
  3. Flexibility: We might want to make the rules engine flexible enough to support different Sudoku variants or difficulty levels. This might involve adding configuration options or using a more generic rule-checking mechanism.

Conclusion

Alright guys, we've covered a lot of ground here! We've discussed the core domain types, the state engine, and the rules engine for our Sudoku game. By carefully designing these components, we're building a solid foundation for our game. Remember, this is just the beginning. We'll continue to refine and expand upon these concepts as we add more features and functionality.

Next steps? Let's start implementing these pieces and writing some tests! We'll also want to think about how these components interact with each other and how they fit into the overall architecture of our game. Keep up the great work!