← Back to work

C++20 · SDL3 · 2025 · solo

2D space shooter

A real-time top-down shooter with three enemy behaviors, a shared bullet system for both sides, and a small polymorphic entity hierarchy driving all of it.

Gameplay screenshot — the player's ship firing upward at a wave of enemies over a starfield.
8
entity classes
3
enemy behaviors

The problem

A shooter’s update loop touches every kind of object on screen every frame — player, bullets, explosions, several enemy types — and the naive version of that is either a wall of special-cased branches, or a class hierarchy that slices itself into corruption the moment you store the wrong thing by value.

The approach

A base Entity owns position, sprite-sheet animation, and a render call. Player, Bullet, and Effect derive from it directly and are stored by value in their own vectors, while Enemy is an abstract Entity subclass with three concrete behaviors — Grunt (straight down, dies in one hit), Tank (slow, three hits to kill, and the only one that shoots back), and Zigzagger (sine-wave horizontal drift) — held as unique_ptr<Enemy> so the update loop can dispatch whichever behavior through one virtual call with no slicing. Player and enemy bullets both reuse the same Bullet class, just constructed with a different velocity sign and texture. Eight classes, one update loop.

What was hard

Two things past the entity hierarchy itself. Removing dead entities without corrupting the loop that’s iterating over them — handled by sweeping with std::erase_if once after the update/collision pass finishes, not during it. And making collision feel fair rather than pixel-exact: the hitbox used for collisions is inset 25% from each sprite’s visual rect, kept separate from the rect used for rendering and off-screen checks, so a near-miss doesn’t register as a hit just because the sprites’ bounding boxes technically touched.

Result

A complete top-down shooter with a real state machine (menu → playing → game over), three enemy behaviors whose spawn rate ramps up the longer you survive, lives with a brief invulnerability window and flicker after getting hit, and score tracking — all sharing the same entity, update, and render path.