Skip to main content

Command Palette

Search for a command to run...

Difficulty Scaling (Making the Game Fight Back)

Updated
6 min read
Difficulty Scaling (Making the Game Fight Back)
D

An engineer with a curious mind and a love for systems that go beyond the surface.I enjoy exploring how things work under the hood — whether it's rendering pixels, routing audio, or handling low-level protocols. My work spans across game development, system programming, and occasionally web tech when the problem is worth solving. I’m driven by the desire to build things that are fast, efficient, and just a bit unconventional. This blog is my space to document experiments, challenges, and the weirdly satisfying bugs that teach you more than tutorials ever could.

Outside of tech, I enjoy observing how people use technology, and occasionally breaking things just to fix them better.

Right now, the game is playable.
But it’s also… predictable.

Enemies:

  • spawn at a fixed rate

  • fall at a fixed speed

  • never change behavior

That’s fine for learning, but games become interesting when they adapt.

In this part, we’ll make the game gradually harder over time.

What Is Difficulty Scaling?

Difficulty scaling means the game becomes harder as the player:

  • survives longer

  • scores more points

  • gets better at the game

The important rule is:

Difficulty should increase slowly and predictably, not suddenly.

We’ll do this using numbers we already have, not new systems

What We Can Scale (Using Existing Code)

Looking at our current game, we already have perfect knobs to turn:

  1. Enemy spawn rate
    → controlled by SPAWN_DELAY

  2. Enemy movement speed
    → controlled by INVERSE_ENEMY_SPEED

We don’t need anything else.

Scaling Based on Score (Simplest & Clear)

We already track score:

int score = 0;

Let’s use it.

A simple rule:

  • every few points, enemies get faster

  • every few points, enemies spawn more often

Step 1: Dynamic Enemy Speed

Right now we have:

const int INVERSE_ENEMY_SPEED = 10;

Instead, make it variable:

int inverseEnemySpeed = 10;

Now update it based on score:

inverseEnemySpeed = 10 - (score / 5);
// Tweak these yourself
if (inverseEnemySpeed < 3)
  inverseEnemySpeed = 3;

What this does

  • every 5 points → enemies move faster

  • speed has a lower cap so it doesn’t become impossible

Step 2: Dynamic Spawn Rate

Same idea for spawning.

Replace the constant:

const int SPAWN_DELAY = 60;

With:

int spawnDelay = 60;

Update it like this:

// Tweak yourself
spawnDelay = 60 - (score * 2);
if (spawnDelay < 15)
  spawnDelay = 15;

Now:

  • enemies spawn faster as score increases

  • the game ramps up naturally

Where This Logic Belongs

Put difficulty updates inside the PLAYING state, after scoring logic:

// Difficulty scaling
inverseEnemySpeed = 10 - (score / 5);
if (inverseEnemySpeed < 3)
  inverseEnemySpeed = 3;

spawnDelay = 60 - (score * 2);
if (spawnDelay < 15)
  spawnDelay = 15;

Full Code

#include <ncurses.h>
#include <chrono>
#include <thread>
#include <vector>
#include <cstdlib>

// --------------------------------------------------
// Data structures
// --------------------------------------------------

struct Bullet {
  int x;
  int y;
};

struct Enemy {
  int x;
  int y;
};

// --------------------------------------------------
// Game states
// --------------------------------------------------

enum GameState {
  STATE_HOME,
  STATE_PLAYING,
  STATE_GAME_OVER
};

// --------------------------------------------------
// Constants
// --------------------------------------------------

const int FPS = 60;
const int FRAME_TIME = 1000 / FPS;

// --------------------------------------------------
// Helper functions
// --------------------------------------------------

void setupNcurses() {
  initscr();
  cbreak();
  noecho();
  nodelay(stdscr, TRUE);
  keypad(stdscr, TRUE);
  curs_set(0);
}

void resetGame(
  int &playerX,
  int maxX,
  std::vector<Bullet> &bullets,
  std::vector<Enemy> &enemies,
  int &score,
  int &spawnCounter,
  int &frameCount
) {
  playerX = maxX / 2;
  bullets.clear();
  enemies.clear();
  score = 0;
  spawnCounter = 0;
  frameCount = 0;
}

// --------------------------------------------------
// Main
// --------------------------------------------------

int main() {
  setupNcurses();

  int maxY, maxX;
  getmaxyx(stdscr, maxY, maxX);

  // Player
  int playerX = maxX / 2;
  int playerY = maxY - 2;

  // Game objects
  std::vector<Bullet> bullets;
  std::vector<Enemy> enemies;

  // Game state
  GameState state = STATE_HOME;
  bool running = true;

  // Game variables
  int score = 0;
  int spawnCounter = 0;
  int frameCount = 0;

  // Difficulty variables (Part 9)
  int spawnDelay = 60;         // lower = more enemies
  int inverseEnemySpeed = 10;  // lower = faster enemies

  // --------------------------------------------------
  // Main loop
  // --------------------------------------------------

  while (running) {
    auto frameStart = std::chrono::high_resolution_clock::now();
    int ch = getch();

    clear();

    // ==================================================
    // HOME STATE
    // ==================================================
    if (state == STATE_HOME) {
      mvprintw(maxY / 2 - 1, maxX / 2 - 8, "TERMINAL INVADERS");
      mvprintw(maxY / 2 + 1, maxX / 2 - 10, "Press ENTER to start");
      mvprintw(maxY / 2 + 2, maxX / 2 - 7, "Press Q to quit");

      if (ch == '\n') {
        resetGame(playerX, maxX, bullets, enemies,
                  score, spawnCounter, frameCount);
        spawnDelay = 60;
        inverseEnemySpeed = 10;
        state = STATE_PLAYING;
      }

      if (ch == 'q' || ch == 'Q') {
        running = false;
      }
    }

    // ==================================================
    // PLAYING STATE
    // ==================================================
    else if (state == STATE_PLAYING) {
      // -------- Input --------
      switch (ch) {
        case 'q':
        case 'Q':
          running = false;
          break;

        case KEY_LEFT:
          playerX--;
          break;

        case KEY_RIGHT:
          playerX++;
          break;

        case ' ':
          bullets.push_back({ playerX, playerY - 1 });
          break;

        default:
          break;
      }

      // -------- Bounds --------
      if (playerX < 0) playerX = 0;
      if (playerX >= maxX) playerX = maxX - 1;

      // -------- Difficulty scaling (Part 9) --------
      inverseEnemySpeed = 10 - (score / 5);
      if (inverseEnemySpeed < 3)
        inverseEnemySpeed = 3;

      spawnDelay = 60 - (score * 2);
      if (spawnDelay < 15)
        spawnDelay = 15;

      // -------- Spawn enemies --------
      spawnCounter++;
      if (spawnCounter >= spawnDelay) {
        enemies.push_back({ rand() % maxX, 1 });
        spawnCounter = 0;
      }

      // -------- Update bullets --------
      for (auto &b : bullets) {
        b.y--;
      }

      for (size_t i = 0; i < bullets.size(); ) {
        if (bullets[i].y < 0) {
          bullets.erase(bullets.begin() + i);
        } else {
          i++;
        }
      }

      // -------- Update enemies --------
      frameCount++;
      if (frameCount % inverseEnemySpeed == 0) {
        for (auto &e : enemies) {
          e.y++;
        }
      }

      // -------- Bullet–Enemy collision --------
      for (size_t i = 0; i < bullets.size(); ) {
        bool hit = false;

        for (size_t j = 0; j < enemies.size(); ) {
          if (bullets[i].x == enemies[j].x &&
              bullets[i].y == enemies[j].y) {
            bullets.erase(bullets.begin() + i);
            enemies.erase(enemies.begin() + j);
            score++;
            hit = true;
            break;
          } else {
            j++;
          }
        }

        if (!hit) i++;
      }

      // -------- Game over condition --------
      for (const auto &e : enemies) {
        if (e.y >= playerY) {
          state = STATE_GAME_OVER;
          break;
        }
      }

      // -------- Render --------
      mvaddch(playerY, playerX, 'A');

      for (const auto &b : bullets) {
        mvaddch(b.y, b.x, '|');
      }

      for (const auto &e : enemies) {
        mvaddch(e.y, e.x, 'V');
      }

      mvprintw(0, 0, "Score: %d", score);
      mvprintw(1, 0, "Enemy speed: %d | Spawn delay: %d",
               inverseEnemySpeed, spawnDelay);
      mvprintw(2, 0, "LEFT/RIGHT move | SPACE shoot | Q quit");
    }

    // ==================================================
    // GAME OVER STATE
    // ==================================================
    else if (state == STATE_GAME_OVER) {
      mvprintw(maxY / 2,     maxX / 2 - 5, "GAME OVER");
      mvprintw(maxY / 2 + 1, maxX / 2 - 6, "Score: %d", score);
      mvprintw(maxY / 2 + 3, maxX / 2 - 9, "Press R to restart");
      mvprintw(maxY / 2 + 4, maxX / 2 - 8, "Press Q to quit");

      if (ch == 'r' || ch == 'R') {
        resetGame(playerX, maxX, bullets, enemies,
                  score, spawnCounter, frameCount);
        spawnDelay = 60;
        inverseEnemySpeed = 10;
        state = STATE_PLAYING;
      }

      if (ch == 'q' || ch == 'Q') {
        running = false;
      }
    }

    refresh();

    // -------- FPS cap --------
    auto frameEnd = std::chrono::high_resolution_clock::now();
    auto elapsed =
      std::chrono::duration_cast<std::chrono::milliseconds>(
        frameEnd - frameStart).count();

    if (elapsed < FRAME_TIME) {
      std::this_thread::sleep_for(
        std::chrono::milliseconds(FRAME_TIME - elapsed));
    }
  }

  endwin();
  return 0;
}

What We’ve Achieved

At this point, the game:

  • starts easy

  • becomes harder

  • rewards survival

  • punishes mistakes

What’s Next?

At this point, the game is complete.

In the final part of this series, we’ll step back from the code and look at what we actually learned along the way, the ideas that mattered, and how they apply beyond this project.

We’ll also talk about where you can go next with this knowledge and how to build on it moving forward.