Difficulty Scaling (Making the Game Fight Back)

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:
Enemy spawn rate
→ controlled bySPAWN_DELAYEnemy movement speed
→ controlled byINVERSE_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.



