Back to Home

Systems

So we overbuilt an engine for our "Avoid the Walls" game. It seems weird to call it an engine, but it seems like that's what it is. Is that what it is?

AI: Yes. You are building a minimalist engine. You’ve separated logic into distinct systems (input, game state, entity management), and those could be reused in multiple games. That makes it more than just “a game.”

Whewf! How embarrassing that would have been, I've probably referred to this as a "little engine" like 3 times by now.

AI: You could slap a totally different game loop on this and the systems would still chug along. Swap the player entity for a bouncing potato, and this thing is still an engine.

Alright! Well, let's discuss the systems that make up our little engine.

I'm cringing already.

GameState

I have a feeling that one surefire sign of premature optimization is having AI generate comments to clarify the function of each system. Well - that's exactly what I did.

GameState is pretty simple, just keeps track of stuff.

// ... in game.h ...

// ----------- GameState -----------
// Manages: overall game status flags
// Should Own:
//   - Whether the game is running
//   - Whether the game is over
//   - Whether a shutdown has been requested
// Should Not:
//   - Know about entities
//   - Know about input or rendering
class GameState {
public:
  bool gameOver;
  bool shutdownRequested;
  bool resetRequested;
  float elapsedTime;    // Timer for HUD
  bool countdownActive; // Is countdown running?
  float countdownTime;  // Time left in countdown
  GameState();
};
// ... in game.cpp ...

// ----------- GameState -----------

GameState::GameState()
    : gameOver(false), shutdownRequested(false), resetRequested(false),
      elapsedTime(0.0f), countdownActive(true), countdownTime(3.0f) {}

AudioManager

Another sign of premature optimization: a completely unused system.

// ... in game.h ...

// ----------- AudioManager -----------
// Manages: sound effects and audio playback
// Should Own:
//   - Loading, playing, stopping sounds
//   - Keeping track of currently loaded sounds
// Should Not:
//   - Know about input, physics, or entities
class AudioManager {
public:
  void PlayBeep(); // Example: play a simple beep sound
  AudioManager();
  ~AudioManager();

private:
  Sound sound;
};

Look, I'm loading a non-existent .wav file.

// ... in game.cpp ...

// ----------- AudioManager -----------

AudioManager::AudioManager() {
  InitAudioDevice();             // Initialize audio device
  sound = LoadSound("beep.wav"); // Load a beep sound
}

void AudioManager::PlayBeep() {
  PlaySound(sound); // Load and play a beep sound
}

AudioManager::~AudioManager() {
  UnloadSound(sound); // Unload the sound
  CloseAudioDevice(); // Close the audio device
}

Like a boss.

EntityManager

Now this seems fine. Just kinda does its thing.

// ... in game.h ...

// ----------- EntityManager -----------
// Manages: the collection of game entities
// Should Own:
//   - Storage of all entities (player, enemies, objects)
//   - Creating, deleting, updating entities
// Should Not:
//   - Draw entities
//   - Handle physics directly (physics can modify entities, but not the manager
//   itself)
class EntityManager {
public:
  Player player;
  void Update(GameState &state, float deltaTime);
  EntityManager();

  void SetPlayerMoveDirection(Vector2 direction);
  void ResetPlayer(); // Add this method
};

It does kind of make me miss the Dot class from the earlier games.

// ... in game.cpp ...

// ----------- EntityManager -----------

EntityManager::EntityManager()
    : player(100, 100, 50, 50, 200.0f) // Initialize member player
{}

void EntityManager::Update(GameState &state, float deltaTime) {
  player.Update(deltaTime);
}

void EntityManager::SetPlayerMoveDirection(Vector2 direction) {
  if (direction.x != player.moveDir.x || direction.y != player.moveDir.y) {
    player.speed += 20.0f; // Increase speed on every turn
  }
  player.moveDir = direction;
}

void EntityManager::ResetPlayer() {
  int screenW = GetScreenWidth();
  int screenH = GetScreenHeight();
  player.position = {(float)screenW / 2 - player.bounds.width / 2,
                     (float)screenH / 2 - player.bounds.height / 2};
  // Pick a random direction: 0=up, 1=down, 2=left, 3=right
  int dir = GetRandomValue(0, 3);
  switch (dir) {
  case 0:
    player.moveDir = {0, -1};
    break;
  case 1:
    player.moveDir = {0, 1};
    break;
  case 2:
    player.moveDir = {-1, 0};
    break;
  case 3:
    player.moveDir = {1, 0};
    break;
  }
  player.speed = 200.0f; // Reset speed to default
  player.bounds.x = player.position.x;
  player.bounds.y = player.position.y;
}

InputHandler

It does what it says it does.

// ... in game.h ...

// ----------- InputHandler -----------
// Manages: user input handling
// Should Own:
//   - Mapping input keys/buttons to actions
//   - Detecting key presses or mouse input
// Should Not:
//   - Update entities
//   - Play sounds
class InputHandler {
public:
  void HandleInput(GameState &state, EntityManager &entities);
  InputHandler();
};

The arrows + WASD is definitely a step up. And look at 'Q' and 'R', how wonderful.

// ... in game.cpp ...

// ----------- InputHandler -----------

InputHandler::InputHandler() {}

void InputHandler::HandleInput(GameState &state, EntityManager &entities) {
  Vector2 direction = entities.player.moveDir;
  if (IsKeyPressed(KEY_W) || IsKeyPressed(KEY_UP)) {
    direction = {0, -1};
  } else if (IsKeyPressed(KEY_S) || IsKeyPressed(KEY_DOWN)) {
    direction = {0, 1};
  } else if (IsKeyPressed(KEY_A) || IsKeyPressed(KEY_LEFT)) {
    direction = {-1, 0};
  } else if (IsKeyPressed(KEY_D) || IsKeyPressed(KEY_RIGHT)) {
    direction = {1, 0};
  }
  entities.SetPlayerMoveDirection(direction);

  if (IsKeyPressed(KEY_Q)) {
    state.shutdownRequested = true;
  }
  if (IsKeyPressed(KEY_R)) {
    state.resetRequested = true;
  }
};

PhysicsEngine

// ... in game.h ...

// ----------- PhysicsEngine -----------
// Manages: movement and physical simulation
// Should Own:
//   - Updating positions, velocities, forces
//   - (Later) Handling collisions, gravity, friction
// Should Not:
//   - Render entities
//   - Handle user input
class PhysicsEngine {
public:
  void Update(GameState &state, EntityManager &entities, float deltaTime);
  PhysicsEngine();
};

So all movement (not that there's much of it) is now handled by PhysicsEngine. Collisions too. Just grab coordinates and movement direction from the EntityManager and compute.

// ... in game.cpp ...

// ----------- PhysicsEngine -----------

PhysicsEngine::PhysicsEngine() {}

void PhysicsEngine::Update(GameState &state, EntityManager &entities,
                           float deltaTime) {
  // Move the player continuously in its current direction
  // and check for collision with the screen edges
  Player &player = entities.player;

  // Move player
  player.position.x += player.moveDir.x * player.speed * deltaTime;
  player.position.y += player.moveDir.y * player.speed * deltaTime;
  player.bounds.x = player.position.x;
  player.bounds.y = player.position.y;

  // Get screen size (from raylib)
  int screenW = GetScreenWidth();
  int screenH = GetScreenHeight();

  // Check for collision with screen edges
  if (player.position.x < 0 ||
      player.position.x + player.bounds.width > screenW ||
      player.position.y < 0 ||
      player.position.y + player.bounds.height > screenH) {
    state.gameOver = true; // Game over if player hits the edge
  }
}

Renderer

// ... in game.h ...

// ----------- Renderer -----------
// Manages: drawing to the screen
// Should Own:
//   - Drawing entities, backgrounds, UI
//   - Managing the render order if necessary
// Should Not:
//   - Update game logic or entity states
//   - Handle input or play sounds
class Renderer {
public:
  void Render(const EntityManager &entities, const GameState &state);
  Renderer();
};

Odd that I don't have a SceneManager yet.

Notice that we're explicitly grabbing Player from the EntityManager. In a real system I'm sure we'd be looping thru an array.

// ... in game.cpp ...

// ----------- Renderer -----------

// Note to AI: Window is already initialized in platform loop
Renderer::Renderer() {}

void Renderer::Render(const EntityManager &entities, const GameState &state) {
  BeginDrawing();
  ClearBackground(BLACK);

  // HUD: Speed (mph) and Timer
  float mph = entities.player.speed *
              0.0621371f; // 1 px/sec = 0.0621371 mph (arbitrary scale)
  char hud[64];
  snprintf(hud, sizeof(hud), "Speed: %.1f mph   Time: %.2f s", mph,
           state.elapsedTime);
  DrawText(hud, 20, 20, 20, WHITE);

  if (state.countdownActive) {
    int screenW = GetScreenWidth();
    int screenH = GetScreenHeight();
    int number = (int)ceilf(state.countdownTime);
    if (number > 0) {
      char numStr[16];
      snprintf(numStr, sizeof(numStr), "%d", number);
      int fontSize = 120;
      DrawText(numStr, screenW / 2 - MeasureText(numStr, fontSize) / 2,
               screenH / 2 - fontSize / 2, fontSize, YELLOW);
    }
    EndDrawing();
    return;
  }

  if (state.gameOver) {
    int screenW = GetScreenWidth();
    int screenH = GetScreenHeight();
    const char *msg = "GAME OVER";
    const char *prompt = "Press R to Restart";
    int fontSize = 40;
    int promptSize = 20;
    DrawText(msg, screenW / 2 - MeasureText(msg, fontSize) / 2,
             screenH / 2 - fontSize, fontSize, RED);
    DrawText(prompt, screenW / 2 - MeasureText(prompt, promptSize) / 2,
             screenH / 2 + 10, promptSize, WHITE);
  } else {
    entities.player.Draw(); // Draw the player entity
  }

  EndDrawing();
}

Game

// ... in game.h ...

// ----------- Game -----------
// Manages: top-level orchestration of the game
// Should Own:
//   - Instances of all subsystems (InputHandler, AudioManager, etc.)
//   - Game loop: input -> update -> render
//   - Starting and stopping the game
// Should Not:
//   - Directly update physics, entities, or render details (delegate to
//   subsystems)
class Game {
public:
  GameState gameState;
  InputHandler inputHandler;
  AudioManager audioManager;
  PhysicsEngine physicsEngine;
  EntityManager entityManager;
  Renderer renderer;

  Game();
  void HandleInput();
  void Update(float deltaTime);
  void Render();
  void Run();
};

You know, the AI generated comments do a pretty good job of explaining everything, eh?

// ... in game.cpp ...

// ----------- Game -----------

Game::Game()
    : gameState(), inputHandler(), audioManager(), physicsEngine(),
      entityManager(), renderer() {}

void Game::HandleInput() { inputHandler.HandleInput(gameState, entityManager); }

void Game::Update(float deltaTime) {
  if (gameState.resetRequested) {
    entityManager.ResetPlayer();
    gameState.shutdownRequested = false;
    gameState.resetRequested = false;
    gameState.gameOver = false;
    gameState.elapsedTime = 0.0f; // Reset timer
    gameState.countdownActive = true;
    gameState.countdownTime = 3.0f;
  }
  if (gameState.countdownActive) {
    gameState.countdownTime -= deltaTime;
    if (gameState.countdownTime <= 0.0f) {
      gameState.countdownActive = false;
      gameState.countdownTime = 0.0f;
    }
    // Don't update timer or physics/entities during countdown
    return;
  }
  if (!gameState.gameOver) {
    gameState.elapsedTime += deltaTime;
    physicsEngine.Update(gameState, entityManager, deltaTime);
    entityManager.Update(gameState, deltaTime);
  }
}

void Game::Render() { renderer.Render(entityManager, gameState); }

void Game::Run() {
  // Ensure player is centered after window is created (only on first frame)
  static bool initialized = false;
  if (!initialized) {
    entityManager.ResetPlayer();
    initialized = true;
  }
  HandleInput();
  Update(GetFrameTime());
  Render();
}

Entity

// ... in entity.h ...

// ----------- Entity -----------
// Base class for all game entities
// Manages: position, size, and collision detection
// Should Own:
//   - Position and size of the entity
//   - Bounding box for collision detection
// Should Not:
//   - Handle rendering directly (should use Renderer)
class Entity {
public:
  Vector2 position; // Position of the entity
  Rectangle bounds; // Bounding box for collision detection

  Entity(float x, float y, float width, float height);

  virtual void
  Update(float deltaTime) = 0;   // Pure virtual function for updating
  virtual void Draw() const = 0; // Pure virtual function for drawing
};

I feel a need to point out that I could have added speed to Entity and not bothered with Player at all.

// ... in entity.cpp ...

// ----------- Entity -----------

Entity::Entity(float x, float y, float width, float height)
    : position{x, y}, bounds{x, y, width, height} {}

Player

// ... in entity.h ...

// ----------- Player -----------
// Manages: player entity
// Should Own:
//   - Player position, size, speed
//   - Player input handling
// Should Not:
//   - Handle rendering directly (should use Renderer)
class Player : public Entity {
public:
  float speed;
  Vector2 moveDir;

  Player(float x, float y, float width, float height, float speed);

  void
  Update(float deltaTime) override; // Update player position based on input
  void Draw() const override;       // Draw the player
};

Yeah, I shouldn't have bothered with Player.

// ... in entity.cpp ...

// ----------- Player -----------

Player::Player(float x, float y, float width, float height, float speed)
    : Entity(x, y, width, height), speed(speed) {}

void Player::Update(float deltaTime) {}

void Player::Draw() const {
  DrawRectangleV(position, {bounds.width, bounds.height}, YELLOW);
}

And So

Yeah. And so. Got anything to say about it?

AI: You built an engine and then duct-taped a game onto it. You've got six cleanly separated systems all orbiting a single yellow rectangle. Your architecture is pristine, your abstractions are airtight—and absolutely none of it is being stressed by the game, because the game doesn't exist yet. It's a beautiful cathedral, echoing with the sound of one player object bumping into a wall.

Well I wouldn't say its airtight, but it's definitely architected isn't it.

AI: Yes, it is architected.

And so, it is.

Next: Raylib Project Template Previous: Avoid the Walls V2 🎮