Files
modbison/docs/architecture/avatar.md
daddy af85048deb PC3 Phase 1.3: backend avatar loadout persistence
New avatar_loadouts table (one row per user, loadout stored as a single
JSONB document matching the client's AvatarLoadout.to_json() shape) and
GET/PUT /api/avatar/loadout.

The server has no copy of the asset catalog, so it validates only the
envelope (JSON object, non-empty base_body, object slots, size cap) --
which-asset-in-which-slot validation stays client-side. New accounts get a
server default (base_human_male_01, no slots) with is_default:true so the
client can tell "never customised" from "customised back to base".

- models/avatar_loadout.py + 0004 migration + User.avatar_loadout relation
- routers/avatar.py wired into main.py
- tests/test_avatar_loadout.py (7 tests: default, save/read, replace,
  validation, auth) -- full suite 68 passed
- lifted the duplicated _override_db fixture from 3 test files into
  conftest.py (new test files need it too)
- docs/architecture/avatar.md §9a: the persistence design

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:42:10 +00:00

20 KiB
Raw Permalink Blame History

Avatar Component System & glTF Attachment Spec

Status: Proposed Owner: Platform team Related: Server authority + client prediction, world chunk data format

1. Overview

This document specifies the avatar component system for agp-game, a Godot-based multiplayer game. It defines:

  • How an avatar is decomposed into body parts and attachment slots.
  • How cosmetic attachments (clothing, accessories, gear) mount onto the base body.
  • The glTF/GLB format requirements that all avatar assets must satisfy so they can be loaded, skinned, and networked consistently.

The goal is a modular, data-driven avatar pipeline: a single base body rig plus swappable attachments, all authored in glTF and consumed by a shared Godot runtime. This enables player customization, cosmetic monetization, and server-authoritative validation of what a player is wearing.

1.1 Design Goals

  • Modularity: Body parts and attachments are independent assets that can be combined at runtime without re-exporting.
  • Consistency: A single, well-defined skeleton and slot convention so any attachment fits any base body.
  • Networkability: The avatar's composition is expressed as a compact, serializable loadout that the server can validate and replicate.
  • Performance: Attachments are skinned to the same skeleton and share materials where possible to minimize draw calls and skinning cost.
  • Tooling-friendly: Assets are authored in standard glTF 2.0 and validated by a build-time checker.

2. Avatar Decomposition

2.1 The Base Body

Every avatar is built on a base body — a single glTF asset containing:

  • A skeleton (the canonical rig, see §3).
  • A base mesh for the skin/body (head, torso, arms, legs).
  • Attachment points (empty nodes) that define where accessories mount.

The base body is the only asset that carries the skeleton. Attachments reference the skeleton by bone name and are skinned to it; they never carry their own rig.

2.2 Body Parts vs. Attachments

Two distinct concepts:

Concept Definition Example
Body part A mesh that replaces part of the base body's skin (e.g., a different head, a tail, wings). Alternate head, cybernetic arm
Attachment A mesh that mounts on top of the body without replacing it (clothing, accessories). Shirt, hat, glasses, backpack

The distinction matters for slot exclusivity and layering:

  • Body parts are exclusive per slot — only one head can be equipped.
  • Attachments are layered — a hat and glasses can coexist, and both sit above the head mesh.

3. Skeleton & Slot Convention

3.1 Canonical Skeleton

All avatars share a canonical skeleton with a fixed set of bone names. This is the contract that lets any attachment fit any body. The skeleton follows a humanoid convention (Godot's Humanoid profile / glTF VRM-style naming).

Required bones (subset of the full rig):

Root
├── Hips
│   ├── Spine
│   │   ├── Chest
│   │   │   ├── Neck
│   │   │   │   └── Head
│   │   │   │       ├── HeadTop          # hat / hair mount
│   │   │   │       ├── Jaw              # face accessories
│   │   │   │       └── LeftEye / RightEye
│   │   │   ├── LeftShoulder
│   │   │   │   └── LeftUpperArm
│   │   │   │       └── LeftLowerArm
│   │   │   │           └── LeftHand
│   │   │   └── RightShoulder
│   │   │       └── RightUpperArm
│   │   │           └── RightLowerArm
│   │   │               └── RightHand
│   │   └── (Chest children for torso attachments)
│   ├── LeftUpperLeg
│   │   └── LeftLowerLeg
│   │       └── LeftFoot
│   └── RightUpperLeg
│       └── RightLowerLeg
│           └── RightFoot

Rules:

  • Bone names are case-sensitive and must match exactly.
  • The skeleton is T-pose at rest for consistent skinning.
  • Attachments must be skinned to at least the bones they deform; static accessories may be parented to a single bone (e.g., a hat parented to Head).

3.2 Attachment Slots

Slots are the logical mount points for body parts and attachments. Each slot maps to one or more bones and defines what can be equipped there.

Slot ID Category Mount bone(s) Exclusivity Example assets
head body part Head exclusive alternate head, face
hair attachment HeadTop exclusive hairstyles, hats
face attachment Jaw exclusive glasses, masks, beards
torso body part Chest exclusive alternate torso
chest attachment Chest exclusive shirts, armor, chest gear
back attachment Chest (back) exclusive backpacks, wings, capes
left_arm body part LeftUpperArm exclusive alternate arm
right_arm body part RightUpperArm exclusive alternate arm
left_hand attachment LeftHand exclusive gloves, weapons (held)
right_hand attachment RightHand exclusive gloves, weapons (held)
legs body part Hips exclusive alternate legs
pants attachment Hips exclusive pants, skirts
left_foot attachment LeftFoot exclusive shoes
right_foot attachment RightFoot exclusive shoes
accessory attachment Root (free) multiple belts, jewelry, floating items

Slot semantics:

  • exclusive slots allow at most one equipped item at a time.
  • The accessory slot is multi-slot — multiple accessories may be equipped simultaneously (bounded by a configurable limit, e.g., 8).
  • Body-part slots (head, torso, legs, left_arm, right_arm) replace the corresponding base mesh. When a body part is equipped, the base mesh for that region is hidden.
  • Attachment slots layer on top of the base mesh and never hide it.

3.3 Slot → Bone Mapping

Each slot declares a primary mount bone and an optional secondary bone list for skinning. The runtime uses this to:

  1. Parent static accessories to the mount bone.
  2. Skin skinned attachments to the union of the slot's bones.

4. glTF/GLB Format Requirements

All avatar assets (base bodies, body parts, attachments) are distributed as glTF 2.0 (.gltf + external buffers/textures) or GLB (.glb, binary container). GLB is the preferred distribution format for runtime loading.

4.1 Required Conventions

Concern Requirement
Format glTF 2.0 (.gltf or .glb). GLB preferred for runtime.
Coordinate system glTF standard: +Y up, +Z forward, right-handed. Godot converts to its own convention on import.
Units Meters. Avatar height ~1.8 m at the Root bone.
Skeleton Single skeleton per asset; bone names per §3.1.
Skinning skins + nodes with JOINTS_0 / WEIGHTS_0 accessors.
Pose T-pose at rest.
Materials PBR metallic-roughness. Use KHR_materials_unlit for emissive/UI-only items.
Textures Max 2048×2048; power-of-two preferred. Use KHR_texture_basisu (BasisU) for runtime.
Animation Optional. If present, must not rename or remove canonical bones.

4.2 Asset Metadata (extras)

Each asset must declare its slot and identity in the glTF asset.extras object. This is the machine-readable contract consumed by the build pipeline and runtime.

{
  "asset": {
    "version": "2.0",
    "extras": {
      "agp": {
        "type": "attachment",          // "base_body" | "body_part" | "attachment"
        "slot": "chest",               // slot ID from §3.2
        "id": "shirt_bandit_01",       // globally unique asset id
        "display_name": "Bandit Shirt",
        "version": "1.0.0",
        "mount_bone": "Chest",         // primary mount bone
        "skin_bones": ["Chest", "Spine", "LeftUpperArm", "RightUpperArm"],
        "exclusive": true,             // matches slot exclusivity
        "tags": ["cloth", "common"],
        "author": "platform-team"
      }
    }
  }
}

Validation rules for extras.agp:

  • type must be one of base_body, body_part, attachment.
  • slot must be a known slot ID (§3.2).
  • mount_bone must exist in the canonical skeleton.
  • skin_bones must all exist in the canonical skeleton.
  • exclusive must match the slot's declared exclusivity.
  • id must be unique across the asset catalog.

4.3 Node Naming Convention

To make assets self-describing and mergeable, nodes follow a convention:

  • Base body: the mesh node is named Body; the skeleton root is Root.
  • Body part: the mesh node is named <SlotID>_Mesh (e.g., head_Mesh).
  • Attachment: the mesh node is named <SlotID>_<AssetID> (e.g., chest_shirt_bandit_01).
  • Attachment points (empty nodes) on the base body are named attach_<slot> (e.g., attach_head, attach_left_hand).

4.4 Material & Texture Requirements

  • All materials use the metallic-roughness PBR model.
  • Textures are embedded in the GLB or referenced as external files; external references must be relative paths within the asset package.
  • Color customization is supported via a baseColorFactor tint applied at runtime (multiplied over the albedo). Assets should use a neutral albedo (white/gray) where tinting is intended.
  • Masked/transparent materials must set the correct alpha mode (MASK or BLEND) and alphaCutoff.

4.5 Skeleton Merging at Runtime

When an attachment is loaded, the runtime merges it onto the base body:

  1. Load the base body (skeleton + base meshes).
  2. Load each equipped attachment.
  3. For each attachment, re-target its skin to the base body's skeleton by bone name (no re-export needed).
  4. Parent static accessories to their mount_bone.
  5. Hide base meshes for any equipped body-part slots.
  6. Apply material tints and merge into the render tree.

The merge is name-based — bone names must match exactly (§3.1). This is the core reason for the strict naming contract.


5. Loadout Model (Network & Persistence)

The avatar's composition is serialized as a loadout — a compact, ordered list of equipped asset IDs. This is what the server validates, persists, and replicates.

5.1 Loadout JSON

{
  "base_body": "base_human_male_01",
  "slots": {
    "head":   "head_cyber_01",
    "hair":   "hair_mohawk_01",
    "face":   null,
    "torso":  null,
    "chest":  "chest_bandit_01",
    "back":   "backpack_01",
    "left_arm":  null,
    "right_arm": null,
    "left_hand": "glove_leather_01",
    "right_hand": "glove_leather_01",
    "legs":   null,
    "pants":  "pants_cargo_01",
    "left_foot":  "shoe_boot_01",
    "right_foot": "shoe_boot_01",
    "accessory": ["belt_01", "necklace_01"]
  }
}

Rules:

  • base_body is required and must reference a valid base_body asset.
  • Each exclusive slot holds at most one asset id (or null).
  • accessory is an array (0..N, bounded).
  • The server validates that every id exists in the catalog and that slot exclusivity is respected.

5.2 Network Replication

The loadout is replicated as part of the player's spawn/state data:

spawn_entity:
  entity_id: uint32
  type: player
  transform: { x, y, rot }
  props:
    loadout_hash: uint32      # hash of the loadout for change detection
    loadout: { ... }          # full loadout on spawn / on change
  • The full loadout is sent on spawn and whenever it changes (reliable).
  • A loadout_hash lets clients skip re-resolving assets if unchanged.
  • The server is the authority on the loadout; clients send requests to change equipment, and the server validates and broadcasts the result.

5.3 Server-Side Validation

The server rejects a loadout if:

  • base_body is missing or unknown.
  • Any slot references an unknown asset id.
  • An exclusive slot has more than one item.
  • An asset's declared slot does not match the slot it is placed in.
  • The accessory list exceeds the configured limit.
  • The asset is not owned/unlocked by the player (for gated cosmetics).

6. Godot Runtime Architecture

6.1 Node Structure

Avatar (Node3D)
├── Skeleton3D            # canonical skeleton (from base body)
├── BodyMeshes            # base body meshes (skinned to Skeleton3D)
│   ├── head_Mesh
│   ├── torso_Mesh
│   └── ...
├── Attachments           # equipped attachments
│   ├── chest_shirt_bandit_01
│   ├── hair_mohawk_01
│   └── ...
└── AttachmentPoints      # empty nodes for dynamic mounting
    ├── attach_head
    ├── attach_left_hand
    └── ...

6.2 Core Classes

Class Responsibility
AvatarSystem Loads base body + attachments, resolves loadouts, owns the avatar node.
AvatarLoader glTF/GLB import, skeleton merge, skin re-targeting.
AvatarSlotRegistry Static registry of slot definitions (id, bones, exclusivity).
AvatarLoadout Serializable loadout model + validation.
AvatarCatalog Asset catalog (id → asset metadata + resource path).
AvatarVisualizer Builds the render tree from a loadout.

6.3 Loading Pipeline

loadout JSON
   │
   ▼
AvatarCatalog.resolve(loadout)   # id → asset metadata + GLB path
   │
   ▼
AvatarLoader.load_base_body()    # import GLB, extract skeleton + base meshes
   │
   ▼
for each equipped slot:
   AvatarLoader.load_attachment()# import GLB, re-target skin to skeleton
   │
   ▼
AvatarVisualizer.assemble()      # parent, hide replaced parts, apply tints
   │
   ▼
Avatar node ready for render

6.4 Performance Considerations

  • Shared skeleton: All meshes skin to one Skeleton3D, so skinning is computed once per frame.
  • Material batching: Attachments that share a material are merged into a single mesh where possible (static accessories).
  • LOD: Optional LOD variants per asset; the runtime selects based on distance.
  • Caching: Resolved loadouts and imported GLB resources are cached by AvatarCatalog to avoid re-importing on respawn or re-join.

7. Asset Pipeline & Validation

7.1 Build-Time Validation

A build-time checker validates every asset before it enters the catalog:

  1. Format check: valid glTF 2.0 / GLB.
  2. Metadata check: extras.agp present and valid (§4.2).
  3. Skeleton check: all skin_bones exist in the canonical skeleton.
  4. Slot check: slot is known and exclusive matches.
  5. Mesh check: meshes are skinned (or static accessories parented to a bone).
  6. Texture check: sizes, formats, and alpha modes are valid.
  7. Uniqueness check: id is unique.

7.2 Catalog Manifest

The catalog is a JSON manifest mapping asset ids to metadata and resource paths:

{
  "assets": {
    "base_human_male_01": {
      "type": "base_body",
      "path": "res://assets/avatars/base_human_male_01.glb",
      "version": "1.0.0"
    },
    "chest_bandit_01": {
      "type": "attachment",
      "slot": "chest",
      "path": "res://assets/avatars/chest_bandit_01.glb",
      "version": "1.0.0"
    }
  }
}

8. Security & Anti-Cheat

  • Server-authoritative loadouts: Clients only request equipment changes; the server validates against the catalog and the player's owned items.
  • No client asset injection: Clients cannot send arbitrary GLB data; they reference catalog ids only.
  • Rate limiting: Equipment-change requests are rate-limited per peer.
  • Hash verification: The loadout_hash lets the server detect tampered or stale loadout state.

9. Open Questions / Future Work

  • Morph targets / blendshapes: Support for facial expressions and body morphs (e.g., height, muscle) via glTF morph targets.
  • Physics attachments: Cloth simulation for capes/skirts (Godot cloth).
  • Dynamic tinting UI: A color-picker that writes baseColorFactor tints.
  • VRM compatibility: Consider supporting VRM (a glTF extension for humanoid avatars) as an import source.
  • Attachment LOD streaming: Streaming high-res attachments on demand.

9a. Loadout Persistence (implemented, PC3 Phase 1)

Storage. One row per user in avatar_loadouts (user_id PK/FK, data JSONB, timestamps). The entire loadout is stored as a single JSON document in the exact shape the client's AvatarLoadout.to_json() emits ({"base_body": str, "slots": {...}, "skin_tone"?: {r,g,b,a}}) rather than exploded into columns.

Why one JSON blob, not columns. The server has no copy of the asset catalog (assets/avatars/catalog.json ships with the client). It therefore cannot meaningfully validate which asset belongs in which slot, whether an asset exists, or slot-exclusivity rules — that validation stays on the client (AvatarLoadout.validate() against the loaded AvatarCatalog). The server enforces only the envelope: a JSON object with a non-empty string base_body, an object slots, and a sane size cap. Keeping the schema opaque to the server also means catalog/loadout-shape changes don't need a migration.

Endpoints (/api/avatar, bearer auth):

Method Path Behaviour
GET /loadout The user's saved loadout, or the server default ({"base_body": "base_human_male_01", "slots": {}}, is_default: true) if they have never saved one.
PUT /loadout Replace the user's loadout (full document, not a patch).

Defaults. A brand-new account has no row; GET returns models.avatar_loadout.DEFAULT_LOADOUT with is_default: true so the client can distinguish "never customised" from "customised back to the base look".


10. Summary

Concern Decision
Format glTF 2.0 / GLB (GLB preferred for runtime)
Skeleton Single canonical humanoid skeleton, name-based merge
Slots Fixed slot registry; exclusive body parts + layered attachments
Loadout Compact JSON; server-authoritative and validated
Replication Full loadout on spawn/change + loadout_hash
Runtime Godot Skeleton3D + name-based skin re-targeting
Validation Build-time asset checker + runtime catalog validation