Gameplaycom.zoa.objectives · v0.1.0

ZOA Objectives

Missions, per-entity objective progress, authored triggers that arm themselves, and rewards.

Objectives is three cooperating services and a trigger layer. IObjectiveTracker owns per-entity progress and the objective state machine. IMissionService groups objectives into missions, gates them on prerequisites, and drives mission lifecycle from objective outcomes. IRewardGranter turns a completed mission into a list of granted rewards. All three are resolved through FoundryServiceRegistry by a scene installer, so gameplay code publishes events rather than calling into any of them directly.

The interesting half is how progress arrives. An ObjectiveDefinitionAsset carries a polymorphic ObjectiveTriggerSource through SerializeReference, so an author picks Kill, Collect, Reach Zone, Interact, Survive or Extract in the inspector and fills in a filter, with no per-objective C# at all. Those triggers subscribe to ObjectiveTriggerBus, a static event hub that producer packages publish to, and ObjectiveTriggerArmer is the piece that wires a trigger's callback into the tracker and tears it down again when the objective ends.

Missions are unordered: accepting one activates all of its objectives at once and completion is order-independent. Step-by-step gating comes from ObjectiveSequenceRunner, a separate engine that tracks one step at a time and advances only when the current one completes.

How it works

Concepts

Objective state and progress

An ObjectiveProgress row is per entity and per objective. It carries the current count, the required count, an ObjectiveState, and UTC timestamps for when it started and when it completed. The normalised Progress property is the clamped ratio of current to required, returning zero when the required count is zero rather than dividing by it.

ObjectiveState runs Locked, Available, Active, Completed, Failed, Expired. Track moves an objective into Active for an entity, ReportProgress adds an increment, and crossing the required count completes it. Complete and Fail force the terminal states directly for objectives that gameplay resolves itself.

The tracker stores the count in an IQuantityLedger rather than in its own dictionary, which is the project-wide rule that scalar counters live in one ledger. What stays local is the metadata: state, required count and timestamps. ObjectiveProgress instances handed back by GetProgress are refreshed from the ledger at call time, so a caller always sees the current count even if something else wrote to the ledger. Passing the same ledger instance to the currency and progression services gives one unified scalar store across all of them.

Triggers are authored data

ObjectiveTriggerSource is an abstract serializable base with three members: Activate taking a progress callback, Deactivate, and a GetEditorSummary used for the inspector preview. Six concrete sources ship. KillEnemyTriggerSource filters on archetype id and weapon id, either optional. CollectItemTriggerSource filters on item slug and can either count by collected quantity, so a stack of five advances by five, or count each grant as one. ReachZoneTriggerSource and ExtractAtTriggerSource both match a zone slug, and they are separate because extraction is a committed end-of-mission action rather than walking through a volume. InteractWithObjectTriggerSource matches an interactable id. SurviveTimeTriggerSource increments once per configured tick of elapsed time.

Because the field is declared with SerializeReference, an author swaps the concrete trigger type in the inspector without the asset changing its scripted type. A null trigger is legal and means the objective advances only through explicit tracker calls, which is the programmer-driven case.

Every trigger's Activate is required to be idempotent: the shipped implementations call Deactivate first if they are already active, so a double-arm cannot double-subscribe and double-count.

The trigger bus and who publishes to it

ObjectiveTriggerBus is a static hub with five events, EnemyKilled, ItemCollected, ZoneEntered, InteractCompleted and Extracted, each carrying a small readonly context struct. Being static means a publisher does not have to resolve a service first, which matters because the publishers are scattered across combat, inventory, interaction and extraction code.

The five Raise methods are the publisher surface: RaiseEnemyKilled with an archetype id, weapon id and victim entity id, RaiseItemCollected with an item id, quantity and collector, RaiseZoneEntered, RaiseInteractCompleted and RaiseExtracted each with an id and an entity. The payloads stay small, so adding a new producer does not change the schema, and filtering happens inside the trigger source instead of at the publish site.

ObjectiveTriggerVolumePublisher is the scene-side convenience: a collider that publishes Zone, Interact or Extract on entry, with a trigger id, an optional exit id, a one-shot flag and a required tag defaulting to Player. It forces its collider to isTrigger on Awake.

Arming an authored trigger

An authored trigger sitting on an asset is inert until somebody calls Activate on it. ObjectiveTriggerArmer is the single canonical bridge that does so. Arm takes an objective id, an entity id and a trigger, activates the trigger with a callback of the form amount goes to tracker.ReportProgress for that objective and entity, and records it under a composite key.

The armer subscribes to the tracker's ObjectiveCompleted and ObjectiveFailed events and deactivates the trigger automatically when its objective ends, so a completed kill objective stops counting kills. Arming a null trigger or arming the same objective twice are both no-ops, and the composite key uses a separator that cannot appear in an objective slug or an entity id so two different pairs can never collide.

Every consumer that wants authored triggers to work routes through this: the sequence runner, mission acceptance, and any bespoke bootstrap.

Missions are a set, sequences are a chain

MissionService holds registered MissionDefinition records and per-entity MissionState. AcceptMission checks that every prerequisite mission id is completed for that entity before it succeeds, and it constructs the objective progress rows for the mission's objectives. It also subscribes to the tracker's ObjectiveCompleted and ObjectiveFailed events at construction, which is how mission completion follows from objective completion rather than being asserted separately.

A mission's objectives are an unordered set. All of them become active on accept and all of them must complete, but not in any particular order; the list order on the asset only controls HUD ordering. IsRepeatable allows re-acceptance after completion, and ExpirationSeconds gives a mission a time-to-fail where zero means no expiry.

ObjectiveSequenceRunner covers the ordered case. It is a separate engine because the mission service does not gate objectives by order. It walks an ordered list of IObjectiveStep, tracking only the current step so the HUD shows one thing at a time, arming that step's trigger through the armer, and advancing when the tracker reports the step complete. It raises StepStarted with the index and step, and SequenceCompleted once at the end.

IObjectiveStep is the minimal surface the runner needs: an id, a description, a required count and a trigger. ObjectiveDefinitionAsset implements it, and so does the plain ObjectiveStep class, which exists so a tutorial or guided tour can be built in code without hand-authoring fragile SerializeReference trigger YAML.

Rewards route by type

A RewardDefinition names a reward id, a display name, a RewardType, an item id and a quantity, plus tags for filtering. RewardType is the routing key: Item goes to inventory, Currency to the wallet, Experience to progression, Unlock to the unlock registry, and Reputation to the faction service. The reward asset holds only the identifier and the quantity; the domain service that owns the type resolves them.

IRewardGranter has two methods, GrantReward for a single definition and GrantMissionRewards which looks up a mission and grants everything on it, returning the ids that were actually granted. MissionService raises RewardGranted with that list when a mission completes.

The shipped RewardGranter records what was granted per entity rather than performing the grants itself. Wiring each RewardType through to the service that owns it is integration work the consuming project does.

In the editor

Screens

Screenshot pending

/screenshots/objectives-definition-inspector.png

The inspector for an ObjectiveDefinitionAsset showing the verb type and required count, with the SerializeReference trigger field expanded to a KillEnemyTriggerSource and its archetype and weapon filters visible.

An objective asset with a trigger source selected.

Screenshot pending

/screenshots/objectives-workbench-trackers.png

The Objectives module with the Trackers view selected during play mode, listing tracked objectives per entity with their current and required counts and states.

The Trackers capability showing live progress.

Screenshot pending

/screenshots/objectives-hud-list.png

Play mode with ObjectiveListHUDHost active, showing two or three objective rows with icon, description and a progress readout such as three of five.

The objective list on the in-game HUD.

Setup

Workflow

  1. 01

    Install the services

    Put a ZOAObjectivesSceneInstaller in the scene. It registers IObjectiveTracker, IRewardGranter and IMissionService into FoundryServiceRegistry and spawns the ObjectiveTickPump that survive-time triggers need. Awake delegates to EnsureRegistered, which tests can call directly without a scene.

  2. 02

    Author objectives with their triggers

    Create an ObjectiveDefinitionAsset from the Objective Wizard or directly, set the verb type and required count, then pick a concrete trigger in the SerializeReference field and fill in its filter. Note the asymmetry in the shipped filters: the kill and extract triggers treat an empty filter as match-anything, while the collect, zone and interact triggers treat an empty filter as match-nothing.

  3. 03

    Publish from gameplay

    Producer code calls the bus rather than the tracker. Raise EnemyKilled from the combat adapter, ItemCollected from the pickup grant callback, and the zone, interact and extract events from scene volumes or the ObjectiveTriggerVolumePublisher. The publisher does no filtering; the trigger source does.

  4. 04

    Arm the triggers

    Nothing arms itself. Create one ObjectiveTriggerArmer over the tracker and call Arm for each objective you have started, passing the objective id, the entity id and the asset's trigger. The armer deactivates each trigger automatically when its objective completes or fails, so the teardown is not yours to remember.

  5. 05

    Group into missions, or chain into a sequence

    Use a MissionDefinitionAsset when the objectives are a set and the player can do them in any order, listing prerequisite missions to gate availability. Use ObjectiveSequenceRunner when they must happen in order, which is the shape a tutorial or guided tour wants; the runner shows one step at a time and arms only that step's trigger.

  6. 06

    Show it on the HUD

    Add an ObjectiveListHUDHost to the HUD root and give it the entity id, conventionally player for the local player. It resolves the tracker lazily and retries every Update until one exists, so ordering against the installer does not matter, and the ShowCompleted flag decides whether finished objectives grey out or disappear.

Surface

Key types

IObjectiveTracker

service

Per-entity objective progress and the objective state machine. Track starts one, ReportProgress advances it, and Complete or Fail force a terminal state for objectives gameplay resolves itself.

  • void Track(ObjectiveId objectiveId, string entityId)
  • void ReportProgress(ObjectiveId objectiveId, string entityId, int amount)
  • ObjectiveProgress GetProgress(ObjectiveId objectiveId, string entityId)
  • void Complete(ObjectiveId objectiveId, string entityId) / void Fail(...)
  • event EventHandler<ObjectiveStartedEventArgs> ObjectiveStarted
  • event EventHandler<ObjectiveProgressUpdatedEventArgs> ProgressUpdated
  • event EventHandler<ObjectiveCompletedEventArgs> ObjectiveCompleted
  • event EventHandler<ObjectiveFailedEventArgs> ObjectiveFailed

IMissionService

service

Mission lifecycle per entity. Registration, acceptance gated on prerequisites, abandonment, and queries for what is active or available.

  • void RegisterMission(MissionDefinition mission)
  • bool AcceptMission(MissionId missionId, string entityId)
  • void AbandonMission(MissionId missionId, string entityId)
  • List<MissionId> GetActiveMissions(string entityId) / GetAvailableMissions(string entityId)
  • MissionState GetMissionState(MissionId missionId, string entityId)
  • event EventHandler<MissionAcceptedEventArgs> MissionAccepted
  • event EventHandler<MissionCompletedEventArgs> MissionCompleted
  • event EventHandler<MissionFailedEventArgs> MissionFailed
  • event EventHandler<RewardGrantedEventArgs> RewardGranted

IRewardGranter

service

Grants a single reward definition or every reward on a mission, returning the ids actually granted. Routing each RewardType to the service that owns it is integration work.

  • bool GrantReward(RewardDefinition rewardDef, string entityId)
  • List<RewardId> GrantMissionRewards(MissionId missionId, string entityId)

ObjectiveTracker

class

The default tracker. Stores progress counts in an IQuantityLedger rather than its own dictionary and keeps state, required count and timestamps locally. Also implements IObjectiveTrackerDiagnostics.

  • ObjectiveTracker()
  • ObjectiveTracker(IQuantityLedger ledger)

MissionService

class

The default mission service. Constructed with a tracker and a granter, and it subscribes to the tracker's completion and failure events so mission outcomes follow from objective outcomes.

  • MissionService(IObjectiveTracker objectiveTracker, IRewardGranter rewardGranter)

ObjectiveTriggerSource

class

The serializable polymorphic base every trigger derives from. Activate must tolerate being called twice without double-subscribing; Deactivate must tolerate being called when inactive.

  • abstract void Activate(Action<int> onProgress)
  • abstract void Deactivate()
  • virtual string GetEditorSummary()

ObjectiveTriggerBus

class

The static hub producers publish to and trigger sources subscribe to. Being static, a publisher never has to resolve a service first, and the small context structs keep new producers from forcing schema changes.

  • static void RaiseEnemyKilled(string archetypeId, string weaponId, string victimEntityId)
  • static void RaiseItemCollected(string itemId, int quantity, string collectorEntityId)
  • static void RaiseZoneEntered(string zoneId, string entityId)
  • static void RaiseInteractCompleted(string interactableId, string entityId)
  • static void RaiseExtracted(string zoneId, string entityId)
  • static event Action<KillContext> EnemyKilled; ItemCollected, ZoneEntered, InteractCompleted, Extracted

ObjectiveTriggerArmer

class

The bridge from an authored trigger into the tracker: activates a trigger with a callback into ReportProgress and auto-deactivates it when the tracker reports the objective completed or failed.

  • ObjectiveTriggerArmer(IObjectiveTracker tracker)
  • void Arm(ObjectiveId objectiveId, string entityId, ObjectiveTriggerSource trigger)
  • void Dispose()

ObjectiveSequenceRunner

class

Ordered, one-step-at-a-time objective gating, which the mission service does not provide. Tracks only the current step, arms its trigger, and advances on completion.

  • ObjectiveSequenceRunner(IObjectiveTracker tracker, IReadOnlyList<IObjectiveStep> steps, string entityId, ObjectiveTriggerArmer armer = null)
  • int StepCount { get; } / int CurrentStepIndex { get; } / IObjectiveStep CurrentStep { get; }
  • bool IsComplete { get; } / string EntityId { get; }
  • event Action<int, IObjectiveStep> StepStarted / event Action SequenceCompleted

IObjectiveStep

interface

The minimal per-step surface the sequence runner needs. Implemented both by the authored ObjectiveDefinitionAsset and by the plain code-defined ObjectiveStep.

  • string ObjectiveId { get; } / string Description { get; }
  • int RequiredCount { get; } / ObjectiveTriggerSource Trigger { get; }

ObjectiveStep

class

A code-defined step for building sequences programmatically, so a tutorial or guided tour does not need an authored asset and its SerializeReference trigger YAML.

  • ObjectiveStep(string objectiveId, string description, ObjectiveTriggerSource trigger, int requiredCount = 1)

ObjectiveProgress

class

One entity's progress on one objective: current and required count, state, start and completion timestamps, and the clamped normalised Progress ratio.

  • ObjectiveId ObjectiveId; int CurrentCount; int RequiredCount; ObjectiveState State
  • DateTime? StartedAtUtc; DateTime? CompletedAtUtc
  • float Progress { get; }

ObjectiveState

enum

Locked, Available, Active, Completed, Failed, Expired.

ObjectiveType

enum

Kill, Collect, Deliver, Explore, Interact, Survive, Craft, Escort, Defend, Custom. A verb category that drives the progress format and informs which trigger shape the objective expects.

RewardType

enum

Item, Currency, Experience, Unlock, Reputation. The routing key deciding which domain service resolves the reward's identifier.

KillEnemyTriggerSource

class

Advances by one per kill, with optional archetype and weapon filters. An empty filter matches everything, so leaving both blank counts every kill.

CollectItemTriggerSource

class

Advances on item collection, filtered by item slug. With count-by-quantity on, picking up a stack of five advances by five; with it off every grant counts as one. An empty filter matches nothing.

SurviveTimeTriggerSource

class

Advances once per configured tick of elapsed time, driven by ObjectiveTickPump rather than owning its own MonoBehaviour. Its game-time-only flag decides whether pause and death stop the clock.

ObjectiveTickPump

component

One per scene, spawned by the installer, driving Update for every active survive-time trigger so each trigger instance does not need a component of its own. EnsureInScene is idempotent.

  • static ObjectiveTickPump EnsureInScene(Transform parent = null)
  • static void Register(SurviveTimeTriggerSource trigger) / Unregister(...)

ObjectiveTriggerVolumePublisher

component

A collider that publishes Zone, Interact or Extract events to the bus on entry, with a trigger id, an optional exit id, a one-shot flag and a required tag. Forces isTrigger on Awake.

ObjectiveListElement

class

A UI Toolkit element rendering an entity's active objectives as icon, label and progress rows. Bind and Unbind are load-bearing: it must drop subscriptions when detached so a destroyed HUD stops receiving events.

ObjectiveListHUDHost

component

Drops the objective list into an existing UIDocument and resolves the tracker lazily, retrying each Update until one is registered, so it works whether the HUD or the installer wakes first.

ZOAObjectivesSceneInstaller

component

Registers the tracker, granter and mission service into the service registry and spawns the tick pump. Awake delegates to a public EnsureRegistered so tests can drive it without a scene.

Surface

Authoring assets

ObjectiveDefinitionAsset

asset

One authored objective: id, description, icon, verb type, required count, a SerializeReference trigger source, and an optional per-objective reward. Implements IObjectiveStep, so it can drop straight into a sequence. Created via Assets, Create, Tools, ZOA, Objectives, Objective Definition.

  • string ObjectiveId; string Description; Sprite icon
  • ObjectiveType ObjectiveType; int RequiredCount
  • ObjectiveTriggerSource Trigger

MissionDefinitionAsset

asset

A mission: id, title, description, optional splash art, the objective assets it contains, its reward assets, prerequisite missions, a repeatable flag and an expiry in seconds. Created via Assets, Create, Tools, ZOA, Objectives, Mission Definition.

  • string missionId; string title; string description; Sprite splashArt
  • List<ObjectiveDefinitionAsset> objectives; List<RewardDefinitionAsset> rewards
  • List<MissionDefinitionAsset> prerequisites
  • bool isRepeatable; float expirationSeconds

RewardDefinitionAsset

asset

One reward: id, type, the identifier of the thing granted, a quantity and tags. Referenced by either an objective or a mission. Created via Assets, Create, Tools, ZOA, Objectives, Reward Definition.

  • string RewardId; RewardType RewardType; string ItemId; int Quantity
  • IReadOnlyList<string> RewardTags
  • RewardDefinition ToRuntimeDefinition()

Usage

Examples

Publishing gameplay events to the buscsharp
using ZOA.Objectives.Unity.Triggers;

// Publishers do not filter and do not know which objectives exist.
// The trigger source on the objective asset does the filtering.
ObjectiveTriggerBus.RaiseEnemyKilled(
    archetypeId: "grunt",
    weaponId: "rifle.standard",
    victimEntityId: victimId);

// Quantity matters: a collect trigger with count-by-quantity on will
// advance by 5 from this single call.
ObjectiveTriggerBus.RaiseItemCollected("medkit", quantity: 5, collectorEntityId: "player");

ObjectiveTriggerBus.RaiseZoneEntered("zone.intel_room", "player");
ObjectiveTriggerBus.RaiseInteractCompleted("console.security", "player");
ObjectiveTriggerBus.RaiseExtracted("extract.bravo", "player");
Tracking and arming an authored objectivecsharp
using ZOA.Messaging;
using ZOA.Objectives.Core;
using ZOA.Objectives.Unity.Definitions;
using ZOA.Objectives.Unity.Sequencing;

var tracker = FoundryServiceRegistry.Get<IObjectiveTracker>();

// One armer per tracker is enough; it holds every armed trigger.
var armer = new ObjectiveTriggerArmer(tracker);

void StartObjective(ObjectiveDefinitionAsset asset, string entityId)
{
    var id = new ObjectiveId(asset.ObjectiveId);

    tracker.Track(id, entityId);

    // Without this the authored trigger is inert data: nothing else
    // ever calls Activate on it. The armer also removes the trigger
    // when the tracker reports the objective completed or failed.
    armer.Arm(id, entityId, asset.Trigger);
}
A code-defined sequence for a tutorialcsharp
using System.Collections.Generic;
using ZOA.Objectives.Core;
using ZOA.Objectives.Unity.Sequencing;
using ZOA.Objectives.Unity.Triggers;

var steps = new List<IObjectiveStep>
{
    // SurviveTimeTriggerSource carries a code-construction overload
    // precisely so a runtime-built sequence needs no authored asset:
    // one increment per second, five of them.
    new ObjectiveStep("tutorial.hold", "Hold position for five seconds",
        new SurviveTimeTriggerSource(tickSeconds: 1f), requiredCount: 5),

    // A null trigger means the step advances only through an explicit
    // tracker call, which is what a scripted beat wants.
    new ObjectiveStep("tutorial.brief", "Listen to the briefing", null),
};

var runner = new ObjectiveSequenceRunner(tracker, steps, entityId: "player");

runner.StepStarted += (index, step) =>
    _hud.ShowObjective(index + 1, steps.Count, step.Description);

runner.SequenceCompleted += () => _hud.HideObjectives();
The runner tracks and arms only the current step, so later steps stay gated and the HUD shows one line at a time.
Accepting a mission and reacting to its outcomecsharp
using ZOA.Messaging;
using ZOA.Objectives.Core;

var missions = FoundryServiceRegistry.Get<IMissionService>();

missions.MissionCompleted += (sender, e) =>
    _hud.ShowBanner(e.Mission.DisplayName + " complete");

// RewardGranted carries the ids that were actually granted, which is
// not necessarily every reward on the mission.
missions.RewardGranted += (sender, e) =>
{
    foreach (var rewardId in e.GrantedRewardIds)
        _hud.ShowRewardToast(rewardId.ToString());
};

// AcceptMission returns false when a prerequisite mission has not been
// completed by this entity, or when the mission id is not registered.
if (!missions.AcceptMission(new MissionId("extraction.bravo"), "player"))
    _hud.ShowLocked("Complete the opening contract first.");
Sharing one scalar ledger across systemscsharp
using ZOA.Objectives.Core;
using ZOA.Quantities.Core;

// The parameterless constructor gives the tracker a private ledger,
// which is what tests and headless contexts want.
var isolated = new ObjectiveTracker();

// Passing a shared ledger puts objective counts, currency and
// progression counters in one scalar store, which is the project rule.
var shared = new QuantityLedger();
var tracker = new ObjectiveTracker(shared);

// GetProgress refreshes CurrentCount from the ledger on every call, so
// a count written elsewhere is visible here without a sync step.
var progress = tracker.GetProgress(new ObjectiveId("opening.collect_intel"), "player");
_hud.SetProgress(progress.CurrentCount, progress.RequiredCount, progress.Progress);

Tooling

Editor tools

Objective Wizard

Tools / ZOA / Advanced / Define / World / Objectives / Objective Wizard

Authors an ObjectiveDefinitionAsset including the choice of concrete trigger source and its filter, with a headless emitter behind it so automation and tests produce the same asset.

Mission Wizard

Tools / ZOA / Advanced / Define / World / Objectives / Mission Wizard

Assembles a MissionDefinitionAsset from existing objective and reward assets, with prerequisites, the repeatable flag and the expiry, and its own headless emitter.

Objectives Workbench module

ZOA Platform Workbench, Objectives module

Five capabilities: Objectives, Missions and Rewards browsers that edit, validate, duplicate and delete their definitions, a Trackers view showing live objective progress, and an architecture Overview. The objective browser groups assets by ObjectiveType.

ZOAObjectivesSceneInstaller scene tool

An editor utility that places and configures the scene installer, so a scene gets the tracker, granter, mission service and tick pump without hand-wiring components.

Read this

Notes and caveats

See also