@siltrun/stage
v0.1.2
Published
Silt 2D client baseline: the PixiJS stage every Silt title starts from. App bootstrap (DPR/resize/context-loss), three-model camera, unified touch+keyboard input, juice primitives (particles/haptics/pattern fills), bloom, procedural audio kit, remote-enti
Maintainers
Readme
@siltrun/stage
The 2D stage every Silt title starts from, extracted from a shipped PixiJS game shell. App bootstrap, camera, unified touch+keyboard input, juice primitives, procedural audio, remote-entity interpolation.
What it is not: a game framework. No scene conventions, no ECS, no draw
abstractions. You draw with raw Pixi Graphics/Text: vector redraw per frame,
additive layers, bloom on top. The stage owns the plumbing every title otherwise
re-derives (and gets subtly wrong on phones).
npm i @siltrun/stage pixi.jsThe ceremony invariant
A consumer's entire per-game obligation is:
const stage = await createStage(mountEl); // 1 — the app
const cam = createCamera(stage); // 2 — one camera call
cam.fitRect({ x: 0, y: 0, w: 900, h: 900 }); // (or follow / fitAll)
const pad = dpad(mountEl, { onDir: d => room.events.send({ turn: d }) }); // 3 — one input primitiveAnything beyond that creeping into a consumer is a design failure in THIS package — report it, don't absorb it.
Quickstart (vanilla TS)
import { createStage, createCamera, createGameClock } from "@siltrun/stage";
import { vectorInput } from "@siltrun/stage/input";
import { createParticles } from "@siltrun/stage/juice";
import { Graphics } from "pixi.js";
const stage = await createStage(document.getElementById("game")!, { background: 0x0b0b0d });
const cam = createCamera(stage);
const clock = createGameClock();
const input = vectorInput(stage.app.canvas);
const ship = new Graphics();
stage.world.addChild(ship); // world-space: camera-driven
const fx = new Graphics();
fx.blendMode = "add"; // light stacks on light
stage.world.addChild(fx);
const particles = createParticles();
cam.follow(() => pos, { baseZoom: 0.6, speedZoom: 0.4, maxSpeed: 1500, lead: 0.12 });
stage.app.ticker.add((tk) => {
const dt = clock.tick(tk.deltaMS);
const v = input.read(); // touch drag ∪ WASD, one read
// …integrate your sim with v…
particles.step(dt, fx);
cam.update(dt);
});
stage.onRestore(() => redrawStaticLayers()); // mobile GPU context loss is normal
// stage.dispose() on teardown⚠️ Await
createStageinside an asyncmain()— never as a top-level await. In a Vite/Rollup production build, a top-levelawait createStage(...)deadlocks the page with no error: the entry chunk's evaluation blocks onapp.init, which dynamically imports Pixi's renderer chunk, which statically depends back on the entry chunk — a circular chunk wait. Dev mode works (unbundled modules), so the hang only appears in the built app.
React wrapper (deliberately not shipped as a hook)
function Game() {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let stage: StageHandle | undefined;
let cancelled = false;
createStage(hostRef.current!).then((s) => {
if (cancelled) { s.dispose(); return; }
stage = s;
// …build the game against s…
});
return () => { cancelled = true; stage?.dispose(); };
}, []);
return <div ref={hostRef} className="absolute inset-0" />;
}Keeping react out of this package's peer set is deliberate: vanilla TS clients consume
the stage too. If this wrapper turns out to be copy-pasted identically everywhere, a
@siltrun/stage/react subpath is a future call. Not built today.
Modules
| Import | Contents |
|---|---|
| @siltrun/stage | createStage (DPR-capped bootstrap, ResizeObserver-owned resize, safe-area probes, context-restore registry, perf overlay), createCamera (fitRect / follow / fitAll + shake), createGameClock (dt clamp + hit-stop), math guards (num, lerpColor, lerpAngle, approach, decay, rng, …) |
| @siltrun/stage/input | vectorInput (dragStick ∪ keyVector — the phone+desktop analog default), dragStick, keyVector, dpad (turn/hold: keys + swipe + hold-relative-to-center), tapBoard |
| @siltrun/stage/juice | createParticles (pooled, streak rendering), haptic, makeTilePattern (engraved hatch/stipple fills) |
| @siltrun/stage/bloom | createBloom — pulls the optional pixi-filters peer; flat-look games skip this subpath entirely |
| @siltrun/stage/audio | createAudioKit — blip/noiseHit/engine over a compressor bus; iOS silent-switch + unlock handled. The kit is the instrument; your game composes its own beats |
| @siltrun/stage/interp | createInterpolator — render-in-the-past + ghost-coast + NaN rejection at the seam. Consumes snapshots and never speaks wire, so it stays independent of the transport |
| @siltrun/stage/smoother | createCorrectionSmoother — eases the predicted SELF across reconcile corrections (nudge → decaying offset; snap on teleport-scale). Zero added latency for normal motion. Interp smooths OTHERS, the smoother smooths SELF — never cross them (interp on self re-adds the latency prediction removes). Pairs with @siltrun/client/predict |
Camera models
- fitRect — a god-view fixed board, or a single-screen floor. Re-fires on
stage.onResize. - follow — camera-follow, in two shapes:
{ posSmooth: 17 }for a pure ease, or{ speedZoom, lead }for a rigid follow that widens with speed. The defaults are tuned feel numbers taken from a shipped game. - fitAll — spectator big-screen framing.
shake(amp) composes onto any model. Hit-stop lives on the clock, not the camera.
HUD conventions (DOM-first — deliberately no component library)
Every Silt title builds chrome in DOM (crisper text, CSS-styleable, accessible), not
Pixi. The stage gives you stage.safeArea() for notch/home-bar insets; the rest is
convention:
- uppercase letterspaced microcopy for status lines; tabular-nums for numbers
- overlays absolutely positioned over the mount element (
position:relativeis ensured) - Pixi
Textonly for things living in world space (nameplates, gate numbers), and throttle world-text updates to ~10 Hz: a.textchange re-rasterizes the glyph texture, so setting it every frame is a per-frame raster you did not ask for
Art direction
The stage takes no aesthetic opinions. Bring your own palette.
The look the Silt sample titles share, if you want a starting point: monochrome
engraving on bone. Ink #0b0b0d, bone #eae7de, hatch and stipple fills via
makeTilePattern, warmth (amber, copper) reserved for things that carry meaning, and no
yellow. Your own art direction always wins over this.
Mobile acceptance (what a stage-based game gets by default)
- Fills the phone viewport correctly — pair with this meta block in
index.html:
(the stage sets<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" /> <style>html, body { overscroll-behavior: none; } </style>touch-action:noneon its canvas itself) - Steerable by touch — every input primitive listens to touch AND keyboard from birth.
- Holds frame rate on DPR-2/3 phones — DPR cap 2, MSAA policy, bloom budget defaults
(if a phone chokes, lower bloom
strength, never its resolution). - Survives backgrounding (
onRestore) and unlocks audio on first gesture — wirecreateAudioKit().unlockas any input primitive'sonGesture.
Peer dependencies
pixi.js >=8 <9 is a peerDependency, so your game owns its Pixi version.
pixi-filters is an optional peer used only by the ./bloom subpath; flat-look games
never install it. The create-siltrun templates pin pixi.js ^8.19.0, the version
these defaults were tuned against.
Docs
Full guides and API reference: https://silt.run/docs/
