
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.
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.
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 on the rightbelow. 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.
#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;
});
}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);
});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.
