Skip to content

Guide: building a game/sim with insomni

insomni isn’t a game engine — there’s no scene graph, no entity system, no asset pipeline. But the pieces a 2D game or simulation loop reaches for over and over (camera + input, sprite atlases, draw-order-by-Y, shader effects, particles, a minimap, deterministic screenshot tests) all exist in the core renderer or its siblings. This guide is a map from “I’m building a top-down/isometric game or a live sim” to the real API and the example page that exercises it.

A viewport owns the camera that maps world coordinates to screen pixels. bindCamera(renderer, canvas, opts) is the batteries-included one-call path: it creates a CameraViewport sized to the canvas, wires pointer drag/wheel zoom/pinch input, subscribes camera updates to renderer.setCamera, and re-clamps zoom on DPR/resize changes. See the Camera binding recipe for the full { viewport, binding, destroy } contract and minZoom/maxZoom as number | () => number.

For full control over the wiring — or to see exactly what bindCamera wraps — createViewport is the underlying imperative state container (panBy/zoomAt/jumpTo) and bindViewport wires the input by hand. See Cameras & viewport and the pan-zoom example, which still uses the manual createViewport + bindViewport + renderer.setCamera(viewport.camera) form.

Layer.pushSprite({ pos, size, texture, tint?, rotation?, anchor?, opaque? }) draws one textured, tinted quad. texture accepts either a whole Texture or a TextureRegion — a sub-rect of a shared atlas, via texture.region(x, y, w, h), texture.regionUV(...), or the SpriteAtlas / GridAtlas helpers — so a single loaded PNG can back many sprites without separate texture binds. See Primitives → Sprites and textures and → Texture regions and atlases for the full field table and GridAtlas/SpriteAtlas API, the sprite-atlas example for a GridAtlas over a real 20x15 item-icon sheet driving 300 pushSprite calls off one shared texture bind, and the push-custom example (via layer.pushCustom/pushSprite) for a layer that pushes sprites at specific submission points.

createLayer({ sort: "y", sortBias? }) depth-sorts a layer’s sprites by anchor pos.y + sortBias (ascending — smaller y drawn first/further back) instead of push order, so lower-on-screen sprites draw over higher ones — the classic top-down/isometric trick. It only reorders that layer’s sprites; shapes and glyphs in the same layer still draw in push order, and layers themselves are still flat z-bands drawn in push/array order. sortBias is an additive nudge on the ranking key, useful for pinning one sprite consistently above/below others at the same y (a shadow, a held item). See the y-sort example: the identical set of orbiting sprite tokens, pushed in the identical order, split-screen — push-order occlusion glitches on the left as tokens cross paths, sort:"y" stays correct on the right. See Layers & groups for how push order and zIndex resolve outside of sort:"y".

createTextureEffect(renderer, { texture, rect, fragment }) wraps a textured quad with a custom WGSL fragment shader — glow, outline, distortion — and returns a CustomDrawable you drop straight into render([...]). See Custom shaders and the sprite-shine example, which layers two different createTextureEffect shaders (an animated sweep and a static outline) over one loaded PNG.

The insomni/particles entry point (ParticleSystem, see packages/insomni/src/particles/system.ts) is a GPU compute-driven, fixed-capacity particle pool with force kernels (gravity, drag, noise, flow fields, boids). See the particles example: a pointer-following ember fountain wiring up gravity/drag/noise forces and createV3Drawable end to end.

The flow-field example is a different, CPU-driven pattern worth knowing too — thousands of particles advected through a vector field by pushing plain segments each frame, no GPU compute involved. Use it for the interaction pattern (pointer-repelled field, per-frame layer rebuild) while the CPU cost of updating a few thousand instances per frame is a non-issue; reach for insomni/particles once that cost becomes the bottleneck.

createNavigator(options) builds a minimap/overview with a draggable indicator rectangle tracking the live camera. See Specialized layers → Navigator and the minimap example, which mirrors a full scene at small scale and keeps the indicator in sync as you pan and zoom.

insomni-node runs the core renderer headless on Dawn/WebGPU — no browser, no canvas — and reads a frame back as raw pixels or a PNG. That’s the basis for deterministic golden-image tests: build a scene with the ordinary insomni API (createLayer, pushRect, etc.), call await api.frame([scene]), then await api.toPNG() and diff it against a checked-in reference image. See packages/insomni-node/examples/smoke.mjs for the full working script (render two rounded rects, write a PNG) and the insomni-node package page for setup/platform notes.