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 damage —
ICombatService.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
| Event | Source | When |
|---|---|---|
CollisionEnter | SpatialCollisionDispatcher | Entities first overlap |
CollisionStay | SpatialCollisionDispatcher | Entities remain overlapping |
CollisionExit | SpatialCollisionDispatcher | Entities stop overlapping |
CollisionHit | CombatService | Combat damage applied |
EntityVisible | VisibilityTracker | Entity enters view range |
EntityInvisible | VisibilityTracker | Entity leaves view range |
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 ON | Physics OFF | |
|---|---|---|
| Entity-entity | BEPU body overlap | Distance check each tick |
| Entity-zone | Position lookup | Position lookup (same) |
| Lifecycle | Enter/Stay/Exit from physics | Enter/Stay/Exit from dispatcher tick |
| One-shot hit | CollisionHit via physics contact | CollisionHit via DispatchHit() |
| Your handler code | Identical | Identical |
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
| Query | Terrain | StaticWorld | Dynamic | Character | Trigger |
|---|---|---|---|---|---|
Movement (World | Dynamic) | HIT | HIT | HIT | skip | skip |
Ground probe (World) | HIT | HIT | skip | skip | skip |
Combat sweep (Character) | skip | skip | skip | HIT | skip |
Trigger check (Trigger) | skip | skip | skip | skip | HIT |
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 Handlers | Combat Events | |
|---|---|---|
| Trigger | Entity type pair match | After damage is applied |
| Granularity | Per entity type pair | Global (all hits/deaths) |
| Phases | Enter/Stay/Exit/Hit/Visible/Invisible | OnHit, OnDeath, OnSweep |
| Use case | Type-specific reactions | Generic 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);