C# combat system implementation in Godot
- gamedev
- godot
- c#
< Placeholder >
More details coming soon ~
The basics
Looking up how others approach skills and abilities, you’ll often find resource-defined ability properties, a runtime ability object that initializes with that data, and various strategies of using that object to calculate combat interactions between entities. They often implement inheritance or composition patterns that can be edited in the Godot editor. It’s a good starting point: resources are a perfect fit for data-driven scalability, for starters. But most examples break down when you consider expanding beyond simple “activate ability > do damage” instances. How do you handle channelling or lasting effects? What if you want those lasting effects to alter abilities used within them while active? What if each pulsing effect requires finding new targets, or you don’t want to hit already-hit targets more than once? How about more complex status effects such as toggleable stances? What about advanced triggers for effects to chain only when certain conditions are met (such as ability type on activation (melee, fire, etc), when an entity reaches a certain health threshold, etc)? If an entity is to keep track of attributes and/or modifiers alongside statuses, how do you interface the ability system with all of that? What about character animations, how will you refine the “feel” of ability usage to match animations? How should you slow or lock a character while casting if rooting in place is required? What about projectiles, how might they be handled? Will all of the above be adaptable to adding networking functionality?
That’s a lot of questions, and there are a hundred more. Most of them potentially have quick and easy (and reasonable!) answers at first glance, such as adding new properties onto a Player script to keep track of a live combat environment, but most approaches I’ve seen quickly get ugly and scale horribly, at least as far as readability if not functionaly. I’d like to come up with my own framework for an expansive and modifiable ability system that can replicate anything you might see in Roguelikes like Shape of Dreams, ARPGs such as Path of Exile, or MMORPGs like Guild Wars 2.
So, back to the beginning - ability data. You need a way to define what an ability is and does. A new resource (which should allow being able to compose abilities within the Godot editor):
[GlobalClass]
public partial class AbilityDefinition : Resource
{
[ExportGroup("General")]
[Export] public StringName ID { get; private set; }
[Export] public string Name { get; private set; } = "New Ability";
[Export] public string Description { get; private set; } = "Ability description.";
[Export] public float CastRange { get; private set; } = 1.0f;
[ExportGroup("Execution")]
[Export] public Godot.Collections.Array<AbilityEffect> Effects { get; private set; }
[ExportGroup("Timing")]
[Export] public float CastTime { get; private set; } = 0f;
[Export] public float RecoveryTime { get; private set; } = 0f;
[Export] public float CooldownTime { get; private set; } = 1.0f;
[ExportGroup("Cast Behavior")]
[Export] public bool IsInterruptable { get; private set; } = true;
[Export] public bool IsCancellable { get; private set; } = true;
[Export] public float RootDuration { get; private set; } = 0f;
[ExportGroup("Presentation")]
[Export] public Texture2D Icon { get; private set; }
[Export] public StringName CastAnimationName { get; private set; }
[Export] public AudioStream CastSound { get; private set; }
}
Looks good - metadata, timing info, behavioral interactions, visuals/audio, and (I’ll get back to this) a list of effects that activate. It doesn’t yet have anything regarding activation costs, or combo chain information, that can come later. Now, there’s a list of effects to run, but they will need some context if they aren’t to just run in complete isolation of each other - a context per ability. An AbilityExecutionContext:
public class AbilityExecutionContext
{
public AbilityInstance AbilityInstance { get; }
public StringName SourceID { get; private set; }
public Node Caster { get; }
public Node SelectedTarget { get; }
public Node FocalTarget { get; set; }
public Vector3 FocalPosition { get; set; }
public List<Node> AcquiredTargets { get; private set; } = new();
public List<Node> PreviousTargets { get; private set; } = new();
public HashSet<Node> HitLog { get; private set; } = new();
public AbilityExecutionContext(AbilityInstance ability, Node caster, Node target)
{
AbilityInstance = ability;
SourceID = ability?.Definition.ID ?? default;
Caster = caster;
SelectedTarget = target;
FocalTarget = target;
FocalPosition = target is Node3D n3d
? n3d.GlobalPosition
: (caster as Node3D)?.GlobalPosition ?? Vector3.Zero;
}
public void UpdateTargets(AbilityEffect effect)
{
PreviousTargets = AcquiredTargets;
AcquiredTargets = effect.ResolveTargets(this);
}
public bool TryHit(Node target, bool ignore = false)
{
if (ignore) return true;
return HitLog.Add(target);
}
}
Now we can keep track of the ability caster, the selected target entity or location, the updated
targets for each effect activation, and a log of entities hit. AbilityInstance is just the runtime
representation of the definition data, SourceID is a convenient shortcut to the definition property,
and effect.ResolveTargets() will be explained shortly. Next, we have AbilityEffect:
public abstract partial class AbilityEffect : Resource
{
[Export] public TargetingStrategyDefinition TargetingStrategyDefinition { get; protected set; } = null;
[Export] public Godot.Collections.Array<AbilityEffect> SubEffects { get; protected set; } = new();
[Export] public bool IgnoreHitLog { get; protected set; } = false;
[Export] public bool ResetHitLog { get; protected set; } = false;
private TargetingStrategy targetingStrategy;
public TargetingStrategy GetOrCreateStrategy() =>
targetingStrategy ??= TargetingStrategyDefinition?.CreateRuntime() ?? new SelfTargetingStrategy();
public abstract Task ApplyAsync(AbilityExecutionContext context);
protected async Task ActivateSubEffectSequence(AbilityExecutionContext context)
{
foreach (var effect in SubEffects)
{
context.UpdateTargets(effect);
await effect.ApplyAsync(context);
}
}
public List<Node> ResolveTargets(AbilityExecutionContext context)
{
if (ResetHitLog) { context.HitLog.Clear(); }
return GetOrCreateStrategy().Acquire(context).ToList();
}
protected static ICombatEntity VerifyTarget(Node target)
{
if (!IsInstanceIdValid(target.GetInstanceId()))
{
GD.PrintErr("Target is not valid. It may have been freed from memory.");
return null;
}
if (target is not ICombatEntity entity)
{
GD.PrintErr($"Target {target.Name} does not implement ICombatEntity.");
return null;
}
return entity;
}
}
A couple new things here: targeting strategies will allow defining how each effect finds targets,
I’ll get back to that. Also, ICombatEntity is an interface that will contract all combat-ready
entities (so that they are known to have components for health, attributes, statuses, etc). I’m
sticking with Node as the base type for passing around before type/validity checks so that potential
expansion into 2D may be possible in the future, but it could also just be Node3D. Anyways,
AbilityEffect is an abstract class with abstract ApplyAsync(): inheriting effects just have to
implement this to be activated at cast time. Each effect also has its own list of sub-effects that
can fork on their own context. Finally, the last piece of this puzzle:
using static TargetingStrategy;
public enum TargetingMode
{
Single,
Area,
Cone,
Self,
Inherit
}
[GlobalClass]
public partial class TargetingStrategyDefinition : Resource
{
[Export] public TargetingMode Mode { get; private set; }
[ExportGroup("Area, Cone")]
[Export] public TargetPriority Priority { get; private set; } = TargetPriority.Auto;
[Export] public int MaxTargets { get; private set; }
[Export] public string TargetOverride { get; private set; }
[Export(PropertyHint.Layers3DPhysics)]
public uint CollisionMask { get; private set; } = 2;
[ExportGroup("Area")]
[Export] public float Radius { get; private set; }
[ExportGroup("Cone")]
[Export] public float Range { get; private set; }
[Export] public float Angle { get; private set; }
public TargetingStrategy CreateRuntime()
{
TargetingStrategy strategy = Mode switch
{
TargetingMode.Single => new SingleTargetingStrategy(),
TargetingMode.Area => new AreaTargetingStrategy(Radius, TargetOverride, CollisionMask),
TargetingMode.Cone => new ConeTargetingStrategy(Range, Angle, TargetOverride, CollisionMask),
TargetingMode.Inherit => new InheritTargetsStrategy(),
TargetingMode.Self => new SelfTargetingStrategy(),
_ => throw new NotImplementedException($"Unsupported targeting mode: {Mode}")
};
strategy.Priority = Priority;
strategy.MaxTargets = MaxTargets;
return strategy;
}
}
and
public abstract class TargetingStrategy
{
public enum TargetPriority
{
Auto,
NearestToCaster,
NearestToFocal,
Random
}
public TargetPriority Priority { get; set; } = TargetPriority.Auto;
public int MaxTargets { get; set; }
protected virtual TargetPriority DefaultPriority => TargetPriority.NearestToCaster;
public IEnumerable<Node> Acquire(AbilityExecutionContext context)
{
var results = AcquireTargets(context);
if (MaxTargets <= 0) return results;
TargetPriority effective = Priority == TargetPriority.Auto ? DefaultPriority : Priority;
return effective switch
{
TargetPriority.NearestToFocal => SortByDistance(results, context.FocalPosition).Take(MaxTargets),
TargetPriority.NearestToCaster => SortByDistance(results, (context.Caster as Node3D)?.GlobalPosition ?? Vector3.Zero).Take(MaxTargets),
TargetPriority.Random => Shuffle(results).Take(MaxTargets),
_ => results.Take(MaxTargets),
};
}
public abstract IEnumerable<Node> AcquireTargets(AbilityExecutionContext context);
private static IEnumerable<Node> SortByDistance(IEnumerable<Node> nodes, Vector3 origin)
=> nodes.OrderBy(n => n is Node3D n3d
? n3d.GlobalPosition.DistanceSquaredTo(origin)
: float.MaxValue);
private static IEnumerable<Node> Shuffle(IEnumerable<Node> nodes)
{
var list = nodes.ToList();
for (int i = list.Count - 1; i > 0; i--)
{
int j = (int)(GD.Randi() % (uint)(i + 1));
(list[i], list[j]) = (list[j], list[i]);
}
return list;
}
}
The strategy definition can be defined in-editor, and instantiates a strategy runtime. The abstract
base runtime defines abstract IEnumerable<Node> AcquireTargets(), which each targeting type will
implement with its own logic. Most of the above should be self-explanatory based on naming, but
concrete examples of the system playing out should answer further questions later on.
Tying it all together
There’s a definition resource for abilities, which contain metadata properties and a list of effects with associated targeting strategies. There’s a context that can be passed between effects so that they all work together within casting sequences. Now, to wire it all into actually doing something: AbilityInstance
public class AbilityInstance(AbilityDefinition definition)
{
public AbilityDefinition Definition { get; } = definition;
public AbilityExecutionContext ActiveContext { get; private set; }
public float CooldownRemaining { get; private set; } = 0;
public bool CanCast => CooldownRemaining <= 0 && ActiveContext is null;
private float pendingCooldown;
public event Action OnReady;
public event Action OnCastStarted;
public event Action OnCastReleased;
public event Action OnCastInterrupted;
public event Action OnCastCompleted;
public event Action<float> OnChannelStarted;
public event Action<float> OnChannelPulsed;
public event Action OnChannelInterrupted;
public event Action OnChannelCompleted;
public void UpdateTimers(float delta)
{
if (CooldownRemaining > 0)
{
CooldownRemaining -= delta;
if (CanCast)
{
OnReady?.Invoke();
}
}
}
internal void InvokeChannelingStarted(float duration) => OnChannelStarted?.Invoke(duration);
internal void InvokeChannelingUpdated(float progress)
{
if (progress < 1.0f)
{
OnChannelPulsed?.Invoke(progress);
}
else
{
OnChannelCompleted?.Invoke();
}
}
internal void InvokeChannelingInterrupted() => OnChannelInterrupted?.Invoke();
public async Task Cast(Node caster, Node selectedTarget)
{
if (!CanCast) return;
if (caster is not ICombatEntity entity) return;
ActiveContext = new AbilityExecutionContext(this, caster, selectedTarget);
CooldownRemaining = Definition.CooldownTime;
OnCastStarted?.Invoke();
if (Definition.CastTime > 0 && !string.IsNullOrEmpty(Definition.CastAnimationName?.ToString()))
{
await Task.Delay(Definition.CastTime);
}
OnCastReleased?.Invoke();
await ActivateEffectSequence(ActiveContext);
OnCastCompleted?.Invoke();
CleanupContext();
}
private async Task ActivateEffectSequence(AbilityExecutionContext context)
{
foreach (var effect in Definition.Effects)
{
// Refresh focal position from the live target so the cast resolves where they are at cast release, not cast begin.
if (context.FocalTarget is Node3D liveTarget && GodotObject.IsInstanceValid(liveTarget))
{
context.FocalPosition = liveTarget.GlobalPosition;
}
context.UpdateTargets(effect);
await effect.ApplyAsync(context);
}
}
private void CleanupContext()
{
ActiveContext = null;
}
}
This is the biggest piece so far, so I’ll go through it step by step.
public AbilityDefinition Definition { get; } = definition;
public AbilityExecutionContext ActiveContext { get; private set; }
public float CooldownRemaining { get; private set; } = 0;
public bool CanCast => CooldownRemaining <= 0 && ActiveContext is null;
private float pendingCooldown;
public event Action OnReady;
public event Action OnCastStarted;
public event Action OnCastReleased;
public event Action OnCastInterrupted;
public event Action OnCastCompleted;
public event Action<float> OnChannelStarted;
public event Action<float> OnChannelPulsed;
public event Action OnChannelInterrupted;
public event Action OnChannelCompleted;
The ability has a definition, an active context, a cooldown timer, and events related to the casting
sequence. Note that it has both a OnCastReleased and OnCastCompleted. Released notifies when the
actual casting animation ends, and the effect chain should begin. Completed finishes the cast sequence
and deletes the active context.
public void UpdateTimers(float delta)
{
if (CooldownRemaining > 0)
{
CooldownRemaining -= delta;
if (CanCast)
{
OnReady?.Invoke();
}
}
}
Self-explanatory.
internal void InvokeChannelingStarted(float duration) => OnChannelStarted?.Invoke(duration);
internal void InvokeChannelingUpdated(float progress)
{
if (progress < 1.0f)
{
OnChannelPulsed?.Invoke(progress);
}
else
{
OnChannelCompleted?.Invoke();
}
}
internal void InvokeChannelingInterrupted() => OnChannelInterrupted?.Invoke();
internal channeling methods that a channeling effect can call to initiate the respective events.
public async Task Cast(Node caster, Node selectedTarget)
{
if (!CanCast) return;
if (caster is not ICombatEntity entity) return;
ActiveContext = new AbilityExecutionContext(this, caster, selectedTarget);
CooldownRemaining = Definition.CooldownTime;
OnCastStarted?.Invoke();
if (Definition.CastTime > 0 && !string.IsNullOrEmpty(Definition.CastAnimationName?.ToString()))
{
await Task.Delay(Definition.CastTime);
}
OnCastReleased?.Invoke();
await ActivateEffectSequence(ActiveContext);
OnCastCompleted?.Invoke();
CleanupContext();
}
public Cast() access. This is where you create a new context, update the cooldown, progress casting events, and call for each effect to be activated in sequence, sending it the context. Notice a few missing things here?
- Cooldown is set as soon as Cast() is called, what if you want the CD to begin only once casting has finished and the ability activates, or once it actually hits (projectile), or once a persistent/channeling effect ends?
- There’s a “CastAnimationName” but what if an ability has both an animation for casting/charging, and one for actual activation?
- There’s no process in place to actually cancel the cast yet, at any point in the sequence.
All things that will have to be added. But first:
private async Task ActivateEffectSequence(AbilityExecutionContext context)
{
foreach (var effect in Definition.Effects)
{
// Refresh focal position from the live target so the cast resolves where they are at cast release, not cast begin.
if (context.FocalTarget is Node3D liveTarget && GodotObject.IsInstanceValid(liveTarget))
{
context.FocalPosition = liveTarget.GlobalPosition;
}
context.UpdateTargets(effect);
await effect.ApplyAsync(context);
}
}
private void CleanupContext()
{
ActiveContext = null;
}
ActivateEffectSequence() goes through each effect defined for the ability, updates the focal target in case they moved while casting, refreshes the target list, and calls ApplyAsync(). The cleanup function just nulls the active context, signaling the instance.Cast() is over and future CanCast checks return true.