Genji Crowd API

v1
EN 中文

Quick start — two things are all you need:

1. API key: YOUR_API_KEY

2. curl -H "Authorization: Bearer YOUR_API_KEY" https://www.genjicrowd.com/v1/crowd/cr_sample01

Genji Crowd API — v1

Partner integration reference for Tuanjie / Unity native rendering.


Versioning

This document describes API v1. The version is reflected in the URL prefix (/v1/). Breaking changes (removed or renamed fields, changed semantics) will increment the version to /v2/, and v1 will be announced deprecated before removal. Additive changes (new optional response fields) do not change the version.


Overview

The Genji Crowd API lets your engine load a saved crowd recipe — the complete configuration needed to reproduce a crowd authored in the Genji editor — together with pre-resolved per-character part assignments and direct asset URLs.

Typical integration flow:

GET /v1/crowd/{crowd_id}
  → recipe (crowd configuration)
  → characters[] (body / face / hair IDs, positions, animation state)
  → assets (GLB URLs, animation GLB URLs)

Your engine then:

  1. Downloads assets.skeleton_glb once (contains all part meshes on a shared skeleton).
  2. For each entry in characters[], selects the named mesh nodes and applies per-character colour, scale, and animation phase.
  3. Runs your own agent simulation using the recipe's boundary / collision params as inputs.

What Genji gives you, and what you bring

Read this before you build anything — it is the single most common misunderstanding.

So either let characters idle and animate at their spawn positions, or map them onto the walkers your engine already simulates. If you are expecting the API to return a crowd that walks around on its own, it will not, and nothing is broken.


Try it without a key

Copy, paste, run. No account, no API key:

# The recommended demo crowd: 120 characters, tight 14m circle, looks good with no tuning
curl -s https://www.genjicrowd.com/v1/crowd/cr_demo01

# The original partner sample: 150 characters spread over an 80m radius
curl -s https://www.genjicrowd.com/v1/crowd/cr_sample01

# What the API can build — shapes, part catalogue, animation clips
curl -s https://www.genjicrowd.com/v1/capabilities

Start with cr_demo01. Prefer it over cr_sample01 for a first build: same data shape, but dense and framed rather than scattered across 80 metres, so it looks right with no tuning.

Those two crowds are the only ones readable with no key at all. The machine-readable spec at https://www.genjicrowd.com/openapi.json is public too.


The public demo key

Everything beyond those two crowds — listing or creating crowds, and the MCP endpoint — needs a Bearer token. You do not have to sign up to get one:

gk_demo_public_v1

This key is public and safe to embed in browser JavaScript. It is shared by everyone, rate limited per IP, and crowds it creates are deleted after 24 hours. Use it to try Genji with no signup.

Your own account keys look like gk_live_… and are not safe in client-side code — keep those on a server. The demo key is the exception, by design.

Demo key limit Value
Requests / minute / IP 60
Requests / day / IP 5,000
Crowd creations / day / IP 50
Characters per crowd 5,000
Created crowds expire after 24 hours

The canonical example

# List crowds owned by the key
curl -H "Authorization: Bearer gk_demo_public_v1" \
  https://www.genjicrowd.com/v1/projects

# Create a crowd from a recipe
curl -X POST -H "Authorization: Bearer gk_demo_public_v1" \
  -H "Content-Type: application/json" \
  -d '{"name":"My crowd","recipe_json":{"size":200,"seed":1,"boundaryShape":"circle","boundaryRadius":20,"distribution":"grid"}}' \
  https://www.genjicrowd.com/v1/projects

The demo key works on the MCP endpoint too — see MCP server below.


Colour and texture setup

⚠️ If your characters look TOO DARK, it is a colour-space setup issue on your

renderer — NOT the assets. Check `outputColorSpace` and texture `colorSpace` first.

This is the most common integration mistake we see, and it looks exactly like broken assets or bad lighting. It is neither.

What we ship. Character base-colour textures are authored in sRGB. There are no normal maps and no roughness/metalness maps, so there is nothing that could be mis-tagged as colour data — the only textures in the GLBs are base colour.

Why it goes wrong. Three.js works internally in linear space and converts on output. If the renderer is left at its default output colour space, the linear result is written out without conversion and everything renders far too dark. Doubling up the other way — tagging a colour texture as linear — darkens it too.

Required setup (Three.js r152 and newer)

// 1. The renderer must convert linear -> sRGB on output. This one line is the fix
//    for "my characters are too dark" in the overwhelming majority of cases.
renderer.outputColorSpace = THREE.SRGBColorSpace;

// 2. GLTFLoader tags base-colour textures for you, so loading our GLBs needs nothing
//    extra. But ANY colour texture you load yourself must be tagged:
texture.colorSpace = THREE.SRGBColorSpace;
//    Data textures (bone_tex.bin, masks, roughness) must NOT be tagged sRGB — they
//    carry numbers, not colour. Leave those at the default.

Older Three.js (before r152)

The same two settings had different names. If you are pinned to an older build:

renderer.outputEncoding = THREE.sRGBEncoding;   // instead of outputColorSpace
texture.encoding        = THREE.sRGBEncoding;   // instead of texture.colorSpace

Our SDK and every example in these docs assume Three.js r169 (three@^0.169.0), which uses the colorSpace form.

Using the SDK? This is handled for you

/sdk/genji-bbm.js exports configureRenderer(renderer), and buildBbmCrowd(...) applies it automatically when you pass your renderer:

import { configureRenderer } from 'https://www.genjicrowd.com/sdk/genji-bbm.js';
configureRenderer(renderer);   // sets the right field for your Three version

Asset hosting

All GLB and animation files are served from the host that answered your request. Asset URLs are fully resolved and returned in every API response — you never construct them yourself.

Environment Where assets are served from
Production https://www.genjicrowd.com (assets may be offloaded to Cloudflare R2 / CDN)
Local dev The server's own host (e.g. http://localhost:3001)

Never hardcode asset URLs. Always read them from response.assets.*. The server constructs fully-resolved URLs at request time, reflecting wherever it is currently running. Copy-pasting an example URL from this document into your code will point at the wrong host. Use assets.skeleton_glb, assets.animations.walk, etc. directly.


Authentication

Every request must carry a Bearer token in the Authorization header:

Authorization: Bearer gk_live_…

Keys are issued from the Genji dashboard (Settings → API Keys) or by the deployment team. The raw key is shown once on creation and never stored in plaintext.

No key yet? Use the public demo key gk_demo_public_v1 (see The public demo key above) — it works on every /v1 route and on the MCP endpoint, with per-IP rate limits and 24-hour crowd expiry.

Error responses:

Status Body Cause
401 {"error":"Missing Authorization: Bearer <api_key>"} Header absent
401 {"error":"Invalid or revoked api key"} Key wrong or revoked
404 {"error":"Crowd not found"} crowd_id does not exist

Endpoint

`GET /v1/crowd/{crowd_id}`

Returns the full recipe, pre-resolved character list, and asset URLs for one saved crowd.

Path parameter

Param Type Description
crowd_id string Opaque ID, format cr_[a-zA-Z0-9]{8}

Response `200 OK` — `application/json`

{
  "crowd_id":    "cr_sample01",          // string — matches request path
  "name":        "Imperial Crowd — Tuanjie Sample",
  "collection":  "genji-v1",             // string — collection tag (may be "")
  "recipe":      { ... },                // object — see Recipe Schema below
  "characters":  [ ... ],               // array  — see Character Object below
  "shared_parts": ["Eye_L","Eye_R","L_Hand","R_Hand","L_Foot","R_Foot"],
  "assets":      { ... },                // object — see Asset URLs below
  "updated_at":  "2026-06-24 10:00:00"  // string — ISO-ish UTC timestamp
}

Managing crowds (`/v1/projects`)

Same Bearer API key as above. These routes let you list, create, and update saved crowds programmatically — the same projects table the website's dashboard reads and writes, so crowds are interchangeable between the two.

`GET /v1/projects`

Lists the caller's saved crowds. Unmetered.

[
  { "crowd_id": "cr_sample01", "name": "Imperial Crowd", "collection": "genji-v1",
    "thumbnail": null, "created_at": "...", "updated_at": "..." }
]

`POST /v1/projects`

Creates and saves a new crowd from a recipe. Billable (same metering as GET /v1/crowd/{crowd_id} and the website's save action).

// Request body
{ "name": "My Crowd", "recipe_json": { "size": 500, "shape": "average", ... }, "collection": "" }
// Response
{ "crowd_id": "cr_AbCdEf12", "name": "My Crowd" }

`PUT /v1/projects/{crowd_id}`

Updates an existing crowd's recipe/name/collection. Only billed if recipe_json actually changes (a rename alone is free, matching the website).


MCP server (for AI coding assistants)

Genji also exposes a remote MCP server at POST /mcp, so tools like Claude Code, Claude Desktop, or Cursor can list, fetch, and create crowds directly as part of a conversation — "vibe code" a scene by describing it in chat.

Tool Purpose
genji_get_capabilities Valid shapes, bucket ids, and part ids/labels for building a recipe
genji_list_crowds List the caller's saved crowds
genji_get_crowd Fetch a crowd by crowd_id. Takes an optional tier (full / untextured / vat / bbm) that shapes the response — full includes the per-character list, the others omit it since those tiers render from the bone texture data instead. The underlying assets returned are identical for every tier (see "Super-optimized render tiers" below).
genji_create_crowd Create and save a new crowd from a recipe — the core tool for building a scene from a description

All tool calls go through the same quota/tier enforcement as the REST routes above — MCP is another authenticated entry point into the same system, not a separate one.


Recipe Schema

recipe is a plain JSON object with the following fields.

Core

Field Type Description
v integer Recipe version. Currently 1.
seed integer Master RNG seed. Determines all character assignments and initial positions deterministically.
size integer Total number of characters in the crowd.
packs string[] Civilization packs to draw from. Omitted or empty means ["chinese"]. See Civilization packs.
weights object Bucket weight map — { bucketId: number }. Controls the mix of costume archetypes. See Capabilities for bucket IDs.

Walk / animation

Field Type Description
walkSpeedMin float Minimum walk-speed multiplier (relative to clip speed). Default 0.6.
walkSpeedMax float Maximum walk-speed multiplier. Default 1.0.
walkRatio float Legacy field (superseded by pace).
pace float 0–1 Fraction of characters that walk vs. stand idle. 0 = all idle, 1 = all walking. Default 0.5.
crowdRate float Characters per minute entering in flow/road mode.

Layout

Field Type Description
boundaryShape string Crowd boundary shape: "circle", "square", "road", "donut", "spline".
boundaryRadius float Outer boundary radius in metres. For road, this is the half-length.
spawnRadius float Inner spawn radius in metres. For road, this is the half-width.
splinePoints [[x,z], …] | null Control points for spline shape (world-space XZ). null for other shapes.
facing float 0–1 0 = characters face random directions, 1 = all face the same direction.
distribution string "random" or "grid". Controls how positions are distributed inside the boundary.

Terrain

Field Type Description
terrain.active boolean true = procedural terrain active.
terrain.amplitude float Terrain height amplitude in metres.
terrain.frequency float Terrain noise frequency.
terrain.noiseType string "fractal" or "simplex".

Colour variation

All garment colours are bucket-specific HSL values jittered per character using these ranges.

Field Type Description
hueShift float Global hue rotation applied to all characters' garments.
hueVar float Per-character hue jitter range ±hueVar.
satVar float Per-character saturation jitter range ±satVar.
lightVar float Per-character lightness jitter range ±lightVar.

Collision / avoidance

These parameters feed your own agent simulation engine.

Field Type Description
collisionRadius float NPC body radius multiplier (× character height). Two characters are "touching" when their distance = 2 × radius × height.
personalSpace float Centering / path-following force strength (01).
anticipation float 0–1 How early NPCs start steering away from collisions. 0 = react only when touching, 1 = steer early.

Obstacles

"obstacles": [
  { "type": "rock", "x": 20.0, "z": 15.0, "radius": 3.5 },
  ...
]

Each obstacle is a cylinder (infinite Y) in world-space XZ. Characters avoid a disc of radius metres centred at (x, z).

Effectors

Field Type Description
groupingMode boolean When true, characters cluster into groups.
attractActive boolean Whether the global attractor is active.
attractTarget {x, z} World-space XZ position of the attractor.

Render flags

These are viewport toggles from the authoring tool. Use them as hints for your renderer.

Field Type Description
textures boolean Author had textures on when saving.
collisionEnabled boolean NPC–NPC avoidance enabled.
jogBlendEnabled boolean Walk→jog blend enabled.
lazyGPU boolean Internal GPU update optimisation flag.
collection string Collection tag (e.g. "genji-v1").

Civilization packs

Characters come from 13 packs. Choose them with packs on the recipe:

"recipe": { "size": 200, "seed": 1, "packs": ["roman", "norse"] }
chinese african arabian aztec
egyptian european greek indian
japanese norse persian roman
western

Omitting packs gives you Chinese only. That default is deliberate: it keeps every crowd created before packs existed byte-for-byte reproducible. It also means a client that never sets the field silently builds a Chinese crowd regardless of intent.

Buckets are filtered by the active packs before weights are applied, so a weight on a Roman bucket does nothing unless "roman" is in packs. An unknown pack id is rejected at create time with 400 and the list of valid ids.

GET /v1/capabilities returns every bucket and part tagged with its pack, so you can build the mapping yourself.

Loading pack meshes

Chinese bodies, all faces, all hair and the six shared skin parts are inside skeleton_glb. Every other pack ships one GLB, listed in assets.pack_glbs — and only the packs that crowd actually uses appear there:

"assets": {
  "skeleton_glb": "{base}/assets/Basemesh31.glb",
  "pack_glbs":    { "roman": "{base}/assets/packs/roman.glb" }
}

Each character carries a pack; use it to pick the file its body_id is in.

Every pack is bound to the same 72-bone skeleton, in the same bone order. So bone_tex_bin, bone_tex_manifest and every animation clip apply to pack meshes unchanged — GPU instancing, VAT and bone-cube paths need no modification to support packs. Adding a pack costs you one more GLB fetch and nothing else.

Pack GLBs are roughly 7-11 MB each. The editor caps concurrent packs at 3 for VRAM reasons; the API does not cap them, so requesting all 13 is possible but means ~96 MB of mesh data.

Faces and hair are currently Chinese for every pack. Only bodies and outfits are pack-specific today. A Roman legionary renders with a Chinese face until the face catalogue is expanded.

Character Object

Each entry in characters[] is one NPC with pre-resolved part IDs.

{
  "index":        0,                              // integer — 0-based character index
  "bucket":       "soldiers",                     // string  — archetype bucket ID
  "pack":         "chinese",                      // string  — which pack holds body_id (see Civilization packs)
  "body_id":      "M_Terracotta_Infantry_Soldier",// string  — SkinnedMesh node name in skeleton_glb
  "face_id":      "M_Chinese_Face_03",            // string  — SkinnedMesh node name in skeleton_glb
  "hair_id":      "Ming_Warrior_Crown",           // string  — SkinnedMesh node name in skeleton_glb
  "x":            12.3400,                        // float   — initial X position (metres, world space)
  "z":            -5.7100,                        // float   — initial Z position (metres, world space)
  "rot_y":        1.2340,                         // float   — initial Y-axis rotation (radians) — spawn facing, NOT a movement vector (see below)
  "scale":        1.0230,                         // float   — uniform scale multiplier
  "phase":        0.6710,                         // float 0–1 — animation phase offset (avoids lockstep)
  "anim_clip":    "walk",                         // string  — "idle" or "walk" at spawn
  "idle_variant": 2,                              // integer 0–7 — which idle animation variant (when anim_clip="idle")
  "garment_hsl":  [0.0521, 0.5182, 0.4830],      // [H, S, L] normalised 0–1 — per-character garment tint
  "skin_hex":     "#ddb892",                      // string  — skin colour hex
  "hair_hex":     "#1a1410"                       // string  — hair colour hex
}

Shared parts

In addition to body_id, face_id, and hair_id, every character also renders the six shared mesh nodes (eyes, hands, feet). These are the same for all characters and listed in the top-level shared_parts array:

["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"]

Coordinate system

All positions, distances, and rotations in the API response use the following convention:

Property Value
Handedness Right-handed
Up axis Y
Units Metres
Ground plane XZ (Y = terrain height, 0 on flat ground)
rot_y = 0 Character faces +Z
Positive rot_y Counter-clockwise rotation viewed from above (+Y)

Character positions are given as (x, z) pairs (Y is always ground height). Place the character at world position (x, groundHeight, z) and apply a Y-axis rotation of rot_y radians.

The direction vector for a character's facing direction is:

forward_x = sin(rot_y)
forward_z = cos(rot_y)

This matches the GLB assets: skeleton_glb and all animation GLBs are exported in glTF convention (right-handed, Y-up). No axis-swap is needed when loading them.

Everything is metres, out of the box. The x/z values in the API response (character positions) are in metres, and the GLB scene loads in metres too: the source skeleton is Maya centimetres, but the export bakes a 0.01 scale into the GLB's root node. Do not apply any unit conversion — a loaded character stands ~1.7 m tall as-is. Sanity check after loading: ~1.7 tall = correct; ~170 = your importer stripped the root-node scale (apply 0.01 at the root yourself); ~0.017 = you applied an extra 0.01 that must be removed.

Unity / left-handed engines: Unity uses a left-handed Y-up system. To convert: negate zposition.z = -C.z, and negate rot_yrotation.y = -C.rot_y. Also negate forward_z when converting the direction vector.

rot_y is a spawn-time facing snapshot, not a movement vector. It is generated once per character based on the recipe's spawn shape (e.g. for a circle boundary it is effectively random). If you drive your own movement (custom paths, a street the character walks down, steering/avoidance) — compute rotation from your own velocity every frame, don't reuse the API's static rot_y for a character you are moving yourself: rot_y = atan2(velocity.x, velocity.z). Only use the API's rot_y as-is for idle characters (never move) or if you are reproducing the exact spawn layout the recipe describes without adding custom movement.


Asset URLs

// ⚠️  These URLs are returned by the server — never constructed by the client.
// The host changes per environment (local dev / production CDN).
// Always use the value from the response; never hardcode the base URL.
"assets": {
  "unit":            "m",
  "unit_scale":      1.0,
  "skeleton_glb":    "{base}/assets/Basemesh31.glb",
  "lod_glb":         "{base}/assets/Basemesh31_LOD1.glb",
  // Only the packs THIS crowd uses. Absent entirely for a Chinese-only crowd,
  // since Chinese meshes ship inside skeleton_glb.
  "pack_glbs":       { "roman": "{base}/assets/packs/roman.glb" },
  "capabilities_json": "{base}/v1/capabilities",
  "animations": {
    "idle":              "{base}/assets/A_Idle.glb",
    "idle_arms_crossed": "{base}/assets/A_Idle_ArmsCrossed.glb",
    "idle_hands_hip":    "{base}/assets/A_Idle_HandsHip.glb",
    "idle_clasp":        "{base}/assets/A_Idle_Clasp.glb",
    "idle_shift":        "{base}/assets/A_Idle_Shift.glb",
    "idle_look_around":  "{base}/assets/A_Idle_LookAround.glb",
    "idle_scratch":      "{base}/assets/A_Idle_Scratch.glb",
    "idle_tired":        "{base}/assets/A_Idle_Tired.glb",
    "walk":              "{base}/assets/A_WalkFwd.glb",
    "jog":               "{base}/assets/A_JogFwd.glb",
    "walk_stop":         "{base}/assets/A_WalkFwd_Stop.glb"
  }
}

The GLB scene loads in metres as-is. The source skeleton is Maya centimetres, but the export bakes a 0.01 scale into the GLB's root node, so no conversion is needed — unit_scale is 1.0 and exists only so SDKs read the value instead of assuming:

gltf.scene.scale.setScalar(assets.unit_scale); // 1.0 — a no-op, but future-proof

Sanity check after loading: measure the height of a visible character.

Never adjust position/Y to compensate for scale problems. The skeleton root is authored at the character's feet (local origin = ground contact point), so placing the root at world (x, 0, z) needs no additional Y offset. If characters float above or sink below the ground, the root transform got lost in cloning/re-parenting — re-check the clone order instead of adding a foot-height offset.

How part IDs map to assets

You load one GLB per pack — never one per character.

skeleton_glb holds every Chinese body, every face, every hair and the six shared skin parts. Each additional pack ships one GLB of its own, listed in assets.pack_glbs.

Each part ID (e.g. M_Terracotta_Infantry_Soldier) is the name of a SkinnedMesh node inside one of those files. Which file:

Field Where the node lives
body_id skeleton_glb when pack is chinese, otherwise pack_glbs[character.pack]
face_id, hair_id always skeleton_glb
shared_parts always skeleton_glb

To render a character:

  1. Load skeleton_glb once, plus each GLB in pack_glbs once.
  2. For each character in characters[], make the following mesh nodes visible:
    • body_id node
    • face_id node
    • hair_id node
    • All 6 shared_parts nodes (eyes, hands, feet)
    • All other mesh nodes: hidden
  3. Instance or clone the visible set, applying the character's scale, rot_y, and (x, z) position.
  4. Apply per-character colour (see Colour application below).
  5. Play the animation clip named by anim_clip, offset by phase × clip_duration.

lod_glb is a second, independent GLB — same node names, same shared skeleton, same scale/origin behaviour as skeleton_glb, just lower-polygon meshes (~40% the file size). It is not a mesh variant inside skeleton_glb; load it separately and clone from it the same way. For a crowd of this scale, a reasonable default is: full-res skeleton_glb inside ~15–20 m of the camera, lod_glb beyond that.

You do not need two live characters per NPC. Keep exactly one active clone per character at a time — swapping is cheap: when a character crosses the LOD distance threshold, replace its clone (hi-res ↔ lod), but carry over the current clip and playback time onto the new clone's mixer (newAction.time = oldAction.time) instead of restarting at 0. That's the only thing "don't hot-swap mid-animation" means — preserve timing across the swap. It does not mean run both resolutions simultaneously; doing that doubles memory and animation cost for every character, all the time, for a distance check that only a fraction of characters need at once.

capabilities_json (GET /v1/capabilities) lists the available shapes, the bucket ids/labels you can weight, and every part's id, label, group, and gender. Useful for building a UI or validation tool.

Colour application

Each character has three colour inputs:

Input Source Apply to
garment_hsl Per-character HSL (0–1 normalised) Body mesh — set directly: material.color.setHSL(h, s, l)
skin_hex Hex string Face + shared skin meshes
hair_hex Hex string Hair mesh

garment_hsl is the complete, final garment colour, computed server-side. Apply it directly with material.color.setHSL(h, s, l) — do not derive or composite it from any other source.


GPU-instanced rendering (bone texture)

The default rendering path described above (SkeletonUtils.clone() + one AnimationMixer per character) is simple but does not scale — expect it to fall below interactive framerates somewhere in the 150–300 character range depending on hardware (confirmed in practice: 17–24 fps at 150–5000 individually-cloned characters).

For crowds of 1000+ characters, use assets.bone_tex_bin + assets.bone_tex_manifest instead: a single shared GPU texture encoding every animation clip's bone matrices, sampled per-vertex in a custom skinning shader, rendered via one THREE.InstancedMesh per body/face/hair part (not per character) — typically ≤36 draw calls for the whole crowd, regardless of size. This is the same technique the Genji crowd-authoring tool itself uses internally to render thousands of characters at full framerate.

"assets": {
  "bone_tex_bin":      "{base}/assets/bone_tex.bin",
  "bone_tex_manifest":  "{base}/assets/bone_tex_manifest.json"
}

A ready-to-use reference implementation ships at /sdk/genji-crowd-instanced.js (self-contained ES module, only peer dependency is three), with a live example at /sdk/genji-crowd-instanced-demo.html (append ?size=2000 to control crowd size).

How it works

bone_tex_bin is a raw Float32Array binary (no header, no compression) laid out as an RGBA32F texture: width = bone_tex_manifest.texWidth, height = bone_tex_manifest.texHeight. Each animation clip occupies a contiguous range of texture columns (one column per baked frame, 30 fps); each bone occupies 4 texture rows (one mat4, stored as 4 vec4 rows). Your shader samples this texture per-vertex, blends the 4 influencing bones per skinWeight, and reconstructs the same skinned position SkeletonUtils.clone() would have computed on the CPU — just done once on the GPU for every instance in a part, instead of once per cloned character on the CPU.

You do not need to understand this to use it — loadBoneTexture() in the reference SDK builds the THREE.DataTexture for you, and the shipped shader does the sampling.

bone_tex_manifest fields

Field Type Meaning
texWidth / texHeight int Dimensions of bone_tex_bin's RGBA32F texture.
nBones int Bone count (matches skeleton_glb's skeleton).
bakeFps int Frame rate the animation was baked at (30).
format string Always "RGBA32F" — 4 floats per texel, no compression.
totalBakedFrames int Total frame count across all baked clips.
numIdle int Count of idle variants (indices 0..numIdle-1, matches idle_variant).
clipWalk int Clip index to use for anim_clip === "walk".
clipInfo object Per-clip {start, frames, duration}, keyed by clip name.
modelScale number Informational — already baked into the texture; do not reapply.

Minimal integration

import { loadParts, loadBoneTexture, buildCrowdInstances, updateCrowdTime }
  from '/sdk/genji-crowd-instanced.js';

const parts   = await loadParts(response.assets.skeleton_glb);
const boneTex = await loadBoneTexture(response.assets.bone_tex_bin, response.assets.bone_tex_manifest);
const crowd   = buildCrowdInstances(scene, parts, boneTex, response.characters, response.shared_parts);

function animate(dt) {
  updateCrowdTime(crowd, dt);
  renderer.render(scene, camera);
}

Scope: no per-character speed variation

This rendering path implements exactly the public Character schema (anim_clip/idle_variant/phase/garment_hsl/skin_hex/hair_hex/x/z/rot_y/scale) — there is no speed or momentum field in the API, so walk always plays at its authored clip speed. Characters vary only by phase (stagger), not cadence. This is a known, accepted trade-off for crowd-scale rendering, not a bug: a wall of 2000 walkers reads slightly more uniform than a hand-tuned simulation with per-NPC speed variation.

Common integration failures (bone texture path)

Symptom Cause Fix
All characters share one pose / T-pose Rendered before loadBoneTexture() resolved await it before calling buildCrowdInstances
Characters never animate (frozen) uTime uniform never advanced Call updateCrowdTime every frame
Every character plays the same clip/pose Per-instance aClip/aPhase not written Use writeCrowdInstances — don't hand-roll instance buffer writes
Jog/run animation requested but not playing Not available — the public API only exposes idle/walk N/A — jog is internal-only, not part of this API
More than ~36 draw calls Building one InstancedMesh per character instead of per part One mesh per unique body_id/face_id/hair_id/shared-part value, not per character

Super-optimized render tiers (untextured / VAT / BBM)

The bone-texture path above already renders the whole crowd in ~36 draw calls at full framerate. For extreme scale (tens of thousands of characters) or memory / GPU-bound targets (mobile, VR, integrated GPUs), three further tiers trade visual fidelity for raw throughput. These are the same optimizations the Genji tool uses internally.

All three consume exactly the data the API already returnsassets.skeleton_glb and assets.bone_tex_bin + assets.bone_tex_manifest. There are no extra endpoints and no extra downloads; each tier is a rendering choice you make client-side. They can also be mixed as LOD bands (full mesh near the camera, BBM far), and because every tier reads the same bone texture, a character's animation stays perfectly in sync across a tier swap.

At a glance

Tier Draw calls Per-vertex work Extra VRAM Use when
Full skinned (bone tex) ~36 4-bone fetch + weighted blend part textures default; near camera
Untextured ~36 4-bone blend, no map fetch none (frees albedo) memory-bound; stylized; far band
VAT ~36 2–8 fetches, no blend large (~2.4 GB full cast) ALU / skin-blend bound
BBM cube proxy 1 1 fetch + 1 matrix multiply none extreme scale; far LOD

1. Untextured (flat) mode

Skip loading the part albedo/normal/roughness maps entirely and render each part as a flat lit colour (white, or a per-part solid tone). This frees all texture VRAM and removes every texture fetch from the fragment shader — the cheapest change here, and a good default for a distant LOD band or a memory-limited device.

2. VAT — Vertex Animation Texture

Pre-bake the skinned vertex positions (the exact result the bone-texture shader computes) into a texture indexed by [absoluteFrame, vertexID]. At runtime the vertex shader does a plain lookup and lerps between two frames instead of blending four bone matrices:

// f0,f1 = the two frame columns bracketing the current time; fr = blend
vec3 pos = mix( texelFetch(uVAT, ivec2(f0, gl_VertexID), 0).xyz,
                texelFetch(uVAT, ivec2(f1, gl_VertexID), 0).xyz, fr );

3. BBM — bone-cube proxy (extreme-scale LOD)

Replace each character's skinned mesh with ~17 rigid primitives — one box/cylinder per bone (limb cylinders, two torso blocks, a head, hands) — each bolted to a single bone. No 4-bone blend: one bone-matrix fetch per vertex. The whole crowd merges into one instanced mesh → 1 draw call, with the cheapest animated vertex shader of any tier.

Build it once from the skeleton bind pose (skeleton_glb): for each limb bone, a cylinder spanning that bone to its child; torso and head fitted from the hip/shoulder/head bone positions. Author each primitive in its bone's local bind space, then tag every one of its vertices with that single bone's index (aBone).

Runtime: fetch that one bone's animated matrix from the same bone texture and transform the vertex rigidly — a parent constraint, not skinning:

mat4 b = boneMatrix(aBone);        // sampled from bone_tex (the shared texture)
vec3 world = (b * vec4(pos, 1.0)).xyz * UNIT_SCALE;   // see the gotcha below

Critical ordering gotcha (learned the hard way). The bone texture bakes the cm→m 0.01 model scale into every matrix (bone_tex_manifest.modelScale), and the cube geometry is authored in metres, so you compensate with a ×100. Apply the ×100 to the transformed point, not to the input: (b * p) * 100, never b * (p * 100). A uniform scale commutes with a matrix's rotation but not its translation — put it on the wrong side and the rotation still looks right but each bone's pose offset is crushed to 1%, so limbs detach and float for any bone far from bind pose (mid-stride calves, swinging arms). Everything looks fine in a bind-pose T-pose and only breaks once animated, which makes this bug easy to ship and hard to spot.


Animation clips

There are 8 idle variants (index 0–7, anim_clip = "idle") and 1 walk clip (anim_clip = "walk"). Use idle_variant to select which idle GLB to play.

anim_clip idle_variant GLB key
"idle" 0 animations.idle
"idle" 1 animations.idle_arms_crossed
"idle" 2 animations.idle_hands_hip
"idle" 3 animations.idle_clasp
"idle" 4 animations.idle_shift
"idle" 5 animations.idle_look_around
"idle" 6 animations.idle_scratch
"idle" 7 animations.idle_tired
"walk" (any) animations.walk

All clips share the same skeleton from skeleton_glb. Load and retarget once.

⚠️ Apply ROTATION tracks only — drop every translation track

The clips are authored for the rest_reference_glb skeleton and carry translation tracks as well as rotations (the walk clip has 60 translation channels, including root, pelvis, and the whole spine). The Genji skeleton in skeleton_glb uses the same bone names but a different bind pose, so those translations do not transfer.

Apply them and your characters sink through the floor the moment the animation starts — the pelvis gets dragged to the reference skeleton's hip height. The rest pose (A-pose) looks correct right up until you play a clip, which makes this easy to misdiagnose as a scale or ground-offset problem. It is neither.

Do not try to fix it with a Y offset. The pelvis translation is re-applied every frame, so a static offset fights an animated value and you get characters that float, detach, or sink by different amounts per frame.

The fix is to keep only rotation tracks. Bone positions come from the Genji skeleton's own bind pose; the clip supplies rotations:

// three.js — the entire fix
const tracks = clip.tracks.filter((t) => t.name.endsWith('.quaternion'));

Then retarget each surviving track with the bone quaternion delta below. The reference implementation in /demo.html does exactly this.

Expected consequence: dropping the root track means walk clips play in place. That is intentional — Genji gives you the crowd's composition and positions, and your engine owns the movement. Drive each character's position yourself (see recipe's boundary/collision fields) and play the walk cycle in place.

animations.walk has baked root-motion translation on the root bone, moving strictly along local +Z (verified: root translation goes from [0,0,0] to [0,0,1.7] over the clip) — the same direction a character with rot_y = 0 faces. If a character appears to walk backwards, the clip is not the cause — the bug is in your own position/rotation code (a swapped atan2 argument order, or an inverted sign when deriving rot_y from a movement direction). Before patching rotation with a conditional + Math.PI flip, re-derive rot_y directly from your velocity vector using rot_y = atan2(velocity.x, velocity.z) and remove any per-branch sign patch — a conditional flip on top of a wrong formula usually looks "half fixed" rather than fully fixed, because it's compensating for the wrong bug.

The phase field (0–1) is the normalised time offset into the clip at spawn time:

startTime = phase × clipDuration   // in seconds

This prevents the whole crowd from being in lockstep.


Capabilities

GET /v1/capabilities (also linked from each crowd response as assets.capabilities_json) is the catalogue you build requests and UIs from. It lists the available boundary shapes, the bucket ids/labels you can reference in weights, and every part's id, label, group, and gender. Structure:

{
  "shapes": ["circle", "square", "road", "donut", "spline", "polygon"],
  "buckets": [
    { "id": "soldiers", "label": "Soldiers" },
    { "id": "laborers", "label": "Laborers" }
    // ... one entry per archetype you can weight
  ],
  "parts": [
    { "id": "M_Terracotta_Infantry_Soldier", "label": "Terracotta Infantry Soldier", "group": "body", "gender": "m" },
    { "id": "M_Chinese_Face_01",             "label": "Male Face 01",                 "group": "face", "gender": "m" },
    { "id": "Ming_Warrior_Crown",            "label": "Ming Warrior Crown",           "group": "hair", "gender": "m" }
    // ... all body, face, and hair parts
  ]
}

Worked Example

Request

cURL

curl -s \
  -H "Authorization: Bearer YOUR_API_KEY" \
  https://www.genjicrowd.com/v1/crowd/cr_sample01

JavaScript

const res = await fetch('https://www.genjicrowd.com/v1/crowd/cr_sample01', {
  headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.json()).error}`);

const crowd = await res.json();
// crowd.assets.*     — fully-resolved GLB URLs; pass directly to your loader
// crowd.characters[] — per-NPC part IDs, position, animation clip, colour
console.log(crowd.characters.length + ' characters');
console.log('Skeleton GLB:', crowd.assets.skeleton_glb);

Unity (C#)

using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class GenjICrowdLoader : MonoBehaviour
{
    [SerializeField] string apiHost = "https://www.genjicrowd.com";
    [SerializeField] string crowdId = "cr_sample01";
    [SerializeField] string apiKey  = "YOUR_API_KEY";

    void Start() => StartCoroutine(LoadCrowd());

    IEnumerator LoadCrowd()
    {
        using var req = UnityWebRequest.Get($"{apiHost}/v1/crowd/{crowdId}");
        req.SetRequestHeader("Authorization", $"Bearer {apiKey}");
        yield return req.SendWebRequest();

        if (req.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError($"[Genji] Crowd fetch failed: {req.error}");
            yield break;
        }

        // crowd.assets.skeleton_glb  — load with GLTFast / Piglet / custom GLB loader
        // crowd.characters[]         — per-NPC part IDs, position, animation, colour
        string json = req.downloadHandler.text;
        Debug.Log($"[Genji] Response: {json.Length} bytes");

        // Next step: parse json into a CrowdResponse class and download assets
    }
}

Response `200 OK` (abbreviated — full response has 150 characters)

{
  "crowd_id": "cr_sample01",
  "name": "Imperial Crowd — Tuanjie Sample",
  "collection": "genji-v1",
  "recipe": {
    "v": 1,
    "seed": 42,
    "size": 150,
    "weights": {
      "laborers": 40,
      "merchants": 10,
      "soldiers": 35,
      "court_officials": 5,
      "emperor": 0,
      "monks": 5,
      "sages": 5,
      "swordsmen": 0
    },
    "walkSpeedMin": 0.6,
    "walkSpeedMax": 1.0,
    "walkRatio": 0.65,
    "pace": 0.5,
    "crowdRate": 30,
    "scaleVariation": 0.06,
    "boundaryShape": "circle",
    "boundaryRadius": 80,
    "spawnRadius": 80,
    "splinePoints": null,
    "facing": 0,
    "distribution": "random",
    "terrain": { "active": false, "amplitude": 2.0, "frequency": 0.08, "noiseType": "fractal" },
    "hueShift": 0.0,
    "hueVar": 0.035,
    "satVar": 0.18,
    "lightVar": 0.12,
    "collisionRadius": 0.4,
    "personalSpace": 0.8,
    "anticipation": 0.3,
    "obstacles": [
      { "type": "rock", "x": 20, "z": 15, "radius": 3.5 },
      { "type": "rock", "x": -25, "z": 30, "radius": 2.8 }
    ],
    "groupingMode": false,
    "attractActive": false,
    "attractTarget": { "x": 0, "z": 0 },
    "textures": true,
    "collisionEnabled": true,
    "jogBlendEnabled": true,
    "lazyGPU": false,
    "collection": "genji-v1"
  },
  "characters": [
    {
      "index": 0,
      "bucket": "soldiers",
      "body_id": "M_Terracotta_Infantry_Soldier",
      "face_id": "M_Chinese_Face_02",
      "hair_id": "Ming_Warrior_Crown",
      "x": 14.2317,
      "z": -8.5901,
      "rot_y": 3.9821,
      "scale": 1.0184,
      "phase": 0.7341,
      "anim_clip": "walk",
      "idle_variant": 5,
      "garment_hsl": [0.0181, 0.5442, 0.4716],
      "skin_hex": "#e8c8a0",
      "hair_hex": "#1a1410"
    },
    {
      "index": 1,
      "bucket": "laborers",
      "body_id": "Conscript_Laborer_Plain_Belted_Tunic",
      "face_id": "M_Chinese_Face_04",
      "hair_id": "Bamboo_Hat",
      "x": -22.1083,
      "z": 11.4452,
      "rot_y": 1.2076,
      "scale": 0.9812,
      "phase": 0.3295,
      "anim_clip": "idle",
      "idle_variant": 2,
      "garment_hsl": [0.0049, 0.5214, 0.5123],
      "skin_hex": "#ddb892",
      "hair_hex": "#251c14"
    }
    // … 148 more characters
  ],
  "shared_parts": ["Eye_L", "Eye_R", "L_Hand", "R_Hand", "L_Foot", "R_Foot"],
  // asset URLs reflect wherever the server is running — read them from the response
  "assets": {
    "skeleton_glb":    "https://www.genjicrowd.com/assets/Basemesh31.glb",
    "lod_glb":         "https://www.genjicrowd.com/assets/Basemesh31_LOD1.glb",
    "capabilities_json": "https://www.genjicrowd.com/v1/capabilities",
    "animations": {
      "idle":              "https://www.genjicrowd.com/assets/A_Idle.glb",
      "idle_arms_crossed": "https://www.genjicrowd.com/assets/A_Idle_ArmsCrossed.glb",
      "idle_hands_hip":    "https://www.genjicrowd.com/assets/A_Idle_HandsHip.glb",
      "idle_clasp":        "https://www.genjicrowd.com/assets/A_Idle_Clasp.glb",
      "idle_shift":        "https://www.genjicrowd.com/assets/A_Idle_Shift.glb",
      "idle_look_around":  "https://www.genjicrowd.com/assets/A_Idle_LookAround.glb",
      "idle_scratch":      "https://www.genjicrowd.com/assets/A_Idle_Scratch.glb",
      "idle_tired":        "https://www.genjicrowd.com/assets/A_Idle_Tired.glb",
      "walk":              "https://www.genjicrowd.com/assets/A_WalkFwd.glb",
      "jog":               "https://www.genjicrowd.com/assets/A_JogFwd.glb",
      "walk_stop":         "https://www.genjicrowd.com/assets/A_WalkFwd_Stop.glb"
    }
  },
  "updated_at": "2026-06-24 10:00:00"
}

How to render this in your engine

1.  GET /v1/crowd/{crowd_id}                  → get response R

2.  Download R.assets.skeleton_glb once.
    It contains one SkinnedMesh node per part, all bound to a single shared skeleton.
    Sanity check: a loaded character stands ~1.7 m tall. Apply NO unit conversion
    (see "Coordinate system" above).

3.  For each character C in R.characters[]:

    a.  Clone the WHOLE loaded scene with a skeleton-aware clone
        (three.js: THREE.SkeletonUtils.clone(gltf.scene)).
        A plain .clone(true) leaves clones bound to the original skeleton —
        they will all deform together or render in the wrong place.

    b.  Select mesh nodes: C.body_id, C.face_id, C.hair_id
        + all nodes in R.shared_parts
        Hide all other SkinnedMesh nodes.

    c.  Place the character — transforms go on the CLONE WRAPPER ONLY:
        position  = (C.x, 0, C.z)   ← Y from your terrain height at (x,z)
        rotation  = Quaternion.fromAxisAngle(Y, C.rot_y)
        scale     = Vector3(C.scale, C.scale, C.scale)   ← size variation, ~0.95–1.05

        Skinned meshes follow BONES, not their own node transform. Setting
        position/scale on an individual SkinnedMesh does nothing. Transform
        the cloned scene wrapper (the object the clone call returned), and
        NEVER touch the inner node named "root" — it carries the baked
        0.01 cm→m scale; overwriting it corrupts the character.

    d.  Apply colour (clone materials first so characters don't share tints):
        body mesh     → C.garment_hsl is the COMPLETE colour:
                        material.color.setHSL(h, s, l). No base-tint lookup.
        face + hands + feet → C.skin_hex
        hair mesh     → C.hair_hex

    e.  Pick animation:
        if C.anim_clip == "idle":
            clip = idle_animation[C.idle_variant]   ← see animation table
        else:
            clip = walk_animation
        Clips are authored for R.assets.rest_reference_glb — retarget to the
        skeleton before playing (see "Animation clips").
        startTime = C.phase × clip.duration

    f.  Run your simulation:
        Use R.recipe as configuration inputs for your agent system:
          - boundary shape/radius  → confinement area
          - collisionRadius        → NPC body size for avoidance
          - personalSpace          → centering / path-following strength
          - anticipation           → early-steer distance
          - obstacles[]            → static avoidance discs
          - pace                   → fraction of walkers vs. idle standers
          - walkSpeedMin/Max       → per-NPC speed range (× clip speed)

Common integration failures (check here before debugging)

Symptom Cause Fix
Character ~100x too small / invisible You applied a 0.01 unit conversion Remove it — the GLB is already metres
Character ~100x too large Your importer stripped the GLB root-node scale Re-apply 0.01 at the root, once
Character below floor / ignores position Transform was set on a SkinnedMesh node Transform the clone wrapper instead
All clones deform together / render at origin Plain .clone(true) instead of skeleton-aware clone Use SkeletonUtils.clone
Body and hat/hair at different scales Inner root node's scale was overwritten Never modify the inner root node
Only head/hat visible, no body Same as above, or body node hidden by mistake Check the root node scale and visibility list
All garments the same colour A shared material instance was tinted Clone the material per character before setHSL
Wrong garment colours garment_hsl treated as a shift over a base tint It is the complete colour — apply directly
Characters float or sink Y-offset added to compensate for a scale bug Remove the offset; fix the root transform instead

Payload scale

The v1 response includes the complete characters[] array with one entry per NPC. Payload size scales linearly with crowd size:

recipe.size Approximate response size
150 (sample) ~55 KB
1 000 ~360 KB
5 000 ~1.8 MB
10 000 ~3.6 MB

For crowds up to a few thousand characters this is fine over a LAN or fast connection. For very large crowds (10 000+), downloading and parsing the full list can add noticeable latency on first load.

Determinism

The same seed and the same request parameters always return the same crowd — the same characters, positions, and colours, in the same order. You can rely on this for caching and reproducibility: cache a response by its crowd_id + seed (+ size) and re-request it freely. Generation runs server-side; you consume the resolved characters[] array and render it directly.


Authentication

Every /v1 request requires your API key as a Bearer token:

Authorization: Bearer YOUR_API_KEY

Create and manage keys from your Genji Account → Keys page. Keep them secret — never commit them or embed them in client-side code.


Running locally

# 1. Start the API server
npm run server

# 2. Seed sample data (run once)
node --env-file=.env server/seed-api.js

# 3. Test — the sample crowd is keyless, so no Authorization header is needed
curl -s http://localhost:3001/v1/crowd/cr_sample01

This section is local development only. localhost and ngrok URLs never appear in production responses — the live API is https://www.genjicrowd.com.

To expose your local server for remote testing (optional, do not run automatically):

ngrok http 3001
# ngrok prints a temporary tunnel URL like https://<random>.ngrok.app — use that as
# your base URL, and set ASSET_BASE_URL to the same value before starting the server.

Unity / Tuanjie

There are two layers of control.

Layer 1 — Design time (before loading)

The crowd is authored in the Genji editor and saved as a recipe. Your engineering team calls the API, receives the recipe and character list, then builds the crowd in Unity / Tuanjie. Any change to shape, colour, movement ratio, or obstacles means editing the recipe in Genji and re-fetching.

Cloning characters — use SkeletonUtils, not scene.clone()

All characters share one GLB. To place multiple characters in your scene you must clone the loaded scene graph for each one. Do not use scene.clone(true) — it copies nodes but leaves cloned SkinnedMesh instances still bound to the original skeleton's bones, so all copies deform together or render invisible.

Use THREE.SkeletonUtils.clone() (Three.js) or your engine's equivalent deep-clone that rebinds skeleton bones per clone:

import { clone } from 'three/examples/jsm/utils/SkeletonUtils.js';

// gltf = the result of GLTFLoader.load(assets.skeleton_glb, ...)
for (const C of characters) {
  const root = clone(gltf.scene);   // correct — rebinds bones per clone
  // hide all meshes, then show C.body_id, C.face_id, C.hair_id + shared_parts
  root.position.set(C.x, 0, C.z);
  scene.add(root);
}

Layer 2 — Runtime (after loading)

The API provides the starting state. At runtime, your Unity / Tuanjie code drives the simulation. The recipe fields are configuration inputs for your own agent system:

Field What it controls
boundaryShape + boundaryRadius Keep agents inside this area
pace Fraction walking vs. idle
walkSpeedMin / walkSpeedMax Per-agent speed range
collisionRadius Minimum distance between agents
personalSpace How hard agents push back when crowded
anticipation How far ahead agents start steering
obstacles[] Static objects to navigate around
attractTarget A point agents drift toward

Your engine has full runtime control — you can override any of these values per frame, move the attractor, add obstacles dynamically, or change the pace on the fly. The API provides the authored starting values; it does not drive the simulation at runtime.

Planned future features

  1. Runtime API — a live connection so a director can update the crowd mid-scene without reloading.
  2. Live editor link — Genji editor pushes recipe changes directly to a running Unity / Tuanjie instance.
  3. Per-character overrides — swap one character's costume or position without reloading the full crowd.

Current model: load once, simulate yourself.