Agents (NPC AI)
NPC behavior drivers: wander/patrol/chase built-ins + an IAgentBehavior game SPI. Server-only.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayAgents(); and it wins.
IAgentAttackHandler game SPI
The attack seam this piece OWNS: a behavior decides WHEN to attack (range, cooldown); this seam decides WHAT an attack does — spawn a projectile, apply a stat change, start a dialogue, tag a player — so Agents never references a combat piece (rule 13). The piece defaults it to NullAgentAttackHandler (a TryAdd seam): with nothing registered, attacks are inert and ranged brains degrade to pure chase.
sealed class ProjectileAttackBridge : IAgentAttackHandler
{
public void Attack(WorldEntity attacker, WorldEntity target)
=> _projectiles.Spawn(attacker.Zone, attacker.X, attacker.Y, attacker.Z,
targetX: target.X, targetY: target.Y, targetZ: target.Z, kind: 3);
}
services.AddSingleton<IAgentAttackHandler, ProjectileAttackBridge>(); // before AddCrossplayAgents -
void Attack(WorldEntity attacker, WorldEntity target)One attack moment: attacker struck at target.
IAgentBehavior game SPI
One NPC brain — the SPI to implement for custom AI. The piece ships WanderBehavior/PatrolBehavior/ChaseNearestBehavior built-ins; implement this for anything smarter. Attach an instance to a server entity with Attach; the piece then calls Tick at the configured agent rate, on the poll thread. How to implement. Read the world through the AgentContext (its position, the nearest player, distances) and drive locomotion through the same context (MoveToward / Stop) — every move rides the standard entity broadcast wire, so the piece adds no messages of its own. Stash per-agent scratch in Memory (it persists across ticks and the piece never touches it). Keep Tick allocation-free — it runs per agent per think.
public sealed class FleeBehavior : IAgentBehavior
{
private readonly float _radius, _speed;
public FleeBehavior(float radius, float speed) { _radius = radius; _speed = speed; }
public void Tick(AgentContext ctx, float dt)
{
var threat = ctx.NearestPlayer(_radius);
if (threat is null) { ctx.Stop(); return; }
// run to the opposite side of home from the threat
ctx.MoveToward(2 * ctx.HomeX - threat.X, 2 * ctx.HomeZ - threat.Z, _speed, dt, locomotion: 2);
}
} -
void Tick(AgentContext context, float deltaSeconds)Advance this agent one think.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IAgentService
Per-entity NPC drivers: attach a behavior to any server entity and the tick loop runs it at the configured rate. Detach is automatic on entity removal. The piece is the mechanism — brains are behaviors (built-ins or the game's own IAgentBehavior).
-
bool Attach(long entityId, IAgentBehavior behavior)Attaches a behavior (replacing any previous one); the entity's current position becomes its home anchor (the leash/patrol origin behaviors steer around).
-
int Count { get; }Number of live agents (diagnostics).
-
bool Detach(long entityId)Removes the entity's behavior (the entity itself stays put).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
AgentOptions
Agents tuning (defaults here, never inline).
-
Dictionary<byte, BrainTemplate> BrainTemplates { get; set; }Brain recipes keyed by an entity's appearance-kind byte — PURE DATA (this piece never reads it): a composition bridge (Hosting/game) looks up a spawned entity's kind here and attaches the matching built-in via Attach. Empty (the default) = inert: nothing is ever auto-attached.
-
int MaxSeparationNeighbors { get; set; }Cap on neighbors summed per agent per think (0 = all in radius) — a dense cluster stays cheap.
-
int MaxUpdatesPerTick { get; set; }Cap on agent thinks per server tick — a stampede degrades think rate instead of the tick (rule 11: unbounded work gets a budget).
-
float SeparationRadius { get; set; }Neighbor-avoidance radius: agents push apart from OTHER server entities within this distance (0 = separation off). Applied per think, before the behavior moves, so the behavior's own broadcast carries the adjusted position (no extra wire, loco preserved). Respects world bounds.
-
float SeparationWeight { get; set; }How hard the separation push is (world units/second of drift away from neighbors). 0 = off.
-
double UpdateHz { get; set; }How often each agent thinks (behavior ticks per second; movement between thinks is covered by client interpolation like any entity).