Skip to content

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 RendererService from @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 inside defineActor

The GWEN engine does not know about rendering at all — everything visual is a plugin.

Prerequisites

  • Read internals-docs/renderer-system.md for architecture context
  • @gwenjs/renderer-core must be installed (it provides the contract)

Step 1 — Scaffold the package

Use the GWEN CLI to generate the package structure:

bash
pnpm dlx @gwenjs/cli scaffold package renderer-mytech

This 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.ts

Then add @gwenjs/renderer-core as a dependency:

bash
cd renderer-mytech
pnpm add @gwenjs/renderer-core

Step 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:

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): HTMLElementgetLayerElement(layerName): HTMLElement
mount(ctx: RendererMountContext): voidmount(container: HTMLElement): void

The orchestration sequence when manager.mount() is called:

  1. For each declared layer — LayerManager calls service.getLayerElement(layerName), which triggers your createElement(layerName) on first call and caches the result.
  2. DOM insertion — LayerManager inserts each element into the container in order order.
  3. Mount call — LayerManager calls service.mount(container). Internally, defineRendererService translates this to def.mount({ container, getLayer: (name) => elementCache.get(name) }).
  4. 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.

ts
const service = MyTechRenderer({ layers: { main: { order: 0 } } })
const container = document.createElement('div')
document.body.appendChild(container)
service.mount(container) // ✅ correct public API

Exposing 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.

ts
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:

ts
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:

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 onDestroy automatically — the game dev never has to

Create src/composables/use-mytech-object.ts:

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:

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

ts
// 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

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.

ts
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.

ts
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:

FieldTypeDescription
entityIdEntityIdThe camera entity
viewportIdstringTarget viewport
worldTransformWorldTransform{ position: Vec3, rotation: Vec3, scale: Vec3 }
projectionCameraProjection{ type: 'orthographic' | 'perspective', zoom, fov, near, far }
shakeOffsetVec2Screen-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.

ts
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 memberRequiredNotes
nameMust match the GwenProvides key
contractVersionMust equal RENDERER_CONTRACT_VERSION
layersAt 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()OptionalImplement to support devtools stats

Checklist before publishing

  • [ ] runConformanceTests() passes in CI
  • [ ] pnpm typecheck passes
  • [ ] GwenProvides augmentation declared in index.d.ts
  • [ ] onDestroy / unmount() releases all resources (listeners, GPU buffers, DOM nodes)
  • [ ] setStatsCollector implemented if the renderer issues draw calls
  • [ ] README includes the gwen.config.ts snippet