WebGL, WebGPU & the GPU stack 🚀
gameplate is renderer-agnostic by design. It hands you state once per frame and an
optional interpolation alpha; what happens between render(state, alpha) and the pixel is
entirely up to you and your renderer of choice.
This guide shows the integration patterns for the most popular renderers in 2026 — each one is a drop-in replacement, no changes to your game logic.
The integration shape
Every pattern below boils down to:
- Initialize the renderer once, before
createGame. - Update GPU resources from state inside
render— sync sprites' positions, push uniforms, etc. Do not allocate on every frame. - Let the renderer draw, either by calling its
draw()directly or letting it tick on its own (e.g. PIXI's internal ticker; just pass it the samestate).
import { createGame } from 'gameplate';
const renderer = await initMyRenderer(); // PIXI app / three.js scene / regl / etc.
const game = createGame({
state,
actions,
render: (state, alpha) => {
// sync state -> renderer
renderer.draw(state, alpha);
},
});
game.start();
That's the whole API surface for "use any GPU library." The rest of this guide is just the specific renderer-side code for each library.
three.js
import { createGame, defineActions } from 'gameplate';
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.append(renderer.domElement);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0xa78bfa }),
);
scene.add(cube);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
scene.add(new THREE.DirectionalLight(0xff5277, 0.8).translateX(5).translateY(5));
type State = { rotation: number };
const actions = defineActions<State>()({
spin: (s, dt: number) => ({ rotation: s.rotation + dt }),
});
const game = createGame({
state: { rotation: 0 },
actions,
fixedStep: 1 / 60,
fixedUpdate: (s, dt, actions) => actions.spin(dt),
render: (state, alpha) => {
// interpolate between physics frames
cube.rotation.x = state.rotation + alpha / 60;
cube.rotation.y = state.rotation * 0.7 + alpha / 60;
renderer.render(scene, camera);
},
});
game.start();
Why this works. Three's WebGLRenderer.render() is the cheapest possible single draw
call. State changes happen in actions (deterministic); GPU sync happens in render. The
fixed-step accumulator + alpha gives buttery 144 Hz visuals on top of a 60 Hz simulation.
PIXI.js v8
PIXI v8 ships with WebGPU support. Switch your renderer with one constant.
import { createGame, defineActions } from 'gameplate';
import * as PIXI from 'pixi.js';
const app = new PIXI.Application();
await app.init({
preference: 'webgpu', // ✨ 2026: WebGPU by default; fallback to WebGL2
resizeTo: window,
background: '#0b0a14',
antialias: true,
});
document.body.append(app.canvas);
const sprite = new PIXI.Graphics().rect(-12, -12, 24, 24).fill({ color: 0xa78bfa });
app.stage.addChild(sprite);
type State = { x: number; y: number; vx: number; vy: number };
const actions = defineActions<State>()({
physics: (s, dt: number) => ({
...s,
x: s.x + s.vx * dt,
y: s.y + s.vy * dt,
vy: s.vy + 200 * dt,
}),
});
const game = createGame({
state: { x: 100, y: 100, vx: 80, vy: 0 },
actions,
fixedStep: 1 / 60,
fixedUpdate: (s, dt, actions) => actions.physics(dt),
render: (state) => {
sprite.x = state.x;
sprite.y = state.y;
},
});
game.start();
// PIXI ticks its own renderer internally — we just sync the scene graph from state.
Pro tip. Disable PIXI's own Application.ticker if you'd rather call app.renderer.render
yourself from inside render(state):
await app.init({ ..., autoStart: false });
// ...
render: (state) => {
sprite.x = state.x; sprite.y = state.y;
app.renderer.render(app.stage);
},
That gives you a single deterministic loop with no double tick.
regl
Functional WebGL. State goes in via uniforms / attributes; commands return draw fns. This pairs
beautifully with gameplate's pure-state model:
import { createGame, defineActions } from 'gameplate';
import REGL from 'regl';
const regl = REGL();
const drawTriangle = regl({
vert: `
precision mediump float;
attribute vec2 position;
uniform float t;
void main() {
gl_Position = vec4(position * (0.6 + 0.2 * sin(t)), 0.0, 1.0);
}`,
frag: `
precision mediump float;
uniform float t;
void main() {
gl_FragColor = vec4(vec3(0.65, 0.35, 0.92) + 0.2 * sin(t), 1.0);
}`,
attributes: {
position: [
[-1, -1],
[1, -1],
[0, 1],
],
},
uniforms: { t: regl.prop('t') },
count: 3,
});
const game = createGame({
state: { t: 0 },
actions: defineActions<{ t: number }>()({
tick: (s, dt: number) => ({ t: s.t + dt }),
}),
fixedStep: 1 / 60,
fixedUpdate: (s, dt, actions) => actions.tick(dt),
render: (state) => {
regl.clear({ color: [0.04, 0.04, 0.08, 1] });
drawTriangle({ t: state.t });
},
});
game.start();
OGL
Tiny WebGL2 framework, ~10 KB. Great fit for the gameplate philosophy.
import { createGame } from 'gameplate';
import { Renderer, Camera, Geometry, Program, Mesh } from 'ogl';
const renderer = new Renderer({ dpr: 2 });
const gl = renderer.gl;
document.body.append(gl.canvas);
const camera = new Camera(gl, { fov: 35 });
camera.position.set(0, 0, 6);
const geometry = new Geometry(gl, {
position: { size: 3, data: new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]) },
});
const program = new Program(gl, {
vertex: `attribute vec3 position; uniform mat4 modelViewMatrix, projectionMatrix; void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragment: `precision highp float; void main() { gl_FragColor = vec4(0.65, 0.35, 0.92, 1.0); }`,
});
const mesh = new Mesh(gl, { geometry, program });
const game = createGame({
state: { t: 0 },
actions: { tick: (s, dt: number) => ({ t: s.t + dt }) } as const,
update: (s, dt, actions) => actions.tick(dt),
render: (state) => {
mesh.rotation.y = state.t;
renderer.render({ scene: mesh, camera });
},
});
game.start();
WebGPU (raw)
You don't need a framework — just bring your pipeline.
import { createGame } from 'gameplate';
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter!.requestDevice();
const canvas = document.querySelector<HTMLCanvasElement>('#stage')!;
const context = canvas.getContext('webgpu')!;
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'premultiplied' });
// (build your pipeline / vertex buffer / uniform buffer here — beyond the scope of this snippet)
const pipeline: GPURenderPipeline = await buildPipeline(device, format);
const game = createGame({
state: { t: 0 },
actions: { tick: (s, dt: number) => ({ t: s.t + dt }) } as const,
update: (s, dt, a) => a.tick(dt),
render: (state) => {
const encoder = device.createCommandEncoder();
const view = context.getCurrentTexture().createView();
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view,
clearValue: { r: 0.04, g: 0.04, b: 0.08, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
},
});
game.start();
WebGPU is the future. gameplate doesn't see GPU contexts at all — it'll never become
obsolete when WebGL2 sunsets.
Canvas 2D
For when you don't need a GPU at all:
import { createGame } from 'gameplate';
const ctx = (document.querySelector('canvas') as HTMLCanvasElement).getContext('2d')!;
const game = createGame({
state,
actions,
render: (state) => {
ctx.fillStyle = '#0b0a14';
ctx.fillRect(0, 0, 800, 360);
ctx.fillStyle = '#a78bfa';
ctx.fillRect(state.x, state.y, 24, 24);
},
});
game.start();
Sometimes the simplest answer is the right one. You can ship Pong with this and never touch a shader.
Picking a renderer
You can always swap later — your state / actions don't change.
Avoiding allocation in render
The single biggest performance trap with any renderer is allocating objects inside the
render callback. gameplate calls render 60–144 times per second; every { ... } in there
is a GC pause waiting to happen.
✅ Do mutate existing GPU/scene-graph nodes from state:
render: (state) => {
sprite.x = state.x; // mutating a long-lived sprite is fine
sprite.y = state.y;
};
❌ Don't allocate new objects, vectors, or arrays in render:
render: (state) => {
sprite.position = { x: state.x, y: state.y }; // allocates every frame
sprite.colors = state.enemies.map((e) => e.color); // allocates every frame
};
This rule applies in every renderer above. gameplate's state is immutable so you stay
honest there; the renderer's scene graph is mutable on purpose, so use it.
Bonus: hot-swap renderers in development
Because the render callback is just a function, you can swap implementations at runtime:
let activeRender = renderWithThree;
const game = createGame({
state,
actions,
render: (state, alpha) => activeRender(state, alpha),
});
if (import.meta.env.DEV) {
window.addEventListener('keydown', (e) => {
if (e.key === 'r' && e.altKey) {
activeRender = activeRender === renderWithThree ? renderWithPixi : renderWithThree;
}
});
}
A/B test renderers, fall back when WebGPU isn't available, hot-swap during a demo. The game loop doesn't notice.