Writing Your First Entity

What We're Building

By the end of this guide you'll have a fully working enemy called the Wandering Grunt. When idle it wanders between random points in the level. When the player gets close enough 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 using sphere queries.

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

Create the File

Create a new file called 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 WorldEntity subclass at the bottom is what gets registered with the engine and placed in the editor. The GruntController above it is where all the behavior lives. LivingActor is an interface that marks this as something that can take damage and die. The three abstract methods from AIEntityTemplate, GetAIAccel, GetAISpeed, and GetThinkTimeAfterPathCompleted, must always be implemented.

Notice that standingBounds is defined as internal static on the controller so that GruntEnemy can reference it in its constructor, keeping the bounds definition in one place.

Note axisAlignedBox = true keeps the physics body upright so the grunt can't tip over. isSimulated = true means the physics engine drives it.

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 = 8f;

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),
        new AnimNode(model.model.Sequences.Find(a => a.Name.Equals("m_runforward")), 0f, true));
    locomotionLayer.influence = 1f;

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

    aiSteerSpeed = 400f;

    PickNewWanderPoint();
}

The animation layer blends between an idle and a run cycle. We're using the player model from the template, but you can swap in your own model path when you have one. aiSteerSpeed controls how quickly the grunt rotates to face its movement direction; lower values make it feel heavier.

Wandering with SetTarget(Vector3)

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;

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

    SetTarget(target);
    wanderTimer = WanderInterval;
}

Without the floor snap the grunt might try to walk to a point floating in mid-air. The pathfinder uses GroundNode entities you place in Rockwall 2 to route around obstacles, so as long as your level has nodes it will find its way.

Detecting the Player

To find the player we use Collision.GetEntitiesInSphere(), the same approach the explosion system uses internally to find what to damage. It returns all entities within a radius, so we just check whether 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, whether that's proximity mines, area attacks, or grunts alerting each other when one spots the player.

OnUpdate

Now we tie it all together. The grunt checks for the player each frame, switches between wandering and chasing, updates its animations, and steps over geometry:

public override void OnUpdate(GameTime gameTime)
{
    if (IsDead)
    {
        // Otherwise the ragdoll wont update
        model.Update();
        return;
    }

    var player = FindPlayerInRange(DetectRange);

    if (player != null && !chasing)
    {
        // Player spotted! Chase them directly using the entity overload
        chasing = true;
        SetTarget(player);
    }
    else if (player == null && chasing)
    {
        // Lost the player... go back to wandering!
        chasing = false;
        PickNewWanderPoint();
    }

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

    // Blend animations based on movement speed
    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();

    // Always call base last, this is what actually runs pathfinding
    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. Note that even when dead we still call model.Update() before returning, because without it the ragdoll won't animate.

Note Always call base.OnUpdate(gameTime) at the end. Skipping it means the pathfinding and movement logic won't run at all since it lives entirely in the base class.

Rendering

Fill in the render callbacks to draw the model and its shadow:

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 & Cleanup

Fill in the final two callbacks. On damage we check for death and trigger a ragdoll. On despawn we free the model's resources:

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

    if (!wasDead && IsDead)
    {
        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 the 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 so the body flies in the direction of the killing blow. After that we clear bounds and disable collision so the corpse doesn't block the player.

Placing it in the Editor

Build and run with -compile. The [EntityDescriptor] attribute on GruntEnemy registers it with the engine, and after compiling 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, so place them in open areas, around corners, and through doorways. Without any nodes, the grunt will just attempt to run in a straight line from A to B.

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 to build on. 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(). Expose HP and detection range as editor properties so you can tune them per-instance without recompiling. Subclass GruntController to make variants like a fast fragile runner or a slow heavy brute. The GetEntitiesInSphere pattern can also be reused for grunts alerting each other when one spots the player, or for any area-of-effect behavior you want to add.

If you wanted, you could also give the grunt a Weapon, and try making him shoot at the player! Perhaps that's a homework assignment! :)