Combat System
Altruist provides a framework-level combat module with single-target attacks, AoE sweeps, pluggable damage calculation, and combat events. Your game owns the real formula, rewards, visuals, and packet contract around that core.
The combat module also integrates with optional Lag Compensation. If the incoming attack packet implements ILagCompensated, combat can validate attacks and sweeps against rewound historical positions automatically.
Architecture
| Component | Description |
|---|---|
ICombatEntity | Any entity that can deal or receive damage |
ICombatService | Core service: Attack, Sweep, ApplyDamage, Kill |
IDamageCalculator | Your damage formula, returns DamageSpec |
AltruistCombatPortal | Built-in base portal with attack and target gates |
Step 1: Implement ICombatEntity
Any world object that participates in combat implements ICombatEntity:
[WorldObject("player")]
public sealed class PlayerEntity : WorldObject3D, ICombatEntity
{
public string Name { get; set; } = "";
public int Hp { get; set; }
public int MaxHp { get; set; }
public int Attack { get; set; }
public int Defense { get; set; }
public uint VirtualId { get; set; }
int ICombatEntity.Health { get => Hp; set => Hp = value; }
int ICombatEntity.MaxHealth => MaxHp;
bool ICombatEntity.IsDead => Hp <= 0;
float ICombatEntity.X => Transform.Position.X;
float ICombatEntity.Y => Transform.Position.Y;
float ICombatEntity.Z => Transform.Position.Z;
public int GetAttackPower() => Attack;
public int GetDefensePower() => Defense;
public PlayerEntity(Transform3D transform) : base(transform) { }
}
The public surface of ICombatEntity is intentionally small:
public interface ICombatEntity
{
uint VirtualId { get; }
int Health { get; set; }
int MaxHealth { get; }
bool IsDead { get; }
float X { get; }
float Y { get; }
float Z { get; }
virtual int GetAttackPower() => 0;
virtual int GetDefensePower() => 0;
}
Note:
VirtualId is the combat-facing identity for built-in portal packets and sweep filtering. If your gameplay model uses different public property names like Hp, explicit interface implementation keeps your own entity API clean.
Step 2: Damage Calculation
Combat formulas are owned by IDamageCalculator:
public interface IDamageCalculator
{
DamageSpec Calculate(ICombatEntity attacker, ICombatEntity target);
}
Calculate(...) now returns a DamageSpec with both numeric damage and trait metadata:
public readonly record struct DamageSpec(
int Damage,
DamageTraits Traits)
{
public DamageSpec(int damage, DamageFlags flags)
: this(damage, new DamageTraits(flags)) { }
public DamageFlags Flags => Traits.Flags;
}
public readonly record struct DamageTraits(
DamageFlags Flags);
The built-in fallback calculator is:
public class DefaultDamageCalculator : IDamageCalculator
{
public virtual DamageSpec Calculate(ICombatEntity attacker, ICombatEntity target)
=> new(
Math.Max(1, attacker.GetAttackPower() - target.GetDefensePower()),
DamageFlags.Normal);
}
Custom Damage Calculator
Register your own calculator with DI:
[Service(typeof(IDamageCalculator))]
public sealed class GameDamageCalculator : IDamageCalculator
{
public DamageSpec Calculate(ICombatEntity attacker, ICombatEntity target)
{
int attack = attacker.GetAttackPower();
int defense = target.GetDefensePower();
int baseDamage = Math.Max(1, attack - defense);
bool critical = Random.Shared.NextDouble() < 0.15;
int finalDamage = critical ? (int)(baseDamage * 1.5f) : baseDamage;
var flags = critical ? DamageFlags.Critical : DamageFlags.Normal;
return new DamageSpec(finalDamage, flags);
}
}
Use DamageFlags for framework-level reusable semantics. If your game needs extra user-facing meaning, carry it in your own packet shape or in the optional context object passed to Attack(...), Sweep(...), or ApplyDamage(...).
Damage Flags
| Flag | Value | Description |
|---|---|---|
None | 0 | No traits |
Normal | 1 | Standard hit |
Critical | 2 | Critical hit |
Penetrate | 4 | Armor-penetrating hit |
Dodge | 8 | Target dodged |
Block | 16 | Target blocked |
Miss | 32 | Attack missed or invalid because the target was already dead |
Killed | 1u << 31 | Target was killed by this hit |
Note:
DamageFlags is intentionally small. If your game needs extra semantics such as elemental types, stun, bleed, holy damage, or faction tags, carry those in your own surrounding packet shape or in a context object that travels with the hit.
Note:
HitResult still exposes kill state through the separate Killed boolean. The Killed flag is most useful on HitEvent.Flags, where it travels as part of the event payload metadata.
Extending Combat Metadata in Game Code
Keep the framework flags small and reusable:
DamageFlags.CriticalDamageFlags.BlockDamageFlags.MissDamageFlags.Killed
Then layer your game's own meaning on top with a typed context object:
public enum DamageElement
{
None,
Fire,
Ice,
Poison,
}
public sealed class SkillDamageContext
{
public string SkillId { get; init; } = "";
public DamageElement Element { get; init; }
public bool CausesBleed { get; init; }
}
Pass that metadata into combat:
var result = _combat.ApplyDamage(
source: player,
target: monster,
damage: 180,
flags: DamageFlags.Critical,
context: new SkillDamageContext
{
SkillId = "fireball",
Element = DamageElement.Fire,
CausesBleed = false,
});
Read it back inside your combat handler:
[CombatEvent(typeof(HitEvent))]
public void OnPlayerHitMonster(HitEvent hit, PlayerEntity player, MonsterEntity monster)
{
var flags = hit.Flags;
var ctx = hit.Context as SkillDamageContext;
if (flags.IsSet(DamageFlags.Critical))
ShowFloatingText("Critical!");
if (ctx?.Element == DamageElement.Fire)
PlayEffect("fire-hit");
if (flags.IsSet(DamageFlags.Killed))
PlayEffect("finisher");
}
That split keeps responsibilities clear:
DamageFlagsfor framework-level shared meaningcontextor your own packets for host/game-specific user-facing meaning
Step 3: Single-Target Attack
The simplest combat operation is one attacker hitting one target:
var result = _combat.Attack(attacker, target);
// result.Target
// result.Damage
// result.Traits
// result.Flags
// result.Killed
Attack(...):
- calls your registered
IDamageCalculator - turns the returned
DamageSpecinto actual health loss - emits
OnHit - emits
OnDeathif the target dies
All three entry points also accept an optional context object:
var result = _combat.Attack(attacker, target, context: new SkillDamageContext
{
SkillId = "heavy-slash",
Element = DamageElement.None,
CausesBleed = true,
});
The result shape is:
public readonly record struct HitResult(
ICombatEntity Target,
int Damage,
DamageTraits Traits,
bool Killed);
Step 4: AoE Sweeps
For skills and abilities that can hit multiple targets, use Sweep(...) with a SweepQuery.
Sphere
var query = SweepQuery.Sphere(
x: attacker.X,
y: attacker.Y,
z: attacker.Z,
radius: 500f);
var result = _combat.Sweep(attacker, query);
Planar Cone
var query = SweepQuery.Cone(
x: attacker.X,
y: attacker.Y,
z: attacker.Z,
range: 400f,
direction: attackerFacingAngle,
angle: 90f);
var result = _combat.Sweep(attacker, query);
3D Cone
var query = SweepQuery.Cone(
x: attacker.X,
y: attacker.Y,
z: attacker.Z,
range: 400f,
dirX: lookDirection.X,
dirY: lookDirection.Y,
dirZ: lookDirection.Z,
angle: 90f);
Line
var query = SweepQuery.Line(
x: attacker.X,
y: attacker.Y,
z: attacker.Z,
length: 800f,
direction: attackerFacingAngle) with
{
Width = 120f
};
var result = _combat.Sweep(attacker, query, damage: 150);
SweepQuery supports:
Sphere,Cone, andLine- multiple spaces:
PlanarXY,PlanarXZ,PlanarYZ,ThreeD MaxTargetsFilter- optional fixed-damage override on
Sweep(...)
Example target limit and filter:
var query = SweepQuery.Sphere(attacker.X, attacker.Y, attacker.Z, 500f) with
{
MaxTargets = 5,
Filter = entity => entity.VirtualId != attacker.VirtualId
};
Note:
If you pass damage: null, sweep falls back to the registered IDamageCalculator for each target. If you pass a fixed damage, the sweep bypasses the calculator and applies the supplied amount to every hit target.
Step 5: Direct Damage
For DoTs, traps, scripted skills, or environment damage, bypass the calculator:
var result = _combat.ApplyDamage(
source: poisonCloud,
target: player,
damage: 100,
flags: DamageFlags.Normal,
context: new DamageOverTimeContext { EffectId = "poison-cloud" });
_combat.Kill(entity, killer: attacker);
ApplyDamage(...) still fires the built-in combat payloads and .NET events. That means your [CombatHandler] methods run, OnHit still fires, and if the target dies it also triggers DeathEvent / OnDeath.
Step 6: Combat Reactions
For per-hit gameplay reactions, the cleanest Altruist-style pattern is the built-in combat event system.
When ApplyDamage(...), Attack(...), or Sweep(...) runs, CombatService dispatches typed combat payloads through ICombatEventDispatcher. That gives Altruist combat its own first-class handler model instead of piggybacking on collision events.
Preferred: CombatHandler + CombatEvent
using Altruist;
using Altruist.Gaming.Combat;
namespace Game.Combat;
[CombatHandler]
public sealed class CombatReactionHandlers
{
[CombatEvent(typeof(HitEvent))]
public void OnPlayerHitsMonster(HitEvent hit, PlayerEntity player, MonsterEntity monster)
{
monster.LastHitByPlayerId = player.StorageId;
monster.AggroTargetId = player.VirtualId;
monster.LastHitAtUtc = DateTime.UtcNow;
if (hit.Flags.IsSet(DamageFlags.Killed))
monster.LastKilledByPlayerId = player.StorageId;
}
[CombatEvent(typeof(DeathEvent))]
public void OnMonsterKilledByPlayer(DeathEvent death, MonsterEntity monster, PlayerEntity player)
{
player.LastKillAtUtc = DateTime.UtcNow;
AwardXp(player, monster);
}
[CombatEvent(typeof(SweepEvent))]
public void OnPlayerUsedSweep(SweepEvent sweep, PlayerEntity player)
{
player.LastSkillHitCount = sweep.Hits.Count;
}
}
This is a good fit for:
- aggro updates
- combat state tags like "in combat"
- per-target debuff hooks
- entity-to-entity reactive gameplay
Important details:
[CombatHandler]classes are discovered automatically[CombatEvent(typeof(...))]methods receive payload first, then one or two actor parameters- the first parameter must implement
ICombatEventPayload - the payload type must be compatible with the
[CombatEvent]event type HitEvent,DeathEvent, andSweepEventare built-in first-class combat payloadsHitEventcarriesDamage,Flags, andContextDeathEventis dispatched with the dead entity first and the killer second when a killer existsSweepEventis dispatched with the attacker as the primary actor
Custom Combat Events
You can also define your own combat payloads and dispatch them through ICombatEventDispatcher:
public readonly record struct LifestealEvent(
int HealAmount,
string SkillId) : ICombatEventPayload;
[CombatHandler]
public sealed class LifestealHandlers
{
[CombatEvent(typeof(LifestealEvent))]
public void OnPlayerLifesteals(LifestealEvent evt, PlayerEntity player, MonsterEntity target)
{
player.Health = Math.Min(player.MaxHealth, player.Health + evt.HealAmount);
ShowFloatingText(player, $"+{evt.HealAmount}");
}
}
// anywhere in your game code
_combatEvents.Dispatch(
new LifestealEvent(HealAmount: 24, SkillId: "blood-strike"),
player,
monster);
That makes combat events a true extension surface, not just a fixed hit/death callback list.
Compatibility: ICombatService Events
If you want an imperative, global listener, ICombatService still exposes .NET events:
[Service]
public sealed class CombatReactions
{
public CombatReactions(ICombatService combat)
{
combat.OnHit += OnHit;
combat.OnDeath += OnDeath;
combat.OnSweep += OnSweep;
}
private void OnHit(HitEvent e)
{
// analytics, logs, generic hit VFX routing
}
private void OnDeath(DeathEvent e)
{
// drop loot, award XP, respawn, etc.
}
private void OnSweep(SweepEvent e)
{
// aggregate AoE reporting or skill telemetry
}
}
This is usually the better fit for:
- analytics
- logging
- global loot / XP systems
- cross-cutting systems that care about every combat event, not just a specific entity pair
The combat event payload types are:
public readonly record struct HitEvent(
ICombatEntity Attacker,
ICombatEntity Target,
int Damage,
DamageTraits Traits,
object? Context = null);
public record DeathEvent(
ICombatEntity Entity,
ICombatEntity? Killer,
float X,
float Y,
float Z);
public readonly record struct SweepEvent(
ICombatEntity Attacker,
SweepQuery Query,
IReadOnlyList<HitResult> Hits);
Step 7: AltruistCombatPortal
Extend AltruistCombatPortal if you want the built-in attack and target gates:
[Portal("/game")]
public sealed class MyCombatPortal : AltruistCombatPortal
{
private readonly IGameSessionService _gameSessions;
private readonly IEntityRegistry _entities;
public MyCombatPortal(
ICombatService combat,
IAltruistRouter router,
ILoggerFactory loggerFactory,
IGameSessionService gameSessions,
IEntityRegistry entities)
: base(combat, router, loggerFactory)
{
_gameSessions = gameSessions;
_entities = entities;
}
protected override Task<ICombatEntity?> ResolveAttacker(string clientId)
{
var session = _gameSessions.GetSession(clientId);
var player = session?.GetContext<PlayerEntity>(clientId);
return Task.FromResult<ICombatEntity?>(player);
}
protected override ICombatEntity? FindTarget(uint vid)
=> _entities.Get(vid);
protected override IEnumerable<string> GetNearbyClientIds(ICombatEntity center, float range)
{
// Return the client IDs that should receive combat broadcasts near this entity.
return _visibilityRecipients.Get(center.VirtualId, range);
}
protected override async Task OnAttackCompleted(
ICombatEntity attacker,
ICombatEntity target,
HitResult result,
string clientId)
{
if (result.Killed && attacker is PlayerEntity player)
await AwardXp(player, target);
}
}
The base portal gives you:
[Gate("attack")][Gate("target")]- built-in hit broadcast
- built-in death broadcast
- post-hook override points:
OnAttackCompleted(...)OnSweepCompleted(...)
Built-In Portal Packet Types
AltruistCombatPortal ships with built-in packet classes:
public class AttackPacket : IPacketBase, ILagCompensated
{
public uint MessageCode { get; set; }
public byte Type { get; set; }
public uint TargetVID { get; set; }
public long ClientTick { get; set; }
}
public class TargetPacket : IPacketBase
{
public uint MessageCode { get; set; }
public uint VID { get; set; }
}
public class DamagePacket : IPacketBase
{
public uint MessageCode { get; set; }
public uint VID { get; set; }
public byte Flags { get; set; }
public int Damage { get; set; }
}
public class DeathPacket : IPacketBase
{
public uint MessageCode { get; set; }
public uint VID { get; set; }
}
public class TargetInfoPacket : IPacketBase
{
public uint MessageCode { get; set; }
public uint VID { get; set; }
public byte HPPercent { get; set; }
}
Those property names are the current built-in combat portal contract. If your game wants a different wire shape such as EntityId or HealthPercent, either:
- map the built-in packets on the client as-is
- or skip the built-in portal packet types and send your own packets from a custom portal
The built-in portal reports defeat with a separate DeathPacket. Clients should not infer death from DamagePacket.Flags.
Lag Compensation
The built-in AttackPacket already implements ILagCompensated and exposes ClientTick.
That means:
Attack(...)can run inside a rewind automatically when lag compensation is enabledSweep(...)can also validate targets against rewound historical positions- compensated positions are used during target-in-shape checks
Important limitation:
- rewound sweep target validation is built in
- but if your skill shape itself depends on historical attacker position or facing, build the
SweepQueryinside a rewind-aware callback so the query origin and direction are historical too
The full workflow is documented in Lag Compensation.
Client Examples
Example packet shapes when using the built-in combat portal:
[MessagePackObject]
public class AttackPacket
{
[Key(0)] public uint MessageCode { get; set; } = 4;
[Key(1)] public byte Type { get; set; }
[Key(2)] public uint TargetVID { get; set; }
[Key(3)] public long ClientTick { get; set; }
}
[MessagePackObject]
public class TargetPacket
{
[Key(0)] public uint MessageCode { get; set; } = 4;
[Key(1)] public uint VID { get; set; }
}
[MessagePackObject]
public class DamagePacket
{
[Key(0)] public uint MessageCode { get; set; }
[Key(1)] public uint VID { get; set; }
[Key(2)] public byte Flags { get; set; }
[Key(3)] public int Damage { get; set; }
}
[MessagePackObject]
public class DeathPacket
{
[Key(0)] public uint MessageCode { get; set; }
[Key(1)] public uint VID { get; set; }
}
[MessagePackObject]
public class TargetInfoPacket
{
[Key(0)] public uint MessageCode { get; set; }
[Key(1)] public uint VID { get; set; }
[Key(2)] public byte HPPercent { get; set; }
}
Using Sweep in Skills
For AoE skills, call ICombatService.Sweep() from your own gate handler:
[Gate("skill")]
public async Task OnSkill(SkillPacket packet, string clientId)
{
var attacker = await ResolveAttacker(clientId);
if (attacker == null) return;
var query = packet.SkillType switch
{
"fireball" => SweepQuery.Sphere(attacker.X, attacker.Y, attacker.Z, 300f),
"cleave" => SweepQuery.Cone(attacker.X, attacker.Y, attacker.Z, 200f, packet.Direction, 120f),
"beam" => SweepQuery.Line(attacker.X, attacker.Y, attacker.Z, 700f, packet.Direction) with { Width = 80f },
_ => null,
};
if (query == null) return;
var result = Combat.Sweep(attacker, query, damage: GetSkillDamage(packet.SkillType));
await BroadcastSweep(attacker, result);
await OnSweepCompleted(attacker, result, clientId);
}