Space-Game

Graphics Pipeline

Every visual asset in the game — ships, stations, buildings, people, outfits, decorations — is a design stored as small hand-editable JSON, expanded at load into flat polygons the renderer draws. Modders edit the JSON. The design atlases render the same expansion, so a plate always shows exactly what the game shows.

This document is the spec for that pipeline. It is written in the present tense and describes the system as it is; history lives in git.

Principles

  1. Polygons only. No strokes, no circles, no arcs. Curves are many-sided polygons; a small round light is an octagon. The renderer has one drawing primitive: a filled polygon.
  2. Fewest vertices that read. Each asset has a vertex budget for its tier (below). A silhouette earns every vertex against the identity.
  3. Separation by shade, not line. Where two same-coloured shapes meet, the far one carries a shade that tapers to nothing at both ends, along one global light direction. There is no outline.
  4. One source of truth. The design JSON is authored and committed. Nothing downstream is edited by hand; nothing upstream exists.
  5. Design, catalogue, and depict every article individually — even the ones that are only ever worn as part of a set.

Source layout

Every file below is resolved through the story’s shared modules: story_assets._load() checks config/stories/<story>/graphics/<rel> first, then each config/modules/<m>/graphics/<rel> the story lists — first hit wins, so a story overrides a shared kit file by dropping its own copy at the same path. materials.json is instead merged across story + modules. See CONFIG_MODULES.md; the figures-human module is the shared human-figure kit today.

config/stories/<story>/graphics/   (+ config/modules/<m>/graphics/ for shared kit files)
├── materials.json          — shading profiles (dark/light deltas) + scale constants
├── draw_order.json         — worn-figure back-to-front stack: body sections + garment tags
├── palettes/
│   └── <group>.json        — a palette: colour key → hex, for one culture or role
├── body/
│   ├── <species>.json      — silhouette, sections, anchors, curves, pivots
│   └── rig_<motion>.json   — per-group swing amplitude & phase for one animation
├── articles/
│   └── <article>.json      — one garment or accessory, designed on its own
├── items/
│   └── <item>.json         — an article's geometry worn with its own colour/shade
├── sets/
│   └── <set>.json          — an ordered list of article/item ids + palette
├── ships/       <ship>.json
├── stations/    <station>.json
├── buildings/   <building>.json      — an elevation facade (view: elevation)
├── decorations/ <decoration>.json    — furniture: top-down, or view: elevation
├── collision/   <id>.json         — hitboxes, one file per asset, loaded on their own
└── interiors/   <interior>.json   — floor plan: rooms, portals, decoration + building placements

Each file is small and reads as a single design. The loader globs a directory; a file’s stem is its id. collision/<id>.json and interiors/<id>.json share the asset’s stem so the three (graphics, collision, interior placement) line up without a manifest.

Organizing it. One design per file, snake_case id. A category directory is one asset kind (ships/, articles/, …); add one only when a kind has more than one asset or needs its own expander path (faces/ earns its place because expand() resolves face slots). Where a directory holds interchangeable variants of one slot, prefix the id with the slot: hair_short, eyes_almond, rig_walk. Curves and anchors live on the body section they belong to, never a separate file. Once a story has more than one culture, prefix ids <culture>_<asset> (vherathi_skiff); split a category into subdirectories only when it passes ~15–20 files or clearly holds distinct sub-families. The atlas mirrors this — one plate generator per category, and once a story is large, one atlas page per subject (culture, the figure), not one page for everything.

Anatomy of a design file

{
  "identity": "A grown resin pod, asymmetric, tapering to one glass point.",
  "tier": "ship_far",                 // sets the vertex budget
  "palette": "vherathi",              // which palette resolves this design's colours
  "silhouette": [
    { "group": "hull",   "color": "resin", "shade": "deep",  "points": [[...], ...] },
    { "group": "canopy", "color": "glass", "shade": "sheen", "points": [[...], ...] }
  ],
  "details": [
    { "group": "canopy", "color": "lamp", "shade": "glow", "role": "detail",
      "note": "single running light at the nose",
      "points": [[...], ...] }
  ],
  "anchors": { ... },                 // bodies and articles only — see Fitting
  "curves":  { ... }                  //          "
}

The six stages

A design is authored — and read — in this order:

# Stage Output in the file
1 Identity identity — what makes this unique, tied to the culture theme
2 Silhouette one outer form, fewest vertices, every choice justified by (1)
3 Colour + shade the silhouette split into regions, each tagged color + shade
4 Auto-shade nothingexpand() derives dark/mid/light per region from its shade profile
5 Details the details list: trim, designs, lights, grouped by region
6 Description identity + every detail’s note name every colour and detail

Colour, shade, palettes, tones

Colour and shade are two independent axes of a part’s look. A silhouette region or detail carries:

materials.json holds the profile table — each profile is a tone_dark / tone_light pair (plus emissive for light sources):

// materials.json
"shading": {
  "flat":  { "tone_dark": 0,   "tone_light": 0 },
  "glow":  { "tone_dark": 0,   "tone_light": 0, "emissive": true },
  "soft":  { "tone_dark": -14, "tone_light": 12 },
  "matte": { "tone_dark": -24, "tone_light": 20 },
  "deep":  { "tone_dark": -34, "tone_light": 24 },
  "sheen": { "tone_dark": -20, "tone_light": 44 },
  "metal": { "tone_dark": -40, "tone_light": 48 }
}

A palette binds colour keys to hex for one culture or role:

// palettes/vherathi.json
{ "hull": "#6b7f5a", "glass": "#9fe8d0", "lamp": "#ffd98a" }

A design names one palette. expand() resolves color + tone → rgb against it, and the shade profile supplies the dark/light deltas. So the same shape can be purple + sheen on one item and purple + matte on another — the two never have to move together. A modder retints an entire culture by editing one palette file; a total conversion writes its own.

There is no materials map any more: color is the palette key. (expand() still honours a legacy "material": name and a materials: {name: profile} map if an unmigrated story carries them.)

Auto-shade

expand() replaces each silhouette region with up to three polygons along one global light vector (materials.json light key, default up-left):

The ribbon’s outer edge is the region silhouette verbatim and its inner edge is a depth-capped inward offset that is Laplacian-relaxed and corner-cut (Chaikin) for smoothness, then snapped back inside wherever a smoothing pass pushed it out across a concave stretch. So the shade is always fully contained within the region — it never bleeds past the silhouette — and a coarsely faceted region still gets a smoothly curved shade. Vertex count per ribbon is bounded (~30–45) regardless of the region’s own count.

A region drawn with a shade profile whose deltas are 0 (or shade: false, or an emissive profile like glow) gets no crescents at all — one flat fill.

Details are not auto-shaded; they are drawn as authored, over the region. A detail’s own shade profile only affects it when it is drawn at a non-mid tone.

Fitting: anchors and curves

Bodies and articles publish, and consume, two kinds of named reference so that an outfit fits a body it was not drawn against — and follows it when the body is reproportioned.

A body section publishes:

Curve storage. A curve in section.curves is either a bare polyline (legacy) or { "pts": [...], "ends": [[x,y],[x,y]], "dir": ±1 }. pts is the resolved polyline and the only field expand() (_curve) reads; ends (the two endpoint coordinates) and dir (the boundary-walk direction) are what the vertex editor uses to re-trace pts when the body is reshaped — see the editor Curves panel. The editor writes the object form; hand-written designs may use either.

pts is stored in the neutral authoring frame, like every other body coordinate — expand_body rotates a limb section by rig.rest_splay before drawing it. An article that fits to a curve is authored already in the rest pose (splay baked into its points), so _curve carries the curve into that same frame on the way out: it rotates pts by the rest transform of the curve’s own section group (arm_near / hand_near / arm_far / hand_far; torso/leg/foot have none). Without this the fitted edge of a sleeve or glove lands on the un-splayed arm — offset from the drawn limb by the splay angle (~0.6 world units at the hand). The editor’s bodyCurve, its “generate N vertices between” trace, and findBodyVertexIndex all apply the same rotation so tailor mode matches the game.

An article region or detail declares one of:

Reproportion the body → anchors and curves move → every article follows, with no coordinate migration. Articles are authored once, against the reference body, and never need re-fitting when proportions change.

A fully-traced region ("fits": [], every vertex free) stops being body-portable. It renders correctly on the body it was traced against and literally unchanged — same fixed coordinates — on any other, so a garment two bodies share (via one outfit set, or two sets naming the same article) will look wrong on whichever body it wasn’t traced for.

Current state of the pipeline-test story: the masc side is further along than the femme side. body/human_masc.json carries edge curves on the torso, both arms/hands, both legs, and both feet; body/human_femme.json carries them on the torso and both arms/hands. duty_boots fits each foot outline to foot_{near,far}.shoe on the masc body; other articles are being fitted region by region, the masc variant leading. A region whose fits can’t resolve on a given body (curve absent) falls back to its free-vertex trace for that body — _curve returns None and _apply_fits leaves the span alone.

One file per article, both body cuts inside it

An article is one articles/<name>.json (no _masc / _femme suffix). The shared half of each region — group, color, shade, tone, note, tag, outset — is written once on the region. The body-specific half lives under a geometry map, one entry per body cut:

{ "identity": "...", "tier": "person", "palette": "civilian",
  "regions": [
    { "group": "torso", "color": "cloth", "shade": "matte", "note": "left panel",
      "geometry": {
        "masc":  { "points": [...], "fits": [{ "curve": "torso.side_left", "from": 1, "to": 1 }] },
        "femme": { "points": [...], "fits": [...] } } } ] }

geometry.<variant> holds points, fits, and — when the region has them — details, shade_dark, shade_light (detail and frozen-crescent coordinates are body-specific too). expand() picks the entry named by the worn body’s top-level "variant" (masc / femme); a pre-merge region with points straight on it still works. There is no fits_body key any more — the geometry keys carry it.

Items and sets follow suit. items/<name>.json names its geometry unsuffixed ("geometry": "long_coat"). sets/<name>.json lists bare article ids and is one file per outfit — unless the two cuts genuinely pick different articles (civilian_work: short hair vs long hair), in which case it stays _masc / _femme. graphics.json outfits.<id>.set points at whichever exists; the outfit’s body selects the variant everywhere downstream.

Bodies

body/<species>.json is a design file with sections instead of a flat silhouette:

{
  "identity": "...",
  "sections": {
    "torso": { "group": "body", "color": "skin", "shade": "soft",
               "points": [...], "anchors": {...}, "curves": {...} },
    "upper_arm_near": { "group": "arm_near", "pivot": [x, y], ... },
    ...
  }
}

Each section is a region (Stages 2–5 apply) plus, for a limb, a pivot. The body carries the walk-cycle knobs; rig_walk.json carries the per-group swing.

Animation

A rig is the static pipeline per segment, with three additions:

  1. Segments overlap at joints. An upper arm has a domed top that tucks under the shoulder; a thigh runs past the hip. No gap opens when a group rotates about its pivot.
  2. Shade is baked into the segment and rotates with it. Recomputing the light per frame over a small swing is not worth it.
  3. Group draw order encodes depth. Far limbs draw behind the torso a shade darker; near limbs draw in front. self.facing mirrors the whole figure.

The swing lives in body/rig_<motion>.json: per animation group a deg amplitude and a phase (fraction of the cycle), plus a torso bob. Legs swing opposite each other; each arm counter to the leg on its side; feet pivot on the ankle and lag their leg; hands pivot on the arm so the limb swings as one unit. apply_walk(parts, body_design, rig_walk, t) takes a composed parts list and returns it deformed into the pose at cycle fraction t — it rotates each part about its group’s pivot and bobs everything above the hips. A group with no rig_walk.swing entry just rides the bob (that is torso, neck, head today). Because a garment shares its limb’s group, it swings with the limb for free. The atlas renders a frame strip; the game will drive t from its own clock.

Every body section is its own animation group. human_masc / human_femme define torso, neck, head, and near/far arm · hand · leg · foot — each limb group with a matching pivots entry, and neck / head too (base of the neck, base of the skull). So a hat or hood can ride group: "head", a collar or necklace group: "neck", and they move with that part for free. torso is the default when a region names no group. neck and head carry no walk swing yet — they exist so articles can attach to them; add rig_walk.swing entries (and regroup the face-slot details + hairstyles off torso/face onto head) when the head should actually move in the gait.

Articles and sets

Every garment and accessory is one articles/<id>.json — its own identity, silhouette, colours, shades, details, and fitting declarations — and gets its own atlas plate, drawn on the bare reference body (both cuts, side by side). Its regions carry a per-cut geometry map (see One file per article under Fitting). The article file carries a default look; an item (below) can wear the same geometry differently.

A sets/<id>.json composes them:

{
  "identity": "Dock crew, cold-weather.",
  "palette": "civilian",
  "articles": ["work_trousers", "quilted_coat", "duty_boots", "watch_cap"]
}

The set gets a combined plate: all its articles expanded onto one model, in list order. A set adds no geometry of its own — only the article list and the palette.

Draw order — one list of body parts and tags. The story publishes a single back-to-front list in graphics/draw_order.json (order): the body’s section names interleaved with author-defined tag strings. Both are reorderable, but only in the vertex editor’s Outfit section.

{ "order": [
  "back", "arm_far", "hand_far", "leg_far", "foot_far",
  "torso", "neck", "hair_back", "head", "hair",
  "leg_near", "foot_near", "arm_near", "hand_near", "front"
] }

compose_worn(body, body_parts, *article_parts, order=<that list>) places every part at its slot in the list:

Within one slot the body part draws first, then article regions in worn order — each successive *article_parts list on top of the ones before (the set’s articles order; extra_articles last). A name not in the list sorts to the very front. With no order, the body’s own draw_order (bare section list) is the fallback.

hair_back, hair, back, front above are just tags — nothing structural. The bulk of a hairstyle is a "hair_back"-tagged region (the skull occludes it), its fringe a "hair" one; a backpack body a "back" region. group still drives animation (which limb a region swings with); the tag only moves it in the draw stack. There is no over / under, no draw_layers. "layer": "<name>" and "back": true are earlier spellings still read as a tag.

Edit the list in the vertex editor’s Outfit section (see GRAPHICS_EDITOR.md).

hides_hair. A headgear article may carry top-level "hides_hair": true (a raised hood, a sealed helmet). If any worn article declares it, _body_worn drops every hairstyle from the outfit — no geometry to clip through the shell, one less thing to expand.

Items

An items/<id>.json is a thin object: an article’s geometry worn with its own look. It is what a person actually owns — two people can carry the same jacket shape in different colours and finishes.

{
  "identity": "Oxblood officer's coat — long_coat geometry, deep red, waxed sheen.",
  "geometry": "long_coat",            // required: the articles/<id> to take shape + details from
  "color":  "#3a1f2e",                // optional: retag every region + detail to this one colour
  "shade":  "sheen",                  // optional: redraw every region + detail with this profile
  "parts": {                          // optional: override one region/detail by its `note`
    "visor bezel": { "color": "#6fe0e0" }
  }
}

expand(article, palette, materials, body, color=, shade=, parts=) applies the override: color / shade retag every region and detail, then a parts entry overrides its named region/detail on top, before the normal emit.

Expansion

game/graphics/expand.py is the one code path from design JSON to render parts. Given a design and its resolved palette it returns the flat list the renderer already draws:

  1. resolve fit: curves against the body’s current silhouette
  2. resolve anchor: to an animation group and offset
  3. apply any item override (color / shade / parts) — an article worn as an items/<id>.json (see Items) is retagged here
  4. auto-shade each region from its shade profile → mid / dark / light
  5. resolve color + tone → rgb via the palette
  6. emit parts, each tagged group and role (fill | shade_dark | shade_light | detail)

rig.rest_splay (the neutral arm pose the walk cycle swings from) is not applied at expand time. expand_body bakes it into the body’s own arm/hand sections, and an arm_near/arm_far article region is authored with the same rotation already baked into its stored points — so the sleeve and the arm share one frame with no runtime rotation, in the game and the editor alike.

The game calls expand() once per asset at load and caches the result. The atlas imports the same function. There is no bake step and no generated file.

Renderer contract

WorldObject.draw_parts() and Person.draw() draw an expanded parts list and nothing else — no synthesised outline, no base shape underneath. Each part is:

{ "points": [[x, y], ...], "color": <rgb>, "group": "...", "role": "..." }

group and role are ignored by the renderer; the atlas and debug tooling use them. Ship/station coords are fractions of size; building and body coords are absolute local units, y negative up.

Scale and world space

One world unit is a fixed real length (the player figure is ~PLAYER_H units tall — the reference every other size is quoted against). Every design states its true size, and the atlas draws it to scale:

The atlas carries two comparison plates per family: every asset beside the player silhouette, and the smallest asset beside the largest at one scale, so a mismatch is visible before anything ships.

Level of detail

The camera zoom ranges over [zoom_min, zoom_max] (Space View and interiors set their own limits). A design is authored at its ship_near / dockable detail level; expand() produces the far levels:

The vertex budget (below) is the near budget. A far bucket is whatever survives the culls — it is not authored.

Collision

Hitboxes live in collision/<id>.json, separate from the graphics and loaded on their own — the physics and pathing code never touches a parts list, and a headless server can load collision without expanding any geometry.

{
  "footprint": [[x, y], ...],        // one convex-ish polygon, world units
  "blocks_lane": true,                // optional: this prop may stand on a lane
  "boxes": [                          // optional extra solids
    { "points": [[...], ...], "blocks_lane": false, "note": "counter" }
  ],
  "sit": [[x, y], ...]                // optional: where a walker can sit / stand
}

Rules:

Interiors

interiors/<interior>.json is a floor plan in world units, sized against the player:

{
  "rooms":   [ { "id": "concourse", "points": [[...], ...] } ],
  "portals": [ { "between": ["concourse", "dock"], "points": [[...], ...] } ],
  "placements": [
    { "decoration": "resin_bench", "at": [x, y], "angle": 0 }
  ]
}

Vertex budget by tier

tier world size on-screen (near) budget (silhouette + details)
ship_far 1–2× player’s ship ~12 px 6–10
ship_near player’s ship docked view ~24
station 20–60× player dockable ~40
building 4–15× player tall city elevation ~30
person ~1× player body section as needed; face kit exempt
decoration 0.3–2× player furniture, props ~16

A low prefab outpost’s buildings can sit well below the building row’s 4× nominal — e.g. ~2–3× player tall — and that’s fine; it’s not a city block. Each design’s scale_note states its real size.

In the game

The engine draws a pipeline asset through the same parts list the atlas renders — WorldObject.draw_parts already takes {points, color} polygons with [r, g, b] or #hex colours, which is exactly what expand() emits. Nothing is baked: the design JSON stays the only source of geometry, expanded at load and cached.

Atlas

A design atlas is a generated page: one plate per asset, the specimen drawn by expand() + a polygons-to-SVG writer, beside the identity text, the detail notes, and a spec block naming the design file’s real keys. The atlas is a viewer — it holds no geometry and no copy of anything.

There is no maintained atlas-generator script. Use the vertex editor below, or the running game, to check a design’s render.

Vertex editor

config/editor.html is a standalone page for dragging a design’s vertices by hand — it renders exactly what expand() would. Its UI, modes (body / face / tailor / outfit), the fit and draw-order panels, and how it reads and writes repo files are documented in GRAPHICS_EDITOR.md.

Faces and hair

The face kit is details on the body’s head section — eyes (socket, sclera, iris, pupil, lid, catchlight), tapered brows, a short nose (bridge shadow + soft tip), two-part lips. Each is a small polygon; a round one may be written "circle": [cx, cy, r] (an optional 4th element, rot, rotates the n-gon about its own centre in degrees; omitted/0 = unrotated) and expand() turns it into an ngon — ngon()’s side count defaults to an octagon floor (max(8, min(24, r*2))), never fewer, so a small round detail still reads as round rather than faceted. The kit is drawn at a shallow 3/4 turn: the near eye larger, the far one compressed. It scales with the head.

Face slots. The head section carries a "face" map — {eyes, brows, nose, lips: <name>} — and load("faces", "<slot>_<name>") supplies each slot’s details. So the features are interchangeable (eyes_almond, eyes_deep, nose_soft, nose_straight, lips_full, lips_thin, …) without editing the body.

Hair is an articlegroup: "head" (so it swings with the head), tagged for the draw stack. Its bulk regions are "tag": "hair_back" (a slot before head, so the skull hides the back and only the volume past the silhouette shows); the framing bits — a thin band along head.hairline, sideburns, a fringe, side panels — are "tag": "hair" (a slot after head) and draw over the face. A hat/hood/helmet region tags itself hair to sit over the hair, hair_back to tuck behind the head. A headgear article that should cover hair entirely carries "hides_hair": true instead.

Per-character variation is a palette override: skin / hair / eye / lip colours are palette keys, so a story or an NPC roster can swap them without touching geometry.

Approval gates

An approval gate is a point where the agent stops, shows a rendered proof, and waits for a yes before spending effort downstream. There are two kinds.

Per-asset design gates

Every new asset passes these, in order. Each shows a plate from expand().

after stage gate proof shown
1–2 silhouette the outer form + identity text, one flat colour, on the tier’s viewBox with the player-scale reference. No materials yet.
3–4 materials & shade the region split and the auto-shade, palette applied. No details yet.
5–6 finished details in, description complete, on its own plate.
on model (articles only) the article fitted to the bare body, then in its set.
in game the asset drawn by the running game at real scale beside its neighbours.

Skip a gate only when the user says so. A silhouette change after the materials gate reopens it.

Pipeline-build gates

One-time, while the pipeline itself is being stood up. Each proves one mechanism on the smallest asset that exercises it.

gate proves proof
A — mechanics design JSON → expand() → specimen, end to end one body renders on a plate
B — reference body proportions, sections, anchors, curves, draw order human_masc / human_femme
C — material & shade auto-shade (tapered crescents) + palette tones side-lit crescents, one continuous per region
D — fit group / fits against the body masc body publishes torso/limb/foot curves; duty_boots splices each foot outline from foot_{near,far}.shoe on the masc cut — see Fitting
E — animation the rig: per-group pivot swing, clothing follows, torso bob civilian_work on the walk-cycle frame strip
F — world scale + LOD + collision size vs. player, detail culls, hitbox overlay courier — near / far / hitbox / beside-the-figure plates
G — interior floor plan sized to the player, generated navmesh, lane check concourse — plan + generated lanes + column (declared) / bench (clear)
H — in game a story boots and the renderer draws all of the above courier + trade ring in space, concourse + bench + column + pipeline-bodied walkers in the interior

Gates are sequential: B needs A, D needs B and C, E needs D, H needs everything. Move a gate or add one when the work shows a seam that needs sign-off.

Minimal asset set (pipeline test)

All eight gates pass. The set below is what the gates needed and no more, and is enough for a bootable story (a story.json + one system + a concourse interior; docked at a trade ring with a courier) — it now lives in the figures-human / orbital-std shared modules rather than a standalone story:

The Common Kit outfit pack — the 15 culture-neutral outfits from the retired common-kit.html atlas (space/flight suit, mechanic, dockworker, prospector, security, station command, marshal, medic, surgeon, researcher, civilian, smuggler, ranger, bounty hunter), rebuilt from the articles above:

Fidelity is silhouette + colour, not every prop: pieces with no article yet (a drill, a baton, a holster, a trek bedroll) are omitted.

Grow the set now by the per-asset gates, not the build gates.

Authoring checklist

  1. Write the identity. If you cannot say what is unique in two sentences, the design is not ready.
  2. Draw the silhouette. Stay under the tier budget. Justify each vertex.
  3. Split it into regions on concept lines (group); tag each color + shade.
  4. Run expand() and look at the auto-shade. Adjust region edges, not shading.
  5. Add details, each with a group and a note.
  6. For a body or article, declare anchor: / fit: — never raw coordinates against the body.
  7. State the true size and a scale note. Check the against-player and smallest-vs-largest comparison plates.
  8. Give details a min_px and regions a flatten_px; check the far lod still reads.
  9. Write collision/<id>.json. Confirm the hitbox sits inside the footprint on the overlaid plate.
  10. For an interior, size corridors and portals to the player first, then place decorations; run the lane check and clear or justify every blocker.
  11. Check the article’s own plate, then its set plate.