Writing Your First Entity

What We’re Building

By the end of this guide you’ll have a working enemy called the Wandering Grunt. When idle it wanders between random points. When the player gets close it switches to chasing them. When killed it ragdolls. Along the way you’ll learn how entities are structured, how to use the AI system, and how to find nearby entities with sphere queries.

This guide assumes you’ve read Core Concepts and have a working project set up.

Create the File

Create GruntEnemy.cs in your project’s Entities/ folder. We’ll write both classes up front with all overrides stubbed out so the project compiles cleanly at every step:

using Engine;
using Engine.Entities.Base.AI;
using Engine.Utils;
using Engine.Utils.Animation;
using FPSTemplate.Entities;
using Microsoft.Xna.Framework;
using Rockwall;
using System;


namespace FPSTemplate.Entities
{
    internal class GruntController : AIEntityTemplate, LivingActor
    {
        public int MaxHP { get; } = 3;
        public int HP { get; set; } = 0;
        public bool IsDead { get; set; } = false;


        public override float GetAIAccel() => 200f;
        public override float GetAISpeed() => 7f;
        public override float GetThinkTimeAfterPathCompleted() => 0f;


        internal static readonly BoundingBox standingBounds =
            new BoundingBox(-new Vector3(0.4f, 2.2f, 0.4f), new Vector3(0.4f, 0.15f, 0.4f));


        public override void OnSpawn() { }
        public override void OnUpdate(GameTime gameTime) { }
        public override void OnBeforeRender(GameTime gameTime) { }
        public override void OnRender(GameTime gameTime) { }
        public override void OnTakeDamage(DamageInfo info) { }
        public override void OnDespawn() { }
    }


    [EntityDescriptor()]
    public class GruntEnemy : WorldEntity
    {
        public GruntEnemy()
        {
            controller = new GruntController();
            axisAlignedBox = true;
            isSimulated = true;
            bounds = GruntController.standingBounds;
        }
    }
}

The three abstract methods from AIEntityTemplate must always be implemented. standingBounds is internal static on the controller so GruntEnemy can reference it in its constructor, keeping the bounds definition in one place.

OnSpawn

Fill in OnSpawn(). This sets up the model, animations, and physics, then kicks the grunt into its first wander:

CModelDisplay model;
AnimationLayer locomotionLayer;
float wanderTimer = 0f;
bool chasing = false;
const float WanderInterval = 4f;
const float DetectRange = 12f;


public override void OnSpawn()
{
    HP = MaxHP;


    entity.physicsBody.SetFriction(0);
    entity.bounds = standingBounds;


    model = new CModelDisplay(“Models/Player/pmodel.ccmdl”);
    model.drawShadow = true;


    locomotionLayer = new AnimationLayer(model.model,
        new AnimNode(model.model.Sequences.Find(a => a.Name.Equals(“idle”)), 1f, true),
    locomotionLayer.influence = 1f;


    model.player = new CLinearAnimator(model.model, locomotionLayer);


    aiSteerSpeed = 400f;


    PickNewWanderPoint();
}

Wandering with SetTarget

SetTarget() accepts either a world position or a WorldEntity. For wandering we use the position version, picking a random point nearby and snapping it to the floor with a downward ray:

void PickNewWanderPoint()
{
    float angle = (float)(Random.Shared.NextDouble() * Math.PI * 2);
    float dist = 4f + (float)(Random.Shared.NextDouble() * 8f);


    Vector3 offset = new Vector3(MathF.Cos(angle) * dist, 0, MathF.Sin(angle) * dist);
    Vector3 target = entity.position + offset;


    var hit = BSPRoot.TraceRay(new Ray(target + Vector3.Up * 5f, Vector3.Down), 20f);
    if (hit.hit) target = hit.point;


    SetTarget(target);
    wanderTimer = WanderInterval;
}

The floor snap prevents the grunt from trying to walk to a point floating in mid-air. The pathfinder uses GroundNode entities you place in Rockwall 2 to route around obstacles.

Detecting the Player

Use Collision.GetEntitiesInSphere() to find nearby entities and check if a Player is among them:

WorldEntity FindPlayerInRange(float range)
{
    var nearby = Collision.GetEntitiesInSphere(entity.position, range);
    foreach (var e in nearby)
    {
        if (e is Player) return e;
    }
    return null;
}

This pattern works any time you need to find entities of a specific type within some radius.

OnUpdate

public override void OnUpdate(GameTime gameTime)
{
    if (IsDead)
    {
        model.Update();
        return;
    }


    var player = FindPlayerInRange(DetectRange);


    if (player != null && !chasing)
    {
        chasing = true;
        SetTarget(player);
    }
    else if (player == null && chasing)
    {
        chasing = false;
        PickNewWanderPoint();
    }


    if (!chasing)
    {
        wanderTimer -= MainEngine.PreviousFrameDelta;
        if (wanderTimer <= 0f) PickNewWanderPoint();
    }


    float horizSpeed = new Vector2(entity.velocity.X, entity.velocity.Z).Length() / GetAISpeed();
    locomotionLayer.nodes[1].influence = float.Clamp(horizSpeed, 0f, 1f);
    locomotionLayer.nodes[1].player.PlaybackSpeed = float.Clamp(horizSpeed, 0f, 1f);


    model.Update();


    float s = entity.TryStepUp();
    if (s <= 0f) entity.TryStepDown();


    base.OnUpdate(gameTime);
}

When we pass the player entity to SetTarget() the AI continuously re-paths as the player moves. When we pass a Vector3 it navigates to that fixed point and stops. Even when dead we still call model.Update() before returning so the ragdoll animates.

Note

Always call base.OnUpdate(gameTime) at the end. This is what actually runs the pathfinding and movement logic since it lives in the base class.

Rendering

public override void OnBeforeRender(GameTime gameTime)
{
    model.RenderShadowTexture(entity);
}


public override void OnRender(GameTime gameTime)
{
    model.transform =
        Matrix.CreateScale(entity.scale) *
        Matrix.CreateRotationY(aiLookAngle * Maths.Deg2Rad) *
        Matrix.CreateTranslation(entity.position + Vector3.UnitY * entity.GetRealBounds().Min.Y);


    model.CheckForLights(entity.orientedBounds.Center);
    model.Draw();
}

aiLookAngle is maintained by the base class and always faces the direction of travel. The GetRealBounds().Min.Y offset pins the model’s feet to the entity’s origin rather than centering it.

Death and Cleanup

public override void OnTakeDamage(DamageInfo info)
{
    HP -= info.damage;
    bool wasDead = IsDead;
    IsDead = HP <= 0;


    if (!wasDead && IsDead)
    {
        entity.CallOutput(“OnDeath”, this);


        Vector3 impulse = Vector3.Zero;
        if (info.hitLocation != null)
            impulse += info.damage * Vector3.Normalize(entity.orientedBounds.Center - (Vector3)info.hitLocation);
        if (info.from != null)
            impulse += info.damage * Vector3.Normalize(entity.orientedBounds.Center - info.from.orientedBounds.Center);


        model.BecomeRagdoll(impulse);


        entity.isSimulated = false;
        entity.ignoreCollision = true;
        entity.bounds = new BoundingBox();
    }
}


public override void OnDespawn()
{
    model.Dispose();
}

The wasDead check ensures death logic only fires once even if the grunt takes multiple hits in the same frame. BecomeRagdoll() hands the skeleton off to physics with the accumulated impulse.

Placing it in the Editor

Build and run with -compile. The [EntityDescriptor] attribute on GruntEnemy registers it with the engine, and it will appear in Rockwall 2’s entity list ready to place.

Make sure you also have GroundNode entities scattered around your level. The grunt needs these to pathfind. Place them in open areas, around corners, and through doorways. Without any nodes the grunt will attempt to run in a straight line from A to B and get stuck on any obstacle in the way.

Note

If the grunt doesn’t appear in the entity list, make sure you’ve run with -compile at least once since adding the class.

Where to Go From Here

The Wandering Grunt is a solid foundation. Some natural next steps: add a melee attack by calling player.TakeDamage() when within close range, with a cooldown between hits. Play a sound on detection using SoundDevice.Device.PlaySound(). Subclass GruntController to make variants. The sphere query pattern is also reusable for area-of-effect abilities, grunts alerting each other when one spots the player, or any proximity-based behavior you want to add. However, writing AI entities like this is far simpler using the NPC system.