Building a Custom Renderer Plugin
This guide walks through building a complete renderer plugin for GWEN — from scaffolding the package to exposing composables and passing the conformance suite.
What is a renderer plugin?
A renderer plugin connects a graphical technology (Canvas, WebGL, Three.js, a custom 2D engine…) to the GWEN engine. It:
- Implements
RendererServicefrom@gwenjs/renderer-core - Registers itself via
engine.provide('renderer:<name>', service) - Manages one or more named DOM layers (each a
<canvas>or<div>) - Exposes composables (
useMyRenderer()) that game code calls insidedefineActor
The GWEN engine does not know about rendering at all — everything visual is a plugin.
Prerequisites
- Read
internals-docs/renderer-system.mdfor architecture context @gwenjs/renderer-coremust be installed (it provides the contract)
Step 1 — Scaffold the package
Use the GWEN CLI to generate the package structure:
pnpm dlx @gwenjs/cli scaffold package renderer-mytechThis generates the full package structure:
renderer-mytech/
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
├── index.ts
├── types.ts
├── plugin.ts
├── composables.ts
├── augment.ts
└── module.tsThen add @gwenjs/renderer-core as a dependency:
cd renderer-mytech
pnpm add @gwenjs/renderer-coreStep 2 — Implement the renderer service
Use defineRendererService to define your service. It handles contract version, element caching, UnknownLayerError, and stats wiring automatically.
Create src/mytech-renderer-service.ts:
import { defineRendererService, type LayerDef } from '@gwenjs/renderer-core'
import { MyTechEngine } from 'mytech'
export interface MyTechRendererOptions {
layers: Record<string, LayerDef>
}
let engine: MyTechEngine | null = null
export const MyTechRenderer = defineRendererService<MyTechRendererOptions>((opts) => ({
name: 'renderer:mytech',
layers: opts.layers,
// Called once per declared layer — result is cached automatically
createElement() {
return document.createElement('canvas')
},
mount({ getLayer }) {
const canvas = getLayer(Object.keys(opts.layers)[0]!) as HTMLCanvasElement
engine = new MyTechEngine({ canvas })
},
unmount() {
engine?.dispose()
engine = null
},
resize(w, h) {
engine?.setSize(w, h)
},
// Called each frame via service.flush() — stats are no-ops when disabled
flush({ reportFrameTime }) {
const t = performance.now()
engine?.render()
reportFrameTime(performance.now() - t)
},
}))How LayerManager orchestrates mount
defineRendererService creates two distinct API surfaces:
RendererServiceDef (what you write) | RendererService (what LayerManager calls) |
|---|---|
createElement(layerName): HTMLElement | getLayerElement(layerName): HTMLElement |
mount(ctx: RendererMountContext): void | mount(container: HTMLElement): void |
The orchestration sequence when manager.mount() is called:
- For each declared layer — LayerManager calls
service.getLayerElement(layerName), which triggers yourcreateElement(layerName)on first call and caches the result. - DOM insertion — LayerManager inserts each element into the container in
orderorder. - Mount call — LayerManager calls
service.mount(container). Internally,defineRendererServicetranslates this todef.mount({ container, getLayer: (name) => elementCache.get(name) }). - Your
mount({ getLayer })runs — at this point, all elements are already in the DOM and fully sized.
Testing your service directly
When testing outside of LayerManager, call service.mount(container) with an HTMLElement — not { getLayer }. The getLayer context is constructed internally by defineRendererService.
const service = MyTechRenderer({ layers: { main: { order: 0 } } })
const container = document.createElement('div')
document.body.appendChild(container)
service.mount(container) // ✅ correct public APIExposing renderer-specific methods for composables
Some renderers need to expose infrastructure methods (e.g. allocateHandle) that composables call via useService. Use the extension field — it is merged into the returned service and typed via the second generic, with no Object.assign and no boilerplate reimplementation.
import { defineRendererService, UnknownLayerError, type LayerDef } from '@gwenjs/renderer-core'
export interface MyTechRendererOptions {
layers: Record<string, LayerDef>
}
export interface MyTechHandle {
setPosition(x: number, y: number): void
destroy(): void
}
// The extension type is reflected on ReturnType<typeof MyTechRenderer>
export const MyTechRenderer = defineRendererService<
MyTechRendererOptions,
{ allocateHandle(layerName: string, key: string): MyTechHandle }
>((opts) => {
// State scoped to this instance — extension methods close over it
const layerObjects = new Map<string, MyTechLayer>()
for (const [name, def] of Object.entries(opts.layers)) {
layerObjects.set(name, new MyTechLayer(name, def))
}
return {
name: 'renderer:mytech',
layers: opts.layers,
createElement: (name) => layerObjects.get(name)!.element,
mount: () => {},
unmount: () => { layerObjects.forEach((l) => l.destroy()) },
resize: () => {},
extension: {
allocateHandle(layerName, key) {
const layer = layerObjects.get(layerName)
if (!layer) throw new UnknownLayerError(layerName, 'renderer:mytech')
return layer.allocate(key)
},
},
}
})
// Export the extended service type for composables to cast to
export type MyTechRendererService = ReturnType<typeof MyTechRenderer>Then in the composable:
import { onCleanup } from '@gwenjs/core'
import { useService } from '@gwenjs/core/system'
import type { MyTechHandle, MyTechRendererService } from './mytech-renderer-service.js'
export function useMyTechObject(layerName: string, key: string): MyTechHandle {
const service = useService('renderer:mytech') as MyTechRendererService
const handle = service.allocateHandle(layerName, key)
onCleanup(() => handle.destroy())
return handle
}Step 3 — Create the GwenPlugin
Create src/mytech-plugin.ts:
import { definePlugin } from '@gwenjs/kit/plugin'
import { getOrCreateLayerManager } from '@gwenjs/renderer-core'
import type { LayerDef } from '@gwenjs/renderer-core'
import { MyTechRenderer } from './mytech-renderer-service.js'
export interface MyTechRendererPluginOptions {
layers: Record<string, LayerDef>
container?: HTMLElement
}
export const MyTechRendererPlugin = definePlugin<MyTechRendererPluginOptions>((opts) => {
const service = MyTechRenderer({ layers: opts.layers })
return {
name: 'renderer:mytech',
setup(engine) {
engine.provide('renderer:mytech', service)
const manager = getOrCreateLayerManager(engine, opts.container ?? document.body)
if (import.meta.env.DEV || engine.debug) {
manager.enableStats()
}
manager.register(service)
engine.hooks.hook('engine:init', () => manager.mount())
engine.hooks.hook('engine:stop', () => manager.unregister('renderer:mytech'))
},
onRender() {
service.flush()
},
}
})Step 4 — Expose composables
Composables are the public API for game code. Each composable:
- Retrieves the service via
useService - Creates the resource on the service
- Registers
onDestroyautomatically — the game dev never has to
Create src/composables/use-mytech-object.ts:
import { onDestroy } from '@gwenjs/core/actor'
import { useService } from '@gwenjs/core/system'
export interface MyTechObjectHandle {
setPosition(x: number, y: number): void
setVisible(v: boolean): void
destroy(): void
}
/**
* Adds a MyTech renderable object to the current actor.
* Cleaned up automatically when the actor is destroyed.
*
* Must be called inside `defineActor()`.
*
* @example
* ```ts
* export const EnemyActor = defineActor(EnemyPrefab, () => {
* const obj = useMyTechObject()
* onUpdate(() => obj.setPosition(Position.x[id], Position.y[id]))
* })
* ```
*/
export function useMyTechObject(): MyTechObjectHandle {
const service = useService('renderer:mytech')
const obj = service.createObject()
onDestroy(() => obj.destroy())
return {
setPosition: (x, y) => obj.setPosition(x, y),
setVisible: (v) => obj.setVisible(v),
destroy: () => obj.destroy(),
}
}No entity ID needed
The composable uses onDestroy (public API) for lifecycle — no internal API needed. If you need transform sync, do it explicitly in onUpdate or build a higher-level composable on top.
Step 5 — Export a GwenModule
Create src/module.ts:
import { defineGwenModule } from '@gwenjs/kit/module'
import { MyTechRendererPlugin } from './mytech-plugin.js'
import type { MyTechRendererOptions } from './mytech-plugin.js'
export default defineGwenModule<MyTechRendererOptions>({
meta: {
name: '@gwenjs/renderer-mytech',
configKey: 'rendererMytech',
},
defaults: {
layers: { main: { order: 0 } },
},
setup(options, gwen) {
gwen.addPlugin(MyTechRendererPlugin(options))
gwen.addAutoImports([
{ name: 'useMyTech', from: '@gwenjs/renderer-mytech' },
])
gwen.addModuleAugment(`
declare module '@gwenjs/core' {
interface GwenProvides {
'renderer:mytech': import('@gwenjs/renderer-mytech').MyTechRendererService
}
}
`)
},
})Step 6 — Add the conformance test
// tests/conformance.test.ts
import { runConformanceTests } from '@gwenjs/renderer-core/testing'
import { MyTechRenderer } from '../src/mytech-renderer-service.js'
describe('@gwenjs/renderer-mytech conformance', () => {
it('satisfies the RendererService contract', () => {
const service = MyTechRenderer({ layers: { main: { order: 0 } } })
expect(() => runConformanceTests(service)).not.toThrow()
})
})Step 7 — Register in gwen.config.ts
import { defineConfig } from '@gwenjs/app'
export default defineConfig({
modules: [
['@gwenjs/renderer-mytech', {
layers: {
background: { order: 0 },
game: { order: 10 },
}
}],
]
})Integrating with cameras and viewports
@gwenjs/renderer-core ships two managed services that renderer plugins should integrate with: ViewportManager and CameraManager.
ViewportManager — named screen regions
A viewport is a normalized [0–1] region of the output surface. Game code calls useViewportManager() to declare viewports; your renderer reads them to position and size render targets.
import { getOrCreateViewportManager } from '@gwenjs/renderer-core'
// Inside GwenPlugin.setup(engine):
const viewports = getOrCreateViewportManager(engine)
// React to viewport changes (resize, add, remove)
engine.hooks.hook('viewport:add', ({ id, region }) => {
// region: { x, y, width, height } — all normalized [0–1]
myRenderer.addRenderTarget(id, region)
})
engine.hooks.hook('viewport:resize', ({ id, region }) => {
myRenderer.resizeRenderTarget(id, region)
})
engine.hooks.hook('viewport:remove', ({ id }) => {
myRenderer.removeRenderTarget(id)
})
// Read current viewports at mount time
for (const [id, region] of viewports.entries()) {
myRenderer.addRenderTarget(id, region)
}The viewport:add, viewport:resize, and viewport:remove hooks are emitted by ViewportManager itself — your renderer only needs to subscribe.
CameraManager — per-viewport camera state
After CameraSystem runs each frame, CameraManager holds the winning CameraState for each viewport. Read it in your render flush to apply transforms.
import { getOrCreateCameraManager } from '@gwenjs/renderer-core'
import type { CameraState } from '@gwenjs/renderer-core'
// Inside GwenPlugin.setup(engine):
const cameras = getOrCreateCameraManager(engine)
// Inside your flush / onRender callback:
flush({ reportFrameTime }) {
const t = performance.now()
for (const [viewportId, target] of myRenderer.targets) {
const cam = cameras.get(viewportId)
if (cam) {
const { x, y, z } = cam.worldTransform.position
const { rotX, rotY, rotZ } = cam.worldTransform.rotation
myRenderer.setCamera(viewportId, x, y, z, rotX, rotY, rotZ, cam.projection)
}
myRenderer.render(viewportId)
}
reportFrameTime(performance.now() - t)
}CameraState fields:
| Field | Type | Description |
|---|---|---|
entityId | EntityId | The camera entity |
viewportId | string | Target viewport |
worldTransform | WorldTransform | { position: Vec3, rotation: Vec3, scale: Vec3 } |
projection | CameraProjection | { type: 'orthographic' | 'perspective', zoom, fov, near, far } |
shakeOffset | Vec2 | Screen-space offset from CameraShake (does not affect worldTransform) |
LayerDef.scope
The scope field on LayerDef lets you declare whether a layer should be replicated per viewport or shared globally.
layers: {
// One game canvas per viewport (default behaviour)
game: { order: 10, scope: 'viewport' },
// Single shared HUD layer across all viewports
hud: { order: 90, scope: 'global' },
}scope: 'viewport' (default) signals to higher-level tooling that the layer is expected to map 1:1 with a viewport region. scope: 'global' signals a full-screen overlay. LayerManager does not enforce layout — the renderer plugin is responsible for using this metadata to position its elements.
Required vs. optional
| RendererService member | Required | Notes |
|---|---|---|
name | ✅ | Must match the GwenProvides key |
contractVersion | ✅ | Must equal RENDERER_CONTRACT_VERSION |
layers | ✅ | At least one entry |
mount() | ✅ | Called after DOM is ready |
unmount() | ✅ | Must free all resources |
resize() | ✅ | Called on viewport resize |
getLayerElement() | ✅ | Must throw UnknownLayerError for unknown names |
setStatsCollector() | Optional | Implement to support devtools stats |
Checklist before publishing
- [ ]
runConformanceTests()passes in CI - [ ]
pnpm typecheckpasses - [ ]
GwenProvidesaugmentation declared inindex.d.ts - [ ]
onDestroy/unmount()releases all resources (listeners, GPU buffers, DOM nodes) - [ ]
setStatsCollectorimplemented if the renderer issues draw calls - [ ] README includes the
gwen.config.tssnippet