Skip to content

Dependency Injection

EngineContext is the typed dependency injection container at the heart of YAGE. Plugins register services during install(), and the rest of the engine resolves them by key.

Services are plugin-owned infrastructure: the renderer, the physics world, the input manager, the audio manager. Game state such as a score, lives or an inventory is not a service. It lives in a component on a host entity, as State Management shows.

import { EngineContext, ServiceKey } from "@yagejs/core";

Every service is identified by a ServiceKey<T>. The type parameter ensures that resolve() returns the correct type without casts:

import { ServiceKey } from "@yagejs/core";
class AudioMixer {
masterVolume = 1;
}
// Define a key with a descriptive label
const AudioMixerKey = new ServiceKey<AudioMixer>("AudioMixer");

Never use plain strings for service resolution — always define a ServiceKey.

The id string is the key’s identity. Two ServiceKey objects with the same id resolve the same service. An optional integration may repeat an id for the same service contract when importing the owner’s key would add a runtime dependency. Keep a comment beside the repeated declaration that names the package that owns the key.

import type { EngineContext } from "@yagejs/core";
// In a plugin's install():
function install(context: EngineContext) {
const mixer = new AudioMixer();
context.register(AudioMixerKey, mixer);
}
// Resolve (throws if not registered)
const mixer = context.resolve(AudioMixerKey);
// Try resolve (returns undefined if not registered)
const maybeMixer = context.tryResolve(AudioMixerKey);
// Check existence
if (context.has(AudioMixerKey)) {
// ...
}

context.resolve() throws a clear error listing the missing key name. Prefer it when the service is required. Use context.tryResolve() for optional services.

A key can add a hint to that error. Pass missingHint to say what provides the service:

import { ServiceKey } from "@yagejs/core";
interface Haptics {
pulse(seconds: number): void;
}
const HapticsKey = new ServiceKey<Haptics>("haptics", {
missingHint: "Install hapticsPlugin().",
});
// Throws: Service "haptics" is not registered. Install hapticsPlugin().
context.resolve(HapticsKey);

InspectorKey uses it: resolving the Inspector when no plugin installed one throws an error that names DebugPlugin and InspectorPlugin.

YAGE’s core and official plugins register these services:

KeyTypePackage
EngineKeyEngine@yagejs/core
EventBusKeyEventBus@yagejs/core
SceneManagerKeySceneManager@yagejs/core
LoggerKeyLogger@yagejs/core
QueryCacheKeyQueryCache@yagejs/core
ErrorBoundaryKeyErrorBoundary@yagejs/core
GameLoopKeyGameLoop@yagejs/core
InspectorKeyInspector@yagejs/core
SceneRandomSourceKeySceneRandomSource@yagejs/core
SystemSchedulerKeySystemScheduler@yagejs/core
ProcessSystemKeyProcessSystem@yagejs/core
AssetManagerKeyAssetManager@yagejs/core

Inside a component, use this.use(key). The result is cached after the first call so repeated access is free:

import { Component } from "@yagejs/core";
import { InputManagerKey } from "@yagejs/input";
import { PhysicsWorldKey, RigidBodyComponent } from "@yagejs/physics";
class PlayerController extends Component {
update(dt: number) {
const input = this.use(InputManagerKey);
const physics = this.use(PhysicsWorldKey);
if (input.isPressed("jump")) {
const body = this.sibling(RigidBodyComponent);
body.setVelocityY(-600);
}
}
}

Some keys like PhysicsWorldKey and SceneRenderTreeKey are per-scene — each scene gets its own instance. this.use() resolves the correct one automatically; you don’t need to do anything different.

Systems receive the EngineContext in onRegister. Store references there or call this.use(key) during update:

import { System, Phase, GameLoopKey, LoggerKey } from "@yagejs/core";
import type { EngineContext, Logger } from "@yagejs/core";
class DebugOverlaySystem extends System {
readonly phase = Phase.Render;
readonly priority = 9999;
private logger!: Logger;
onRegister(context: EngineContext) {
this.logger = context.resolve(LoggerKey);
}
update(dt: number) {
const frame = this.use(GameLoopKey).frameCount;
this.logger.debug("overlay", `Frame ${frame}: ${Math.round(1 / dt)} FPS`);
}
}

A plugin of your own registers a service when it wraps infrastructure the engine does not provide, such as a browser API:

import { ServiceKey, type Plugin, type EngineContext } from "@yagejs/core";
/** Vibration on devices that support it. */
export interface Haptics {
pulse(seconds: number): void;
}
export const HapticsKey = new ServiceKey<Haptics>("haptics");
export function hapticsPlugin(): Plugin {
return {
name: "haptics",
version: "1.0.0",
install(context: EngineContext) {
context.register(HapticsKey, {
pulse(seconds) {
if ("vibrate" in navigator) navigator.vibrate(Math.round(seconds * 1000));
},
});
},
};
}

Any component can then resolve it with this.use(HapticsKey) or this.service(HapticsKey):

import { Component, defineEvent } from "@yagejs/core";
import { HapticsKey } from "./haptics.js";
const PlayerHit = defineEvent("player:hit");
class HitFeedback extends Component {
private readonly haptics = this.service(HapticsKey);
onAdd(): void {
this.listen(this.entity, PlayerHit, () => this.haptics.pulse(0.08));
}
}

Don’t register game state this way. A service is not an entity, so queries, scene.findByKey and the Inspector don’t see it. An engine-level service also keeps its state when a scene restarts.

context.register services live for the engine’s lifetime. A plugin that keeps one instance per scene, as the physics world does, declares its key with scope: "scene". It registers each scene’s instance with scene.registerScoped(key, value) from a beforeEnter scene hook:

import { SceneHookRegistryKey, ServiceKey, type Plugin, type EngineContext } from "@yagejs/core";
/** Walkable cells of one scene's level. */
export class NavGrid {}
export const NavGridKey = new ServiceKey<NavGrid>("navGrid", { scope: "scene" });
export function navPlugin(): Plugin {
return {
name: "nav",
version: "1.0.0",
install(context: EngineContext) {
context.resolve(SceneHookRegistryKey).register({
beforeEnter: (scene) => {
scene.registerScoped(NavGridKey, new NavGrid());
},
});
},
};
}

Components resolve it the same way as any other service (this.use(NavGridKey)). Every key registered this way is automatically unregistered when the scene exits (after onExit and plugin afterExit hooks run), so the next scene starts with a clean slate and there’s no cross-scene leakage.

Per-scene game state, such as a level clock, needs no scoped service. A component on an entity the scene spawns is destroyed with the scene in the same way.

Resolving scene-scoped services from anything holding the scene

Section titled “Resolving scene-scoped services from anything holding the scene”

Scene.use(key) and Scene.service(key) are scope-aware: they check scene scope first, then engine scope — exactly like Component.use. Plugin beforeEnter hooks run before onEnter, so a scene-scoped value is already registered by the time onEnter fires. That means the obvious call works in the obvious place — e.g. attaching a scene-wide effect or mask at setup:

import { Scene } from "@yagejs/core";
import { SceneRenderTreeKey } from "@yagejs/renderer";
import { crt } from "@yagejs/effects";
class LevelScene extends Scene {
readonly name = "level";
override onEnter() {
// Resolves this scene's render tree — registered by RendererPlugin's
// beforeEnter hook, which ran before onEnter.
this.use(SceneRenderTreeKey).fx.addEffect(crt({ lineContrast: 0.4 }));
}
}

Both are public, so any holder of a scene reference resolves the same way — an entity’s setup(), a helper function that takes a scene, an addon:

import { Entity, RandomKey, Transform, Vec2 } from "@yagejs/core";
class Grass extends Entity {
setup({ position }: { position: Vec2 }) {
// RandomKey is scene-scoped, so every scene gets its own seeded stream.
const rng = this.scene.use(RandomKey);
this.add(new Transform({ position, rotation: rng.range(-0.2, 0.2) }));
}
}

You don’t need SceneRenderTreeProviderKey for this — that key is for cross-scene tooling such as the inspector and debug overlay, which enumerate every scene’s tree. Game code reaches its own scene’s tree with this.use(SceneRenderTreeKey).

For scene-scoped keys, resolve them inside onEnter() (or later) via use() rather than a field initializer with service() — the scoped value is recreated each time the scene is entered, and a field-level proxy would cache the first one.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/core-concepts.md.