How-To: Add Holds, Channels & a Progress Prompt

Goal: extend your own interact ability (How-To: Write Your Own Interact Ability) with hold-to-confirm interactions, timed channels (mining, reviving), and an on-screen prompt - still using only CrimsonInteraction.

Prerequisites
How-To: Write Your Own Interact Ability completed: you have GA_Interact with CurrentOptions, the Input.Interact.Pressed / Input.Interact.Released events, and a working instant press.

1. Hold support

Hold timing lives in ability timers, never in an Enhanced Input Hold trigger (per-option durations are data, and the input layer cannot know what you are aiming at). A repeating timer pushes progress to the target; a one-shot timer fires the interaction when the duration elapses; the release event cancels. Add the members PendingHoldOption, bHoldPending, HoldElapsed, HoldTickHandle, HoldDoneHandle to GA_Interact, branch on InputType in the press handler, and bind OnReleased to the release event from How-To: Write Your Own Interact Ability step 4.

Add five variables to GA_Interact: PendingHoldOption (Crimson Interaction Option), bHoldPending (Boolean), HoldElapsed (Float), and HoldTickHandle / HoldDoneHandle (both Timer Handle). Then split the press path from How-To: Write Your Own Interact Ability step 5:

  1. After the length guard, insert Get CurrentOptions -> Get (a ref) (Index = 0) -> Break CrimsonInteractionOption -> Equal (Enum) comparing Input Type against Hold -> Branch. False keeps the instant Trigger Interaction Option you already wired.
  2. True -> Set PendingHoldOption (the option) -> Set bHoldPending (true) -> Set HoldElapsed (0.0) -> Set Timer by Event (Time = 0.05, Looping = ticked), whose red delegate pin goes to a Custom Event TickHold. Promote the node's Return Value into HoldTickHandle.
  3. -> Set Timer by Event again (Time = the broken option's Input Hold Duration, Looping = unticked), delegate pin to a Custom Event CompleteHold, Return Value into HoldDoneHandle.
  4. TickHold: Set HoldElapsed (HoldElapsed + 0.05) -> Divide (float) by PendingHoldOption's Input Hold Duration -> Clamp (float) (Min = 0.0, Max = 1.0) -> Notify Interaction Progress (interface message call, Target = PendingHoldOption's Interactable Target, In Instigator = Get Avatar Actor From Actor Info).
  5. CompleteHold: Clear and Invalidate Timer by Handle on HoldTickHandle, then on HoldDoneHandle -> Notify Interaction Progress (Progress = 1.0) -> Trigger Interaction Option (Interaction Option = PendingHoldOption, and the same Instigator / Interaction Event Tag as Quick Start step 8) -> Set bHoldPending (false).
  6. The cancel path: from the Wait Gameplay Event (Input.Interact.Released) task's Event Received pin, Get bHoldPending (Branch, True) -> Clear and Invalidate Timer by Handle on both handles -> Notify Interaction Progress (Progress = 0.0) -> Set bHoldPending (false). Pushing 0 is what resets the target's bar; without it a released hold leaves the fill frozen mid-way.
Verify
Set an option to InputType = Hold, InputHoldDuration = 1.5. Holding fills the target's progress (override Notify Interaction Progress on the interactable to see it); releasing early resets it to 0; completing fires the handler.

2. A channeled action phase

For mining / reviving style channels, set the option's InteractionActionAbility to your own channel ability. The grant task pre-grants it automatically (it grants both ability fields), so you launch it the same handle-based way. Give the channel ability its own gameplay-event trigger tag (e.g. Ability.Interaction.Duration.Start - add it like the input tags in How-To: Write Your Own Interact Ability step 1), then route options that carry an InteractionActionAbility to a launcher like this instead of the instant fire:

cpp
void UGA_Interact::StartChannel(const FCrimsonInteractionOption& Option)
{
UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo();
const FGameplayAbilitySpec* Spec = ASC ? ASC->FindAbilitySpecFromClass(Option.InteractionActionAbility) : nullptr;
if (!Spec)
{
return; // grant hasn't replicated yet - ignore the press
}
FGameplayEventData EventData;
EventData.EventTag = FGameplayTag::RequestGameplayTag(FName("Ability.Interaction.Duration.Start"));
EventData.Instigator = GetAvatarActorFromActorInfo();
EventData.Target = UCrimsonInteractionStatics::GetActorFromInteractableTarget(Option.InteractableTarget);
// Pass CLASSES / net-addressable objects, never spec handles - handles are machine-local.
EventData.OptionalObject = Option.TargetAbilitySystem;
EventData.OptionalObject2 = Option.InteractionAbilityToGrant.Get();
ASC->TriggerAbilityFromGameplayEvent(Spec->Handle, GetCurrentActorInfo(),
EventData.EventTag, &EventData, *ASC);
}

The channel ability itself is an event-triggered ability (constructor identical to the Quick Start handler but with your duration tag) that ticks a timer, pushes progress, and notifies the target on completion. The plugin's UCrimsonInteractionDurationContext gives its widget a ready-made progress channel (OnProgressChanged):

cpp
// GA_Mine.cpp - members: float DurationSeconds = 3.f; float Elapsed = 0.f;
// TObjectPtr<AActor> Target; TObjectPtr<UCrimsonInteractionDurationContext> DurationContext;
// TSubclassOf<UUserWidget> ProgressWidgetClass; FTimerHandle TickHandle;
#include "CrimsonInteractionDurationContext.h" // CrimsonInteraction
#include "Interaction/ICrimsonInteractableTarget.h" // CrimsonInteraction
void UGA_Mine::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
return;
}
Target = TriggerEventData ? const_cast<AActor*>(ToRawPtr(TriggerEventData->Target)) : nullptr;
// Optional progress widget: create the context and hand it to your HUD, which spawns ProgressWidgetClass and binds OnProgressChanged:
if (ActorInfo->IsLocallyControlled() && ProgressWidgetClass)
{
DurationContext = NewObject<UCrimsonInteractionDurationContext>(this);
DurationContext->Construct(ProgressWidgetClass, FText::FromString(TEXT("Mining...")), DurationSeconds);
// MyHUD->ShowDurationProgress(DurationContext); - however your HUD places widgets
}
GetWorld()->GetTimerManager().SetTimer(TickHandle, this, &UGA_Mine::TickChannel, 0.05f, true);
}
void UGA_Mine::TickChannel()
{
Elapsed += 0.05f;
if (DurationContext) { DurationContext->UpdateProgress(Elapsed, DurationSeconds); }
if (Elapsed >= DurationSeconds)
{
GetWorld()->GetTimerManager().ClearTimer(TickHandle);
if (Target && Target->Implements<UCrimsonInteractableTarget>())
{
ICrimsonInteractableTarget::Execute_NotifyInteractionSuccess(Target, GetAvatarActorFromActorInfo());
}
EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, true, false);
}
}
Channel launch in Blueprint
The handle-precise channel launch is C++ (spec lookup + TriggerAbilityFromGameplayEvent). Blueprint alternative: give each channel ability a unique gameplay-event trigger tag and launch it with Send Gameplay Event to Actor (Actor = avatar, payload built with Make GameplayEventData, Target = the interactable). The tag must be unique per channel ability - a shared tag would activate every listening ability at once. The channel ability body itself (timers, Update Progress on the duration context, Broadcast Message, Notify Interaction Success) is fully Blueprint-authorable.

3. A minimal prompt

You already have both signals: OnOptionsChanged tells you what to show (CurrentOptions[0].Text, or hide when empty), and the duration context's OnProgressChanged carries channel progress (a hold reports through NotifyInteractionProgress on the target, or a delegate you add to your ability). Push the text into any widget you like from the options handler; for progress, bind OnProgressChanged on the context your ability hands the widget (Blueprint: Bind Event to On Progress Changed; C++ below). The context is created on the owning client only, so a listen server never shows other players' progress.

cpp
// In your progress widget - members: TObjectPtr<UCrimsonInteractionDurationContext> Context;
#include "CrimsonInteractionDurationContext.h" // CrimsonInteraction
void UMyProgressWidget::SetContext(UCrimsonInteractionDurationContext* InContext)
{
Context = InContext;
Context->OnProgressChanged.AddDynamic(this, &UMyProgressWidget::HandleProgress); // HandleProgress is a UFUNCTION
}
void UMyProgressWidget::HandleProgress(float CurrentProgress, float Duration)
{
SetBarPercent(Duration > 0.f ? FMath::Clamp(CurrentProgress / Duration, 0.f, 1.f) : 1.f);
if (CurrentProgress >= Duration) { HideBar(); }
}
void UMyProgressWidget::NativeDestruct()
{
if (Context) { Context->OnProgressChanged.RemoveAll(this); }
Super::NativeDestruct();
}
Verify
PIE as client: prompt appears near the chest, instant press opens it, hold options fill and cancel correctly, a channel ticks its widget, and a second client sees the chest react (server-authoritative).

See also

  • How-To: Instant, Hold & Duration Interactions - choosing the right shape per option.
  • Concept: Two Activation Methods - the replication rules behind the handle-based launches.
  • How-To: World Indicators & Hold-Progress Feedback - richer feedback options.