Questlight Studio
Docs / Extending the plugin

27. Extending the plugin

In short: most extensions need no C++. Conditions, effects, ability effects, AI scorers, interactable objects, menu entries, screens and command handlers can all be written in Blueprint and show up in the editor's lists. When you do write C++, follow the plugin's rules: logic in a module with no presentation, content in data assets, rules in settings, state that can be saved and replicated.

27.1 Basics

27.1.1 Pick the lightest tool

Work down this list and stop at the first one that does the job.

  1. Data. A new ability, item, enemy, quest or dialog is a data asset, a spreadsheet row or a Studio wizard. See Editor tools.
  2. A setting or a rules profile. A different number or rule for one zone or boss is a setting override in a rules profile, not code.
  3. A Blueprint extension class. A condition, effect, scorer, reaction, interactable, menu entry, screen or command handler written as a Blueprint class. It appears in the same dropdowns as the built-in ones.
  4. A C++ subclass of the same extension classes, when you need speed or access the Blueprint graph cannot give.
  5. A new system in your own module, built the plugin way (below).
  6. A change to the plugin's source, only for things like a new battle mode. It is a bigger commitment, because you then own the merge with every update.

27.1.2 The Blueprint-only workflow

This is the step list for a condition. The other extension classes work the same way.

A condition: "it is night".

  1. In the Content Browser: Add → Blueprint Class → All Classes, search for RSECondition_Custom (its display name is Custom (Blueprint), which every custom base shares, so search by the class name), and name it BP_Condition_IsNight.
  2. Open it. In Class Defaults, set Label to Is night (it shows in logs and debug tools).
  3. In My Blueprint → Functions → Override, choose Is Condition Met. The Context input is a handle to the game state.
  4. Build the graph: Context → Is Flag Set (Context) (the flag, for example your own Narrative.Flag.Night) → Return Node. The Context nodes live under RPG Saga|Narrative|Context.
  5. Compile and save. The condition does not change the game: it must only read.
  6. In any conditions list (a dialog branch, a quest transition, a puzzle, an interaction, a shop) click +, and pick Custom (Blueprint), then your class. It now behaves like a built-in condition.

An effect: "heal the party and play a sound".

  1. Add → Blueprint Class → All Classes, search for RSEWorldEffect_Custom, and name it BP_Effect_HealParty.
  2. Add editable variables (Instance Editable) for the numbers, so designers tune them on the instance.
  3. Override On Execute. Use the Context nodes (for example Modify Member Resource (Context)) so the change is reported to quests and the HUD, like built-in effects.
  4. List it in any effects list. It runs in order with the others, and the Only If gate works.

A command handler (co-op ready). See Co-op and online: a child of RSE Command Handler, a Command Type, and the class listed in Project Settings → RPG Saga Engine → Commands → Handler Classes.

An interactable: a lever. A Blueprint child of RSE Interactable Actor with On Interact is interactable even without data. In it, flip a story flag, play a sound and refresh the door it controls.

A menu entry. A Blueprint child of RSE Menu Extension: set Id, Point, Label, Order, implement Is Visible, Is Enabled and Execute (which returns what the menu does next), and list the class in Game Flow → Menu Extensions.

A screen. A Widget Blueprint derived from RSE Screen Widget that reads the screen's view model. Set it in the settings of that screen. See User interface.

27.2 How it works

27.2.1 The module layers

The plugin separates logic from presentation with modules, and the compiler enforces it.

ModuleTypeHoldsMay depend on
RSECoreRuntimeData, tags, settings, state, and the rules and simulation: battle (all modes), grid, pathfinding, abilities, items, AI, conditions and effects, narrative state, save structures. No actors, visual components, rendering, UI or input.engine base modules, GameplayTags
RSERuntimePresentation and world: actors, pawns, camera, input, debug drawing, UI, exploration, save and load orchestrationRSECore
RSEOnlineRuntimeSessions and the lobbyRSECore, RSE
RSENetRuntimeThe co-op transportRSECore, RSE, RSEOnline
RSEEditorEditorTools, wizards, importers, visual editors, extra validationRSECore, RSE
RSEGAS, RSEGASEditorRuntime, EditorOptional bridge: Gameplay Ability System characters in battlesthe above
RSEStateTree, RSEStateTreeEditorRuntime, EditorOptional bridge: StateTree nodes for the utility AIthe above
RSECommonUIRuntimeOptional Common UI host for the screensthe above

The rules that follow from this:

  • RSECore never includes anything from RSE, RSEOnline, RSENet or RSEEditor. It talks to presentation through events (delegates).
  • Any new logic goes into the logic layer by default. Only what needs the world, actors or rendering goes in the presentation layer.
  • The plugin never depends on your game module or on /Game content. Your game depends on the plugin.
  • Battle logic can run headless, with no rendering, which is how the simulator, Balance My Campaign and the tests work. Presentation listens to events and reads state through IRSEBattleView. It never changes a battle directly.

In your own project, add RSECore and RSE to your game module's PublicDependencyModuleNames. Add RSEEditor only for Target.bBuildEditor code, and RSENet or RSEOnline only if you use their types.

27.2.2 The extension points

Base classModuleOverrideWhere it shows up
URSECondition_CustomRSECoreIs Condition Metevery Conditions list: dialog, quests, puzzles, interactions, tutorials, shops
URSEWorldEffect_CustomRSECoreOn Executeevery Effects list, in order with the built-in effects
URSEEffect_CustomRSECoreOn Apply, Preview Damageeffects of abilities. Preview Damage is read by the forecast and the AI
URSEBattleCue_CustomRSEOn Cue Start / Update / Stoppresentation timelines
URSEAIConditionRSECoreIs Metrules and phases of AI profiles
URSEAIScorerRSECoreScoreadded to the score of every ability option, weighted
URSEAbilityReaction, URSEPassiveModifierRSECoreCan Trigger, Is Active Forreactions and passives
ARSEInteractableActorRSEOn Interact, Can Interact (Custom)custom interactions in the world
URSECommandHandlerRSECoreValidate, Applythe command bus
URSEMenuExtensionRSEIs Visible, Is Enabled, Executemain and in-game menus
URSEScreenWidget + URSEScreenViewModelRSEthe screenany framework screen
URSESessionProviderRSEOnlinehost, find, join, leaveonline services
URSEPlayerProvider, URSEAuthorityPolicyRSEplayers, authoritywho plays, and when co-op rules apply
IRSESaveBackendRSECoreslot read and writewhere saves are stored
URSEDeveloperSettingsRSECoreyour settings classProject Settings → RPG Saga Engine and the Configurator

The base classes are Abstract, Blueprintable, EditInlineNew, so a Blueprint or C++ subclass appears by itself in the dropdowns of instanced objects. Effects and conditions you write are deterministic and read-only where documented: use the battle's Roll Int and Roll Percent nodes instead of random nodes, so a battle replays the same from its seed.

A policy class, such as the leash, the group travel rule or the co-op encounter conversion, is the same idea: the setting holds a class, and you replace it with your own.

27.2.3 The engineering standards

These are the rules every piece of the plugin follows. Follow them in your own systems and your project stays easy to extend, test and ship.

RuleIn practice
No hardcoded tunablesContent goes in data assets and tables. Global rules go in a settings class (Project Settings → RPG Saga Engine). Debug switches are console variables named RSE.*. Only mathematical or logical invariants stay in code.
Data assets are Primary Data AssetsDerive from URSEPrimaryDataAsset. Its PrimaryAssetId is the stable id that saves and runtime state refer to.
Validate in the editorOverride IsDataValid (call Super), so mistakes show on save, not in play. Settings classes validate too.
Soft references for heavy assetsMeshes, animations, sounds and UI are TSoftObjectPtr or TSoftClassPtr, loaded when needed.
Ids, not pointers, in stateRuntime state refers to content by tag or FPrimaryAssetId.
No Tick by defaultActors and components start with Tick off. Use events, timers or a subsystem that processes a batch.
Flat hot dataGrids and pathfinding use contiguous arrays, not an actor per tile. Repeated visuals use instanced meshes.
Serializable stateRuntime state that must be saved is a plain USTRUCT with SaveGame properties, separate from actors.
Control from the editorSettings are editable in Project Settings. Test actors have CallInEditor buttons and live properties.
Logs and CVars per domainA log category per domain (LogRSEGrid, LogRSEBattle) and RSE.<Domain>.* CVars.
Rules profilesA gameplay rule lives in a profile-able settings class and is read with RSERules::Get<T>(Profile) or URSEBattle::GetRules<T>(), never with GetDefault<T>() in rule code.
TestsAutomation tests named RSE.<Domain>.<Test> for logic, runnable from the Session Frontend and the command line.
Blueprint accessEvery public API is exposed (BlueprintCallable, BlueprintPure, events, BlueprintType structs). Extension points are Blueprintable with BlueprintNativeEvent. A system is not done until it can be used and extended without C++.
No copyright or trademark problemsEvery source file has the copyright header. Nothing names other games or companies. Every shipped asset has a licence that allows redistribution.

27.3 Key classes and assets

Class / assetModuleRole
URSEPrimaryDataAssetRSECoreBase of all content definitions: display name, dev notes, balance lock, archetype link, validation
URSEDeveloperSettingsRSECoreBase of every settings class. IsProfileSection() makes a class overridable by a rules profile
URSERulesProfileRSECoreA rules profile asset
RSERules::Get<T>(Profile)RSECoreRead a profile-able setting
URSEFeatureSettings, RSEFeatures::IsEnabled, RSE_FEATURE_GATE(...)RSECoreFeature toggles and the macro that stops a subsystem from being created while its feature is off
URSESaveSubsystem, RegisterStruct<T>RSECoreRegister your system's state as a save section
URSECommandSubsystem, FRSECommand, URSECommandHandlerRSECoreThe command bus
URSESeedSourceRSECoreDeterministic seeds by named stream
RSEGameplayTagsRSECoreNative tags, for tags that C++ reads directly
URSEBattle, IRSEBattleViewRSECoreThe headless battle and the read-only view presentation uses
ARSETacticsBattleDirector, ARSEATBBattleDirector, ARSEFreeTacticsDirector, ARSEActionBattleDirectorRSEThe actor that runs one battle in each mode (presentation, camera, input)

27.4 Settings

The settings that matter when extending:

WhereWhat
Project Settings → RPG Saga Engine → FeaturesSwitch whole systems off. A disabled subsystem is not created. The command line -RSEDisableFeatures=Quests,Cards does it for one run
Project Settings → RPG Saga Engine → CommandsHandler Classes (your Blueprint handlers), Max Nested Depth, Log Refused Commands
Project Settings → RPG Saga Engine → Game FlowMenu Extensions
Project Settings → Asset ManagerThe folders scanned for your own Primary Data Asset types
Project Settings → Editor → RPG Saga Engine Configurator → GroupsWhich group your settings class appears in

Defaults the plugin ships live in Plugins/RPGSagaEngine/Config/Defaults/{Game,Editor}.ini and apply under your project's config, so anything you change wins and a plugin update never overwrites it. See Appendix B.

27.5 Blueprint usage

A short list of nodes that help when you extend:

  • Is Feature Enabled (RPG Saga|Features): adapt your UI to the project's feature set.
  • Submit Command, Submit Command As, Can Submit, Submit Command (Game), Has Game Authority (RPG Saga|Commands): the command bus and the authority check.
  • Get Subsystem nodes for the framework's subsystems, each with its events (On Quest Started, On Encounter Ended, On Saved, and so on).
  • The Context nodes (RPG Saga|Narrative|Context): read and change the game from a custom condition or effect, with changes reported once, like built-in effects.
  • In battle code: Get Unit, Is Unit Active, Unit Has Status, Deal Damage, Heal Unit, Apply Status, Defeat Unit, Roll Int, Roll Percent.

The full list is generated: Appendix A. The Blueprint API is frozen: a node or event that ships is never removed or renamed. That is checked by the test RSE.Editor.BlueprintAPI.Manifest, and RSE.BlueprintAPI.Export regenerates the manifest after a deliberate change.

27.6 C++ usage

27.6.1 A new system, the plugin way

A system has a data asset, a settings class, a subsystem that owns the state, a save section, commands, and events. The snippets below show the shape of each piece.

The content type. Derive from URSEPrimaryDataAsset, keep references soft, validate:

UCLASS(BlueprintType)
class UMyBountyData : public URSEPrimaryDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Bounty", meta = (ClampMin = "0"))
    int32 Reward = 100;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Bounty")
    FGameplayTag TargetEnemyTag;

    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Bounty")
    TSoftObjectPtr<UTexture2D> Poster;          // soft: loaded when shown

#if WITH_EDITOR
    virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override
    {
        EDataValidationResult Result = Super::IsDataValid(Context);
        if (!TargetEnemyTag.IsValid())
        {
            Context.AddError(INVTEXT("Target Enemy Tag is empty."));
            Result = EDataValidationResult::Invalid;
        }
        return Result;
    }
#endif
};

Put the folder in Project Settings → Asset Manager → Primary Asset Types to Scan, so saves and tools can find it.

The settings class. Global rules, with limits and a description:

UCLASS(Config = Game, DefaultConfig, meta = (DisplayName = "Bounties"))
class UMyBountySettings : public URSEDeveloperSettings
{
    GENERATED_BODY()
public:
    /** Gold multiplier for bounties on bosses. */
    UPROPERTY(Config, EditAnywhere, meta = (ClampMin = "0.1", ClampMax = "10"))
    float BossRewardMultiplier = 2.f;
};

If the rule should differ per zone or boss, override IsProfileSection() to return true, add EditInlineNew, and read it with RSERules::Get<UMyBountySettings>(Profile).

The subsystem and its state. The state is a plain struct, saved by id, replicated by section:

USTRUCT()
struct FMyBountyState
{
    GENERATED_BODY()
    UPROPERTY(SaveGame) TArray<FPrimaryAssetId> Claimed;      // ids, never pointers
};

UCLASS()
class UMyBountySubsystem : public UGameInstanceSubsystem
{
    GENERATED_BODY()
public:
    RSE_FEATURE_GATE(ERSEFeature::Quests)                      // not created while the feature is off

    virtual void Initialize(FSubsystemCollectionBase& Collection) override
    {
        Super::Initialize(Collection);
        Collection.InitializeDependency<URSESaveSubsystem>()
            ->RegisterStruct<FMyBountyState>(TEXT("Bounties"), 1, this, [this]() { return &State; });
        Collection.InitializeDependency<URSECommandSubsystem>()
            ->RegisterHandler<FMyCmd_ClaimBounty>(
                [this](const FRSECommandContext& C, const FMyCmd_ClaimBounty& Cmd, FText& Why) { return CanClaim(Cmd.Bounty, Why); },
                [this](const FRSECommandContext& C, const FMyCmd_ClaimBounty& Cmd) { Claim(Cmd.Bounty); },
                ERSECommandPermission::AnyPlayer, this);
    }

    FSimpleMulticastDelegate OnBountyClaimed;                  // presentation listens to this

private:
    bool CanClaim(FPrimaryAssetId Id, FText& Why) const;
    void Claim(FPrimaryAssetId Id)
    {
        State.Claimed.Add(Id);
        RSEStateSections::MarkChanged(this, TEXT("Bounties"));
        OnBountyClaimed.Broadcast();
    }
    FMyBountyState State;
};

The picture to keep in mind: the data asset says what a bounty is, the settings say how the rules work, the subsystem owns the one copy of the state, the section makes it save and replicate, the command is the only way to change it, and the event tells the screen.

The test. Logic that lives in RSECore-style code is testable without a world:

IMPLEMENT_SIMPLE_AUTOMATION_TEST(FMyBountyClaimTest, "MyGame.Bounties.Claim",
    EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FMyBountyClaimTest::RunTest(const FString& Parameters)
{
    // build the state, run the pure function, TestEqual(...)
    return true;
}

27.6.2 Subclassing directors and other actors

A battle director is the actor that runs one battle in its mode: it owns the camera, the input and the presentation, and holds a headless battle driven by a driver. ARSEActionBattleDirector is Blueprintable, so you can make a Blueprint child to change its defaults and hook its events. The grid and ATB directors are placed in a level and configured through their properties, their zone or stage, and the encounter asset. For anything the properties do not cover, subclass in C++ and override the virtual functions the director documents, and keep your logic out of the director: put it in a policy class or a handler that the director calls.

27.6.3 Adding another battle mode (what it takes)

A new battle mode is not a data-only extension. The existing modes (grid Tactics, Free Tactics, ATB, turn-based and Action) follow one pattern, and a new one needs the same pieces:

  1. A simulation in the logic layer that runs headless, built on the common battle pipeline, so the simulator and the balance tool can play it.
  2. A driver and a director actor in RSE for the camera, input and presentation, which read the battle through IRSEBattleView and listen to its events.
  3. A command struct derived from FRSECommand for every player action, with a handler on the command bus. That is what makes it co-op ready.
  4. A native gameplay tag for the mode (Battle.Mode.*), the encounter data to select it, and a case in the encounter starter.
  5. A feature toggle if the mode should be switchable. The feature list is an enum in the plugin, so adding to it is a source change.
  6. Settings, validation, tests, and a Blueprint surface.

Before you start, check whether an existing mode with a rules profile, an AI profile and your own conditions and effects already gives you what you want. Most "new modes" turn out to be a configuration of Free Tactics, ATB or Action.

27.7 Advanced

27.7.1 Keeping changes to the plugin mergeable

If you must edit the plugin's source:

  • Keep your changes small, in your own files where you can, and note each one.
  • Never edit a shipped asset in place. Use Create a copy in the project.
  • Run RSE.Plugin.PlatformApis, the content tests and Validate All Data after the change.
  • Before you package, build the game target and the editor target with strict includes (every .cpp includes what it uses, and editor-only APIs sit under #if WITH_EDITOR). The plugin is also compiled in the game configuration, which the editor target never covers.

27.7.2 Stable ids and tags

Name a content tag for what it is, not for where you use it. Tags the code reads directly are declared as native tags with UE_DECLARE_GAMEPLAY_TAG_EXTERN, never built from a string. Content tags live in .ini files; a new domain gets its own Config/Tags/<Prefix>_<Domain>.ini, and the plugin registers its tag folder at load. Your own tags go in your project's Config/Tags.

27.7.3 Mechanical renames

The product name and prefix are provisional. Keep them out of dynamically built names so a future rename stays mechanical: use the prefix in class names, never in strings you assemble at run time.

27.7.4 Replication readiness

Systems you add should be ready for co-op from the start, even if you never ship it. See Rules for network-ready systems in Co-op and online: actions as commands, one owner per piece of state, presentation fed by events, swappable control, deterministic seeds, authority-guarded effects, and no references to "player 0" in logic.

27.8 Troubleshooting

SymptomCause and fix
My Blueprint condition or effect is not in the listThe class must derive from the Custom (Blueprint) base, be saved, and compile. Reopen the list. A class that does not derive from the base is not offered.
A condition changes the gameConditions must only read. Move the change to an effect.
A custom effect's change is not reported to quests or the HUDIt used a plain variable node. Use the Context nodes so changes are reported and deferred like built-in effects.
An ability effect replays differently each timeIt uses random nodes. Use Roll Int or Roll Percent, which come from the battle's seed.
A new settings class sits under Other in the ConfiguratorAdd it to a group (Project Settings → Editor → RPG Saga Engine Configurator → Groups). The test RSE.Editor.Configurator.EveryClassHasGroup fails until you do.
My data asset type is not found by the toolsIts folder is not in the Asset Manager's scan list. Add it in Project Settings → Asset Manager.
Link errors using RSENet or RSEEditor typesAdd the module to your Build.cs. Editor modules only under Target.bBuildEditor.
The plugin fails to compile in the game configurationA .cpp relied on an include from a neighbour, or an editor API sits outside #if WITH_EDITOR. Build the game target with strict includes to find it.
My subsystem exists although I switched the feature offIt lacks RSE_FEATURE_GATE(...) (or an equivalent ShouldCreateSubsystem).
A saved game from before my change fails to loadYou changed a section's struct without raising its version number in RegisterStruct. Bump the version and handle the old one.

27.9 See also