Gameplay AI Demo ยท 2026

Tactical AI Showcase

Solo Project Gameplay Programmer C++ UE5 AI Open Source PC

Overview

Tactical AI Showcase is a small combat encounter in Unreal Engine 5.7, built end to end in C++. Three enemies with fixed roles fight the player as a group, coordinating through perception stimuli and a shared world subsystem rather than scripted sequences. The central challenge was getting them to behave as a unit without wiring every system directly to every other one, so the coordination stayed readable and the code stayed modular.

By the end it had sight, hearing, and damage perception, a multi-state behavior tree covering patrol, alert, engage, and retreat, role-based combat across three distinct enemies, group coordination with no direct references between them, a runtime panel for tuning perception live, and a save system with a versioned schema. Blueprint is used only for default values, the behavior tree graph, and input bindings.

Three enemies engaging the player, one Flanking, with debug sight cones and Suppress lines

The Encounter and Roles

The encounter needed enemies that read as a squad, so each one has a fixed role in the demo.

The Suppressor holds its starting position, faces the player, and fires immediately, keeping pressure on one spot. The Closer moves in through NavMesh navigation and opens fire once it arrives, collapsing the player's space. The Flanker works its way to a side position scored by EQS, but only when an ally is already engaging. On its own it falls back to a direct attack from where it stands, and when an ally enters the engage state it swings back to flanking.

Getting the flank right took two passes. Pathing straight to the EQS point sent the Flanker across the front of the player, because the shortest route to a side position often crosses the middle. The second pass moved it through an intermediate waypoint placed off to the side so it arcs around instead. The other half of the fix was the relocation: re-deciding its spot every frame made it drift between near-identical points, so it now holds its position and only re-arcs when the player turns to face it and the new pick lands far enough from where it already stands.

Perception

The problem was giving each enemy awareness of the player without tying every system to every other one. The solution was to let perception write what it senses into a blackboard, and let the behavior tree read that blackboard on its own schedule. Perception fires on an event, the tree runs on its tick, and neither needs to know the other exists.

Every enemy senses through sight, hearing, and damage. Sight uses AutoSuccessRangeFromLastSeenLocation, so a brief slip out of the view cone does not drop the target and leave the enemy losing and re-acquiring the player every time they clip a corner. And when an enemy loses sight it clears the seen flag but keeps the last known location, so it walks to where it last saw the player and investigates rather than giving up the instant the player steps behind cover. Hearing picks up the player's footsteps, which are emitted on a timer while moving. Damage is wired in but nothing writes to it yet, left as a hook for when real combat replaces the placeholders.

Squad Coordination

Once one enemy engages, the rest should react, but I did not want a messaging system where every enemy holds a reference to every other and broadcasts its intentions. The solution came from two smaller decisions, and the coordination ends up emergent rather than scripted.

The first is that gunfire is treated as a hearing stimulus, reported at the player's location with the player as its source. Reporting it as the shooter would not work, because every enemy filters hearing to detect enemies only, so a friendly's gunfire would be ignored. Passing the player as the instigator routes the noise through that same filter, so the other enemies hear it, run it through the normal perception path, and write the player's position into their own blackboards. The squad converges on gunfire with no broadcast and no enemy referencing any other.

The second is a shared world subsystem that holds each enemy's role, state, and current target. Enemies register with it when they are possessed and unregister when they are not. A decorator on the Flanker's branch reads that subsystem and counts how many allies are engaged. When that count crosses 1, the decorator aborts the Flanker's lower-priority fallback and the flank branch takes over. When it drops back, the fallback returns. The count can flip briefly when an ally loses and regains sight of the player behind cover, so the decorator holds its value for a short moment before it commits, which keeps the Flanker from swinging between the two branches on a momentary change.

The Behavior Tree

A state selector sits at the top, trying its branches in priority order: retreat on low health, engage on sight, alert when there is a last known location to investigate, and patrol as the fallback. Engage holds the role dispatch. Three sibling branches are gated by role tag, one for each enemy type, and the Flanker's branch carries the sub-selector that makes the flank-or-fallback decision described above.

Behavior tree graph: state selector at the top, role dispatch under Engage, the Flanker sub-selector and its Direct Attack fallback

Runtime Tuning

Tuning perception by editing values in the editor and recompiling is slow, and it is the wrong loop for finding the feel of an encounter. The panel solves that by exposing the perception ranges as sliders you adjust while the game runs. It is built in Slate rather than UMG, because it is a development tool with no asset and no Blueprint surface, and Slate keeps it entirely in C++. The sliders write pending values to a settings object that every enemy shares. The apply button pushes those values into each live enemy's perception and asks it to refresh, so a change lands across the whole encounter at once.

Runtime debug panel: sliders for sight range, lose-sight range, half FOV angle, and hearing, with show-cones and show-labels toggles and an apply button

Stealth

The player needed a way to control how detectable they are, so movement and noise are linked. Moving emits hearing stimuli on a timer. Crouching cuts the loudness, which shrinks the range at which enemies pick the player up. Standing still emits nothing. The result is a simple, legible stealth loop that plays directly against the hearing sense the enemies already use.

Save and Load

The encounter saves and restores through a save game that stamps a schema version on every write, so an older save runs through a migration step before anything reads it. F5 saves to a quick slot and F9 loads it. Loading puts the player and enemies back in place and lets each enemy's tree re-derive its state from its new position. The save feature is intentionally kept as a bare-bones structure, since it was not the main focus of the demo. Even so, it was built so that if this codebase is reused for a stealth, shooter, or other prototype, a developer can build on the bare-bones structure rather than start from scratch.

One honest limit is worth naming. Save and load pair enemies to their snapshots by iteration order, which is stable within a session but not across level edits, so adding or removing an enemy breaks the pairing. The code detects the mismatch and stops rather than corrupting the save. A shipping version would match by a stable identifier per enemy instead. I traded that for simplicity and left the boundary documented.

Technical Notes

The systems talk through channels scoped to the data they carry. The blackboard is each enemy's private working memory. The squad subsystem is world-scoped shared state, created and destroyed with the world and reached by a lookup rather than a stored pointer. Keeping those scopes separate is what kept the AI from turning into a web of cross-references.

Perception is event-driven rather than ticked, so the enemies react to stimuli as they arrive instead of polling every frame. The few operations that walk every enemy in the world, save, load, and the tuning panel's apply, use an actor iterator, which is a full scan and belongs on rare actions rather than on a tick. Allies are tracked through weak pointers, because enemies die and a strong reference would keep dead pawns alive and leave stale entries behind, so every read checks validity first. The codebase was written with open-source release in mind, commented and structured so it can be read on its own.

What I Learned

The habit that carried the whole project was decoupling the thing that produces data from the thing that consumes it through shared state rather than direct calls. Perception writing a blackboard the tree reads, on separate schedules, is what made everything else easy to compose.

The coordination taught me that the best version is often the one you do not write. Routing gunfire through the hearing system that already existed, with the player as the source, was enough to make the squad converge on the player on its own.

The Flanker was the hardest to get right. Its first problem was the path. The straight line to a side position cuts through the middle, so it walked across the player's front instead of around. The second issue was the re-scoring. Running the query every frame, it kept moving to each new pick even when the points were barely different. A side waypoint fixed the first, and holding the position until the new pick was clearly better fixed the second.

Demo Video