Dom Williams

Devlog #4: entity activities

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.

GIF showing some entities walking around doing various activities.Watch them bumble around hauling things to a chest and digging a hole. This is the majority of the activities implemented so far, with a million more well on the way.

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:

Diagram comparing polling to an event-driven architecture.As you can see, polling sucks and will make your dad turn this car around if you don't stop asking!

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

State machine for activities.The basic lifecycle of an activity, and the possible states it can be in.

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:

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:

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:

  1. Determine control flow, i.e. which subactivities to delegate to, and when it's finished
  2. 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.

GIF of an entity hauling an item to a container, showing the current activity and subactivity in the UI. The main activity here is to haul entity E1:4 (the item) into the chest entity E1:6, and this remains the case throughout. The subactivity display shows the separate parts of the job - going to the item, picking it up, then carrying it to the chest.

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!


  1. If you were expecting a sad Consuela meme here, sorry to disappoint. 

  2. The code for passing events around can be found here if you're interested. 

  3. A naive implementation might have a is_blocked flag in each activity and starts on_tick with a if 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.