Daemon Libra
A 2D top-down tank shooter with heat-map AI pathfinding, procedural map generation via worm algorithms, and bouncing bullet physics, built on a custom C++ engine.
Overview
Daemon Libra is a 2D top-down tank shooter built in C++20 on the custom Daemon Engine. The player controls a tank with dual-stick mechanics — WASD drives the body, IJKL aims the turret independently — and fights through 3 procedurally generated maps to reach the exit tile on each. Three enemy types patrol the terrain: Scorpio (stationary turret with laser sight), Leo (mobile tank using heat-map pathfinding), and Aries (high-HP melee rusher).
Maps are generated at runtime using a worm algorithm that carves organic grass, sparkle, and water pathways through stone. An L-shaped safe zone guarantees traversable space, and a validation pass ensures start-to-exit reachability before spawning enemies. All map parameters — dimensions, worm counts, enemy spawn percentages — are configured in XML.
Highlights
The heat-map AI pathfinding uses Dijkstra-style distance fields with separate land and amphibian heat maps, so enemies can navigate terrain without per-entity pathfinding overhead. The algorithm floods outward from the player’s position, assigning each tile a distance value:
void Map::PopulateDistanceField(TileHeatMap const& heatMap, IntVec2 const& startCoords, float specialValue) const {
heatMap.SetValueAtAllTiles(specialValue);
heatMap.SetValueAtCoords(startCoords, 0.f);
float currentSearchValue = 0.f;
bool isStillGoing = true;
while (isStillGoing) {
isStillGoing = false;
for (int tileY = 0; tileY < m_dimensions.y; ++tileY) {
for (int tileX = 0; tileX < m_dimensions.x; ++tileX) {
IntVec2 tileCoords(tileX, tileY);
float value = heatMap.GetValueAtCoords(tileX, tileY);
if (std::fabs(value - currentSearchValue) < FLOAT_MIN) {
// Spread to cardinal neighbors
IntVec2 neighbors[4] = {
tileCoords + IntVec2(0, 1), // North
tileCoords + IntVec2(1, 0), // East
tileCoords + IntVec2(0, -1), // South
tileCoords + IntVec2(-1, 0) // West
};
float nextSearchValue = currentSearchValue + 1.f;
for (auto& n : neighbors) {
if (!IsTileCoordsOutOfBounds(n) && !IsTileSolid(n) &&
heatMap.GetValueAtCoords(n) > nextSearchValue) {
heatMap.SetValueAtCoords(n, nextSearchValue);
isStillGoing = true;
}
}
}
}
}
currentSearchValue++;
}
}
Line-of-sight detection switches behavior from wandering to chasing. The hardest part wasn’t implementing it — it was knowing whether it was working correctly. I added debug text overlays to display each enemy’s current state (wandering, chasing, attacking) in real time, which turned out to be essential for verifying the algorithm.
The bouncing bullet physics use raycasting ahead for wall detection and reflect off surface normals — player bullets bounce 3 times, enemy bullets don’t. The interesting bug I hit was bullet tunneling: at high velocities, a bullet would move far enough in a single frame to pass entirely through a tile without ever intersecting it. The fix was to raycast along the bullet’s trajectory instead of checking a single point:
void Bullet::UpdateBody(float deltaSeconds) {
m_velocity = Vec2::MakeFromPolarDegrees(m_orientationDegrees, m_moveSpeed);
// Raycast ahead to detect walls before tunneling through them
Ray2 ray = Ray2(m_position, m_velocity, 0.05f);
RaycastResult2D raycastResult = m_map->RaycastVsTiles(ray);
Vec2 nextPosition = m_position + m_velocity * deltaSeconds;
if (raycastResult.m_didImpact) {
m_health--;
// Reflect off surface normal
IntVec2 normalInt = IntVec2(raycastResult.m_impactNormal);
Vec2 normal(static_cast<float>(normalInt.x), static_cast<float>(normalInt.y));
Vec2 reflectedVelocity = m_velocity.GetReflected(normal.GetNormalized());
m_orientationDegrees = Atan2Degrees(reflectedVelocity.y, reflectedVelocity.x);
} else {
m_position = nextPosition;
}
}
Finding the root cause took longer than the fix — a good lesson in how physics bugs often masquerade as rendering issues until you instrument the right thing.
What I Learned
The procedural map generation taught me how to balance randomness with playability. The worm algorithm carves organic terrain, but without validation, maps could spawn with unreachable exits. I added a reachability check using the same heat-map system that drives AI — if the exit tile has a distance value of 999 (the “unreachable” sentinel), the map regenerates. This dual-purpose use of the distance field was an elegant solution that avoided writing separate pathfinding logic.
The XML data-driven design (map dimensions, worm parameters, enemy spawn percentages, tile definitions) made iteration fast — I could tweak enemy counts or map sizes without recompiling. This was my first experience with data-driven architecture, and it showed me why separating game logic from content is valuable for rapid prototyping.
Gallery
Technical Specifications
| Component | Technology |
|---|---|
| Language | C++20 |
| Graphics | DirectX 11, HLSL |
| Audio | FMOD |
| Data | XML definitions (maps, tiles) |
| Engine | Custom Daemon Engine |
| Platform | Windows (x64) |
Related Projects
Game
Daemon Engine
A C++20 engine with real-time rendering, V8 JavaScript scripting, multithreaded jobs, networking.
Game
Daemon Agent
A dual-language game application built on DaemonEngine that lets AI agents write game logic in JavaScript while C++ handles rendering at 60 FPS. Features async thread isolation, double-buffered state with dirty tracking, and KĀDI agent protocol for external tool invocation.
Game
Daemon Windows
A multi-window action game that turns the Windows desktop into a shrinking battlefield, with wave-based survival, a shop upgrade system, and real Win32 windows as game objects.