IronHammer started as a question: what does a game engine actually look like under the hood?
I have experience working with commercial engines like Unity and Unreal. While I can use them comfortably and work with their built in systems, I always wanted to understand what is actually happening beneath the surface. It is easy to attach a collider component and have collision magically work, but what is that magic, exactly? This project is my attempt to find out by building those systems myself from scratch.
The result is a complete 2D game engine and editor built without any framework or scaffolding, only libraries for windowing, graphics, and UI. Every architectural decision had to be made deliberately, from how thousands of game objects are stored in memory, to how the editor converts between three different coordinate systems. The engine is still actively in development. This page is a record of the systems built so far and the thinking behind them.
The ECS is the foundation that everything else is built on. Rather than storing game objects as individual classes scattered across memory, IronHammer groups entities that share the same set of components together in one contiguous block, an archetype. Within each archetype, all transforms are packed together, all sprites are packed together, and so on.
This layout, known as Structure of Arrays, means that iterating 10,000 entities to move them is a single sequential read through memory, rather than 10,000 random jumps between objects. Modern CPUs are designed to handle sequential reads efficiently, so the difference in performance is significant and measurable.
ArchetypeA
{Transform, Sprite, RigidBody}
Chunk size of 64 | Chunks | Chunk0 | Chunk1 | ...
| Entites | E0 | E1 | E2 | ... | E63 | E64 | E65 | E66 | ... | E127 | ...
CTransform Allocator -> | CTransform | T0 | T1 | T2 | ... | T63 | T64 | T65 | T66 | ... | T127 | ...
CSprite Allocator -> | CSprite | S0 | S1 | S2 | ... | S63 | S64 | S65 | S66 | ... | S127 | ...
CRigidBody Allocator -> | CRigidBody | R0 | R1 | R2 | ... | R63 | R64 | R65 | R66 | ... | R127 | ...
class Archetype
{
std::vector<ArchetypeChunk> m_chunks;
std::vector<TypeErasedBlockAllocator> m_allocators;
uint16_t m_densIds[MaxComponents];
uint16_t m_sparse[MaxComponents];
ArchetypeId m_archetypeId;
std::string m_archetypeName;
ComponentSignatureMask m_componentSignature;
size_t m_chunkCapacity = 0;
size_t m_totalSize = 0;
}
struct ArchetypeChunk
{
std::vector<void*> components;
uint16_t densIds[MaxComponents];
uint16_t sparse[MaxComponents];
std::vector<Entity> entities;
size_t capacity = 0;
size_t size = 0;
}
struct CTransform
{
Vect2f position = Vect2f(0, 0);
Vect2f scale = Vect2f(1, 1);
float rotation = 0.f;
static constexpr const char* name = "Transform";
REGISTER_COMPONENT(CTransform);
void Reset()
{
position = Vect2f(0, 0);
scale = Vect2f(1, 1);
rotation = 0.f;
}
};
struct CSprite
{
const sf::Texture* texture = nullptr;
std::string textureName = "";
Vect2f size = Vect2f(32, 32);
sf::IntRect textureRect = sf::IntRect({0, 0}, {32, 32});
sf::Color color = sf::Color::White;
static constexpr const char* name = "Sprite";
REGISTER_COMPONENT(CSprite);
void Reset()
{
textureName = "";
texture = nullptr;
size = Vect2f(32, 32);
textureRect = sf::IntRect({ 0, 0 }, { 32, 32 });
color = sf::Color::White;
}
}
// Find matching archetypes using a query
spriteQuery = newWorldPtr->Query<RequiredComponents<CSprite, CTransform>, ExcludedComponents<CNotDrawable>>();
// Use a loop to go through every component column
for (auto& archetype : spriteQuery->GetMatchingArchetypes())
{
for (auto& chunk : archetype->GetChunks())
{
auto sprites = chunk.GetComponentRow<CSprite>();
auto transforms = chunk.GetComponentRow<CTransform>();
for (size_t i = 0; i < chunk.size; ++i)
{
// Process sprites[i] and transforms[i]
}
}
}
Most programs rely on the operating system to hand out and reclaim memory, a process that works well in general but has measurable overhead when done thousands of times per frame. IronHammer implements three purpose built allocators that pre-claim large blocks of memory up front and manage them internally, keeping allocations fast and predictable.
SlabSize objects of type T and
tracks free slots using a 64-bit integer bitset —
finding the next available slot reduces to a single CPU
instruction (std::countr_zero).
// Free slots tracked as bits: 1 = free, 0 = used.
size_t FindFirstFreeBit()
{
if constexpr (wordCount == 1)
{
if (bitSet.single != 0)
return std::countr_zero(bitSet.single);
}
else
{
for (size_t i = 0; i < wordCount; ++i)
{
if (bitSet.multiple[i] != 0)
return (i * 64) + std::countr_zero(bitSet.multiple[i]);
}
}
}
T* Allocate()
{
if (currentSlabIndex == SIZE_MAX || slabs[currentSlabIndex].freeCount == 0)
{
currentSlabIndex = FindFreeSlab();
if (currentSlabIndex == SIZE_MAX)
{
slabs.emplace_back();
currentSlabIndex = slabs.size() - 1;
}
}
auto& slab = slabs[currentSlabIndex];
size_t freeIndex = slab.FindFirstFreeBit();
slab.SetBitToZero(freeIndex);
--slab.freeCount;
return &slab.blocks[freeIndex];
}
void DeAllocate(T* ptr)
{
auto [slabIndex, blockIndex] = LocateBlock(ptr);
slabs[slabIndex].ResetBitToOne(blockIndex);
++slabs[slabIndex].freeCount;
currentSlabIndex = slabIndex;
}
struct Slot
{
Slot* nextFreeSlot;
alignas(64) char data[1];
};
void* InternalAllocate()
{
if (m_freeList == nullptr) AllocateNewSlab();
Slot* slot = m_freeList;
m_freeList = m_freeList->nextFreeSlot;
return slot->data;
}
void InternalDeallocate(void* ptr)
{
Slot* slot = reinterpret_cast<Slot*>(
reinterpret_cast<char*>(ptr) - offsetof(Slot, data));
slot->nextFreeSlot = m_freeList;
m_freeList = slot;
}
template <typename T, typename... Args>
T* Allocate(Args&&... args)
{
void* memory = InternalAllocate();
return new (memory) T(std::forward<Args>(args)...);
}
template <typename T>
void Deallocate(T* obj)
{
obj->~T();
InternalDeallocate(obj);
}
// 64-byte aligned blocks — one per component array per chunk.
void AllocateNewBlock()
{
void* newBlock = operator new(
m_dataPerBlock * m_dataSize,
std::align_val_t(64)
);
m_allBlocks.push_back(newBlock);
m_freeBlocks.push_back(newBlock);
}
void* AllocateBlock()
{
if (m_freeBlocks.empty()) AllocateNewBlock();
void* freeBlock = m_freeBlocks.back();
m_freeBlocks.pop_back();
return freeBlock;
}
void DeallocateBlock(void* ptr)
{
m_freeBlocks.push_back(ptr);
}
The physics system runs in three separate stages each frame. Splitting it this way keeps each stage focused and independently testable — a design decision that paid off significantly during debugging.
// Each entity registers into every grid cell its bounds touch.
// Only entities sharing a cell become collision candidates.
for (size_t i = 0; i < chunk.size; ++i)
{
CCollider& col = colliders[i];
CTransform& tr = transforms[i];
Vect2<int> topLeft = ((tr.position + col.offset - col.halfSize) / m_cellSize).Floor();
Vect2<int> botRight = ((tr.position + col.offset + col.halfSize) / m_cellSize).Ceil();
for (size_t r = topLeft.y; r < botRight.y; ++r)
{
for (size_t c = topLeft.x; c < botRight.x; ++c)
{
m_grid[r * m_cellPerRow + c].overlapingEntities.push_back(chunk.entities[i]);
}
}
}
// For each two collision candidate pair (e1, e2)
Vect2f e1Center = e1Transform->position + e1Collider->offset;
Vect2f e2Center = e2Transform->position + e2Collider->offset;
Vect2f distance = e2Center - e1Center;
Vect2f distanceAbs = distance.Abs();
bool xCollide = distanceAbs.x <= (e1Collider->halfSize.x + e2Collider->halfSize.x);
bool yCollide = distanceAbs.y <= (e1Collider->halfSize.y + e2Collider->halfSize.y);
if (xCollide && yCollide)
{
Vect2f overlap = (e1Collider->halfSize + e2Collider->halfSize) - distanceAbs;
Vect2f lastFrameE1Center = e1Rb->previousPosition + e1Collider->offset;
Vect2f lastFrameE2Center = e2Rb->previousPosition + e2Collider->offset;
Vect2f lastFrameDistance = lastFrameE2Center - lastFrameE1Center;
bool lastXCollide = std::abs(lastFrameDistance.x) <= (e1Collider->halfSize.x + e2Collider->halfSize.x);
bool lastYCollide = std::abs(lastFrameDistance.y) <= (e1Collider->halfSize.y + e2Collider->halfSize.y);
Vect2f normal;
float penetration;
if (lastXCollide && !lastYCollide)
{
normal = (distance.y < 0) ? Vect2f(0, -1) : Vect2f(0, 1);
penetration = overlap.y;
}
else if (!lastXCollide && lastYCollide)
{
normal = (distance.x < 0) ? Vect2f(-1, 0) : Vect2f(1, 0);
penetration = overlap.x;
}
else
{
if (overlap.x < overlap.y)
{
normal = (distance.x < 0) ? Vect2f(-1, 0) : Vect2f(1, 0);
penetration = overlap.x;
}
else
{
normal = (distance.y < 0) ? Vect2f(0, -1) : Vect2f(0, 1);
penetration = overlap.y;
}
}
m_collisionDataVector.emplace_back(e1, e2, normal, penetration);
}
Yellow cells show potential collision. Red cells show confirmed collision.
At the beginning of this project the one area where I felt most lacking was graphics programming. I decided to use SFML's graphics module, which is built on top of OpenGL, to handle simple rendering of sprites, shapes, lines, and text. The key point is to ensure efficient rendering when entity count grows and draw calls increase. The rendering system avoids issuing one draw call per entity by accumulating all geometry into a single vertex array and flushing it in one call, or as few calls as possible. For sprites, a new flush only happens when the texture changes.
Sprite geometry is computed manually per entity: rotation is applied using a precomputed cosine/sine pair, UV coordinates are mapped from each sprite's texture rectangle, and the result is written directly into the batch. No intermediate objects, no allocations per frame.
70,000 sprites every frame — 15–20 FPS
70,000 sprites every frame — 50–60 FPS
// Sprites are batched together as long as they share a texture.
// A draw call is only issued when the texture changes.
const sf::Texture* currentTexture = nullptr;
for (auto& archetype : spriteQuery->GetMatchingArchetypes())
for (auto& chunk : archetype->GetChunks())
{
auto sprites = chunk.GetComponentRow<CSprite>();
auto transforms = chunk.GetComponentRow<CTransform>();
for (size_t i = 0; i < chunk.size; ++i)
{
if (currentTexture != sprites[i].texture)
{
renderTarget.draw(batch, currentTexture); // flush
batch.clear();
currentTexture = sprites[i].texture;
}
AddSpriteToBatch(sprites[i], transforms[i], batch);
}
}
renderTarget.draw(batch, currentTexture); // final flush
void RenderSystem::AddSpriteToBatch(const CSprite& csprite, const CTransform& ctransform, sf::VertexArray& batch)
{
float width = csprite.size.x * ctransform.scale.x;
float height = csprite.size.y * ctransform.scale.y;
float halfWidth = width / 2.0f;
float halfHeight = height / 2.0f;
Vect2f center = ctransform.position;
float rad = ctransform.rotation * (float)M_PI / 180.0f;
float cosRad = std::cos(rad);
float sinRad = std::sin(rad);
auto Rotate = [&](float x, float y) {
return sf::Vector2f(
center.x + x * cosRad - y * sinRad,
center.y + x * sinRad + y * cosRad);
};
sf::Vector2 topLeft = Rotate(-halfWidth, -halfHeight);
sf::Vector2 topRight = Rotate( halfWidth, -halfHeight);
sf::Vector2 bottomRight = Rotate( halfWidth, halfHeight);
sf::Vector2 bottomLeft = Rotate(-halfWidth, halfHeight);
float u1 = csprite.textureRect.position.x;
float v1 = csprite.textureRect.position.y;
float u2 = csprite.textureRect.position.x + csprite.textureRect.size.x;
float v2 = csprite.textureRect.position.y + csprite.textureRect.size.y;
batch.append(sf::Vertex(topLeft, csprite.color, sf::Vector2f(u1, v1)));
batch.append(sf::Vertex(topRight, csprite.color, sf::Vector2f(u2, v1)));
batch.append(sf::Vertex(bottomRight, csprite.color, sf::Vector2f(u2, v2)));
batch.append(sf::Vertex(topLeft, csprite.color, sf::Vector2f(u1, v1)));
batch.append(sf::Vertex(bottomRight, csprite.color, sf::Vector2f(u2, v2)));
batch.append(sf::Vertex(bottomLeft, csprite.color, sf::Vector2f(u1, v2)));
}
When something goes wrong in a game, knowing exactly where in the code it happened saves hours of debugging. IronHammer's logger captures a full stack trace on every log call. The problem is that translating raw memory addresses into readable function names and file paths is extremely slow. Doing it on the game thread would cause a noticeable frame drop on every log.
The solution is to split the work across two threads. The game thread captures the raw trace instantly and pushes it to a queue. A dedicated background thread picks it up, resolves the symbols at its own pace, and places the finished result in a buffer. At the end of each frame, the main thread moves everything from the buffer into the log window.
// The logger thread sleeps until work arrives, resolves
// the expensive stack trace off the game thread, then
// writes the finished result to the buffer queue.
m_loggerThread = std::thread([]()
{
while (true)
{
std::unique_lock lock(m_pendingLogsMutex);
m_cv.wait(lock, [] {
return !m_pendingLogMesssageQueue.empty() || !m_loggerRunning; });
if (!m_loggerRunning && m_pendingLogMesssageQueue.empty()) break;
while (!m_pendingLogMesssageQueue.empty())
{
PendingLogMessage msg = std::move(m_pendingLogMesssageQueue.front());
m_pendingLogMesssageQueue.pop();
lock.unlock();
// Expensive — fully off the game thread
auto traces = ResolveStackTrace(msg.stackTrace);
{
std::lock_guard bufLock(m_logMessagesMutex);
m_logMessageQueueBuffer.emplace_back(
msg.message, msg.time, traces, msg.color, msg.logType);
}
lock.lock();
}
}
});
#define LOG_INFO(msg) Debug::Log(msg, Grey, LogType::Info);
#define LOG_WARNING(msg) Debug::Log(msg, Yellow, LogType::Warning);
#define LOG_ERROR(msg) Debug::Log(msg, Red, LogType::Error);
// Usage:
LOG_INFO("Scene loaded successfully");
LOG_WARNING("Entity has no Transform component");
LOG_ERROR("Failed to open file: " + path);
IronHammer ships with a fully integrated editor built using Dear ImGui. The goal was a complete authoring environment where game worlds can be built, simulated, modified, and saved without leaving the application. All editor panels share a single context object — no global state, no singletons, passed by reference to every panel that needs it.
// Three coordinate systems must be bridged correctly:
// World space (Y up), texture pixels (Y down), screen (Y down).
inline static ImVec2 WorldToViewportGui(const Vect2f& worldPos)
{
// World → texture pixel (Y flip handled by SFML view)
sf::Vector2i texPixel = m_renderTexture->mapCoordsToPixel({ worldPos.x, worldPos.y });
sf::Vector2u texSize = m_renderTexture->getSize();
float scaleX = m_viewportImageDrawSize.x / (float)texSize.x;
float scaleY = m_viewportImageDrawSize.y / (float)texSize.y;
return ImVec2(
m_viewportImagePos.x + (float)texPixel.x * scaleX,
m_viewportImagePos.y + m_viewportImageDrawSize.y
- (float)texPixel.y * scaleY);
}
{
"Transform": {
"position": { "x": 0, "y": 0 },
"scale": { "x": 1, "y": 1 },
"rotation": 0
},
"Sprite": {
"textureName": "Square",
"size": [32, 32],
"color": [55, 90, 161, 255]
},
"Collider": {
"size": { "x": 32, "y": 32 },
"isTrigger": false
},
"RigidBody": {
"mass": 10,
"bounciness": 0.1,
"isStatic": false
},
"Enemy": {}
}