-
Game
Date
Duration
Team Size
Game Programmer, Engine Programmer
Enchanted Defense started as a personal learning project. I wanted to get closer to the systems that a game engine normally hides, so instead of using an existing engine I built a small one myself in C++ with SDL2 and then used it to make a top-down shooter. The project is strongly inspired by the LazyFoo SDL tutorial series especially for the rendering part with SDL2.
What made the project especially valuable to me was the overlap between engine code and gameplay code. Even small features forced me to think about update order, rendering, input, animation, scene flow, collision, UI, enemy logic and audio at the same time. Because I owned the framework underneath the game, every new feature also taught me something about what retail engines normally do for me.
Before I could focus on enemies or combat, I first had to build the foundation that a normal engine would already provide. Initialization, timing, the object update loop, rendering, scene changes and shutdown all had to be handled manually. That groundwork made the rest of the project possible.
The clearest example of this is the main loop. It is a simple loop, but it shows the project at its most honest. SDL events are polled directly, UI buttons get a chance to consume input, gameplay objects are updated one by one and the renderer presents the finished frame at the end of the tick.
int main(int argc, char* args[])
{
bool success = true;
if (success && !ENGINE->gRenderer->init())
{
printf("failed to initialize renderer \n");
}
GAME->gGameManager = std::make_shared<GameManager>();
GAME->gGameManager->init();
const auto gameClock = std::make_shared<GameClock>();
gameClock->init();
SDL_Event e;
while (!ENGINE->gQuit)
{
gameClock->startTick();
ENGINE->gQueueForDelete.clear();
ENGINE->gSizeQueueForDelete = 0;
while (SDL_PollEvent(&e) != 0)
{
if (e.type == SDL_QUIT)
{
ENGINE->gQuit = true;
}
for (int i = 0; i < ENGINE->gTotalButtons; ++i)
{
if (ENGINE->gButtons[i]->handleEvent(&e))
{
break;
}
}
}
ENGINE->notify(HANDLE_INPUT);
for (int object = 0; object < ENGINE->gTotalObjects; ++object)
{
if (ENGINE->gObjectList[object] &&
ENGINE->gObjectList[object]->shouldUpdate)
{
ENGINE->gObjectList[object]->update();
}
}
for (int objectIndex = 0; objectIndex < ENGINE->gSizeQueueForDelete; ++objectIndex)
{
if (ENGINE->gQueueForDelete[objectIndex])
{
ENGINE->gQueueForDelete[objectIndex]->close();
}
}
ENGINE->gRenderer->renderUpdate();
gameClock->endTick();
}
return 0;
}It made me think much more carefully about the frame lifecycle, because there was no hidden engine behavior in the background. If something needed to happen each frame, I had to decide where it belonged and how it interacted with everything else.
The first important piece is the base object layer. Every gameplay and engine object inherits from Object, registers itself on construction and can later remove itself through a small deletion flow. That gave the engine a consistent way to keep track of active objects.
void Object::markForDelete()
{
queuedForDelete = true;
ENGINE->removeObject(this);
ENGINE->addToDeleteQueue(this);
}
Object::Object()
{
ENGINE->addObject(this);
name = typeid(this).name();
}
Object::~Object()
{
ENGINE->removeObject(this);
}To have a system where I was able to create and react to events, I implemented the observer pattern, which gives the engine a clean way to broadcast events such as ALL_INPUTS_HANDLED or PLAYER_DIED. That kept systems loosely connected without needing a more complicated messaging framework.
class Subject
{
std::vector<Observer*> observers;
int numObservers = 0;
public:
void addObserver(Observer* observer)
{
observers.push_back(observer);
numObservers++;
}
void removeObserver(Observer* observer)
{
const auto position = std::find(observers.begin(), observers.end(), observer);
if (position != observers.end())
{
observers.erase(position);
numObservers--;
}
}
void notify(EEvent event)
{
for (int i = 0; i < numObservers; ++i)
{
observers[i]->onNotify(event);
}
}
};Rendering is handled through a small pairing of Renderer and Texture. The renderer owns the SDL window and present step, while textures register themselves and can represent either static elements like backgrounds and UI or dynamic elements like the player and enemies. The same texture type also supports clipping, which is what made sprite-sheet animation possible later on.
void Renderer::renderUpdate()
{
SDL_SetRenderDrawColor(ENGINE->gSDL_Renderer, 0xFF, 0xFF, 0xFF, 0xFF);
SDL_RenderClear(ENGINE->gSDL_Renderer);
for (int i = 0; i < numTextures; ++i)
{
if (textureContainer[i]->markForRender)
{
textureContainer[i]->render();
}
}
SDL_RenderPresent(ENGINE->gSDL_Renderer);
}
void Renderer::addTexture(Texture* texture)
{
textureContainer.push_back(texture);
std::sort(textureContainer.begin(), textureContainer.end(), [](const Texture* a, const Texture* b)
{
return a->getZindex() < b->getZindex();
});
}
void Texture::render(double angle, SDL_Point* center)
{
SDL_Rect renderQuad;
if (mDynamicPos)
{
int tempX = static_cast<int>(mDynamicPos->x) - getWidth() / 2;
int tempY = static_cast<int>(mDynamicPos->y) - getHeight() / 2;
renderQuad = {tempX, tempY, mWidth, mHeight};
}
else
{
renderQuad = {static_cast<int>(mStaticPos.x), static_cast<int>(mStaticPos.y), mWidth, mHeight};
}
if (clip != nullptr)
{
renderQuad.w = clip->w;
renderQuad.h = clip->h;
}
SDL_RenderCopyEx(ENGINE->gSDL_Renderer, mTexture, clip, &renderQuad, angle, center, flip);
}Timing is handled by GameClock, which measures the frame, clamps unusually large spikes and then updates the global DELTA_TIME value using the average of previous frame times. That one value feeds movement, cooldowns, pathfinding updates and animation timing across the rest of the project to keep time-based logic independent from the frame rate.
void GameClock::startTick()
{
tickBeginning = std::chrono::high_resolution_clock::now();
}
void GameClock::endTick()
{
tickEnd = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> elapsedTime = tickEnd - tickBeginning;
if (elapsedTime > maxElapsedTime)
elapsedTime = maxElapsedTime;
pFrameTimes[frameTimeIndex] = elapsedTime;
frameTimeIndex = (frameTimeIndex + 1) % numFrameTimes;
elapsedTime = std::chrono::duration<double>{0.0};
for (int t = 0; t < numFrameTimes; ++t)
elapsedTime += pFrameTimes[t];
elapsedTime /= static_cast<double>(numFrameTimes) * 1000;
DELTA_TIME = static_cast<float>(elapsedTime.count());
}Every position and most calculations in the project rely on 2D vector math. Therefore, I implemented a vector type which has access to all vector operations. It also features overridden operators to make for example vector addition or multiplication easier.
CollisionObject handles bounding-box checks and a lightweight collision response. There is also a small raycast helper that is used when deriving collision information from border points.
bool CollisionObject::checkForIntersection(CollisionObject* otherObject)
{
updatePosition();
otherObject->updatePosition();
if (otherObject->topLeft.x >= bottomRight.x
|| otherObject->bottomRight.x <= topLeft.x
|| otherObject->topLeft.y >= bottomRight.y
|| otherObject->bottomRight.y <= topLeft.y)
return false;
return true;
}
void CollisionObject::collisionResponse(CollisionObject* otherObject)
{
Vector collNormal = (*mCenter - *otherObject->mCenter).normalize();
Vector relativeVel = mParent->velocity - otherObject->mParent->velocity;
float restitution = 0.f;
float impulse = -(1 + restitution) * relativeVel * collNormal;
mParent->velocity += impulse * collNormal;
otherObject->mParent->velocity -= impulse * collNormal;
}The rest of the folder fills in the smaller but still important gaps. SoundEffect and Soundtrack wrap SDL_mixer for one-shot sounds and looping music, Helper provides numeric utility functions such as epsilon comparison and angle conversion, andMeasurePerformance adds simple profiling output for debugging. None of these systems are large on their own, but together they form the core layer that the gameplay code depends on.
I added a small scene system. In my case, a scene is one of the screens the player will see, for example the main menu or the game view. Each scene is responsible for spawning its own background, text elements, buttons and gameplay objects, while the scene manager handles the transitions between them.
This also meant building UI helpers inside the engine code. Buttons register themselves, receive SDL mouse events during the main loop and call gameplay callbacks when clicked. It is a simple setup, but it turned the project from a technical experiment into something that already felt more like a game.
The main menu scene is a good example of how these pieces come together. The scene creates its background, adds styled text for the title and then constructs buttons with callbacks that either start the game, open the credits or quit the application.
void MainMenuScene::startScene()
{
BaseScene::startScene();
background = std::make_shared<Texture>();
background->loadTexture("assets/textures/MainMenuScreen.png");
background->setStaticPosition({
(ENGINE->SCREEN_WIDTH - background->getWidth()) / 2,
(ENGINE->SCREEN_HEIGHT - background->getHeight()) / 2
});
title = std::make_shared<StyledText>();
title->init("Enchanted Defense", TEXT_COLOR, HEADLINE_SIZE);
title->loadFromFile("assets/fonts/alagard.ttf");
title->setPosition(Vector((ENGINE->SCREEN_WIDTH - title->getTexture()->getWidth()) / 2, 150));
title->createShadow(SHADOW_COLOR);
startButton = std::make_shared<CustomButton>();
startButton->init(TEXT_COLOR);
startButton->setButtonText("START", TEXT_SIZE);
startButton->setPosition(Vector((ENGINE->SCREEN_WIDTH - startButton->getWidth()) / 2, 300));
startButton->setCallback([]
{
GAME->gSoundManager->getSoundtrack(MENU_TRACK)->stop(500);
GAME->gSceneManager->changeScene<GameScene>();
});
startButton->getButtonText()->createShadow(SHADOW_COLOR);
}Underneath that, the button code is deliberately lightweight. Buttons register themselves with the engine, track whether the mouse is hovering over them and fire a stored callback when they are clicked. Since the main loop already iterates through the registered buttons during SDL event polling, the UI ends up fitting naturally into the rest of the engine instead of feeling like a separate system.
void Button::init(const SDL_Color& buttonColor)
{
ENGINE->addButton(this);
mButtonColor = buttonColor;
}
void Button::setCallback(std::function<void()> callback)
{
mCallback = std::move(callback);
}
bool Button::handleEvent(const SDL_Event* e)
{
if(enableHover && e->type == SDL_MOUSEMOTION)
{
int x, y;
SDL_GetMouseState(&x, &y);
if (x > getPosition().x
&& x < getPosition().x + mWidth
&& y > getPosition().y
&& y < getPosition().y + mHeight)
{
if(!mIsHovering)
{
mIsHovering = true;
mButtonText->setColor(mHoverColor);
}
}
else
{
if(mIsHovering)
{
mIsHovering = false;
mButtonText->setColor(mButtonColor);
}
}
}
if (e->type == SDL_MOUSEBUTTONDOWN)
{
int x, y;
SDL_GetMouseState(&x, &y);
if (x > getPosition().x
&& x < getPosition().x + mWidth
&& y > getPosition().y
&& y < getPosition().y + mHeight)
{
playButtonSound();
mCallback();
return true;
}
}
return false;
}Scene changes are handled by a small scene manager that first ends the current scene, then renders a short fade using the textures that are already on screen and finally constructs the next scene. It is a simple transition, but it adds a lot of polish for very little code and made the project feel much more complete.
template <class T>
void changeScene()
{
if (currentScene)
{
currentScene->endScene();
}
for (Uint8 alpha = 255; alpha > 0; alpha -= 5)
{
SDL_SetRenderDrawColor(ENGINE->gSDL_Renderer, 0, 0, 0, 255);
SDL_RenderClear(ENGINE->gSDL_Renderer);
for (const auto& texture : ENGINE->gRenderer->getTextures())
{
SDL_SetTextureAlphaMod(texture->getSDLTexture(), alpha);
SDL_Rect rec = {
static_cast<int>(texture->getPosition().x),
static_cast<int>(texture->getPosition().y),
texture->getWidth(),
texture->getHeight()
};
SDL_RenderCopy(ENGINE->gSDL_Renderer, texture->getSDLTexture(), nullptr, &rec);
}
SDL_RenderPresent(ENGINE->gSDL_Renderer);
SDL_Delay(10);
}
currentScene = std::make_shared<T>();
currentScene->startScene();
}Animation became another place where a bit of structure helped a lot. I wanted a sprite-based animation system, which I later used for player and enemy animation.
The animation state machine owns the current state, handles transitions and delegates the per-frame work to an Animator that advances through the sprite sheet over time. That separation was important because it meant the same animation runtime could support any future object that needed state-based sprite animation.
void AnimationStateMachine::init()
{
if (!currentState)
{
Log::print("Current State is invalid");
}
else
{
currentState->startState();
}
}
void AnimationStateMachine::stateTransition(BaseState* state)
{
currentState->endState();
currentState = state;
currentState->startState();
}
void Animator::update()
{
if (currentFrameTime <= 0.f)
{
currentFrameTime = frameRate;
animationFrame.x += getWidth();
if (currentSprite < maxSprites - 1)
{
currentSprite++;
}
else
{
currentSprite = 0;
animationFrame.x = 0;
}
}
else
{
currentFrameTime -= DELTA_TIME;
}
}In the following snippet you can see the implementation for the player animation state machine. What I like about this system is that it keeps responsibilities clear. The input layer decides whether the player is moving left, right, up, down or not at all. The animation state machine then translates that into the correct sprite sheet and transition. Even in a small project, that separation already made the player code easier to extend.
void PlayerASM::init()
{
idleState = std::make_shared<BaseState>(&PLAYER->position, "assets/textures/player/Idle.png", 4.f, 2);
leftState = std::make_shared<BaseState>(&PLAYER->position, "assets/textures/player/MoveRight_Left.png", 8.f, 2,
SDL_FLIP_HORIZONTAL);
rightState = std::make_shared<BaseState>(&PLAYER->position, "assets/textures/player/MoveRight_Left.png", 8.f, 2);
downState = std::make_shared<BaseState>(&PLAYER->position, "assets/textures/player/MoveDown.png", 16.f, 5);
upState = std::make_shared<BaseState>(&PLAYER->position, "assets/textures/player/MoveUp.png", 8.f, 2);
currentState = idleState.get();
PLAYER->addObserver(this);
AnimationStateMachine::init();
}
void PlayerASM::update()
{
AnimationStateMachine::update();
if(stateEnum != lastStateEnum)
{
switch (stateEnum)
{
case LEFT:
lastStateEnum = stateEnum;
stateTransition(leftState.get());
break;
case RIGHT:
lastStateEnum = stateEnum;
stateTransition(rightState.get());
break;
case UP:
lastStateEnum = stateEnum;
stateTransition(upState.get());
break;
case DOWN:
lastStateEnum = stateEnum;
stateTransition(downState.get());
break;
case IDLE:
lastStateEnum = stateEnum;
stateTransition(idleState.get());
break;
}
}
}Once the foundation was stable, most of my work shifted toward the player and enemy loop. The player needed responsive movement, aiming and shooting, enemies needed to spawn and chase the player, and the whole thing had to remain readable enough that I could keep extending it. This was where programming patterns started to become practical tools.
One of the first systems I put some structure into was input. Rather than letting the player class read SDL key state directly, I routed input through an InputManager and a set of command objects. That kept the mapping between keys and actions much easier to reason about and gave the player controller a cleaner interface.
Command* InputManager::handleInput()
{
const Uint8* currentKeyStates = SDL_GetKeyboardState(NULL);
if (!disablePlayerInput && PLAYER)
{
PLAYER->stateMachine->stateEnum = IDLE;
if (currentKeyStates[SDL_SCANCODE_W]) buttonW->execute();
if (currentKeyStates[SDL_SCANCODE_A]) buttonA->execute();
if (currentKeyStates[SDL_SCANCODE_S]) buttonS->execute();
if (currentKeyStates[SDL_SCANCODE_D]) buttonD->execute();
if (currentKeyStates[SDL_SCANCODE_UP]) buttonUp->execute();
if (currentKeyStates[SDL_SCANCODE_LEFT]) buttonLeft->execute();
if (currentKeyStates[SDL_SCANCODE_DOWN]) buttonDown->execute();
if (currentKeyStates[SDL_SCANCODE_RIGHT]) buttonRight->execute();
}
if (currentKeyStates[SDL_SCANCODE_ESCAPE]) buttonESC->execute();
ENGINE->notify(ALL_INPUTS_HANDLED);
return nullptr;
}
class MoveLeftCommand : public Command
{
public:
virtual void execute()
{
Vector dir = Vector(-1, 0);
PLAYER->addMoveDirection(dir);
PLAYER->isMoving = true;
PLAYER->stateMachine->stateEnum = LEFT;
}
};Movement is mapped to `WASD`, while aiming and shooting use the arrow keys. Each pressed key executes a command that contributes movement or aim direction to the player. Once all input has been handled, the player resolves that accumulated state in one place. I also used the same observer setup to disable player input after death, which kept the behavior easy to centralize.
I implemented the enemy AI with a shared BaseEnemy class. On top of that base layer, the SkeletonCharacter owns the actual enemy behavior. Sprite animation, path refresh timing, movement speed, collision checks against the player and a death callback that removes the enemy from the active list and updates the win condition. That separation kept the scene focused on pacing and spawning while the enemy class handled movement and combat behavior.
class BaseEnemy : public BaseCharacter
{
public:
virtual void onDeath() const;
void setEventOnDeath(std::function<void()> callback);
private:
std::function<void()> mOnDeath = nullptr;
};
template <class T>
class EnemySpawnerFor : public EnemySpawner
{
public:
std::shared_ptr<BaseEnemy> spawnEnemy(Vector& spawnPoint) override
{
return std::make_shared<T>(spawnPoint);
}
};Once enemies existed, they needed a reliable way to reach the player without turning into a single clump. For that I implemented a grid-based A* pathfinding system which was inspired by Javidx9 and adjusted to fit my demands. Skeletons request new paths at intervals, follow the returned nodes and use the pathfinding grid to avoid occupied cells.
float Pathfinding::heuristic(AStarNode* node1, AStarNode* node2)
{
return distance(node1, node2) + node1->heat;
}
bool Pathfinding::findPath(Vector& start, Vector& end, std::vector<Vector>& path, const Object* callingObject)
{
if (start == end)
{
path.clear();
return true;
}
resetGrid();
AStarNode* nodeStart = vectorToNode(start);
AStarNode* nodeEnd = vectorToNode(end);
nodeStart->localGoal = 0.f;
nodeStart->globalGoal = heuristic(nodeStart, nodeEnd);
auto compareNodes = [](const AStarNode* a, const AStarNode* b)
{
return a->globalGoal > b->globalGoal;
};
std::priority_queue<AStarNode*, std::vector<AStarNode*>, decltype(compareNodes)> pqOpenNodes(compareNodes);
pqOpenNodes.push(nodeStart);
while (!pqOpenNodes.empty())
{
AStarNode* nodeCurrent = pqOpenNodes.top();
pqOpenNodes.pop();
nodeCurrent->visited = true;
for (auto neighbour : nodeCurrent->neighbours)
{
if(neighbour)
{
if (neighbour->visited || (neighbour->blocked && neighbour->blockingObject != callingObject))
continue;
float possiblyLowerGoal = nodeCurrent->localGoal + distance(nodeCurrent, neighbour);
if (possiblyLowerGoal < neighbour->localGoal)
{
neighbour->parent = nodeCurrent;
neighbour->localGoal = possiblyLowerGoal;
neighbour->globalGoal = neighbour->localGoal + heuristic(neighbour, nodeEnd);
pqOpenNodes.push(neighbour);
}
}
}
if (nodeCurrent == nodeEnd)
{
AStarNode* p = nodeEnd;
while (p->parent != nullptr)
{
path.push_back(nodeToVector(p));
p = p->parent;
}
std::reverse(path.begin(), path.end());
return true;
}
}
return false;
}The extra detail here is the heat value that is added to the heuristic. Besides normal distance, a node can become more expensive when nearby enemies occupy the area. This allowed me to bias navigation away from crowded spots without marking them as completely impossible. It is a small system, but it already captures the kind of experimentation I wanted from this project.
A shooter also needs pressure, so I implemented a wave system inside the gameplay scene. Enemies spawn from fixed points around the edges of the arena and the time between waves becomes shorter over time. This gave the game a simple difficulty curve without requiring any complicated encounter scripting.
void GameScene::startScene()
{
skeletonSpawner = std::make_shared<EnemySpawnerFor<SkeletonCharacter>>();
}
void GameScene::updateScene()
{
BaseScene::updateScene();
textEnemyCount->setText("x" + std::to_string(GAME->gKillCount));
if (GAME->gKillCount > 0)
{
if (GAME->gCurrentEnemyCount > 0)
{
if (currentDifficultyTimer <= 0)
{
minWaveCD *= 0.6f;
maxWaveCD *= 0.6f;
currentDifficultyTimer = difficultyTimer;
}
else
{
currentDifficultyTimer -= DELTA_TIME;
}
if (waveCountDown <= 0)
{
Vector spawnPoint = chooseRandomSpawn();
bool spawnNewEnemy = true;
if (GAME->gCurrentEnemyCount > 0)
{
for (const auto& enemy : GAME->gEnemyList)
{
if (Vector::dist(enemy->position, spawnPoint) <= 50.f)
{
if (!enemy->collision->checkIfPointInCollision(spawnPoint))
{
spawnNewEnemy = false;
}
}
}
}
if (spawnNewEnemy)
{
waveCountDown = setRandomWaveCountDown(minWaveCD, maxWaveCD);
std::shared_ptr<BaseEnemy> newEnemy = skeletonSpawner->spawnEnemy(spawnPoint);
GAME->addEnemy(newEnemy);
std::weak_ptr<BaseEnemy> enemyTemp(newEnemy);
newEnemy->setEventOnDeath([this, enemyTemp]
{
GAME->removeEnemy(enemyTemp);
GAME->gKillCount--;
});
GAME->gCurrentEnemyCount--;
}
}
else
{
waveCountDown -= DELTA_TIME;
}
}
}
else
{
GAME->gSoundManager->getSoundEffect(WIN_SOUND)->play();
GAME->gSceneManager->changeScene<WinScene>();
}
}A detail I still like here is the cleanup callback attached to each spawned enemy. When an enemy dies, the scene removes it from the active list and decreases the remaining kill count. Together with the prototype styleEnemySpawnerFor<SkeletonCharacter>, this kept the spawning code compact while still making it easy to add new enemy types later.
I used the prototype pattern here in a lightweight gameplay focused way. Instead of letting the scene know how every enemy type is constructed, the scene only talks to anEnemySpawner interface and asks it for a new enemy when a wave should spawn. The concrete spawner,EnemySpawnerFor<SkeletonCharacter>, acts like a reusable creation template for that enemy type. That meant I could swap in different enemy classes later without rewriting the wave system, and it kept the spawning logic decoupled from the concrete skeleton implementation.
class EnemySpawner
{
public:
virtual ~EnemySpawner() = default;
virtual std::shared_ptr<BaseEnemy> spawnEnemy(Vector& spawnPoint) = 0;
};
template <class T>
class EnemySpawnerFor : public EnemySpawner
{
public:
std::shared_ptr<BaseEnemy> spawnEnemy(Vector& spawnPoint) override
{
return std::make_shared<T>(spawnPoint);
}
};
void GameScene::startScene()
{
skeletonSpawner = std::make_shared<EnemySpawnerFor<SkeletonCharacter>>();
}
std::shared_ptr<BaseEnemy> newEnemy = skeletonSpawner->spawnEnemy(spawnPoint);
GAME->addEnemy(newEnemy);Looking back, this project gave me a much more grounded understanding of what an engine actually does for a game. It is still one of the projects I think about when I want to remind myself how valuable low-level curiosity can be for gameplay programming.