Ever since I started developing games, I’ve mostly worked with game engines to bring them to life. However, I’ve always wanted to understand the fundamentals of how engines work behind the scene.How objects are rendered, how collisions are detected, and what drives the core game loop. I started this small project to explore the backend of game engines and aim to eventually build a simple engine of my own using C++, the SFML library, and ImGui for UI handling. I also had prior exposure to ECS (Entity-Component-System) architecture, and this project provided the perfect opportunity to implement a basic ECS system from scratch.
While planning my approach to the game and the tools I would need, I quickly realized I needed a way to interact with various components of the computer. Using SFML provided a simple and effective interface to handle tasks such as rendering objects, playing sounds, registering input, and more. This allowed me to provide my small engine with core modules that could be reused across multiple systems. Similarly, I wanted a configurable UI system to display information to the player and assist with debugging. That’s why I chose ImGui, to handle both UI functionality and in-game debugging efficiently.
void Game::SUserInput()
{
if (!Player()) return;
auto& inputC = Player()->Get();
while (const auto event = m_window.pollEvent())
{
ImGui::SFML::ProcessEvent(m_window, *event);
if (event->is())
{
m_running = false;
m_window.close();
}
else if (const auto* keyPressed = event->getIf())
{
switch (keyPressed->scancode)
{
case sf::Keyboard::Scancode::W:
inputC.up = true;
break;
case sf::Keyboard::Scancode::S:
inputC.down = true;
break;
case sf::Keyboard::Scancode::A:
inputC.left = true;
break;
case sf::Keyboard::Scancode::D:
inputC.right = true;
break;
default:
break;
}
}
else if (const auto* keyReleased = event->getIf())
{
switch (keyReleased->scancode)
{
case sf::Keyboard::Scancode::W:
inputC.up = false;
break;
case sf::Keyboard::Scancode::S:
inputC.down = false;
break;
case sf::Keyboard::Scancode::A:
inputC.left = false;
break;
case sf::Keyboard::Scancode::D:
inputC.right = false;
break;
default:
break;
}
}
else if (const auto* mousePressed = event->getIf())
{
if (mousePressed->button == sf::Mouse::Button::Left)
{
inputC.shoot = true;
SpawnBullet(Player(), Vec2f(mousePressed->position.x, mousePressed->position.y));
}
}
else if (const auto* mouseReleased = event->getIf())
{
if (mouseReleased->button == sf::Mouse::Button::Left)
{
inputC.shoot = false;
}
}
}
}
sf::RenderWindow m_window;
void Game::SRender()
{
m_window.clear();
if (Player())
{
Player()->Get().circle.setPosition(Player()->Get().pos);
Player()->Get().angle += 1.0f;
Player()->Get().circle.setRotation(sf::degrees(Player()->Get().angle));
m_window.draw(Player()->Get().circle);
}
for (auto& bullet : m_entities.GetEntities("Bullet"))
{
auto& shape = bullet->Get();
auto& lifeSpan = bullet->Get();
auto& transform = bullet->Get();
if (lifeSpan.lifeSpan > 0)
{
uint8_t alpha = static_cast((lifeSpan.remaining * 255) / lifeSpan.lifeSpan);
sf::Color fillColor = shape.circle.getFillColor();
sf::Color outColor = shape.circle.getOutlineColor();
fillColor.a = alpha;
outColor.a = alpha;
shape.circle.setFillColor(fillColor);
shape.circle.setOutlineColor(outColor);
}
shape.circle.setPosition(transform.pos);
m_window.draw(shape.circle);
}
for (auto& enemy : m_entities.GetEntities("Enemy"))
{
enemy->Get().circle.setPosition(enemy->Get().pos);
enemy->Get().angle += 1.0f;
enemy->Get().circle.setRotation(sf::degrees(enemy->Get().angle));
m_window.draw(enemy->Get().circle);
}
for (auto& sEnemy : m_entities.GetEntities("SmallEnemy"))
{
auto& lifeSpan = sEnemy->Get();
auto& transform = sEnemy->Get();
auto& shape = sEnemy->Get();
if (lifeSpan.lifeSpan > 0)
{
uint8_t alpha = static_cast((lifeSpan.remaining * 255) / lifeSpan.lifeSpan);
sf::Color fillColor = shape.circle.getFillColor();
sf::Color outColor = shape.circle.getOutlineColor();
fillColor.a = alpha;
outColor.a = alpha;
shape.circle.setFillColor(fillColor);
shape.circle.setOutlineColor(outColor);
}
transform.angle += 1.0f;
shape.circle.setRotation(sf::degrees(sEnemy->Get().angle));
shape.circle.setPosition(transform.pos);
m_window.draw(sEnemy->Get().circle);
}
ImGui::SFML::Render(m_window);
m_window.display();
}
void Game::SGUI()
{
ImGui::Begin("Shape Wars");
ImGuiTabBarFlags tab_bar_flags = ImGuiTabBarFlags_None;
if (ImGui::BeginTabBar("MyTabBar", tab_bar_flags))
{
if (ImGui::BeginTabItem("Game"))
{
ImGui::Text("%s%i", "Score:", m_score);
int clicked = 0;
if (ImGui::Button("Replay")) clicked++;
if (clicked & 1)
{
if (Player()) Player()->Destroy();
SResetGame();
clicked = 0;
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Entities"))
{
int btnId = 0;
if (ImGui::CollapsingHeader("Entities by Tags"))
{
for (auto& [tag, entityVec] : m_entities.GetEntityMap())
{
std::string headerName = tag;
if (ImGui::CollapsingHeader(headerName.c_str()))
{
bool isDestroyed = false;
for (auto& e : entityVec)
{
//Delete btn
static int clicked = 0;
isDestroyed = false;
sf::Color shapeColor = e->Get().circle.getFillColor();
ImGui::PushID(btnId);
btnId++;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(static_cast(shapeColor.r) / 255, static_cast(shapeColor.g) / 255, static_cast(shapeColor.b) / 255, 1.0f));
if (ImGui::Button("D")) isDestroyed = true;
ImGui::PopStyleColor();
ImGui::PopID();
ImGui::SameLine();
//ID
ImGui::Text("%i", e->Id());
ImGui::SameLine();
//Position
ImGui::Text("(%.2f,%.2f)", e->Get().pos.x, e->Get().pos.y);
if (isDestroyed) e->Destroy();
}
}
}
}
if (ImGui::CollapsingHeader("All Entities"))
{
bool isDestroyed = false;
for (auto& e : m_entities.GetEntities())
{
//Delete btn
static int clicked = 0;
isDestroyed = false;
sf::Color shapeColor = e->Get().circle.getFillColor();
ImGui::PushID(btnId);
btnId++;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(static_cast(shapeColor.r) / 255, static_cast(shapeColor.g) / 255, static_cast(shapeColor.b) / 255, 1.0f));
if (ImGui::Button("D")) isDestroyed = true;
ImGui::PopStyleColor();
ImGui::PopID();
ImGui::SameLine();
//ID
ImGui::Text("%i", e->Id());
ImGui::SameLine();
//Tag
ImGui::Text("%s", e->Tag().c_str());
ImGui::SameLine();
//Position
ImGui::Text("(%.2f,%.2f)", e->Get().pos.x, e->Get().pos.y);
if (isDestroyed) e->Destroy();
}
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ImGui::End();
}
Entity Component System (ECS) is an architectural pattern that allows for flexible and efficient management of game objects. It breaks down into three parts:
using ComponentTuple = std::tuple<
CTransform,
CShape,
CCollision,
CScore,
CLifeSpan,
CInput
>;
class Entity
{
friend class EntityManager;
ComponentTuple m_components;
bool m_active = true;
std::string m_tag = "default";
size_t m_id = 0;
Entity(const size_t& id, const std::string &tag)
:m_tag(tag), m_id(id) { }
public:
bool IsActive() const
{
return m_active;
}
void Destroy()
{
m_active = false;
}
size_t Id() const
{
return m_id;
}
const std::string& Tag() const
{
return m_tag;
}
template
T& Get()
{
return std::get(m_components);
}
template
const T& Get() const
{
return std::get(m_components);
}
template
void Remove()
{
Get() = T();
}
template
T& Add(TArgs&&... mArgs)
{
auto& component = Get();
component = T(std::forward(mArgs)...);
component.exists = true;
return component;
}
template
bool Has() const
{
return Get().exists;
}
};
class EntityManager
{
EntityVec m_entities;
EntityVec m_entitiesToAdd;
std::map m_entityMap;
size_t m_totalEntities = 0;
void RemoveDeadEntities(EntityVec& vec)
{
vec.erase(std::remove_if(vec.begin(),vec.end(),
[](const auto& e) { return !e->IsActive(); }), vec.end());
}
public:
EntityManager() = default;
void Update()
{
for (auto& e : m_entitiesToAdd)
{
m_entities.push_back(e);
}
m_entitiesToAdd.clear();
RemoveDeadEntities(m_entities);
for (auto& [tag, entityVec] : m_entityMap)
{
RemoveDeadEntities(m_entityMap[tag]);
}
}
std::shared_ptr AddEntity(const std::string& tag)
{
auto entity = std::shared_ptr(new Entity(m_totalEntities++, tag));
m_entitiesToAdd.push_back(entity);
if (m_entityMap.find(tag) == m_entityMap.end())
{
m_entityMap[tag] = EntityVec();
}
m_entityMap[tag].push_back(entity);
return entity;
}
const EntityVec& GetEntities()
{
return m_entities;
}
const EntityVec& GetEntities(const std::string& tag)
{
if (m_entityMap.find(tag) == m_entityMap.end())
{
m_entityMap[tag] = EntityVec();
}
return m_entityMap[tag];
}
const std::map& GetEntityMap()
{
return m_entityMap;
}
};
class Component
{
public:
bool exists = false;
};
class CTransform : public Component
{
public:
Vec2f pos = { 0.0,0.0 };
Vec2f velocity = { 0.0,0.0 };
float angle = 0;
float speed = 0;
CTransform() = default;
CTransform(const Vec2f& p, const Vec2f& v, float a, float s) :pos(p), velocity(v), angle(a), speed(s){ }
};
class CShape : public Component
{
public:
sf::CircleShape circle;
CShape() = default;
CShape(float radius, size_t points, const sf::Color& fillColor, const sf::Color& outlineColor, float outlineThickness)
:circle(radius, points)
{
circle.setFillColor(fillColor);
circle.setOutlineColor(outlineColor);
circle.setOutlineThickness(outlineThickness);
circle.setOrigin({ radius, radius });
}
};
class CCollision : public Component
{
public:
float radius = 0;
CCollision() = default;
CCollision(float r): radius(r) {}
};
class CScore : public Component
{
public:
int score = 0;
CScore() = default;
CScore(int s) : score(s) {}
};
class CLifeSpan : public Component
{
public:
int lifeSpan = 0;
int remaining = 0;
CLifeSpan() = default;
CLifeSpan(int totalLifeSpan) : lifeSpan(totalLifeSpan), remaining(totalLifeSpan){}
};
class CInput : public Component
{
public:
bool up = false;
bool down = false;
bool right = false;
bool left = false;
bool shoot = false;
CInput() = default;
};
void Game::Run()
{
while (m_running)
{
m_entities.Update();
//Update UI
ImGui::SFML::Update(m_window, m_clock.restart());
////Systems
SEnemySpawner();
SMovement();
SCollision();
SUserInput();
SLifeSpan();
SGUI();
SRender();
////
m_currentFrame++;
}
}