Skip to content

Adding a puzzle device

Designers: Configure existing devices in Blueprint before adding C++. Subclass AClockDevice or AStarObservatory in the level when possible — this recipe is for puzzle engineers extending the C++ template.

For narrative-bearing puzzle objects with their own state machine. The clock and the observatory are the templates (Puzzle narrative devices).

Pattern

A puzzle device:

  • Is an ARuneableObject subclass (so verbs can attach to it).
  • Owns its scene components (visual mesh, pivots, spawn points).
  • Optionally references one or more ARuneSlot* for runes the device consumes or produces.
  • Has a state machine driven by which verb components are currently attached.
  • Broadcasts events for designers to bind VFX/audio/gating to.

Recipe

1. Header

Source/Rephrased_Demo/MyDevice.h:

#pragma once

#include "CoreMinimal.h"
#include "RuneableObject.h"
#include "MyDevice.generated.h"

class ARuneSlot;
class URotateComponent;

UENUM(BlueprintType)
enum class EMyDeviceState : uint8
{
    Idle      UMETA(DisplayName = "Idle"),
    Charging  UMETA(DisplayName = "Charging"),
    Active    UMETA(DisplayName = "Active")
};

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnMyDeviceCharged);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnMyDeviceActivated);

UCLASS()
class REPHRASED_DEMO_API AMyDevice : public ARuneableObject
{
    GENERATED_BODY()

public:
    AMyDevice();
    virtual void BeginPlay() override;
    virtual void Tick(float DeltaTime) override;

    UPROPERTY(BlueprintReadOnly, Category = "My Device|State")
    EMyDeviceState CurrentState = EMyDeviceState::Idle;

    UPROPERTY(EditInstanceOnly, BlueprintReadOnly, Category = "My Device|References")
    ARuneSlot* CoreSlot;

    UPROPERTY(EditAnywhere, Category = "My Device|Tuning")
    float ChargeDuration = 5.0f;

    UPROPERTY(BlueprintAssignable, Category = "My Device|Events")
    FOnMyDeviceCharged OnCharged;

    UPROPERTY(BlueprintAssignable, Category = "My Device|Events")
    FOnMyDeviceActivated OnActivated;

private:
    float ChargeElapsed = 0.0f;
};

2. Source

Source/Rephrased_Demo/MyDevice.cpp:

#include "MyDevice.h"
#include "RuneSlot.h"
#include "RuneSystem/Components/RotateComponent.h"

AMyDevice::AMyDevice()
{
    PrimaryActorTick.bCanEverTick = true;
}

void AMyDevice::BeginPlay()
{
    Super::BeginPlay();
    CurrentState = EMyDeviceState::Idle;
}

void AMyDevice::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    switch (CurrentState)
    {
    case EMyDeviceState::Idle:
        if (FindComponentByClass<URotateComponent>())
        {
            CurrentState = EMyDeviceState::Charging;
            ChargeElapsed = 0.f;
        }
        break;

    case EMyDeviceState::Charging:
        ChargeElapsed += DeltaTime;
        if (ChargeElapsed >= ChargeDuration)
        {
            CurrentState = EMyDeviceState::Active;
            OnCharged.Broadcast();
            OnActivated.Broadcast();
        }
        break;

    case EMyDeviceState::Active:
        // steady-state behavior, or watch for another component to advance further
        break;
    }
}

3. Designer setup

  1. Build, then create BP_MyDevice in Content/Blueprints/ deriving from AMyDevice.
  2. Add visual components (UStaticMeshComponent for the body, USceneComponents for any pivots).
  3. Configure the inherited InitialNounData (the noun identity the device starts with — usually a custom noun like DA_Rune_DEVICE).
  4. Place the device in a level. Drag a slot into CoreSlot. Tune ChargeDuration.
  5. Bind OnCharged / OnActivated in the level Blueprint or a coordinator BP for VFX, audio, and downstream puzzle gating.

Why subclass ARuneableObject and not AActor

Because the device:

  • Has a noun identity (so sentences can target it).
  • Wants verbs to attach as components naturally.
  • Should be visible to the registry for Find by noun queries.
  • Reacts when its noun is transformed via IS (e.g. a clock that becomes "STONE" should freeze).

Plain AActor puzzle objects don't get any of that for free.

Watching for verb components

Two patterns:

PollTick checks FindComponentByClass<UFooComponent>(). Cheap for one device, simple to read. The clock uses this.

Subscribe — Override the verb component's OnEffectApplied in a per-device subclass. Trickier because the strategy creates and destroys the component, but precise.

Use polling unless you have a measured reason not to.

Producing a rune (the clock pattern)

The clock spawns a Time rune after refinement. Pattern:

  1. Carry an EditAnywhere URuneDataAsset* TimeRuneData.
  2. Carry a TSubclassOf<ARune> RuneClassToSpawn.
  3. On state transition: TakeRune from the core slot (consumes input), then SpawnActor<ARune>(...) followed by InitializeRune(TimeRuneData).
  4. Place the new rune at a designer-authored spawn point.

Verifying your work

  1. Place the device with the right noun identity.
  2. Build a calculator that hits the device with the verb your state machine expects.
  3. Confirm CurrentState advances in the editor (use Show 3D Stats or print logs).
  4. Confirm events fire in the bound BP graph.
  5. Break the sentence. The component disappears. Decide whether your state machine should also rewind or stay latched. The clock stays latched; design intent matters.

Source files

  • Source/Rephrased_Demo/RuneableObject.h — parent class
  • Source/Rephrased_Demo/ClockDevice.h — full worked example
  • Source/Rephrased_Demo/StarObservatory.h — coupled-device example using events

Change history

  • 2026-05-23 — Gabriel Li — Designer vs engineer routing callout at top.