@3dsource/angular-unreal-module
v0.0.194
Published
Angular integration for Unreal Engine Pixel Streaming
Readme
@3dsource/angular-unreal-module
Standalone Angular integration for Unreal Engine Pixel Streaming. The package provides the scene component, WebRTC and signalling lifecycle, NgRx state, command/callback APIs, reconnection, file transfer and telemetry.
Requirements
- Angular, Angular CDK and Angular Forms
>=19.0.0 <23.0.0 - NgRx Store and Effects
>=19.0.0 <23.0.0 - RxJS
>=7.8.0 <8.0.0 @3dsource/types-unreal >=0.0.14 <0.1.0provideHttpClient()in the host application
Installation
pnpm add @3dsource/angular-unreal-module @3dsource/types-unrealThe package is standalone and does not expose an NgModule.
Entry points
| Entry point | Contains |
| ---------------------------------------- | ------------------------------------------------------------------------------------ |
| @3dsource/angular-unreal-module | Everything — scene component, services, NgRx state, providers, helpers |
| @3dsource/angular-unreal-module/config | UNREAL_CONFIG, UnrealInitialConfig, UnrealMode only — no engine, 266 B of FESM |
Import the token from /config in the application root and keep every other
import behind a lazy route — see Setup for why.
Styling
The package ships its own styles and requires no UI library. Everything it draws on top of the video is customisable through three levels — take the cheapest one that solves your case.
| Need | Level | Cost | | ---------------------------------- | ------------- | -------------------------- | | different colour / radius / font | design tokens | 3 lines of CSS | | your own look for a whole block | class hooks | one class in global styles | | a different set of elements inside | slots | one template |
The three levels compose: tokens recolour what stays, a class hook restyles one block, a slot replaces what is inside it. A slot makes the class hook for the same part irrelevant — the markup inside is then entirely yours.
What you can customise
These are the blocks the scene draws over the video. Nothing else is themed — the video itself, and the dev-only overlays, are out of scope.
| Block | When the user sees it | Token-only | Class hook parts | Slot |
| -------------- | ---------------------------------- | ---------- | ------------------------------------------ | ------------------ |
| Loading status | while connecting, shows percentage | ✓ | statusCard, statusMessage | unrealStatusSlot |
| Resume / Start | stream paused or not yet started | ✓ | resumeCard, resumeText, resumeButton | unrealResumeSlot |
| AFK timeout | inactivity countdown before drop | ✓ | afkScrim, afkCard, afkButton | unrealAfkSlot |
Error dialogs (unreal-error-modal, webrtc-error-modal) are not part of
this contract: they have no class hook, no slot, and they read the
@3dsource/source-ui-native tokens (--src-*) rather than --unreal-*. They
also render outside the scene DOM — see the note under Design tokens.
1. Design tokens
Set the --unreal-* variables on app-unreal-scene — custom properties inherit
through the DOM and reach every block inside.
app-unreal-scene {
--unreal-surface-card: #101014;
--unreal-text-main: #f5f5f7;
--unreal-radius-card: 16px;
}| Token | Default |
| ------------------------- | -------------------------------------------------------- |
| --unreal-surface-card | #fff |
| --unreal-surface-screen | #fff |
| --unreal-surface-scrim | rgba(100, 100, 100, .7) |
| --unreal-text-main | #1f2937 |
| --unreal-text-secondary | #6b7280 |
| --unreal-font-family | system-ui, sans-serif |
| --unreal-radius-card | 8px |
| --unreal-radius-pill | 9999px |
| --unreal-shadow-card | 0 26px 80px 0 rgba(0,0,0,.2), 0 0 1px 0 rgba(0,0,0,.2) |
| --unreal-accent | #017bff |
| --unreal-accent-hover | #016fe6 |
| --unreal-accent-text | #fff |
If @3dsource/source-ui-native is loaded, its tokens are used automatically
wherever you do not set an --unreal-* value.
Anything opened through CDK Dialog is out of reach. The error dialogs render in a separate overlay container, outside the scene DOM, so variables set on
app-unreal-scenenever reach them — declare those on:root. Note that those dialogs read--src-*, not--unreal-*.
2. Class hooks
Supply your own class for a part; it replaces the built-in decoration class. The positioning class stays, so the scene layout cannot break.
<app-unreal-scene [uiClasses]="{ resumeCard: 'my-card', statusCard: 'my-pill' }" />Parts: resumeCard, resumeText, resumeButton, afkScrim, afkCard,
afkButton, statusCard, statusMessage.
Your classes must live in global styles (
styles.scss). Declared in a component's own SCSS, Angular scopes them and they never reach the scene.
The two button parts behave differently.
resumeButtonandafkButtonput your class on the<app-unreal-button>host, but the<button>inside is a library component whose styles are scoped — and a scoped.unreal-buttonselector (0,2,0) outweighs a global.my-cta button(0,1,1). So the hook is good for the button's outer box (width, margin, alignment); to change the button itself, use the--unreal-accent*tokens, or a slot if you want different markup altogether.
afkScrimand--unreal-surface-scrimhave one caveat. The package also ships a legacy partial,src/lib/styles/unreal.scss, which is not part of the published bundle and is not meant to be imported. It contains.frame #videoPlayOverlay { background-color: … }— an id selector that outweighs any class. If you import that partial anyway, it wins over both the class hook and the token for the AFK overlay background.
3. Slots
Replace the contents of a block with your own markup. The module keeps the wrapper and the visibility logic; you get the data through the template context.
<app-unreal-scene>
<ng-template unrealResumeSlot let-ctx>
<button (click)="ctx.start()" class="my-cta">{{ ctx.isSecondStart() ? 'Resume' : 'Start' }}</button>
</ng-template>
<ng-template unrealStatusSlot let-ctx>
<my-progress [value]="ctx.percents()" />
</ng-template>
</app-unreal-scene>| Directive | Context |
| ------------------ | --------------------------------------------- |
| unrealResumeSlot | isSecondStart, isAfkDisconnect, start() |
| unrealAfkSlot | countdown (seconds left), reset() |
| unrealStatusSlot | percents, message |
All context values except methods are Signals — call them in the template.
percents is undefined until the first value arrives — guard it if you render
it raw.
The context object is created once and never replaced; what changes are the
signals inside it. So let-ctx is stable and you can pass ctx around freely.
Markup inside a slot belongs to you, not to the library. Angular scopes a template to the component that declares it, and your slot template is declared in your component. Two consequences:
- The library's own classes do nothing there. Writing
class="resume-box__text"inside a slot will not pick up the module's styling — that class is scoped to the module.- Style slot content the way you style any of your own markup: your component's SCSS applies normally (no
::ng-deep, no global stylesheet needed — unlike class hooks).The
--unreal-*tokens do reach inside, because CSS custom properties inherit through the DOM. Read them if you want your markup to follow the same theme:.my-cta { background: var(--unreal-accent, #017bff); border-radius: var(--unreal-radius-card, 8px); }
Recipes
Dark theme, nothing else. One rule, no TypeScript:
app-unreal-scene {
--unreal-surface-card: #101014;
--unreal-surface-screen: #0b0b0f;
--unreal-text-main: #f5f5f7;
--unreal-text-secondary: #a1a1aa;
--unreal-shadow-card: 0 20px 60px 0 rgb(0 0 0 / 60%);
}Your brand's accent on the buttons. Buttons are recoloured with tokens, not with a class hook — see the note above:
app-unreal-scene {
--unreal-accent: #ff5f6d;
--unreal-accent-hover: #f0454f;
--unreal-accent-text: #fff;
}If you need a button that is shaped differently, not just recoloured, replace
the whole block through unrealResumeSlot and render your own control.
A completely different loading status. Structure, not just skin — so this is a slot. Visibility is still decided by the module:
<app-unreal-scene>
<ng-template unrealStatusSlot let-ctx>
<my-brand-progress [value]="ctx.percents() ?? 0" />
</ng-template>
</app-unreal-scene>What you cannot change
- When a block appears. Visibility stays with the module — the conditions are non-trivial (connection state, reconnect flags, AFK timers) and duplicating them in a host app is a reliable source of bugs.
- Where a block sits in the scene. Each customisable element keeps a positioning class that a host class never replaces, so the scene layout cannot be broken from outside. Move the block by restyling its container instead.
- The video element and dev overlays (
video-stats,stat-graph).
Setup
1. Register state and configuration once
Add the Unreal feature state, HTTP client and configuration at the application
root. UNREAL_CONFIG is required, although all its fields are optional.
import { provideHttpClient } from '@angular/common/http';
import type { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideUnrealState } from '@3dsource/angular-unreal-module';
import {
UNREAL_CONFIG,
type UnrealInitialConfig,
} from '@3dsource/angular-unreal-module/config';
const unrealConfig = {
regionsPingUrl: 'https://datacenter.3dsource.com/regions/',
dataChannelConnectionTimeout: 8000,
fpsMonitor: false,
autoHighResolution: false,
} satisfies UnrealInitialConfig;
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideStore(),
provideUnrealState(),
{ provide: UNREAL_CONFIG, useValue: unrealConfig },
],
};Omit provideStore() when the root NgRx store is already configured.
UNREAL_CONFIG comes from the /config entry point on purpose. The token has to
be provided in the root injector, because the module's services are
providedIn: 'root' singletons and cannot see a route-level provider. The package
itself ships as one FESM, and esbuild places a module in the common-ancestor chunk
of its importers — so importing the token from the package root here pulls the
whole engine into the application's eager bundle. On a metabox production build
that was 180 kB raw (49 kB gzip) of main spent on pages that never stream.
This pays off only while nothing else in the eager graph imports the package
root. Following the example above literally does not qualify — the
provideUnrealState() import next to it brings the FESM back. An app that wants
the engine out of its initial bundle moves provideUnrealState() to the lazy
route alongside provideUnrealModule() (step 2) and leaves only the
UNREAL_CONFIG provider at the root.
Available configuration fields:
| Field | Purpose |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| regionsPingUrl | Region latency endpoint |
| dataChannelConnectionTimeout | DataChannel connection timeout in ms |
| customErrorsEndpoint | Custom error reporting endpoint |
| streamTelemetryV2Url | Stream lifecycle telemetry endpoint |
| screenLockerContainerId | Container used by the screen-locker overlay |
| mode | 'metabox' (default) — full Metabox command protocol; 'default' — stock Pixel Streaming app, the module sends no Metabox commands of its own |
| fpsMonitor | Enables FPS monitoring (currently off — the service is not instantiated) |
| autoHighResolution | Raises resolution after the scene becomes idle |
| playwright | Enables the test-specific service behaviour |
Use { provide: UNREAL_CONFIG, useValue: {} } for the minimal configuration.
2. Boot the engine on a lazy route
Lazy-load the route file from the application router:
import type { Routes } from '@angular/router';
export const APP_ROUTES: Routes = [
{
path: 'stream',
loadChildren: () =>
import('./stream/stream.routes').then((module) => module.STREAM_ROUTES),
},
];Register provideUnrealModule() inside that lazy route file:
import type { Routes } from '@angular/router';
import { provideUnrealModule } from '@3dsource/angular-unreal-module';
import { StreamComponent } from './stream.component';
export const STREAM_ROUTES: Routes = [
{
path: '',
component: StreamComponent,
providers: [provideUnrealModule()],
},
];Keep provideUnrealState() and UNREAL_CONFIG at the application root.
The loadChildren() boundary keeps the streaming engine out of the initial
bundle. Route-scoping provideUnrealModule() tears down its effects when the
route is left.
UNREAL_CONFIGon a route'sprovidersdoes not work: the engine services areprovidedIn: 'root', so they are created by the root injector and read the token from there. A route-level value is invisible to them andinject(UNREAL_CONFIG, { optional: true })resolves tonull— e.g. the post-connection region re-ping never runs (emptyregionsPingUrl), so without the prefetch script no region is ever cached and every request goes out without one. Importing only the token at the root does not pull the module into the initial bundle (the package issideEffects: false).
3. Render the scene
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { UnrealSceneComponent } from '@3dsource/angular-unreal-module';
@Component({
selector: 'app-stream',
imports: [UnrealSceneComponent],
template: `<app-unreal-scene />`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StreamComponent {}UnrealSceneComponent also accepts isStudio,
useContainerAsSizeProvider and resolutionSize inputs, and emits
changeMouseOverScene.
Main API
provideUnrealState()— registers theunrealFeatureNgRx state.provideUnrealModule()— registers effects and boots streaming services.UnrealSceneComponent— renders and manages the Pixel Streaming scene.UnrealCommunicatorService— sends commands and UI interactions.UnrealCallbackService— observes Unreal callbacks and command responses.unrealFeature, exported selectors and actions — expose connection and scene lifecycle state.
Command packet types are provided by @3dsource/types-unreal.
That package ships two type contours, prod and QA, and your application and
this package must resolve the same one — the selection is a tsconfig
path override on the bare specifier, never an import. A bundler that ignores
tsconfig paths (plain Vite or webpack without a matching resolve.alias)
takes the types from one contour and the enum values from the other, which
fails at runtime rather than at compile time. See "Two contours: prod and QA"
in the @3dsource/types-unreal README.
Run pnpm demo:start from the repository root to see the scene component in
the demo application.
Optional prefetch scripts
The package publishes two dependency-free scripts for use in the document
<head> before Angular starts:
region-ping-prefetch.jsmeasures regions early, caches a full result and publishes its live answer. The connection never waits for it: the request takes the cached region, else the prefetch's answer if it is already certain, else no region at all (the orchestrator then picks by geo-IP). Load it first in<head>; without it the cache is filled after the first connection.stream-prefetch.jsopens and parks an eligible WebRTC connection so Angular can adopt it after bootstrap.
<script
src="https://cdn.jsdelivr.net/npm/@3dsource/angular-unreal-module/js/region-ping-prefetch.js"
async
></script>
<script
src="https://cdn.jsdelivr.net/npm/@3dsource/angular-unreal-module/js/stream-prefetch.js"
async
></script>stream-prefetch.js runs only on metabox-configurator/modular/{configuratorId}
routes — the configurator id comes from the route and there is no attribute to
supply one, so the script cannot be enabled for an arbitrary page.
It reads the same-origin assets/config.json and assets/features.json, then
the configurator's cached record from {cdn}/cache/configurator/{id}.json — the
same document the host app reads — so it polls under the identity the app will
use rather than inventing one. The newOrchestration feature flag picks the
transport: signalling (HTTP polling) or newSignalling (control socket +
requestStream). The orchestration-issued streamRequestId is forwarded to
Cirrus on the WebSocket URL (the session connectionId) and parked for adoption.
window.__stream is both the parked stream and the lock: whichever of the script
and the Angular module writes it first owns the connection, so a page never
builds two. Load the script and the package from the same version — an older
script does not know about the lock.
Optional data-*: data-config-url, data-features-url, data-ws-timeout,
data-poll-timeout.
Pin an exact package version in production when deterministic CDN assets are required.
Repository development
Run commands from the repository root:
pnpm unreal-module:build
pnpm unreal-module:build:watch
pnpm unreal-module:lint
pnpm unreal-module:test
pnpm unreal-module:test:watch
pnpm unreal-module:test:signalling
pnpm unreal-module:test:signalling:leaksunreal-module:build builds the local types-unreal dependency before this package.
Release commands publish only this package:
pnpm unreal-module:release:patch
pnpm unreal-module:release:devAfter a successful publish, the release automatically purges the matching jsDelivr tag. Retry a failed purge without rerunning the release:
pnpm unreal-module:purge-cdn -- latest
pnpm unreal-module:purge-cdn -- devRepository tooling requires Node.js 24.16.0 or newer and pnpm last version.
