unreal-rc
v0.5.1
Published
Typed client for Unreal Engine Remote Control over WebSocket or HTTP
Readme
unreal-rc
Typed TypeScript client for Unreal Engine's Remote Control plugin. Communicate with a running Unreal Editor or game instance over WebSocket or HTTP.
- Transport-agnostic — swap between WebSocket and HTTP with one option
- Type-safe — Effect Schema validation on request and response payloads
- Resilient — auto-reconnect, configurable retries, end-to-end request timeouts
- Observable — lifecycle hooks for logging and tracing
- Effect-powered — bring your own compatible Effect version
Install
npm install unreal-rc effectSetup
Enable the Remote Control plugin in your Unreal project (Edit > Plugins > search "Remote Control"). The plugin opens two localhost endpoints:
| Protocol | Default Port |
|----------|-------------|
| HTTP | 30010 |
| WebSocket | 30020 |
Creating a Client
import { UnrealRC } from "unreal-rc";
// WebSocket (default) — persistent connection with auto-reconnect
const ue = new UnrealRC();
// HTTP — stateless fetch-based requests
const ue = new UnrealRC({ transport: "http" });
// Full options
const ue = new UnrealRC({
transport: "ws", // "ws" | "http"
host: "127.0.0.1", // Unreal host
port: 30020, // Port (default: 30020 for ws, 30010 for http)
passphrase: "secret", // Overrides the default Remote Control HTTP passphrase
validateResponses: true, // Validate response schemas with Effect Schema
retry: { // Retry policy (or `true` for defaults, `false` to disable)
maxAttempts: 3,
delayMs: 100, // or (context) => context.attempt * 200
shouldRetry: (ctx) => ctx.error.kind !== "decode",
},
});
// Always dispose when done
await ue.dispose();HTTP clients send Passphrase: "smh ue, this is stupid" by default. Override passphrase if your Unreal Remote Control setup uses a different value. In practice, some Unreal versions require the Passphrase header for /remote/batch even when other HTTP routes appear to work without it.
API Reference
call(args)
Call a function on a remote UObject.
// Call a Blueprint function
await ue.call({
objectPath: "/Game/Maps/Main.Main:PersistentLevel.MyActor",
functionName: "SetActorHiddenInGame",
parameters: { bNewHidden: false }
});
// Call with transaction support (for undo/redo in the editor)
await ue.call({
objectPath: path,
functionName: "IncrementCounter",
parameters: { Delta: 5 },
transaction: true
});
// Access the return value
const result = await ue.call({ objectPath: path, functionName: "GetHealth" });
console.log(result.ReturnValue); // e.g. 100Options:
| Option | Type | Description |
|--------|------|-------------|
| transaction | boolean | Wrap in an editor transaction (enables undo) |
| timeoutMs | number | Per-request timeout override for the full request lifecycle, including queued websocket time |
| retry | RetryOptions | Per-request retry override |
getProperty<T>(args)
Read a single property from a remote UObject. Returns the property value directly.
const health = await ue.getProperty<number>({ objectPath: path, propertyName: "Health" });
// health === 100
const location = await ue.getProperty<{ X: number; Y: number; Z: number }>({
objectPath: path,
propertyName: "RelativeLocation"
});getProperties<T>(args)
Read all properties from a remote UObject at once.
const props = await ue.getProperties<{ Health: number; Mana: number }>({ objectPath: path });
// props.Health, props.ManasetProperty(args)
Write a property on a remote UObject.
await ue.setProperty({ objectPath: path, propertyName: "Health", propertyValue: 100 });
// With transaction support
await ue.setProperty({ objectPath: path, propertyName: "Health", propertyValue: 100, transaction: true });
// Setting a struct property
import { vector } from "unreal-rc";
await ue.setProperty({ objectPath: path, propertyName: "RelativeLocation", propertyValue: vector(100, 200, 300) });Arguments:
| Option | Type | Description |
|--------|------|-------------|
| access | "WRITE_ACCESS" \| "WRITE_TRANSACTION_ACCESS" | Access mode |
| transaction | boolean | Wrap in an editor transaction |
| timeoutMs | number | Per-request timeout override for the full request lifecycle, including queued websocket time |
| retry | RetryOptions | Per-request retry override |
describe(args)
Get metadata about a remote UObject — its properties, functions, class, and display name.
const meta = await ue.describe({ objectPath: path });
// List all exposed functions
for (const fn of meta.Functions ?? []) {
console.log(fn.Name, fn.Arguments);
}
// List all exposed properties
for (const prop of meta.Properties ?? []) {
console.log(prop.Name, prop.Type);
}Returns: ObjectDescribeResponse
{
Name?: string;
Class?: string;
DisplayName?: string;
Path?: string;
Properties?: PropertyMetadata[];
Functions?: FunctionMetadata[];
}searchAssets(args)
Search for assets in the project.
const result = await ue.searchAssets({ query: "Chair" });
for (const asset of result.Assets ?? []) {
console.log(asset.Name, asset.ObjectPath, asset.AssetClass);
}Arguments:
| Option | Type | Description |
|--------|------|-------------|
| classNames | string[] | Filter by asset class |
| packagePaths | string[] | Filter by package path |
| recursivePaths | boolean | Search subdirectories |
| recursiveClasses | boolean | Include subclasses |
info(options?)
List all available Remote Control HTTP routes.
const info = await ue.info();
for (const route of info.HttpRoutes ?? info.Routes ?? []) {
console.log(route.Verb, route.Path, route.Description);
}event(request, options?)
Wait for a property change event on a remote UObject.
const change = await ue.event({
objectPath: path,
propertyName: "Health",
timeoutSeconds: 30,
});
console.log(change.propertyValue); // new value after changethumbnail(args)
Get a thumbnail image for an asset.
const thumb = await ue.thumbnail({ objectPath: "/Game/Meshes/Chair" });batch(configure, options?)
Execute multiple requests in a single round-trip. Each sub-request returns a BatchResult with its own status code and body.
const results = await ue.batch((b) => {
b.call({ objectPath: path, functionName: "ResetFixtures" });
b.getProperty({ objectPath: path, propertyName: "Health" });
b.setProperty({ objectPath: path, propertyName: "Score", propertyValue: 0 });
b.describe({ objectPath: path });
b.searchAssets({ query: "Chair" });
b.request("GET", "/remote/info"); // raw request
});
for (const result of results) {
console.log(result.requestId, result.statusCode, result.body);
}If batch requests fail unexpectedly while single-route HTTP calls succeed, make sure the client passphrase matches the Unreal Remote Control configuration.
BatchResult:
{
requestId: number;
statusCode: number;
body: unknown;
request: BatchRequestItem;
}dispose()
Shut down the transport and release resources. Always call this when done.
await ue.dispose();Helpers
Utility functions for building Unreal-specific values.
Path Builders
import { objectPath, piePath, blueprintLibraryPath } from "unreal-rc";
// Build an object path: "/Game/Maps/Main.Main:MyActor"
objectPath("/Game/Maps/Main", "Main", "MyActor");
// Build a PIE (Play In Editor) world name: "UEDPIE_0_Main"
piePath("Main");
piePath("Main", 1); // "UEDPIE_1_Main"
// Build a Blueprint function library path
blueprintLibraryPath("MyModule", "MyBlueprintLibrary");
// "/Script/MyModule.Default__MyBlueprintLibrary"Struct Constructors
import { vector, rotator, linearColor, transform } from "unreal-rc";
vector(100, 200, 300);
// { X: 100, Y: 200, Z: 300 }
rotator(0, 90, 0);
// { Pitch: 0, Yaw: 90, Roll: 0 }
linearColor(1, 0, 0);
// { R: 1, G: 0, B: 0, A: 1 }
linearColor(1, 0, 0, 0.5);
// { R: 1, G: 0, B: 0, A: 0.5 }
transform(vector(0, 0, 0), rotator(0, 90, 0));
// { Translation: {...}, Rotation: {...}, Scale3D: { X: 1, Y: 1, Z: 1 } }Response Parsing
import { parseReturnValue } from "unreal-rc";
const response = await ue.call({ objectPath: path, functionName: "GetHealth" });
const health = parseReturnValue<number>(response); // reads .ReturnValue
const health = parseReturnValue<number>(response, "Health"); // reads .HealthError Handling
All transport errors are thrown as TransportRequestError with structured metadata.
import { TransportRequestError } from "unreal-rc";
try {
await ue.call({ objectPath: path, functionName: "DoSomething" });
} catch (error) {
if (error instanceof TransportRequestError) {
error.kind; // "timeout" | "connect" | "disconnect" | "http_status"
// | "remote_status" | "decode" | "unknown"
error.statusCode; // HTTP status code (if applicable)
error.details; // Response body from Unreal
error.verb; // "GET" | "PUT" | ...
error.url; // "/remote/object/call"
error.transport; // "ws" | "http"
error.requestId; // Server-assigned request ID
}
}Error Kinds
| Kind | Description |
|------|-------------|
| timeout | Request exceeded the timeout |
| connect | Could not connect to Unreal |
| disconnect | Connection dropped mid-request |
| http_status | Non-2xx HTTP response from Unreal |
| remote_status | Unreal returned an application-level error |
| decode | Response did not match the expected schema |
| unknown | Unexpected error |
Retries
By default, timeout, connect, disconnect, and HTTP 502/503/504 errors are retried. Configure globally or per-request:
// Global retry policy
const ue = new UnrealRC({
retry: { maxAttempts: 5, delayMs: 200 },
});
// Per-request override
await ue.call({
objectPath: path,
functionName: "SlowFunction",
retry: { maxAttempts: 10, delayMs: 500 },
timeoutMs: 30000
});
// Disable retries for a specific request
await ue.call({ objectPath: path, functionName: "FastFunction", retry: false });Hooks
Lifecycle hooks for observability — logging, metrics, tracing.
const ue = new UnrealRC({
onRequest: (ctx) => {
console.log(`>> ${ctx.verb} ${ctx.url}`);
},
onResponse: (ctx) => {
console.log(`<< ${ctx.statusCode} (${ctx.durationMs}ms)`);
},
onError: (ctx) => {
console.error(`!! ${ctx.error.kind}: ${ctx.error.message}`);
},
// Redact sensitive data before it reaches hooks
redactPayload: (payload, ctx) => {
if (ctx.phase === "request") return "[redacted]";
return payload;
},
});Hook Contexts
RequestHookContext: { transport, verb, url, body, attempt }
ResponseHookContext: { transport, verb, url, body, requestBody, attempt, durationMs, statusCode, requestId }
ErrorHookContext: { transport, verb, url, body, error, errorBody, attempt, durationMs, statusCode, requestId }
Effect-Native Hooks
Effect-native hooks run inside the request pipeline and propagate failures (unlike callback hooks which silently ignore errors). Use them when you need typed, fail-fast observability in an Effect program.
import { Effect } from "effect";
const ue = new UnrealRC({
onRequestEffect: (ctx) => Effect.logInfo(`>> ${ctx.verb} ${ctx.url}`),
onResponseEffect: (ctx) =>
Effect.logInfo(`<< ${ctx.statusCode} (${ctx.durationMs}ms)`),
onErrorEffect: (ctx) =>
Effect.logError(`${ctx.error._tag}: ${ctx.error.message}`),
});
// Callback hooks and Effect hooks can coexist
const ue = new UnrealRC({
onRequest: (ctx) => { /* callback: errors ignored */ },
onRequestEffect: (ctx) => Effect.logInfo("..."), // fails propagate
});Effect API
All request methods are available on ue.effect.* with the same argument types as the Promise API. Effect methods return Effect<...> with tagged TransportError errors.
import { UnrealRC } from "unreal-rc";
import { Effect } from "effect";
const ue = new UnrealRC();
// Same argument shapes as the Promise API
const program = Effect.gen(function* () {
const result = yield* ue.effect.call({
objectPath: "/Game/Maps/Main.Main:Actor",
functionName: "GetHealth"
});
return result.ReturnValue;
});Error Handling with Effect
Effect methods fail with a tagged TransportError union. Narrow errors with catchTag or catchTags:
import { TimeoutError, ConnectError, HttpStatusError } from "unreal-rc/effect";
const robustCall = ue.effect.call({
objectPath: "/Game/Maps/Main.Main:Actor",
functionName: "GetHealth"
}).pipe(
Effect.catchTag("TimeoutError", (e) => Effect.succeed({ ReturnValue: -1 })),
Effect.catchTag("ConnectError", (e) =>
Effect.fail(new Error(`Unreal not running: ${e.message}`))
),
Effect.catchTag("HttpStatusError", (e) =>
Effect.succeed({ ReturnValue: e.statusCode })
)
);Error tags: TimeoutError, ConnectError, DisconnectError, HttpStatusError, RemoteStatusError, DecodeError.
Import tagged error classes from unreal-rc/effect:
import {
TimeoutError,
ConnectError,
DisconnectError,
HttpStatusError,
RemoteStatusError,
DecodeError,
type TransportError
} from "unreal-rc/effect";Schema-Driven Return Decoding (callReturn)
callReturn calls /remote/object/call and decodes only the ReturnValue field with a schema, eliminating manual unwrapping:
import { Schema } from "effect";
const ScoreSchema = Schema.Struct({ points: Schema.Number, rank: Schema.String });
// Promise API
const score = await ue.callReturn({
objectPath: "/Game/Maps/Main.Main:Actor",
functionName: "GetScore",
returnSchema: ScoreSchema
});
// score: { points: number; rank: string }
// Effect API
const score = yield* ue.effect.callReturn({
objectPath: "/Game/Maps/Main.Main:Actor",
functionName: "GetScore",
returnSchema: ScoreSchema
}).pipe(
Effect.catchTag("DecodeError", () => Effect.succeed({ points: 0, rank: "unknown" }))
);Generic Requests (request / requestRaw)
Send arbitrary HTTP requests to Unreal Remote Control endpoints:
// Decoded request — validates response with an optional schema
const data = await ue.request({
verb: "GET",
url: "/remote/info",
responseSchema: InfoResponseSchema
});
// Raw request — returns the full TransportResponse
const raw = await ue.requestRaw({
verb: "PUT",
url: "/remote/search/assets",
body: { query: "Chair" }
});
// raw: { body: unknown; statusCode?: number; requestId?: number | string }
// Effect equivalents
const data = yield* ue.effect.request({ verb: "GET", url: "/remote/info" });
const raw = yield* ue.effect.requestRaw({ verb: "PUT", url: "/custom", body: { key: "val" } });Effect Layer / Service
For full Effect applications, use the injectable service layer:
import { UnrealRCService, UnrealRCLive } from "unreal-rc/effect";
import { Effect } from "effect";
// Define your program against the service interface
const program = Effect.gen(function* () {
const ue = yield* UnrealRCService;
const info = yield* ue.info();
const result = yield* ue.call({
objectPath: "/Game/Maps/Main.Main:Actor",
functionName: "GetHealth"
});
return result.ReturnValue;
});
// Provide the live layer at the edge
await Effect.runPromise(
program.pipe(
Effect.provide(UnrealRCLive({ transport: "http" }))
)
);The service is scoped — dispose is called automatically when the Effect scope ends.
For testing, use UnrealRCTest as a stub layer:
import { UnrealRCTest } from "unreal-rc/effect";
// Replace with your own implementation in tests
const testLayer = Layer.succeed(UnrealRCService, {
call: () => Effect.succeed({ ReturnValue: "mocked" }),
// ... other methods
});Migration Note
Positional overloads have been removed. All methods now take a single object argument:
// ✅ Current (object-arg)
await ue.call({ objectPath: "/Foo", functionName: "Bar" });
// ❌ Removed (positional)
await ue.call("/Foo", "Bar");This applies to call, getProperty, getProperties, setProperty, describe, searchAssets, thumbnail, and batch builder methods. The Effect API (ue.effect.*) uses the same object-arg shapes.
Low-Level Exports
For building custom tooling or higher-level abstractions, the package also exports:
- Request builders:
buildCallRequest,buildPropertyRequest,buildDescribeRequest,buildBatchRequest - Batch builder class:
BatchBuilder - All Effect schemas:
ObjectCallRequestSchema,ObjectCallResponseSchema, etc. - Transport layers:
HttpTransportLive,WebSocketTransportLive(for Effect-based usage) - All TypeScript types:
ObjectCallRequest,ObjectCallResponse,FunctionMetadata, etc.
import { buildCallRequest, BatchBuilder } from "unreal-rc";
// Build a raw request body
const body = buildCallRequest({
objectPath: path,
functionName: "SetActorHiddenInGame",
parameters: { bNewHidden: false }
});
// Use with your own HTTP client, CLI tool, etc.License
MIT
