top of page

tude
 

role: Gameplay & System
programmer

software: VS Code
framework: C++, SDL2
duration: 8 weeks

date: -
team size: 1
type: game engine
---
github:
Here

 

2025-05-26 10.25.53.gif

 

 

 

overview.

​

As part of my studies at Stockholm University, I built a custom C++ game engine using SDL2 from scratch. The engine was designed with a modular, entity-based architecture that allows multiple games to share core systems — input handling, rendering, audio, and collision detection — while each game defines its own mechanics on top. 

 

To test the engine I built two games: Starship and Rabbit Hammer.

​

starship.

A vertical space shooter where the player controls a starship and destroys waves of incoming enemy ships before they reach the bottom of the screen. The focus is on precision, timing, and threat prioritisation.

 

Enemy ships spawn from the top of the screen at random X positions and move downward at a fixed rate each tick. If one reaches the bottom it reduces the player's health and removes itself. The player fires bullets by clicking, each spawning at the pistol's current position and moving upward at 6 pixels per tick. When a bullet intersects an enemy ship both are queued for removal and the kill counter increments. The game is won when the player reaches the target kill count, and lost when health hits zero.

 

rabbit hammer.

A reaction-based game where the player wields a sledgehammer to eliminate fast-moving rabbits before they disappear. The focus is entirely on reflexes and fast decision-making.

 

Rabbits spawn at random screen positions and store their creation timestamp using SDL_GetTicks(). Every tick they check how long they have existed — if 3 seconds have elapsed without being hit, they remove themselves and reduce the player's health. The sledgehammer tracks the mouse cursor directly each tick, and on click it checks all active sprites for collision, removes any rabbits it intersects, and increments the kill counter.

​​

​

​

development.

Engine architecture
The engine is built around a single global GameEngine instance (ge) and a single global System instance (sys), both accessible from any class. This was a deliberate simplicity tradeoff — rather than passing references everywhere, any sprite or system can call into the engine or renderer directly.

All game objects inherit from a base Sprite class that stores position and size as an SDL_Rect and provides a common interface: draw(), tick(), mouseDown(), checkCollision(), and deleteAtRestart(). The base implementations are all "empty", so each subclass only overrides what it needs. Collision detection is handled in Sprite::checkCollision using SDL's SDL_HasIntersection, so every object in the game gets it for free without duplicating the logic.

Switching between the two games is done entirely in main.cpp and EnemySpawner. In main.cpp I instantiate either a Pistol or a Sledgehammer, and in EnemySpawner::tick I call either randomSpawnRabbit or randomSpawnShip. Everything else — the engine loop, health tracking, collision, rendering — runs identically for both.


game engine
The GameEngine class owns the main loop and all active sprites. Rather than modifying the sprite list directly mid-loop — which would invalidate iterators during the tick and draw pass — I maintain two separate staging vectors: added and removed. New sprites go into added, sprites marked for deletion go into removed, and both are flushed at the start of each frame before the draw and tick pass runs. This means any sprite can safely call ge.addSprite or ge.removeSprite during its own tick without risk of corrupting the active list.

The loop itself runs at a fixed 100 FPS target. After processing input events and flushing the add/remove queues, it clears the renderer, calls draw() and tick() on every active sprite, renders the UI text for health and kill count directly via sys.renderText, then presents the frame. Frame timing is enforced by calculating the next tick timestamp at the start of each iteration and calling SDL_Delay for the remaining time if the frame completes early.

Win and loss conditions are checked at the end of each frame — if enemyKilledCounter reaches enemiesToKill the game is won, if playerHealthCounter hits zero it's lost. Either triggers endScreen, which clears the renderer, draws the result text, and blocks polling for an ENTER key press before calling resetGame.

resetGame iterates the active sprite list and deletes any sprite where deleteAtRestart() returns true — enemies, bullets, and the health handler — while leaving persistent sprites like the pistol and spawner in place. It then resets all counters and re-adds a fresh HealthHandler.

 
system
The System class handles all SDL2 initialisation and owns the window, renderer, audio mixer, and font. In its constructor it calls SDL_Init, creates the window and a hardware-accelerated renderer with vsync, opens the audio mixer via Mix_OpenAudio, loads and immediately starts looping the background music, and initialises SDL_ttf with the Silkscreen font. The destructor mirrors this exactly, freeing every resource in the correct order before calling SDL_Quit.

Text rendering is done in renderText, which creates an SDL_Surface from the string using TTF_RenderText_Solid, converts it to an SDL_Texture, renders it at the specified position and size, then immediately frees both the surface and texture. This avoids holding onto texture memory for UI elements that change every frame.

enemy spawner
The spawner uses SDL_GetTicks() to track time since the last spawn. When the elapsed time exceeds spawnInterval, it spawns a new enemy at a random position and resets the timer. The spawn rate tightens over time in two stages: above 1000ms the interval drops by 200ms per spawn, below 1000ms it drops by 20ms. This gives an initial rapid ramp-up in difficulty that gradually plateaus as the interval approaches zero.

The spawner itself inherits from Sprite and has deleteAtRestart() returning false, so it persists across rounds. Its draw() is empty — it has no visual representation, it just exists in the sprite list so its tick() runs every frame.

weapon classes
Both weapon classes follow the same pattern. They track the mouse cursor in followMouse() via SDL_GetMouseState and respond to clicks via mouseDown(). The key difference is in what clicking does. The Pistol spawns a new Bullet instance at the pistol's current X position offset by 90 pixels to align with the sprite centre, then plays a gunshot sound via sys.playSound. The Sledgehammer skips projectile spawning entirely and instead immediately iterates all active sprites, checks for collision against any EnemyRabbit via dynamic_cast, and queues matching sprites for removal.

The Bullet moves upward by 6 pixels every tick and runs its own collision check against all active EnemyShip instances. When it hits one, both the bullet and the ship are queued for removal via ge.removeSprite and the kill counter is incremented. If the bullet reaches the top of the screen without hitting anything it removes itself.




what I learned.
Building an engine from scratch rather than working inside an existing one forced me to think carefully about things that are easy to take for granted. The entity lifecycle problem — how to safely add and remove objects during the same loop that's iterating them — is a good example. The staged add/remove queue solution I landed on was simple, but arriving at it myself and understanding exactly why it was necessary gave me a much deeper understanding of how game loops actually work under the hood.

Designing for reuse across two different games early on also shaped how I thought about separation of concerns. The constraint of not wanting to rewrite core systems for each game pushed me toward cleaner boundaries — what belongs in the engine, what belongs in a sprite subclass, what belongs in game-specific setup. Getting that right meant adding the second game was mostly just writing new subclasses and wiring them up in main.cpp.

More broadly, working directly with SDL2 — managing textures, handling the render pipeline, dealing with memory ownership explicitly — built habits around resource management and low-level rendering that I've carried forward into everything since.

bottom of page