Unreal 5.6 C++ – AI Orbs Project


Designing a multi-behavior AI system in C++: Behavior Trees, Blackboards, Gameplay Tags for faction logic, and a dynamic reservoir with emissive material feedback.


This prototype was built in Unreal Engine 5.6 using C++ and Blueprints, with a deliberate focus on one goal: getting comfortable with Unreal’s AI framework from the ground up. Not just plugging in a Behavior Tree and calling it done, but understanding how Blackboards, Tasks, Services, and Decorators interact to produce emergent, readable AI behavior.

The source code is available on GitHub.


The Game

The player navigates a first-person environment populated by two types of flying orbs. Friendly orbs wander the space autonomously — the player’s proximity activates them, causing them to follow. When a following orb gets close enough to the reservoir at the center of the level, it flies directly in and is absorbed. The reservoir fills incrementally, glowing brighter with each collected orb. Fill it completely to win.

Enemy orbs have a simpler, more aggressive behavior: they fly straight toward the reservoir. If they reach it, they drain stored orbs — a penalty that scales with the number that gets through. The player must intercept them before they arrive.

The core tension is a spatial priority problem: ally orbs need escorting toward the reservoir, enemy orbs need intercepting before they reach it, and both are happening simultaneously. Managing that spatial pressure is the game.


Orb Faction System with Gameplay Tags

Each orb carries a FGameplayTag stored in OrbTypeTag — either OrbType.Ally, OrbType.Enemy, or OrbType.Player (reserved for the player character). This tag is the single source of truth for faction identity across the entire system.

The design consequence is that neither AOrbAIController nor AOrbReservoir needs to know which specific subclass of orb they’re dealing with. The controller reads the tag from the possessed orb to decide which Behavior Tree to run. The reservoir reads the tag from the overlapping actor to decide whether to score or penalize. Adding a new orb type — say, a neutral orb that neither helps nor hurts — requires defining a new tag and authoring a new Behavior Tree asset. No C++ changes to the controller or reservoir.

This is the practical value of Gameplay Tags over class-based discrimination: they decouple the what (which faction) from the how (the specific class that implements it).


Two Behavior Trees, One Controller

Ally and enemy orbs run entirely different Behavior Trees but share the same AOrbAIController class. The controller’s OnPossess reads GetBehaviorTreeAsset() from the possessed AOrb, initializes the Blackboard from that asset’s blackboard data, and starts the tree. The orb Blueprint — not the controller — determines which tree runs.

This is a deliberate inversion of the naive approach, where each orb type would have its own controller subclass. A single controller that defers to the possessed pawn for its configuration is more maintainable and more extensible.

Enemy orb behavior is simple by design: a single Task that moves directly to the reservoir’s location. No conditions, no branching. The simplicity is intentional — the threat comes from numbers and timing, not from individual intelligence.

Ally orb behavior is where the Behavior Tree’s full feature set gets exercised:

Selector
├── Sequence [Decorator: close to reservoir AND IsActive]
│ └── Task: MoveToReservoir
├── Sequence [Decorator: player in detection range]
│ └── Task: FollowPlayer
└── Task: Wander

The Selector evaluates its children left to right and runs the first one whose decorators pass. This produces the intended priority: reservoir approach beats following beats wandering. Each transition is handled declaratively in the tree rather than imperatively in code — adding or reordering behaviors is a matter of editing the tree asset, not recompiling.

The FollowPlayer task is what activates the orb. Until the player gets close, ally orbs are dormant — they wander without contributing to the score even if they drift near the reservoir. Activation by proximity is the design mechanic that requires the player to actively engage with ally orbs rather than waiting for them to wander in on their own.


AOrb as ACharacter

Orbs derive from ACharacter rather than AActor. This is not an obvious choice — orbs don’t have humanoid locomotion or skeletal mesh animation — but it’s architecturally correct for two reasons.

First, UCharacterMovementComponent in MOVE_Flying mode provides exactly the smooth 3D navigation the orbs need: NavMesh-integrated pathfinding, configurable acceleration and deceleration, and controller-driven rotation toward targets via bUseControllerDesiredRotation. The alternative — a custom UPawnMovementComponent or manual velocity integration in Tick — would reproduce most of this work with significantly more code.

Second, Unreal’s AI framework — Behavior Trees, MoveToLocation, NavMesh queries — integrates most naturally with ACharacter and APawn. Using AActor as the base would require implementing interfaces and stubs that ACharacter provides out of the box.

The movement tuning produces the atmospheric feel the orbs need: low MaxAcceleration (200), low BrakingDecelerationFlying (150), and a slow RotationRate (60 degrees/second) give orbs a drifting, deliberate quality rather than snapping directly to their targets.


AOrbReservoir: Event-Driven Scoring

The reservoir is a fully event-driven actor — PrimaryActorTick.bCanEverTick = false. All logic runs from OnComponentBeginOverlap delegates on the CollectTrigger sphere. This is the correct approach for an actor whose state only changes when something physically enters its space.

When an active ally orb enters the trigger, it’s consumed (ConsumeOrb() spawns the destruction VFX/SFX and calls Destroy()), StoredCount increments, and UpdateReservoirEmissive() recalculates the material’s EmissiveStrength parameter. When StoredCount reaches MaxStorage, the OnAllOrbsCollected delegate fires — the Blueprint subscribes to this to trigger the win sequence.

When an enemy orb reaches the trigger, the penalty is applied in a single clamped expression:

cpp

StoredCount = FMath::Clamp(StoredCount - EnemyOrbPenalty, 0, MaxStorage);

EnemyOrbPenalty is a UPROPERTY(EditAnywhere) — the designer can tune the severity of enemy orb impacts without touching code.

The emissive feedback is driven by a UMaterialInstanceDynamic created at BeginPlay from the reservoir mesh’s material slot 0. Each time StoredCount changes, UpdateReservoirEmissive() computes the target intensity:

cpp

const float Target = FMath::Clamp(
BaseEmissive + EmissivePerOrb * static_cast<float>(StoredCount),
0.f,
MaxEmissive
);
ReservoirMID->SetScalarParameterValue(EmissiveParamName, Target);

The reservoir communicates its fill state entirely through the material — no progress bar widget, no HUD element. The visual feedback is spatial and diegetic, which fits the aesthetic better than a traditional UI element would.


Reflection

The most important thing this project taught me about Unreal’s AI framework is the value of declarative behavior composition. Once the individual Tasks and Decorators work correctly in isolation, the Behavior Tree lets you compose them into complex behaviors without writing conditional logic in C++. The ally orb’s three-state priority — reservoir approach, player follow, wander — is readable directly from the tree structure. That readability matters: when behavior is wrong, you can see where in the tree execution diverged without stepping through code.

The Gameplay Tag faction system is the other architectural decision I’d carry into any future AI project. It’s a small investment in setup — defining tags in DefaultGameplayTags.ini, assigning them in the Blueprint — that pays off immediately when you need a third orb type or want to extend the reservoir to handle different scoring rules per faction.

The source code for this project is available on GitHub.

Leave a comment

Create a website or blog at WordPress.com

Up ↑