Back to Home

Collect the Dots V1

So the first version of Dot Game was 92 lines of procedural simplicity. The fourth and final version was 257 lines of object oriented complexity.

The path from 92 to 257 was fraught with bugs and added zero features to the game. Was it worth it?

AI: I can't help you with that.

Right. We'll see. I guess now its time to...

Collect the Dots

That's right - time for a new game.

We are on to the second game of our series - the first was a clone of "Dot Chase", which I'm pretty sure wasn't a real game. Now we're going to clone "Dot Eater", which also sounds like an AI hallucination.

AI: Neither "Dot Chase" nor "Dot Eater" are widely recognized as classic, real-world video games—at least not under those names. They sound like plausible names for simple arcade or educational games, but there’s no famous historical record of them as established titles.

You say that like you weren't the one who came up with the list.

Anyways, time for something completely different. We got plans for this puppy.

AI: You made the Dot move.

Yeah, SO FAR I've made the Dot move. And let's talk about that.

I made the Dot move

Remember that virtual Control() method on Dot? Well, I overrode it on the Target class. To do this I needed a few things:

  1. I needed to get the player's position - hence the GetPlayerPosition() method on the PositionManager class.
  Vector2 GetPlayerPosition() const {
    return player ? player->GetPosition() : Vector2{0, 0};
  }
  1. I needed a way to attract and repel Dots from stuff - hence the Vector2WeightedAttraction() method on the Dot class.
  static Vector2 Vector2WeightedAttraction(Vector2 from, Vector2 to,
                                           float threshold, float weight) {
    Vector2 dir = Vector2Subtract(to, from);
    float dist = Vector2Length(dir);
    if (dist < 0.01f || dist > threshold)
      return {0, 0};
    dir = Vector2Scale(dir, 1.0f / dist); // normalize
    float strength = weight * (threshold - dist) / threshold;
    return Vector2Scale(dir, strength);
  }
  1. Finally I needed to come up with a way to get the Dot to move around. I got some stuff to say about that.

The obvious thing was that the Target must avoid the Player. This was straightforward.

  void Control(float deltaTime, PositionManager &positionManager) override {
    // Strong repulsion from player only
    Vector2 playerPos = positionManager.GetPlayerPosition();
    Vector2 repulsion =
        Vector2WeightedAttraction(position, playerPos, 200.0f, -400.0f);
    ...

I tried several things from here. First I had the Target avoid the walls. Then I tried the corners. Then I tried attracting it to the center. Then the mid point of the furthest point between the Player and the Player itself.

Jeez it was not nice.

  void Control(float deltaTime, PositionManager &positionManager) override {
    // Strong repulsion from player only
    Vector2 playerPos = positionManager.GetPlayerPosition();
    Vector2 repulsion =
        Vector2WeightedAttraction(position, playerPos, 200.0f, -400.0f);

    // Use repulsion as velocity
    Vector2 velocity = repulsion;

    // Limit speed
    float maxSpeed = 160.0f;
    if (Vector2Length(velocity) > maxSpeed) {
      velocity = Vector2Scale(Vector2Normalize(velocity), maxSpeed);
    }

    // Move target
    Vector2 newPos = Vector2Add(position, Vector2Scale(velocity, deltaTime));
    position = positionManager.UpdatePosition(this, newPos, radius);
  }
  }

So this is what I came up with. Kinda feels like air hockey if the puck was magnetically opposed to the paddle.

#include "raylib.h"
#include "raymath.h" // Add this for vector math
#include <cstdlib>
#include <ctime>
#include <functional>
#include <memory>
#include <vector>

#ifdef PLATFORM_WEB
#include <emscripten/emscripten.h>
#endif

// Global constants
const int screenWidth = 800;
const int screenHeight = 600;

// Base Dot class
class Dot {
protected:
  Vector2 position;
  float radius;
  Color color;

public:
  Dot(Vector2 startPos, float radius, Color color)
      : position(startPos), radius(radius), color(color) {}

  virtual void Control(float deltaTime,
                       class PositionManager &positionManager) {
    // Default behavior: no movement
  }

  virtual void HandleCollision(Dot *other) {
    // Default behavior: do nothing
  }

  void Draw() const { DrawCircleV(position, radius, color); }

  Vector2 GetPosition() const { return position; }

  float GetRadius() const { return radius; }

  void SetPosition(Vector2 newPos) { position = newPos; }

protected:
  static Vector2 Vector2WeightedAttraction(Vector2 from, Vector2 to,
                                           float threshold, float weight) {
    Vector2 dir = Vector2Subtract(to, from);
    float dist = Vector2Length(dir);
    if (dist < 0.01f || dist > threshold)
      return {0, 0};
    dir = Vector2Scale(dir, 1.0f / dist); // normalize
    float strength = weight * (threshold - dist) / threshold;
    return Vector2Scale(dir, strength);
  }
};

// PositionManager class
class PositionManager {
private:
  std::vector<Dot *> dots;
  std::function<void()> onScoreIncrement;
  Dot *player = nullptr;
  Dot *target = nullptr;

public:
  void AddDot(Dot *dot) { dots.push_back(dot); }

  void SetPlayerAndTarget(Dot *playerDot, Dot *targetDot) {
    player = playerDot;
    target = targetDot;
  }

  void SetScoreIncrementCallback(const std::function<void()> &callback) {
    onScoreIncrement = callback;
  }

  void Update(float deltaTime) {
    // Check for collisions between all dots
    for (size_t i = 0; i < dots.size(); ++i) {
      for (size_t j = i + 1; j < dots.size(); ++j) {
        Dot *dotA = dots[i];
        Dot *dotB = dots[j];

        if (CheckCollisionCircles(dotA->GetPosition(), dotA->GetRadius(),
                                  dotB->GetPosition(), dotB->GetRadius())) {
          // Notify both dots of the collision
          dotA->HandleCollision(dotB);
          dotB->HandleCollision(dotA);

          // Trigger the score increment callback only if the collision is
          // between the player and target
          if (onScoreIncrement && ((dotA == player && dotB == target) ||
                                   (dotA == target && dotB == player))) {
            onScoreIncrement();
          }
        }
      }
    }

    // Let each dot update its position
    for (Dot *dot : dots) {
      dot->Control(deltaTime, *this);
    }
  }

  bool IsPositionValid(Vector2 newPos, float radius) const {
    for (const Dot *dot : dots) {
      if (CheckCollisionCircles(newPos, radius, dot->GetPosition(),
                                dot->GetRadius())) {
        return false;
      }
    }
    return true;
  }

  Vector2 GetValidPosition(float radius) {
    Vector2 newPos;
    do {
      newPos = {static_cast<float>(rand() % screenWidth),
                static_cast<float>(rand() % screenHeight)};
    } while (!IsPositionValid(newPos, radius));
    return newPos;
  }

  Vector2 UpdatePosition(Dot *dot, Vector2 newPos, float radius) {
    // Enforce screen bounds
    if (newPos.x < radius)
      newPos.x = radius;
    if (newPos.x > screenWidth - radius)
      newPos.x = screenWidth - radius;
    if (newPos.y < radius)
      newPos.y = radius;
    if (newPos.y > screenHeight - radius)
      newPos.y = screenHeight - radius;

    return newPos;
  }

  Vector2 GetPlayerPosition() const {
    return player ? player->GetPosition() : Vector2{0, 0};
  }
};

// Player class (inherits from Dot)
class Player : public Dot {
private:
  float speed;

public:
  Player(Vector2 startPos, float radius, Color color, float speed)
      : Dot(startPos, radius, color), speed(speed) {}

  virtual ~Player() {}

  void Control(float deltaTime, PositionManager &positionManager) override {
    Vector2 newPos = position;

    if (IsKeyDown(KEY_W))
      newPos.y -= speed * deltaTime;
    if (IsKeyDown(KEY_S))
      newPos.y += speed * deltaTime;
    if (IsKeyDown(KEY_A))
      newPos.x -= speed * deltaTime;
    if (IsKeyDown(KEY_D))
      newPos.x += speed * deltaTime;

    // Update position using the PositionManager
    position = positionManager.UpdatePosition(this, newPos, radius);
  }

  void HandleCollision(Dot *other) override {
    // Player-specific collision handling (e.g., no special behavior for now)
  }
};

// Target class (inherits from Dot)
class Target : public Dot {
public:
  Target(Vector2 startPos, float radius, Color color)
      : Dot(startPos, radius, color) {}

  virtual ~Target() {}

  void Control(float deltaTime, PositionManager &positionManager) override {
    // Strong repulsion from player only
    Vector2 playerPos = positionManager.GetPlayerPosition();
    Vector2 repulsion =
        Vector2WeightedAttraction(position, playerPos, 200.0f, -400.0f);

    // Use repulsion as velocity
    Vector2 velocity = repulsion;

    // Limit speed
    float maxSpeed = 160.0f;
    if (Vector2Length(velocity) > maxSpeed) {
      velocity = Vector2Scale(Vector2Normalize(velocity), maxSpeed);
    }

    // Move target
    Vector2 newPos = Vector2Add(position, Vector2Scale(velocity, deltaTime));
    position = positionManager.UpdatePosition(this, newPos, radius);
  }

  void HandleCollision(Dot *other) override {
    // Respawn the target at a new valid position
    position = {static_cast<float>(rand() % screenWidth),
                static_cast<float>(rand() % screenHeight)};
  }
};

// Game class
class Game {
private:
  std::unique_ptr<Player> player;
  std::unique_ptr<Target> target;
  PositionManager positionManager;
  int score;

public:
  Game() : score(0) {
    // Initialize player and target
    player = std::make_unique<Player>(
        Vector2{screenWidth / 2.0f, screenHeight / 2.0f}, 15.0f, BLUE, 200.0f);
    target = std::make_unique<Target>(positionManager.GetValidPosition(10.0f),
                                      10.0f, RED);

    // Register dots with the position manager
    positionManager.AddDot(player.get());
    positionManager.AddDot(target.get());

    // Set player and target in the position manager
    positionManager.SetPlayerAndTarget(player.get(), target.get());

    // Set the score increment callback
    positionManager.SetScoreIncrementCallback([this]() { score++; });
  }

  void Update(float deltaTime) {
    // Delegate all updates to the PositionManager
    positionManager.Update(deltaTime);
  }

  void Render() const {
    // Draw game elements
    BeginDrawing();
    ClearBackground(RAYWHITE);

    DrawText("Catch the moving dot!", 10, 10, 20, DARKGRAY);
    DrawText(TextFormat("Score: %d", score), 10, 40, 20, DARKGRAY);

    player->Draw();
    target->Draw();

    EndDrawing();
  }

  bool IsRunning() const { return !WindowShouldClose(); }
};

Game *gameInstance = nullptr;

void MainLoop() {
  float deltaTime = GetFrameTime();
  gameInstance->Update(deltaTime);
  gameInstance->Render();
}

int main() {
  // Initialization
  InitWindow(screenWidth, screenHeight, "Dot Game - Catch the Dot");
  SetTargetFPS(60);

  // Seed random number generator
  std::srand(std::time(nullptr));

  // Create game object
  Game game;
  gameInstance = &game;

#ifdef PLATFORM_WEB
  emscripten_set_main_loop(MainLoop, 0, 1);
#else
  while (game.IsRunning()) {
    float deltaTime = GetFrameTime();
    game.Update(deltaTime);
    game.Render();
  }
#endif

  CloseWindow();

  return 0;
}
Next: Collect the Dots V2 Previous: Shipping Code and Callback Functions