Web Games
back to documentation

Web Games

Ship your own HTML, ThreeJS and JavaScript game on Portals with player identity, multiplayer, coins, avatars, and server logic.

Pose Tuner (dev tool)

The Pose Tuner is an in-game panel for tuning procedural animation: the number tables your pose code reads every frame, the live inputs that drive them, and — through a 3D gizmo — bone rotations, bone positions and the placement of a held weapon. You tune in the real renderer, on the real rig, and export exactly what changed as a paste-ready snippet. Your committed source stays the source of truth; the panel is never a data format.

It is a dev tool, not part of the game runtime:

  • Draft previews get it stamped at _portals/dev/pose-tuner.js, on the game's own origin, so the strict game CSP is satisfied.
  • Published bundles never include it. A game that lazy-imports it behind a dev flag works in preview and quietly no-ops in production.
  • Local development: download the same bytes from portals.to/portals-sdk/pose-tuner.js into your project's _portals/dev/ directory. Files under _portals/ are managed by Portals and never upload, so the local copy cannot drift into your bundle.
// Behind a flag only you use — absent in production, so the import rejects
// and the game plays on untouched.
if (new URLSearchParams(location.search).has('poseTuner')) {
  try {
    const { createPoseTuner } = await import('./_portals/dev/pose-tuner.js');
    tuner = createPoseTuner({ title: 'My game', storageKey: 'my-game-tuner' });
  } catch {
    /* published build — no dev tools */
  }
}

Why a panel instead of an animation editor

A procedural pose is a pure function of numbers (carry tables, grip offsets, recoil shares) plus inputs (pitch, kick, flags). No authored clip can be "the" gun-holding animation, because the hold has to survive things clips cannot know: camera pitch, strafe direction, recoil, scope state. So the tool binds the live objects your frame loop already reads — a slider mutates the table in place and the very next frame shows the result — and forces the inputs so the failure states are one click instead of a play session.

Wiring

Three touch points, each a no-op when the tuner is off:

// 1. Bind the live tables your pose code reads (nested objects and numeric
//    arrays are walked; per-key ranges optional).
tuner.table('HEAVY_CARRY', HEAVY_CARRY, { tuck: { min: -1, max: 1 } });

// 2. Name forced inputs after your pose function's argument keys, then merge
//    them over the real inputs each frame.
tuner.control('pitch', { min: -1.35, max: 1.35, step: 0.01 });
tuner.control('scoped', { type: 'toggle' });
aimWeapon(avatar, tuner.apply({ pose, lookYaw, pitch, kick, scoped }));

// 3. Route the frame delta through pause / single-step / slow-mo.
const dt = tuner.dt(rawDt);

// 4. Optional: the freecam — WASD flies, Q/E sink/rise, right-drag looks,
//    wheel scales speed, Shift ×3. Flies on its own clock, so it keeps moving
//    while the game is paused. `onToggle` is where the game stands its own
//    movement down (the controller still hears WASD otherwise); update() runs
//    LAST in the frame, after every game-side camera write, and wins by going
//    last. No-op while off.
const freecam = tuner.attachFreecam({
  THREE, camera, domElement: renderer.domElement,
  onToggle: (on) => controller.setEnabled(!on),
});
freecam.update(); // right before renderer.render(...)

Editing bones and weapons in 3D

attachBoneEditor adds direct manipulation: pick a stance (activation goes through your game's real code paths), then click a joint and drag a local-space gizmo. Edit mode grows a clickable sphere on every bone (green on registered targets, orange on the selection) so the pick targets are visible, and the skeleton overlay shows by default. Pass defaultOn: true to start with edit mode already enabled — note the canvas then belongs to the editor from the first frame, so give the game another way into pointer lock (an action button calling requestPointerLock()). A button switches the gizmo between rotation and position; holding Shift hides it so you can click straight through to the next joint; right-drag orbits and the wheel zooms via callbacks you supply.

tuner.attachBoneEditor({
  THREE, scene, camera, domElement: renderer.domElement,
  getBones: () => avatar.bones,
  poses: [
    { id: 'hipPose', label: 'hip', activate: () => holdHipStance() },
    { id: 'adsPose', label: 'ads', activate: () => setAds(true) },
  ],
  loadTransformControls: () =>
    import('three/addons/controls/TransformControls.js').then((m) => m.TransformControls),
});

three/addons/controls/TransformControls.js is part of the managed Three.js runtime, so the import resolves in hosted previews and in local projects that use the managed import map. Without it the editor falls back to sliders.

  • Bone rotations are stored per pose as Euler XYZ radians and exported as a boneAdjust map — exactly the shape avatar.animations.load takes at clip registration, and applied with the same math, so what you see while dragging is what the baked clip will look like.

  • Bone positions are local offsets over the clip, exported separately (boneAdjust cannot bake positions — apply them at runtime).

  • Non-bone targets — a held weapon's mount, a prop — register as targets: the gizmo attaches in translate or rotate mode and every drag calls your fold(object, poseId, mode) to convert the dragged transform back into whatever live table your game poses it from. If the target's object is replaced mid-session (a weapon swap builds a new mount), the editor re-resolves getObject() and re-attaches on its own.

    One trap: fold receives the panel's selected pose, but a table your game keys by its own live state must resolve that key itself and ignore the argument. A two-handed weapon is in its heavy stance whatever the pose row says — folding into the selected pose writes a row the game is not reading, so the drag snaps back next frame and looks dead while the wrong row quietly accumulates edits. On Guardian SDK 0.44.0+, gunMount(avatar) is the ready-made gun target (holder, rest position, rest rotation), and aimWeapon's nudge/tilt tables are where the folds land.

Run the editor's per-frame pass in the post-mixer slot. On Guardian SDK 0.42.0 and later, register it once and the ordering is the SDK's promise:

avatars.onAfterUpdate(() => tuner.boneEditor?.update());

On older pinned versions, call it in your frame loop directly after avatars.update(dt) and before your own bone writers (procedural aim, IK, ragdolls) — anything that writes bones before the mixer pass is silently overwritten.

The panel

Section What it does
Force state Sliders/toggles that override pose inputs; one-click corner-state snapshots (pitch extremes, strafe diagonals, scoped raises) and release all.
Time Pause, single-step, slow-motion — via tuner.dt().
Actions Buttons wired to your game's real paths (fire once, ADS on/off, cycle weapon).
Tables One collapsible section per bound table, per-row reset dots, persisted across reloads under storageKey.
Bones Pose buttons, bone/target picker, gizmo mode and space buttons (rotate/translate; drag in the bone's local axes or the world's — the stored edit is local either way, so boneAdjust exports do not care), rot/pos rows, boneAdjust export.
Inspect Skeleton overlay drawn through the mesh.
Camera The freecam toggle — a detached fly camera on its own clock, for judging a pose from angles gameplay never gives you (and for walking around a frame the Time section holds frozen).
Undo / redo ↶/↷ in the header, plus Ctrl/⌘ Z and Ctrl/⌘ ⇧Z. Covers everything that edits data — table sliders, bone edits, gizmo drags (one entry per drag), resets. Forced inputs are session state and stay out.
Copy changes / Reset all Export a TABLE.key = value; diff of everything that differs from the code's values; restore the code's values and clear persistence — bone edits included (they persist under their own storageKey:bones entry, and a reset that left them behind kept re-posing bones out of stale storage).

Export diffs are always measured against what the code held at bind time — persisted slider edits from a previous session still show as changed, so nothing tuned can silently fail to make it back into your source.

Caveats

  • Keystrokes inside the panel are kept from the game where possible, but a capture-phase listener registered before the tuner mounts (the Guardian controller's input is one) still sees them first. Pause time while typing in a number field, or use the sliders.
  • tuner.dt() pausing stops what you step with that delta; eases timed off wall-clock keep moving. Force their inputs instead (that is what a kick control is for).
  • Bones a later procedural pass rewrites (an IK'd arm, an aim-bent spine) show that pass, not your edit — tune those through their tables, or edit them under a stance the pass leaves alone.