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