Living entities are always doing something, otherwise they're no different from their inanimate counterparts. In the game, as in real life, the brain decides what to do and commands the body to actually do it. The AI system represents the brain, which for now can be treated as a black box that emits decisions, and will get its own post in the future. This post covers the body, or activity system, its design, and how it's implemented.
Activities
Each entity tracks the activity that they're currently working on, which describes the high level behaviour. For example: go and break that block, or go pick up that item and put it in that chest over there, or omg drop everything and run away from that threat.
There can only be a single activity active at a time, which can be gracefully interrupted if a higher priority need arises. I plan to let entities do some number of unrelated concurrent tasks eventually (e.g. haul something while going somewhere for a different reason), but that's a long way off - the game should actually be playable first.
Tick frequency
The system that handles activities for every entity is ticked every game tick, as it's the main observable behaviour of an entity. Any latency by only ticking it occasionally would certainly be obvious to the player.
The AI system, however, is ticked less often (every half second/10 game ticks), because considering all possible behaviours and choosing the best is quite an intensive operation. Reevaluating their life choices as often as 20 times a second would be burning a huge amount of cycles for not much gain, so slowing it down (or even staggering a few entities at a time across multiple frames) gives us a large chunk of the frame budget back at the small cost of entities being slightly slower to react to the world.
I think this is an obvious trade-off to take, given the depth of complexity this game will eventually have. The small impact on reaction time of living entities will barely be noticeable if at all, but the stuttering lag after having more than 10 alive definitely would.
Polling considered harmful
Supporting a large number of living entities across the world is a key goal for this project. This comes with the need to cut down on any unnecessary work each tick. An obvious place to start here is avoid polling for completion per-entity per-tick.
Consider the activity "go to this position and break this block with this tool in your bag". A naive implementation would have the activity poll to check 1) are we there yet 2) have we got that tool in our hand yet 3) is the block broken yet, every tick. If the block is far away, we would be wasting a lot of cycles checking the distance constantly. The path following system already knows how to travel to a block and when it's arrived, so can we harness this to let us know when instead of asking non-stop?
Absolutely1 - using an event-driven architecture, we can just wait around doing nothing until the engine notifies us of something happening. Consider the diagram below:
This illustrates 2 separate timelines for the same activity, which has the entity walk to a block and break it. Each vertical line represents a game tick where this activity is updated/runs code.
The upper timeline shows the polling implementation, which is busily querying the world to check for completion each tick. Each individual check may not be too expensive but adds up if every living entity is doing the same constantly. Won't somebody please think of the children cycles?!
The lower is a much more pleasant sight; the activity only ever needs to do something when the previous step is complete. While the entity is travelling or punching there's no extra work to be done, freeing up a lot of cycles. Even better, the activity overhead is constant regardless of its duration! That is, the destination could be thousands of blocks away or 5, and the work required is just as low.
Here are some examples of events to help put the following section into context:
pub enum EventPayload {
/// Path finding ended
Arrived(Result<WorldPoint, NavigationError>),
/// Item entity (the subject) picked up by the given holder
PickedUp(Result<Entity, PickupItemError>),
/// Food entity has been fully eaten
Eaten(Result<(), ()>),
/// Item entity (subject) has been equipped in the inventory of the given entity
Equipped(Result<Entity, EquipItemError>),
// ...
}
Activity lifecycle
Active state
The activity is initially active, and ticked each game tick it's in this state. The function signature is roughly fn on_tick(&mut self, world: &EcsWorld, subscriptions: &mut Vec<Subscription>) -> ActivityResult)
. Breaking this down:
&mut self
: It can mutate its internal state.world: &EcsWorld
: An immutable reference to the world. Through this it can query the world and queue up mutable updates for the following tick (e.g. adding components, creating new entities).subscriptions: &mut Vec<Subscription>
: A means of subscribing to specific events about a specific entity, this is covered in more detail below.-> ActivityResult
: The return value is one of the following:Ongoing
: Remain in the active state and get ticked again next tickFinished(FinishType)
: This activity has finished. The type of finish is one ofSuccess
,Failure(Error)
orInterrupted
, and influences how the game continues. For example, if interrupted maybe this activity should be considered again in the near future, or if unsuccessful try something different.Blocked
: put this activity in the blocking state and don't tick until it's active again, see below.
Blocking state
The activity is inactive and waiting on the events it subscribed to when on_tick
transitioned it to this state. When events are emitted by the game engine, they are passed to a function with the rough signature fn on_event(&mut self, event: &EventPayload, subject: Entity) -> (UnblockResult, UnsubscribeResult)
. Breaking this down too:
&mut self
: As before, it can mutate its internal state. This is essential before passing control flow back toon_tick
in the active state.event: &EventPayload
: The triggering event. This is the payload represented by the enum above, and holds the event type and any additional context, such as if the entity actually arrived at its destination or if navigation was aborted or failed.subject: Entity
: The entity that this event related to, for example who has arrived at his destination, or the item that has been equipped.-> (UnblockResult, UnsubscribeResult)
: The return value indicates if the activity should return to the active state (or remain blocked), and if its event subscriptions should be cleared.
Event subscriptions are based on the subject entity and event type of interest. The "go to destination" activity would subscribe only to the Arrived
event with the travelling entity as the subject, whereas the "pick up item" activity would be interested in all events relating to the item in question, to detect if it has already been picked up or consumed or made inaccessible in some way.
In a large world there will be possibly hundreds of events being fired off each tick. It would be a waste to go through all this effort to avoid polling activities just to quadratically run every event past every blocking activity. Therefore care is taken that only events about pertinent entities are passed to an interested activity2. Events about entities that no-one is interested in are dropped on the floor immediately.
An important point to note about the event handler is that there is no reference to the world. The function is isolated from the game engine and cannot affect any state outside of the activity. This simplifies the state machine and ensures all "real work" is done in only one place, in on_tick
.
The power of the ECS architecture means that a blocked activity really is "asleep" and does not incur any overhead while ticking the active ones each tick3. This is indicated with the addition of a zero-sized component BlockingActivityComponent
to the entity. The component requires no storage and membership boils down to a blazing fast bitset check.
Finished state
Technically this is a third state but it's a very simple one. When an activity ends gracefully, fails, or is interrupted its on_finish
method is called, which allows it to clean up before yielding to the new activity. This is passed a reference to the EcsWorld
like on_tick
, so it's able to mutate the world if necessary.
An interesting implementation is that of the hauling subactivity, where an entity carries another item to a destination. If this finishes successfully the item should be placed as desired (on the ground or in a container), but if it's interrupted then the item may be dropped to the floor instead.
Subactivities
Many activities share common behaviours such as "walk here" or "pick this up", which can be implemented once and reused across activities. These primitives are known as subactivities, and implement most of the actual work that an activity does. This reduces the responsibilities of an activity to simply:
- Determine control flow, i.e. which subactivities to delegate to, and when it's finished
- Handle events to manage internal state
An added benefit is that the player can more clearly see what an entity is doing at any time; in the UI they see both a high level description (the activity) and the current task (the subactivity), as in the GIF below.
Summary
Watch this space for a follow-up post on how the AI system makes its decisions. If you're working on a similar system, I hope this can save you some time and code churn!
-
If you were expecting a sad Consuela meme here, sorry to disappoint. ↩
-
The code for passing events around can be found here if you're interested. ↩
-
A naive implementation might have a
is_blocked
flag in each activity and startson_tick
with aif self.is_blocked { return; }
. The overhead of a function call and likely branch misprediction for every activity is a waste of our precious frame budget and could have a noticeable effect in large, busy worlds. ↩