A data driven composable combat framework for Unity.
Build weapons, abilities, and status effects in the inspector.
- Everything is built in the inspector. Build anything with a variety of combinations to suit whatever you need.
- Many built-in building blocks. 22 payloads, 9 query types, 13 conditions, 9 value scalers, 10 damage steps, 5 firing modes, 5 resource regen rules, multiple status effects and triggers.
- Easy to add custom types. Implement one interface and add a single attribute to be able to choose your custom types in the inspector.
- Zero garbage on hot paths. Per frame, enforced by allocation tests that fail builds.
- Many automated tests. EditMode tests, PlayMode tests, and performance suites.
- Headless design.
- Overview
- Installation
- Core Concepts
- Building in the Inspector
- Custom Implementations
- Integrating Yourself
- Design Decisions
- Performance
- Testing
- Roadmap
- Known Limitations
CG Combat Framework is the rules for the combat in your game. Who can be hit, how much damage to do, what reacts to what. This is meant to be usable in a variety of games, and is not built for any one type.
This is built to not include movement, animations, UI, audio or netcode. This allows users to be able to hook into any system using built-in seams, states, events, and interfaces.
- Open Window -> Package Manager.
- Click + -> Install package from git URL.
- Paste the URL of the desired version like:
https://github.com/Contradictive-Games/CGCombatFramework.git#v0.2.0
Replace the tag at the end with the latest version from the Releases page.
Requirements:
- Unity 6000.0+
- Unity physics module
General vocabulary, what things are, etc.
A unit is any GameObject with a Unit component. I.e: players, barrel, boss, turrets, invisible fire zone, etc. What a unit can do comes from their components. UnitComponents include: stats, resources (like health, mana), status effects, loadouts, etc. A barrel could be a unit with a HealthComponent and nothing else.
Components can be acquired like:
if(unit.TryGet<T>(out var T)){ /* do whatever */ }These are not required to be an IUnitComponent
Units are pooled. Each unit life gets a new UnitId and a UnitHandle. A UnitHandle refers to one life, so that references held across a despawn do not hit recycled Units on new lives.
Everything a designed could make is a definition. This is a ScriptableObject that is shared and never changes at runtime. Per-owner state is created into small runtime objects (like cooldown timers, magazines, stacks). Two Units can hold the same exact item, with their own indpendent state.
Most of a definition is made of strategies picked from dropdowns in the inspector. Each kind answers a question
| Strategy | Explanation | Examples |
|---|---|---|
| Fire Mode | How a Firable converts a pres into firing |
Semi auto, burst firing, channeled spell casts, Charged spell casts |
| Gate | If it can fire and what will it cost | Cooldowns (spell cooldowns, fire rates), ammo counts, resource costs (mana cost), charges, conditions |
| Emission | What will come out | A projectile, random, delayed |
| Query | Who will be hit | Frontal cones, raycasts, area of effects, wide box slashes, everyone, chain lightning |
| Conditions | What must pass to do X | Has tags (immune tag, poison tag), hit zones (headshots, weak points), Resource threshold (under x% health) |
| Payloads | What is going to happen to is hit | Deal damage, heal, apply status effects, knockback, consume stacks |
| Scaler | How big the final number is | Stat scalers (crit damage), power (charged attacks being higher at higher charge %), distance fallof (weaker near edge of explosion) |
Every gate is checked before anything is 'paid'. So an empty magazine can never waste a cooldown.
Each unit's health runs incoming damage through an ordered list of steps defined on the defender. Steps can be changed/removed freely in the inspector. Steps can also have custom implementations.
Important: order matters in this. Will start from list beginning -> end
A status effect is a timed bundle of effects. Effects are very powerful and can perform a lot of different things. Adding tags, stat modifiers, payloads. This can handle different use cases like class passive effects, elite affixes, burns, stuns, item bonuses, buffs, debuffs, HoTs, etc. Effects are stackable through refresh, adding duration, and independent stacks. Effects can stack per caster and are pooled.
Triggers are behaviors that listen to host's (whoever has the effect) while the effect is active.
A While Trigger keeps another effect applied while a specific set of conditions hold
Every unit comes equipped with an individual event bus. There is additionally one global event bus.Events are readonly structs passed by reference to prevent any allocations.
CombatServices holds all swappable services like the CombatTicker, random source (seeded or not), unit spawners, and unit registries.
Here are a few examples of weapons, abilities, and effects built entirely in the inspector using only what comes with the package.
|
|
| Pistol | Shotgun |
|
|
| Heal | Drain Life |
|
|
| Burn | Time Bomb |
Every dropdown in the package lists every class that implements its interface, in any assembly including your own. All you have to do is implement the interface, mark the class as Serializable and add a TypeMenu({context_menu_path_goes_here}) attribute.
The IPayload interface looks like:
public interface IPayload { public void Apply(in EffectContext ctx); }using CG.CombatFramework; /* use this namespace */
[System.Serializable, TypeMenu("Economy/Grant Gold")]
public sealed class GrantGoldPayload : IPayload
{
[UnityEngine.SerializeField] private ScalableValue amount = new(5f);
public void Apply(in EffectContext ctx){
var source = ctx.SourceUnit?.Root(); //credits all the way to the owner (i.e: turret kills)
if(source != null && source.TryGet<WalletComponent>(out var wallet)){
wallet.Add(Mathf.RoundToInt(amount.Evaluate(in ctx)));
}
}
}Select this payload in an OnKill trigger of an item. WalletComponent = your component.
Add your own custom damage step into the DamagePipeline in your inspector.
The IDamageStep interface looks like:
public interface IDamageStep { public void Process(ref DamageCalculation calc); }NOTE: The order is IMPORTANT. This computes the damage it has already calculated up to this step. If you want a critical strike to be taken into consideration after a damage increase from the backstab step, you would put the backstab step before the crit strike step.
[System.Serializable, TypeMenu("Attacker/Backstab")]
public sealed class BackstabkStep : IDamageStep {
[UnityEngine.SerializeField, UnityEngine.Min(1f)]
private float multiplier = 2f;
[UnityEngine.SerializeField, UnityEngine.Range(0f, 180f)]
private float behindAngle = 60f;
public void Process(ref DamageCalculation calc){
var attacker = calc.Request.Context.SourceUnit;
if(attacker == null) return;
var target = calc.TargetUnit.Transform;
Vector3 fromTarget = attacker.Transform.position - target.position;
if(Vector3.Angle(-target.forward, fromTarget) <= behindAndle * 0.5f) calc.Amount *= multiplier;
}
}Your own custom game events will ride the same event buses. Raise one, and a Trigger can react just as it does with a built-in event.
public readonly struct YourOnDashedEvent {
public readonly IUnit Unit;
public DashedEvent(IUnit unit) => Unit = unit;
}
// ...somewhere in your movement code you raise:
GameEvents.Raise(unit, new YourOnDashedEvent(unit));
// You create your own trigger
[Serializable, TypeMenu("Triggers/On Dash")]
public sealed class OnDashTrigger : TriggerBehavior<DashedEvent> {
protected override bool Read(in DashedEvent evt, StatusEffectInstance effect, out TriggerRead read){
read = new TriggerRead(null, default, 0f, 0); // nothing needed in this
return true;
}
}| Interface | Write when you want |
|---|---|
IFireMode |
A new firing type |
IFireGate |
A new cost for firing |
IHitQuery |
A new shape to check for enemies |
ICondition |
Any yes/no check |
IValueScaler |
Any number transformation |
IStatusEffectBehavior |
Anything that an effect can do over its lifetime |
IRegenRule |
How a resource will refill (or decay) |
IUnitComponent |
A new unit component with spawn/despawn/binding hooks |
Views implement IunitView and never search for a unit, something binds a unit to them.
- On a unit prefab (like for worldspace healthbars, nameplates), add a
UnitViewBinderto the prefab, and every view inside is bound/unbound on spawn/despawn - For units matching a rule (like for boss HUD bars, party frames);
UnitWatcherreports every matching unit already spawned then every one spawned later.
watcher = new UnitWatcher(u => u.Tags.Has(bossTag), onFound: ShowBossBar, onLost: HideBossBar);PlayerInput and AI functions will use the same four functions
loadout.Press(loadoutSlot);
loadout.Release(loadoutSlot);
loadout.Reload(loadoutSlot);
aimProvider.SetTarget(target); // or screenpointAI that implements the ITickable interface on the unit will be ticked by the CombatTicker, so it will pause when combat pauses.
- Unit reacts (like for hit flash, death animations, death sound), you can have a component on the prefab listening to the event bus on the unit.
- Visuals of a status effect (like flames on a burning enemy, poison cloud on poisoned enemy), you can have a behavior on the status effect attach to the prefab while it is active.
- Weapon cosmetics (like muzzle flash, impact sparks), you can write a custom
PlayVFXEmissionor aPlayVFXPayloadimplementing theIEmissionorIPayloadand add it onto the FirableDefinition in the Unity inspector.
Composition > inheritance. There is no single monolith Unit component that handles all sorts of things (health, weapons, status effects). Units are instead built through adding multiple components/capabilities. Every component lookup goes through a TryGet<T>, a dictionary access.
Strategy Pattern. Using [SerializeReference] allows for the strategy pattern to be extremely useful for designing everything in the inspector. Add your own custom Conditions, Payloads, Queries, etc and all you have to do is select that option in the dropdown of the item's ScriptableObject.
Handles for pooled objects. UnitHandle and the versioned StatusEffectHandle knows when a target's life has ended, preventing pooling from producing damaging incorrect enemies on recycled object bugs. This does require an extra ID check on every resolve, but is otherwise very useful to use.
Struct events passed by in.. No boxing/garbage when raising events with a per-unit bus or global bus.
One tick source. Combat times comes from a swappable ITickSource. That single hook gives the ability for combat-only pauses, fixed-step ticking, delayed effects that pause correctly, and determenistic tests.
Measured with performance tests. An allocation test found a real problem with a hot path that was using a List.Sort with a comparer in area queries. Adding performance tests allows me to ensure better performance/design standards.
All per-frame hot paths are covered by allocation tests that fail if they do allocate. This includes ticking, firing, damage pipeline, area queries, projectile flights.
Units, projectiles, and status effects, hit collections are all pooled.
Each system has a profiler marker (CG.Combat.*).
| Benchmark | Median | Per |
|---|---|---|
| Damage request through all 10 damage pipeline steps | 0.41µs | hit |
| Status effects, 1k units x 3 effects | 0.37ms | frame |
| Sphere delivery among 200 units (~20 hits) | 18µs | delivery |
| 100 projectiles in flight | 0.24ms | simulation step |
| 200 burning units, combat tick | ~1ms | frame |
| Suite | Tests | Covers |
|---|---|---|
| EditMode | ~110 | Stats, resources, regen, firables, gates, damage pipeline, status effects, triggers, value scalers, allocations |
| PlayMode | ~27 | Spawning, pooling, handles, real physics queries, projectiles, combat flows |
| Performance | ~14 | Benchmarks per system and whole frames |
Next
- More triggers (on healed, on fired, on effect applied)
- HeatFireGate w/ overheat lockout
- Netcode hooks (server-auth damage with replicated health/effects)
- Editor tooling
- Firable preview
- Damage pipeline debugger
- Better homing projectile targeting
- Single-threaded
- Homing projectiles find targets by scanning unit registry; large counts may cause performance spikes






