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.

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
Local dev The server's own host (e.g. http://localhost:3001)
ngrok tunnel The tunnel URL (e.g. https://genji-api.ngrok.app)
Production Configured by the deployment team (Cloudflare R2 / CDN)

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.

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
}

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.
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").

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
  "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 / ngrok / 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",
  "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

All character parts live inside a single GLBskeleton_glb.

Each part ID (e.g. M_Terracotta_Infantry_Soldier) is the name of a SkinnedMesh node inside that file. To render a character:

  1. Load skeleton_glb once. It contains one shared skeleton and one SkinnedMesh node per part.
  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.

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://genji-api.ngrok.app/v1/crowd/cr_sample01

JavaScript

const res = await fetch('https://genji-api.ngrok.app/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://genji-api.ngrok.app";
    [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://genji-api.ngrok.app/assets/Basemesh31.glb",
    "lod_glb":         "https://genji-api.ngrok.app/assets/Basemesh31_LOD1.glb",
    "capabilities_json": "https://genji-api.ngrok.app/v1/capabilities",
    "animations": {
      "idle":              "https://genji-api.ngrok.app/assets/A_Idle.glb",
      "idle_arms_crossed": "https://genji-api.ngrok.app/assets/A_Idle_ArmsCrossed.glb",
      "idle_hands_hip":    "https://genji-api.ngrok.app/assets/A_Idle_HandsHip.glb",
      "idle_clasp":        "https://genji-api.ngrok.app/assets/A_Idle_Clasp.glb",
      "idle_shift":        "https://genji-api.ngrok.app/assets/A_Idle_Shift.glb",
      "idle_look_around":  "https://genji-api.ngrok.app/assets/A_Idle_LookAround.glb",
      "idle_scratch":      "https://genji-api.ngrok.app/assets/A_Idle_Scratch.glb",
      "idle_tired":        "https://genji-api.ngrok.app/assets/A_Idle_Tired.glb",
      "walk":              "https://genji-api.ngrok.app/assets/A_WalkFwd.glb",
      "jog":               "https://genji-api.ngrok.app/assets/A_JogFwd.glb",
      "walk_stop":         "https://genji-api.ngrok.app/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
curl -s \
  -H "Authorization: Bearer YOUR_API_KEY" \
  https://genji-api.ngrok.app/v1/crowd/cr_sample01

To expose via ngrok for remote testing (do not run automatically):

ngrok http 3001
# Current tunnel: https://genji-api.ngrok.app
# Share https://genji-api.ngrok.app/v1/crowd/cr_sample01 with the integration team.
# Set ASSET_BASE_URL=https://genji-api.ngrok.app 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.