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:
- Downloads
assets.skeleton_glbonce (contains all part meshes on a shared skeleton). - For each entry in
characters[], selects the named mesh nodes and applies per-character colour, scale, and animation phase. - 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.
- Genji gives you: the CHARACTERS (bodies, outfits, hair, faces, animation clips on one shared skeleton) and their SPAWN POSITIONS.
- You bring: movement. Genji does not do pathing, steering, collision or crowd AI. A crowd is positions + animation clips, not motion over time.
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. Useassets.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.
- Transport: Streamable HTTP, stateless (no session handshake required — every request is independently authenticated).
- Auth: the same
Authorization: Bearer gk_live_...API key as every other/v1route. Point your MCP client's config athttps://<your-genji-host>/mcpwith that header. - Tools:
| 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 (0–1). |
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/zvalues 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
z→position.z = -C.z, and negaterot_y→rotation.y = -C.rot_y. Also negateforward_zwhen converting the direction vector.
rot_yis 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 staticrot_yfor a character you are moving yourself:rot_y = atan2(velocity.x, velocity.z). Only use the API'srot_yas-is foridlecharacters (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.
- ~1.7 — correct, do nothing.
- ~170 — your importer stripped the root-node scale; apply
0.01at the root yourself. - ~0.017 — you applied an extra
0.01on top of the baked scale; remove it.
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:
- Load
skeleton_glbonce, plus each GLB inpack_glbsonce. - For each character in
characters[], make the following mesh nodes visible:body_idnodeface_idnodehair_idnode- All 6
shared_partsnodes (eyes, hands, feet) - All other mesh nodes: hidden
- Instance or clone the visible set, applying the character's
scale,rot_y, and(x, z)position. - Apply per-character colour (see Colour application below).
- Play the animation clip named by
anim_clip, offset byphase × 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 returns — assets.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.
- Don't bind any
map/normalMap/roughnessMap; give the material a plain colour. - For a clean white look on a PBR material, add a small white emissive (~0.35) so the mid-tones lift out of grey; a flat/unlit material doesn't need it.
- This is orthogonal to VAT and BBM — you can render untextured in any tier. The
recipe.texturesflag tells you whether the author had textures on when saving.
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 );
- Why: collapses per-vertex skinning from ~32–64 texture fetches to 2 (idle), 4 (walk/jog), or 8 (mid-crossfade). Best when the 4-bone blend — not draw calls or memory — is your bottleneck.
- Cost: VRAM. Every vertex × every baked frame is stored as a
vec3; the full cast is ~2.4 GB. It is baked once at load, client-side, from the samebone_tex_binthe API serves — it is deliberately not shipped pre-baked (far too large to transfer). - Bake: for each part geometry, for each baked frame in
bone_tex_manifest.clipInfo, run the bone-texture skinning once on the GPU and write each vertex's world position intoVAT[frame][vertexID]. Reusebone_texframe layout so clip indices/phasematch. - Scope: mutually exclusive with BBM (VAT keeps the real mesh; BBM replaces it).
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.01model scale into every matrix (bone_tex_manifest.modelScale), and the cube geometry is authored in metres, so you compensate with a×100. Apply the×100to the transformed point, not to the input:(b * p) * 100, neverb * (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.
- Colour: the cubes have no textures; tint each primitive with the character's
garment_hsl(body/limbs),skin_hex(head/hands), andhair_hex. In untextured mode, render them flat white to match the full mesh. - When: beyond ~50 m, or crowds in the tens of thousands where only silhouette and motion read. Use it as the far LOD band under a full-mesh (or VAT) near band.
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_glbskeleton and carry translation tracks as well as rotations (the walk clip has 60 translation channels, includingroot,pelvis, and the whole spine). The Genji skeleton inskeleton_glbuses 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.htmldoes exactly this.Expected consequence: dropping the
roottrack 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 (seerecipe'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.
localhostandngrokURLs never appear in production responses — the live API ishttps://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
- Runtime API — a live connection so a director can update the crowd mid-scene without reloading.
- Live editor link — Genji editor pushes recipe changes directly to a running Unity / Tuanjie instance.
- Per-character overrides — swap one character's costume or position without reloading the full crowd.
Current model: load once, simulate yourself.