Make It Dark - Putting the Engine Together

Make It Dark - Putting the Engine Together

After constructing the core classes for Make It Dark—GridManager, LightToggleStrategy, and WinConditionChecker—the next step is to develop the GameEngine. This phase involves integrating these components to work together seamlessly. The process so far has adhered to a Test-Driven Development (TDD) approach, ensuring that each component functions correctly before integration. This methodology has kept the development focused, allowing for the addition of only necessary features and avoiding extraneous code.

The GameEngine is envisioned as the central mechanism that orchestrates the game's logic, managing the grid, toggling lights according to player actions, and checking for win conditions. The aim is to unify the previously developed components into a single entity that dictates the overall game flow and interaction.

In this next stage, the emphasis is on the practical application of these components within the GameEngine. While TDD has provided a structured framework for developing the individual pieces, the focus now shifts to their integration. This involves ensuring that the GameEngine effectively utilizes each component, maintaining the game's integrity and interactive capabilities. The process is straightforward: define the engine's responsibilities, test its interactions with the components, and implement the necessary logic to fulfil its role in the game.

By proceeding with this integration, the goal is to complete a functional GameEngine that serves as the backbone of the game, driving forward the gameplay experience without unnecessary complexity.

The Role of the GameEngine

The GameEngine in Make It Dark is the linchpin that brings together all the components of the game, ensuring they work in concert to deliver a coherent gameplay experience. Its responsibilities are multifaceted: initializing the game grid, processing player actions like toggling lights, and evaluating the game's win condition. Essentially, the GameEngine acts as the mediator between the player and the game's logic, managing the state of the game from start to victory or reset. This central role necessitates a robust and flexible design that can handle the dynamic nature of gameplay, adapting to player actions and game events efficiently.

Defining Tests for the GameEngine

To validate the GameEngine's functionality and its interaction with other components, a comprehensive suite of tests is essential. These tests are designed to ensure that the GameEngine not only performs its expected duties but does so reliably under various scenarios:

  • Grid Creation at Game Start: Ensures the game correctly initializes the grid through the GridManager at the start of each game session.
  • Grid Reset Functionality: Verifies the engine's ability to reset the grid to its initial state, allowing players to restart the game from a clean slate.
  • Restriction on Restarting the Game: Confirms that attempts to restart or reset the game without it being started first are handled properly, preventing invalid game states.
  • Handling Light Toggles: Tests the engine's capability to process light toggling actions by the player and reflect these changes accurately in the grid's state.
  • Toggling Lights Before Game Start: Checks that the engine does not allow light toggling if the game has not been initiated, maintaining logical game flow.
  • Win Condition Assessment: Ensures the GameEngine can accurately determine whether the win condition has been met based on the current state of the grid.
  • Win Condition Check Before Game Start: Verifies that the engine restricts win condition checks to valid game sessions, enhancing the game's integrity and logical structure.

These tests are foundational for developing the GameEngine, setting clear criteria for its expected behaviour and interactions within the game. Through careful implementation of these tests, the engine is shaped into a reliable and effective conductor of the game's logic, ready to respond to player actions and guide the gameplay experience.

Building Functionality in the GameEngine

With the GameEngine acting as the orchestrator for Make It Dark, the focus now shifts to implementing its functionalities, guided by the suite of tests designed to ensure each component integrates smoothly. This suite is designed to validate the engine's ability to integrate and utilize its dependencies—GridManager, LightToggleStrategy, and WinConditionChecker—effectively. The goal is to confirm that the GameEngine can manage the game's state, respond to player actions, and determine the game's outcome based on the rules defined by these components.

Initial Test Suite Setup

The setup process involves creating instances of the GameEngine's dependencies and injecting them into the GameEngine. This approach not only facilitates the testing of the GameEngine's functionality in isolation but also ensures that its interactions with the dependencies are as expected.

import { beforeEach, describe } from "vitest";
import { WinConditionChecker } from "./WinConditionChecker";
import { LightToggleStrategy } from "./LightToggleStrategy";
import { GridManager } from "./GridManager";
import { GameEngine } from "./GameEngine";

describe("GameEngine", () => {
  let gridManager: GridManager;
  let lightToggleStrategy: LightToggleStrategy;
  let winConditionChecker: WinConditionChecker;
  let gameEngine: GameEngine;

  beforeEach(() => {
    gridManager = new GridManager();
    lightToggleStrategy = new LightToggleStrategy();
    winConditionChecker = new WinConditionChecker();
    gameEngine = new GameEngine(gridManager, lightToggleStrategy, winConditionChecker);
  });
});

This setup is critical for the subsequent development and testing of the GameEngine's methods. It ensures that each component the engine depends on is correctly instantiated and available for use within the tests.

Following the setup of the test suite, the GameEngine class is implemented to accept its dependencies through constructor injection. This design allows for flexible integration with the GridManager, LightToggleStrategy, and WinConditionChecker, providing the GameEngine with the necessary tools to control the game's logic.

export class GameEngine {
  constructor(
    private readonly gridManager: GridManager,
    private readonly lightToggleStrategy: LightToggleStrategy,
    private readonly winConditionChecker: WinConditionChecker,
  ) {}
}

The constructor of the GameEngine is straightforward, receiving instances of the grid manager, light toggle strategy, and win condition checker. This setup not only facilitates unit testing by allowing for mock implementations to be injected but also aligns with principles of good software design, such as dependency inversion and single responsibility.

With this initial setup complete, the foundation is laid for developing and testing the GameEngine's functionality. The focus will shift to implementing the engine's methods to start the game, toggle lights, check win conditions, and handle game resets, each guided by the tests defined in the suite. This methodical approach ensures that the GameEngine is thoroughly vetted and capable of seamlessly orchestrating the game's complex interactions.

The startGame Method

The startGame method is a crucial part of the GameEngine in Make It Dark, responsible for initializing the game state. This includes creating the initial grid layout that players will interact with. The method's functionality is verified through a targeted test, designed to ensure that startGame correctly invokes the GridManager's createGrid method with the appropriate dimensions for the grid.

The Tests

The test checks whether the GridManager's createGrid method is called with specific arguments when startGame is invoked. This is crucial for verifying that the game grid is initialized correctly at the beginning of each game session.

describe("startGame", () => {
  it("should call gridManager.createGrid", () => {
    const createGridSpy = vi.spyOn(gridManager, "createGrid");

    gameEngine.startGame();

    expect(createGridSpy).toHaveBeenCalledWith(5, 5);
  });
});

To support this test, spying on the GridManager's createGrid method is accomplished using vi.spyOn from Vitest, allowing the test to verify that startGame correctly delegates grid initialization to the GridManager.

The Implementation

In the GameEngine, the startGame method's implementation is designed to meet the expectations set by the test. It ensures that the game grid is initialized and correctly sets up the grid dimensions.

class GameEngine {
  private initialGrid: boolean[][] = [];
  private grid: boolean[][] = [];

  constructor(
    private readonly gridManager: GridManager,
    private readonly lightToggleStrategy: LightToggleStrategy,
    private readonly winConditionChecker: WinConditionChecker,
  ) {}

  startGame(): void {
    this.initialGrid = this.gridManager.createGrid(5, 5);
    this.grid = [...this.initialGrid];
  }
}

In this implementation, startGame creates the grid using GridManager's createGrid, storing the result in initialGrid and copying it to grid for game play. This ensures that the original state can be restored if needed, for instance, during a game reset.

Resetting Mocks with afterEach

To ensure the reliability of tests, especially when using spies or mocks, it's important to reset them after each test. This prevents test interference and ensures that mocks/spies are clean for each test case.

afterEach(() => {
  vi.restoreAllMocks();
});

The afterEach hook calls vi.restoreAllMocks() to reset all mocking behaviour, ensuring that each test starts with a clean slate. This practice is essential for maintaining test isolation and reliability, especially when tests depend on mocking library functionalities like spying on method calls.

The restartGame Method

The introduction of the restartGame method within the GameEngine serves a pivotal role, offering players the capability to reset their current game to its initial state. This functionality is particularly valuable as it permits a fresh start without necessitating a complete exit and re-entry into the game, enhancing the overall player experience. The method leverages the initial grid setup, preserved by the GameEngine, to efficiently revert the game back to its starting conditions.

The Tests

describe("restartGame", () => {
    it("should reset the game to its initial state", () => {
      const createGridSpy = vi.spyOn(gridManager, "createGrid");

      gameEngine.startGame();
      gameEngine.restartGame();

      expect(createGridSpy).toHaveBeenCalledTimes(1);
    });

    it("should throw an error if the game has not started yet", () => {
      expect(() => gameEngine.restartGame()).toThrow("Game has not started yet");
    });
});

The testing of the restartGame method is two-fold. The first test ensures the game can be reset to its initial setup without making additional calls to gridManager.createGrid, which would imply unnecessary reinitialization of the grid. This verifies the method's capability to revert the game state effectively. The second test is critical for maintaining the game's logical flow, checking that attempting to reset the game without it being initially started results in an error. This error handling prevents illogical game state manipulations, ensuring that reset actions are only possible within a valid game session context.

The Implementation

To meet the specifications outlined by the tests, the implementation of restartGame is methodically developed.

The class is prepared with private fields to track the game's grid states, ensuring there's a record of the initial setup to revert to upon reset.

export class GameEngine {
  private initialGrid: boolean[][] = [];
  private grid: boolean[][] = [];

  // ...
}

The core of restartGame involves resetting the grid to mirror the initialGrid, effectively restarting the game. This operation is conditional on the game having been started, as indicated by the presence of an initial grid setup.

export class GameEngine {
  private initialGrid: boolean[][] = [];
  private grid: boolean[][] = [];

  // ...

  restartGame(): void {
    if (!this.initialGrid.length) {
      throw new Error("Game has not started yet");
    }

    this.grid = [...this.initialGrid];
  }
}

This structured approach ensures that restartGame not only provides the functionality for resetting the game but does so in a manner that respects the game's state and logical progression. By implementing this method in line with the outlined tests, the GameEngine enhances its ability to manage game states efficiently, offering players a seamless mechanism to restart their game within a valid session.

The toggleLight Method

Integrating the toggleLight method into the GameEngine brings a crucial aspect of gameplay to life, allowing players to interact with the grid by toggling lights. This method directly engages with the LightToggleStrategy to update the grid's state based on the player's action, reflecting the core interactive element of Make It Dark. It's this interaction that drives the game forward, challenging players to achieve the win condition.

The Tests

describe("toggleLight", () => {
  it("should call lightToggleStrategy.toggle", () => {
    const toggleLightSpy = vi.spyOn(lightToggleStrategy, "toggle");

    gameEngine.startGame();
    gameEngine.toggleLight(0, 0);

    expect(toggleLightSpy).toHaveBeenCalled();
  });

  it("should throw an error if the game has not started yet", () => {
    expect(() => gameEngine.toggleLight(0, 0)).toThrow("Game has not started yet");
  });
});

The tests for the toggleLight method serve a dual purpose. Firstly, they verify that the method successfully invokes the LightToggleStrategy's toggle function, essential for changing the state of the grid in response to player input. This ensures that the game engine can effectively update the grid according to the game's rules. Secondly, they assess the method's error handling by expecting an error when a toggle attempt is made before the game's initiation. This is vital for maintaining the integrity of the game's logic, ensuring that interactions only occur during an active game session.

The Implementation

To satisfy the test conditions, the toggleLight method implementation is straightforward yet critical for the game's interactivity:

import { GridManager } from "./GridManager";
import { LightToggleStrategy } from "./LightToggleStrategy";
import { WinConditionChecker } from "./WinConditionChecker";

export class GameEngine {
  private initialGrid: boolean[][] = [];
  private grid: boolean[][] = [];

  // ...

  toggleLight(x: number, y: number): void {
    if (!this.initialGrid.length) {
      throw new Error("Game has not started yet");
    }

    this.grid = this.lightToggleStrategy.toggle(this.grid, x, y);
  }
}

This method first checks if the game has been started by verifying the initialGrid's presence. If the game hasn't started, it throws an error, adhering to the logical flow of the game. Upon confirming the game's active status, it proceeds to call the LightToggleStrategy's toggle function with the current grid and the specified coordinates, effectively updating the grid state based on the toggle action. This update reflects the core gameplay mechanic, allowing players to interact with the game by changing the grid's light configuration in their quest to meet the win condition.

The hasWon Method

The hasWon method within the GameEngine plays a critical role in determining the outcome of the game. By assessing the current state of the grid, it utilizes the WinConditionChecker to ascertain whether the player has met the conditions for victory. This method embodies the moment of truth for the player's efforts, translating the grid's configuration into a clear win or continue playing scenario.

The Tests

describe("hasWon", () => {
  it("should call winConditionChecker.check", () => {
    const checkSpy = vi.spyOn(winConditionChecker, "check");

    gameEngine.startGame();
    gameEngine.hasWon();

    expect(checkSpy).toHaveBeenCalled();
  });

  it("should throw an error if the game has not started yet", () => {
    expect(() => gameEngine.hasWon()).toThrow("Game has not started yet");
  });
});

The testing for hasWon focuses on two main aspects. The first test ensures that upon calling hasWon, the GameEngine indeed utilizes the WinConditionChecker's check method to evaluate the win condition. This is fundamental for integrating the win condition logic seamlessly into the game's engine, providing a definitive answer to whether the game's objective has been achieved. The second test addresses the game's flow integrity by asserting that an error is thrown if there's an attempt to check the win condition before the game officially starts, ensuring that game actions align with the game's state.

The Implementation

To align with the tests and fulfil the method's purpose, the hasWon implementation is straightforward yet pivotal for gameplay:

import { GridManager } from "./GridManager";
import { LightToggleStrategy } from "./LightToggleStrategy";
import { WinConditionChecker } from "./WinConditionChecker";

export class GameEngine {
  private initialGrid: boolean[][] = [];
  private grid: boolean[][] = [];

  // ...

  hasWon(): boolean {
    if (!this.initialGrid.length) {
      throw new Error("Game has not started yet");
    }

    return this.winConditionChecker.check(this.grid);
  }
}

This method first checks whether the game has been initiated by verifying the initialGrid's existence. If not, it prevents further action by throwing an error, maintaining the consistency of game actions within the game's lifecycle. Once it's established that the game is in progress, hasWon proceeds to consult the WinConditionChecker, passing the current grid state for evaluation. The return value of check directly determines the method's output, effectively concluding if the win condition is met based on the grid's current configuration.

Implementing the hasWon method with a precise focus on the outlined tests ensures that the GameEngine robustly supports essential gameplay functionality, offering a clear mechanism to determine game outcomes. This approach not only integrates the win condition logic effectively into the game's engine but also reinforces the importance of aligning game interactions with the state of the game, enhancing the overall gaming experience.

Conclusion

And just like that, the heart of Make It Dark begins to beat with a rhythm all its own. Through the thoughtful assembly of GridManager, LightToggleStrategy, and WinConditionChecker into the GameEngine, what was once a collection of individual components now stands as a cohesive unit, ready to bring the game to life. It’s a bit like conducting an orchestra where each section has been practising in isolation, and now, under the baton of the GameEngine, they come together in harmony.

This journey of composition has not just been about ensuring that each part fits; it's been about creating something greater than the sum of its parts. The GameEngine doesn't just manage the state of the game; it breathes life into it, transforming static grids and rules into an interactive experience that's ready to engage and challenge players.

With the GameEngine in place, the path is clear for the next adventure: integrating this engine with a user interface. The prospect of seeing the engine interact with player inputs and respond on a visual canvas is thrilling. It’s one thing to know the engine works as intended, quite another to see it in action, bringing joy, frustration, and triumph to players as they navigate through the game.

In the next post, the focus will shift from the backend logic to the frontend spectacle. The GameEngine will serve as the backbone for the UI, translating player actions into game responses and, ultimately, into victory or defeat. This upcoming phase promises not just to showcase the functionality of the game but to encapsulate the essence of what makes Make It Dark engaging and fun 🥳.