This project demonstrates a modular spell system that I designed and implemented in a custom C++ engine.
The goal of the system was to create a flexible way to define gameplay behavior by composing spells from reusable components.
Spells are constructed by combining a small set of reusable components such as emission type, form, and behavior, enabling a wide range of outcomes from a small set of building blocks.
Each component is responsible for a single aspect of the spell, allowing behaviors to be composed rather than hardcoded. This makes it possible to create a wide range of gameplay outcomes while keeping the system scalable and maintainable.
The system is designed to shift gameplay from predefined abilities to player-driven composition, allowing different combinations of components to create varied and emergent interactions. This enables designers to build mechanics such as puzzle-solving through spell configuration, rather than relying on fixed behaviors.
A spell consists of 4 components: Emission, Form, Element and hit component.
Emission:
Emission decides how the spell will be cast, if it's a directional spell or an area spell
Emission
Form
Form dictates how the spell will look and act, Ball form is a moving entity that travels straight while Burst is as it's named a single burst from a static position.
Form
Element
Element is purely visual as it decides the color but if I had the time to continue developing the system I would add gameplay effects from elements such as lighting objects on fire or applying other effects on entities
Element
Hit
Hit is what should happen when the spell is finished, if you should cast a new spell or nothing else happens, the spell simply ends
Hit
When casting a spell I gather all the data from the components and build a constructed spell struct. Then I use the struct to customize the particle system and spell entity behaviour.
{
Entity entity(entityID);
Spells::ConstructedSpellData spellData;
spellData.spellForm = entity.AccessOrAddComponent<SpellFormComponent>()->data;
spellData.element = entity.AccessOrAddComponent<SpellElementComponent>()->elementType;
auto* castSpellComp = entity.AccessOrAddComponent<CastSpellComponent>();
auto* hitEffectComp = entity.AccessOrAddComponent<SpellHitComponent>();
auto* spellEmissionComponent = entity.AccessOrAddComponent<SpellEmissionComponent>();
switch (spellEmissionComponent->emissionType)
{
case Spells::SpellEmissionType::Dir:
{
spellData.origin = castSpellComp->origin;
if (castSpellComp->target.has_value())
{
spellData.emissionType = Spells::SpellEmissionType::Dir;
spellData.dirOrRadius = (castSpellComp->target.value() - castSpellComp->origin).GetNormalized();
}
else
{
spellData.dirOrRadius = Tga::Vector3f{0.f, 0.f, 1.f};
}
break;
}
case Spells::SpellEmissionType::Area:
{
spellData.emissionType = Spells::SpellEmissionType::Area;
if (castSpellComp->target.has_value())
{
spellData.origin = castSpellComp->target.value();
}
spellData.dirOrRadius = 1.f;
break;
}
default:
{
break;
}
}
spellData.spellHitEffect = hitEffectComp->spellHitEffectType;
switch (spellData.spellHitEffect)
{
case Spells::SpellHitEffect::Cast:
spellData.reCastData = hitEffectComp->reCastData;
break;
case Spells::SpellHitEffect::Nothing:
break;
}
if (spellData.emissionType == Spells::SpellEmissionType::Area && spellData.spellForm.formType == Spells::SpellForm::Ball)
{
constexpr std::array dirs{
Tga::Vector3f{0.f, 0.f, 1.f},
//F
Tga::Vector3f{0.5f, 0.f, 0.5f},
//FR
Tga::Vector3f{1.f, 0.f, 0.f},
//R
Tga::Vector3f{0.5f, 0.f, -0.5f},
//RD
Tga::Vector3f{0.f, 0.f, -1.f},
//D
Tga::Vector3f{-0.5f, 0.f, -0.5f},
//DL
Tga::Vector3f{-1.f, 0.f, 0.f},
//L
Tga::Vector3f{-0.5f, 0.f, 0.5f} //FL
};
for (auto& dir : dirs)
{
spellData.dirOrRadius = dir;
CastSpell(spellData);
}
entity.RemoveComponent<CastSpellComponent>();
return;
}
CastSpell(spellData);
entity.RemoveComponent<CastSpellComponent>();
}
For the visuals I used a Particle system made by myself integrated with the ECS architecture.
Currently every particle is taken from a particle pool and has their data reset within the particle comp when reaquired. If I had more time to work on this I would want to move the particles to be GPU based instead of CPU based as they are currently
struct ParticleHotData
{
Tga::Vector3f velocity;
float speed;
float age;
float lifetime;
ParticleFlags flags;
};
struct ParticleColdData
{
Gradient colorOverLifetime;
Timeline sizeOverLifetime;
Timeline speedOverLifetime;
Tga::Vector3f originalSize;
std::function<void()> myDeathCallback;
};
Entity particleEntity(aParticle);
auto* particleComponent = particleEntity.AccessComponent<ParticleComponent>();
ParticleHotData& particleHotData = particleComponent->hotData;
{
particleHotData.age += aDeltaTime;
if (particleHotData.age >= particleHotData.lifetime)
{
particleComponent->coldData.myDeathCallback();
return false;
}
}
//Movement
{
Tga::TRSf& trs = particleEntity.AccessComponent<TransformComponent>()->AccessTRS();
trs.Translate(particleHotData.velocity * aDeltaTime);
}
if (IsFlagSet(particleHotData.flags, ParticleFlags_SizeOverLife))
{
Tga::TRSf& trs = particleEntity.AccessComponent<TransformComponent>()->AccessTRS();
const float age = particleHotData.age;
const float lifeTime = particleHotData.lifetime;
const float percentage = age/lifeTime;
auto sizeMod = particleComponent->coldData.sizeOverLifetime.CalcValue(percentage);
trs.SetScale(particleComponent->coldData.originalSize * sizeMod);
}
if (IsFlagSet(particleHotData.flags, ParticleFlags_ColorOverLife))
{
const float age = particleHotData.age;
const float lifeTime = particleHotData.lifetime;
const float percentage = age / lifeTime;
const Tga::Vector4f color = particleComponent->coldData.colorOverLifetime.GetColorFromTable(percentage);
particleEntity.AccessComponent<ModelComponent>()->color = Tga::Color{ color.x, color.y, color.z, color.w };
}