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:
ICombatServiceresolves the hitHitEventandDeathEventgive 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
CollisionHandlerhook
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:
Continue the Series