Collision Handling

Altruist's collision system dispatches events when entities interact. It works with and without physics, supports entity-to-entity, entity-to-zone, and visibility events — all through the same [CollisionHandler] API.

What's Automatic

These systems work without any collision handler code:

  • Visibility — entities spawn/despawn for players automatically based on view range
  • Zone tracking — the framework tracks which zone each entity is in
  • Combat damageICombatService.Attack() applies damage and fires events
  • Hibernation — entities with zero observers are hibernated automatically

[CollisionHandler] is for custom logic on top. You register handlers when you need game-specific reactions to these events (aggro AI, sound effects, quest triggers, loot drops, etc.).

Event Types

EventSourceWhen
CollisionEnterSpatialCollisionDispatcherEntities first overlap
CollisionStaySpatialCollisionDispatcherEntities remain overlapping
CollisionExitSpatialCollisionDispatcherEntities stop overlapping
CollisionHitCombatServiceCombat damage applied
EntityVisibleVisibilityTrackerEntity enters view range
EntityInvisibleVisibilityTrackerEntity leaves view range
Loading diagram...

Defining a Collision Handler

using Altruist.Physx;

[CollisionHandler]
public class ProximityHandler
{
    // Fires when a player first enters a monster's range
    [CollisionEvent(typeof(CollisionEnter))]
    public void OnPlayerNearMonster(PlayerEntity player, MonsterEntity monster)
    {
        // Set aggro, start combat stance
    }

    // Fires every tick while they're close
    [CollisionEvent(typeof(CollisionStay))]
    public void OnPlayerStayNearMonster(PlayerEntity player, MonsterEntity monster)
    {
        // Aura damage, poison tick
    }

    // Fires when they separate
    [CollisionEvent(typeof(CollisionExit))]
    public void OnPlayerLeaveMonster(PlayerEntity player, MonsterEntity monster)
    {
        // De-aggro, return to patrol
    }
}

Note:

CollisionEnter, CollisionStay, CollisionExit, and CollisionHit are built-in event types. You can also define your own marker types for custom events.

One-Shot Hits (Combat)

For combat damage that doesn't need enter/stay/exit tracking, use CollisionHit:

[CollisionHandler]
public class CombatHandler
{
    private readonly ICombatService _combat;

    public CombatHandler(ICombatService combat) => _combat = combat;

    [CollisionEvent(typeof(CollisionHit))]
    public void OnPlayerHitMonster(PlayerEntity player, MonsterEntity monster)
    {
        _combat.ApplyDamage(player, monster, player.GetAttackPower());
    }
}

CombatService.Attack() and Sweep() dispatch CollisionHit automatically on every successful hit:

// Single target — CollisionHit handlers fire
var result = combat.Attack(player, monster);

// AoE — CollisionHit fires per entity hit
var result = combat.Sweep(player,
    SweepQuery.Sphere(player.X, player.Y, 0, radius: 500));

Entity-to-Zone Collisions (Optional)

The dispatcher automatically tracks when entities move between zones. Register handlers only if you need custom logic (the zone system works without them):

[CollisionHandler]
public class ZoneHandler
{
    [CollisionEvent(typeof(CollisionEnter))]
    public void OnEnterZone(PlayerEntity player, IZone3D zone)
    {
        // Player entered a zone — load spawns, send zone name to client
    }

    [CollisionEvent(typeof(CollisionExit))]
    public void OnLeaveZone(PlayerEntity player, IZone3D zone)
    {
        // Player left a zone — cleanup, deactivate spawns
    }
}

Note:

Zone collision detection uses the ZoneManager's FindZoneAt lookup — no physics needed. As entities move, the dispatcher tracks which zone they're in and fires enter/exit when it changes.

How It Works: Physics ON vs OFF

Physics ONPhysics OFF
Entity-entityBEPU body overlapDistance check each tick
Entity-zonePosition lookupPosition lookup (same)
LifecycleEnter/Stay/Exit from physicsEnter/Stay/Exit from dispatcher tick
One-shot hitCollisionHit via physics contactCollisionHit via DispatchHit()
Your handler codeIdenticalIdentical

Without physics, call ISpatialCollisionDispatcher.Tick(world) in your game loop to run enter/stay/exit detection:

// In your game loop or [Cycle] method
_collisionDispatcher.Tick(world);

Collision Shapes

Entities define their collision area using ColliderDescriptors — the same shape system used by the physics engine. The dispatcher reads these shapes to determine collision ranges.

// Monster with aggro detection sphere (2000 unit radius)
[WorldObject("monster")]
public class MonsterEntity : WorldObject3D
{
    public MonsterEntity(uint vid, int x, int y, int z)
        : base(Transform3D.From(new Vector3(x, y, z), Quaternion.Identity, Vector3.One))
    {
        VirtualId = vid;
        ColliderDescriptors = [PhysxCollider3D.CreateSphere(2000, isTrigger: true)];
    }
}

// Player with smaller melee range
[WorldObject("player")]
public class PlayerEntity : WorldObject3D
{
    public PlayerEntity(uint vid, int x, int y, int z)
        : base(Transform3D.From(new Vector3(x, y, z), Quaternion.Identity, Vector3.One))
    {
        VirtualId = vid;
        ColliderDescriptors = [PhysxCollider3D.CreateSphere(200, isTrigger: true)];
    }
}

// NPC with box-shaped interaction area
[WorldObject("npc")]
public class ShopNpc : WorldObject3D
{
    public ShopNpc(uint vid, int x, int y, int z)
        : base(Transform3D.From(new Vector3(x, y, z), Quaternion.Identity, Vector3.One))
    {
        VirtualId = vid;
        ColliderDescriptors = [PhysxCollider3D.Create(
            PhysxColliderShape3D.Box3D,
            Transform3D.From(Vector3.Zero, Quaternion.Identity, new Vector3(500, 500, 500)),
            isTrigger: true)];
    }
}

One shape definition, two backends:

  • Physics OFF — dispatcher reads the collider size and does distance checks
  • Physics ON — BEPU creates actual physics bodies from the same descriptors

Note:

You define collision shapes once via ColliderDescriptors. The framework uses them for both physics-based and distance-based collision detection. No separate configuration needed.

Note:

isTrigger: true means the collider is used for detection only — entities pass through it instead of bouncing off. Use isTrigger: false for solid colliders (walls, terrain).

Collision Layers

Layers control what collides with what. Each entity belongs to a layer (bitmask). Each query specifies which layers it cares about. Works with and without physics.

Built-in Layers

[Flags]
public enum PhysxLayer : uint
{
    None        = 0,
    Terrain     = 1u << 0,   // Ground, heightmap
    StaticWorld = 1u << 1,   // Walls, buildings, props
    Dynamic     = 1u << 2,   // Moving objects (doors, platforms)
    Character   = 1u << 3,   // Players, monsters
    Trigger     = 1u << 4,   // Non-blocking detection zones

    World = Terrain | StaticWorld,
    All   = 0xFFFFFFFFu
}

Assigning Layers

Every IWorldObject3D has a CollisionLayer property (default: PhysxLayer.All). Set it on your entities:

// Player — Character layer
player.CollisionLayer = (uint)PhysxLayer.Character;

// Building — StaticWorld layer, blocks movement
building.CollisionLayer = (uint)PhysxLayer.StaticWorld;

// Monster aggro zone — Trigger layer
monster.CollisionLayer = (uint)PhysxLayer.Trigger;

You can also set it in the constructor:

[WorldObject("monster")]
public class MonsterEntity : WorldObject3D
{
    public MonsterEntity(Transform3D t) : base(t)
    {
        CollisionLayer = (uint)PhysxLayer.Character;
    }
}

The collision dispatcher checks (entityA.CollisionLayer & entityB.CollisionLayer) — if the layers don't overlap, the pair is skipped entirely. No handler lookup, no distance check.

Note:

CollisionLayer defaults to PhysxLayer.All for backward compatibility. Existing entities without an explicit layer will collide with everything. Set specific layers to opt into filtering.

Filtering Queries

Every CapsuleCast and RayCast takes a layerMask. Only entities on matching layers are returned:

// Movement — hit terrain and walls, pass through characters and triggers
var hits = queryProvider.CapsuleCast(pos, radius, halfLength, dir, dist,
    layerMask: (uint)(PhysxLayer.World | PhysxLayer.Dynamic));

// Ground detection — only terrain
var hits = queryProvider.RayCast(pos, Vector3.Down, 1f,
    layerMask: (uint)PhysxLayer.Terrain);

// Combat AoE — only characters
var hits = queryProvider.CapsuleCast(pos, radius, halfLength, dir, dist,
    layerMask: (uint)PhysxLayer.Character);

KCC Layer Masks

The KinematicCharacterController3D has two configurable masks:

var controller = new KinematicCharacterController3D
{
    // What the character collides with when moving
    CollisionMask = PhysxLayer.World | PhysxLayer.Dynamic,

    // What counts as "ground" for grounding and slope checks
    GroundMask = PhysxLayer.World,
};

This means the character walks on terrain, slides along walls, passes through other characters, and ignores trigger zones.

Collision Matrix

QueryTerrainStaticWorldDynamicCharacterTrigger
Movement (World | Dynamic)HITHITHITskipskip
Ground probe (World)HITHITskipskipskip
Combat sweep (Character)skipskipskipHITskip
Trigger check (Trigger)skipskipskipskipHIT

Custom Layers

Add game-specific layers using bits 5–31 (up to 32 total):

public static class GameLayers
{
    public const PhysxLayer Projectile = (PhysxLayer)(1u << 5);
    public const PhysxLayer Water      = (PhysxLayer)(1u << 6);
    public const PhysxLayer NPC        = (PhysxLayer)(1u << 7);
    public const PhysxLayer Loot       = (PhysxLayer)(1u << 8);
}

// Projectile hits characters only
var hits = queryProvider.CapsuleCast(...,
    layerMask: (uint)PhysxLayer.Character);

// Player pickup query — only loot items
var hits = queryProvider.CapsuleCast(...,
    layerMask: (uint)GameLayers.Loot);

Note:

Layers work identically with both physics and heightmap backends. The layerMask parameter is passed through to both PhysicsSpatialQueryProvider and HeightmapSpatialQueryProvider. No code changes when switching.

Manual Dispatch

You can fire collision events directly for custom scenarios:

// One-shot hit (e.g., a trap triggers)
_dispatcher.DispatchHit(trap, player);

// Specific phase (e.g., scripted zone enter)
_dispatcher.Dispatch(player, bossArena, typeof(CollisionEnter));

Handler Examples

Aggro detection (Enter/Exit)

[CollisionHandler]
public class AggroHandler
{
    [CollisionEvent(typeof(CollisionEnter))]
    public void OnPlayerEnterRange(PlayerEntity player, MonsterEntity monster)
    {
        // Monster spotted player — set aggro
    }

    [CollisionEvent(typeof(CollisionExit))]
    public void OnPlayerLeaveRange(PlayerEntity player, MonsterEntity monster)
    {
        // Player out of range — return to patrol
    }
}

NPC shop interaction (Enter/Exit)

[CollisionHandler]
public class ShopTriggerHandler
{
    [CollisionEvent(typeof(CollisionEnter))]
    public void OnPlayerNearShop(PlayerEntity player, ShopNpc npc)
    {
        // Show shop UI prompt
    }

    [CollisionEvent(typeof(CollisionExit))]
    public void OnPlayerLeaveShop(PlayerEntity player, ShopNpc npc)
    {
        // Hide shop UI
    }
}

Damage aura (Stay)

[CollisionHandler]
public class PoisonAuraHandler
{
    [CollisionEvent(typeof(CollisionStay))]
    public void OnPlayerInPoison(PlayerEntity player, PoisonCloudEntity cloud)
    {
        // Tick damage every frame while inside
    }
}

Combat damage (Hit)

[CollisionHandler]
public class DamageHandler
{
    private readonly ICombatService _combat;
    public DamageHandler(ICombatService combat) => _combat = combat;

    [CollisionEvent(typeof(CollisionHit))]
    public void OnPlayerHitMonster(PlayerEntity player, MonsterEntity monster)
    {
        _combat.ApplyDamage(player, monster, player.GetAttackPower());
    }
}

Zone boundaries (Enter/Exit)

[CollisionHandler]
public class ZoneHandler
{
    [CollisionEvent(typeof(CollisionEnter))]
    public void OnEnterZone(PlayerEntity player, Zone3D zone)
    {
        // Player entered zone — activate spawns, send zone name
    }

    [CollisionEvent(typeof(CollisionExit))]
    public void OnExitZone(PlayerEntity player, Zone3D zone)
    {
        // Player left zone — deactivate spawns
    }
}

Visibility (EntityVisible/EntityInvisible)

Handle entity spawn/despawn packets when entities enter or leave a player's view range:

[CollisionHandler]
public class VisibilityHandler
{
    private readonly IAltruistRouter _router;
    public VisibilityHandler(IAltruistRouter router) => _router = router;

    [CollisionEvent(typeof(EntityVisible))]
    public void OnPlayerSeesMonster(PlayerEntity player, MonsterEntity monster)
    {
        // Send spawn packet to the player
        _router.Client.SendAsync(player.ClientId, monster.ToAddPacket());
    }

    [CollisionEvent(typeof(EntityInvisible))]
    public void OnPlayerLosesMonster(PlayerEntity player, MonsterEntity monster)
    {
        // Send despawn packet
        _router.Client.SendAsync(player.ClientId, new EntityRemovePacket
        {
            VID = monster.VirtualId
        });
    }

    [CollisionEvent(typeof(EntityVisible))]
    public void OnPlayerSeesPlayer(PlayerEntity observer, PlayerEntity other)
    {
        _router.Client.SendAsync(observer.ClientId, other.ToAddPacket());
    }

    [CollisionEvent(typeof(EntityInvisible))]
    public void OnPlayerLosesPlayer(PlayerEntity observer, PlayerEntity other)
    {
        _router.Client.SendAsync(observer.ClientId, new EntityRemovePacket
        {
            VID = other.VirtualId
        });
    }
}

Note:

Visibility events use the same [CollisionHandler] pattern as all other collision types. You define handlers per entity type pair — the framework dispatches to them when entities enter or leave view range. No separate event subscription needed.

Combat Events vs Collision Handlers

Collision HandlersCombat Events
TriggerEntity type pair matchAfter damage is applied
GranularityPer entity type pairGlobal (all hits/deaths)
PhasesEnter/Stay/Exit/Hit/Visible/InvisibleOnHit, OnDeath, OnSweep
Use caseType-specific reactionsGeneric systems (loot, XP)
// Collision handler: specific to PlayerEntity + MonsterEntity
[CollisionEvent(typeof(CollisionHit))]
public void OnPlayerHitMonster(PlayerEntity p, MonsterEntity m) { ... }

// Combat event: fires for ANY hit, any entity types
combat.OnHit += (e) => UpdateScoreboard(e.Attacker, e.Damage);
combat.OnDeath += (e) => DropLoot(e.Entity, e.Killer);