Enterprise-Grade Game Automation.
Modern Developer Experience.

Bring modern software testing practices directly into Unreal Engine. Specter bridges UE5 directly to Node via WebSockets, empowering your SDETs to build robust, code-driven test suites in TypeScript from day one.

Whether you are a native Unreal Engine developer, a web-standard SDET, or a manual QA tester transitioning to automation, Specter's readable TypeScript syntax eliminates the learning curve.

See It In Action

Watch how perfectly our Playwright game-object model syncs with native Unreal Engine execution.

charge.spec.tsREADY
import { test, expect } from '@specter-automation/test'

test("Q - Charge should move Steel forward and apply damage", async ({
    server: {
      heroes: { steel },
      minion,
    },
    steelActor,
  }) => {
    const enemySpawnLocations: Vector3[] = [
      { x: -412.950478, y: 441.416008, z: 160.0 },
      { x: -412.950478, y: 231.416008, z: 160.0 },
      { x: -582.950478, y: 301.416008, z: 160.0 },
    ];

    const enemies = await test.step("Spawn enemies", async () => {
      return await Promise.all(
        enemySpawnLocations.map((location) =>
          minion.spawn({
            theme: "paragon",
            type: "range",
            spawnOptions: {
              location,
              properties: { InitialTeamId: 2 },
              rotation: { pitch: 0, yaw: -90, roll: 0 },
            },
          }),
        ),
      );
    });

    const startLocation = await steelActor.getLocation();
    const forwardDirection = await steelActor.getForwardVector();
    await steelActor.triggerAbilityWithTarget(
      steel.abilityCharge,
      VectorUtils.addOffset(startLocation, forwardDirection, 50),
    );

    await test.step("Verify hit enemies recieve damage and knockback", async () => {
      await Promise.all([
        ...enemies.flatMap((enemy, index) => [
          expect(enemy).toHaveGameplayTag("Status.Stunned"),
          expect(enemy).toHaveAttributeChange("HealthSet", "Health", {
            delta: -100,
          }),
          expect(enemy).toHaveMovedFurtherThan(enemySpawnLocations[index], 100),
        ]),
      ]);
    });

    await expect(steelActor).toHaveMovedFurtherThan(startLocation, 1000);
  });

90% Less Boilerplate.
100% Native Execution.

Say goodbye to brittle, massive C++ test harnesses. Specter introduces the Game Object Model—giving your SDETs strongly-typed access to native actors and abilities directly from TypeScript.

steel.tsGAME OBJECT MODEL
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");

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

The Native Reality

For contrast, look at the code below. This is the exact same test written natively in Unreal's Gauntlet framework using C++.

Manual cast fetching, brittle latent command queuing, zero IntelliSense for objects... this is what costs studios millions of dollars in SDET velocity.

CombatTest.cppNATIVE REALITY
#include "Misc/AutomationTest.h"
#include "Tests/AutomationCommon.h"
#include "Engine/World.h"
#include "AbilitySystemGlobals.h"
#include "AbilitySystemComponent.h"
#include "HotsAttributeSet.h"
#include "GameFramework/Character.h"

// 1. Define the Spec and State Variables
BEGIN_DEFINE_SPEC(FSteelChargeSpec, "Game.Combat.SteelCharge", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
    ACharacter* SteelActor;
    TArray<ACharacter*> Enemies;
    TArray<FVector> EnemySpawnLocations;
    FVector SteelStartLocation;
    TArray<float> InitialEnemyHealths;
END_DEFINE_SPEC(FSteelChargeSpec)

void FSteelChargeSpec::Define()
{
    It("Q - Charge should move Steel forward and apply damage and stun to enemies", [this]()
    {
        UWorld* World = GEngine->GetWorldContexts()[0].World();
        if (!World) return false;

        // 2. Set Up Spawn Locations
        EnemySpawnLocations = {
            FVector(-412.95f, 441.41f, 160.0f),
            FVector(-412.95f, 231.41f, 160.0f),
            FVector(-582.95f, 301.41f, 160.0f)
        };

        // 3. Spawn Enemies and Record Initial State
        for (const FVector& SpawnLoc : EnemySpawnLocations)
        {
            // Note: Manual class fetching from Blueprint paths
            UClass* MinionClass = StaticLoadClass(ACharacter::StaticClass(), nullptr, TEXT("/Game/Blueprints/Units/Minions/Paragon/Range/BP_Paragon_Range_Minion.BP_Paragon_Range_Minion_C"));
            ACharacter* Minion = World->SpawnActor<ACharacter>(MinionClass, SpawnLoc, FRotator(0, -90, 0));
            
            // Note: Property overrides like TeamId require manual casting/setters in C++
            if (AMinionCharacter* MinionChar = Cast<AMinionCharacter>(Minion)) {
                MinionChar->TeamId = 2;
            }
            
            Enemies.Add(Minion);

            // Fetch ASC to read baseline health
            UAbilitySystemComponent* MinionASC = UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(Minion);
            if (MinionASC) {
                bool bFoundHealth;
                float Health = MinionASC->GetNumericAttribute(UBaseAttributeSet::GetHealthAttribute(), bFoundHealth);
                InitialEnemyHealths.Add(Health);
            }
        }

        // 4. Calculate Target Vector
        SteelStartLocation = SteelActor->GetActorLocation();
        FVector ForwardDirection = SteelActor->GetActorForwardVector();
        FVector TargetLocation = SteelStartLocation + (ForwardDirection * 50.0f);

        // 5. Trigger the Ability via Gameplay Ability System
        UAbilitySystemComponent* SteelASC = UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(SteelActor);
        if (SteelASC)
        {
            // Note: Targeting requires manually constructing GameplayEventData in C++
            FGameplayEventData Payload;
            Payload.TargetData = UAbilitySystemBlueprintLibrary::AbilityTargetDataFromLocations(
                FGameplayAbilityTargetingLocationInfo(), 
                FGameplayAbilityTargetingLocationInfo() // Requires manual setup
            );
            
            FGameplayTag AbilityTag = FGameplayTag::RequestGameplayTag(FName("Ability.Charge"));
            SteelASC->HandleGameplayEvent(AbilityTag, &Payload);
        }

        // 6. Queue a Latent Command to allow physics/animations to tick
        ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(2.5f));

        // 7. Queue ANOTHER Latent Command lambda to execute the assertions
        ADD_LATENT_AUTOMATION_COMMAND(FFunctionLatentCommand([this]()
        {
            // Verify Steel's Movement
            float SteelDistance = FVector::Dist(SteelStartLocation, SteelActor->GetActorLocation());
            TestTrue(TEXT("Steel moved further than 1000 units"), SteelDistance > 1000.0f);

            // Verify Enemy States
            for (int32 i = 0; i < Enemies.Num(); ++i)
            {
                ACharacter* Enemy = Enemies[i];
                if (!Enemy) continue;

                UAbilitySystemComponent* EnemyASC = UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(Enemy);
                if (!EnemyASC) continue;

                // Assert Tag
                FGameplayTag StunTag = FGameplayTag::RequestGameplayTag(FName("Status.Stunned"));
                TestTrue(TEXT("Enemy has Stunned tag"), EnemyASC->HasMatchingGameplayTag(StunTag));

                // Assert Attribute Change
                bool bFoundHealth;
                float CurrentHealth = EnemyASC->GetNumericAttribute(UBaseAttributeSet::GetHealthAttribute(), bFoundHealth);
                TestEqual(TEXT("Enemy took 100 damage"), CurrentHealth, InitialEnemyHealths[i] - 100.0f);

                // Assert Knockback
                float EnemyDistance = FVector::Dist(EnemySpawnLocations[i], Enemy->GetActorLocation());
                TestTrue(TEXT("Enemy was knocked back > 100 units"), EnemyDistance > 100.0f);
            }
            
            return true;
        }));

        return true;
    });
}
Visual Debugging

In-Engine Test Explorer & Playwright UI

No context switching. Specter includes a native Unreal Editor Utility Widget that embeds the Playwright test runner directly inside UE5. Run test suites, stream live frame telemetry, and debug step failures without ever leaving the editor.

Native UE5 Editor Utility Widget acting as Playwright UI inside Unreal Engine
Live step telemetry, green/red status indicators, and console log streaming
Run via Playwright UI Mode and export detailed HTML reports for CI/CD
In-Engine Trace Viewer Preview
See Full Features