Skip to content

Crow Puzzle System

Module: CrowPuzzleSystem | Engine: Unreal Engine 5.6 | © 2026 SuspectCrow

1. Overview

The Crow Puzzle System is a signal-routing graph framework for Unreal Engine 5. Actors equipped with USCPuzzleNodeComponent form a directed graph: each node receives a typed signal, filters and transforms it through an ordered rule pipeline, and — if all rules pass — fires a local delegate and forwards the resulting signal to its connected downstream nodes.

The entire puzzle topology is configured in the editor Details panel with zero per-puzzle C++ code.

Design Goals

GoalHow the System Achieves It
No per-puzzle codeTopology (which node connects to which) is set via TargetNodes in the Details panel. Logic lives in instanced USCPuzzleRule objects on each node
Typed signalsFSCPuzzlePayload carries a FGameplayTag for identity, a FInstancedStruct for arbitrary typed data, and a weak source reference
Composable logicRules run as an ordered pipeline. Each rule receives the previous rule’s output, so rules can filter, transform, or pass signals through in sequence
Extensible rulesUSCPuzzleRule is abstract and EditInlineNew. Subclass it in C++ or Blueprint to add any filtering or transformation logic
Dual-target routingDownstream nodes can be any actor that either directly implements ISCPuzzleNodeInterface or owns a component that does

2. Architecture

The system is a directed acyclic graph (though the engine imposes no loop detection). Each node in the graph is a USCPuzzleNodeComponent attached to a world actor.

 Source Actor                Intermediate Actor                Target Actor
 ┌──────────────┐            ┌──────────────────┐             ┌─────────────┐
 │ (e.g. Button)│            │  (e.g. Gate)     │             │ (e.g. Door) │
 │              │            │                  │             │             │
 │ USCPuzzle    │  Signal    │ USCPuzzleNode    │  Signal     │ USCPuzzle   │
 │ NodeComponent│ ─────────► │ Component        │ ──────────► │ NodeComp.   │
 │              │            │                  │             │             │
 │ TargetNodes  │            │ ValidationRules:  │             │ (no rules,  │
 │  └─► Gate    │            │  [0] ExcludeTag   │             │  triggers   │
 └──────────────┘            │  [1] ConvertTag   │             │  animation) │
                             │                  │             └─────────────┘
                             │ TargetNodes:     │
                             │  └─► Door        │
                             └──────────────────┘

Signal Flow Inside a Single Node

ReceivePuzzleSignal(InPayload)
     │
     ├─ Copy InPayload → CurrentPayload
     │
     ├─ For each Rule in ValidationRules (in order):
     │     Rule.EvaluateSignal(CurrentPayload, CurrentPayload)
     │       │
     │       ├─ returns false → bAllRulesPassed = false → BREAK
     │       └─ returns true  → CurrentPayload may have been mutated → continue
     │
     ├─ bAllRulesPassed == false → STOP (signal consumed silently)
     │
     └─ bAllRulesPassed == true:
           ├─ OnPuzzleSignalReceived.Broadcast(CurrentPayload)
           └─ For each actor in TargetNodes:
                 ├─ Actor implements ISCPuzzleNodeInterface directly?
                 │     YES → Execute_ReceivePuzzleSignal(Actor, CurrentPayload)
                 │     NO  → GetComponentsByInterface(USCPuzzleNodeInterface)
                 │               → Execute_ReceivePuzzleSignal(Comp, CurrentPayload)
                 └─ (next target)

3. Quick Start

Step 1 — Add USCPuzzleNodeComponent to a Puzzle Actor

Any actor that participates in the puzzle graph needs this component. Add it in the Blueprint editor or in C++:

// MyPuzzleButton.h
#include "Components/SCPuzzleNodeComponent.h"

UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
TObjectPtr<USCPuzzleNodeComponent> PuzzleNode;
// MyPuzzleButton.cpp — constructor
PuzzleNode = CreateDefaultSubobject<USCPuzzleNodeComponent>(TEXT("PuzzleNode"));

Step 2 — Send a Signal from a Source Actor

When a gameplay event occurs (button pressed, trigger entered, lever pulled), construct a payload and call ReceivePuzzleSignal on the component:

// In a Blueprint or C++ interaction handler:
FSCPuzzlePayload Payload;
Payload.SignalTag   = FGameplayTag::RequestGameplayTag(TEXT("SC.Puzzle.Signal.Activate"));
Payload.SourceObject = this;  // optional weak reference to the sender

PuzzleNode->ReceivePuzzleSignal(Payload);

Blueprint: Construct a FSCPuzzlePayload struct, set its SignalTag, then call Receive Puzzle Signal on the component.

Step 3 — Wire Nodes Together in the Editor

On the source node’s Details panel, expand Target Nodes and add references to the downstream actors. The editor enforces that every entry implements ISCPuzzleNodeInterface, which USCPuzzleNodeComponent does automatically.

Important: TargetNodes is EditInstanceOnly. You must wire connections on placed instances in the level, not on the Blueprint class default.

Step 4 — Add Rules to a Node (Optional)

On any intermediate node, expand Validation Rules in the Details panel and add entries from the built-in rule classes or your own subclasses:

RuleEffect
Exclude Tag (Blocker)Blocks the signal if its tag matches TagToExclude
Convert TagReplaces the signal tag if it exactly matches TagToFind

Rules run top-to-bottom. Reorder them by dragging within the array.

Step 5 — React to the Signal on the Destination Node

Bind to OnPuzzleSignalReceived in Blueprint:

BeginPlay (on the Door actor)
  └── PuzzleNodeComponent → Bind OnPuzzleSignalReceived → OnSignalReceived

OnSignalReceived(Payload)
  └── If Payload.SignalTag == SC.Puzzle.Signal.Activate → Play Open Animation

4. Core Classes & Types

4.1 FSCPuzzlePayload

File: Structs/SCPuzzlePayload.h

The data packet that travels through the puzzle graph. Every node receives one, every rule inspects and optionally modifies one, and every downstream node receives the final transformed version.

PropertyTypeDescription
SignalTagFGameplayTagIdentifies the signal’s type or intent. Rules and downstream nodes switch on this tag. A node may change this tag in transit (e.g., via ConvertTag).
SignalDataFInstancedStructArbitrary typed payload, powered by the UE5 StructUtils plugin. Allows any USTRUCT to be embedded without a fixed class dependency. Can be left empty for simple on/off signals.
SourceObjectTWeakObjectPtr<UObject>A weak reference to the object that originated the signal. Useful for downstream nodes that need to identify who sent the signal (e.g., which pressure plate was triggered). Safe to be null.
Using SignalData

FInstancedStruct allows you to embed any USTRUCT into the payload at runtime:

// Sender side — embedding custom data:
USTRUCT(BlueprintType)
struct FMyDamageData { GENERATED_BODY() int32 DamageAmount = 0; };

FSCPuzzlePayload Payload;
Payload.SignalTag = FGameplayTag::RequestGameplayTag(TEXT("SC.Puzzle.Signal.Damage"));
Payload.SignalData.InitializeAs<FMyDamageData>();
Payload.SignalData.GetMutable<FMyDamageData>()->DamageAmount = 50;
// Receiver side — reading custom data:
if (const FMyDamageData* Data = InPayload.SignalData.GetPtr<FMyDamageData>())
{
    TakeDamage(Data->DamageAmount);
}

For simple signals that carry no extra data (a button activation, a door trigger), leave SignalData default-constructed.

4.2 ISCPuzzleNodeInterface

File: Interfaces/SCPuzzleNodeInterface.h

The single-method interface that marks an actor or component as a puzzle graph node. Any participant in the graph must implement this interface, directly or through a component.

UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category = "Puzzle")
void ReceivePuzzleSignal(const FSCPuzzlePayload& InPayload);

USCPuzzleNodeComponent provides a full C++ implementation of this method. For most actors, simply adding the component is sufficient — you do not need to also implement the interface on the actor class itself.

4.3 USCPuzzleNodeComponent

File: Components/SCPuzzleNodeComponent.h | Base: UActorComponent

The core of the system. This component is both a node in the graph (it implements ISCPuzzleNodeInterface) and a router (it forwards processed signals to TargetNodes).

Tick is enabled by default (bCanEverTick = true). Disable it on instances that do not need per-frame logic.

Properties
PropertyEditor VisibilityDescription
ValidationRulesEditAnywhere (Instanced)Ordered array of USCPuzzleRule objects. Evaluated top-to-bottom. Each rule receives the output of the previous one. All rules must pass for the signal to continue.
TargetNodesEditInstanceOnlyActors to forward the signal to after all rules pass. Every entry must implement ISCPuzzleNodeInterface (enforced by MustImplement meta). Set per-level-instance, not on the class default.
Delegate
DelegateSignatureFired When
OnPuzzleSignalReceived(const FSCPuzzlePayload& Payload)All ValidationRules returned true. Fires before forwarding to TargetNodes, so the local actor can react first.

4.4 USCPuzzleRule

File: Objects/SCPuzzleRule.h | Base: UObject

Abstract base class for all puzzle rules. Rules are the logic atoms of the system — each one has a single job: inspect the current payload, optionally modify it, and return whether the signal should continue.

UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Puzzle|Rule")
bool EvaluateSignal(const FSCPuzzlePayload& InPayload, FSCPuzzlePayload& OutPayload) const;
ParameterDirectionDescription
InPayloadInThe payload as received by this rule (output of the previous rule in the chain).
OutPayloadOutThe payload this rule passes forward. Typically start with OutPayload = InPayload and then modify only what you need.
Return valuetrue = signal passes through; false = signal is blocked here, chain stops.

Because rules are EditInlineNew and DefaultToInstanced, each rule is configured and stored directly inside the component’s ValidationRules array — no separate asset is needed.

4.5 USCPuzzleRule_ConvertTag

File: Rules/SCPuzzleRule_ConvertTag.h | Display Name: Convert Tag

Transforms the signal’s SignalTag when it matches a specific tag. The signal always passes through (returns true); this rule only mutates, never blocks.

PropertyTypeDescription
TagToFindFGameplayTagThe tag to look for in the incoming payload. Matching is exact — child tags do not trigger the conversion.
TagToReplaceWithFGameplayTagThe tag written to OutPayload.SignalTag when a match is found.
Example Use Case

A lever sends SC.Puzzle.Signal.LeverPulled. A downstream gate node uses a ConvertTag rule to translate it into SC.Puzzle.Signal.Activate before forwarding, so the door at the end of the chain only needs to understand one standardized signal.

4.6 USCPuzzleRule_ExcludeTag

File: Rules/SCPuzzleRule_ExcludeTag.h | Display Name: Exclude Tag (Blocker)

Blocks any signal whose tag matches TagToExclude. Acts as a gate that rejects specific signal types.

PropertyTypeDescription
TagToExcludeFGameplayTagThe tag to block. Matching is hierarchical — if TagToExclude is SC.Puzzle.Signal.Damage, then a signal tagged SC.Puzzle.Signal.Damage.Fire is also blocked.
bAltTagsIncludedboolReserved for Blueprint subclass customization or future expansion. The built-in C++ implementation does not read this value.
Example Use Case

A puzzle has two input sources: a friendly button and a hostile explosion. The door should only respond to the button. Add an ExcludeTag rule on the door’s node to block explosion tags.

5. Signal Processing Pipeline

Chained Rule Evaluation

Rules are evaluated in array order. The key implementation detail is that the same CurrentPayload variable is used for both InPayload and OutPayload on every rule call:

// From USCPuzzleNodeComponent::ReceivePuzzleSignal_Implementation:
FSCPuzzlePayload CurrentPayload = InPayload;

for (const TObjectPtr<USCPuzzleRule>& Rule : ValidationRules)
{
    if (Rule)
    {
        if (!Rule->EvaluateSignal(CurrentPayload, CurrentPayload))
        {
            bAllRulesPassed = false;
            break;
        }
    }
}

This means each rule’s output becomes the next rule’s input. A ConvertTag rule placed before an ExcludeTag rule will operate on the converted tag, not the original one.

Rule Order Matters

Signal in: SC.Puzzle.Signal.LeverPulled

Order A — Convert first, then Exclude:
  [0] ConvertTag:  LeverPulled → Activate
  [1] ExcludeTag:  Activate
  → Signal is blocked (ExcludeTag sees "Activate")

Order B — Exclude first, then Convert:
  [0] ExcludeTag:  Activate
  [1] ConvertTag:  LeverPulled → Activate
  → Signal passes and is forwarded as "Activate"
    (ExcludeTag sees "LeverPulled", which doesn't match "Activate")

6. Node Routing

After all rules pass, the component forwards the final CurrentPayload to every actor in TargetNodes using a two-step resolution strategy:

For each TargetActor in TargetNodes:

  Step 1 — Does the actor directly implement ISCPuzzleNodeInterface?
      YES → ISCPuzzleNodeInterface::Execute_ReceivePuzzleSignal(TargetActor, Payload)
            (works for actors with a custom C++ or Blueprint interface implementation)

  Step 2 (fallback) — Find components that implement ISCPuzzleNodeInterface:
      GetComponentsByInterface(USCPuzzleNodeInterface)
      → For each matching component:
           ISCPuzzleNodeInterface::Execute_ReceivePuzzleSignal(Component, Payload)
           (works for actors that have a USCPuzzleNodeComponent attached)
Actor setupResolution path used
Actor has USCPuzzleNodeComponentStep 2 — component is found and signaled
Actor implements interface directly in C++ or BlueprintStep 1 — actor is signaled directly
Actor has bothStep 1 takes priority

Important: TargetNodes is EditInstanceOnly. You must set up connections on the actor instance placed in the level, not on the Blueprint class default. This is intentional — puzzle wiring is level-specific, not class-specific.

7. Creating Custom Rules

In Blueprint

  1. Create a Blueprint with parent class USCPuzzleRule
  2. Override the Evaluate Signal event
  3. Set OutPayload, then return true (pass) or false (block)
  4. Add it to a node’s Validation Rules array in the Details panel

In C++

UCLASS(meta = (DisplayName = "Require Source Object"))
class UMyRule_RequireSource : public USCPuzzleRule
{
    GENERATED_BODY()
public:
    virtual bool EvaluateSignal_Implementation(
        const FSCPuzzlePayload& InPayload, 
        FSCPuzzlePayload& OutPayload) const override
    {
        OutPayload = InPayload;

        // Block the signal if no source object is attached
        if (!InPayload.SourceObject.IsValid())
        {
            return false;
        }

        return true;
    }
};

Rule Design Principles

Always start your implementation with OutPayload = InPayload. This ensures that any properties you do not explicitly change pass through unchanged.

A rule should do exactly one thing. Compose complex behavior by stacking multiple simple rules rather than writing one monolithic rule. This makes the logic visible and reorderable in the editor.

Return false only when you have a definitive reason to block the signal. Rules that return false silently consume the signal — nothing downstream fires and no error is shown.

8. Common Patterns & Recipes

Pattern 1 — Simple Button → Door

[BP_PuzzleButton]                        [BP_PuzzleDoor]
 USCPuzzleNodeComponent                   USCPuzzleNodeComponent
 ValidationRules: (empty)                 ValidationRules: (empty)
 TargetNodes: → BP_PuzzleDoor             OnPuzzleSignalReceived → PlayOpenAnimation
OnPlayerInteract
  └── PuzzleNode → Receive Puzzle Signal
        SignalTag: SC.Puzzle.Signal.Activate
        SourceObject: Self

Pattern 2 — AND Gate (All Buttons Must Be Pressed)

[BP_PuzzleGate]
 USCPuzzleNodeComponent
 ValidationRules: (empty)
 TargetNodes: → BP_PuzzleDoor

 // Blueprint variables:
 bButton1Active: bool = false
 bButton2Active: bool = false

OnPuzzleSignalReceived(Payload)
  ├─ If Payload.SignalTag == SC.Puzzle.Signal.Button1 → bButton1Active = true
  ├─ If Payload.SignalTag == SC.Puzzle.Signal.Button2 → bButton2Active = true
  └─ If bButton1Active AND bButton2Active:
        Send SC.Puzzle.Signal.Activate → PuzzleNode.ReceivePuzzleSignal

Pattern 3 — Tag Converter as a Signal Adapter

[Combat System]                   [Converter Node]             [Puzzle Door]
 Sends:                            ValidationRules:             Expects:
 SC.Combat.Enemy.Defeated    →     ConvertTag:             →   SC.Puzzle.Signal.Activate
                                     Find:    SC.Combat.Enemy.Defeated
                                     Replace: SC.Puzzle.Signal.Activate
                                   TargetNodes: → PuzzleDoor

Pattern 4 — Blocked Channel (Exclude as a Switch)

[Hazard Trigger]  →  [Blocker Node]  →  [Trap]
                      ValidationRules:
                        ExcludeTag: SC.Puzzle.Signal.Hazard

[Safety Lever]   →   (disables the Blocker Node's component at runtime)
// At runtime from the lever's OnPuzzleSignalReceived:
BlockerNode->PuzzleNodeComponent->SetActive(false);
// Now signals flow through unimpeded

Pattern 5 — Multi-Tag Dispatch (One Source, Many Destinations)

[Central Relay Node]
 ValidationRules: (empty)
 TargetNodes:
   → BP_Door_A
   → BP_TrapSpawner
   → BP_AmbientLight

Pattern 6 — Sequential Puzzle with ConvertTag Chaining

Step 1: Pressure plate sends SC.Puzzle.Signal.Step1Done
         └─► Gate node
               ConvertTag: Step1Done → Step2Unlock
               TargetNodes: → [Torch Holder]

Step 2: Torch Holder expects SC.Puzzle.Signal.Step2Unlock to enable itself
         Player places torch → sends SC.Puzzle.Signal.Step2Done
         └─► Gate node
               ConvertTag: Step2Done → Activate
               TargetNodes: → [Final Door]

Pattern 7 — Forwarding SignalData Through the Graph

// Trap actor — when triggered:
FSCPuzzlePayload Payload;
Payload.SignalTag = FGameplayTag::RequestGameplayTag(TEXT("SC.Puzzle.Signal.Damage"));
Payload.SignalData.InitializeAs<FMyDamageData>();
Payload.SignalData.GetMutable<FMyDamageData>()->DamageAmount = 75;
Payload.SourceObject = this;
PuzzleNode->ReceivePuzzleSignal(Payload);
// Health component node — OnPuzzleSignalReceived:
void AMyHealthActor::OnSignalReceived(const FSCPuzzlePayload& Payload)
{
    if (const FMyDamageData* Data = Payload.SignalData.GetPtr<FMyDamageData>())
    {
        CurrentHealth -= Data->DamageAmount;
    }
}

Pattern 8 — Custom Rule: Require Specific Source Actor Class

UCLASS(meta = (DisplayName = "Require Source Class"))
class UMyRule_RequireClass : public USCPuzzleRule
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, Category = "SC|Puzzle|Rule")
    TSubclassOf<AActor> RequiredClass;

    virtual bool EvaluateSignal_Implementation(
        const FSCPuzzlePayload& InPayload,
        FSCPuzzlePayload& OutPayload) const override
    {
        OutPayload = InPayload;
        if (!RequiredClass) return true;

        const AActor* Source = Cast<AActor>(InPayload.SourceObject.Get());
        return Source && Source->IsA(RequiredClass);
    }
};