Skip to content

Interaction Framework

One framework for every timed, cancellable player action — use, pickup, search, lockpick, tie up, revive, and anything added later. The original built each of these as a separate use of timer.Conditional, hand-rolled per feature; this document is the generic replacement.


1. What an interaction is

An interaction is a timed, cancellable player action. (Standards/01_NAMING.md §9)

Every interaction has the same shape regardless of what it does: a duration, a set of conditions that abort it early, and an effect that runs on completion.

namespace Applejack.Interaction;

/// <summary>
/// A timed player action: search a locker, lockpick a door, tie up a downed player. See
/// Documentation/Architecture/10_INTERACTION_FRAMEWORK.md.
/// </summary>
public sealed class InteractionRequest
{
    public required string VerbId { get; init; }         // "search", "lockpick", "tie_up"
    public required Character Actor { get; init; }
    public required InteractionTarget Target { get; init; }
    public required TimeSpan Duration { get; init; }

    /// <summary>Checked every tick while the interaction runs. Any false result cancels it.</summary>
    public required IReadOnlyList<IInteractionCondition> Conditions { get; init; }

    /// <summary>Runs host-side, once, only if the interaction completes without cancellation.</summary>
    public required Action<InteractionOutcome> OnComplete { get; init; }
}

/// <summary>Something an interaction is aimed at: an entity, an item instance, or another character.</summary>
public readonly record struct InteractionTarget(InteractionTargetKind Kind, Guid Id);

public interface IInteractionCondition
{
    bool StillHolds(InteractionRequest request);

    /// <summary>Player-facing reason shown if this condition fails and cancels the interaction.</summary>
    string FailureMessage { get; }
}

InteractionOutcome (passed to OnComplete) and InteractionStartResult (returned by TryStart) were named but not shown here originally -- both are small and engine-agnostic:

namespace Applejack.Interaction;

/// <summary>The completed request, handed to OnComplete -- lets a completion handler avoid
/// closing over Actor/Target separately when it already has the request.</summary>
public sealed class InteractionOutcome
{
    public required InteractionRequest Request { get; init; }
    public Character Actor => Request.Actor;
    public InteractionTarget Target => Request.Target;
}

/// <summary>Whether TryStart actually started (or, for an instant interaction, completed)
/// the request. See Documentation/Architecture/13_ERROR_HANDLING.md §2 -- a bespoke type
/// rather than the generic Result only for the clearer name at call sites; it carries no
/// extra fields Result doesn't already have.</summary>
public readonly struct InteractionStartResult
{
    public bool Succeeded { get; }
    public string? FailureMessage { get; }

    public static InteractionStartResult Success() => new(true, null);
    public static InteractionStartResult Failure(string message) => new(false, message);
}

2. The runner

IInteractionRunner is one registered singleton service (InteractionModule, 03_SERVICE_REGISTRY.md), like ICharacterService or IInventoryService -- "one instance per character" (below) describes the logical scoping (at most one running interaction per character, tracked inside that one service, keyed by CharacterId), not literal one-object-per-character construction. Read this section's Cancel/Tick/Progress/IsBusy signatures, not the original draft's, which omitted the tick-driving method entirely and left Cancel's actor parameter unexplained against a "one instance per character" reading taken literally.

namespace Applejack.Interaction;

/// <summary>
/// Runs every character's interaction to completion or cancellation. Owns no gameplay rules
/// itself -- every verb supplies its own conditions and completion effect.
/// </summary>
public interface IInteractionRunner
{
    /// <summary>
    /// Starts <paramref name="request"/> if <see cref="InteractionRequest.Actor"/> has no
    /// interaction already running. Concurrent interactions per character are refused, not
    /// queued -- matching the original's single-conditional-timer-per-player model, which is
    /// good design, not a limitation (see 01_VISION.md §1.3 on interaction as a deliberate
    /// pace-setter). A zero-or-negative <see cref="InteractionRequest.Duration"/> completes
    /// synchronously, inline, inside this call -- see §2.1.
    /// </summary>
    InteractionStartResult TryStart(InteractionRequest request);

    /// <summary>Cancels <paramref name="actor"/>'s running interaction, if any -- a safe
    /// no-op otherwise.</summary>
    void Cancel(CharacterId actor, string reason);

    bool IsBusy(CharacterId actor);

    /// <summary>0 when idle, otherwise elapsed / Duration while running. Mirrored into
    /// InteractionComponent's own [Sync] property for replication -- §4.</summary>
    float Progress(CharacterId actor);

    /// <summary>
    /// Advances <paramref name="actor"/>'s running interaction (if any) by
    /// <paramref name="elapsed"/>, checking every condition and firing
    /// <see cref="InteractionRequest.OnComplete"/> or cancelling as needed. Called once per
    /// frame, per actor, by that actor's own InteractionComponent -- see §4. Each actor
    /// advancing only their own dictionary entry is what makes calling this once per
    /// character-component, rather than once globally, both correct and non-duplicating.
    /// </summary>
    void Tick(CharacterId actor, TimeSpan elapsed);
}
stateDiagram-v2
    [*] --> Idle
    Idle --> Running: TryStart succeeds (Duration > 0)
    Idle --> Idle: TryStart succeeds (Duration <= 0) -- completes inline, see §2.1
    Running --> Running: tick, all conditions hold, duration not yet elapsed
    Running --> Cancelled: a condition fails, or Cancel() called
    Running --> Completed: duration elapsed, all conditions still held
    Cancelled --> Idle
    Completed --> Idle

2.1 Instant interactions skip the state machine entirely

A verb with Duration <= TimeSpan.Zero (eating a cake -- no timer in the legacy source either, Legacy/03_INVENTORY_AND_ITEMS.md §3.4) checks its Conditions and, if they hold, calls OnComplete synchronously inside TryStart -- it is never added to the running-interactions dictionary, never occupies the actor's "busy" slot, and needs no tick to complete. This avoids the alternative of every instant action still taking a full frame (or more, depending on tick order) to resolve, which would be a needless, perceptible delay for something the original did with no timer at all. Only genuinely timed verbs (Equippable's equip flow, lockpicking) run through Running.

3. Requesting an interaction

An interaction is requested exactly like any other client action — 07_NETWORKING.md governs this without exception, because an interaction that could be started, cancelled, or force-completed by the client is exactly the kind of state a player could profit from forging:

public sealed class LockpickingService : Component
{
    [Rpc.Host]
    public void RequestLockpick(Guid doorId)
    {
        var character = Rpc.Caller.GetCharacter();
        if (character is null || !character.IsInitialised) return;

        var door = _property.Find(doorId);
        if (door is null || !door.IsLocked) return;
        if (character.DistanceTo(door) > InteractionRange) return;
        if (!character.HasItem(LockpickItemId)) return;

        _interactions.TryStart(new InteractionRequest
        {
            VerbId = "lockpick",
            Actor = character,
            Target = new InteractionTarget(InteractionTargetKind.Entity, doorId),
            Duration = _config.LockpickDuration,
            Conditions = [new StillInRange(character, door, InteractionRange), new StillAlive(character)],
            OnComplete = _ => _property.Unlock(door),
        });
    }
}

This example is illustrative -- _property/_config (a property/Ownership system) don't exist yet (Milestone 7). It is also not literal about StillInRange/StillAlive's constructors: Character (Code/Characters/Character.cs) carries no position, so those two conditions are built from Func<> delegates the caller supplies (new StillInRange(() => actor.Transform.Position, () => door.Transform.Position, InteractionRange)), not raw domain objects, so the condition type itself stays engine-agnostic and unit-testable per §7 -- see Code/Interaction/StillInRange.cs and Code/Interaction/StillAlive.cs.

4. Progress and cancellation are replicated, not polled

Interaction progress is [Sync] state on the actor's own component (InteractionComponent, mirroring IInteractionRunner.Progress(actor) each frame into its own [Sync(SyncFlags.FromHost)] float Progress), replicating only to the actor per 07_NETWORKING.md §3. InteractionComponent also calls IInteractionRunner.Tick(character.Id, Time.Delta) once per frame, for its own character only -- see §2's note on why per-actor, per-component ticking is what makes a single shared IInteractionRunner service correct without double-ticking. The client renders a progress bar by reading that value locally; it does not poll the host for percentage complete, and the host does not stream per-tick progress RPCs. A third party observing the actor (someone watching a lockpicking attempt) sees a plain "character is busy" state via a separate, non-private [Sync] flag if the verb calls for that (InteractionComponent.IsBusy) — most do not.

5. Standard conditions

A small library of reusable IInteractionCondition implementations, composed per verb rather than reimplemented:

Condition Fails when
StillInRange Actor moves beyond the interaction's distance
StillAlive Actor dies or is incapacitated
StillHasItem A required item (lockpicks, a key) leaves the actor's inventory
TargetUnchanged The target's relevant state changed (door got locked/unlocked by someone else)
NotInterrupted Actor takes damage, per verbs where that should cancel it (not all should)

Verb-specific conditions live beside the verb's service, not in this shared library, unless a second verb needs the same one.

6. Registering a verb

A "verb" is not a separate registration mechanism — it is simply the VerbId string plus whichever module's service constructs the InteractionRequest and supplies OnComplete. There is no central verb registry to keep in sync, because nothing outside the owning module needs to know a verb exists; IInteractionRunner is generic over the request, not over a fixed enum of known verbs. Adding "revive" means adding a service method in the module that owns revival, not touching this framework at all.

7. Testing

InteractionRequest, IInteractionCondition, and the runner's state machine are engine-agnostic (Standards/03_TESTING_STANDARDS.md §1) and unit-tested directly: starting a second interaction while one runs is refused; a failing condition cancels and reports its FailureMessage; OnComplete runs exactly once and only on a clean completion. The RPC entry point itself is covered by the exploit checklist, per 07_NETWORKING.md.