Combat System

Altruist provides a framework-level combat module with single-target attacks, AoE sweeps, damage calculation, and an event-driven architecture. Your game extends it with custom damage formulas, loot drops, XP rewards, and animations.

Architecture

Loading diagram...
ComponentDescription
ICombatEntityInterface for any entity that can deal/receive damage
ICombatServiceCore service: attack, sweep, apply damage, kill
IDamageCalculatorPluggable damage formula (default: attack - defense)
AltruistCombatPortalBase portal with attack/target gate handlers

Step 1: Implement ICombatEntity

Any world object that participates in combat implements ICombatEntity:

[WorldObject("player")]
public 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; }

    // ICombatEntity implementation
    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) { }
}

[WorldObject("monster")]
public class MonsterEntity : WorldObject3D, ICombatEntity
{
    public int Hp { get; set; }
    public int MaxHp { get; set; }
    public int AttackMin { get; set; }
    public int AttackMax { get; set; }
    public int Defense { 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() => Random.Shared.Next(AttackMin, AttackMax + 1);
    public int GetDefensePower() => Defense;

    public MonsterEntity(Transform3D transform) : base(transform) { }
}

Note:

ICombatEntity uses explicit interface implementation so your entity's public API stays clean. The combat service works through the interface, while your game code uses your own property names (Hp, Attack, etc.).

Step 2: Single-Target Attack

The simplest combat operation — one attacker hits one target:

// Inject ICombatService
var result = _combat.Attack(attacker, target);

// result.Damage  — how much damage was dealt
// result.Flags   — Normal, Critical, Dodge, etc.
// result.Killed  — whether the target died

The service uses IDamageCalculator to compute damage, applies it to the target's health, and fires events.

Default Damage Formula

// Built-in: attack power - defense power, minimum 1
damage = Math.Max(1, attacker.GetAttackPower() - target.GetDefensePower());

Custom Damage Calculator

Override the default by implementing IDamageCalculator. See Custom Damage Calculator for examples.

Note:

By implementing IDamageCalculator with [Service], your calculator replaces the default one automatically. The ICombatService picks it up via DI.

Step 3: AoE Sweeps

For skills and abilities that hit multiple targets, use Sweep with a shape query:

Sphere (Radius)

Hit everything within a radius:

var query = SweepQuery.Sphere(
    x: attacker.X,
    y: attacker.Y,
    z: attacker.Z,
    radius: 500f);

var result = _combat.Sweep(attacker, query);
// result.Hits — list of all entities hit

Cone (Fan Shape)

Hit entities in a fan-shaped area (e.g., breath attack, cleave):

var query = SweepQuery.Cone(
    x: attacker.X,
    y: attacker.Y,
    z: attacker.Z,
    range: 400f,
    direction: attackerFacingAngle,  // radians
    angle: 90f);                      // degrees (45 deg each side)

var result = _combat.Sweep(attacker, query);

Line (Beam)

Hit entities along a line (e.g., piercing arrow, laser):

var query = SweepQuery.Line(
    x: attacker.X,
    y: attacker.Y,
    z: attacker.Z,
    length: 800f,
    direction: attackerFacingAngle);

var result = _combat.Sweep(attacker, query);
Loading diagram...

Fixed Damage and Target Limits

// Override calculator — use fixed damage for this skill
var result = _combat.Sweep(attacker, query, damage: 150);

// Limit to 5 targets (first 5 found)
var query = SweepQuery.Sphere(x, y, z, 500f) with { MaxTargets = 5 };

Step 4: Direct Damage

For skills, DoTs, or environment damage that bypass the damage calculator:

// Apply 100 poison damage directly
var result = _combat.ApplyDamage(
    source: poisonCloud,
    target: player,
    damage: 100,
    flags: DamageFlags.Poison);

// Kill instantly
_combat.Kill(entity, killer: attacker);

Damage Flags

FlagValueDescription
Normal1Standard attack
Critical2Critical hit
Penetrate4Ignores armor
Dodge8Target dodged
Block16Target blocked
Miss32Attack missed
Poison64Poison damage
Magic128Magic damage

Note:

Flags are a bitmask — combine them: DamageFlags.Critical | DamageFlags.Magic. Your damage calculator can set flags, and your portal can read them to decide animations or effects.

Step 5: Events

Subscribe to combat events for game-specific reactions:

[Service]
public class CombatReactions
{
    public CombatReactions(ICombatService combat)
    {
        combat.OnHit += OnHit;
        combat.OnDeath += OnDeath;
        combat.OnSweep += OnSweep;
    }

    private void OnHit(HitEvent e)
    {
        // Play hit animation, show damage number
    }

    private void OnDeath(DeathEvent e)
    {
        // Drop loot, award XP, respawn timer
        if (e.Killer is PlayerEntity player)
        {
            AwardXP(player, e.Entity);
            DropLoot(e.Entity, e.X, e.Y, e.Z);
        }
    }

    private void OnSweep(SweepEvent e)
    {
        // Log AoE usage, apply debuffs to all hit targets
    }
}

Step 6: Combat Portal

Extend AltruistCombatPortal to get attack/target gate handlers automatically:

[Portal("/game")]
public class MyCombatPortal : AltruistCombatPortal
{
    private readonly ISessionService _sessions;
    private readonly IEntityRegistry _entities;

    public MyCombatPortal(
        ICombatService combat,
        IAltruistRouter router,
        ILoggerFactory loggerFactory,
        ISessionService sessions,
        IEntityRegistry entities)
        : base(combat, router, loggerFactory)
    {
        _sessions = sessions;
        _entities = entities;
    }

    protected override Task<ICombatEntity?> ResolveAttacker(string clientId)
    {
        var session = _sessions.GetSession(clientId);
        var player = session?.GetContext<PlayerEntity>(clientId);
        return Task.FromResult<ICombatEntity?>(player);
    }

    protected override ICombatEntity? FindTarget(uint vid)
    {
        return _entities.Get(vid);
    }

    protected override IEnumerable<string> GetNearbyClientIds(ICombatEntity center, float range)
    {
        // Return client IDs of all players within range for broadcasting
        return _visibilityTracker.GetVisibleEntities(/* ... */);
    }

    // Game-specific: award XP, drop loot after a kill
    protected override async Task OnAttackCompleted(
        ICombatEntity attacker, ICombatEntity target, HitResult result, string clientId)
    {
        if (result.Killed && attacker is PlayerEntity player)
        {
            await AwardXP(player, target, clientId);
            await DropLoot(target);
        }
    }
}

This gives you these gate handlers automatically:

Client EventHandlerDescription
"attack"OnAttackSingle-target attack (uses IDamageCalculator)
"target"OnTargetTarget selection (returns HP percentage)

Note:

The portal handles attack resolution, damage broadcasting to nearby clients, and death packets automatically. You only implement the hooks you need — XP, loot, aggro, etc.

Client Types

Packets the client sends and receives when using the combat portal:

// ── Client → Server ───────────────────
[MessagePackObject]
public class AttackPacket
{
    [Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
    [Key(1)][JsonPropertyName("type")] public byte Type { get; set; }
    [Key(2)][JsonPropertyName("targetVID")] public uint TargetVID { get; set; }
}

[MessagePackObject]
public class TargetRequestPacket
{
    [Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
    [Key(1)][JsonPropertyName("vid")] public uint VID { get; set; }
}

// ── Server → Client ───────────────────
[MessagePackObject]
public class DamagePacket
{
    [Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
    [Key(1)][JsonPropertyName("vid")] public uint VID { get; set; }
    [Key(2)][JsonPropertyName("flags")] public byte Flags { get; set; }
    [Key(3)][JsonPropertyName("damage")] public int Damage { get; set; }
}

[MessagePackObject]
public class DeathPacket
{
    [Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
    [Key(1)][JsonPropertyName("vid")] public uint VID { get; set; }
}

[MessagePackObject]
public class TargetInfoPacket
{
    [Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
    [Key(1)][JsonPropertyName("vid")] public uint VID { get; set; }
    [Key(2)][JsonPropertyName("hpPercent")] 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;

    // Define AoE based on skill type
    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),
        "arrow_rain" => SweepQuery.Sphere(packet.TargetX, packet.TargetY, packet.TargetZ, 400f) with { MaxTargets = 10 },
        _ => null,
    };

    if (query == null) return;

    var result = Combat.Sweep(attacker, query, damage: GetSkillDamage(packet.SkillType));
    await BroadcastSweep(attacker, result);
    await OnSweepCompleted(attacker, result, clientId);
}