Back to Projects
Game Dev · Created at Dec 15, 2025 · Last updated at Dec 15, 2025 · 8 mins read

Corrupted Hollow

A third-person puzzle-platformer built in Unreal Engine 5.7 on a 24-person team, where I designed the multi-module C++ architecture, built the Ghosty companion system, and explored MassEntity for swarm behavior.

C++ Unreal Engine Behavior Tree MassEntity NavMesh Niagara Perforce Steam

Overview

Ling and the Corrupted Hollow is a third-person puzzle-platformer where the player controls Ling, accompanied by spirit companions called Ghosties that possess and control environment objects — platforms, gears, boats, switches — to solve puzzles. On a 24-person team at SMU Guildhall, I was the Game AI Engineer. My first contribution was designing a 9-module C++ architecture that split the codebase so programmers could build systems in parallel without merge conflicts or recompile cascading. I wrote the initial Ghosty companion framework (distance-based following with floating motion), explored MassEntity for swarm rendering, built the enemy AI framework (behavior tree base classes, spline-based patrol with NavMesh obstacle avoidance), and supported shipping through cross-system debugging, performance profiling, and Traditional Chinese localization.

This was a two-semester capstone (June – December 2025) following milestone-driven production:

Architecture

The project enters through the CorruptedHollow module, which serves as the main game module and depends on several gameplay modules. The most important relationship is the dependency graph between modules: Player depends on Ghosty, Interactable, Camera, and UI, while Ghosty uses include-path references to Player and Interactable to avoid circular dependencies. This layering ensures each system compiles independently and changes in one module don’t trigger recompiles across unrelated systems.

ModuleDescription
CorruptedHollowMain game module — binds top-level gameplay systems and manages level flow
PlayerCharacter controller with Enhanced Input, jump assist (coyote time), and Ghosty pool management
GhostySpirit companion with state machine (Following, TryingPossess, Possessing) and Niagara particle effects
EnemyAI framework with custom behavior tree base classes and spline-based patrol routing
InteractableBase classes for possessable objects (platforms, gears, switches) driven by Ghosty possession
CameraState machine camera system (Normal, Aiming, Crouching, Sprinting) with spring arm collision
UIUMG-based user interface with CommonUI integration
CheckPointCheckpoint system with kill zones and room-based triggers
CollectablesPickup system for soul shards and other in-game items

Module Boundaries as Compilation Firewalls

The multi-module pattern here — domain-driven packages with explicit dependency declarations and enforced isolation at the build system level — isn’t specific to Unreal Engine. It’s the same structure that appears in monorepo package boundaries (Nx, Turborepo), microservice dependency graphs, and plugin architectures. Each module owns its compilation unit, declares what it depends on, and physically cannot reference internals of modules it doesn’t list as dependencies. The build system enforces what code reviews alone can’t guarantee: that a change in the Enemy system will never accidentally couple to Player internals.

Design Decisions

Why I Split the Codebase into Nine Modules Instead of Using Unreal’s Default Single-Module Structure

The default Unreal project template puts everything in one module. For a solo developer or a small team, that’s fine. For a team of twenty-four with multiple programmers building independent systems (player, enemies, camera, UI, interactables), a single module means every header change triggers a recompile of everything, merge conflicts happen constantly in shared build files, and there’s no enforcement of which systems can depend on which.

I split the project into domain-driven modules early in production. Each module has its own .Build.cs, its own Public/Private folder structure, and explicit dependency declarations. The Enemy module can only see AIModule, GameplayTasks, and NavigationSystem — it has no access to Player or Ghosty.

The Ghosty module uses PublicIncludePathModuleNames to reference Player and Interactable headers without creating a hard link dependency, avoiding circular dependency issues. I also wrote a reference project with documentation to help teammates understand and follow the pattern.

The Ghosty.Build.cs shows how modules declare dependencies explicitly. PublicIncludePathModuleNames lets Ghosty reference Player and Interactable headers for type information without creating a hard link, which prevents circular dependency chains:

// Ghosty.Build.cs — explicit dependency declarations with include-path-only references
public class Ghosty : ModuleRules
{
    public Ghosty(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;

        PublicDependencyModuleNames.AddRange(
            new string[]
            {
                "Core", "CoreUObject", "Engine",
                "Slate", "SlateCore", "Niagara"
            }
        );

        // Include-path-only: Ghosty can reference Player and Interactable headers
        // without creating a hard link dependency (avoids circular dependencies)
        PublicIncludePathModuleNames.AddRange(
            new string[] { "Player", "Interactable" }
        );
    }
}

The module boundaries enforced separation of concerns at the build system level — something that code reviews alone can’t guarantee. If I were the lead programmer, I would have paired this architecture with early feature ownership assignments so that every programmer knew exactly which module they were responsible for from day one.

Why I Explored MassEntity for the Ghosty Swarm — and Why It Didn’t Ship

The original design called for a swarm of Ghosties following the player — potentially dozens of spirits floating and reacting in real time. Spawning that many individual Actors with skeletal meshes, collision, and tick functions would have been a performance problem. Unreal’s MassEntity framework (part of the Mass AI plugin) is designed for exactly this: it processes entities in bulk through data-oriented fragments and processors, avoiding the per-Actor overhead.

I built a prototype level (ZLVL_MASS_AI) to test the approach. The core issue was that MassEntity operates in a fundamentally different paradigm from Unreal’s Actor/Component model. The Ghosty needed to interact with Actors (the player character, possessable objects) through standard Unreal interfaces — overlap events, component references, Blueprint-exposed functions. MassEntity entities don’t have these affordances natively.

Bridging the two systems would have required writing custom Mass Processors that constantly synchronized entity state with Actor state, which defeated the performance benefit.

The team ultimately shipped with a simpler Ghosty system using traditional Actors. The MassEntity research wasn’t wasted — it gave me a concrete understanding of when ECS-style frameworks are the right tool (large homogeneous populations with minimal Actor interaction) and when they aren’t (small populations that need deep integration with existing gameplay systems).

Why I Used Distance-Based Speed Scaling for Companion Movement

Before the MassEntity pivot, I wrote the initial Ghosty companion framework. The core problem: how should a floating spirit follow the player without looking robotic? Moving at a constant speed creates a rigid, mechanical feel. Unreal’s built-in AI movement is designed for ground-based NavMesh navigation, not floating spirits.

I used distance-based speed scaling — within the slowing distance, the Ghosty decelerates smoothly to zero at the stopping distance; beyond it, the Ghosty accelerates to catch up. A sinusoidal Z-offset creates a gentle bobbing motion. Another programmer extended this framework after I shifted to other systems, but the core movement logic shipped as written.

// NewGhostyBase.cpp — distance-based companion following with floating motion
void ANewGhostyBase::FollowPlayer()
{
    if (IsValid(PlayerCharacter))
    {
        TargetLocation            = PlayerCharacter->GetActorLocation();
        FVector currentLocation   = GetActorLocation();
        FVector fwdVector         = GetActorForwardVector();
        FVector targetDirection   = (TargetLocation - currentLocation).GetSafeNormal();
        FVector newFwd            = FMath::VInterpTo(fwdVector, targetDirection,
                                                     GetWorld()->GetDeltaSeconds(), TurnSpeed);

        FRotator newRotation = newFwd.Rotation();
        newRotation.Pitch    = 0.f;
        newRotation.Roll     = 0.f;
        SetActorRotation(newRotation);

        float distanceSqFromTarget   = (TargetLocation - currentLocation).SquaredLength();
        float stoppingDistanceSquared = StoppingDistance * StoppingDistance;
        float slowingDistanceSquared  = SlowingDistance * SlowingDistance;
        FVector newLocation           = currentLocation;

        if (distanceSqFromTarget > stoppingDistanceSquared)
        {
            // Within slowing zone: decelerate as Ghosty approaches the player
            float moveScale = FMath::GetMappedRangeValueClamped(
                FVector2D(slowingDistanceSquared, stoppingDistanceSquared),
                FVector2D(1.f, 0.f), distanceSqFromTarget);

            // Beyond slowing zone: accelerate to catch up
            if (distanceSqFromTarget > slowingDistanceSquared)
            {
                moveScale = FMath::GetMappedRangeValueClamped(
                    FVector2D(MaxAwayDistance * MaxAwayDistance, slowingDistanceSquared),
                    FVector2D(MaxMoveScaleWhenFarFromPlayer, 1.f), distanceSqFromTarget);
            }

            newLocation = currentLocation + newFwd * (MoveSpeed * moveScale)
                          * GetWorld()->GetDeltaSeconds();
        }

        // Sinusoidal bobbing for organic floating feel
        newLocation.Z += sin(GetWorld()->GetTimeSeconds() * ZOffsetFrequency + ZOffset);
        SetActorLocation(newLocation);
    }
}

Challenges

Integrating Systems Built by Different Programmers Without a Shared Interface Contract

The Ghosty system, the Player system, and the Interactable system were each built by a different programmer. When it came time to integrate them — the player commands a Ghosty to possess an interactable object — the implementations didn’t align cleanly.

I had designed the module boundaries to enforce build-time separation, but I hadn’t established shared interface contracts between the modules. The Ghosty module referenced Player and Interactable through include paths, but the actual API surface (which functions to call, what state transitions to expect, who owns the Ghosty reference during possession) was worked out during integration rather than designed in advance.

The lesson: module architecture solves the compilation and conflict problem, but it doesn’t solve the communication problem. Next time, I’d define interface headers (abstract base classes or UInterfaces) at the module boundary before any implementation begins, so that each programmer codes against an agreed contract rather than their own assumptions.

Scope Changes Redirecting Engineering Effort Late in Production

I had built the enemy AI framework — custom behavior tree task nodes, decorators, services, and a spline-based patrol system with automatic obstacle avoidance — expecting it to be a core gameplay pillar. The EnemyPatrolRoute class samples spline paths at regular intervals, performs line traces to detect obstacles, calculates NavMesh-validated avoidance points, and dynamically inserts new spline points to route enemies around blocked paths.

The behavior tree base classes (UEnemyTaskNode, UEnemyDecorator, UEnemyService) were designed to be extended in Blueprint for rapid iteration by designers. Late in production, the enemy system was descoped because designers needed more time on level design and puzzle mechanics. The MassEntity Ghosty swarm was also cut for the technical reasons described above.

Rather than idle, I shifted to debugging across the entire codebase, performance profiling and optimization, and implementing Traditional Chinese localization for the Steam release. These contributions were less architecturally interesting but equally necessary for shipping.

What I’d Do Differently

  • Define interface contracts before implementation begins. Module boundaries enforced build-time isolation, but didn’t prevent API mismatches during integration. Abstract base classes or UInterfaces at module boundaries would have let each programmer code against an agreed contract.
  • Prototype the Actor-ECS bridge before committing to MassEntity. I should have built a minimal test — one MassEntity Ghosty interacting with one Actor — before investing in the full swarm prototype. The integration boundary would have surfaced immediately.
  • Pair module architecture with feature ownership from day one. The 9-module split enabled parallel development, but without explicit ownership assignments, programmers sometimes worked across module boundaries without understanding the dependency implications.
  • Build scope-change resilience into the schedule. Two major systems (enemy AI, MassEntity swarm) were descoped late. Structuring work so that each sprint delivers a shippable increment — rather than building toward a single integration point — would have reduced the impact of scope cuts.

Technical Specifications

ComponentTechnology
EngineUnreal Engine 5.7
LanguageC++ / Blueprint
AI FrameworkBehavior Tree (BTTaskNode, BTDecorator, BTService)
NavigationNavMesh, Spline-based patrol routing
ECS ResearchMassEntity / Mass AI (explored, not shipped)
VFXNiagara particle system
UIUMG + CommonUI
OnlineSteam, Epic Online Services
Version ControlPerforce
Team24 members (SMU Guildhall Capstone — Team Orange)
RoleGame AI Engineer
TimelineJune – December 2025 (milestone-driven agile)
PlatformPC (Steam)