ZOA Interaction
Verb-based interaction contracts, world prop state, and the pickup spine the whole product shares.
Interaction answers a question every game asks in a hundred places: the player is looking at something, so what can they do with it, and what happens when they do it. The answer here is a verb. An interactable advertises the verbs it supports in a given context, a handler is registered per verb, and the service resolves one against the other. The target never learns which system will service the verb, and the system that services it never learns what kind of object it was.
There are two layers and it is worth knowing which one you are on. `ZOA.Interaction.Core` is engine-free: ids, requests, results, verbs, the world-prop registry, and the adapter interfaces that inventory and equipment implement. `ZOA.Interaction.Unity` is the MonoBehaviour layer that lands those contracts in a scene: authored interactables, doors, switches, breakables, typed pickups, the UI Toolkit prompt view, and the transport shims. The core stays plain C#, so the state machine and the registry run under plain NUnit with no play mode.
Alongside the verb pipeline the package owns two things that look unrelated and are not. `IZOAInteractable` is the three-member contract every Foundry interactable actually implements at runtime, consumed by the player interactor in the equipment package. `IWorldPropService` is the id-addressed registry that gives doors, switches, breakables, and consumed pickups one persistence spine instead of five. Both live here because interaction is the package everything downstream already depends on, and hoisting the contract upstream breaks the cycles.
Depends on (5)
How it works
Concepts
An interaction is a named verb
An interaction is identified by an `InteractionVerbId`, a string-backed readonly struct with ordinal equality. `DefaultVerbs` names the seven built-ins: use, inspect, loot, equip, reload, open, and talk. Because the id is a string rather than an enum, a project adds its own verbs without editing the package and without a shared enum that everything must recompile against.
The flow has three shapes. `QueryAvailable` takes an `InteractionQuery` (source id, optional target, max range) and returns the `InteractionOption` list a prompt renders, each carrying a display label, a priority, and an enabled flag. `TryExecute` takes an `InteractionRequest` (verb, source, target) and returns an `InteractionResult` whose status is Success, Failed, Denied, or NotSupported. `RegisterHandler` binds one `IInteractionHandler` per verb.
The contract never hands you the target object. Requests and options carry an `InteractableId`, a string handle, so a handler that resolves the target does so through its own system rather than through a reference the interaction layer smuggled across a package boundary.
Adapters keep inventory and equipment out of the graph
`EquipVerbHandler` and `LootVerbHandler` are shipped implementations of `IInteractionHandler`, and neither one knows anything about slots, weight, or container contents. Each is constructed with an adapter (`IEquipmentInteractionAdapter`, `IInventoryInteractionAdapter`) that the owning package implements, and the handler is a thin, uniform wrapper: null-check, `CanExecute` gate, delegate, translate a refusal into `InteractionResult.Denied`.
That indirection is the reason `com.zoa.interaction` depends on neither inventory nor equipment. The adapter interfaces are declared here, the implementations live where the domain logic already lives, and composition happens at bootstrap when someone registers `new LootVerbHandler(myInventoryAdapter)` against the service.
IZOAInteractable is the runtime contract that actually gets called
The verb pipeline is the queryable, networkable, prompt-driving surface. Underneath it sits a three-member contract: `IZOAInteractable` with `Prompt`, `CanInteract(GameObject)`, and `Interact(GameObject)`, carrying no ids and no context object. The canonical consumer is the raycast-driven player interactor in `com.zoa.equipment`, and every producer in the product (pickups, loot crates, world props, gameplay anchors) implements it.
Two optional extensions layer on top without widening the base. `IZOAInteractionMenu` turns a single verb into a small scrollable option list, detected at runtime by the interactor and driven with the mouse wheel. `IZOACloseable` adds `IsOpen` and `Close`, which gives the interact key toggle semantics: first press opens the container, second press closes it, and the interactor never learns which concrete loot or dialog type it is talking to.
The contract moved here from `com.zoa.equipment` for that reason: producers should depend on the interaction layer rather than on the much larger equipment package.
World props: one state machine, one save section
Doors, switches, breakables, reward containers, and consumed pickups all reduce to a five-value enum. `WorldPropState` is Closed, Open, Locked, Broken, or Depleted. A prop cycles Closed and Open, can be gated by Locked, and terminally settles in Broken or Depleted. Props that need richer sub-state layer it on top rather than replacing it, so save, objectives, and diagnostics can reason about any prop without knowing its concrete type.
`IWorldPropService` is the scene-scoped registry. Props self-register on enable and unregister on disable, and consumers address them by a stable string id, case-insensitively, never by object reference. `SwitchProp` works on that basis: it holds a list of target prop ids, resolves the service through `FoundryServiceRegistry`, and actuates each id on every flip. A lever keeps opening its bulkhead across prefab boundaries, additive scene loads, and procedurally generated scenes, because nothing serialized a reference that could break.
Registration is strict about duplicates. `Register` returns false and keeps the first registration when a second live prop claims the same id, because a duplicate id is an authoring bug that should be surfaced rather than silently resolved. Re-registering the same instance is idempotent.
The state archive closes the consumed-pickup hole
A save that only walks live registrations loses exactly the states you most need: the pickup that was consumed and destroyed, the breakable that was reduced to rubble. Those props unregistered before the capture ran. And a restore that arrives before an additively-loaded prop registers has nowhere to deliver the state.
`IWorldPropStateArchive` closes both holes on the one canonical path rather than adding a parallel placed-object store. `WorldPropService` implements it: on unregister it retains the prop's terminal state so capture still sees it, and `StagePendingState` either force-applies to a live prop or holds the state and applies it the moment a prop with that id registers. Consumers feature-test with `service is IWorldPropStateArchive`, because the base contract was left untouched.
`ConsumablePropMarker` is what pulls ordinary pickups onto that spine. Sit it next to any pickup, call `ConsumablePropMarker.NotifyConsumed(gameObject)` after a successful grant, and the consumed state persists as `WorldPropState.Depleted` through `WorldPropSaveAdapter` exactly like a door's open state. The call is null-safe and no-ops when no marker is present, so pickup code can make it unconditionally.
Authority is a three-value answer
`IInteractionAuthority.Validate` returns an `AuthorityDecision` of Allowed, Denied, or Unavailable. The third value is the interesting one: it means no authority is reachable right now and the caller should fall back to local validation, so single-player and offline sessions run through the same code path as a server-authoritative match.
Pickups have their own, narrower authority story. `ZOAPickupBase` scans its own GameObject for an `IZOAPickupNetworkSync` component. Finding none, the grant is purely local. Finding one that reports `IsNetworked` and not `IsServer`, the grant becomes a `RequestGrant` to the server and returns immediately, with the authoritative side later calling `TryGrantAuthoritative` and broadcasting a consume. Pickups stay local-only unless a backend component opts them into the transport.
In the editor
Screens
Screenshot pending
/screenshots/interaction-authoring-wizard.png
The wizard shell with the four-step rail visible on the left, the verb selection list mid-panel with two or three verbs checked and labels edited, and the issue tray at the bottom showing a clean state.
Screenshot pending
/screenshots/interaction-workbench-module.png
The Workbench window with Interaction selected in the module list, the capability strip showing Authoring Wizard, Verb Browser, and Overview, and the Overview panel rendering the built-in verb list.
Screenshot pending
/screenshots/interaction-prompt-view.png
Game view with the player facing a door or crate, the prompt panel anchored near the lower centre, showing two option rows with key hints, one selected and one disabled.
Screenshot pending
/screenshots/interaction-world-prop-inspector.png
Inspector for a SwitchProp showing the World prop foldout with an authored propId and initial state, plus the Switch foldout listing two target prop ids and the unlock targets toggle enabled.
Setup
Workflow
- 01
Provide a service and register handlers
The package ships the contracts and the verb handlers, not a production IInteractionService. Register your implementation against FoundryServiceRegistry during bootstrap, then bind handlers to verbs. The shipped EquipVerbHandler and LootVerbHandler only need the adapter your equipment and inventory layers already implement.
- 02
Author interactables in the scene
Run the Interaction Wizard, or add AuthoredInteractableComponent by hand. The wizard walks Identity, Verbs, Options, and Review, then applies the configuration to the current selection or creates a new GameObject for it, adding a BoxCollider when the target has no collider of either dimensionality. The work is Undo-registered and marks the scene dirty.
- 03
Place world props and give them ids
Drop DoorProp, SwitchProp, or BreakableProp and set a stable, scene-unique propId such as door.armory_east. An empty id falls back to the lowercased GameObject name, which is fine for a one-off and fragile for anything a switch targets. Point a switch at a door by id and the composition works without either object holding a reference to the other.
- 04
Mark consumables so they persist
Add ConsumablePropMarker beside any one-time pickup and have the pickup call ConsumablePropMarker.NotifyConsumed(gameObject) after a successful grant. Nothing else is needed: the marker registers with the world-prop service, WorldPropRuntime hosts the save adapter, and the Depleted state survives the GameObject being destroyed.
- 05
Attach a prompt view
Add DefaultInteractionPromptView to a GameObject carrying a UIDocument, or implement IInteractionPromptView against whatever UI stack the project already uses. Feed it the InteractionOption list from a query and it renders one row per verb with a key hint, a label, and the enabled state.
- 06
Opt into networking last
Interactables and pickups run local-only by default. Add the transport shim for your backend, each guarded by its own define (ZOA_NETWORK_FISHNET and the equivalents for Mirror, NGO, Fusion, Netick, and PUN2), and pickups switch to server-authoritative grants automatically because ZOAPickupBase discovers the sync component on its own GameObject.
Surface
Key types
IInteractionService
service
The coordination point: query what a source can do to a target, execute one verb, and register the handlers that service verbs.
- IReadOnlyList<InteractionOption> QueryAvailable(InteractionQuery query)
- InteractionResult TryExecute(InteractionRequest request)
- void RegisterHandler(InteractionVerbId verbId, IInteractionHandler handler)
- bool UnregisterHandler(InteractionVerbId verbId)
IInteractable
interface
The queryable side of a target. Supported verbs are computed per context rather than fixed, so range and state can narrow the list.
- InteractableId Id { get; }
- IReadOnlyList<InteractionVerbId> GetSupportedVerbs(InteractionContext context)
- bool IsActive { get; }
IInteractionHandler
interface
One verb's implementation. CanExecute is the gate a prompt consults; Execute is what runs when the player commits.
- InteractionVerbId VerbId { get; }
- bool CanExecute(InteractionContext context)
- InteractionResult Execute(InteractionContext context)
IZOAInteractable
interface
The minimal runtime contract every Foundry interactable implements. Three members, consumed by the raycast player interactor. Implementations do not re-check distance: the caller already did.
- string Prompt { get; }
- bool CanInteract(GameObject interactor)
- void Interact(GameObject interactor)
IZOACloseable
interface
Extends IZOAInteractable for anything with an open state, giving the interact key toggle semantics. Close must be idempotent.
- bool IsOpen { get; }
- void Close(GameObject interactor)
IZOAInteractionMenu
interface
Optional extension for interactables that expose a scrollable option list rather than a single verb. The interactor detects it at runtime and drives selection with the mouse wheel.
- int GetOptionCount(GameObject interactor)
- bool TryGetOption(GameObject interactor, int index, out ZOAInteractionMenuOption option)
- void SelectOptionDelta(GameObject interactor, int delta)
- void InteractSelected(GameObject interactor)
InteractionVerbId
struct
String-backed readonly struct with ordinal equality. Projects add verbs by declaring new ids, not by extending an enum the whole product recompiles against.
- string Value { get; }
- bool IsValid { get; }
InteractionResult
class
Status plus an optional message, built through named factories so a refusal is always distinguishable from a failure.
- static InteractionResult Success(string message = null)
- static InteractionResult Failed(string reason)
- static InteractionResult Denied(string reason)
- static InteractionResult NotSupported()
IInteractionAuthority
interface
Server-authoritative validation. Returns Allowed, Denied, or Unavailable, where Unavailable explicitly licenses a local fallback.
- AuthorityDecision Validate(InteractionRequest request)
IWorldPropService
service
Scene-scoped, id-addressed registry of world props. Resolve it through FoundryServiceRegistry to actuate a prop you only know by id.
- bool Register(IWorldProp prop)
- void Unregister(IWorldProp prop)
- bool TryGet(string propId, out IWorldProp prop)
- bool TryActuate(string propId, string instigatorId)
- IReadOnlyCollection<IWorldProp> All { get; }
- event Action<IWorldProp, WorldPropState, WorldPropState> StateChanged
IWorldProp
interface
One prop's engine-free surface: a stable id, a diagnostic kind tag, a canonical state, one actuation verb, and a forced-state path for save restore.
- string PropId { get; }
- string Kind { get; }
- WorldPropState State { get; }
- bool TryActuate(string instigatorId)
- void ForceState(WorldPropState state)
IWorldPropStateArchive
interface
Optional surface for retaining the states of props that unregistered and staging states for props that have not registered yet. Feature-test with a type check rather than assuming it.
- IReadOnlyDictionary<string, WorldPropState> RetainedStates { get; }
- void StagePendingState(string propId, WorldPropState state)
- bool TryGetRetainedState(string propId, out WorldPropState state)
WorldPropState
enum
Closed, Open, Locked, Broken, Depleted. Small enough that save, objectives, and diagnostics can reason about any prop without knowing its type.
WorldPropRuntime
class
The single ensure-path for the world-prop service and its save adapter. Every prop routes through it on enable, which is also the retry that catches boot orders where the save system publishes its ownership registry later.
- static IWorldPropService EnsureService()
- static bool TryRegisterSaveAdapter(IWorldPropService service)
IZOAPickup<TPayload>
interface
The generic world-pickup contract. Payload is the typed grant; TryGrant returns false without consuming when the collector cannot accept it.
- TPayload Payload { get; }
- bool TryGrant(GameObject collector)
IZOAPickupNetworkSync
interface
Optional transport hook. A pickup is local-only until a backend component implements this, at which point clients request grants and the server broadcasts consumption.
- bool IsNetworked { get; }
- bool IsServer { get; }
- bool RequestGrant(GameObject collector)
- void BroadcastConsumed()
IPlayerInteractionSource
interface
The player-controller side of the bridge: detect a target, expose its distance, and dispatch primary or explicit verbs. Nucleon's controller implements it so proximity detection meets the verb pipeline.
- IInteractable CurrentTarget { get; }
- float TargetDistance { get; }
- bool CanInteract { get; }
- IReadOnlyList<InteractionOption> GetAvailableOptions()
- InteractionResult ExecutePrimary()
- InteractionResult ExecuteVerb(InteractionVerbId verbId)
IInteractionPromptView
interface
The UI contract for showing prompts. The shipped implementation is UI Toolkit, but the interface says nothing about UI Toolkit, so a world-space canvas or IMGUI prompt substitutes cleanly.
- void Show(IReadOnlyList<InteractionOption> options)
- void Hide()
- void UpdateState(IReadOnlyList<InteractionOption> options, int selectedIndex)
- bool IsVisible { get; }
InteractionTraceLog
class
Static, opt-in ring buffer of interaction events capped at 256 entries. Runtime code records into it without taking a dependency on editor-only diagnostics UI, and nothing is stored until BeginRecording is called.
- static void BeginRecording(bool clearExisting = false)
- static void StopRecording()
- static void Record(InteractionTraceEventKind kind, string sourceId, string targetId, string verbId, string result = null, string message = null)
- static IReadOnlyList<InteractionTraceEvent> RecentEvents { get; }
Surface
Authoring assets
AuthoredInteractableComponent
component
The general-purpose scene interactable the authoring wizard produces. Implements both IInteractable and IZOAInteractable, carries a list of verb entries with labels and priorities, and fires a UnityEvent<GameObject> so a designer can wire behaviour without writing a handler.
- ZOA/Interaction/Authored Interactable
- UnityEvent<GameObject> Interacted { get; }
- void Configure(string interactableId, string displayName, string prompt, float maxInteractionRange, bool requiresAuthority, IEnumerable<VerbEntry> verbs)
DoorProp
component
Closed and Open gated by Locked, animated by a kinematic blend toward per-state local poses. Animator-free, so a generated scene gets working doors with no authored clips; override OnStateTransition to drive an Animator instead.
- ZOA/Interaction/World Props/Door Prop
- void SetLocked(bool locked)
- openLocalEuler, openLocalOffset, transitionSeconds
SwitchProp
component
A lever that toggles its own state and forwards the actuation to a list of target prop ids. With unlockTargets on, a Locked DoorProp target is unlocked first, which is key-switch behaviour.
- ZOA/Interaction/World Props/Switch Prop
- IReadOnlyList<string> TargetPropIds { get; }
- void ConfigureTargets(IEnumerable<string> ids, bool unlock = false)
BreakableProp
component
Damage-actuated rather than use-actuated. Requires the canonical HealthDamageReceiver from com.zoa.combat rather than carrying a second HP model, and transitions to Broken on defeat, swapping visuals and dropping colliders.
- ZOA/Interaction/World Props/Breakable Prop
- HealthDamageReceiver Receiver { get; }
- TryActuate always returns false
ConsumablePropMarker
component
Sits beside a pickup and persists its consumed state through the world-prop spine. Available maps to Closed, consumed to Depleted. Author an explicit propId for anything that can move before being consumed: the fallback id derives from name plus authored position.
- ZOA/Interaction/Consumable Prop Marker
- static void NotifyConsumed(GameObject source)
- void MarkConsumed()
- void EditorSetPropId(string id)
ZOAPickupBase<TPayload>
component
Abstract base owning the grant-then-consume lifecycle, the network-sync handoff, and the destroyOnPickup switch. Subclasses implement TryGrantPayload and optionally OnGranted for feedback.
- protected abstract bool TryGrantPayload(GameObject collector)
- protected virtual void OnGranted(GameObject collector)
- bool TryGrant(GameObject collector)
- bool TryGrantAuthoritative(GameObject collector)
MedPickup, AbilityStonePickup, LockboxKeyPickup, ConditionCurePickup
component
Shipped typed pickups under ZOA/Interaction/Pickups. Each declares a serializable PickupPayload and raises a static Granted event on success, so the health, ability, key-ring, and condition systems attach adapters without this package referencing any of them.
- static event Action<PickupPayload, GameObject> Granted
- MedPickup.PickupPayload: HealAmount, MedKindId
- AbilityStonePickup.PickupPayload: AbilityId, Tier
- LockboxKeyPickup.PickupPayload: KeyId, DisplayName
- ConditionCurePickup.PickupPayload: ConditionIdsCsv, DisplayName
DefaultInteractionPromptView
component
The shipped UI Toolkit prompt. Requires a UIDocument, builds its visual tree lazily on first Show, and emits BEM-style classes (interaction-prompt__option, --selected, --disabled) so a project restyles it from USS without subclassing.
- ZOA/Interaction/Default Interaction Prompt View
WorldPropSaveAdapter
class
The ISavePackageOwner boundary for prop state. Writes a versioned JsonUtility payload of id and state pairs under com.zoa.interaction.worldprops, capturing live registrations plus the archive's retained states, and restores through ForceState so no transition side effects replay on load.
- const string CanonicalPackageId = "com.zoa.interaction.worldprops"
- const int Schema = 1
- static WorldPropSaveAdapter RegisterWith(ISaveOwnershipRegistry registry, IWorldPropService service)
Usage
Examples
using ZOA.Interaction.Core;
using ZOA.Interaction.Core.Contracts;
using ZOA.Interaction.Core.Integration;
using ZOA.Interaction.Core.Verbs;
using ZOA.Messaging;
// Bootstrap: bind verbs to the systems that service them.
var service = FoundryServiceRegistry.Get<IInteractionService>();
service.RegisterHandler(DefaultVerbs.Loot, new LootVerbHandler(inventoryAdapter));
service.RegisterHandler(DefaultVerbs.Equip, new EquipVerbHandler(equipmentAdapter));
// Later, from the player's interaction source:
var request = new InteractionRequest(
DefaultVerbs.Loot,
sourceId: "player.local",
targetId: new InteractableId("crate.armory_01"));
var result = service.TryExecute(request);
if (result.Status != InteractionResultStatus.Success)
Debug.Log(result.Message);using ZOA.Interaction.Core;
using ZOA.Interaction.Core.Contracts;
using ZOA.Interaction.Core.UI;
var query = new InteractionQuery(
sourceId: "player.local",
targetId: new InteractableId("door.armory_east"),
maxRange: 3f);
var options = service.QueryAvailable(query);
if (options.Count == 0)
promptView.Hide();
else
promptView.Show(options);using ZOA.Interaction.Core.WorldProps;
using ZOA.Messaging;
// A scripted mission beat opens a door nothing holds a reference to.
if (FoundryServiceRegistry.TryResolve<IWorldPropService>(out var props))
{
if (props.TryGet("door.armory_east", out var prop) && prop.State == WorldPropState.Locked)
((DoorProp)prop).SetLocked(false);
props.TryActuate("door.armory_east", instigatorId: "mission.script");
}using ZOA.Interaction.Core.WorldProps;
private void Subscribe(IWorldPropService props)
{
// Aggregate of every registered prop's StateChanged. Handlers are
// exception-isolated, so a faulty subscriber cannot break actuation
// for the rest of the scene.
props.StateChanged += OnPropChanged;
}
private void OnPropChanged(IWorldProp prop, WorldPropState from, WorldPropState to)
{
if (prop.Kind == "breakable" && to == WorldPropState.Broken)
SpawnDebris(prop.PropId);
}using UnityEngine;
using ZOA.Interaction.Unity.Pickups;
using ZOA.Interaction.Unity.SceneTools;
using ZOA.Interaction.Unity.WorldProps;
public sealed class ScrapPickup : ZOAPickupBase<int>, IZOAInteractable
{
[SerializeField] private int amount = 10;
public override int Payload => amount;
public string Prompt => $"Take {amount} Scrap";
public bool CanInteract(GameObject interactor) => interactor != null;
public void Interact(GameObject interactor) => TryGrant(interactor);
protected override bool TryGrantPayload(GameObject collector)
{
if (!collector.TryGetComponent<ScrapWallet>(out var wallet))
return false;
wallet.Add(amount);
// Persist the consumed state through the canonical prop spine.
ConsumablePropMarker.NotifyConsumed(gameObject);
return true;
}
}using ZOA.Interaction.Core.Diagnostics;
InteractionTraceLog.BeginRecording(clearExisting: true);
// ... play through the scenario ...
foreach (var evt in InteractionTraceLog.RecentEvents)
Debug.Log($"{evt.TimestampUtc:HH:mm:ss} {evt.Kind} {evt.SourceId} -> {evt.TargetId} [{evt.VerbId}] {evt.Result}");
InteractionTraceLog.StopRecording();Tooling
Editor tools
Interaction Wizard
Tools > ZOA > Advanced > Build > World > Interaction Wizard
A four-step wizard (Identity, Verbs, Options, Review) built on FoundryWizardShell with a step rail, an issue tray, and a preview dock. Completing it configures an AuthoredInteractableComponent on the current selection, or creates a GameObject when nothing is selected, and ensures a collider exists. The menu item routes into the Workbench rather than opening a floating window.
Interaction Workbench module
ZOA Workbench > Interaction
Registered at order 135 under the workflow.environment lane. Three capabilities: the embedded authoring wizard, a verb browser listing the built-in verbs, and an overview panel describing the system's current configuration.
Prompt view inspector
A FoundryComponentEditor drawer registered for IInteractionPromptView and its subclasses, so custom prompt implementations pick up the house inspector styling without writing an editor.
Read this
Notes and caveats
See also