Building a small C++ Game from scratch
A walkthrough of building Sentinel, a tiny terminal game in C++, using only simple logic and code structure. This game is designed to supplement Game Testing that is covered under my Games ENGG II module.
I teach a module called Games Engineering II. While the name promises games, the content is mostly software testing. That gap is the reason this small game exists, and it is where I want to start, because it explains every decision that follows.
I wanted to introduce Software Testing (Afterall games are essentially piece of code in a certain programming language) literature and practices and align it with hands-on exercises. Now, there’s a very loose definition of Software Testing in industry, especially when it comes to Games. Most of the testing is a flavor of unit-testing which is conducted under focus testing groups.
Naturally, students on a games development specialization stream expect to spend the module working on a game. A testing module, taught honestly, tends to spend its time on plain functions with clear inputs and outputs, because that is what the techniques apply to. Rather than fight that, I built a small game whose insides are exactly those plain functions, and then used the game as the thing we test, week by week. This post is about that game, Sentinel, and about how I would build it from an empty folder if I were starting today.
Disclaimer: This is the most bare-bones clone of Space Invaders with absolutely no Graphics, features, or levels. AND, that is exactly how I intended it to be.
What Sentinel is
Sentinel is a small arcade shooter that runs in a terminal. You move a ship along the bottom of the screen, fire upward, and try to survive waves of enemies falling from the top. The ship is a ^, an enemy is a *, a shot is a |, and the score and lives sit on a line above the field. It has a menu, a playing state, a pause, and a game over.
I was very cautious writing this post, I NEVER used the word SPECTACULAR, quite rightfully so, that would come later. But, Most importantly, it is a complete game in the sense that matters: you can start it, play it, lose, and start again.
Sentinel score 40 lives 3 PLAYING
----------------------------------------
* *
*
|
|
^
----------------------------------------
a/d move space fire p pause q quit
Scope / Game Premise
The scope is narrow on purpose (intentional). One screen, keyboard only, no sound, no sprites, no saved state. The whole thing is a few hundred lines. I set it up this way deliberately, because the game is a teaching example and a starting point, not a product. A student can read all of it in an afternoon, and there is room left over to add to it, which is the point. Everything I could have bolted on, levels, power-ups, a high-score table, a second kind of enemy, is left as something to build rather than something to study (very typical of an academic :-D).
The technology used
It is written in C++ 17 and built with CMake, the same toolchain the module uses in its labs. There are no external libraries at all, and no game engines used. The game draws by printing characters and reads the keyboard directly, so it needs nothing but a compiler to build and run.
That was a choice, not a limitation since using advanced libraries and game engines could complicate the process. The simple linear structure was required so that students can quickly understand the logic and moving elements, rather than spinning up AI models to understand how the game actually performs.
A graphics library would add an install step that can fail on a lab machine for reasons that have nothing to do with the student’s code, and it would bury the part I actually want them looking at. The tests use GoogleTest (that would come way later), which CMake fetches on demand, and version control is plain GIT. The one piece of platform-specific code, reading a key without waiting for Enter, is quarantined in a single small file so the rest of the program stays ordinary, portable C++.
What it is aimed at
The module is a testing module, and its techniques including, branch and path coverage, logic coverage, finite state machines, input space partitioning, mocking, and mutation testing, apply most cleanly to plain, deterministic logic. So Sentinel is built as plain, deterministic logic with a thin shell on top. Its rules live in one place with no screen attached, which means they can be tested directly, and each week of the module takes one of those rules and tests it with that week’s technique.
The scoring becomes a first GoogleTest project. The firing rule becomes a logic-coverage exercise. The spawn guard becomes an input-partitioning exercise. The state machine is tested with mocks. By the end, students have tested a real game one piece at a time, and they are left with a codebase they understand well enough to extend for their own project.
The rest of this post is how I would build that game, from nothing, in the order I would actually write it.
Two decisions before any code
There are two things worth settling before I open an editor. Both are cheap now and expensive to change later.
Start with the smallest thing that runs
I do not set out to build “the game”. To be brutally honest, my game development skills are very limited, rather non-existent. I should rather rename this game to (Sentinel v -1.0 : PUN INTENDED).
With that out of the way (phewwwww !!!) I built the smallest version of it that compiles and does one visible thing, and grow from there. The usual name for this is a walking skeleton and the reason for it is extremely simple. You are never more than a few minutes from a program that works, so when something breaks you already know it was the last thing you changed. For this game the smallest useful target is a ship you can move and a way to lose.
Scoring, waves and pausing are all things I add afterwards. And YES, like I mentioned before, there’s NO sound, or graphics, or levels, or leaderboards etc.
Decide where the rules live
This is the decision that shapes everything else. A game contains two kinds of code that behave very differently. There are the rules, which say how the score works, when you can fire, and what counts as a hit. And there is the input and output, which reads the keyboard and draws the screen. I keep the two apart. The rules go somewhere that knows nothing about the screen. The screen goes somewhere that has no rules of its own.
keyboard -> app (read keys, draw) -> core (the rules) -> screen
There are numerous reasons for it. Some argue that it keeps the code and project structure clean (which it sure does), some argue that it helps with debugging large projects (Well, not so much anymore, since we tickle AI Gods for everything these days. :: Angry Face Emoji here :: -> Gotcha!! Nope, that wasn’t an AI Placeholder, your man could have used AI placeholder, or an actual emoji here. But, NOO!!! he deliberately left those little nuggets there to catch your attention. You’re welcome !!!).
For this module honestly the reason is testing. A rule with no screen attached can be checked by calling it with known values and looking at what it returns, with no window and no keyboard in the way. I would write it this way regardless, because the code most likely to be wrong is the code I most want a test around.
The one rule to hold onto (At least for Sentinel ): rules live in core, and reading keys and drawing the screen live in app. If I catch myself writing an if about game logic inside the drawing code, it is in the wrong file.
Why a terminal, and not a window
Students expect a window, because its not 80s, even Sentinel V -1.0 must have a screen and at least some graphics. I use a terminal, and it is worth saying why, because the reason is the same reason the code is shaped the way it is. The graphics are not what we intend to learn here, nor is the game engine or development techniques. A window means a library, a build that can fail for reasons that have nothing to do with your code, and a great deal of drawing that hides how little logic is actually underneath.
A terminal on the other hand needs nothing but a compiler, and it makes the point plainly: the interesting part of a game is the rules under the surface. Once those rules sit in one place and have tests around them, moving to a window later is a change to one folder and nothing else.
One file or Multiple?
Students ask this too early and too often. My answer is always the same, and that is to begin with one file. A single main.cpp is the right size for a program that prints a line.
A file ONLY earns a split when it starts doing a second job, or when I want a test around one part of it on its own. That is the whole rule.
So the folder tree is not something I design in advance, it grows (as my code logic flows - that rhymed a bit). Each idea that turns into more than a couple of lines, the score, the state machine, the firing rule, gets its own small file. The two kinds of code from the diagram become the two folders.
Here is where it starts:
sentinel/
├─ main.cpp
└─ CMakeLists.txt
And here is where it ends up:
SentinelGame/
├─ core/ # the rules
│ ├─ vec2.hpp
│ ├─ score.*
│ ├─ combat.*
│ ├─ game_state.*
│ └─ world.*
├─ app/ # the shell
│ ├─ main.cpp
│ └─ terminal.hpp
├─ tasks/ # the tests
└─ CMakeLists.txt
A concept usually becomes a pair. A .hpp says what the thing does, and a .cpp says how it does it. Other files include the small header and never see the how. A type with no behaviour, like a point, can stay in a single header with no .cpp at all.
Here’s how I normally design my workflow, iterate, and re-use every single time.
Step 0: Make basic block, Build and Run
The first file is not game code, it is essentially sort of a Hello world to test and cleanse our compile pipeline. It is the smallest program that compiles and runs, plus the build file that produces it. I get this green before anything else, so that from here on I am only ever debugging one new thing at a time, instead of debugging the build and the game together.
TIP: Sometimes it is referred to as Scaffolding . There’s no shame in putting up one, because it is the fundamental building block. BUT, no matter what you do, please replace that “Hello World” to something cooler, perhaps “Make Coding Great Again” - MCGA ??? (didn’t rhyme so well this time)
// main.cpp
#include <cstdio>
int main() {
std::puts("Sentinel: it runs. I can now call it v 1.0 finally!!!");
return 0;
}
# CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(sentinel LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
add_executable(sentinel_app main.cpp)
CMake is the build tool the module already uses. It takes a description of the project and produces whatever your compiler needs. Two commands configure it and build it, and a third runs the result.
cmake -S . -B build # configure into a build/ folder
cmake --build build # compile
./build/sentinel_app # run -> "Sentinel: it runs."
Then it goes under version control and I commit. From here on every step ends with a commit. This is not optional (students, pay attention please). It is what lets me try something, and if it goes wrong, get back to the last working version without having to think about it. AND, it is what I expect from your projects as well.
git init && git add . && git commit -m "Walking skeleton: builds and runs"
Step 1: Name The Nouns
Before any behaviour, I write down the things the game talks about. There are only two of them (at least in our example). Where something sits on the field, and which screen the game is on. Writing these first gives every later file a shared vocabulary, and they are small enough that they are obviously right.
I am not saying this is the right approach OR the only approach. But, I always strike off the mundane, immovable elements first
// core/vec2.hpp
struct Vec2i { int x = 0; int y = 0; }; // a point on the field
// keep a value inside [lo, hi]
int clampToRange(int v, int lo, int hi);
The coordinates are int, not float. I make that choice early and on purpose. Integer positions are exact, so later a test can say the ship is at x == 120 and mean it, with no rounding to argue about. Or, I could purposefully change it to float to see how it rattles my game logic calculations. Either way, a very small unit test case could be wrapped around it.
// core/phase.hpp
enum class Phase { Menu, Playing, Paused, GameOver };
enum class Input { Start, Pause, Resume, Die, Reset };
An enum class gives the compiler a closed set of named values. A stray number where a phase belongs will not compile, which removes a whole category of mistake before it can happen. Both enums sit in their own header so the state machine and the observer can share them without depending on each other.
git commit -am "Core vocabulary: Vec2i, Phase, Input"
Step 2: The First Rule, and its First Test
Now the first piece of real behaviour. I start with the score because it is small, but it has a rule worth protecting: it never goes below zero (unlike Sentinel v -1.0 that literally fell below ZERO , but, nevermind, I have faith in my students; I already envision playing Sentinel v20.x on AR glasses). A rule like that is exactly what a test is for, so this is where testing as I go begins.
// core/score.hpp
class Score {
public:
int value() const;
void add(int points); // a non-positive amount is ignored
void penalty(int points); // subtract, but never below zero
void reset();
private:
int value_ = 0;
};
The header is the promise, and the source keeps it. Each guard is one line.
The idea here is that all logic must be structured and logically separated from its implementation. Its easier to scale, easier to read and understand, and most importantly easier to TEST when things start to break.
// core/score.cpp (excerpt)
void Score::add(int points) {
if (points <= 0) return; // ignore junk input
value_ += points;
}
void Score::penalty(int points) {
if (points <= 0) return;
value_ -= points;
if (value_ < 0) value_ = 0; // the rule: never negative
}
At this stage. I do not need a framework to start. A couple of asserts in a throwaway main are enough to show the rules hold, which is the same order the labs follow before GoogleTest arrives.
#include <cassert>
int main() {
Score s;
s.add(10); s.add(-5); // the -5 must be ignored
assert(s.value() == 10);
s.penalty(50); // must clamp at zero
assert(s.value() == 0);
}
The “never negative” rule is invisible when you play and easy to lose in a later edit. A test written now, while the rule is fresh in my head, costs almost nothing, and it will complain the day someone deletes the clamp by accident.
Your Turn. Add a
bonus()method that doubles the current score, and write theassertthat provesbonus()on a freshScoreleaves it at zero. It is two lines of code and one line of test, and it is the whole habit in miniature.
I literally turned this post into an assignment !!
git commit -am "Score with a tested no-negative rule"
Step 3: The Decision Rules
A game is full of small yes-or-no decisions. Can the ship fire?. Is the round over?. Was that a hit?. I give each one a small function that depends only on what you pass it. A function like that, where the same inputs always give the same answer and nothing else is touched, is called pure, and pure functions are the easiest code there is to test.
// core/combat.cpp (excerpt)
// may the ship fire this frame?
bool canFire(bool hasAmmo, bool cooldownReady, bool overdrive) {
return (hasAmmo && cooldownReady) || overdrive;
}
// how close did the shot pass? 0 miss, 1 graze, 2 direct hit
int classifyShot(int dxAbs, int grazeRadius, int hitRadius) {
if (dxAbs <= hitRadius) return 2;
if (dxAbs <= grazeRadius) return 1;
return 0;
}
Each rule is a single expression, and that is deliberate. A rule I can read in one line is a rule I can be sure about. It is not a coincidence that these same functions are the ones the labs test for branch coverage, logic coverage and MC/DC. Writing them this small is what makes them teachable, readable, and testable (rhymed again - catch my flow!!).
git commit -am "Combat rules: canFire, classifyShot, and the rest"
Step 4: The State Machine
The game is always in exactly one phase or sometimes referred to as State. A menu, playing, paused, or over. Only some moves between them make sense. That is a finite state machine (FSM), and writing it out as one, rather than scattering paused flags around the code, keeps the game honest about where it is.
// core/game_state.cpp (the transition function)
Phase GameState::next(Phase current, Input e) const {
switch (current) {
case Phase::Menu:
if (e == Input::Start) return Phase::Playing;
break;
case Phase::Playing:
if (e == Input::Pause) return Phase::Paused;
if (e == Input::Die) return Phase::GameOver;
break;
// Paused and GameOver follow the same shape
}
return current; // an input that does not apply changes nothing
}
Two things here make it easy to test. First, next() is pure. It works out the resulting phase and changes nothing, so each transition is a one-line test. Second, the machine can tell observers when the phase actually changes. That is the hook a test uses to attach a fake listener and confirm that, for example, starting the game fired exactly one change. Building that hook before I need it is what “design for testability” means once you take away the slogan.
git commit -am "GameState: a pure transition plus an observer hook"
Step 5: The Simulation
Now the parts meet. World holds the player, the bullets, the enemies, the score and the state machine, and it has one method, step(), that moves the whole game on by a single tick. Everything that happens in a frame is a short list, and nearly every item on it is a call into a file I already wrote.
- Move the player left or right, kept on the field with
clampToRange. - Fire a bullet, but only when
canFire(...)allows it. - Advance the bullets up and the enemies down.
- Spawn a new enemy when the timer says it is time.
- Resolve hits.
classifyShot(...)decides each one, and a hit adds to theScore. - Lose a life for any enemy that reaches the bottom.
- End the game when lives reach zero, by handing
Input::Dieto the state machine.
World uses a fixed pseudo-random sequence, so the same inputs always produce the same game. That is what lets me test even the simulation. A test can drive it a tick at a time and know exactly what should come out. Notice that World still reads no keys and prints nothing. It is all rules. It just has no face yet.
git commit -am "World: the deterministic step() that ties it together"
Step 6: Make it Playable
Only now do I write the part you can see, and it is the only code that touches the keyboard or the screen. The loop is four lines. Read what the player wants, move the world on, draw it, and wait a moment before doing it again.
// app/main.cpp (the loop)
while (running) {
InputFrame in = readInput(); // a/d move, space fire, p pause, q quit
world.step(in); // every rule runs in here
draw(world); // print ^ ship, * enemy, | bullet
std::this_thread::sleep_for(std::chrono::milliseconds(70));
}
draw() reads what world exposes and puts a character in a grid. A ^ for the ship at the bottom, a * for each thing falling, and a | for each shot.
Reading a key without waiting for Enter is the one thing plain C++ cannot do the same way on every system, so that single awkward part lives in terminal.hpp and nothing else has to know about it. This is the decision from the start of the post paying off. Because the rules were already in core, the shell stayed small, and if I ever wanted a graphical version, these two files are the only ones I would touch.
Your Turn. Make the ship wrap around the edges of the screen instead of stopping at them. It is a one-line change to the rule that clamps the player’s position, and because that rule lives in
core, you can test the wrap without running the game at all.
git commit -am "Playable: a terminal shell over the core"
Step 7: Enhance the Gameplay
The skeleton walks, so now I make it a game. Each feature is a vertical slice. This is a very fascinating concept that is widely used (Horizontal and Vertical Scaling). We scale Horizontally by building core or enhancing vitals. On the other hand, we scale Vertically, by adding features or layers.
A rule in core with a test, a line or two wiring it into World, and if you can see it, a character in draw(). The spawn guard is a fair example. Its whole job is to say whether a spawn request is allowed.
// core/spawn.cpp (excerpt)
SpawnResult spawnEnemy(int lane, int type, int speed, int waveCount) {
if (lane < 0 || lane > 3) return SpawnResult::BAD_LANE;
if (speed < 1 || speed > 10) return SpawnResult::BAD_SPEED;
if (waveCount >= 12) return SpawnResult::WAVE_FULL;
return SpawnResult::OK;
}
It returns a reason rather than a plain yes or no, which reads better in the game and gives the input-partitioning lab something to work with. World calls it before adding an enemy. Collisions and lives go in the same way, a small rule wired in and checked. The finished game is this one slice, repeated until it plays well.
git commit -am "Waves, collisions, lives"
Where beginners go wrong
Almost every problem I see in a first game project is one of these four, and all four are avoided by the habits/methods/techniques/practices above, rather than by any clever code (And certainly NOT any AI code, which would haunt your grades later).
- Putting rules inside the drawing code, so there is nothing you can test without a screen in front of it.
- One
main.cppthat does everything, until no one can find where a change belongs. - Writing several hundred lines before the first build, then debugging the build and the logic at the same time.
- No commits, so an experiment that goes wrong cannot be undone, and the last good version is gone.
The loop you repeat
Take away the specifics and every step above was the same four beats. This is the loop I run all day. It is small enough that nothing is ever far from working, and plain enough that a broken step shows up at once.
write a slice -> build and run -> test the rule -> commit -> (repeat)
I intentionally keep each pass small. A slice I can build, run and commit in a few minutes is a slice I can debug. A thousand lines written before the first build is a debugging session I have set up for myself. When a file starts doing two jobs, I split it, and that is the tree growing a branch at the moment it should.
Adding a feature later
Every new feature follows the same recipe, and it is worth teaching as a recipe rather than as a set of one-off tricks.
- Write the rule in
coreas a small, pure function, and test it. - Wire it into
World::step, which is usually a line or two. - Show it in
draw()if it is something you can see. - Commit.
As a worked example, take a shield power-up. Step one is the rule and its test.
// core/shield.hpp
// the shield is up while its timer is still running
bool shieldActive(int shieldTimer) { return shieldTimer > 0; }
Step two, in World, count the timer down each tick, and when an enemy reaches the ship, only lose a life when the shield is not up. Step three, draw the ship as # instead of ^ while the shield holds. Step four, commit. Four small moves, and the rule was tested before it ever touched the game.
There are plenty of other slices to try. A difficulty ramp is a rule that shortens the spawn timer as the score climbs. A high score is a rule that keeps the best. A second enemy type moves sideways as it falls. Each one is the same recipe. The game is a starting point on purpose, and the recipe is the thing to learn from it.
Exited to try?
I can only imagine! Well, my enrolled students at SETU Carlow will certainly have the luxury to try this Game first. The rest of you can come back when the winds bring a little more sunshine, and I’ll have the GIT repo link ready.