Portals Avatars
The avatar is called the Portals avatar; default avatar describes its base body or fallback. Existing identifiers such as PortalsGuardians, GuardianAvatar, guardiansSdk and SDK download paths retain their spelling for compatibility with existing games.
See the changelog for what changed in each SDK version.
The Portals avatar SDK renders the same avatars players wear across Portals — body types, skin/hair/eye colour, hair styles, the wearables system, retargeted locomotion clips, facial animation, and a first/third-person character controller with the Portals feel.
It is a Three.js library, not a standalone renderer: you own the scene, camera and render loop, and the SDK adds avatars to it. For sign-in, saved progress, leaderboards and host control, see Portals SDK.
Portals stamps the SDK into every processed preview and published bundle. Include it exactly like the Portals SDK:
<script src="./_portals/sdk.js"></script>
<script src="./_portals/guardians-sdk.js"></script>
<script src="./game.js"></script>
That gives you the global PortalsGuardians. Wait for it the same way you wait for Portals:
const { PortalsAvatars } = await PortalsGuardians.ready();
Do not edit _portals/guardians-sdk.js; Portals manages that copy. Which SDK version it holds depends on how the game reaches Portals — see Versions for the rules, how to pin one, and how to develop against it locally.
Importing it instead
The SDK is an ES module underneath — that is how it shares your game's Three.js instance instead of loading a second copy. If your game is already module-based, skip the loader and import it by name:
<script type="module" src="./game.js"></script>
import { PortalsAvatars } from '@portals/avatars';
Portals writes the import map that resolves @portals/avatars, three and three/addons/ when it processes the project; you only need to keep it if you are hand-authoring index.html. Both routes load the same module, so mixing them in one project is fine.
Types for editor autocomplete: portals.to/portals-sdk/guardians.d.ts.
Versions
The avatar SDK is released in versions. Which version your game runs depends on how it reaches Portals:
- Editor and AI builder. The game is frozen on the version it was first stamped with: publishing again, or a newer platform release, never changes the avatar SDK under a working game.
- Zip import, GitHub build, and pushes from the editor plugin. Each import replaces the whole bundle, so the SDK is stamped at the current platform release every time. An unpinned game can change SDK version on any push.
To control the version on every path, pin it in a portals.json at your project root:
{ "guardiansSdk": "0.23.0" }
The pin wins everywhere — editor, AI builder, zip imports, GitHub builds and plugin pushes — so the version you developed against is the version that publishes. Pinning a version that was never released fails the publish with an error naming the current release. If you push or import a Portals avatar game, pin it.
The current release, and the Three.js version it is built against, are published at https://portals.to/portals-sdk/guardians/current.json as { "version": "…", "three": "…" }. Read it instead of guessing a number; the changelog lists what each version changed.
Local development
Every released version is downloadable, so you can develop outside Portals against the exact build your game will run:
https://portals.to/portals-sdk/guardians/<version>/guardians-sdk.js
https://portals.to/portals-sdk/guardians/<version>/guardians-sdk.module.js
Locally you own the import map (Portals rewrites it to same-origin managed paths when it processes the project). Map three to a CDN build matching the SDK's Three.js release, and @portals/avatars to the versioned module URL:
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.178.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.178.0/examples/jsm/",
"@portals/avatars": "https://portals.to/portals-sdk/guardians/0.23.0/guardians-sdk.module.js"
}
}
</script>
Then record the same version in portals.json. When you push the game to Portals, the known-CDN URLs are rewritten to the managed same-origin runtime and the pin keeps the SDK at the release you tested.
Load the current Portals player
Portals.player.get() reads the player's active public username and the playable look saved on /avatar. createAvatarFromPlayer() turns that result into the exact Portals avatar configuration and wearables—or the selected full-avatar asset—in one call:
await Portals.ready();
const { THREE, PortalsAvatars } = await PortalsGuardians.ready();
const avatars = new PortalsAvatars({ scene });
const player = await Portals.player.get();
nameLabel.textContent = player.username
? `@${player.username}`
: player.displayName || "Guest";
let avatar = await avatars.createAvatarFromPlayer(player);
if (!avatar) {
// Guests and hosts without a saved look get the game's ordinary fallback.
avatar = await avatars.createAvatar({ bodyType: "female" });
}
The result is null for a guest, so keep a playable default. Both the profile read and asset load can reject; catch them at startup and use that same fallback rather than blocking the game.
The first signed-in profile is cached for the hosted-game load. If the player changes their look through the SDK picker described below, the picker resolves with a refreshed profile immediately. If they change it in another tab, reopen or reload the game.
After a player signs in, read the profile again—the lightweight object returned by requestLogin() does not include the playable avatar:
await Portals.identity.requestLogin();
const signedInPlayer = await Portals.player.get();
const avatar = await avatars.createAvatarFromPlayer(signedInPlayer);
player.avatarUrl is the 2D public profile image. The 3D /avatar look is player.avatar; letting createAvatarFromPlayer() load it also routes creator-hosted wearables through Portals' managed same-origin item proxy. See the publishable avatar-game example.
Let the player change their global avatar
Call Portals.avatar.openPicker() from a click or tap. Portals opens its trusted avatar interface over the game, signs the player in when needed, and resolves only after the global /avatar look is saved. The result is the refreshed player profile, ready to render:
customizeButton.addEventListener("click", async () => {
const player = await Portals.avatar.openPicker();
if (avatar) avatars.removeAvatar(avatar);
avatar = await avatars.createAvatarFromPlayer(player);
controller = avatars.createController(avatar, {
camera,
domElement: renderer.domElement,
});
});
Choosing Done changes the player's avatar across Portals, not just inside the current game. Until then the picker keeps a local draft; Cancel discards it. Catch rejection so cancelation, an unavailable host, or a failed save does not interrupt play.
The editor preview intentionally rejects openPicker() because it does not open account-wide interfaces. Keep the preview playable on rejection and verify the interaction in a published game host.
The game does not receive inventory, ownership records, account credentials, or a method that accepts wearable IDs. The Portals-owned picker only offers eligible items from the signed-in player's existing Shop inventory, and the save endpoint validates ownership, acquisition source, item type, slots, and compatibility again. A game cannot equip an arbitrary, unapproved, unowned, or sold item by posting a message or calling the SDK.
player.avatar.wearables is only a sanitized, render-ready snapshot of what is currently equipped. It is not the player's inventory, proof of ownership, or a marketplace API. The Portals avatar SDK's wearables.equip() changes an avatar object inside the game; it does not change the player's global Portals look. Marketplace listing, buying, selling, and inventory browsing are not exposed to games.
Starter
game.js, loaded with a plain <script src="./game.js">:
const { THREE, PortalsAvatars } = await PortalsGuardians.ready();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 200);
const renderer = new THREE.WebGLRenderer({ antialias: true });
document.body.appendChild(renderer.domElement);
const avatars = new PortalsAvatars({ scene });
const avatar = await avatars.createAvatar({
bodyType: 'female',
skinColor: '#EAAB7F',
hairColor: '#131110',
});
const controller = avatars.createController(avatar, {
camera,
domElement: renderer.domElement,
});
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
avatars.update(clock.getDelta());
renderer.render(scene, camera);
});
avatars.update(dt) drives every avatar and controller — call it once per frame or nothing animates. It is also the mixer tick that rewrites every bone local from the clips, so anything that writes bones on top of them — procedural aiming, ragdoll blending, tooling like the Pose Tuner — must run after it or be silently overwritten. From 0.42.0, register such code with avatars.onAfterUpdate(cb) (returns an unsubscribe function) and the ordering is the SDK's promise; on older pinned versions, call it in your frame loop directly after avatars.update(dt).
Since 0.55.2, createAvatar prepares idle, walk, run and sprint before it resolves, so the first movement does not wait for a clip download. All walk and run directional clips are prepared too. Optional behaviours and face clips still load on demand; call avatars.preloadAnimations() to prepare those behind a loading screen. The default third-person camera frames the avatar on the left; shoulderOffset: 0 centres it, and a positive offset frames it on the right.
Three.js is shared, not bundled
The SDK does not ship its own copy of Three.js — it uses the same managed runtime, so the page has exactly one instance and objects the SDK creates work with your own scene graph, raycasters and materials.
Take THREE from the SDK rather than loading it yourself. From the classic script it comes out of ready(), as above. From a module you can import it directly, since the import map points at the same file:
import * as THREE from 'three';
Loading a second copy from a CDN will not work — the game CSP admits no external script hosts — and would break instance checks even if it did.
Assets load through Portals
Portals avatar models, wearables and animation clips live on the Portals CDN. Published games run under connect-src 'self' and cannot fetch from another host, so the SDK rewrites asset URLs onto managed same-origin paths that Portals serves for you (/_portals/cdn/…, /_portals/media/… and, for wearables stored elsewhere, /_portals/item/…). This is automatic — pass ordinary Portals URLs and they resolve.
Two consequences worth knowing:
Thumbnails need the same treatment.
img-srcis'self'too, so run a wearable'sthumbnailthroughresolveAssetUrlbefore putting it in an<img>:import { resolveAssetUrl } from '@portals/avatars'; img.src = resolveAssetUrl(def.thumbnail);Arbitrary hosts need an item id. A wearable whose GLB is hosted outside Portals can only be fetched through the item proxy (
/_portals/item/…), which addresses it by inventory item id and serves the item's current catalog model. Equip it as a catalog item (with itsid) rather than as a bare URL. A raw URL to an unmanaged host logs a warning and will be blocked by the browser.
Assets you ship inside your own bundle are unaffected — relative paths are already same-origin.
Appearance
avatar.setSkinColor('tan'); // preset id or any CSS colour
avatar.setHairColor('#584435');
avatar.setEyeColor('green');
avatar.getHairStyles(); // [{ id, name }]
avatar.setHairStyle('Spiky_Bun');
avatar.setFacialHair(null); // remove
avatar.configure({ … }) applies a whole GuardianConfig at once, and avatar.getConfig() reads it back — useful for persisting a look with the Portals SDK's save API.
Wearables
avatar.wearables.registerCatalog(myItems);
await avatar.wearables.equip('basic-tshirt'); // catalog id
await avatar.wearables.equip({ id: 'hat-1', name: 'Cap', slot: 'hat', url: '…' });
await avatar.wearables.unequip('hat'); // by slot or id
avatar.wearables.getEquipped();
Equipping replaces whatever occupies the item's slots, hides the body meshes the slot covers, and re-binds skinned wearables to the avatar skeleton. Multi-slot items (dresses, full-body suits) declare every slot they occupy via slots.
Right-hand items get a carry pose and a use action inferred from the item name — an axe swings, a pistol shoots — or set handItemType explicitly. Listen for the action with controller.onItemUse.
Animated wearables
Some back wearables — fish-tank backpacks, shoulder pets, rocket packs — carry an animation inside their GLB. From SDK 0.47.0, a back-category item marked animated plays the first clip embedded in its GLB, on loop, for as long as it is worn:
Items from a player's inventory need nothing. Portals marks them, and
createAvatarFromPlayerequips them animated.A wearable your game ships itself sets
animated: trueon its definition, and can passanimationsalongsideobjectwhen you load the GLB yourself:await avatar.wearables.equip({ id: 'jetpack', name: 'Jetpack', slot: 'back', url: './jetpack.glb', animated: true });
The animation runs from avatars.update(dt) and stops when the item comes off. It only moves nodes inside the wearable, so rig the moving part to its own small skeleton and attach it rigidly under a Portals avatar bone; it cannot pose the avatar. Games pinned below 0.47.0 show these items static.
Wearables that follow the standard
From SDK 0.49.0, a wearable that follows the Portals wearable standard moves as its file describes, with no game code:
- Clips play on cue. Each clip is bound to a trigger (
always,equip, a locomotion state such asjumporrun, oremote), and the highest-priority matching clip plays. - Extra bones stay the wearable's own. A cape chain or a tail hangs from the rig bone above it instead of collapsing onto the hips, so its clips move it.
- Material animation plays per wearer. Glow, colour and texture-scroll channels animate each player's own copy of the material.
The SDK reads the avatar's state from the body clip it plays. A game that animates the body itself reports it:
avatar.setMotionState('jump'); // while airborne
avatar.setMotionState('idle', { emote: true }); // during an emote the game plays itself
avatar.setMotionState(null); // back to reading the body clip
From SDK 0.50.0, capes, skirts, hair and tails swing on their spring chains and bend around the wearer's body. A game only chooses how much to spend on them:
const avatars = new PortalsAvatars({ scene, dynamicsBudget: 512, reducedMotion: player.prefersReducedMotion });
avatars.setWind({ x: 0.3, y: 0, z: 0 }); // a breeze; 1 pushes as hard as a spring's gravity power of 1 pulls
controller.respawn({ position }); // re-seats springs, so nothing stretches
avatar.resetDynamics(); // the same, after moving an avatar yourself
console.log(avatars.getDynamicsStats()); // joints simulated, and which avatars run full, reduced or off
From SDK 0.52.0, a spring with a center (VRM's centre space) moves with that node instead of lagging behind it, so a sheet or dress centred on the hips no longer streams out behind a running player; ghost-sheet.glb among the templates shows one.
From SDK 0.53.0, a wearable can hide the parts of the body it covers with PORTALS_wearable.covers. A costume, robe or pair of gloves then never shows the wearer through it, however the body moves inside it. avatar.wearables.getCoveredRegions() reports what is hidden. See Covers in the standard.
From SDK 0.54.0, a wearable can be cloth (PORTALS_wearable.cloth): a coarse proxy mesh the SDK simulates against the body, driving the item's own meshes. It runs for the avatars the game controls and those nearest them, within clothBudget particles (default 2048). Past that, the item hangs on its springs. getDynamicsStats() counts cloth particles and the avatars running it. See Cloth in the standard.
From SDK 0.55.0, a hat can trim the hair under it instead of hiding it all. With trimHair: true (the Portals trim_hair flag, which a shop item carries on its model URL), only the hair that would poke through the item goes: the hair it sits over and the hair that falls out below it, a ponytail or the sides of a crop, stay. The SDK works this out when the item goes on, once for each hair style and item, with no cost per frame, and again when the hair style changes. A Portals item that trims also carries no_hair for runtimes that can only hide the hair whole, and trimHair wins where both are set. The beanie.glb template is a snug knit beanie to try it with.
From SDK 0.55.1, every wearable category can select show, trim or hide in the Shop item editor, including hooded tops and robes. Explicit trimming also works for full-body outfits occupying the hat slot; unmarked head-covering costumes still hide all hair by default.
From SDK 0.51.0, particle emitters and ribbon trails run too, from the same triggers, after springs, and under the same quality and reduced-motion settings; getDynamicsStats() also counts live particles and trail points. A host that loads wearables itself passes effectTextures: wearableEffectTexturesOf(gltf) and springBones: wearableSpringBonesOf(gltf) with the descriptor.
Springs run last in avatars.update(dt), after every onAfterUpdate bone writer, so they follow ragdolls and aim poses. An avatar you update yourself runs them inside update(dt); call avatar.updateDynamics(dt) after your own bone writes instead if you pose bones later.
createAvatarFromPlayer() loads a wearable's moving version when the saved look has one; older SDKs load its still version, which is always correct. If your game loads wearable GLBs itself, register the wearable loader plugins and pass the file's descriptor:
import { registerWearableLoaderPlugins, wearableDescriptorOf } from '@portals/avatars';
const loader = registerWearableLoaderPlugins(new GLTFLoader());
const gltf = await loader.loadAsync(url);
avatar.wearables.equipLoaded({ def, scene: gltf.scene, animations: gltf.animations, descriptor: wearableDescriptorOf(gltf) });
Animation
The Portals avatar models ship without clips; clips load from separate GLBs and are retargeted onto the Portals avatar rig automatically, including foreign Mixamo-rigged clips.
await avatar.animations.load([
{ name: 'idle', url: './anims/locomotion.glb', clip: 'idle', loop: true },
{ name: 'wave', url: './anims/wave.glb', loop: false },
]);
avatar.animations.play('wave', { fade: 0.2 });
The character controller drives locomotion states itself. Return true from controller.onBeforeAnimationUpdate to take over for a frame.
Faces are a separate layer:
avatar.face.setEmotion('smile');
avatar.face.setTalking(true);
avatar.face.setAutoBlink(true);
Controller
createController returns a character controller with WASD movement, sprint, jump, crouch and a first/third-person camera rig. Movement values are clamped to the ranges in MOVEMENT_LIMITS.
controller.configureMovement({ walkSpeed: 2, runSpeed: 4, jumpHeight: 1.2 });
controller.toggleCameraMode();
Ground and collision are yours to supply — the defaults are a flat floor at y = 0 and free movement. The quickest way to a level that behaves is SceneCollider, which reads collision off the meshes you register:
const collider = new SceneCollider();
collider.add(ground).add(levelGeometry); // level meshes only, never avatars
avatars.createController(avatar, {
camera,
domElement: renderer.domElement,
...collider.controllerOptions(),
});
It casts against real triangles, so ramps, cylinders and imported geometry work as they look. Call collider.refresh() after adding or removing meshes under a registered root, and keep the registered set to level geometry — every ray is tested against all of it.
If you already have physics, pass the four hooks yourself instead:
avatars.createController(avatar, {
camera,
domElement: renderer.domElement,
getGroundHeight: (x, z, y) => terrain.heightAt(x, z),
resolveMovement: (current, proposed, radius) => world.slide(current, proposed, radius),
raycast: (origin, direction, maxDistance) => world.raycast(origin, direction, maxDistance),
checkClearance: (point, radius, height) => world.capsuleFree(point, radius, height),
});
raycast is what enables ledge grabbing: two rows of rays, one at each shoulder, sweep down across the character's reach, and a surface within 30° of horizontal is a ledge it pulls itself onto. The line between the two rows' hits is the edge it squares up to, so curved and chamfered edges work too.
Two rules keep it deliberate. Both shoulders have to find the ledge, so brushing past a wall never triggers a grab — the cost is that a ledge narrower than the character's shoulders cannot be grabbed at all. And the probe only runs while airborne: the player always jumps (or falls) into a climb, never walks into one. It goes live on the first airborne frame, so pressing jump right at a ledge catches it immediately rather than at the apex. Walls with no reachable top are never climbable, and without a raycast nothing is.
Emotes
From SDK 0.57.0 every player has an emote wheel, and games need no code to offer it. createAvatarFromPlayer() loads the player's wheel into avatar.emotes; a guest, or an avatar you build yourself, gets the starter set every player has (wave, clap, shrug, dance, celebrate).
The character controller draws the default wheel. B opens it, a touch button opens it on phones, the slot numbers pick while it is open, and a pick plays on the player's avatar. Moving, jumping, crouching, using an item or leaving the ground cancels an emote. In first person the camera pulls back to third person for the emote and returns afterwards.
const controller = avatars.createController(avatar, { camera, domElement: renderer.domElement });
controller.setEmotesEnabled(false); // off during a round…
controller.setEmotesEnabled(true); // …on again in your lobby
Pass emotes: false to createController for a game with no emotes at all. A game with its own emote UI keeps the rules and drops the wheel's key:
const controller = avatars.createController(avatar, { camera, domElement, emotes: { key: null } });
myButton.onclick = () => controller.playEmote(0); // by wheel slot, or by key
avatar.emotes.list(); // the wheel: one entry (or null) per slot
The wheel opens over the middle of the game's view. Where the avatars are only part of the page, pass emotes: { anchor: element }: it opens centred over that element, moving only as far as keeps it wholly in view.
avatar.emotes plays on any avatar, including one the controller does not own — a remote player, or an NPC:
await remoteAvatar.emotes.play(emoteDef); // an entry from that player's wheel
await remoteAvatar.emotes.play(emoteDef, { elapsed: 2 }); // one that started 2 s ago
remoteAvatar.emotes.stop();
remoteAvatar.emotes.on('end', (playback, reason) => { /* 'finished' | 'stopped' | 'replaced' */ });
Emotes in multiplayer
From SDK 0.58.0 one call keeps every player's emotes in step over Portals.net:
await Portals.net.join();
const sync = syncEmotes({
net: Portals.net,
local: avatar, // the player's own avatar
avatarFor: (id) => remoteAvatars.get(id), // your avatar for another player
});
// on leave: sync.dispose(); after rebuilding the player's avatar: sync.setLocal(newAvatar)
Each player's emotes play on their avatar for everyone else, and a looping one is announced again every few seconds so a player who joins mid-dance sees it. A player can only show emotes Portals vouched for when they joined — player.emotes on the roster — so a peer's message cannot make anyone appear to own an emote they do not have. Messages it sends carry $portals: "portals:emote"; your own message handler can ignore them.
An emote plays as a full-body layer over whatever the avatar is doing, so your own clips for a remote player do not have to stop for it to show. One that declares feet: "planted" (every starter emote that moves the legs does) keeps each foot where it stood when it began, whatever idle it plays over. A file plays alike on either body: its keys turn each body's own joints the same way, and where its hands meet on the rig it was authored on, as in a clap, they meet on both. A wearable bound to the emote trigger of the wearable standard plays along. Emote files follow the emote standard; one that does not is skipped.
Props and sound
From SDK 0.59.0 an emote can carry a prop — a flag, a microphone, a guitar — and a sound, and both play with no game code. The prop rides on its bone only while the emote plays. The sound starts with the emote's main clip: it loops with a looping emote and ends when the loop does, and a one-shot's plays through.
Every emote sound plays through avatars.audio. The SDK measures each sound on the player's device and plays it at the same loudness (-16 LUFS), so no file can be louder than another. Other players' sounds come from where they stand, at half the volume of the player's own, and only the nearest three are heard at once. The player's Portals setting (everyone's sounds, only their own, or none) and Portals voice chat, which ducks emote sounds while someone else talks, are followed for you. Your game sets the level:
avatars.audio.setVolume(settings.sfxVolume); // 0–1: your game's own volume control
avatars.audio.setRemoteVolume(0.3); // others' emotes relative to the player's own
avatars.audio.setMuted(paused); // silent while paused; each sound resumes where its emote is
Emote sounds go silent on their own while the page is hidden, and come back where their emotes are when it shows again.
A game with its own mixer passes its context and master bus, so its mute and volume apply to emote sounds as well:
const avatars = new PortalsAvatars({ scene, audio: { context: audioContext, destination: masterGain } });
createController() hears from its camera. A game that renders from another camera, or has no controller, sets the listener itself with avatars.audio.setListener(camera).
Props share one budget across every avatar: the player's own first, then everyone else by distance. Past it, an avatar's emote plays without its prop. The default is 80,000 triangles in 64 draw calls, eight props at the standard's limit, or thirty-two small ones; set emotePropBudget to change it. avatars.getEmoteStats() shows what props and sounds cost in a frame.
Many avatars
Every part of an avatar that follows its skeleton — the body, its wearables and anything hung on its bones with attachToBone — is drawn as one merged mesh, so a crowd costs a draw per avatar instead of eleven, in every pass including shadows. Nothing to call: it is on by default, and a part you hide, unequip, re-material or move on its bone draws on its own again that same frame and merges back once the avatar settles. If your game changes avatar parts after avatars.update(dt), call avatars.syncMergedDraws() before rendering. Raycasts, getParts() and your own layers see every part exactly as before: a merged part is only culled from the cameras, never removed. Set avatar.mergeDraws = false to keep one avatar's parts separate, or pass mergeDraws: false to PortalsAvatars to turn it off. avatars.getMergeStats() shows what merged.
Avatars out of view are not drawn, and avatars far from the camera draw and animate only what can be seen from there, with nothing to call either: once you create a controller its camera decides, the player's own avatar stays in full, past 8 m an avatar leaves out its mouth interior and poses every other frame, past 24 m its eyes too and poses every fourth, and one out of view beyond 12 m poses every eighth frame. Every avatar keeps its shadow, and an avatar whose detail or animationStride you set yourself is left to you. To choose the levels yourself call avatars.setDetailPolicy({ viewer: camera, ... }) (add shadows: 'keep' to keep shadows, offscreen: 'throttle' or 'pause' for avatars out of view); to draw every avatar in full pass detailPolicy: null to PortalsAvatars.
Cleanup
avatars.dispose() tears down every avatar and controller and releases their GPU resources. Call it when leaving a scene; individual avatars can go with avatars.removeAvatar(avatar).
