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

Loading diagram...
ComponentDescription
ICombatEntityAny entity that can deal or receive damage
ICombatServiceCore service: Attack, Sweep, ApplyDamage, Kill
IDamageCalculatorYour damage formula, returns DamageSpec
AltruistCombatPortalBuilt-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

FlagValueDescription
None0No traits
Normal1Standard hit
Critical2Critical hit
Penetrate4Armor-penetrating hit
Dodge8Target dodged
Block16Target blocked
Miss32Attack missed or invalid because the target was already dead
Killed1u << 31Target 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.Critical
  • DamageFlags.Block
  • DamageFlags.Miss
  • DamageFlags.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:

  • DamageFlags for framework-level shared meaning
  • context or 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(...):

  1. calls your registered IDamageCalculator
  2. turns the returned DamageSpec into actual health loss
  3. emits OnHit
  4. emits OnDeath if 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, and Line
  • multiple spaces: PlanarXY, PlanarXZ, PlanarYZ, ThreeD
  • MaxTargets
  • Filter
  • 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, and SweepEvent are built-in first-class combat payloads
  • HitEvent carries Damage, Flags, and Context
  • DeathEvent is dispatched with the dead entity first and the killer second when a killer exists
  • SweepEvent is 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 enabled
  • Sweep(...) 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 SweepQuery inside 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);
}