# Activity, Objective, Task, Action

https://jaredrhodes.com/blog/activity-objective-task-action/

Every agent system eventually invents the same vocabulary and then ruins it. Somebody says "task," somebody else says "action," a third person says "behavior tree node," and within a month all three words mean all three things and nobody can review a pull request. I have watched it happen more times than I want to count.

Westworld of Warcraft has four words. They are load-bearing and they are not synonyms.

## One Sentence, Four Kinds of Thing

Take one sentence a person might say about a game: *"I ran Upper Blackrock Spire last night."*

Unpack it and you get four completely different kinds of thing:

- **The run itself** - hours long, involved nine other people, had a name.
- **Getting to the entrance** - a discrete goal with a definite end state, composed of many smaller things.
- **Walking through the Burning Steppes** - a repeating loop with stuck detection, re-pathing, and a way to give up.
- **Pressing the forward key for one frame** - the smallest thing a player can actually do.

They differ in duration by six orders of magnitude, they differ in who decides them, and - critically - they differ in whether anything outside the bot process needs to know about them. Flatten them into one concept and you get a system where a test cannot tell whether it is asserting on a strategy or a keystroke.

## The Four Layers

<figure class="diagram">
  <a href="/assets/diagrams/westworld-of-warcraft/aota-layers.svg" target="_blank" rel="noopener" title="Open the full-size diagram"><img src="/assets/diagrams/westworld-of-warcraft/aota-layers.svg" width="940" height="510" alt="Four stacked layers with their owners: Activity owned by the state manager and composer, Objective as the only layer that crosses the wire, Task owned by the bot runner behavior tree, and Action as pure local code that never leaves the process" loading="lazy" decoding="async" data-theme-filter="off" /></a>
</figure>

| Layer | Definition | Crosses the wire |
| --- | --- | --- |
| **Activity** | A major, usually dynamic event supporting any number of characters: a raid, a battleground, a dungeon run, a multi-hour farm. | No |
| **Objective** | One high-level state change, composed of tasks. Travels as `ObjectiveMessage`. | **Yes - the only one** |
| **Task** | One behavior-tree node on a last-in-first-out stack, driving a single state change with verification and failure handling. Pushes child tasks. | No |
| **Action** | An atomic local primitive: one memory read, one bit write, one opcode send, one key press. | No |

The single most useful line in the whole system is the wire column. Exactly one layer is observable from outside the bot process, and everything about testing, debugging, and service boundaries follows from that.

### Where People Get It Wrong

The recurring mistake is putting compound operations in the Action layer because they *feel* atomic from the caller's side.

| Looks atomic | Actually is | Why |
| --- | --- | --- |
| `MoveToCoord(coord)` | A Task | Loops position reads, movement bit writes, and heartbeat opcodes over many ticks with stuck detection |
| `UseAbility(id, target)` | A Task | Checks the global cooldown, sets the target, sends the cast opcode, then verifies the cast result |
| `InviteToParty(player)` | A Task | Sends the invite, then polls group membership until accepted or timed out |
| `LootCorpse()` | A Task | Opens the loot window, enumerates slots, takes items, verifies bag deltas |

The rule that settles every argument: **an Action cannot fail partway through.** It either happened or it did not. If a thing can be half-done, it is a Task and it needs verification.

Convenience wrappers on the object manager - `MoveToAsync`, `UseAbilityAsync`, `TurnInQuestAsync` - are all Task-level. They exist because writing the same seven-Action sequence in twelve places is worse than naming it once. Naming it does not make it atomic.

## Why the Wire Layer Is Deliberately Expensive

The set of objective types a state manager can request is a closed enum. Adding one costs five coordinated edits:

1. Add the value to the protobuf definition.
2. Add the mirrored value in the managed enum.
3. Add the mapping in the dispatcher.
4. Add the sequence builder for both runtimes.
5. Regenerate protobuf for every consumer.

That is annoying on purpose. Every time someone reaches for a new objective type, the cost forces the question: *could this be a new Task that composes existing objectives instead?* Almost always, yes.

The alternative - an open-ended wire vocabulary - produces a protocol that grows one verb per feature until nobody can enumerate what a bot can be asked to do. A closed set of verbs that everyone can read is a feature - twenty-six defined today, with IDs reserved through sixty-three for the ones the roadmap will want.

Here is the current shape of that vocabulary:

```csharp
public enum ObjectiveType
{
    Travel = 0,           // arrive at a named-location position
    Interact = 1,         // open conversation, click an NPC
    AcceptQuest = 2,
    TurnInQuest = 3,
    Kill = 4,             // kill N of a creature entry
    Collect = 5,          // gather N of an item
    UseGameObject = 6,    // chest, door, lever, herb, ore, fishing pool
    CastSpell = 7,
    Escort = 8,
    EncounterTrash = 9,   // a dungeon or raid trash leg
    EncounterBoss = 10,
    Loot = 11,
    Queue = 12,           // battleground or dungeon queue
    Cap = 13,             // node or flag capture
    Hold = 14,            // node defense
    Craft = 15,
    Train = 16,
    Bank = 17,
    Mail = 18,
    Auction = 19,
    Vendor = 20,
    Rebind = 21,          // hearthstone bind
    Equip = 22,
    Loop = 23,            // gathering route, hotspot loop, any "until X" sweep
    GroupForm = 24,
    WorldEventStage = 25,
    // reserved through 63
}
```

Read that list and you can predict what the server population is capable of without reading any implementation. That is the point.

## From a Sentence to a Keypress

Take the dungeon run from the opening and trace it all the way down.

<figure class="diagram">
  <a href="/assets/diagrams/westworld-of-warcraft/aota-worked-example.svg" target="_blank" rel="noopener" title="Open the full-size diagram"><img src="/assets/diagrams/westworld-of-warcraft/aota-worked-example.svg" width="1000" height="580" alt="Worked example: the dungeon activity produces an objective which crosses the wire as an ObjectiveMessage, the objective builds a travel task which pushes a child movement task, and the movement task issues atomic actions per tick - a position read, a movement bit write, a heartbeat opcode, a stop opcode, and a verifying position read" loading="lazy" decoding="async" data-theme-filter="off" /></a>
</figure>

```text
Activity   dungeon.ubrs
  +- Objective   ubrs.reach-flame-crest   <- ObjectiveMessage { Type = Travel }
       +- Task   TravelToTask(coord)
            +- Task   GoToTask            <- the universal child
                 |- Action  ReadPlayerPosition()
                 |- Action  WriteMovementBit(forward, true)
                 |- Action  SendOpcode(MSG_MOVE_HEARTBEAT, payload)
                 |- Action  SendOpcode(MSG_MOVE_STOP, payload)
                 +- Action  ReadPlayerPosition()   <- verify: inside radius
```

Five things worth noticing.

**`GoToTask` is the universal child.** Almost every task in the system eventually needs to be somewhere else first, so movement is factored out once. If you are writing a new task and you find yourself writing pathing, stop.

**One Action, two completely different implementations.** In the foreground runtime `ReadPlayerPosition` is a plain memory read off the client's own object manager. The background runtime has no memory to read, so coordinates come from the movement block of `SMSG_UPDATE_OBJECT` and the `MSG_MOVE_*` traffic, and its object manager holds the latest value. Neither one reaches for an update field, because position has never been one. The task above does not know or care which implementation answered.

**The last Action is a verification read.** Arriving somewhere is exactly the kind of operation where a naive implementation sends the movement opcodes and then sleeps for the estimated travel time. That is a blind sequence, and blind sequences are banned. The task is not arrived until a position read puts the character inside the radius. The same shape covers the interactive objectives: a `UseGameObject` task sends `CMSG_GAMEOBJECT_USE` at a chest or a lever and then reads that gameobject's own state byte, rather than assuming the click landed.

**Only one line crossed a process boundary.** The state manager said "reach Flame Crest." It did not say how, and it never hears about the re-path around the Burning Steppes patrols.

**Tasks push children; objectives do not.** An objective builds exactly one head task. That task may push a whole subtree. This keeps the recursion in one place instead of spread across two layers.

## Objectives Are Generated, Not Written

Here is the part that surprises people.

The activity catalog is **86 hand-authored rows** - data only, no logic. A row declares what an activity is and leaves the how to the composer:

```csharp
public sealed record ActivityDefinition
{
    public required string Id { get; init; }                // "dungeon.wc"
    public required ActivityFamily Family { get; init; }
    public required string Location { get; init; }          // "Wailing Caverns"
    public required LevelRange LevelRange { get; init; }    // 17-24
    public required FactionPolicy FactionPolicy { get; init; }
    public required RoleTemplate RoleTemplate { get; init; } // tank, healer, 3 dps
    public required EntryRequirements EntryRequirements { get; init; }
    public required TravelTarget TravelTarget { get; init; }
    public required TimeSpan ExpectedDuration { get; init; }
    public required HumanJoinPolicy HumanJoinPolicy { get; init; }
    public required BotSelectionPolicy BotSelectionPolicy { get; init; }
    public required IReadOnlyList<RewardDefinition> Rewards { get; init; }
    public required string TaskFamily { get; init; }
}
```

The **objective sequence** for that row gets composed at runtime, per bot, from four inputs:

- **The catalog row**, which contributes shape, legality gates, travel target, and role template.
- **The world database**, which knows which quests exist, who gives them, what drops where, which spawns are nearby, and what the trainer teaches.
- **The bot's snapshot**: level, class, race, faction, reputation, completed quests, inventory, keys, attunements, position.
- **The unlock graph**, which says which objectives open which other objectives.

The composer reads the world server's own tables - quest templates and their relations, creature and gameobject templates and spawns, item templates, vendor and trainer lists, area-trigger teleports, loot templates - and synthesizes an objective list for *this* bot at *this* tick. Then it prepends precondition objectives for anything the entry requirements demand but the bot does not have.

The consequence is worth stating plainly: **nobody wrote a script for Wailing Caverns.** Two bots assigned the same catalog row get different objective sequences, because one already did the pre-quest and the other did not, and because one is a druid who can skip a fight the other has to take.

That is also why the world database is read-only from the bot side. Every mutation goes through the server's own administrative interface. The composer treats the world as a fact source, and a fact source you write to is not a fact source.

## Tasks, Verification, and Failure

A task is a behavior-tree node with three responsibilities: drive one state change, verify it, and fail informatively.

```csharp
public interface IObjective
{
    string Id { get; }                          // "ubrs.reach-flame-crest"
    ObjectiveType Type { get; }
    IObjectiveEndState EndState { get; }        // predicate over the snapshot
    IReadOnlyList<ObjectiveGate> Gates { get; } // start-time preconditions

    IBotTask BuildHeadTask(BotTaskContext ctx);
    bool CheckCompletion(WoWActivitySnapshot snapshot);
    void OnHeadTaskTerminal(BotTaskStatus terminal, string? reason);
}

public interface IObjectiveEndState
{
    bool IsSatisfied(WoWActivitySnapshot snapshot);
    string DiagnosticLabel { get; }   // "QuestLog[slotForQ132].Counter >= 8"
}
```

`DiagnosticLabel` is the small detail that pays for itself weekly. When a bot stalls, the operator console does not say "task failed." It says the bot was waiting on `QuestLog[slotForQ132].Counter >= 8` and the counter is at 5. That is the difference between an hour of log archaeology and a ten-second answer.

The stack discipline is equally deliberate. Tasks push and pop on a last-in-first-out stack. A parent pushes `GoToTask`, `GoToTask` completes and pops, the parent resumes. There is no global "what is the bot doing" variable to get out of sync - the top of the stack is the answer, and it is published on every snapshot.

## The Contract with Tests

Because objectives are the only thing on the wire, they are the only thing a test may legally drive, and snapshots are the only thing a test may legally read.

A test declares an activity, lets the composer and the resolver do their work, and asserts on published snapshot fields:

```text
current_activity_id      // "dungeon.ubrs"
current_objective_id     // "ubrs.reach-flame-crest"
current_objective_type   // Travel
current_task_name        // top of the task stack
advice_log[]             // what was suggested, and whether it was used
```

That last field is the advisory layer's paper trail, and it gets its own post in [Advisory, Not Authoritative](/blog/advisory-not-authoritative/).

A test that constructs an `ObjectiveMessage` in its own body and dispatches it is not testing the bot. It is remote-controlling it, and it has silently skipped every layer that decides *what to do* - which is where the interesting regressions live. [Proving a World Is Alive](/blog/proving-a-world-is-alive/) works through what that leaves you with; the point here is that the rule falls straight out of the layer model.

## How a Vocabulary Rots

Nothing about this model breaks loudly. It rots, one reasonable-looking pull request at a time, and it starts with a new contributor adding an objective type because that was the shortest path.

1. A quest requires using a specific item on a specific corpse.
2. Rather than compose `UseGameObject` and `Interact`, someone adds `UseItemOnCorpse` to the enum.
3. It works. It ships.
4. Six months later the enum has 140 values, twelve of which are near-duplicates, and the dispatcher has a switch nobody will refactor.

The system has not gained a capability. It has gained a synonym. And because the enum is on the wire, every synonym is permanent - field numbers are never reused, so the cost is paid forever.

| Situation | Correct response |
| --- | --- |
| A behavior does not fit an existing objective type | Write a Task that composes existing types |
| A task needs to be somewhere first | Push `GoToTask`; do not write pathing |
| A task cannot tell whether it succeeded | The end state is missing; write one |
| An objective needs to push its own children | It does not; its head task does |
| A test needs to force a specific action | It belongs in the action-dispatch suite, not in live validation |

## Four Words, Still Four Meanings

The vocabulary is holding when every objective declares an end-state predicate with a human-readable diagnostic label, no task validates state with a sleep or a counter or a fixed repeat count, the snapshot publishes the activity, the objective, and the top of the task stack on every tick, and the composer reads the world database without ever writing to it. Those I check the way you check a lock: by trying the door occasionally and moving on.

The measure I actually watch is the ratio. The objective enum has to grow more slowly than the task library, because the moment it does not, somebody has started encoding features in the wire vocabulary and the rot above has begun. The other one is diagnostic: a stalled bot has to be explainable from its snapshot alone, without attaching a debugger to anything. If I have to attach a debugger to find out what a bot is waiting on, the four words have collapsed back into one and I have written the system I opened this post complaining about.

## Related Posts

The runtimes that execute all of this are in [Two Ways to Wear a Character](/blog/two-ways-to-wear-a-character/). The universal child task depends on the navigation stack in [Teaching a Bot to Walk](/blog/teaching-a-bot-to-walk/). How the composer breaks ties is covered in [Advisory, Not Authoritative](/blog/advisory-not-authoritative/).

## References

- [Protocol Buffers language guide (proto3)](https://protobuf.dev/programming-guides/proto3/)
- [Protocol Buffers: updating a message type](https://protobuf.dev/programming-guides/proto3/#updating)
- [Behavior trees in robotics and AI](https://arxiv.org/abs/1709.00084)
- [Hierarchical task network planning](https://www.cs.umd.edu/~nau/papers/nau2003shop2.pdf)
