{"openapi":"3.0.3","info":{"title":"Genji Crowd API","version":"1.0.0","description":"Procedural crowd/NPC generator API spanning 13 civilization packs. A crowd_id identifies a saved crowd configuration (recipe). GET /v1/crowd/{crowd_id} returns the full recipe, pre-resolved per-character part assignments (body/face/hair SkinnedMesh node IDs), world positions, animation state, and GLB asset download URLs.\n\nChinese parts and the shared skin parts live inside skeleton_glb as named SkinnedMesh nodes. Every other pack ships its own GLB: read the `pack` on each `pack` and look the file up in assets.pack_glbs. All packs share the same 72-bone skeleton, so one bone texture and one animation set covers them all. Coordinate system: right-handed, Y-up. Character positions (x, z) are in METRES. The GLB scene is also in METRES as loaded — the cm→m conversion (0.01) is baked into the GLB root node. Do NOT apply any extra unit scale; a character should stand ~1.7 m tall straight out of the loader. rot_y=0 → faces +Z. For Unity (left-handed): negate z and rot_y.\n\nSanity check: after loading, measure the bounding box height of a visible character. ~1.7 = correct; ~170 = your importer stripped the root scale (apply 0.01 yourself); ~0.017 = you applied 0.01 on top of the baked scale (remove it).\n\nSample key: YOUR_API_KEY  |  Sample crowd: cr_sample01"},"servers":[{"url":"http://www.genjiwu.com","description":"Current server"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key in format gk_live_… — obtain from the Genji dashboard. Sample for testing: YOUR_API_KEY"}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string","example":"Crowd not found"}}},"Obstacle":{"type":"object","properties":{"type":{"type":"string","example":"rock"},"x":{"type":"number","description":"World X position (metres)"},"z":{"type":"number","description":"World Z position (metres)"},"radius":{"type":"number","description":"Avoidance cylinder radius (metres)"}}},"Recipe":{"type":"object","description":"Complete crowd configuration. Feed these values into your agent simulation engine.","properties":{"v":{"type":"integer","example":1,"description":"Recipe format version."},"seed":{"type":"integer","example":42,"description":"Mulberry32 RNG seed. Deterministically produces the characters[] list."},"size":{"type":"integer","example":150,"description":"Total character count."},"packs":{"type":"array","items":{"type":"string","enum":["chinese","african","arabian","aztec","egyptian","european","greek","indian","japanese","norse","persian","roman","western"]},"example":["roman"],"description":"Civilization packs to draw characters from. Omitted or empty means [\"chinese\"], which is what every crowd created before packs existed resolves to. Buckets outside the active packs are filtered out BEFORE weights are read, so a weight on a Norse bucket does nothing unless \"norse\" is listed here."},"activity":{"type":"string","enum":["everyday","spectators"],"example":"spectators","description":"What the crowd is doing. \"everyday\" (the default when omitted) mixes idling and walking characters, steered by pace. \"spectators\" stands every character still on the spectator clips (cheer, clap, wave, jump, watch, point): anim_clip is \"spectator\" for all, and idle_variant picks the clip from assets.animations.spectator_variants. Positions, parts and colours are the same as the everyday crowd from the same seed."},"genderRatio":{"type":"number","minimum":0,"maximum":1,"example":0.5,"description":"Target fraction of the crowd that is female, 0 to 1. Omitted leaves the natural mix of the active packs, which is male-heavy because the catalogue is (about 19% female). Works by reweighting which buckets are drawn, since every bucket is single-gender, and preserves the relative weights within each gender so a role mix still holds. Ignored if the active packs contain only one gender."},"ethnicityMix":{"oneOf":[{"type":"string","enum":["packs"]},{"type":"object","properties":{"asian":{"type":"number","minimum":0},"black":{"type":"number","minimum":0},"middle_eastern":{"type":"number","minimum":0},"white":{"type":"number","minimum":0}}}],"example":{"asian":2,"black":1,"middle_eastern":1,"white":1},"description":"Which faces the crowd gets. An object of relative weights over the four face groups (asian, black, middle_eastern, white), or \"packs\" to give each character the face mix of its own pack (manifest packs[].faceMix, e.g. African characters get Black faces). Omitted means Asian faces only, which is what every crowd created before this field existed resolves to, so saved crowds keep their faces. Changes only face_id and hand_tint; every other field of every character stays the same."},"weights":{"type":"object","additionalProperties":{"type":"number"},"example":{"Conscript_Laborer_Plain_Belted_Tunic":40},"description":"Bucket weight map — {bucketId: weight}. Bucket IDs match manifest.json → buckets[].id, and only buckets belonging to an active pack are eligible. GET /v1/capabilities lists every bucket id with its pack."},"walkSpeedMin":{"type":"number","example":0.6,"description":"Min walk speed multiplier relative to clip speed."},"walkSpeedMax":{"type":"number","example":1,"description":"Max walk speed multiplier."},"pace":{"type":"number","minimum":0,"maximum":1,"example":0.5,"description":"Fraction of characters that walk vs. idle. 0=all idle, 1=all walking."},"scaleVariation":{"type":"number","example":0.06,"description":"±scale jitter (uniform scale = 1 + (basis−0.5)×2×scaleVariation)."},"boundaryShape":{"type":"string","enum":["circle","square","road","donut","spline"],"description":"Spawn boundary shape."},"boundaryRadius":{"type":"number","example":80,"description":"Outer boundary radius/half-length (metres)."},"spawnRadius":{"type":"number","example":80,"description":"Inner spawn radius/half-width (metres)."},"splinePoints":{"type":"array","nullable":true,"items":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},"description":"[[x,z]…] control points for spline shape; null otherwise."},"facing":{"type":"number","minimum":0,"maximum":1,"description":"0=random facing, 1=all face the same direction."},"distribution":{"type":"string","enum":["random","grid","startpoint"]},"terrain":{"type":"object","properties":{"active":{"type":"boolean"},"amplitude":{"type":"number","description":"Terrain height (metres)."},"frequency":{"type":"number"},"noiseType":{"type":"string","enum":["fractal","simplex"]}}},"hueShift":{"type":"number","description":"Global garment hue rotation."},"hueVar":{"type":"number","description":"Per-character hue jitter range."},"satVar":{"type":"number","description":"Per-character saturation jitter range."},"lightVar":{"type":"number","description":"Per-character lightness jitter range."},"collisionRadius":{"type":"number","description":"NPC body radius multiplier × character height. Two touching NPCs are collisionRadius×height apart."},"personalSpace":{"type":"number","description":"Centering/path-following force (0–1)."},"anticipation":{"type":"number","minimum":0,"maximum":1,"description":"0=react on contact, 1=steer early."},"obstacles":{"type":"array","items":{"$ref":"#/components/schemas/Obstacle"}},"groupingMode":{"type":"boolean","description":"Characters cluster into groups."},"attractActive":{"type":"boolean"},"attractTarget":{"type":"object","properties":{"x":{"type":"number"},"z":{"type":"number"}}},"collection":{"type":"string","example":"genji-v1"}}},"Character":{"type":"object","description":"One NPC with pre-resolved part IDs and spawn state. Derived deterministically from recipe.seed via Mulberry32 RNG.","properties":{"index":{"type":"integer","description":"0-based character index."},"bucket":{"type":"string","description":"Archetype bucket ID (matches manifest.json → buckets[].id)."},"body_id":{"type":"string","description":"SkinnedMesh node name inside skeleton_glb to use as the body/outfit."},"face_id":{"type":"string","description":"SkinnedMesh node name for the face."},"hair_id":{"type":"string","description":"SkinnedMesh node name for headwear/hair."},"x":{"type":"number","description":"World X (metres). Place character at (x, terrain_y, z)."},"z":{"type":"number","description":"World Z (metres)."},"rot_y":{"type":"number","description":"Y-axis rotation (radians). 0=faces +Z. Right-handed Y-up. Unity: negate."},"scale":{"type":"number","description":"Per-character SIZE VARIATION multiplier (typically 0.95–1.05). Apply as the clone's uniform scale. It is NOT a unit conversion — the GLB is already in metres."},"phase":{"type":"number","minimum":0,"maximum":1,"description":"Animation phase offset. startTime = phase × clipDuration. Prevents crowd lockstep."},"gender":{"type":"string","enum":["m","f"],"description":"Gender of this character, taken from its body part. Every character shares one animation set today, so this does not change which clip plays; it is there so a renderer can pick per-gender clips if a set ever has them."},"anim_clip":{"type":"string","enum":["idle","walk","spectator"],"description":"Clip to play at spawn. \"idle\" and \"walk\" in an everyday crowd; \"spectator\" for every character when recipe.activity is \"spectators\"."},"idle_variant":{"type":"integer","minimum":0,"description":"Which clip of the group named by anim_clip to play. For \"idle\" it indexes assets.animations.idle_variants, for \"spectator\" assets.animations.spectator_variants. It ranges from 0 to the group's count minus 1, and the counts come from manifest animationCounts ({ idle: { m, f }, spectator: { m, f } }, 19 idles and 6 spectator clips in the Kimodo set; 8 idles in the old set). Ignored for \"walk\"."},"garment_hsl":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3,"description":"[H, S, L] normalised 0–1. Complete garment colour — apply directly with setHSL(h, s, l) on the body mesh material. This is NOT a shift on top of manifest bucket tints; it is the full colour, pre-computed from recipe hueShift + per-character jitter."},"skin_hex":{"type":"string","description":"Hex colour for face + shared skin meshes (eyes, hands, feet)."},"hand_tint":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3,"example":[0.41,0.33,0.31],"description":"RGB multiplier for the shared hands and feet (not the eyes), which are painted in one light skin tone, so they match this face. [1,1,1] for Asian faces. Multiply it with skin_hex if you use that."},"hair_hex":{"type":"string","description":"Hex colour for hair/headwear mesh."}}},"AnimationMap":{"type":"object","description":"All animation clip GLB URLs. With set \"kimodo\" (the current set) every clip is authored on skeleton_glb's own skeleton and plays as is. With set \"legacy\" (the old A_* clips) clips are authored for the rest_reference_glb skeleton; retarget them to skeleton_glb using the bone quaternion delta technique (see API.md). The named idle_* keys exist only in the legacy set; use idle_variants and spectator_variants, which work in both.","properties":{"set":{"type":"string","enum":["kimodo","legacy"],"description":"Which clip set these URLs belong to."},"retarget":{"type":"boolean","description":"true when the clips need the rest-pose retarget (legacy set only)."},"idle_variants":{"type":"array","items":{"type":"string","format":"uri"},"description":"Idle clip URLs. idle_variants[idle_variant] is the clip for anim_clip \"idle\"."},"spectator_variants":{"type":"array","items":{"type":"string","format":"uri"},"description":"Spectator clip URLs (cheer, clap, jump, point, watch, wave). spectator_variants[idle_variant] is the clip for anim_clip \"spectator\". The legacy set has none and repeats the idles here."},"clips":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"group":{"type":"string","enum":["idle","spectator","loco"]},"url":{"type":"string","format":"uri"},"duration":{"type":"number"},"loop":{"type":"boolean"}}},"description":"Kimodo set only: every clip with its id, group and duration in seconds."},"idle":{"type":"string","format":"uri","description":"The first idle clip."},"idle_arms_crossed":{"type":"string","format":"uri"},"idle_hands_hip":{"type":"string","format":"uri"},"idle_clasp":{"type":"string","format":"uri"},"idle_shift":{"type":"string","format":"uri"},"idle_look_around":{"type":"string","format":"uri"},"idle_scratch":{"type":"string","format":"uri"},"idle_tired":{"type":"string","format":"uri"},"walk":{"type":"string","format":"uri"},"jog":{"type":"string","format":"uri"},"walk_stop":{"type":"string","format":"uri"}}},"Assets":{"type":"object","description":"Download URLs for all GLB assets. URLs reflect the server's ASSET_BASE_URL — do NOT hardcode localhost. Two rendering paths are supported: per-character cloning (skeleton_glb + SkeletonUtils.clone, simple, fine up to a few hundred characters) and GPU-instanced rendering (bone_tex_bin/bone_tex_manifest, for 1000+ characters — see the \"GPU-instanced rendering\" doc section).","properties":{"unit":{"type":"string","enum":["m"],"description":"Effective GLB scene unit as loaded: metres. The source skeleton is Maya cm, but the export bakes a 0.01 scale into the GLB root node, so no conversion is needed."},"unit_scale":{"type":"number","example":1,"description":"Extra scale to apply to the GLB root. Always 1.0 — the cm→m conversion is already baked into the file. Applying 0.01 yourself will make characters 100x too small. Sanity check: a loaded character should be ~1.7 m tall."},"skeleton_glb":{"type":"string","format":"uri","description":"A CATALOGUE of every body/face/hair mesh in the pack, as named SkinnedMesh nodes on one shared skeleton. It is NOT a single character. Clone it per character and then show only the nodes named by that character: body_id, face_id and hair_id plus shared_parts, hiding every other mesh. Skipping that step gives each character every outfit at once. See the `usage` object on the crowd response."},"lod_glb":{"type":"string","format":"uri","description":"Lower-poly LOD variant of the bodies, hair and the ten original face ids. It predates the GNM faces: the faces added in October 2026 (Black, Middle Eastern and White ids) are not in it yet, so take faces from skeleton_glb."},"rest_reference_glb":{"type":"string","format":"uri","description":"Basemesh GLB used as animation retarget reference. Animation clips in this API are authored for this skeleton's rest pose."},"manifest_json":{"type":"string","format":"uri","description":"JSON catalogue listing all parts, buckets, and shared nodes."},"bone_tex_bin":{"type":"string","format":"uri","description":"Raw Float32Array binary (RGBA32F layout) of a baked bone-matrix texture for GPU-instanced crowd rendering — an alternative to per-character cloning for crowds of 1000+. Pair with bone_tex_manifest to interpret it. Reference implementation: /sdk/genji-crowd-instanced.js."},"bone_tex_manifest":{"type":"string","format":"uri","description":"Describes bone_tex_bin: texWidth, texHeight, nBones, bakeFps, per-clip frame ranges (clipInfo), and the clip index to use for anim_clip=\"walk\". clipSets maps each anim_clip to its clip indices: clipSets.idle[idle_variant] for \"idle\", clipSets.spectator[idle_variant] for \"spectator\", clipSets.walk for \"walk\". Required to interpret the binary."},"animations":{"$ref":"#/components/schemas/AnimationMap"},"usage":{"type":"object","description":"Machine-readable rendering instructions: what skeleton_glb actually contains, which nodes to show per character, how to clone, and what goes wrong if the hiding step is skipped. Read this before writing a renderer."}}},"CrowdResponse":{"type":"object","properties":{"crowd_id":{"type":"string","example":"cr_sample01"},"name":{"type":"string","example":"Imperial Crowd — Tuanjie Sample"},"collection":{"type":"string","example":"genji-v1"},"recipe":{"$ref":"#/components/schemas/Recipe"},"characters":{"type":"array","items":{"$ref":"#/components/schemas/Character"},"description":"One entry per NPC, size = recipe.size. Deterministic from recipe.seed."},"shared_parts":{"type":"array","items":{"type":"string"},"description":"SkinnedMesh node names rendered on every character (eyes, hands, feet). Constant — same for all characters.","example":["Eye_L","Eye_R","L_Hand","R_Hand","L_Foot","R_Foot"]},"assets":{"$ref":"#/components/schemas/Assets"},"updated_at":{"type":"string","format":"date-time"}}}}},"paths":{"/v1/crowd/{crowd_id}":{"get":{"summary":"Get crowd recipe and character list","operationId":"getCrowd","description":"Returns the full crowd recipe, all pre-resolved character part assignments, and GLB asset URLs.\n\nRendering guide — the GLB scene is ALREADY IN METRES (a 0.01 cm→m scale is baked into its root node). Do NOT apply any unit conversion anywhere:\n1. Download assets.skeleton_glb once and load it — this is your ORIGINAL, un-cloned scene.\n2. Sanity check the load: a character should stand ~1.7 m tall. If ~170, your importer stripped\n   the root-node scale — re-apply 0.01 at the root. If ~0.017, you added a 0.01 that should not be there.\n3. For each character: clone the scene (SkeletonUtils.clone or equivalent).\n4. Set visible=true for body_id, face_id, hair_id, and each of shared_parts; hide all other SkinnedMesh nodes.\n5. Place at world (x, 0, z) — no Y offset needed, the root is already at the character's feet —\n   apply Y rotation rot_y, then set the clone's uniform scale to character.scale\n   (the small ~0.95–1.05 per-character size variation).\n6. Load the animation clip: anim_clip \"walk\" plays assets.animations.walk, \"idle\" plays\n   assets.animations.idle_variants[idle_variant], \"spectator\" plays\n   assets.animations.spectator_variants[idle_variant].\n   Kimodo clips (animations.set \"kimodo\") play on skeleton_glb as is. Only when\n   animations.retarget is true (the old set) retarget from rest_reference_glb first.\n7. Start clip playback at time = phase × clipDuration.","parameters":[{"name":"crowd_id","in":"path","required":true,"schema":{"type":"string","pattern":"^cr_[a-zA-Z0-9]{8}$","example":"cr_sample01"},"description":"Crowd identifier. Use cr_sample01 with the sample key for testing."}],"responses":{"200":{"description":"Crowd recipe, character list, and asset URLs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CrowdResponse"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"crowd_id not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/projects":{"get":{"summary":"List saved crowds","operationId":"listProjects","description":"Lists the caller's saved crowds. Unmetered.","responses":{"200":{"description":"Array of crowd summaries.","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"crowd_id":{"type":"string"},"name":{"type":"string"},"collection":{"type":"string"},"thumbnail":{"type":"string","nullable":true},"created_at":{"type":"string"},"updated_at":{"type":"string"}}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create and save a crowd","operationId":"createProject","description":"Creates a new crowd from a recipe and saves it to the caller's account. Billable — same metering as GET /v1/crowd/{crowd_id}.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recipe_json"],"properties":{"name":{"type":"string"},"recipe_json":{"$ref":"#/components/schemas/Recipe"},"collection":{"type":"string"}}}}}},"responses":{"200":{"description":"Created crowd_id + name.","content":{"application/json":{"schema":{"type":"object","properties":{"crowd_id":{"type":"string"},"name":{"type":"string"}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Recipe size exceeds plan limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Quota exhausted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/projects/{crowd_id}":{"put":{"summary":"Update a saved crowd","operationId":"updateProject","description":"Updates an existing crowd's recipe/name/collection. Only billed if recipe_json actually changes.","parameters":[{"name":"crowd_id","in":"path","required":true,"schema":{"type":"string","pattern":"^cr_[a-zA-Z0-9]{8}$"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"recipe_json":{"$ref":"#/components/schemas/Recipe"},"collection":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not the crowd owner, or size exceeds plan limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"crowd_id not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}