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);
});