Docs Menu
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.
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 raw Blueprint paths leads to brittle duplication:
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.
1. Creating Base & Specific Objects
Define a base HeroObject for shared behavior:
import { SpecterWorld, ActorLocator } from '@specter-automation/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 });
}
}Inherit to create Steel, encapsulating his widgets and abilities:
import { WidgetLocator, SpecterWorld, SpawnOptions } from "@specter-automation/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");
// UI Widgets
this.talentJuggernautCharge = world.widget(
"/Game/UI/Talents/WBP_Talent_JuggernautCharge"
);
// Gameplay Tags
this.abilityCharge = "Ability.Slot.Q";
this.abilityForceShield = "Ability.Slot.W";
this.abilityShieldBash = "Ability.Slot.E";
this.abilityShieldSmash = "Ability.Slot.R";
this.abilityShieldBlock = "Ability.Slot.Trait";
}
// Encapsulate default spawn params
async spawn(options: SpawnOptions = {}) {
return this.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 },
...options,
}
);
}
}Define a KeepObject for defensive structures:
import { ActorLocator, SpecterWorld } from "@specter-automation/test";
export class KeepObject {
public readonly locator: ActorLocator;
constructor(readonly world: SpecterWorld, team: 1 | 2) {
this.locator = world.actorByTag(`Structure.Keep.Team${team}`);
}
}2. Writing the Clean Test
The resulting test is self-documenting and resilient to Blueprint refactors:
import { test, expect } from '@specter-automation/test';
import { Steel } from '../objects/heroes/steel.object';
import { KeepObject } from '../objects/structures/keep.object';
test('Hero destroys enemy keep', async ({ world }) => {
const steel = new Steel(world);
const enemyKeep = new KeepObject(world, 2);
// 1. Spawn using defaults encapsulated in Steel object
const steelActor = await steel.spawn();
// 2. Issue move order using shared HeroObject logic
await steel.moveTo(steelActor, enemyKeep.locator);
// 3. Cast Ability using strongly typed property
await steelActor.triggerAbilityByTag(steel.abilityShieldBash);
// 4. Assert Keep is destroyed
await expect(enemyKeep.locator).toHavePropertyValue('bIsDestroyed', true);
});