Dorothy’s Job
Developed at Bola 13 Studios
A frenetic, comedic isometric twin-stick shooter where players control a heavily-armed maid fighting through chaotic, enemy-filled mansions.
My Role
UI, Audio & Gameplay Programmer
Team Size
30+ Developers
Development
9 Months
Platform
PC (Steam)
Engineering Systems
Custom Focus Routing & Dynamic Input
To create a seamless, input-agnostic menu experience, I bypassed Unreal Engine's native SetUserFocus limitations. I engineered a dynamic input detection system and a custom focus router that maps flat arrays of widgets into irregular 2D grids.
- Hardware Polling: The
ABasePlayerControlleracts as the single source of truth, dynamically switching betweenFOCUS(Gamepad) andFREE(Mouse) states the moment hardware intent is detected. - Menu Stack & Grid Calculation: The
UGeneralFocusManagerroutes inputs exclusively to the top of the menu stack, while theUSpecificFocusManagerhandles complex, irregular grid navigation. - Interface Interception: Widgets inheriting
IAxisNavigableintercept directional inputs to adjust internal values (like a volume slider) without forcing the system to move focus to a different widget.
void ABasePlayerController::HandleNavigation(const FInputActionValue& _rIAValue) {
if (!IsValid(pGeneralFocusManager)) return;
// Instantly reclaim focus if the player touches the gamepad/keyboard
if (m_eCurrentNavigationMode == ENavigationInputType::FREE) {
SwitchNavigationMode(ENavigationInputType::FOCUS);
return;
}
pGeneralFocusManager->Navigate(_rIAValue.Get<FVector2D>());
}
Data-Driven Production Layouts
Hardcoding text or image references into C++ or UMG directly creates a massive bottleneck for narrative designers and artists. To solve this, production layouts are strictly data-driven, utilizing Unreal Engine's UDataTable and custom structs.
- Dynamic Content Fetching: The
UTipManagercaches all available row names and dynamically extractsFTipDatastructs. It explicitly removes used rows from the active pool to guarantee non-repeating content until the pool is exhausted. - Narrative State Machines: Rather than UMG widgets driving logic, the
UDialogsManagercontrols the narrative flow. It broadcasts multicast delegates (OnDialogLineReceived), ensuring total separation of concerns between game state and visual presentation.
FText UTipManager::GetNextTip() {
if (m_pAvailableTips.Num() == 0) {
if (m_pTipTable) m_pAvailableTips = m_pTipTable->GetRowNames();
else return FText::FromString(TEXT("DT_ERROR: DataTable is not valid."));
}
// Pick a random tip index and remove it to prevent repetition
int32 iIndex = FMath::RandRange(0, m_pAvailableTips.Num() - 1);
FName sRowName = m_pAvailableTips[iIndex];
if (FTipData* pTip = m_pTipTable->FindRow<FTipData>(sRowName, TEXT("GetNextTip"))) {
m_pAvailableTips.RemoveAt(iIndex);
return pTip->m_sTipText;
}
return FText::FromString(TEXT("TIP_ERROR: No tips available."));
}
Event-Driven HUD & Rich Text Parsers
To prevent the common performance bottleneck of UI elements ticking every frame, the visual HUD strictly adheres to the Observer Pattern. It binds to player pawn delegates and only executes visual updates when explicitly notified of a state change.
- Reactive Material Instances: The
UHUDDorothywidget recalculates scalar parameters for Dynamic Material Instances to achieve complex, non-linear fill effects and "Doom-style" reactive facial damage responses. - Memory-Safe Timer Visuals: Transitory UI states are handled via asynchronous timers utilizing
TWeakObjectPtr, ensuring UI deletion mid-animation never causes a fatal engine crash. - Custom Rich Text Parsing: Hiding characters during a typewriter effect breaks native Unreal Rich Text formatting tags. I wrote a custom parser that evaluates strings character-by-character, automatically closing any active tags before pushing the sub-string to the UI.
FTimerHandle oHandle;
TWeakObjectPtr<UHUDDorothy> WeakThis(this);
GetWorld()->GetTimerManager().SetTimer(
oHandle,
[WeakThis]() {
// WeakPtr ensures safety if the UI is destroyed before the timer finishes
if (WeakThis.IsValid() && IsValid(WeakThis->m_pDorothyImage)) {
WeakThis->m_pDorothyImage->SetBrushFromTexture(WeakThis->m_pDorothyNeutralTexture);
}
},
0.3f, false
);
FMOD Audio & Auto-Garbage Collection
To prevent gameplay classes from becoming tightly coupled to audio APIs, I developed a centralized Audio Manager alongside an event-driven component structure to handle FMOD integration.
- Low-Level Middleware Integration: The
UAudioManageracts as aUGameInstanceSubsystem. By directly retrieving theFMOD::Studio::System, the manager can instantiate events and apply dynamic parameters, bypassing standard Unreal Audio Component overhead. - The Auto-Destroyer: To automate garbage collection and prevent memory leaks, dynamic audio components are passed to a
UFMODAutoDestroyerwrapper. It binds toOnEventStopped, callingConditionalBeginDestroy()the exact frame the sound finishes. - Event-Driven Audio Components: Gameplay actors have zero awareness of FMOD. Instead, the
UBaseWeaponAudioComponentacts as a bridge, retrieving data assets and translating gameplay logic into middleware parameters.
void UAudioManager::PlayEventInstanceWithParameters(UFMODEvent* _pEvent, const TArray<FAudioParam>& _rParameters) {
FMOD::Studio::System* pStudioSystem = IFMODStudioModule::Get().GetStudioSystem(EFMODSystemContext::Runtime);
if (!pStudioSystem) return;
// Setup instance and apply dynamic parameter arrays via low-level API
FMOD::Studio::EventInstance* pEventInstance = nullptr;
if (FMOD_OK == pEventDesc->createInstance(&pEventInstance) && pEventInstance) {
for (const FAudioParam& rParam : _rParameters) {
pEventInstance->setParameterByName(TCHAR_TO_UTF8(*rParam.sName.ToString()), rParam.fValue);
}
pEventInstance->start();
// Flag for immediate destruction once the sound finishes playing
pEventInstance->release();
}
}
UObject-Based Consumable Architecture
A common anti-pattern in inventory systems is representing held items as physical AActor instances, bloating memory. To ensure maximum scalability, the consumable framework is strictly UObject-based, separating abstract logic from physical instantiation.
- Logic over Actors: Consumables only exist as lightweight data containers until used. Timer-driven durables delegate duration tracking entirely to the World's
FTimerManagerinstead of ticking. - Safe NavMesh Spawning:
USpawnableConsumablegenerates radial vectors, queries the NavMesh, and performs anFPathFindingQueryto ensure items never spawn out of bounds or inside walls. - Filtered Spatial Queries: Physical deployables (like bombs) bypass collision spheres, utilizing highly optimized
SphereOverlapActorswith specificEObjectTypeQuerychannels to selectively damage targets viaIDamageableinterfaces.
// Perform an optimized spatial query using specific collision channels
UKismetSystemLibrary::SphereOverlapActors(
GetWorld(),
GetActorLocation(),
m_fExplosionRadius,
_oChannel,
nullptr,
aActorsToIgnore,
aOutActors
);
// Apply damage purely via interfaces, avoiding hard class dependencies
for (AActor* pActor : aOutActors) {
if (IDamageable* pEnemy = Cast<IDamageable>(pActor)) {
pEnemy->Damage(_fDamage, EDirtType::Neutral);
}
}
Asynchronous Steam API Synchronization
To handle progression tracking, I built a persistent UGameInstanceSubsystem that manages synchronization between local save data and the Steamworks API, while preventing race conditions during asynchronous platform initialization.
- Data-Driven Initialization: To avoid hardcoding achievement IDs, the manager dynamically reads the
DefaultEngine.iniutilizing Unreal'sGConfigparser to construct its internal map of structs. - Asynchronous Queuing: Connecting to Steam is asynchronous. If a player unlocks an achievement before the API is ready, the system pushes it to an internal
m_lPendingUnlocksqueue, which is flushed upon successful initialization callbacks. - Bi-Directional Synchronization: The manager compares local save data against Steam's cloud records. If the local save is ahead (e.g., the player played offline), it aggressively pushes the data up to force a cloud synchronization.
void UAchievementSubsystem::UnlockAchievement(const FString& _rAchievementId) {
FAchievementData* pData = FindAchievement(_rAchievementId);
if (!pData || pData->bUnlocked) return;
pData->CurrentValue = pData->MaxValue;
pData->bUnlocked = true;
// Queue the achievement if Steam is not yet ready to receive writes
if (!m_bSteamAchievementsReady) {
m_lPendingUnlocks.AddUnique(_rAchievementId);
}
else {
WriteAchievementToSteam(*pData);
}
OnAchievementUnlocked.Broadcast(_rAchievementId);
}