npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Tests Coverage TypeScript Phaser


📋 Table of Contents


✨ 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-renderer

Peer 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

  1. Layer Schema - Defines rendering order and variant categories for a body type
  2. Items Catalog - Reusable equipment items with visual variants
  3. Character Registry - Character definitions that reference items by ID
  4. State Manager - Tracks current visual state (expression, wetness, etc.)
  5. 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 origin
  • scale?: number - Scale multiplier (combines with RenderOptions.scale)
  • rotation?: number - Rotation in radians
  • alpha?: 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:

  1. Reference canvas: 1062×1674px
  2. Cropped to: 406×675px (using Photoshop's "Trim Transparent Pixels")
  3. 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
): void

Parameters:

  • layerSchemaRegistry - Layer schemas defining body types
  • characterRegistry - Character definitions
  • itemCatalog - 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
): void

Parameters:

  • 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.Container

Parameters:

  • characterId - Character ID to render
  • options - 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
): void

Example:

// 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> | null

Example:

const state = this.avatarRenderer.getVisualState('player');
console.log(state?.expression); // 'happy'
resetVisualState(characterId)

Reset character to default visual state.

resetVisualState(characterId: string): void

Equipment Management

equipItem(characterId, slotId, itemId)

Equip a new item at runtime (hot-swap equipment).

equipItem(
  characterId: string,
  equipmentSlotId: EquipmentSlotId,
  itemId: ItemId
): void

Example:

// 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 | null
isRendered(characterId)

Check if a character is currently rendered.

isRendered(characterId: string): boolean
getRenderedCharacterIds()

Get all currently rendered character IDs.

getRenderedCharacterIds(): string[]
destroyCharacter(characterId, keepState?)

Destroy a specific character.

destroyCharacter(characterId: string, keepState?: boolean): void

Parameters:

  • keepState - If true, preserves visual state for re-rendering (default: false)
destroyAllCharacters(keepStates?)

Destroy all rendered characters.

destroyAllCharacters(keepStates?: boolean): void

Lifecycle 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 avatarCanvasSize from the schema
  • Helps verify that cropped assets with localTransform are positioned correctly
  • Disappears automatically when the character is destroyed

Use cases:

  • ✅ Validating localTransform.offset values
  • ✅ Verifying that avatarCanvasSize matches 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 origin is NOT specified, uses global RenderOptions.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:

  1. Container dimensions are set to these values
  2. Interactive hit area is calculated based on the origin
  3. 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 visuals instead)
  • ❌ 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 functionality

Exported Components:

  • StateManager - Manages visual state per character with event emission
  • ContainerManager - Manages Phaser container lifecycle
  • VariantIndexer - Reverse index for finding layers affected by variant categories
  • ItemCatalogResolver - 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 use

Hot-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 cache

Memory 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 sizes

Best 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 items

Advanced 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 objects

Layer 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 independently

Hot-Swapping Mechanisms

Visual Variant Updates (updateVisualVariant)

How it works:

  1. Queries VariantIndexer to find affected layers
  2. Uses VariantResolver to determine new texture key
  3. Changes texture on existing Image object
  4. 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:

  1. Validates item exists in catalog and is compatible with character schema
  2. Updates character's equipmentSlots in registry (replaces item ID)
  3. Rebuilds EquipmentSlots via ItemCatalogResolver
  4. Calls rerenderCharacter() which iterates through layers
  5. For each layer, swaps texture on existing Image object (if texture changed)
  6. 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 sprites

Performance 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 variants

Closet 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():

  1. Loads equipped items' textures
  2. Loads closet items' textures
  3. 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 cache

When 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 used

Recommendation: 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 time

Avoiding 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 PreloadOptions

Selective 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 10

Impact:

  • ✅ 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 needed

Lifecycle & 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 state

destroyAllCharacters(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 AvatarRenderer instance
  • 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-download

Implication:

  • ✅ 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:

  1. Gets current visual state: { expression: 'happy', wetness: 'wet' }
  2. Gets available variants from EquipmentSlotDefinition
  3. Iterates variant categories in priority order (from schema)
  4. Returns first matching variant key
  5. 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() uses VariantIndexer to find affected layers only
  • equipItem() 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/mode

Rule 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