Back to blog
Your FirstPart 8 of 9

Friday, April 24, 2026

Your First Combat

cover

Starting Point

The series project now has:

  • synchronized player entities
  • AI-driven drones
  • visibility-based spawn and remove flow

That is exactly the moment where combat starts making sense.

This post adds a first real combat loop:

  • players and drones become ICombatEntity
  • the server resolves hits authoritatively
  • damage and death events go back to clients as packets

1. Make the Player Combat-Ready

Replace World/PlayerPrefab.cs with this version:

using System.Numerics;
using Altruist.Gaming;
using Altruist.Gaming.Combat;
using Altruist.Gaming.ThreeD;
using Altruist.Networking;
using Altruist.ThreeD.Numerics;

namespace SpaceGame.World;

[Synchronized(20, SyncUnit.Hz)]
[WorldObject("player")]
public sealed class PlayerPrefab : WorldObjectPrefab3D, ISynchronizedEntity, ICombatEntity
{
    public string Name { get; set; } = "";
    public int Level { get; set; } = 1;
    public int AttackPower { get; set; } = 24;
    public int DefensePower { get; set; } = 8;
    public int Health { get; set; } = 100;
    public int MaxHealth { get; set; } = 100;

    [Synced(0, SyncAlways: true)]
    public uint EntityId => VirtualId;

    [Synced(1)]
    public float PosX => Transform.Position.X;

    [Synced(2)]
    public float PosY => Transform.Position.Z;

    [Synced(3)]
    public float FacingYaw { get; set; }

    [Synced(4)]
    public int SyncedHealth => Health;

    [Synced(5)]
    public int SyncedMaxHealth => MaxHealth;

    int ICombatEntity.Health
    {
        get => Health;
        set => Health = value;
    }

    int ICombatEntity.MaxHealth => MaxHealth;
    bool ICombatEntity.IsDead => Health <= 0;
    float ICombatEntity.X => Transform.Position.X;
    float ICombatEntity.Y => Transform.Position.Y;
    float ICombatEntity.Z => Transform.Position.Z;

    public PlayerPrefab(uint entityId, string name, Transform3D transform)
    {
        VirtualId = entityId;
        Name = name;
        Transform = transform;
    }

    public int GetAttackPower() => AttackPower;
    public int GetDefensePower() => DefensePower;
}

2. Make the Drone a Combat Target

Replace World/DroneEntity.cs with this version:

using Altruist.Gaming;
using Altruist.Gaming.Combat;
using Altruist.Gaming.ThreeD;
using Altruist.Networking;
using Altruist.ThreeD.Numerics;

namespace SpaceGame.World;

[Synchronized]
[WorldObject("drone")]
public sealed class DroneEntity : WorldObject3D, IAIBehaviorEntity, ISynchronizedEntity, ICombatEntity
{
    public string AIBehaviorName => "drone";
    public IAIContext AIContext { get; set; } = null!;
    public new string ClientId { get; set; } = "";
    public string Name { get; set; } = "Training Drone";
    public DateTime LastAttackAtUtc { get; set; }
    public int Health { get; set; } = 60;
    public int MaxHealth { get; set; } = 60;
    public int AttackPower { get; set; } = 12;
    public int DefensePower { get; set; } = 4;

    [Synced(0, SyncAlways: true)]
    public uint EntityId => VirtualId;

    [Synced(1)]
    public float PosX => Transform.Position.X;

    [Synced(2)]
    public float PosY => Transform.Position.Z;

    [Synced(3)]
    public int SyncedHealth => Health;

    int ICombatEntity.Health
    {
        get => Health;
        set => Health = value;
    }

    int ICombatEntity.MaxHealth => MaxHealth;
    bool ICombatEntity.IsDead => Health <= 0;
    float ICombatEntity.X => Transform.Position.X;
    float ICombatEntity.Y => Transform.Position.Y;
    float ICombatEntity.Z => Transform.Position.Z;

    public DroneEntity(Transform3D transform) : base(transform)
    {
        ClientId = $"drone_{Guid.NewGuid():N}";
    }

    public int GetAttackPower() => AttackPower;
    public int GetDefensePower() => DefensePower;
}

3. Add Explicit Combat Packets

Create Packets/CombatPackets.cs:

using System.Text.Json.Serialization;
using Altruist;

namespace SpaceGame.Packets;

public struct AttackRequestPacket : IPacketBase
{
    [JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
    [JsonPropertyName("targetEntityId")] public uint TargetEntityId { get; set; }

    public AttackRequestPacket()
    {
        MessageCode = 1010;
        TargetEntityId = 0;
    }
}

public struct DamageEventPacket : IPacketBase
{
    [JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
    [JsonPropertyName("entityId")] public uint EntityId { get; set; }
    [JsonPropertyName("damage")] public int Damage { get; set; }
    [JsonPropertyName("flags")] public uint Flags { get; set; }

    public DamageEventPacket()
    {
        MessageCode = 1011;
        EntityId = 0;
        Damage = 0;
        Flags = 0;
    }
}

public struct EntityDefeatedPacket : IPacketBase
{
    [JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
    [JsonPropertyName("entityId")] public uint EntityId { get; set; }

    public EntityDefeatedPacket()
    {
        MessageCode = 1012;
        EntityId = 0;
    }
}

4. Broadcast Combat Reactions with CombatHandler

Create Combat/CombatFeedbackHandler.cs:

using System;
using Altruist;
using Altruist.Gaming.Combat;
using SpaceGame.Packets;

namespace SpaceGame.Combat;

[CombatHandler]
public sealed class CombatFeedbackHandler
{
    private readonly IAltruistRouter _router;

    public CombatFeedbackHandler(IAltruistRouter router)
    {
        _router = router;
    }

    [CombatEvent(typeof(HitEvent))]
    public void OnPlayerHitDrone(HitEvent hit, PlayerPrefab player, DroneEntity drone)
    {
        _ = _router.Broadcast.SendAsync(new DamageEventPacket
        {
            EntityId = drone.VirtualId,
            Damage = hit.Damage,
            Flags = (uint)hit.Flags,
        });
    }

    [CombatEvent(typeof(DeathEvent))]
    public void OnDroneDefeated(DeathEvent death, DroneEntity drone, PlayerPrefab player)
    {
        _ = _router.Broadcast.SendAsync(new EntityDefeatedPacket
        {
            EntityId = drone.VirtualId
        });
    }
}

This keeps the responsibilities clean:

  • ICombatService resolves the hit
  • HitEvent and DeathEvent give you first-class combat reaction hooks
  • your handler decides how the client hears about it

For this first cut, the packet layer is intentionally simple. The important part is that the handler consumes the real combat payloads from Altruist instead of re-deriving anything locally.

5. Add the Attack Gate

Update Portals/GamePortal.cs:

private readonly ICombatService _combat;
private readonly IGameWorldOrganizer3D _worlds;

public GamePortal(
    IGameSessionService gameSessionService,
    IAltruistRouter router,
    IGameWorldOrganizer3D worlds,
    ICombatService combat,
    ILoggerFactory loggerFactory)
    : base(gameSessionService, router)
{
    _worlds = worlds;
    _combat = combat;
    _logger = loggerFactory.CreateLogger<GamePortal>();
}

[Gate("attack")]
public Task OnAttack(AttackRequestPacket packet, string clientId)
{
    var attacker = GetPlayer(clientId);
    if (attacker == null)
        return Task.CompletedTask;

    var world = _worlds.GetWorld(0);
    if (world == null)
        return Task.CompletedTask;

    var target = world
        .FindAllObjects<DroneEntity>()
        .FirstOrDefault(x => x.VirtualId == packet.TargetEntityId);

    if (target == null)
        return Task.CompletedTask;

    _combat.Attack(attacker, target);
    return Task.CompletedTask;
}

That is already a complete first combat slice.

The server decides:

  • who is allowed to hit
  • which entity was targeted
  • how much damage is dealt
  • whether the target died

6. Why This Is the Right First Cut

This is intentionally small.

You are not building a full MMO combat layer yet. You are building the correct shape:

  • world entities own authoritative health
  • the server resolves hits
  • clients react to packets emitted from a clean CollisionHandler hook

That is the minimum stable base for skills, loot, aggro, and quest kill hooks.

Next Up

Now that the world can produce real kills, we can wire that into progression: