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.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.
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:
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:
{
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:
{
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:
{
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.
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:
{
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:
{
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.
-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! :)