@series-inc/universal-avatar-renderer
v1.0.0
Published
Phaser Scene Plugin for rendering static avatars with visual variant system
Downloads
49
Readme
@series-inc/universal-avatar-renderer
Phaser Scene Plugin for rendering layered avatar images with visual variant system
A production-ready, type-safe Phaser plugin for rendering customizable characters using a Container-based architecture with Items Catalog system.
📋 Table of Contents
- Features
- Package Information
- Installation
- Quick Start
- Core Concepts
- JSON Format Reference
- API Reference
- Type Definitions
- Advanced Usage
- Performance Considerations
- Testing
- Architecture
- Examples
- Troubleshooting
✨ Features
- 🎨 Layered Avatar Rendering - Compose avatars from multiple image layers
- 🔄 Hot-Swapping - Change visual variants at runtime without re-rendering
- 📦 Items Catalog System - Reusable equipment items across characters
- 🎯 Type-Safe - Full TypeScript support with comprehensive type definitions
- ⚡ Optimized Performance - Smart caching and selective preloading
- 🧪 Well-Tested - 191 tests with ~95% coverage
- 🔌 Phaser Plugin - Seamless integration with Phaser 3 lifecycle
- 📊 State Management - Built-in visual state tracking with events
- 🔧 Extensible Architecture - Pluggable resource loaders for multiple formats
- 📐 Local Transform Support - Per-layer positioning, scaling, and effects for memory optimization
📦 Package Information
Bundle & Format
| Property | Value | |----------|-------| | Bundle Size | ~50KB minified (~15KB gzipped) | | Module Format | ESM (ECMAScript Modules) | | Tree-shakeable | ✅ Yes | | TypeScript | ✅ Full support with type definitions | | Side Effects | None (pure functions + Phaser plugin) |
Browser Compatibility
| Feature | Requirement | |---------|-------------| | JavaScript | ES2020+ | | WebGL | Required (Phaser 3 requirement) | | Browsers | All modern browsers (Chrome, Firefox, Safari, Edge) | | Mobile | iOS Safari 12+, Chrome Android 80+ | | Node.js | >=20.0.0 (for build tools) |
Phaser Version Compatibility
| Phaser Version | UAR Support | |----------------|-------------| | 3.60.0 - 3.70.0 | ✅ Fully tested | | 3.80.0+ | ✅ Compatible | | < 3.60.0 | ⚠️ Not tested (may work) |
Dependencies
Runtime Dependencies: None (Phaser is a peer dependency)
Peer Dependencies:
phaser: ^3.60.0- Required
Dev Dependencies: (Only for package development)
- TypeScript, ESLint, Vitest, tsdown
📦 Installation
NPM/Yarn
npm install @series-inc/universal-avatar-renderer
# or
yarn add @series-inc/universal-avatar-rendererPeer Dependencies
{
"phaser": "^3.60.0"
}🚀 Quick Start
1. Register the Plugin
import Phaser from 'phaser';
import { AvatarRenderer } from '@series-inc/universal-avatar-renderer';
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 800,
height: 600,
scene: {
preload: preload,
create: create,
},
plugins: {
scene: [
{
key: 'AvatarRenderer',
plugin: AvatarRenderer,
mapping: 'avatarRenderer',
},
],
},
};
const game = new Phaser.Game(config);2. Define Your Data
import type {
LayerSchemaRegistry,
CharacterRegistry,
ItemCatalog,
} from '@series-inc/universal-avatar-renderer';
// Define layer schema (body type)
const layerSchemas: LayerSchemaRegistry = {
humanoid: {
id: 'humanoid',
displayName: 'Humanoid',
layers: ['body', 'face', 'hair', 'outfit'],
variantCategories: {
expression: {
displayName: 'Expression',
values: ['default', 'happy', 'sad', 'angry'],
defaultValue: 'default',
},
},
},
};
// Define items catalog
const itemCatalog: ItemCatalog = {
items: {
item_body_default: {
id: 'item_body_default',
name: 'Default Body',
compatibleSchemas: ['humanoid'],
equipmentSlotId: 'body_slot',
visuals: {
body: {
default: { resource: { type: 'static', id: 'body_d', uri: '/assets/body.png' } },
},
},
},
item_face_default: {
id: 'item_face_default',
name: 'Default Face',
compatibleSchemas: ['humanoid'],
equipmentSlotId: 'face_slot',
visuals: {
face: {
default: { resource: { type: 'static', id: 'face_d', uri: '/assets/face_default.png' } },
happy: { resource: { type: 'static', id: 'face_h', uri: '/assets/face_happy.png' } },
sad: { resource: { type: 'static', id: 'face_s', uri: '/assets/face_sad.png' } },
},
},
},
},
};
// Define characters
const characters: CharacterRegistry = {
player: {
id: 'player',
displayName: 'Player Character',
layerSchemaId: 'humanoid',
equipmentSlots: {
body_slot: 'item_body_default',
face_slot: 'item_face_default',
},
},
};3. Use in Your Scene
function preload(this: Phaser.Scene) {
// Initialize the plugin
this.avatarRenderer.initialize(layerSchemas, characters, itemCatalog);
// Preload character assets
this.avatarRenderer.preload(['player']);
}
function create(this: Phaser.Scene) {
// Render character
const container = this.avatarRenderer.render('player', {
x: 400,
y: 600,
scale: 1.5,
originX: 0.5,
originY: 1.0,
});
// Update visual variant (hot-swap)
this.avatarRenderer.updateVisualVariant('player', 'expression', 'happy');
}🧩 Core Concepts
Architecture Overview
┌─────────────────────────────────────────────────────────┐
│ AvatarRenderer │
│ (Scene Plugin) │
└────────────┬────────────────────────────────────────────┘
│
┌────────┴────────┐
│ │
┌───▼────┐ ┌────▼─────┐ ┌──────────┐
│ Layer │ │ Items │ │ State │
│ Schema │──────│ Catalog │──────│ Manager │
└────────┘ └──────────┘ └──────────┘
│ │ │
│ │ │
┌────▼───────────────▼───────────────────▼────┐
│ Character Registry │
│ (References items by ID from catalog) │
└──────────────────────────────────────────────┘Key Components
- Layer Schema - Defines rendering order and variant categories for a body type
- Items Catalog - Reusable equipment items with visual variants
- Character Registry - Character definitions that reference items by ID
- State Manager - Tracks current visual state (expression, wetness, etc.)
- Container Manager - Manages Phaser containers for each rendered character
📄 JSON Format Reference
While the Quick Start shows TypeScript definitions, in real projects you'll load data from JSON files. Here are complete, production-ready examples.
Layer Schema JSON (layer-schemas.json)
{
"humanoid_v1": {
"id": "humanoid_v1",
"displayName": "Humanoid Base",
"avatarCanvasSize": {
"width": 1062,
"height": 1674
},
"layers": ["body", "face", "hair_back", "outfit", "hair_front", "accessories"],
"variantCategories": {
"expression": {
"displayName": "Expression",
"description": "Character's emotional state",
"values": ["default", "happy", "sad", "angry", "surprised"],
"defaultValue": "default",
"order": 1
},
"wetness": {
"displayName": "Wetness",
"description": "Water/rain effect",
"values": ["default", "wet"],
"defaultValue": "default",
"order": 2
}
}
},
"fem_gen2a": {
"id": "fem_gen2a",
"displayName": "Female Body Type A",
"layers": ["body", "face", "hair_b", "clothing", "hair_f", "props_b", "props_f"],
"variantCategories": {
"expression": {
"displayName": "Expression",
"values": ["NEUTRAL", "HAPPY", "SAD", "ANGRY", "SURPRISED"],
"defaultValue": "NEUTRAL"
}
}
}
}Item Catalog JSON (item-catalog.json)
{
"items": {
"item_body_base": {
"id": "item_body_base",
"name": "Base Body",
"compatibleSchemas": ["humanoid_v1"],
"equipmentSlotId": "body_slot",
"visuals": {
"body": {
"default": {
"resource": {
"type": "static",
"id": "body_default",
"uri": "/assets/avatars/body/base_default.png"
}
}
}
}
},
"item_face_expressive": {
"id": "item_face_expressive",
"name": "Expressive Face",
"compatibleSchemas": ["humanoid_v1"],
"equipmentSlotId": "face_slot",
"visuals": {
"face": {
"default": {
"resource": {
"type": "static",
"id": "face_default",
"uri": "/assets/avatars/face/expressive_default.png"
}
},
"happy": {
"resource": {
"type": "static",
"id": "face_happy",
"uri": "/assets/avatars/face/expressive_happy.png"
}
},
"sad": {
"resource": {
"type": "static",
"id": "face_sad",
"uri": "/assets/avatars/face/expressive_sad.png"
}
},
"angry": {
"resource": {
"type": "static",
"id": "face_angry",
"uri": "/assets/avatars/face/expressive_angry.png"
}
}
}
}
},
"item_hair_long_brown": {
"id": "item_hair_long_brown",
"name": "Long Brown Hair",
"compatibleSchemas": ["humanoid_v1", "fem_gen2a"],
"equipmentSlotId": "hair_slot",
"visuals": {
"hair_back": {
"default": {
"resource": {
"type": "static",
"id": "hair_back_brown",
"uri": "/assets/avatars/hair/long_brown_back.png"
}
}
},
"hair_front": {
"default": {
"resource": {
"type": "static",
"id": "hair_front_brown",
"uri": "/assets/avatars/hair/long_brown_front.png"
}
}
}
},
"metadata": {
"color": "brown",
"length": "long",
"price": 50,
"unlockLevel": 1,
"tags": ["casual", "natural"]
}
},
"item_premium_dress": {
"id": "item_premium_dress",
"name": "Evening Gown",
"compatibleSchemas": ["fem_gen2a"],
"equipmentSlotId": "outfit_slot",
"visuals": {
"clothing": {
"default": {
"resource": {
"type": "static",
"id": "dress_evening_default",
"uri": "/assets/avatars/clothing/evening_gown.png"
}
}
}
},
"metadata": {
"category": "formal",
"isPremium": true,
"price": 500,
"currency": "gems",
"rarity": "legendary",
"season": "all",
"tags": ["formal", "elegant", "premium"]
}
}
}
}LocalTransform Support (Optional)
Resources can include optional localTransform properties for memory optimization and special effects:
{
"items": {
"item_hair_cropped": {
"id": "item_hair_cropped",
"name": "Cropped Hair (Memory Optimized)",
"compatibleSchemas": ["humanoid_v1"],
"equipmentSlotId": "hair_slot",
"visuals": {
"hair_f": {
"default": {
"resource": {
"type": "static",
"id": "hair_cropped_default",
"uri": "/assets/avatars/hair/cropped_400x600.png",
"localTransform": {
"offset": { "x": 0, "y": -500 },
"scale": 1.2,
"alpha": 0.95
}
}
}
}
}
}
}
}Benefits:
- 50-70% memory savings - Use cropped assets instead of full canvas (e.g., 400×600px vs 1062×1674px)
- Fine-tuning - Adjust positioning without re-exporting assets
- Special effects - Blend modes, alpha, rotation per layer
LocalTransform Properties:
offset?: { x: number, y: number }- Position relative to container originscale?: number- Scale multiplier (combines withRenderOptions.scale)rotation?: number- Rotation in radiansalpha?: number- Opacity (0-1)blendMode?: number- Phaser blend mode for special effects
When to use:
- ✅ Cropped assets for memory optimization
- ✅ Layer-specific adjustments during development
- ✅ Glow effects, overlays with blend modes
- ❌ Not needed for full-canvas assets (all defaults to 0)
Working with Avatar Canvas Size:
If your layer schema defines an avatarCanvasSize, the renderer will automatically set the dimensions of the Phaser Container. You can use it as reference for offsets:
// layer-schemas.json
{
"nanny_affair_humanoid": {
"avatarCanvasSize": {
"width": 1062,
"height": 1674
}
}
}Calculating offset for cropped assets:
When you crop an asset from the reference canvas:
- Reference canvas: 1062×1674px
- Cropped to: 406×675px (using Photoshop's "Trim Transparent Pixels")
- Calculate offset:
// If hair was at top of canvas:
// - Top trimmed: ~127px
// - Bottom trimmed: ~872px
// - Left/Right centered: ~328px each side
offset = {
x: 52, // Fine-tune horizontal position
y: -873 // Negative of bottom trim amount
}Why negative Y?
- Container origin is at bottom (originY: 1.0)
- Offset.y moves the anchor point UP (negative) or DOWN (positive)
- For cropped assets, you typically move UP to compensate for removed bottom pixels
Character Registry JSON (character-registry.json)
{
"player": {
"id": "player",
"displayName": "Player Character",
"layerSchemaId": "humanoid_v1",
"equipmentSlots": {
"body_slot": "item_body_base",
"face_slot": "item_face_expressive",
"hair_slot": "item_hair_long_brown"
},
"closetOfItems": [
"item_premium_dress",
"item_hair_blonde_short",
"item_hair_red_curly"
]
},
"npc_shopkeeper": {
"id": "npc_shopkeeper",
"displayName": "Shopkeeper",
"layerSchemaId": "humanoid_v1",
"equipmentSlots": {
"body_slot": "item_body_base",
"face_slot": "item_face_friendly",
"hair_slot": "item_hair_gray_short",
"outfit_slot": "item_apron_green"
}
}
}File Organization Example
your-game/
├── public/
│ └── assets/
│ └── avatars/
│ ├── layer-schemas.json # Layer definitions
│ ├── item-catalog.json # All items
│ ├── character-registry.json # All characters
│ └── images/
│ ├── body/
│ │ ├── base_default.png
│ │ └── ...
│ ├── face/
│ │ ├── expressive_default.png
│ │ ├── expressive_happy.png
│ │ └── ...
│ ├── hair/
│ │ ├── long_brown_back.png
│ │ ├── long_brown_front.png
│ │ └── ...
│ └── clothing/
│ └── ...Loading in Phaser
import { loadUARRegistries } from '@series-inc/universal-avatar-renderer';
class GameScene extends Phaser.Scene {
async preload() {
// Load all JSON registries from base path
const registries = await loadUARRegistries('/assets/avatars');
// Initialize UAR
this.avatarRenderer.initialize(
registries.layerSchemas,
registries.characterRegistry,
registries.itemCatalog
);
// Preload character assets
this.avatarRenderer.preload(['player']);
}
}Note: The loadUARRegistries() function expects:
{basePath}/layer-schemas.json{basePath}/character-registry.json{basePath}/item-catalog.json
📚 API Reference
Main Plugin: AvatarRenderer
Initialization
initialize(layerSchemas, characters, itemCatalog)
Initialize the plugin with registries.
initialize(
layerSchemaRegistry: LayerSchemaRegistry,
characterRegistry: CharacterRegistry,
itemCatalog: ItemCatalog
): voidParameters:
layerSchemaRegistry- Layer schemas defining body typescharacterRegistry- Character definitionsitemCatalog- Items catalog with equipment
Example:
this.avatarRenderer.initialize(layerSchemas, characters, itemCatalog);Asset Loading
preload(characterIds?, options?)
Preload character assets before rendering.
preload(
characterIds?: string[],
options?: PreloadOptions
): voidParameters:
characterIds- Array of character IDs to preload (optional, defaults to all)options- Preload options for selective variant loading
Examples:
// Preload all characters
this.avatarRenderer.preload();
// Preload specific characters
this.avatarRenderer.preload(['player', 'npc1']);
// Selective variant preloading
this.avatarRenderer.preload(['player'], {
preloadVariants: {
expression: ['default', 'happy', 'sad'],
},
});Rendering
render(characterId, options)
Render a character at the specified position.
render(
characterId: string,
options: RenderOptions
): Phaser.GameObjects.ContainerParameters:
characterId- Character ID to renderoptions- Render options (position, scale, origin, depth)
Returns: Phaser Container with all character layers
Example:
const container = this.avatarRenderer.render('player', {
x: 400,
y: 600,
scale: 1.5,
originX: 0.5, // 0-1 (0.5 = center)
originY: 1.0, // 0-1 (1.0 = bottom)
depth: 10, // Optional z-index
});Visual Variant Updates
updateVisualVariant(characterId, categoryId, value)
Update a visual variant (hot-swap texture).
updateVisualVariant(
characterId: string,
variantCategoryId: VariantCategoryId,
variantValue: VisualVariantValue
): voidExample:
// Change expression
this.avatarRenderer.updateVisualVariant('player', 'expression', 'happy');
// Change wetness
this.avatarRenderer.updateVisualVariant('player', 'wetness', 'wet');getVisualState(characterId)
Get current visual state of a character.
getVisualState(
characterId: string
): Record<VariantCategoryId, VisualVariantValue> | nullExample:
const state = this.avatarRenderer.getVisualState('player');
console.log(state?.expression); // 'happy'resetVisualState(characterId)
Reset character to default visual state.
resetVisualState(characterId: string): voidEquipment Management
equipItem(characterId, slotId, itemId)
Equip a new item at runtime (hot-swap equipment).
equipItem(
characterId: string,
equipmentSlotId: EquipmentSlotId,
itemId: ItemId
): voidExample:
// Change character's hair
this.avatarRenderer.equipItem('player', 'hair_slot', 'item_hair_blonde');Container Management
getContainer(characterId)
Get the Phaser Container for a character.
getContainer(characterId: string): Phaser.GameObjects.Container | nullisRendered(characterId)
Check if a character is currently rendered.
isRendered(characterId: string): booleangetRenderedCharacterIds()
Get all currently rendered character IDs.
getRenderedCharacterIds(): string[]destroyCharacter(characterId, keepState?)
Destroy a specific character.
destroyCharacter(characterId: string, keepState?: boolean): voidParameters:
keepState- If true, preserves visual state for re-rendering (default: false)
destroyAllCharacters(keepStates?)
Destroy all rendered characters.
destroyAllCharacters(keepStates?: boolean): voidLifecycle Hooks
boot()
Called automatically by Phaser when scene starts. Registers lifecycle event listeners.
shutdown()
Called automatically by Phaser when scene shuts down (can be restarted).
destroy()
Called automatically by Phaser when scene is permanently destroyed. Cleans up all resources.
📐 Type Definitions
Core Types
// Basic identifiers
type LayerId = string;
type VariantCategoryId = string;
type VisualVariantValue = string;
type LayerSchemaId = string;
type ItemId = string;
type EquipmentSlotId = string;
// Image resource type discriminator
enum ImageResourceType {
Static = 'static',
Atlas = 'atlas',
Spritesheet = 'spritesheet',
ImageSequence = 'imageSequence',
Skeletal = 'skeletal',
}
// Local transform configuration (optional)
type LocalTransform = {
offset?: { x: number; y: number }; // Position relative to container
scale?: number; // Scale multiplier
rotation?: number; // Rotation in radians
alpha?: number; // Opacity (0-1)
blendMode?: Phaser.BlendModes | number; // Blend mode
origin?: { x: number; y: number }; // Custom pivot point (0-1 range)
}
// Base image resource
type BaseImageResource = {
id: string;
type: ImageResourceType;
localTransform?: LocalTransform;
}
// Static image resource (PNG/JPG)
type StaticImageResource = BaseImageResource & {
type: ImageResourceType.Static;
uri: string;
}
// Image resource (discriminated union)
type ImageResource = StaticImageResource;
// Equipment slot definition
type EquipmentSlotDefinition = {
[key: VisualVariantValue]: ImageResource;
}Layer Schema
type LayerSchemaDefinition = {
id: LayerSchemaId;
displayName: string;
layers: LayerId[]; // Render order (index 0 = bottom)
variantCategories: Record<VariantCategoryId, VariantCategoryDefinition>;
avatarCanvasSize?: { // Optional reference canvas size
width: number;
height: number;
};
}
type VariantCategoryDefinition = {
displayName: string;
description?: string;
values: VisualVariantValue[];
defaultValue: VisualVariantValue;
order?: number;
}
type LayerSchemaRegistry = Record<LayerSchemaId, LayerSchemaDefinition>;Items Catalog
type ItemData = {
id: ItemId;
name: string;
compatibleSchemas: LayerSchemaId[];
equipmentSlotId: EquipmentSlotId;
visuals: Record<LayerId, Record<VisualVariantValue, { resource: ImageResource }>>;
metadata?: ItemMetadata;
}
type ItemCatalog = {
items: Record<ItemId, ItemData>;
}Character Definition
type CharacterDefinition = {
id: string;
displayName: string;
layerSchemaId: LayerSchemaId;
equipmentSlots: Record<EquipmentSlotId, ItemId>; // Slot → Item ID
closetOfItems?: ItemId[]; // Optional items for hot-swapping
}
type CharacterRegistry = Record<string, CharacterDefinition>;Render Options
type RenderOptions = {
x: number;
y: number;
scale?: number;
originX?: number; // 0-1 (default: 0.5)
originY?: number; // 0-1 (default: 1.0)
depth?: number; // Optional z-index
debug?: boolean; // Show orange bounds (default: false)
}
type PreloadOptions = {
preloadVariants?: Record<VariantCategoryId, VisualVariantValue[]>;
}🎯 Advanced Usage
Loading Registries from JSON
import { loadUARRegistries } from '@series-inc/universal-avatar-renderer';
async function preload(this: Phaser.Scene) {
// Load all registries from JSON files
const registries = await loadUARRegistries('/assets/uar');
this.avatarRenderer.initialize(
registries.layerSchemas,
registries.characterRegistry,
registries.itemCatalog
);
this.avatarRenderer.preload();
}Debug Visualization
Enable visual debugging to see the logical bounds of your avatar:
// Render with debug bounds visible
const container = this.avatarRenderer.render('player', {
x: 400,
y: 600,
debug: true // Shows orange rectangle around avatar
});What you'll see:
- Orange rectangle representing the
avatarCanvasSizefrom the schema - Helps verify that cropped assets with
localTransformare positioned correctly - Disappears automatically when the character is destroyed
Use cases:
- ✅ Validating
localTransform.offsetvalues - ✅ Verifying that
avatarCanvasSizematches your character dimensions - ✅ Debugging hit area issues
- ✅ Fine-tuning cropped asset positioning
Custom Origin (Pivot Point) per Layer
Override the pivot point for individual layers. Useful for rotating accessories:
{
"resource": {
"type": "static",
"id": "hat_item",
"uri": "/assets/hat.png",
"localTransform": {
"offset": { "x": 0, "y": -800 },
"rotation": 0.1,
"origin": { "x": 0.5, "y": 0.8 } // Rotate around bottom-center of hat
}
}
}Default behavior:
- If
originis NOT specified, uses globalRenderOptions.originX/originY - Default global origin:
(0.5, 1.0)(center-bottom, feet of character)
With custom origin:
- The layer rotates/scales around its own pivot point
- Perfect for: swinging earrings, tilting hats, spinning accessories
Example use case:
// Hat that tilts when character moves
"localTransform": {
"rotation": Math.sin(time) * 0.1, // Pendulum motion
"origin": { "x": 0.5, "y": 0.9 } // Pivot near top attachment point
}Auto Hit Area
When avatarCanvasSize is defined, the renderer automatically configures the container for input detection:
{
"avatarCanvasSize": {
"width": 1062,
"height": 1674
}
}What happens automatically:
- Container dimensions are set to these values
- Interactive hit area is calculated based on the origin
- The character responds to click/touch events within the logical bounds
Benefits:
- ✅ No manual
setInteractive()needed - ✅ Hit area accounts for
originY: 1.0(feet at bottom) - ✅ Works correctly even with cropped assets
Example:
const container = this.avatarRenderer.render('player', { x: 400, y: 600 });
// Container is automatically interactive
container.on('pointerdown', () => {
console.log('Player clicked!');
});State Change Events
// Listen to visual state changes
const unsubscribe = this.avatarRenderer.onStateChange((event) => {
console.log('State changed:', {
characterId: event.characterId,
categoryId: event.categoryId,
oldState: event.oldState,
newState: event.newState,
timestamp: event.timestamp,
});
});
// Unsubscribe when done
unsubscribe();Custom Variant Resolver
import type { IVariantResolver } from '@series-inc/universal-avatar-renderer';
class CustomResolver implements IVariantResolver {
resolve(layerId, visualState, slotDef, schema) {
// Custom resolution logic
// Example: prioritize certain variants
if (visualState.expression === 'angry' && slotDef['angry']) {
return 'angry';
}
return slotDef['default'] ? 'default' : Object.keys(slotDef)[0];
}
}
// Set custom resolver
this.avatarRenderer.setVariantResolver(new CustomResolver());Item Metadata System
Overview
Item metadata is an optional, flexible property that allows developers to attach custom information to catalog items. It's completely game-agnostic and can store any data your application needs.
type ItemMetadata = Record<string, MetadataValue>;
// MetadataValue supports:
// - Primitives: string, number, boolean
// - Arrays: string[], number[], boolean[]
// - Objects: Record<string, string>, Record<string, unknown>Common Use Cases
1. Economy & Shop Systems
{
id: 'item_premium_dress',
metadata: {
price: 500,
currency: 'gems',
isPremium: true,
discount: 20, // percentage
}
}2. Unlock & Progression Systems
{
id: 'item_legendary_armor',
metadata: {
unlockLevel: 25,
rarity: 'legendary',
requiredAchievements: ['complete_story', 'defeat_boss'],
}
}3. Categorization & Filtering
{
id: 'item_summer_outfit',
metadata: {
category: 'outfit',
season: 'summer',
tags: ['casual', 'beach', 'colorful'],
style: 'modern',
}
}4. Preload Priority & Performance
{
id: 'item_main_character_skin',
metadata: {
preloadPriority: 'high', // 'high' | 'medium' | 'low'
alwaysLoaded: true,
memorySize: 2048, // KB
}
}5. Custom Game Logic
{
id: 'item_magical_robe',
metadata: {
statsBonus: { defense: 10, magic: 15 },
specialAbility: 'fire_resistance',
durability: 100,
canBeCrafted: true,
}
}Querying Items by Metadata
Use the ItemCatalogResolver to query items based on metadata:
// Get catalog resolver
const catalogResolver = this.avatarRenderer.getCatalogResolver();
// Query 1: Find all premium items
const premiumItems = catalogResolver.queryItems(item =>
item.metadata?.isPremium === true
);
// Query 2: Find affordable items under 100 coins
const affordableItems = catalogResolver.queryItems(item =>
typeof item.metadata?.price === 'number' &&
item.metadata.price <= 100
);
// Query 3: Find all summer outfits
const summerOutfits = catalogResolver.queryItems(item =>
item.metadata?.season === 'summer' &&
item.equipmentSlotId === 'outfit_slot'
);
// Query 4: Complex query - unlockable by current player
const currentLevel = 15;
const unlockedItems = catalogResolver.queryItems(item => {
const unlockLevel = item.metadata?.unlockLevel;
return !unlockLevel || unlockLevel <= currentLevel;
});
// Query 5: Find items by tags
const beachItems = catalogResolver.queryItems(item => {
const tags = item.metadata?.tags;
return Array.isArray(tags) && tags.includes('beach');
});Best Practices
DO:
- ✅ Keep metadata optional - not all items need it
- ✅ Use consistent property names across your catalog
- ✅ Document your metadata schema in your game
- ✅ Use TypeScript types for your custom metadata:
// Define your game's metadata schema
type MyGameItemMetadata = {
price?: number;
rarity?: 'common' | 'rare' | 'legendary';
unlockLevel?: number;
tags?: string[];
}
// Use it when defining items (type-safe!)
const item: ItemData = {
id: 'my_item',
// ... other properties
metadata: {
price: 100,
rarity: 'rare',
} satisfies MyGameItemMetadata
}DON'T:
- ❌ Don't use metadata for rendering logic (use
visualsinstead) - ❌ Don't store large binary data (images, audio)
- ❌ Don't rely on metadata for core avatar functionality
Getting All Items
// Get all items for custom processing
const allItems = catalogResolver.getAllItems();
// Example: Group items by category
const itemsByCategory = allItems.reduce((acc, item) => {
const category = item.metadata?.category || 'uncategorized';
if (!acc[category]) acc[category] = [];
acc[category].push(item);
return acc;
}, {});Accessing Specific Items
// Get single item
const item = catalogResolver.getItem('item_hair_blonde');
console.log(item?.metadata?.price); // Access metadata safely
// Get multiple items
const items = catalogResolver.getItems([
'item_skin_1',
'item_skin_2',
'item_skin_3'
]);
// Filter by metadata
const affordableSkins = items.filter(item =>
(item.metadata?.price || 0) <= 50
);Accessing Internal Managers
// Get catalog resolver for item queries
const catalogResolver = this.avatarRenderer.getCatalogResolver();
const item = catalogResolver?.getItem('item_hair_blonde');
// Get character registry for read-only access
const registry = this.avatarRenderer.getCharacterRegistry();
if (registry) {
const character = registry['player'];
console.log('Character equipment:', character?.equipmentSlots);
}Advanced: Internal Components (for framework authors)
UAR exports internal components for advanced use cases. These are typically not needed for normal usage:
import {
StateManager,
ContainerManager,
VariantIndexer,
ItemCatalogResolver
} from '@series-inc/universal-avatar-renderer';
// These are low-level components used internally by AvatarRenderer
// Use only if building custom avatar systems or extending functionalityExported Components:
StateManager- Manages visual state per character with event emissionContainerManager- Manages Phaser container lifecycleVariantIndexer- Reverse index for finding layers affected by variant categoriesItemCatalogResolver- Resolves item IDs to visual resources
Note: These components are used internally by AvatarRenderer. Most applications should use the high-level API methods instead.
Resource Loaders (Advanced)
UAR uses a pluggable loader architecture (Strategy Pattern) for extensibility:
import {
type IResourceLoader,
ResourceLoaderRegistry,
StaticImageLoader,
ImageResourceType
} from '@series-inc/universal-avatar-renderer';
// The built-in registry handles resource loading
// Currently supports:
// - StaticImageLoader (PNG/JPG files)
//
// Future loaders (can be implemented):
// - AtlasImageLoader (TexturePacker format)
// - SpritesheetImageLoader (Grid-based animations)
// - ImageSequenceLoader (Frame sequences)Architecture Benefits:
- Open/Closed Principle - Add new resource types without modifying core
- Single Responsibility - Each loader handles one format
- Testable - Each loader can be tested in isolation
Custom Loader Example (Advanced):
For framework authors or applications with specialized resource formats:
class CustomResourceLoader implements IResourceLoader {
canLoad(resource: ImageResource): boolean {
// Check if this loader handles the resource type
return resource.type === 'custom';
}
load(
loader: Phaser.Loader.LoaderPlugin,
cacheKey: string,
resource: ImageResource
): void {
// Custom loading logic
loader.image(cacheKey, resource.uri);
}
createGameObject(
scene: Phaser.Scene,
cacheKey: string,
resource: ImageResource
): Phaser.GameObjects.GameObject {
// Custom game object creation
return scene.add.image(0, 0, cacheKey);
}
}
// Register custom loader
const registry = new ResourceLoaderRegistry();
registry.registerLoader(new CustomResourceLoader());Note: Most applications won't need custom loaders. This is for framework authors or applications with specialized needs.
Closet System & Hot-Swapping
What is the Closet System?
The closet is an optional array of item IDs that are preloaded but not equipped initially. This enables instant hot-swapping without loading delays during gameplay.
When to Use Closets
✅ Good Use Cases:
- Character customization screens
- Dynamic outfit changes during gameplay
- Skin tone selection
- Seasonal outfit variants
- Progressive unlocks that need to be ready instantly
❌ Not Needed For:
- One-time equipped items
- Items loaded only once at game start
- NPCs with fixed appearance
Basic Closet Setup
// Character definition with closet
const characters = {
player: {
id: 'player',
layerSchemaId: 'humanoid_v1',
equipmentSlots: {
body_slot: 'item_body_pale', // Initially equipped
outfit_slot: 'item_casual_shirt', // Initially equipped
},
closetOfItems: [
// Skin tones (not equipped, but ready to swap)
'item_body_tan',
'item_body_dark',
// Outfits (not equipped, but ready to swap)
'item_formal_suit',
'item_party_dress',
'item_sports_gear',
]
}
};Preloading Behavior
// In preload phase:
this.avatarRenderer.preload(['player']);
// What happens:
// 1. ✅ Loads item_body_pale (equipped)
// 2. ✅ Loads item_casual_shirt (equipped)
// 3. ✅ Loads item_body_tan (closet)
// 4. ✅ Loads item_body_dark (closet)
// 5. ✅ Loads item_formal_suit (closet)
// 6. ✅ Loads item_party_dress (closet)
// 7. ✅ Loads item_sports_gear (closet)
//
// All items are in Phaser's texture cache and ready for instant useHot-Swapping from Closet
class CustomizationScene extends Phaser.Scene {
create() {
// Render character with initial equipment
this.avatarRenderer.render('player', { x: 400, y: 600 });
// Hot-swap skin tone (instant - already loaded)
this.createButton('Tan Skin', () => {
this.avatarRenderer.equipItem('player', 'body_slot', 'item_body_tan');
// ⚡ Instant - no loading delay
});
// Hot-swap outfit (instant - already loaded)
this.createButton('Formal Suit', () => {
this.avatarRenderer.equipItem('player', 'outfit_slot', 'item_formal_suit');
// ⚡ Instant - no loading delay
});
}
}Dynamic vs Static Loading
// ❌ WITHOUT CLOSET (causes loading delay)
const characterNoCloset = {
id: 'player',
equipmentSlots: {
body_slot: 'item_body_pale',
}
// No closet - other items not preloaded
};
// During gameplay:
this.avatarRenderer.equipItem('player', 'body_slot', 'item_body_tan');
// ⚠️ May cause brief delay if texture not loaded
// ✅ WITH CLOSET (instant swap)
const characterWithCloset = {
id: 'player',
equipmentSlots: {
body_slot: 'item_body_pale',
},
closetOfItems: ['item_body_tan'] // Preloaded
};
// During gameplay:
this.avatarRenderer.equipItem('player', 'body_slot', 'item_body_tan');
// ⚡ Instant - already in cacheMemory Considerations
The closet preloads textures into memory. Consider these factors:
// Small closet (recommended for mobile)
closetOfItems: [
'skin_variant_1',
'skin_variant_2',
'skin_variant_3'
] // ~2-4MB depending on image sizes
// Large closet (desktop/high-end devices)
closetOfItems: [
// 10 skin variants
...skinVariants,
// 20 outfit variants
...outfitVariants,
// 15 hair variants
...hairVariants
] // ~20-40MB depending on image sizesBest Practice: Use closet for items likely to be needed soon, not all possible items.
Selective Variant Preloading
Combine closet with selective variant preloading for fine-grained control:
// Preload only specific variants of closet items
this.avatarRenderer.preload(['player'], {
preloadVariants: {
expression: ['default', 'happy'], // Only 2 expressions
// Other variants not loaded
}
});
// This applies to both equipped items AND closet itemsAdvanced Pattern: Lazy Closet
For very large item sets, you can implement a "lazy closet" pattern:
class CustomizationScene extends Phaser.Scene {
private currentCategory = 'outfits';
loadCategoryItems(category: 'outfits' | 'hair' | 'accessories') {
// Dynamically update character's closet
const itemsToLoad = this.getCategoryItems(category);
// Load new category items
this.load.image(itemsToLoad.map(item => /* ... */));
this.load.once('complete', () => {
// Items now available for hot-swapping
this.currentCategory = category;
});
this.load.start();
}
getCategoryItems(category: string): string[] {
// Return item IDs for category
// This is application-specific logic
return [];
}
}Checking Item Availability
// Check if item exists in catalog
const catalogResolver = this.avatarRenderer.getCatalogResolver();
const item = catalogResolver?.getItem('item_body_tan');
if (!item) {
console.error('Item not in catalog');
return;
}
// Check if texture is loaded (from closet or currently equipped)
const textureKey = `${item.id}_${variantValue}`;
const isLoaded = this.textures.exists(textureKey);
if (isLoaded) {
// Safe to equip
this.avatarRenderer.equipItem('player', 'body_slot', 'item_body_tan');
} else {
// Need to load first
console.warn('Item not in closet - will load on demand');
}Multi-Scene Management
Plugin Instance per Scene
The AvatarRenderer is a Scene Plugin, meaning each Phaser scene gets its own instance:
class Scene1 extends Phaser.Scene {
create() {
// This is Scene1's instance
this.avatarRenderer.render('player', { x: 100, y: 200 });
}
}
class Scene2 extends Phaser.Scene {
create() {
// This is Scene2's instance (separate from Scene1)
this.avatarRenderer.render('player', { x: 300, y: 400 });
}
}Key Points:
- ✅ Each scene has independent renderer state
- ✅ Registries need to be initialized per scene (or use global loading)
- ✅ Visual states are scene-specific
- ✅ Textures are shared across scenes (Phaser's global texture cache)
Initialization Strategies
Strategy 1: Initialize per Scene (Simple)
class GameScene extends Phaser.Scene {
async preload() {
// Load registries
const registries = await loadUARRegistries('/assets/avatars');
// Initialize THIS scene's renderer
this.avatarRenderer.initialize(
registries.layerSchemas,
registries.characterRegistry,
registries.itemCatalog
);
this.avatarRenderer.preload(['player']);
}
}Strategy 2: Global Initialization (Efficient)
// Global store for registries
class RegistryStore {
static schemas: LayerSchemaRegistry | null = null;
static characters: CharacterRegistry | null = null;
static catalog: ItemCatalog | null = null;
static async loadOnce(basePath: string) {
if (!this.schemas) {
const registries = await loadUARRegistries(basePath);
this.schemas = registries.layerSchemas;
this.characters = registries.characterRegistry;
this.catalog = registries.itemCatalog;
}
}
}
// In boot/preloader scene
class BootScene extends Phaser.Scene {
async create() {
await RegistryStore.loadOnce('/assets/avatars');
this.scene.start('GameScene');
}
}
// In any other scene
class GameScene extends Phaser.Scene {
preload() {
// Reuse loaded registries
this.avatarRenderer.initialize(
RegistryStore.schemas!,
RegistryStore.characters!,
RegistryStore.catalog!
);
this.avatarRenderer.preload(['player']);
}
}Scene Transitions with State Preservation
Preserving Visual State:
class Scene1 extends Phaser.Scene {
shutdown() {
// Destroy containers but KEEP visual states
this.avatarRenderer.destroyAllCharacters(true); // keepStates = true
}
}
class Scene2 extends Phaser.Scene {
create() {
// Initialize renderer
this.avatarRenderer.initialize(schemas, characters, catalog);
this.avatarRenderer.preload(['player']);
// Render character - visual state is restored!
this.avatarRenderer.render('player', { x: 400, y: 600 });
// Character appears with same expression/equipment as Scene1
}
}Fresh State (Reset):
class Scene1 extends Phaser.Scene {
shutdown() {
// Destroy everything including states
this.avatarRenderer.destroyAllCharacters(false); // keepStates = false
}
}
class Scene2 extends Phaser.Scene {
create() {
// Character will render with default state
this.avatarRenderer.render('player', { x: 400, y: 600 });
// Fresh state - default expression, original equipment
}
}Lifecycle Hooks
The plugin automatically handles Phaser's lifecycle:
// ✅ Automatically called by Phaser
boot() // When scene starts - registers listeners
shutdown() // When scene pauses/stops - can be restarted
destroy() // When scene is permanently destroyed - cleans up everything
// You typically don't call these manually
// But you can if needed:
this.avatarRenderer.destroy();Memory Management Across Scenes
Important: Phaser's texture cache is global across all scenes.
// Scene 1 loads textures
class Scene1 extends Phaser.Scene {
preload() {
this.avatarRenderer.initialize(schemas, characters, catalog);
this.avatarRenderer.preload(['player']); // Loads textures
}
}
// Scene 2 uses same textures (no re-download)
class Scene2 extends Phaser.Scene {
preload() {
this.avatarRenderer.initialize(schemas, characters, catalog);
this.avatarRenderer.preload(['player']); // ✅ Uses cached textures
}
}
// Explicitly clear textures (rarely needed)
class Scene3 extends Phaser.Scene {
shutdown() {
// Remove specific character textures from global cache
const characterData = this.avatarRenderer.getCharacterData('player');
// Custom logic to remove textures if needed
// Usually NOT necessary - Phaser manages memory well
}
}Parallel Scene Rendering
Multiple scenes can render different characters simultaneously:
// Background scene (parallax, environment)
class BackgroundScene extends Phaser.Scene {
create() {
this.avatarRenderer.initialize(schemas, characters, catalog);
this.avatarRenderer.render('npc_background', {
x: 200,
y: 400,
depth: 1 // Behind UI
});
}
}
// UI scene (HUD, menus)
class UIScene extends Phaser.Scene {
create() {
this.avatarRenderer.initialize(schemas, characters, catalog);
this.avatarRenderer.render('player_portrait', {
x: 50,
y: 50,
scale: 0.3,
depth: 100 // In front
});
}
}
// Both scenes can run in parallel
this.scene.run('BackgroundScene');
this.scene.run('UIScene');Best Practices
// ✅ DO: Initialize in preload()
class GoodScene extends Phaser.Scene {
preload() {
this.avatarRenderer.initialize(/* ... */);
this.avatarRenderer.preload(['player']);
}
create() {
this.avatarRenderer.render('player', { x: 400, y: 600 });
}
}
// ❌ DON'T: Initialize in create()
class BadScene extends Phaser.Scene {
create() {
this.avatarRenderer.initialize(/* ... */); // Too late
this.avatarRenderer.preload(['player']); // Async load in create!
this.avatarRenderer.render('player', { x: 400, y: 600 }); // May fail
}
}
// ✅ DO: Clean up on shutdown if preserving state
shutdown() {
this.avatarRenderer.destroyAllCharacters(true);
}
// ✅ DO: Use global registry loading for efficiency
// (See Strategy 2 above)⚡ Performance Considerations
Understanding how UAR works internally helps you write efficient code. This section explains architectural decisions and their performance implications.
Note: This section focuses on design and architecture, not specific benchmarks. Performance characteristics may vary based on your game, assets, and target platform.
Rendering Architecture
How Characters are Rendered
Each rendered character consists of:
- 1 Container (
Phaser.GameObjects.Container) - Holds all layers - N Images (
Phaser.GameObjects.Image) - One per layer in schema - Managed by ContainerManager - Lifecycle and positioning
// Example: Character with 7 layers
layerSchema: {
layers: ['body', 'face', 'hair_b', 'clothing', 'hair_f', 'props_b', 'props_f']
}
// Results in:
// 1 Container containing 7 Image objectsLayer Count Impact
- Each layer = 1 Phaser Image object in the scene
- More layers = more objects to manage
- Layers are ordered by schema definition (index 0 = bottom, rendered first)
Implication:
// Efficient: 5-7 layers (typical humanoid)
layers: ['body', 'face', 'hair_back', 'outfit', 'hair_front']
// Still efficient: 10-12 layers (detailed character)
layers: ['body', 'face', 'hair_b', 'outfit', 'hair_f', 'props_b', 'props_f', ...]
// Consider optimization: 20+ layers
// May want to combine some layers if they never change independentlyHot-Swapping Mechanisms
Visual Variant Updates (updateVisualVariant)
How it works:
- Queries
VariantIndexerto find affected layers - Uses
VariantResolverto determine new texture key - Changes texture on existing
Imageobject - No object creation/destruction
// FAST: Just swaps texture on existing Image
this.avatarRenderer.updateVisualVariant('player', 'expression', 'happy');
// Internally: imageObject.setTexture(newTextureKey)Performance characteristics:
- ✅ Very fast (texture swap only)
- ✅ No garbage collection
- ✅ No container recreation
- ✅ Preferred for frequent changes (expressions, states)
Equipment Changes (equipItem)
How it works:
- Validates item exists in catalog and is compatible with character schema
- Updates character's
equipmentSlotsin registry (replaces item ID) - Rebuilds
EquipmentSlotsviaItemCatalogResolver - Calls
rerenderCharacter()which iterates through layers - For each layer, swaps texture on existing
Imageobject (if texture changed) - Does NOT destroy/recreate container or images
// Changes equipment and updates visuals
this.avatarRenderer.equipItem('player', 'outfit_slot', 'item_dress_blue');
// Internally: Updates registry + swaps textures on existing spritesPerformance characteristics:
- ✅ Reuses existing container and Image objects
- ✅ Only changes textures that are different
- ✅ No object creation/destruction
- ⚠️ Iterates through all layers (vs targeted layer in updateVisualVariant)
- ✅ Use when changing equipped items (different visual resources)
Best Practice
// ✅ CORRECT: Use variants for visual state changes
// Targets specific layers affected by variant category
this.avatarRenderer.updateVisualVariant('player', 'expression', 'happy');
this.avatarRenderer.updateVisualVariant('player', 'wetness', 'wet');
// ✅ CORRECT: Use equipItem for equipment changes
// Changes item reference, iterates all layers
this.avatarRenderer.equipItem('player', 'hair_slot', 'item_hair_blonde');
this.avatarRenderer.equipItem('player', 'outfit_slot', 'item_dress_red');
// ❌ INCORRECT: Creating separate items for each expression
// Wasteful - use visual variants instead
{
id: 'item_face_happy',
id: 'item_face_sad',
id: 'item_face_angry'
}
// Should be ONE item with expression variantsCloset System Design
What the Closet Does
The closetOfItems array specifies items to preload but not equip initially:
{
id: 'player',
equipmentSlots: {
body_slot: 'item_body_pale' // ← Equipped AND preloaded
},
closetOfItems: [
'item_body_tan', // ← NOT equipped, but preloaded
'item_body_dark' // ← NOT equipped, but preloaded
]
}During preload():
- Loads equipped items' textures
- Loads closet items' textures
- All textures stored in Phaser's global texture cache
During equipItem():
- If item was in closet → texture already cached → instant
- If item wasn't in closet → may need to load texture → delay
Closet vs No Closet
// WITHOUT CLOSET
{
equipmentSlots: { body_slot: 'item_body_pale' }
// Only item_body_pale loaded
}
// Later:
this.avatarRenderer.equipItem('player', 'body_slot', 'item_body_tan');
// ⚠️ May need to load texture if not already cached
// WITH CLOSET
{
equipmentSlots: { body_slot: 'item_body_pale' },
closetOfItems: ['item_body_tan', 'item_body_dark']
// All three loaded during preload
}
// Later:
this.avatarRenderer.equipItem('player', 'body_slot', 'item_body_tan');
// ✅ Instant - texture already in cacheWhen to Use Closet
✅ Use closet for:
- Items you'll
equipItem()during gameplay - Character customization options
- Skin tone variants
- Multiple outfit options
- Progressive unlocks that need instant swapping
❌ Don't use closet for:
- Items never used
- Items only needed in other scenes
- One-time equipment (won't change)
Closet Size Consideration
// Small closet: A few alternatives
closetOfItems: ['skin_variant_1', 'skin_variant_2']
// Loads: ~2-3 additional items
// Medium closet: Customization options
closetOfItems: [...10 items]
// Loads: ~10 additional items
// Large closet: Full customization
closetOfItems: [...50 items]
// Loads: Many textures - ensure they're all actually usedRecommendation: Only add items to closet if you'll actually use equipItem() with them during gameplay.
State Management
How State is Tracked
StateManager maintains visual state per character:
// Internal state structure:
{
player: {
expression: 'happy',
wetness: 'default',
outfit_state: 'clean'
}
}State Persistence
// Destroy but keep state
this.avatarRenderer.destroyCharacter('player', true); // keepState = true
// State preserved: { expression: 'happy', wetness: 'default' }
// Later, re-render
this.avatarRenderer.render('player', { x: 400, y: 600 });
// ✅ Renders with preserved state (happy expression)
// Destroy and clear state
this.avatarRenderer.destroyCharacter('player', false); // keepState = false
// State cleared: character will render with default state next timeAvoiding Redundant Updates
// ❌ BAD: Always updates (even if already in that state)
setExpression(expr: string) {
this.avatarRenderer.updateVisualVariant('player', 'expression', expr);
}
// ✅ GOOD: Check before updating
setExpression(expr: string) {
const currentState = this.avatarRenderer.getVisualState('player');
if (currentState?.expression !== expr) {
this.avatarRenderer.updateVisualVariant('player', 'expression', expr);
}
}Preload System
What preload() Does
this.avatarRenderer.preload(['player']);
// Internally:
// 1. Get character definition from registry
// 2. Resolve equipped items via ItemCatalogResolver
// 3. Get all visual resources from items
// 4. Load closetOfItems resources
// 5. Call Phaser's load.image() for each texture
// 6. Optionally filter by PreloadOptionsSelective Variant Preloading
Built-in feature to load only specific variants:
// Load ALL variants (default behavior)
this.avatarRenderer.preload(['player']);
// If face has 10 expressions, loads all 10
// Load ONLY specific variants
this.avatarRenderer.preload(['player'], {
preloadVariants: {
expression: ['default', 'happy', 'sad']
}
});
// Only loads 3 expressions, not all 10Impact:
- ✅ Fewer textures loaded = faster preload phase
- ✅ Less memory used
- ✅ Can still change to loaded variants at runtime
- ⚠️ Non-loaded variants won't render (will use 'default' or fail)
Preload Strategies
// Strategy 1: Load everything (simple, memory-heavy)
this.avatarRenderer.preload(['player']);
// Strategy 2: Load common variants only (balanced)
this.avatarRenderer.preload(['player'], {
preloadVariants: {
expression: ['default', 'happy', 'sad', 'angry'], // 4 common ones
wetness: ['default'] // Skip 'wet' state
}
});
// Strategy 3: Minimal preload (load on-demand later)
this.avatarRenderer.preload(['player'], {
preloadVariants: {
expression: ['default'] // Just default
}
});
// Later, load additional variants as neededLifecycle & Cleanup
Destruction Behavior
destroyCharacter(id, keepState):
// keepState = false (default)
this.avatarRenderer.destroyCharacter('player', false);
// - Destroys Container
// - Destroys all Image objects
// - Clears visual state
// - Character can be re-rendered with fresh state
// keepState = true
this.avatarRenderer.destroyCharacter('player', true);
// - Destroys Container
// - Destroys all Image objects
// - PRESERVES visual state in StateManager
// - Re-rendering restores statedestroyAllCharacters(keepStates):
// Same logic, applied to all rendered characters
this.avatarRenderer.destroyAllCharacters(true);Scene Lifecycle Integration
UAR automatically hooks into Phaser's scene lifecycle:
// Automatically called by Phaser
boot() {
// When scene starts
// Registers event listeners
}
shutdown() {
// When scene stops/pauses (can restart)
// Keeps plugin instance alive
// You should manually destroy characters here
}
destroy() {
// When scene is permanently destroyed
// Full cleanup, plugin instance disposed
}Best practice:
class GameScene extends Phaser.Scene {
shutdown() {
// Clean up when scene stops
this.avatarRenderer.destroyAllCharacters(true); // Keep state for restart
}
// Don't override destroy() unless you have specific cleanup
// UAR's destroy() will be called automatically
}Multi-Scene Architecture
Plugin Instances
AvatarRenderer is a Scene Plugin - key characteristics:
- Each scene gets its own
AvatarRendererinstance - Instances are independent (separate state, containers)
- Registries must be initialized per instance (or shared globally)
class Scene1 extends Phaser.Scene {
create() {
// This is Scene1's AvatarRenderer instance
this.avatarRenderer.render('player', { x: 100, y: 200 });
}
}
class Scene2 extends Phaser.Scene {
create() {
// This is Scene2's AvatarRenderer instance (different from Scene1)
this.avatarRenderer.render('player', { x: 300, y: 400 });
}
}Texture Cache is Global
Important: Phaser's texture cache is shared across ALL scenes:
// Scene 1
this.avatarRenderer.preload(['player']); // Loads textures into cache
// Scene 2 (later)
this.avatarRenderer.preload(['player']); // Uses cached textures, no re-downloadImplication:
- ✅ Textures loaded once, available everywhere
- ✅ No need to reload in each scene
- ✅ Memory efficient
- ⚠️ Texture cleanup only happens when explicitly removed
Efficient Multi-Scene Pattern
// Global registry store
class RegistryStore {
static schemas: LayerSchemaRegistry | null = null;
static characters: CharacterRegistry | null = null;
static catalog: ItemCatalog | null = null;
static async loadOnce(basePath: string) {
if (!this.schemas) {
const registries = await loadUARRegistries(basePath);
this.schemas = registries.layerSchemas;
this.characters = registries.characterRegistry;
this.catalog = registries.itemCatalog;
}
}
}
// Boot scene - load once
class BootScene extends Phaser.Scene {
async create() {
await RegistryStore.loadOnce('/assets/avatars');
this.scene.start('GameScene');
}
}
// Any game scene - reuse registries
class GameScene extends Phaser.Scene {
preload() {
// Reuse loaded registries (no re-parsing JSON)
this.avatarRenderer.initialize(
RegistryStore.schemas!,
RegistryStore.characters!,
RegistryStore.catalog!
);
// Textures already in cache if loaded by previous scene
this.avatarRenderer.preload(['player']);
}
}Variant Resolution
How VariantResolver Works
When rendering or updating variants:
- Gets current visual state:
{ expression: 'happy', wetness: 'wet' } - Gets available variants from
EquipmentSlotDefinition - Iterates variant categories in priority order (from schema)
- Returns first matching variant key
- Falls back to
'default'if no match
// Example resolution:
// State: { expression: 'happy', wetness: 'wet' }
// Available variants: { default: {...}, happy: {...}, wet: {...} }
// Schema order: ['expression', 'wetness'] (expression checked first)
// Result: 'happy' (found before checking wetness)Custom Resolution Strategy
You can provide custom variant resolution logic:
import type { IVariantResolver } from '@series-inc/universal-avatar-renderer';
class PriorityResolver implements IVariantResolver {
resolve(layerId, visualState, slotDef, schema) {
// Custom logic: Always prefer wetness over expression
if (visualState.wetness === 'wet' && slotDef['wet']) {
return 'wet';
}
if (visualState.expression && slotDef[visualState.expression]) {
return visualState.expression;
}
return 'default';
}
}
// Set custom resolver
this.avatarRenderer.setVariantResolver(new PriorityResolver());Use cases:
- Custom priority orders
- Complex fallback logic
- Game-specific variant rules
Anti-Patterns to Avoid
❌ Anti-Pattern 1: Re-rendering Every Frame
// BAD: Creates/destroys containers every frame
update() {
this.avatarRenderer.destroyCharacter('player');
this.avatarRenderer.render('player', { x: 400, y: 600 });
// Massive garbage collection, very slow
}
// GOOD: Render once, update variants
create() {
this.avatarRenderer.render('player', { x: 400, y: 600 });
}
update() {
if (shouldChangeExpression) {
this.avatarRenderer.updateVisualVariant('player', 'expression', 'happy');
}
}❌ Anti-Pattern 2: Using equipItem() for Visual States
// BAD: Creates separate items for each expression state
const catalog = {
items: {
item_face_happy: { /* ... */ },
item_face_sad: { /* ... */ },
item_face_angry: { /* ... */ }
}
};
changeExpression(expr: string) {
// Iterates all layers, swaps multiple textures
this.avatarRenderer.equipItem('player', 'face_slot', `item_face_${expr}`);
}
// GOOD: Use visual variant system
const catalog = {
items: {
item_face_default: {
visuals: {
face: {
default: { /* ... */ },
happy: { /* ... */ },
sad: { /* ... */ },
angry: { /* ... */ }
}
}
}
}
};
changeExpression(expr: string) {
// Only swaps textures on affected layers (via VariantIndexer)
this.avatarRenderer.updateVisualVariant('player', 'expression', expr);
}Why it matters:
updateVisualVariant()usesVariantIndexerto find affected layers onlyequipItem()iterates through ALL layers in schema- Visual variants are the intended way to handle state changes
❌ Anti-Pattern 3: Not Using Closet for Hot-Swapping
// BAD: Items not in closet
{
equipmentSlots: { outfit_slot: 'item_casual' },
closetOfItems: [] // Empty closet
}
// During gameplay:
this.avatarRenderer.equipItem('player', 'outfit_slot', 'item_formal');
// ⚠️ May need to load texture, causing delay
// GOOD: Preload via closet
{
equipmentSlots: { outfit_slot: 'item_casual' },
closetOfItems: ['item_formal', 'item_party'] // Preloaded
}
// During gameplay:
this.avatarRenderer.equipItem('player', 'outfit_slot', 'item_formal');
// ✅ Instant - texture already loaded❌ Anti-Pattern 4: Initialize in create() Instead of preload()
// BAD: Async operations in create()
create() {
this.avatarRenderer.initialize(schemas, characters, catalog);
this.avatarRenderer.preload(['player']); // Async!
this.avatarRenderer.render('player', { x: 400, y: 600 }); // May fail - not loaded yet
}
// GOOD: Use Phaser's lifecycle correctly
preload() {
this.avatarRenderer.initialize(schemas, characters, catalog);
this.avatarRenderer.preload(['player']); // Loads during preload phase
}
create() {
this.avatarRenderer.render('player', { x: 400, y: 600 }); // Textures ready
}❌ Anti-Pattern 5: Bloated Closet with Unused Items
// BAD: Loads many items never used
{
closetOfItems: [
'item1', 'item2', 'item3', ... 'item100' // 100 items!
]
}
// Most items never actually equipped during gameplay
// GOOD: Only preload what you'll use
{
closetOfItems: [
'item_skin_variant_1',
'item_skin_variant_2',
'item_hair_alternate'
]
}
// Only items actually used in this scene/modeRule of thumb: If you're not calling equipItem() with it, don't put it in closet.
❌ Anti-Pattern 6: Not Cleaning Up on Scene Shutdown
// BAD: Memory leak across scene transitions
class GameScene extends Phaser.Scene {
shutdown() {
// No cleanup!
}
}
// Containers still in memory
// GOOD: Clean up properly
class GameScene extends Phaser.Scene {
shutdown() {
// De