Car Race Game in C++: Source Code and a Fix for Unwinnable Traffic

A three-lane terminal racing game in C++17 with full source, and why random traffic made 994 of 1,000 races impossible to finish.

Top-down three-lane road where a green car drives through the gap left by a missing car in a diagonal row of blue cars

In 994 of 1,000 races, random traffic made the finish line unreachable. Not hard to reach: unreachable, for a driver who could see the whole road in advance and never made a mistake. The cause was the most common way small racing games place obstacles, one random lane at a time, and the fix is one check in the traffic generator.

This guide gives you a complete three-lane car race game in modern C++ that runs in a terminal on Windows, Linux and macOS. It is built from Car, Obstacle and Timer classes, and then shows how to make random traffic fair, how to keep a countdown honest and how to test a game without playing it. Every program was compiled for this article on Ubuntu 24.04 with GCC 13.3 and Clang 18.1.3 under -std=c++17 and -std=c++20, with -Wall -Wextra -pedantic -Werror and no warnings, and the tests run clean under AddressSanitizer and UndefinedBehaviorSanitizer. The game was played in a pseudo-terminal by a script that presses keys and quits; the Windows and macOS builds were not run on the test machine. Every output block is captured verbatim, and the code is in a GitHub repository whose workflow runs these tests, plus MSVC and macOS builds, on every commit.

What Is a Car Race Game?

A car race game is a driving game in which the player steers a car along a road that scrolls toward them, avoiding other vehicles and obstacles while racing to reach a finish line or survive as long as possible. A crash ends the race, and most versions add a time limit, a score or a speed boost.

The top-down lane racer is one of the classic first games to write, because the rules fit in a few dozen lines: the road is a grid, the player moves between lanes, and everything else moves down the screen one row at a time. The version on this page has three lanes, slower traffic to dodge, a nitro boost and a countdown. It replaces a 2008 version written for Turbo C++ and the DOS-era graphics.h library, which current compilers do not provide.

How to Play

RuleThis version
Road3 lanes, 18 rows on screen, 600 rows from start to finish
TrafficSlower cars appear at the top and move down one row per tick; touching one ends the race
SteeringOne lane per key press: Left/Right arrows or A/D
NitroUp or W toggles it; it halves the tick from 70 ms to 35 ms and lasts 200 ticks
Clock39 seconds; without nitro the race takes 42 seconds, so you have to use it
Levelseasy, normal and hard set how often cars appear: 10%, 16% or 22% of ticks
Other keysP or Space pauses, Q quits

Here is the road 408 ticks into a race at normal traffic, driven by the built-in autopilot. The player is the ^^ car at the bottom, and the |XX| cars are traffic:

Normal traffic, seed 1, after 408 ticks:
+--------------------+
|      : .--. :      |
| .--. : |XX| :      |
| |XX|   '--'        |
| '--'               |
| .--. :      :      |
| |XX| :      :      |
| '--'               |
|                    |
|      :      :      |
|      :      : .--. |
|               |XX| |
|               '--' |
|      :      :      |
|      :      : .--. |
|         ^^    |XX| |
|        [##]   '--' |
| .--. : [##] :      |
| |XX| :      : .--. |
+--------------------+

How the Code Is Organized

FileJobPlatform code?
race.hpp, game.cppCar, Obstacle, Timer and Game: steering, nitro, collisions and the clockNo
road.cppRoad: where traffic appears, decided before the race starts, and the route plannerNo
screen.hpp, screen.cppTurns two text frames into the ANSI escape sequences that change one into the otherNo
terminal.hpp, terminal.cppRaw keyboard input and output for Windows consoles and POSIX terminalsYes
main.cppThe game loopNo

The rules never read a key or print a character. Game::step() takes one input, advances the race by one tick and returns what happened, so a test can drive hundreds of races in a fraction of a second without a terminal. The same split runs through the brick breaker game in C and C++ on this site, which shares this game’s screen and terminal code.

The Source Code

race.hpp

// race.hpp - rules for a terminal car race game. No input or output here.
#ifndef RACE_RACE_HPP
#define RACE_RACE_HPP

#include <chrono>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>

namespace race {

inline constexpr int lanes = 3;
inline constexpr int lane_w = 6;                       // columns per lane
inline constexpr int road_w = lanes * lane_w + (lanes - 1);   // 20, with dividers
inline constexpr int view_h = 18;                      // road rows on screen
inline constexpr int car_h = 3, car_w = 4;             // every vehicle is 3 x 4
inline constexpr int car_top = 14;                     // player's top row
inline constexpr int track_len = 600;                  // rows from start to finish
inline constexpr int quiet_ticks = 20;                 // no new traffic near the end
inline constexpr int nitro_ticks = 200;                // boosted ticks in the tank

inline constexpr std::chrono::milliseconds normal_tick{70};
inline constexpr std::chrono::milliseconds boost_tick{35};
inline constexpr std::chrono::milliseconds time_limit{39000};

// A car spawned at tick s overlaps the player's rows on ticks s+first_hit
// through s+last_hit (both inclusive).
inline constexpr int first_hit = car_top + 1;                    // 15
inline constexpr int last_hit = car_top + car_h - 1 + car_h;     // 19

enum class Input { none, left, right, nitro };

namespace event {
inline constexpr unsigned crash = 1, finish = 2, time_up = 4;
}

// The player's car. It changes lane one lane at a time and never leaves the road.
class Car {
public:
    explicit Car(int lane) : lane_(lane) {}
    int lane() const noexcept { return lane_; }
    bool steer(int direction);               // -1 or +1; false if at the edge
    static const char* const sprite[car_h];

private:
    int lane_;
};

// A slower car in the player's way. It moves down one row per tick.
class Obstacle {
public:
    Obstacle(int lane, int y) : lane_(lane), y_(y) {}
    int lane() const noexcept { return lane_; }
    int y() const noexcept { return y_; }    // top row; negative above the screen
    void advance() noexcept { ++y_; }
    bool overlaps_rows(int top, int bottom) const noexcept
    {
        return y_ <= bottom && y_ + car_h - 1 >= top;
    }
    bool off_screen() const noexcept { return y_ >= view_h; }
    static const char* const sprite[car_h];

private:
    int lane_;
    int y_;
};

// Counts down game time, not wall-clock time: it only runs while the game
// runs, so pausing stops it and a test can replay a race exactly.
class Timer {
public:
    explicit Timer(std::chrono::milliseconds limit) : left_(limit) {}
    void tick(std::chrono::milliseconds dt) noexcept
    {
        left_ = dt >= left_ ? std::chrono::milliseconds{0} : left_ - dt;
    }
    std::chrono::milliseconds left() const noexcept { return left_; }
    bool expired() const noexcept { return left_.count() == 0; }

private:
    std::chrono::milliseconds left_;
};

enum class Traffic { naive, fair };

// Where traffic appears: a lane bitmask for every tick of the race, fixed by
// the seed before the race starts. The fair generator drops any car that
// would leave the player no way through.
class Road {
public:
    Road(std::uint32_t seed, int density_permille, Traffic mode);
    explicit Road(std::vector<unsigned char> spawns);   // for tests: index = tick

    unsigned spawns_at(int tick) const;            // bit l set: a car enters lane l
    bool blocked(int tick, int lane) const;        // a car overlaps the player's rows
    int cars_drawn() const noexcept { return drawn_; }
    int cars_dropped() const noexcept { return dropped_; }

private:
    std::vector<unsigned char> spawns_;            // index = tick, 1..track_len
    int drawn_ = 0, dropped_ = 0;
};

// Lanes the player can be in after `tick` without crashing, given the lanes
// reachable after the previous tick. The rules are the same as Game::step().
unsigned reachable_after(const Road& road, int tick, unsigned before);

// One crash-free lane for every tick 0..track_len, or empty if none exists.
std::vector<int> plan_route(const Road& road, int start_lane);

enum class Level { easy, normal, hard };
int density_of(Level level);                       // spawn chance per tick, in 1/1000

class Game {
public:
    Game(std::uint32_t seed, Level level, Traffic mode = Traffic::fair);
    explicit Game(Road road) : road_(std::move(road)) {}

    unsigned step(Input in);                       // one tick; returns event flags
    std::string render() const;                    // the road, one line per row

    const Road& road() const noexcept { return road_; }
    int lane() const noexcept { return car_.lane(); }
    int distance() const noexcept { return distance_; }
    int nitro() const noexcept { return nitro_; }
    bool boosting() const noexcept { return boost_; }
    bool over() const noexcept { return over_; }
    std::chrono::milliseconds time_left() const noexcept { return timer_.left(); }
    std::chrono::milliseconds last_tick() const noexcept { return last_tick_; }
    const std::vector<Obstacle>& traffic() const noexcept { return traffic_; }

private:
    bool hits_player(int lane) const;

    Road road_;
    Car car_{1};
    Timer timer_{time_limit};
    std::vector<Obstacle> traffic_;
    int distance_ = 0;
    int nitro_ = nitro_ticks;
    bool boost_ = false;
    bool over_ = false;
    std::chrono::milliseconds last_tick_ = normal_tick;
};

}  // namespace race

#endif

game.cpp

// game.cpp - the race rules: steering, nitro, traffic, collisions, the clock.
#include "race.hpp"

#include <algorithm>

namespace race {

const char* const Car::sprite[car_h] = {" ^^ ", "[##]", "[##]"};
const char* const Obstacle::sprite[car_h] = {".--.", "|XX|", "'--'"};

bool Car::steer(int direction)
{
    const int to = lane_ + direction;
    if (to < 0 || to >= lanes)
        return false;
    lane_ = to;
    return true;
}

Game::Game(std::uint32_t seed, Level level, Traffic mode)
    : road_(seed, density_of(level), mode)
{
}

bool Game::hits_player(int lane) const
{
    return std::any_of(traffic_.begin(), traffic_.end(), [lane](const Obstacle& o) {
        return o.lane() == lane && o.overlaps_rows(car_top, car_top + car_h - 1);
    });
}

unsigned Game::step(Input in)
{
    if (over_)
        return 0;
    unsigned events = 0;

    if (in == Input::nitro)
        boost_ = !boost_ && nitro_ > 0;
    else if (in == Input::left || in == Input::right) {
        // Steering into a lane where a car is level with you is a side impact.
        if (car_.steer(in == Input::left ? -1 : 1) && hits_player(car_.lane()))
            events |= event::crash;
    }

    last_tick_ = boost_ ? boost_tick : normal_tick;
    if (boost_ && --nitro_ == 0)
        boost_ = false;
    timer_.tick(last_tick_);

    for (Obstacle& o : traffic_)
        o.advance();
    traffic_.erase(std::remove_if(traffic_.begin(), traffic_.end(),
                                  [](const Obstacle& o) { return o.off_screen(); }),
                   traffic_.end());
    ++distance_;
    const unsigned spawns = road_.spawns_at(distance_);
    for (int l = 0; l < lanes; ++l)
        if ((spawns >> l) & 1u)
            traffic_.emplace_back(l, -car_h);      // just above the screen

    if (hits_player(car_.lane()))
        events |= event::crash;
    if (events & event::crash)
        over_ = true;
    else if (distance_ >= track_len)
        events |= event::finish, over_ = true;
    else if (timer_.expired())
        events |= event::time_up, over_ = true;
    return events;
}

std::string Game::render() const
{
    const std::string border = '+' + std::string(road_w, '-') + "+\n";
    std::vector<std::string> rows(view_h, std::string(road_w, ' '));

    for (int y = 0; y < view_h; ++y) {
        // Dashed lane dividers that scroll with the road.
        const bool dash = (y - distance_ % 4 + 4) % 4 < 2;
        for (int l = 1; l < lanes; ++l)
            rows[y][l * (lane_w + 1) - 1] = dash ? ':' : ' ';
    }
    const int finish_row = car_top + distance_ - track_len;   // reaches the car at the end
    if (finish_row >= 0 && finish_row < view_h)
        for (int x = 0; x < road_w; ++x)
            rows[finish_row][x] = x % 2 ? ' ' : '#';

    auto draw = [&rows](int lane, int top, const char* const* sprite) {
        const int left = lane * (lane_w + 1) + 1;
        for (int r = 0; r < car_h; ++r)
            if (top + r >= 0 && top + r < view_h)
                rows[top + r].replace(left, car_w, sprite[r]);
    };
    for (const Obstacle& o : traffic_)
        draw(o.lane(), o.y(), Obstacle::sprite);
    draw(car_.lane(), car_top, Car::sprite);

    std::string frame = border;
    for (const std::string& row : rows)
        frame += '|' + row + "|\n";
    return frame + border;
}

}  // namespace race

road.cpp

// road.cpp - traffic generation and the route planner.
#include "race.hpp"

#include <random>
#include <utility>

namespace race {

namespace {
bool in_lane_mask(unsigned mask, int lane) { return (mask >> lane) & 1u; }
}  // namespace

int density_of(Level level)
{
    switch (level) {
    case Level::easy:   return 100;
    case Level::normal: return 160;
    case Level::hard:   return 220;
    }
    return 160;
}

Road::Road(std::uint32_t seed, int density_permille, Traffic mode)
    : spawns_(track_len + 1, 0)
{
    // std::mt19937's output is fixed by the standard; the distributions in
    // <random> are not, so the mapping to lanes and chances is done by hand.
    std::mt19937 rng(seed);
    std::vector<unsigned> reach(track_len + 1, 0);
    reach[0] = 1u << 1;                            // the player starts in lane 1
    int known = 0;                                 // reach[] is final up to here

    for (int t = 1; t <= track_len; ++t) {
        const bool spawn = static_cast<int>(rng() % 1000) < density_permille;
        const int lane = static_cast<int>(rng() % lanes);
        if (!spawn || t > track_len - quiet_ticks)
            continue;
        ++drawn_;
        spawns_[t] = static_cast<unsigned char>(1u << lane);
        if (mode == Traffic::naive)
            continue;

        // A car spawned at t first reaches the player at t + first_hit, and
        // nothing spawned later can affect earlier ticks. So the reachable
        // lanes up to t + first_hit are now final: check that one is left.
        const int horizon = t + first_hit;
        if (horizon > track_len)
            continue;
        for (int k = known + 1; k < horizon; ++k)
            reach[k] = reachable_after(*this, k, reach[k - 1]);
        known = horizon - 1;
        if (reachable_after(*this, horizon, reach[horizon - 1]) == 0) {
            spawns_[t] = 0;                        // this car would trap the player
            ++dropped_;
        }
    }
}

Road::Road(std::vector<unsigned char> spawns) : spawns_(std::move(spawns))
{
    spawns_.resize(track_len + 1, 0);
    spawns_[0] = 0;
}

unsigned Road::spawns_at(int tick) const
{
    return tick >= 1 && tick <= track_len ? spawns_[tick] : 0u;
}

bool Road::blocked(int tick, int lane) const
{
    for (int s = tick - last_hit; s <= tick - first_hit; ++s)
        if (in_lane_mask(spawns_at(s), lane))
            return true;
    return false;
}

unsigned reachable_after(const Road& road, int tick, unsigned before)
{
    unsigned after = 0;
    for (int to = 0; to < lanes; ++to) {
        if (road.blocked(tick, to))
            continue;                              // hit from the front
        for (int from = to - 1; from <= to + 1; ++from) {
            if (from < 0 || from >= lanes || !in_lane_mask(before, from))
                continue;
            if (from != to && road.blocked(tick - 1, to))
                continue;                          // steered into the side of a car
            after |= 1u << to;
        }
    }
    return after;
}

std::vector<int> plan_route(const Road& road, int start_lane)
{
    std::vector<unsigned> reach(track_len + 1, 0);
    reach[0] = 1u << start_lane;
    for (int t = 1; t <= track_len; ++t) {
        reach[t] = reachable_after(road, t, reach[t - 1]);
        if (reach[t] == 0)
            return {};
    }
    // Walk back from the finish, choosing at each tick a lane the next one
    // can be reached from, and preferring not to change lane.
    std::vector<int> route(track_len + 1, 0);
    for (int l = 0; l < lanes; ++l)
        if (in_lane_mask(reach[track_len], l)) {
            route[track_len] = l;
            break;
        }
    for (int t = track_len; t > 0; --t) {
        const int to = route[t];
        for (int from : {to, to - 1, to + 1}) {
            if (from >= 0 && from < lanes && in_lane_mask(reach[t - 1], from) &&
                in_lane_mask(reachable_after(road, t, 1u << from), to)) {
                route[t - 1] = from;
                break;
            }
        }
    }
    return route;
}

}  // namespace race

main.cpp

// main.cpp - the game loop: read keys, advance one tick, draw what changed,
// and keep ticks on a deadline with std::chrono.
// Usage: race [easy|normal|hard]
#include "race.hpp"
#include "screen.hpp"
#include "terminal.hpp"

#include <chrono>
#include <cstdio>
#include <exception>
#include <iostream>
#include <random>
#include <string>
#include <thread>

namespace {

std::string status(const race::Game& g, const char* msg)
{
    const long long tenths = g.time_left().count() / 100;
    std::string tank(10, '-');
    for (int i = 0; i < g.nitro() * 10 / race::nitro_ticks; ++i)
        tank[static_cast<std::size_t>(i)] = '#';
    char line[96];
    std::snprintf(line, sizeof line, "Dist %3d/%d  Time %2lld.%llds\nNitro [%s] %-3s  %-13s\n",
                  g.distance(), race::track_len, tenths / 10, tenths % 10, tank.c_str(),
                  g.boosting() ? "ON" : "", msg);
    return line;
}

int play(race::Level level)
{
    using clock = std::chrono::steady_clock;
    race::Terminal term;                      // restored when play() returns or throws
    race::Game g(std::random_device{}(), level);
    const char* msg = "Arrows/WAD, P, Q";
    std::string prev;
    bool paused = false, quit = false;
    auto next = clock::now();

    while (!quit) {
        auto in = race::Input::none;
        for (race::Key k; !quit && (k = term.key()) != race::Key::none;) {
            if (k == race::Key::quit) quit = true;
            else if (k == race::Key::pause) paused = !paused;
            else if (k == race::Key::nitro) in = race::Input::nitro;
            else in = (k == race::Key::left) ? race::Input::left : race::Input::right;
        }
        if (!paused && !quit) {
            const unsigned ev = g.step(in);
            if (ev & race::event::crash)        msg = "Crashed!";
            else if (ev & race::event::finish)  msg = "Finished!";
            else if (ev & race::event::time_up) msg = "Out of time";
        }
        std::string cur = g.render() + status(g, paused ? "Paused" : msg);
        term.write(prev.empty() ? race::full_redraw(cur) : race::diff_redraw(prev, cur));
        prev = std::move(cur);
        if (g.over())
            break;

        // Sleep until the next tick's start time; nitro makes ticks shorter.
        next += g.last_tick();
        const auto now = clock::now();
        if (next > now)
            std::this_thread::sleep_until(next);
        else if (now - next > std::chrono::milliseconds(250))
            next = now;                       // after a stall, do not race to catch up
    }
    return g.distance();
}

}  // namespace

int main(int argc, char** argv)
{
    auto level = race::Level::normal;
    if (argc > 1) {
        const std::string arg = argv[1];
        if (arg == "easy") level = race::Level::easy;
        else if (arg == "hard") level = race::Level::hard;
        else if (arg != "normal") {
            std::cerr << "usage: " << argv[0] << " [easy|normal|hard]\n";
            return 1;
        }
    }
    try {
        const int distance = play(level);
        std::cout << "Distance: " << distance << " of " << race::track_len << '\n';
    } catch (const std::exception& e) {
        std::cerr << argv[0] << ": " << e.what() << '\n';
        return 1;
    }
}

terminal.hpp and screen.hpp

terminal.cpp puts the terminal into raw mode and reads arrow keys, and screen.cpp redraws only the characters that changed between two frames. Both are in the repository and are the same as in the brick breaker game, with the Up arrow and W added for nitro. Their interfaces are all main.cpp uses:

// terminal.hpp - raw keyboard input and output for Windows consoles and POSIX
// terminals. The destructor restores the terminal, however the game ends.
#ifndef RACE_TERMINAL_HPP
#define RACE_TERMINAL_HPP

#include <string_view>

namespace race {

enum class Key { none, left, right, nitro, pause, quit };

class Terminal {
public:
    Terminal();                     // throws std::runtime_error if not a terminal
    ~Terminal();
    Terminal(const Terminal&) = delete;
    Terminal& operator=(const Terminal&) = delete;

    Key key();                      // next pending key, or Key::none; never blocks
    void write(std::string_view s);
};

}  // namespace race

#endif
// screen.hpp - turn a new text frame into the terminal output that draws it.
#ifndef RACE_SCREEN_HPP
#define RACE_SCREEN_HPP

#include <string>
#include <string_view>

namespace race {

// Clears the screen and draws the frame in full.
std::string full_redraw(std::string_view frame);

// Only the characters that differ between two frames with the same line
// lengths, each run preceded by an ANSI cursor-position sequence.
std::string diff_redraw(std::string_view prev, std::string_view cur);

}  // namespace race

#endif

Build and Play

With CMake on any platform:

cmake -S . -B build
cmake --build build
./build/race normal

Or with one command on Linux or macOS:

g++ -std=c++17 -Wall -Wextra -pedantic -Iinclude src/main.cpp src/game.cpp src/road.cpp src/screen.cpp src/terminal.cpp -o race

The game needs a real console: Windows Terminal or the console host on Windows 10 or later, or any terminal emulator on Linux and macOS. Started from an IDE’s output pane, or with its input redirected, it stops with a message instead:

./race: needs an interactive terminal

Why Random Traffic Makes Races Unwinnable

The obvious way to place traffic is the one most small racing games use: on each tick, roll a random number, and if it comes up, put a car in a random lane. Road does exactly that when it is built with Traffic::naive. To find out whether such a road can be finished at all, plan_route() works through the race tick by tick, tracking every lane the player could be in without having crashed. If that set ever becomes empty, no sequence of key presses gets through, however good the driver.

road_stats builds the same 1,000 seeded roads both ways at four traffic levels and asks the planner about each one:

Spawn chance  Cars/race  Naive: no route  Fair: no route  Fair: cars dropped
      6.0%       34.7         275 of 1000          0 of 1000            1.0%
     10.0%       58.1         756 of 1000          0 of 1000            2.6%
     16.0%       93.3         994 of 1000          0 of 1000            5.6%
     22.0%      127.9        1000 of 1000          0 of 1000            8.9%

At normal traffic, 994 of 1,000 naive roads cannot be finished. Even at 6%, with about 35 cars in a race, more than a quarter cannot. The fair column is zero at every level. The output then shows the first trap on the naive road for seed 1:

Seed 1, naive: no lane is safe at tick 44. Cars that block it:
  lane 0, spawned at tick 27, level with the player on ticks 42-46
  lane 1, spawned at tick 28, level with the player on ticks 43-47
  lane 2, spawned at tick 29, level with the player on ticks 44-48
Three cars, one tick apart: a wall nobody can pass Seed 1, normal traffic. Rows are ticks; a lane is safe only if the player can reach it. Naive traffic Lane 0 Lane 1 Lane 2 40 41 42 43 44 45 46 47 48 Fair traffic, same seed Lane 0 Lane 1 Lane 2 40 41 42 43 44 45 46 47 48 Tick 44: no lane is safe Lane 2 stays clear to the end car level with the player lane the player can reach dropped car
The first trap on the naive road for seed 1: three cars entering lanes 0, 1 and 2 one tick apart leave no safe lane at tick 44. From the same random numbers, the fair generator drops the third car.

Three cars, one per lane, entered one tick apart. Each blocks its lane for five ticks, and the player can change only one lane per tick, so the blocked lanes form a staircase that closes faster than the player can cross it. By tick 44 there is nowhere to be. Nothing about any single car was unusual; the trap comes from three ordinary rolls in a row.

The Fair Generator

The fix uses a fact about timing. A car that enters at tick t cannot reach the player before tick t + 15, and cars that enter later reach the player later still. So once the generator has decided tick t, the set of safe lanes up to tick t + 15 is final. Road‘s constructor in fair mode checks that set after each new car, and if it is empty, it drops the car.

Dropping a car always restores a safe lane. With no new car arriving, every obstacle level with the player on tick t + 15 was already level with them on the tick before, so a player who was safe on the earlier tick is still safe by staying in the same lane. That is why the fair column above is zero, and the tests confirm it by driving the autopilot to the finish on 600 fair roads.

Fairness costs very little traffic. At normal density the generator drops 5.6% of the cars it draws, and both generators use the same random numbers, so the fair road is the naive road minus those cars. On the seed 1 road above, the dropped car is the third step of the staircase.

Fair does not mean easy. The planner knows the whole road in advance; a player sees 14 rows ahead, about one second at normal speed and half that with nitro. The guarantee is that every crash is the driver’s fault.

Designing the Classes

The 2008 version of this game was presented as an example of object-oriented design with Car, Obstacle and Timer classes. Those three names survive here, and each class owns one idea:

  • Car is the player’s car. Its only state is a lane, and steer() refuses to leave the road. Everything about collisions lives elsewhere, because a collision involves two objects and belongs to neither.
  • Obstacle is a car in the way. It knows its lane and its row, moves down one row per tick and can say whether it overlaps a range of rows.
  • Timer counts down in std::chrono::milliseconds of game time. It is told how long each tick lasted and never goes below zero.
  • Road decides all the traffic before the race starts, from the seed alone. That is what lets the planner reason about the whole race.
  • Game owns one of each, plus a std::vector<Obstacle>, and applies the rules in step().

There is no GameObject base class with a virtual update() and draw(). Car and Obstacle share a sprite size, not behaviour: one moves sideways on input and the other moves down on a clock. A shared base would mean storing traffic as std::vector<std::unique_ptr<GameObject>>, one heap allocation per car, to gain an interface nothing calls polymorphically. Plain value types in a std::vector are simpler, and the erase-remove idiom in step() drops cars that have left the screen. If you want a refresher on the basics, the guide to classes in C++ covers constructors, access control and member functions.

Why the Timer Counts Game Time

Timer is not a stopwatch. The game loop tells it how long each tick lasted, 70 ms or 35 ms with nitro, so pausing stops the clock, a stalled terminal does not cost the player time, and a race can be replayed exactly in a test. Without nitro, the 39-second limit runs out on tick 558 every time:

race::Game slow(scripted({}));
unsigned e = 0;
while (!slow.over())
    e = slow.step(Input::none);
CHECK(e == ev::time_up && slow.distance() == 558);

That is 558 × 70 ms = 39.06 seconds, the first tick to reach the limit. The original timer class took the shortcut many first games take: it counted passes through the game loop instead of measuring time, so its minutes ran at whatever speed the computer did.

The loop in main.cpp keeps real time separately. It sleeps until the next tick’s deadline with std::this_thread::sleep_until() on a std::chrono::steady_clock, rather than for a fixed delay after each frame, so drawing time does not slow the game down. The brick breaker article measures the difference: 50.0 ms per tick on a deadline against 56.0 ms with a fixed delay. Nitro simply makes the next deadline closer.

The Terminal class follows the same ownership idea as the rest of the program. Its constructor switches the terminal into raw mode and its destructor switches it back, so the terminal is restored however play() exits, including by an exception. Constructors and destructors in C++ explains the pattern, usually called RAII.

Reproducible Randomness Without std::uniform_int_distribution

The planner, the tests and the expected output files all depend on every compiler producing the same road from the same seed. Road uses std::mt19937 but turns its output into lanes and chances with % rather than with std::uniform_int_distribution. The reason is a gap in the standard: it fixes the exact sequence of numbers each named engine produces, but leaves the algorithms of the distributions to each library, so the same seed can give different results with GCC’s libstdc++, Clang’s libc++ and Microsoft’s library. P2059R0, “Make Pseudo-random Numbers Portable” describes the problem.

The engine half of that is easy to check. The standard requires the 10,000th output of a default-constructed std::mt19937 to be 4123659995, and random_check.cpp prints it next to the two mappings:

// random_check.cpp - the standard fixes std::mt19937's output but not the
// algorithm of std::uniform_int_distribution. Compare this output across
// standard libraries: the first line must match everywhere.

#include <cstdio>
#include <random>

int main()
{
    std::mt19937 a;
    a.discard(9999);                    // skip the first 9,999 outputs
    std::printf("10000th mt19937 value: %u\n", static_cast<unsigned>(a()));

    std::mt19937 rng(1);
    std::uniform_int_distribution<int> lane(0, 2);
    std::printf("uniform_int_distribution(0, 2), seed 1:");
    for (int i = 0; i < 12; ++i) std::printf(" %d", lane(rng));
    std::printf("\nrng() %% 3, seed 1:                    ");
    std::mt19937 raw(1);
    for (int i = 0; i < 12; ++i) std::printf(" %u", static_cast<unsigned>(raw() % 3));
    std::printf("\n");
}

Output (GCC 13.3, libstdc++):

10000th mt19937 value: 4123659995
uniform_int_distribution(0, 2), seed 1: 1 2 2 2 0 0 0 2 0 0 0 1
rng() % 3, seed 1:                     1 2 0 2 1 1 2 2 2 0 2 0

The first line is the value the standard requires. The other two show that a distribution and a hand-made mapping give different sequences from the same engine. Only libstdc++ was available on the test machine, so this page does not show the distribution itself changing between libraries. The repository’s CI prints this program’s output on Linux, macOS and Windows, and the macOS and MSVC builds compare road_stats output against the same expected file, which is where a non-portable mapping would fail. For a game, rng() % 3 is close enough to uniform: with a 32-bit engine the bias is less than one part in a billion. For more on the trade-offs, see generating random numbers in C and C++.

Testing a Game Without Playing It

The collision code exists twice. Game::step() checks real Obstacle positions, and Road::blocked() with reachable_after() works from the spawn schedule. They are written differently on purpose, and one test makes them check each other: 300 races at hard traffic on naive roads, driven by random key presses, where after every tick the game’s crash flag must match the planner’s verdict for the move just made. If the two disagree on a single tick, the test fails.

The other tests cover each rule directly: a car entering at tick 1 hits a player who stays in its lane on exactly tick 16, steering back alongside a car is a side impact, a staircase of three cars has no route, nitro halves the tick and runs out, and the autopilot finishes 600 fair races. Tests are only as good as what they catch, so seven deliberate mistakes were tried against them:

Deliberate mistakeCaught by
Side-impact check removed from the gameDodge test
Cars enter one row lowerCrash-timing test
Fair check skippedAutopilot test: no route
Planner ignores side impactsGame-versus-planner test
Nitro never runs outNitro test
Overlap test off by one rowOverlap test
Cars below the screen never removedMissed by the first version of the suite

The last one did no visible harm: off-screen cars cannot collide or be drawn, so every rule test passed while the traffic list grew all race. The game-versus-planner test now also checks that no car is kept below the screen, and catches it.

Make It Your Own

  • Coins or fuel. Add a second kind of object that the player wants to touch. Road can schedule it the same way, and the planner can ignore it.
  • Variable speed. Let Down slow the car instead of only toggling nitro. The timer already accepts any tick length.
  • More lanes. Change lanes to 4 or 5; road_w follows. With the same traffic at normal density, 540 of 1,000 naive roads still have no route with four lanes, and 86 with five, so keep the fair generator. It works for up to eight lanes, the bits in an unsigned char mask.
  • High scores. Record the time left at the finish line and store the best runs in a file.
  • Graphics. Port main.cpp and terminal.cpp to a library such as SDL or raylib. The rules in game.cpp and road.cpp do not change. If you are choosing a language for a bigger game, see the best programming languages for game development.

Key Takeaways

  • Random obstacles need a fairness check. With one car in a random lane on 16% of ticks, 994 of 1,000 roads in this game could not be finished by any driver.
  • A fair generator only has to look ahead to the first tick a new obstacle can matter, and dropping the obstacle always leaves a way through. Here it cost 5.6% of the traffic.
  • Keep the rules free of input and output. Game::step() never touches the terminal, so 300 random races and 600 autopilot races run in a test.
  • Count game time, not loop passes or wall-clock time, and pause, replays and tests all work.
  • For reproducible randomness, use the standard engines but not the standard distributions, which differ between libraries.
  • Check your tests with deliberate mistakes. Six of seven were caught; the seventh needed a new check.

Frequently Asked Questions

Conclusion

A racing game looks like a test of reflexes, but the interesting decisions are about fairness and time: whether the road can be finished at all, and whether the clock means the same thing on every computer. Both are invisible in a quick play-through and obvious once you measure them, which is the case for building a game so that a program can play it.

The traffic generator is the part most worth taking to other games. Any game that spawns hazards at random can trap its player, and the same look-ahead check applies to falling blocks, enemy waves and platform gaps. More games and game-programming guides are in the game development section.

Source Code and Tests

car-race-game

All programs on this page are in the games/car-race-game directory of the MYCPLUS C++ examples repository.

Build and test:

cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure

What the build checks. It compiles with GCC and Clang under C++17 and C++20 on Linux, with Clang on macOS and with MSVC on Windows, treating warnings as errors. It runs the rule tests described above, checks that autoplay and road_stats print exactly the output shown on this page, replays a full race through a small terminal emulator to check the screen diff, and on Linux and macOS plays the game in a pseudo-terminal to check that Q and Ctrl+C both exit cleanly and restore the cursor. A sanitizer job runs the tests under AddressSanitizer and UndefinedBehaviorSanitizer. The builds do not run the interactive game on Windows or check how it feels to play.

Scroll to Top