DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

Writing a 2D Platformer in C++ with SFML: Notes From the Codebase

Writing a 2D Platformer in C++ with SFML: Notes From the Codebase

PlataformGame began as an experiment. The question was how far I could get with C++17 and SFML if SFML were only allowed to be a backend: window, rendering, audio, input, and nothing else.

A couple of years later the answer is "further than I expected, but the architecture work never really stops." The repo is public, and I keep re-explaining the same decisions in issues and DMs, so this is my attempt to write them down once.

If you're building anything 2D with SFML, most of this should transfer. If you're building a game engine in C++ from scratch, some of it might save you a week.

Repo: https://github.com/matheuscamarques/plataformgame

SFML gives you less than you think

SFML hands you a window, a render target, textures, sprites, sound buffers, and input. That is roughly the whole list. There is no scene graph, no entity system, no physics, no resource manager, no scheduler, no lighting.

When I started, I read this as a limitation. Now I think it's the reason the project still compiles. Every system in the codebase exists because I needed it, and I know exactly what it does, because I wrote it and I've had to debug it. Nothing is hidden behind an abstraction I didn't choose.

The cost is real too. You write your own scheduler, your own collision, your own content pipeline. If you want to ship something in three months, use an engine. If you want to understand how a 2D engine actually works, SFML is a good place to start.

The Makefile enforces the architecture

The codebase is split into three layers:

core/     kernel, no game logic, no upward dependencies
world/    procedural world, tiles, strata, generation
support/  gameplay: combat, AI, lighting, audio, UI
Enter fullscreen mode Exit fullscreen mode

The rule is that core/ must never include anything from world/ or support/. Architecture rules that live only in a README get broken within a month, so I put this one in the build:

test-layers:
    @! grep -r "support/" src/core/ && echo "core is clean"
Enter fullscreen mode Exit fullscreen mode

Four lines, and it has caught more bad decisions than any code review I've done on myself. If you're working on a solo C++ project and you have a layering rule you care about, make the compiler or the build script enforce it. Willpower does not scale, even when the only developer is you.

System order is not an implementation detail

Systems run by priority, sorted with std::stable_sort:

std::stable_sort(systems.begin(), systems.end(),
    [](const System& a, const System& b) {
        return a.priority < b.priority;
    });
Enter fullscreen mode Exit fullscreen mode

The stable part matters. Two systems with the same priority should run in the order they were registered, every time. Plain std::sort gives you a deterministic result for a fixed input, but the ordering of equal elements is unspecified, and "unspecified" turns into "different on the machine where the bug happens."

Non-deterministic update order produces bugs that appear once every forty runs. Those are the worst bugs. Use stable_sort.

Procedural generation has to be boring

The world runs about 12,000 tiles deep across 11 vertical strata, generated from a seed. The requirement I set early on was that a given seed produces an identical world whether the game is built at -O0 or -O2.

That is harder than it sounds. Floating-point results are not guaranteed to be bit-identical across optimization levels once the compiler starts contracting operations. The fix in the build flags:

CXXFLAGS += -ffp-contract=off -fno-fast-math
Enter fullscreen mode Exit fullscreen mode

I also avoid libm functions in the generation path where I can, and wrap the ones I can't avoid. If you want reproducible procedural worlds in C++, you have to treat the compiler as an adversary. It is trying to help, and its help changes your terrain.

Combat is per body part

The player's body is split into seven parts:

enum class BodyPart { Head, Torso, ArmL, ArmR, LegL, LegR, Weapon };
Enter fullscreen mode Exit fullscreen mode

Each part carries damage and posture multipliers, and in narrowphase collision the largest multiplier among the touched parts wins. A hit to the head means something different from a hit to the leg, without a ragdoll or a hitbox hierarchy or a physics rig.

For a 2D action game, that's a good ratio of feel to complexity. Players notice the difference. The implementation is a table and a loop.

Lighting on a grid

The lighting system is a grid of 0 to 15 per cell for sky and block light, a 96-ray cast with DDA stepping at half-tile resolution, a ten-minute day/night cycle, and bloom plus vignette on top. It's the flood-fill approach you've seen in voxel games, adapted to 2D.

It looks better than it has any right to. Bloom does a lot of the heavy lifting, and the vignette hides the edges of the light radius. If you're doing 2D lighting in SFML, this is a pattern worth copying.

There are no .wav files in the repository

All 26 sound effects and 6 music tracks are synthesized at runtime into sf::SoundBuffer objects. The only binary asset in the repo is a font for the HUD.

sf::SoundBuffer makeJumpSound() {
    std::vector<sf::Int16> samples;
    // fill the waveform
    return sf::SoundBuffer(samples.data(), samples.size(), 1, 44100);
}
Enter fullscreen mode Exit fullscreen mode

I did this at first because I had no sound design skills and no budget. It turned out to be a good constraint. The repo stays small, the sounds are tunable as code, and I actually understand sf::SoundBuffer now instead of treating it as a black box that eats files.

Adding content is one file and one macro

Enemies, items, blocks, and skills are registered through macros. Adding one is a new file plus one line:

REGISTER_ENEMY("crawler", CrawlerBehavior, 40, 12);
Enter fullscreen mode Exit fullscreen mode

There are seven registries, each with its own macro. No central switch statement to extend, no enum to update, no build file to touch. This is the single decision that has saved the most time. It's static initialization and a map under the hood, which is an old trick, but it's the difference between adding an enemy in five minutes and adding an enemy in an evening.

Sprites are text

Entity sprites are const char*[] with a palette. A rebuild pass sweeps pixels per body part, so hitboxes follow the art automatically.

static const char* PLAYER_SPRITE[] = {
    "..HH..",
    ".HTTH.",
    "HTTTTH",
    "..TT..",
};
Enter fullscreen mode Exit fullscreen mode

This started as a way to avoid writing an asset pipeline. It stuck because editing art in a text editor at two in the morning is faster than opening a drawing program, and because the hitbox and the sprite can never disagree.

What I would change

There's no disk save. The game is run-based with checkpoint respawn, which works for a roguelike-ish descent but is not a substitute for save/load.

The lighting recalculates per frame. It should be cached and invalidated when a block changes.

And I've started to think an ECS would simplify some of this. The registry and system approach has held up well past the point where I expected it to fall over, but there's a ceiling somewhere and I'm probably approaching it.

Running it

git clone https://github.com/matheuscamarques/plataformgame
cd plataformgame
make
./plataformgame
Enter fullscreen mode Exit fullscreen mode

You need a C++17 compiler and SFML installed. Build notes are in the README.

I'm looking for code review, contributors, and people who will clone it and tell me what breaks on their machine. Issues and PRs are welcome, and if you're working on something similar with SFML, I'm happy to compare notes.

Top comments (0)