Game Object Model (GOM)
As your test suite scales, managing raw locators, blueprint paths, and scattered logic becomes difficult. The Game Object Model (GOM) is an architectural pattern inspired by web automation's Page Object Model (POM), adapted for Unreal Engine. It encapsulates game entities, locators, and actions into reusable classes.
Why use the Game Object Model?
- Reusability: Define a locator or complex logic once, use it across hundreds of tests.
- Maintainability: When an actor's class name or a widget's hierarchy changes in Unreal, you only update the Game Object class.
- Readability: Tests read like game design documents rather than dense procedural code.
Without GOM (The Problem)
Writing tests directly with locators and raw blueprint paths can quickly lead to duplication and brittle tests. Here is an example of a MOBA test where Steel destroys a Keep:
test('Hero destroys enemy keep', async ({ world }) => {
// Hardcoding the long blueprint path in every test
const steelActor = await world.spawnActor(
"/Game/Blueprints/Units/Heroes/Steel/BP_Hero_Steel.BP_Hero_Steel",
{
spawnController: true,
location: { x: 497, y: 351, z: 100 },
rotation: { pitch: 0, yaw: 180, roll: 0 }
}
);
const enemyKeep = world.actorByTag('Structure.Keep.Team2');
// Issuing commands directly
await steelActor.callFunction('IssueMoveOrder', { Location: await enemyKeep.getLocation() });
// Wait until close enough
await expect(steelActor).toBeWithinDistanceOf(enemyKeep, 500);
// Casting ability by hardcoded tag
await steelActor.triggerAbilityByTag('Ability.Slot.E');
// Asserting keep is destroyed
await expect(enemyKeep).toHavePropertyValue('bIsDestroyed', true);
});With GOM (The Solution)
By wrapping these interactions in TypeScript classes, we hide the implementation details of Unreal Engine from the test logic. Notice how we can expose locators and strings directly as properties to avoid writing endless wrapper methods.
1. Creating the Base Objects
First, define a base HeroObject to handle shared logic.
import { SpecterWorld, ActorLocator } from '@specter/test';
// Base class for all heroes, containing shared gameplay actions
export class HeroObject {
constructor(readonly world: SpecterWorld, readonly heroTag: string) {}
// Encapsulate raw function reflection into a clean domain method
async moveTo(actor: ActorLocator, target: ActorLocator) {
const location = await target.getLocation();
await actor.callFunction('IssueMoveOrder', { Location: location });
}
}Then, inherit from it to create Steel, encapsulating his specific widgets, abilities, and complex spawning logic.
import { WidgetLocator, SpecterWorld, SpawnOptions } from "@specter/test";
import { HeroObject } from "./hero-object";
export class Steel extends HeroObject {
public readonly talentJuggernautCharge: WidgetLocator;
// Core Abilities
public readonly abilityCharge: string; // Q
public readonly abilityForceShield: string; // W
public readonly abilityShieldBash: string; // E
public readonly abilityShieldSmash: string; // R
public readonly abilityShieldBlock: string; // Trait (D)
constructor(readonly world: SpecterWorld) {
super(world, "Hero.Steel");
// Locators are exposed directly as properties to avoid boilerplate wrapper methods
this.talentJuggernautCharge = world.widget(
"Button_Talent_JuggernautCharge",
);
// Expose gameplay tags representing his abilities
this.abilityCharge = "Ability.Slot.Q";
this.abilityForceShield = "Ability.Slot.W";
this.abilityShieldBash = "Ability.Slot.E";
this.abilityShieldSmash = "Ability.Slot.R";
this.abilityShieldBlock = "Ability.Slot.D";
}
async spawn(spawnOptions: SpawnOptions = {}) {
spawnOptions.spawnController = true;
return await this.world.spawnActor(
"/Game/Blueprints/Units/Heroes/Steel/BP_Hero_Steel.BP_Hero_Steel",
spawnOptions,
);
}
}Next, define a KeepObject for defensive structures.
import { SpecterWorld, ActorLocator } from '@specter/test';
export class KeepObject {
readonly locator: ActorLocator;
constructor(world: SpecterWorld, team: number) {
this.locator = world.actorByTag(`Structure.Keep.Team${team}`);
}
}2. Writing the Test
The resulting test is drastically cleaner, self-documenting, and resilient to blueprint name changes.
import { test, expect } from '@specter/test';
import { Steel } from '../objects/Steel';
import { KeepObject } from '../objects/KeepObject';
test('Hero destroys enemy keep', async ({ world }) => {
const steel = new Steel(world);
const enemyKeep = new KeepObject(world, 2);
// Cleanly spawn the actor using our factory method
const steelActor = await steel.spawn({
location: { x: 497, y: 351, z: 100 },
rotation: { pitch: 0, yaw: 180, roll: 0 },
});
// High-level movement action instead of raw callFunction!
await steel.moveTo(steelActor, enemyKeep.locator);
await expect(steelActor).toBeWithinDistanceOf(enemyKeep.locator, 500);
// Use strongly-typed properties instead of guessing string tags!
await steelActor.triggerAbilityByTag(steel.abilityShieldBash);
// Assert keep actor is destroyed
await expect(enemyKeep.locator).toBeDestroyed();
});