Make It Dark - Turning Requirements into Code
Ah, finally, the moment has arrived where we transition from meticulous planning to the exciting phase of writing code. Up to this point, the focus has been on laying a robust foundation: establishing the project's architecture, embracing the principles of Test-Driven Development (TDD), and refining my coding style and linting preferences. The integration of Git hooks has also played a crucial role, ensuring every push to the repository adheres to consistent, high-quality standards. Now, it's time to bring all these preparations into fruition, translating the carefully gathered requirements into functional, well-tested code.
The Development Approach
As the coding phase begins, my strategy is rooted in clarity and precision. The transition from requirements to code involves a systematic process guided by the principles of Test-Driven Development (TDD). This approach is crucial for ensuring that each feature is not only necessary but implemented accurately and effectively.
This approach is designed to ensure that every piece of code is purposeful and contributes meaningfully to the overall gameplay experience. By adhering to TDD, the development is structured and focused, ensuring that the game not only functions as intended but does so in a way that aligns seamlessly with the game’s core objectives. This disciplined methodology sets the stage for building a game that is both engaging and technically sound.
Object-Oriented Programming
In shaping the codebase for Make It Dark, my inclination towards Object-Oriented Programming (OOP), stemming from my extensive experience with languages like C# and Java, plays a pivotal role. I’ve chosen to structure the game’s logic using classes (apologies, Ash ??), a decision that aligns with my familiarity and comfort with OOP principles. This approach not only aids in organizing the code into logical, manageable units but also facilitates maintainability and scalability. Let's outline the key classes that will form the backbone of Make It Dark’s functionality:
By using these classes, I aim to create a clear and logical structure for my game, where each class has a distinct responsibility and contributes to the overall functionality of the game. This object-oriented approach not only aligns with my programming preferences but also ensures that the game's code is robust, testable, and extendable. As we delve deeper into the coding phase, these classes will be fleshed out, each playing its part in bringing the game to life.
The GridManager Class
The GridManager class is a cornerstone in Make It Dark, responsible for generating the game's grid—a fundamental aspect of gameplay. It must not only create grids of varying sizes but also ensure each cell's initial state is set correctly and handle invalid grid dimensions gracefully.
The Tests
The test suite for GridManager is designed to validate each aspect of its functionality, ensuring the class behaves as expected under various scenarios.
import { beforeEach, describe, expect, it } from "vitest";
import { GridManager } from "./GridManager";
describe("GridManager", () => {
let gridManager: GridManager;
beforeEach(() => {
gridManager = new GridManager();
});
describe("createGrid", () => {
it("should create a grid with the correct number of rows", () => {
const rows = 3;
const columns = 3;
const grid = gridManager.createGrid(rows, columns);
expect(grid.length).toEqual(rows);
});
it("should create a grid with the correct number of columns", () => {
const rows = 3;
const columns = 3;
const grid = gridManager.createGrid(rows, columns);
expect(grid[0].length).toEqual(columns);
});
it("should create a grid with all cells set to false", () => {
const rows = 3;
const columns = 3;
const grid = gridManager.createGrid(rows, columns);
expect(grid).toStrictEqual([
[false, false, false],
[false, false, false],
[false, false, false]
]);
});
it("should throw an error if the number of rows is less than 1", () => {
expect(() => gridManager.createGrid(0, 5)).toThrow("Number of rows must be greater than 0");
});
it("should throw an error if the number of columns is less than 1", () => {
expect(() => gridManager.createGrid(5, 0)).toThrow("Number of columns must be greater than 0");
});
});
});
The Implementation
I am going to implement the GridManager class in is a step-by-step process, aligning with the Test-Driven Development (TDD) approach. Here’s how each test shapes the gradual build-up of the class.
Initially, GridManager is a simple class without any methods:
export class GridManager {}
The first implementation step addresses the tests for invalid grid sizes. I start by adding a createGrid method that includes validation checks:
class GridManager {
createGrid(rows: number, columns: number): boolean[][] {
if (rows < 1) {
throw new Error("Number of rows must be greater than 0");
}
if (columns < 1) {
throw new Error("Number of columns must be greater than 0");
}
return [];
}
}
This ensures the class correctly handles requests for grids with zero or negative dimensions. I also find it advantageous to work on the tests that have error conditions first, as it ensures that any leading tests I run have valid inputs.
I also extend the createGrid method to create a grid. Initially, it returns an empty array, which is enough to pass the dimension validation tests but not the grid creation tests.
To satisfy the tests for the correct number of rows and columns, the createGrid method is further refined to return a grid of the specified dimensions:
export class GridManager {
createGrid(rows: number, columns: number): boolean[][] {
if (rows < 1) {
throw new Error("Number of rows must be greater than 0");
}
if (columns < 1) {
throw new Error("Number of columns must be greater than 0");
}
return Array.from({ length: rows }, () => new Array(columns));
}
}
At this stage, the grid is created with the correct dimensions, but the cell states are not yet set.
Finally, to pass the test that checks the initial state of the grid's cells, I update the grid creation logic to set all cells to false:
export class GridManager {
createGrid(rows: number, columns: number): boolean[][] {
if (rows < 1) {
throw new Error("Number of rows must be greater than 0");
}
if (columns < 1) {
throw new Error("Number of columns must be greater than 0");
}
return Array.from({ length: rows },
() => Array.from({ length: columns }, () => false)
);
}
}
This completes the GridManager implementation, ensuring it meets all the outlined tests.
Through this step-by-step implementation, aligned with the TDD methodology, the GridManager evolves from a basic class into a functional component capable of generating and managing the game's grid.
The LightToggleStrategy Class
In Make It Dark, the LightToggleStrategy class plays a key role in defining the game's core mechanics. This class is responsible for changing the state of lights on the grid, including adjacent lights, based on player interactions. It is crucial for ensuring the game's logic is consistent and adheres to the defined rules of how lights should behave when toggled.
The Tests
The test suite for LightToggleStrategy is designed to validate several key aspects of light toggling behaviour:
import { beforeEach, describe, expect, it } from "vitest";
import { LightToggleStrategy } from "./LightToggleStrategy";
describe("LightToggleStrategy", () => {
let lightToggleStrategy: LightToggleStrategy;
beforeEach(() => {
lightToggleStrategy = new LightToggleStrategy();
});
describe("toggle", () => {
it("should toggle the light at the given coordinates", () => {
const grid = [
[false, false, false],
[false, false, false],
[false, false, false],
];
const updatedGrid = lightToggleStrategy.toggle(grid, 1, 1);
expect(updatedGrid[1][1]).toEqual(true);
});
it("should toggle adjacent lights", () => {
const grid = [
[false, false, false],
[false, false, false],
[false, false, false],
];
const updatedGrid = lightToggleStrategy.toggle(grid, 1, 1);
expect(updatedGrid).toStrictEqual([
[false, true, false],
[true, true, true],
[false, true, false],
]);
});
it("should toggle adjacent lights on the edge of the grid", () => {
const grid = [
[false, false, false],
[false, false, false],
[false, false, false],
];
const updatedGrid = lightToggleStrategy.toggle(grid, 0, 0);
expect(updatedGrid).toStrictEqual([
[true, true, false],
[true, false, false],
[false, false, false],
]);
});
it("should toggle adjacent lights off that are already on", () => {
const grid = [
[false, true, false],
[true, true, true],
[false, true, false],
];
const updatedGrid = lightToggleStrategy.toggle(grid, 0, 0);
expect(updatedGrid).toStrictEqual([
[true, false, false],
[false, true, true],
[false, true, false],
]);
});
it("should throw an error if the given coordinates are out of bounds", () => {
const grid = [
[false, false, false],
[false, false, false],
[false, false, false],
];
expect(() => lightToggleStrategy.toggle(grid, -1, 0)).toThrow("Row -1 is out of bounds");
expect(() => lightToggleStrategy.toggle(grid, 0, -1)).toThrow("Column -1 is out of bounds");
expect(() => lightToggleStrategy.toggle(grid, 3, 0)).toThrow("Row 3 is out of bounds");
expect(() => lightToggleStrategy.toggle(grid, 0, 3)).toThrow("Column 3 is out of bounds");
});
});
});
The Implementation
With the tests defined, the LightToggleStrategy class can be implemented to satisfy these requirements.
Initially, LightToggleStrategy is an empty class:
export class LightToggleStrategy {}
The main functionality revolves around the toggle method. This method must toggle the state of the specified light. However, I will tackle the error conditions first:
export class LightToggleStrategy {
toggle(grid: boolean[][], row: number, column: number): boolean[][] {
const updatedGrid = grid.map((row) => [...row]);
if (row < 0 || row >= grid.length) {
throw new Error(`Row ${row} is out of bounds`);
}
if (column < 0 || column >= grid[0].length) {
throw new Error(`Column ${column} is out of bounds`);
}
return updatedGrid;
}
}
Now that the error cases have been handled, the method can be updated to toggle the state of the specified light:
class LightToggleStrategy {
toggle(grid: boolean[][], row: number, col: number): boolean[][] {
const updatedGrid = grid.map((row) => [...row]);
updatedGrid[row][column] = !updatedGrid[row][column];
return updatedGrid;
}
}
I can now extend the toggle method to also toggle adjacent lights:
export class LightToggleStrategy {
toggle(grid: boolean[][], row: number, column: number): boolean[][] {
const updatedGrid = grid.map((row) => [...row]);
updatedGrid[row][column] = !updatedGrid[row][column];
updatedGrid[row - 1][column] = !updatedGrid[row - 1][column];
updatedGrid[row + 1][column] = !updatedGrid[row + 1][column];
updatedGrid[row][column - 1] = !updatedGrid[row][column - 1];
updatedGrid[row][column + 1] = !updatedGrid[row][column + 1];
return updatedGrid;
}
}
Finally, I need to ensure that I handle edge cases, such as when the light is at the border of the grid:
export class LightToggleStrategy {
toggle(grid: boolean[][], row: number, column: number): boolean[][] {
const updatedGrid = grid.map((row) => [...row]);
updatedGrid[row][column] = !updatedGrid[row][column];
if (row > 0) {
updatedGrid[row - 1][column] = !updatedGrid[row - 1][column];
}
if (row < grid.length - 1) {
updatedGrid[row + 1][column] = !updatedGrid[row + 1][column];
}
if (column > 0) {
updatedGrid[row][column - 1] = !updatedGrid[row][column - 1];
}
if (column < grid[0].length - 1) {
updatedGrid[row][column + 1] = !updatedGrid[row][column + 1];
}
return updatedGrid;
}
}
Through this implementation process, the LightToggleStrategy evolves into a functional component of Make It Dark, capable of handling the intricate logic of light toggling.
The WinConditionChecker Class
Next up, I will be looking at the WinConditionChecker class, tasked with evaluating the grid to determine if the player has met the win conditions. Unlike traditional games where winning might involve reaching a certain score or level, Make It Dark's victory criteria are based on the state of the grid - specifically, whether all lights have been turned on. This class encapsulates the logic needed to assess the grid's state, providing a clear and concise check that can be called at any point in the game to determine if the player has won.
The Tests
The tests designed for WinConditionChecker aim to cover all possible scenarios the player might encounter:
import { beforeEach, describe, expect, it } from "vitest";
import { WinConditionChecker } from "./WinConditionChecker";
describe("WinConditionChecker", () => {
let winConditionChecker: WinConditionChecker;
beforeEach(() => {
winConditionChecker = new WinConditionChecker();
});
describe("check", () => {
it("should return true if all the cells are false", () => {
const grid = [
[false, false, false],
[false, false, false],
[false, false, false],
];
const result = winConditionChecker.check(grid);
expect(result).toBe(true);
});
it("should return false if all the cells are true", () => {
const grid = [
[true, true, true],
[true, true, true],
[true, true, true],
];
const result = winConditionChecker.check(grid);
expect(result).toBe(false);
});
it("should return false if some cells are true and some are false", () => {
const grid = [
[true, false, false],
[false, true, false],
[false, false, true],
];
const result = winConditionChecker.check(grid);
expect(result).toBe(false);
});
});
});
The Implementation
To fulfil the outlined tests, WinConditionChecker needs a check method that can accurately assess the state of the grid.
Initially, WinConditionChecker is an empty class:
class WinConditionChecker {}
The core functionality resides in the check method, which initially will return false until we provide the actual implementation.
class WinConditionChecker {
check(grid: boolean[][]): boolean {
return false;
}
}
Next, I can refine the new method to accurately return true when all lights in the grid are turned off.
export class WinConditionChecker {
check(grid: boolean[][]): boolean {
return grid.every((row) => row.every((light) => !light));
}
}
Through this implementation, WinConditionChecker becomes a crucial element in determining the progression and conclusion of Make It Dark. The class's method, developed and refined through TDD, provides a reliable means to assess the game's win condition. This ensures that the game's objectives are clear and that the condition for winning is consistently applied, enhancing the game's playability and challenge.
Pausing For Now...
After developing the GridManager, LightToggleStrategy, and WinConditionChecker with a focus on Test-Driven Development, the groundwork for the core game mechanics is now in place. These components are crucial for setting up the gameplay, managing the grid, and defining the conditions for winning.
At this point, it's appropriate to pause and prepare for the next steps. Integrating these components into a unified system will be tackled in the next phase of development, specifically through the GameEngine class. This class will serve as the central hub for interaction within the game, tying together the logic and functionality of the various components.
The GameEngine will also illustrate the implementation of the Proxy pattern, streamlining the complexity of interactions. The focus on this pattern in the upcoming work will show how it aids in managing the game state and delegating tasks efficiently.
This staged development approach ensures that each part of the game is built with attention to detail and integrates seamlessly. The next step, focusing on the GameEngine, is crucial for bringing together the game's logic and providing a smooth gameplay experience. It marks a significant transition from building the game's foundational elements to focusing on the architecture that will drive the game forward.
Co-Founder Next Systems AI | AI Solutions Architect and Data Engineering
7 个月I do like the idea of making the test first to verify that the functionality is working correctly. My only concern would be some people will code for the test and not the purpose. Making sure the class and its methods are modular is also a need approach but that can be addressed with code reviews. Thanks for the article.