The Jabbertoon block language

Every move, scene and cartoon on Jabbertoon is a program in this language: JSON that people see as blocks, as text or as a timeline. This is the public edition of the specification. The JavaScript engine in the browser is the reference implementation, and a Python port follows it to within 1e-9 on every recorded case.

Which hats fire today

A script starts with a hat, the block that says when it runs. These hats run today: start (when the move starts), talk-start and talk-end (when the character starts and stops speaking a line), and scene (when a scene of a cartoon starts). In the block editor, key and click hats also fire, for the one character on its stage. Four more hats, touch, beat, loud and every, are part of the language: programs that use them load, validate and save without losing anything, but nothing makes them fire yet, so a script under one of them never runs. They are named future features.

The specification

0. Non-negotiables

  • Exact time. A timed block that starts at t and lasts d ends at exactly t+d, never at "the next frame". The runtime is a discrete-event scheduler: stepTo(T) processes every block event with time ≤ T in time order, then the pose is read at T. So a pose depends only on T, not on how the clock was stepped (24 fps, sample jumps, one big jump all agree bit-for-bit; tested).
  • The only step-dependent things are polls: while/until conditions are checked once per step, and a loop iteration that took zero time waits for the next step (no infinite loops). Behaviours meant to be exact use timed blocks (for, wait until), not polls.
  • Deterministic. No wall clock. random is a seeded PRNG per runtime (mulberry32).
  • Two clocks. timeline (video: the renderer calls stepTo(frame/fps)) and live (play: the page steps by the real time elapsed, step(dt), and sends input events). Every block works under both; no block can read the step size (§5: there is no dt name).
  • Units. Positions in 1080p pixels (renderers scale by min(W,H)/1080). Angles in degrees, positive = counter-clockwise as the viewer sees it (the original studio's PIL convention; canvas renderers draw −angle). Scales are factors. Time in seconds. A scene's placement of a character is in stage fractions (x of the width, y and h of the height; §7.2); behaviour offsets add to it.

1. Program JSON

program := { "v": 1, "kind": "behaviour"|"scene"|"cartoon", "name"?, "params"?: {name: value},
             "vars"?: {name: number}, "scripts": [script] }      // scene/cartoon add "cast", "places" (§7.1)
script  := { "hat"?: hat, "body": [block] }           // no hat = { "on": "start" }
hat     := {"on":"start"} | {"on":"talk-start"} | {"on":"talk-end"} | {"on":"key","key"?} | {"on":"click"}
         | {"on":"scene","name"?,"place"?,"mood"?,"at"?} | {"on":"beat"} | {"on":"loud"} | {"on":"every"} | {"on":"touch"}
block   := { "op": string, ...args }                    // "note" and "id" are allowed on any block
value   := number | "=expression"                       // see §5

params are defaults; the caller's params override them. Defaults are evaluated at start, in declaration order, and may use dur and earlier params (e.g. "t_in": "=min(max(dur*0.6, 0.5), 3.0)"). A variable starts at a finite number: a formula, text, true, null, a list or an object is a validation error (vars.x: a variable starts at a number, not "=1"); setVar gives a variable any value as the script runs. vars that is not an object adds no variables (null, 5, []); a list or a string adds its entries by index, which are checked the same way (a string's entries are its UTF-16 code units, as Object.entries gives them: an emoji is two, vars.0 and vars.1).

2. The pose model

A character runs behaviour instances (startBehaviour(char, program, {dur, params})). Each instance owns a layer with two kinds of state:

  • Base channels — absolute values written by set and ramp. One value per channel per instance; the last writer wins; a ramp starts from the channel's current value and then holds the target.
  • Motions — wave, hop, gait, drive. Continuous functions of time. A motion block does not wait: it starts the motion and the script continues. A motion lives until its scope ends.

Scopes. The script's top level is the instance's root scope: motions started there live until the instance is stopped (even after the script's last block). for and while open a scope; when the for's time is up or the while exits, the motions started inside stop. together branches start motions in the enclosing scope. if, repeat and forever bodies do not open scopes.

Start-or-keep. Running a motion block whose motion is already running in the same scope does nothing (it does not restart or stack). forever { bob; wait 1 } bobs once, continuously.

Combining. The character's pose = rest ⊕ every instance's base ⊕ every motion, where dx dy rot and part channels add, sx sy multiply, mouth blink take the max, mood takes the latest write. Rest is 0 (1 for sx sy). The skeleton adds two pose keys that are not channels (§8): reach (per hand or foot, the latest reach/release wins) and view (the latest view wins, like mood).

Channels. dx dy rot sx sy mouth blink mood, and part channels part:<role>[:<side>][#<index>] (angle, deg) / lift:<role>[:<side>][#<index>] (px, +up). side is −1 left / 1 right; the index's meaning depends on the role: on legs (leg shin foot) it numbers the legs back to front (quadruped: #0 back-left, #1 back-right, #2 front-left, #3 front-right); on a chain (a tail, the spine, ears, hair; §8.2) it numbers the segments from the root (tail#0 is next to the body; the skeleton's blocks write segment 0 on the plain channel, part:tail, §8.2). A drawn node with role R, side S, index I reads the sum of R, R:S, R#I, R:S#I, so "both arms" and "left arm" combine — except a chain follower (segment i ≥ 1 of a chain the template draws), which reads only R#i and R:S#i (§8.1; docs/SHAPES.md §3.2). Roles: head neck torso hips arm forearm hand leg shin foot tail ear wing fin claw snout nose hair hat eye brow (engine/parts.js ROLES, the same list as the roles ui/shapes.js draws; no template draws a neck yet, so a block that moves the neck is a warning, v0.4.1, §8.6). Validation: a channel that does not have this form (part:Arm, lift:arm:2, part:__proto__) is an error; a well-formed part channel of any other lower-case role (part:elbow) is a channel like the others (it sums, rt.pose reports it), but nothing draws it, so it is a warning.

Renderer conventions (part synonyms). The engine computes channels; it does not know which body a character has. So that every character answers the same moves, the shape renderer (ui/shapes.js applyPose) also lets some drawn parts read other roles' channels, added to their own:

  • a wing reads arm plus half of forearm at its root, so "lift arms" flaps the wings and "wave" waves a wing;
  • a hand reads claw (the original crab moves wiggle a person's hands);
  • on a four-legged body the front legs (leg #2, #3) read 40 % of arm and their shins (shin #2, #3) 40 % of forearm; in profile the left side's angle is negated (a profile body has no outward side, so "lift arms" raises both front legs forward: a dog "waves" a front paw); seen head on it is used as it is (outward, like a person's arm);
  • (v0.4.1) the part channels name the limb ends a reach names (§8.4): part:hand (hand:S) moves a four-legged body's front paws (which read the foot channels only by their leg number, part:foot#2), a bird's wing tip (the outer half of its wing) and a monster's claw; part:foot moves a four-legged body's back paws;
  • (v0.4.1) a part a template draws with fewer segments than a chain block bends (a one-piece torso, tail or ear) shows the whole bend: its last drawn segment also reads the index channels of the segments it does not draw. These are drawing rules, not engine semantics: the pose (rt.pose) never contains them, and another renderer may choose its own (the Python studio adapter does not apply them).

3. Primitive blocks (the runtime executes only these)

Timing args: over (seconds; 0 = instant), ease ∈ linear | smooth (smoothstep, default) | in (u²) | out (sin(uπ/2)) | inout (=smooth) | snappy (overshoot).

opargswaits?meaning
setto: {ch: value}nobase channels jump to values
rampto and/or by: {ch: value}, over, easeover sglide base channels; a glide cut off by a scope end or a stop freezes where it is
waits or until (behaviour time)yes
fors or until, bodyuntil the time is upruns body; aborts it at the end; ends its scope
whilecond, bodypollsscope; condition checked each iteration/step
untilcondpollswait until cond
repeatn, body—
foreverbody—
togetherbranches: [[block]]until all endbranches run as parallel threads
ifcond, body, else—
stop / stopAll——end this script / every script of this instance
wavech (or list), shape, one of w/hz/every, phase, amp, offset, pow, decay, clip, syncnovalue = offset + amp·shape(w·t + phase)
hopamp, hz or every, syncnothe studio hop curve on dy, sx, sy
gaitevery, stride, give, bend, thighScale, shinScale, armSwing, bob: [base, amp] | null, roll, syncnowalk cycle on every leg of the body plan
drivech (or list), valuenochannel follows a live expression
moodmoodno
setVar / changeVarvar, value / bynoinstance variables (score, lives…)
chainch, shape, amp, one of w/hz/every, phase, decay, lag, falloff, segments, syncnoa wave travelling down ch, ch#1 … ch#(n−1) (§8.2)
followch (or list), source, gain, hz, dampingnoch lags source like a damped spring (§8.3)
reach / releasepart, to: {x, y} or at (reach), over, easeover sa hand's or foot's reach weight glides to 1 / 0 (§8.4)
viewtonothe drawing to show: front, side, back (§8.5)

wave. shape ∈ sin cos abssin possin dampsin square saw tri. w is rad/s; hz gives w = 2π·hz; every gives w = 2π/every. offset defaults to the channel's rest (0, or 1 for sx/sy), so on a scale channel the wave value is the factor. pow raises the shaped value; dampsin multiplies by e^(−decay·t); clip: "pos"|"neg" clamps after shaping. amp and offset are live (evaluated every frame — e.g. "amp": "=-20*loud" makes the bob follow the voice); w, phase, pow, decay are fixed when the motion starts.

sync. A motion's clock t is local (time since the motion started; default; no pop at start) or start (time since the behaviour started; keeps phase across phases of a behaviour).

gait. ph = (t mod every)/every. Each leg gets walkleg(ph + offset) → (thigh, shin) where stance (p < ½) is thigh = stride − 2·stride·s, shin = give·sin(sπ), and swing is thigh = −stride + 2·stride·s, shin = bend·sin(sπ). Two legs: offsets 0, ½ on part:leg:∓1/part:shin:∓1, arms counter-swing part:arm:S = −thigh_S·armSwing. Four legs: offsets [0, ½, ½, 0] on part:leg#i. Six: tripod. bob adds dy = base − amp·|sin(2π·ph)|; roll adds rot = sin(2π·ph)·roll.

hop. cyc = (t·hz) mod 1. cyc < 0.12: crouch (dy = 6k, sx = 1+0.08k, sy = 1−0.08k, k = cyc/0.12). Else c = (cyc−0.12)/0.88, h = sin(cπ): dy = −amp·h, sx = 1−0.10h, sy = 1+0.12h; landing (c > 0.86): sx = 1+0.11k, sy = 1−0.11k with k = (c−0.86)/0.14. hopair(cyc) in expressions gives h (0 on the ground).

4. Friendly blocks (desugared before running)

move {dx, dy, over} → ramp by · moveTo {x, y, over} → ramp to dx/dy · turn/turnTo {deg} → rot · scaleTo {s | sx, sy} (each a number or =formula) · squash {k} → sx 1+k, sy 1−k · setPart/turnPart {part, deg} · liftPart {part, px} · bob {amp} → wave dy abssin −amp · sway {deg} → wave rot · wobble {px} → wave dx · shake {px, deg} → wave dx (and rot), 6 times a second unless it gives w, hz or every (the wiggles default to every 1 s) · swingPart {part, deg} · window {from, to, body} → wait until from + for until to (negative times count back from dur) · the skeleton's swish flop bend bow letFollow turnView and reach {hold} (§8). Part names accept channels (part:arm:-1), legacy names (arm_l, thigh_r) and words ("left arm", "arms", "tail", "front right leg"; v0.4.1: "front paws" / "front left paw" / "front feet" are part:hand(:S), the front paws being a four-legged body's hands, "back paws" / "hind paws" part:foot; a paw named without front or back is a front paw: "paws" is part:hand, "left paw" part:hand:-1, on two legs the hands). "front legs" / "back legs" without a side is the pair's left leg's channel (part:leg#2, part:leg#0); a friendly block moves both legs of the pair (part:leg:-1#2 and part:leg:1#3, outward as for any pair; a lift lift:leg#2 and lift:leg#3; v0.4.1, before which "swing front legs" moved the front-left leg alone). A friendly block names a part, so a raw channel there must be well-formed and of a role in the vocabulary (§2): part:elbow or part:constructor is an unknown part (an error), not the warning a primitive block gets. Friendly part angles mean "lift outward" on either side: a left-side part's raw angle is negated, and a side-less paired part ("arms", "ears", "legs") becomes both sides mirrored, so setPart arms 80 raises both arms and swingPart arms 30 flaps them together. Unpaired parts (head, tail, neck) pass through raw. Primitive blocks always use raw channel angles (the skeleton's reach/release name a hand or foot like a friendly block, and a reach point's x is outward, §8.4).

5. Expressions

A value is a number or a string starting with =. Grammar, low → high precedence: || · && · == != < <= > >= · + - · * / % · unary - + ! · ^ (right-assoc) · atom (number | name | name(args) | (expr)). % is floored modulo (Python semantics). Comparisons give 1/0. Names: tau (behaviour time), local (motion time), t (runtime time), dur, loud (0..1 voice level), talking (0/1), random, pi, e, then params, then vars, then the page's globals (rt.vars). Functions: sin cos tan abs exp sqrt floor ceil round(=floor(x+½)) sign pow min max mod clamp smoothstep hopair lerp step. Validation knows exactly these built-in names (validate.js BUILTIN_NAMES); any other name must be a param, a var or a page global. Validation cannot see page globals (the page sets rt.vars at run time), so it warns about any name that is not a built-in, a param or a var; when the expression runs, a name that none of them holds (page globals included) is an error.

  • There is no dt. Under exact time (§0) a value never depends on how the clock was stepped, and a step size is exactly that, so the runtime does not provide it (v0.3 validation listed it, and such a program failed when it ran). Use tau/local and timed blocks; a param or var may be called dt.
  • Names are data. Function names, ops, params, vars, page globals, part words, channel keys, place names, cast ids and hat names are looked up as own properties (hasOwnProperty, null-prototype objects for params and vars), so constructor, toString, __proto__ are ordinary names where a name is free (a param, a var, a place, a cast id) and unknown where the vocabulary is fixed (no function, op or part has those names). This is also how the Python port behaves.

6. Interpreter contract (JS engine/runtime.js; Python py/c4c_runtime/)

The Python port (py/c4c_runtime/, docs/PYTHON_PORT.md) is the same contract in snake_case, behaviours, scenes and cartoons (§7) alike (create_runtime, add_character, start_behaviour, step_to, pose, start_cartoon, frame, validate, …), held to the JS engine by fixtures/parity_js.json (poses, frames, timelines, warnings and errors within 1e-9).

rt = createRuntime({ clock: "timeline"|"live", seed,
                     speech?: (text, {who, voice, mood, speed, pitch}) => seconds,   // §7.4
                     packs?: (id, version) => program | pack,                         // §7.5, synchronous
                     fps = 24, aspect = 16/9, maxDuration = 3600 })                   // §7.2, §7.7
rt.addCharacter(id, { legs: 2 })
inst = rt.startBehaviour(id, program, { dur, params })     // runs start-hat scripts at rt.t
rt.stopBehaviour(inst)
rt.setInputs(id, { loud, talking })
rt.emit(event, { charId, key, name })                     // talk-start/-end set talking; hats restart
rt.stepTo(T)   rt.step(dt)                                // time only moves forward
rt.pose(id) → { dx, dy, sx, sy, rot, parts: {"arm:-1": deg, …}, lift: {…}, mouth, blink, mood,
                reach: {"hand:1": {x, y, w} | {spot, w} | {spot, dx, dy, w}, …}, view: "front" | "side" | "back" | null }   // §8
rt.startCartoon(program | {cast, places, program}, { params }) → { duration, timeline }   // §7; one at a time
rt.frame() → { t, scene, place, background, grade, caption,
               characters: { id: { x, y, h, facing, visible, pose, talking } }, order: [id] }   // §7.8
rt.timeline   rt.duration   rt.warnings                                                 // §7.7
validate(program) → { ok, errors[], warnings[] }          // unknown ops/channels/parts = errors; unknown names, roles nothing draws = warnings

These are all the options: anything else passed to createRuntime is ignored (the plan's twin runtime, §7.7, is marked internally, not by an option). startCartoon either returns with the cartoon running or throws (an invalid program, a scene expression the plan cannot evaluate, a behaviour the cartoon starts failing at once); after a throw no cartoon is running: the runtime has the cartoon it had before (none, or the last one that ended), whatever the failed cartoon had started is stopped, and the next startCartoon is accepted. When the plan failed nothing else changed either: no cast was added, nothing was scheduled, and the speech answers and warnings the plan collected (it asks this runtime's speech provider, §7.4) are dropped with it. When the run failed once begun, the cast it added stays, and so do the plan's warnings and speech answers. engine/index.js poseAt(program, {dur, params, legs, inputs}, tau): one behaviour's pose at tau, the same bits as a fresh runtime started at 0 and stepped once to tau (so a renderer can scrub). It keeps the runtimes of recent calls warm (per program object and settings, a few per program) and steps one on when the next call asks for the same or a later time, so playing forward costs the new stretch of time, not the whole history (a follow-through ticks 240 times a second from its start, §8.3). It reuses a runtime only while that gives the same bits as from scratch: no thread has yielded to a step (a poll, §0; rt.polled), no pose has drawn random, and the inputs are this call's or no block or tick has read them yet (rt.inputsRead; a pose reading them does not count). Otherwise, for an earlier time, or for settings that cannot be compared by value, it starts from scratch. A program object must not change once it has run (the engine prepares each one once). The Python port's pose_at keeps runtimes warm in the same way (the same answers; docs/PYTHON_PORT.md). engine/parts.js: partChannel(name), partAngle(pose, role, side, index), legacyPose(pose). engine/director.js: cartoonProgram(content), scenesOf(program), estimateSpeech(text, {speed}).

7. Scenes and cartoons (built in v0.3: engine/director.js, tests/scene.test.mjs)

A scene or a cartoon is a program like a behaviour, run by the same scheduler (§0 holds: exact time, deterministic, poll-only step dependence). Its scripts are threads of a director instance that is never drawn. The director owns the stage (place, grade, caption), each character's placement, the lines being spoken, and the behaviour instances a scene starts with does.

7.1 Programs and the one design: scene hats

scene   := { "v":1, "kind":"scene",   "name"?, "cast"?: {id: entry}, "places"?: {name: place}, "params"?, "vars"?, "scripts" }
cartoon := { "v":1, "kind":"cartoon", "name"?, "cast":  {id: entry}, "places"?: {name: place}, "params"?, "vars"?, "scripts" }
entry   := "role description" | { "name"?, "legs"?: 2, "at"?: {x, y, h, facing}, "voice"?, "pack"?, "look"?, "note"? }
pack    := "c4c:char:sunny" | { "id": "c4c:char:sunny", "version"?: "1.0.0" }    // a character pack reference
place   := "background name" | { "background"?, "grade"? }

A cast entry is an inline descriptor (name legs at voice look, what the example cartoon uses) or, once character packs exist, a pack reference (pack, optionally with name/at/voice to override the pack's own); both forms are validated by validate.js (unknown fields are warnings, a pack must be a pack id or {id, version}). docs/PACK_FORMAT.md describes the same two forms.

  • A cartoon is an ordered list of scenes, written as scene hats. Every cartoon script starts with {"on":"scene","name":…}; scripts with the same name are one scene and run in parallel (like two actors' parts); scenes run in the order their names first appear. A hat may carry place (the scene's place, as if it began with cut {place}), mood (its grade) and at (§7.6). Two scripts of one scene must not disagree about these (validation error).
  • A scene program (kind scene, a scene template pack) is exactly one scene: its scripts have no hat or a scene hat; the scene's name is the first hat name, else the program's name. Its cast usually lists roles ({"A": "any", "B": "any"}) that the page fills with addCharacter first.
  • cast ids become characters on startCartoon (addCharacter(id, {legs}), existing characters are kept). name, voice (the default voice of the character's lines), pack (a character pack reference) and look (the character's design, for the renderer: {template, style, shape, colors: {body, skin, acc}, stretch, options, facing}, docs/PACK_FORMAT.md, what ui/shapes.js makeCharacter takes as it is) are data the runtime passes through; validation checks the look's form (not an object is an error, anything inside it a renderer cannot use is a warning). A cartoon pack's content is {cast?, places?, program} (docs/PACK_FORMAT.md: program required, nothing else allowed); startCartoon accepts it directly and merges the outer cast and places into the program (cartoonProgram).
  • A scene's scripts may use the control blocks — wait for while until repeat forever together if stop stopAll setVar changeVar window — and the scene blocks below. Blocks that move a character (set ramp wave hop gait drive and the friendly moves) are errors in a scene: a scene says does {who, behaviour}. Scene blocks are errors in a behaviour.
  • stop ends the script (or the together branch) it is in. stopAll ends every script of the scene, wherever it runs (also inside a branch), so the scene ends then — or holds its stage until a pinned next scene (§7.6). What they cut off ends as in §7.2 and §7.4, and is reported (§7.6).
  • In scene expressions: tau is time since the scene started (so wait {until: 2} means 2 s into the scene), t is runtime time, dur is the scene's length when the next scene is pinned with at (so window {from: -1} works) and Infinity otherwise, random is the director's own seeded stream (separate from the behaviours', so the plan in §7.7 matches the run), params and vars as usual. loud/talking belong to characters; a scene reads 0 (warning).
  • Held scripts. A scene script that can only wake when its scene ends — a window without to, wait {until: "=dur"}, any wait or for until Infinity — is held: it does not keep the scene going, and it ends with the scene (its for scope too, so what it started stops then). So window {from: 1, body} means "from 1 s to the end of the scene" whether or not the end is pinned; alone in its scene, the scene ends when the window's body has run. Counting back from an end that is not pinned (window {from: -1}, "=dur - 1") never arrives: validation warns, and the blocks never reached are reported in rt.warnings (§7.6). window {from, to} takes times in seconds (to too).
  • Screenplay conversion. The structure is a screenplay already: a scene hat is a scene heading, say is a line with a parenthetical, does/enter/exit are action lines, cut is a transition, parallel scripts are simultaneous action. toText prints it (§7.9).

7.2 Placement (owned by the scene)

Each character has a placement {x, y, h, facing, visible}, separate from its behaviours; behaviours only offset it (pose dx dy etc. are added on top by the renderer).

  • Units: x is a fraction of the stage width (0 = left edge, 1 = right edge) at the character's root; y is the feet line as a fraction of the stage height (0 = top, 1 = bottom; the ground is 0.92); h is the character's height as a fraction of the stage height (default 0.5); facing is the way the character faces on screen: +1 right, −1 left (a glide through 0 turns it round). Pose offsets stay in 1080p pixels, scaled by min(W,H)/1080. These are the studio page's own units (its scene table's ax, ah, baseline 0.92·H).
  • Facing, defined as a product. A character's art has its own facing A: the way the art faces as designed. The shape templates are drawn facing right (dog, cat) or facing the front (person, kid, bird, monster), A = +1; the studio's "face the other way" switch makes A = −1, and so does a bitmap drawn facing left. The renderer mirrors the designed art by facing × A (its sx, and the pose's dx and rot with it: ui/shapes.js drawCharacter mirrors "the whole performance"), so on screen the character faces facing whichever way its art was designed: a design turned to face left still enters from the left facing right, never walking backwards. In ui/shapes.js terms, whose drawCharacter({facing}) mirrors the template itself, a cartoon renderer passes the placement's facing for a shape character (the design's own switch is overridden by the scene), and a page without a cartoon passes the design's own facing, as the studio does today. A is the renderer's to know (from the character design or a cast entry's look); the engine only carries the placement's facing.
  • Validation of placement values (numbers in place, enter, exit and a cast entry's at; a "=formula" is known only when it runs): facing must be 1 or −1 (error), h must be more than 0 (error), a y outside 0..1 (the feet out of frame) is a warning.
  • Defaults: characters are spread in cast order, x = (i+1)/(n+1), y = 0.92, h = 0.5, facing +1. Nobody is visible until placed: by the cast entry's at (visible from the start), place, or enter. After exit finishes, the character is not visible.
  • Persistence: placement persists across behaviours, does replacements and scenes, until a scene block changes it.
  • The spot on stage survives an exit. exit remembers where the character stood when it began (on the axis it leaves by: x for left/right, y for top/bottom). enter without x/y comes back to that spot, per axis, else to its current placement; so exit right → enter from the left lands where it stood, and exit through the bottom → enter from a side lands with its feet on the ground. place with x/y and enter {x, y} set a new spot.
  • Glides (place … over, enter, exit) use the same segments and eases as ramp: the script waits exactly over seconds; a glide cut off by a scope end (for), a stop, or its scene ending freezes where it is (and an interrupted exit leaves the character visible); cut off at the very instant it lands (a for or a pinned scene ending then), it has landed (an exit hides). Two glides of one value: the last writer wins (an exit whose value was taken over by a later place/enter does not hide the character when it ends).
  • Off stage for enter/exit with aspect = W/H (createRuntime option, default 16/9): left x = −(0.02 + h/aspect), right x = 1 + 0.02 + h/aspect, top y = −0.02, bottom y = 1 + h + 0.02 — far enough for a character as wide as it is tall.

7.3 Scene blocks

opargswaits?meaning
saywho, text, voice?, mood?, speed?, pitch?the line's length§7.4
doeswho, behaviour, for?, params?, also?no§7.5
captiontext, for?noshows text for for s (or until its scope/scene ends)
cutplace?, mood? (at least one)nochanges the place and/or the grade (§7.6)
placewho, x?, y?, h?, facing?, over=0, ease=smoothoversets or glides the placement; makes the character visible
enterwho, from=left|right|top|bottom, over=1, ease=linear, x?, y?, h?, facing?overjumps off stage on that side, glides to the x/y given, else its spot from before its last exit, else its placement (§7.2; a given h is set at once); facing defaults to the way it moves (from left: +1, from right: −1)
exitwho, to=right|left|top|bottom, over=1, ease=linear, facing?overglides off stage on that side (facing the way it moves), then is not visible
moodwho, moodnothe character's face mood (in a behaviour, mood {mood} is the instance's own)

7.4 say

  • Its length comes from createRuntime({ speech: (text, opts) => seconds }), opts = {who, voice, mood, speed, pitch} (only the given ones; voice defaults to the cast entry's voice). Without a provider, or when it throws or returns something that is not a finite number ≥ 0 (a Promise included; warning): 0.06 s per character + 0.25 s, at least 0.6 s, divided by speed. The provider must be synchronous and pure (the same answer for the same line; measure the audio before startCartoon); the runtime asks once per distinct line and reuses the answer.
  • The block waits exactly that long. From its start to its end the speaker's talking input is 1; talk-start hats fire at the start and talk-end hats at the end, at the exact times (overlapping lines by one speaker: one talk-start at the first start, one talk-end at the last end). The mouth is the renderer's (from the audio's loudness via setInputs(id, {loud}), or a flap while talking) or a behaviour's (drive mouth); the director does not set talking by any other means, so pages should not set it themselves while a cartoon runs.
  • The line is the caption while it lasts. mood applies to the speaker from the line's start and holds until another mood is written (a later line's mood, mood {who}, or a behaviour's mood; the latest write wins; at equal times the scene's beats the behaviour's).
  • Cut off (scope end, stop, the scene ending), the line ends then: talk-end fires, the caption ends, and the record's dur becomes what was spoken.
  • Record: {t, dur, kind: "say", who, text, voice, mood} (+ speed, pitch when given) — exactly what an audio stage needs to synthesise and place the line.

7.5 does

  • Starts a behaviour instance on who at the current time: behaviour is a pack id ("c4c:beh:bounce"), {id, version}, or an inline program (kind behaviour). Ids resolve through createRuntime({ packs }), which may return the program or the whole pack (its content is used); the engine does no I/O (a page passes its catalog: const cat = await loadPacks(); createRuntime({ packs: cat.get })). A missing or invalid pack never stops the cartoon: it falls back to c4c:beh:idle (or nothing if that is missing too) and is reported in rt.warnings.
  • The instance's dur is for when given, else the time until the earliest known end of its scope: an enclosing for block, else the scene's end (from the plan, §7.7), else Infinity.
  • params are scene expressions, like every other value in a scene block: they are evaluated when the does runs, with the scene's names (its params, vars, tau, random, …), in the plan as in the run, and the behaviour receives the numbers (its own defaults fill the rest, evaluated in the behaviour). A name the scene does not know is a validation warning and an error at startCartoon (the plan evaluates it), never part-way through a render. A param the behaviour does not declare is a warning (validation for an inline behaviour, rt.warnings for a pack) and is passed anyway. A behaviour that cannot start (e.g. a default naming something unknown) is skipped with a warning.
  • It ends at for (exact time), when its scope ends (a for/while block in the scene, or the scene), or when replaced: a does on the same character stops every behaviour the scene started on that character, unless it has also: true, which adds it alongside. Behaviours started by the page with startBehaviour are never replaced by a scene. A stopped behaviour's offsets disappear at once (a new shot).
  • It does not wait: does A bounce then say A "…" bounces while talking. Use wait to hold.

7.6 Scenes, cuts, captions, the stage

  • A scene starts when the previous one ends, or at its pinned at (seconds from the cartoon's start, normally a song time). It ends when all its scripts have ended (held ones aside, §7.1) — unless the next scene is pinned, in which case it holds its stage until that time, and anything still running is cut off then (glides freeze, lines end). The pin wins: a pin that has already passed when the scene before it starts (an earlier line ran longer than planned, e.g. TTS) cuts that scene off at once — it runs 0 s, its lines are recorded with dur 0 — and the pinned scene starts then, late. Validation warns when a pinned scene is pinned before an earlier pinned one.
  • Nothing a scene loses goes unreported. When a scene ends, rt.warnings gets one message for it (with the scene, the time and the pin) if it lost anything: squeezed to 0 s by a pin that had passed (and how late the pinned scene starts); a line cut off part-way by a pin (how much was spoken); a caption whose for ran past the scene's end (how much was shown — a caption does not wait); a does … for whose time ran past the scene's end (how much of it ran — a does does not wait either); a glide (place … over, enter, exit) cut off part-way by a pin (how much of it ran; a cut-off exit leaves the character on stage, and every cut-off glide leaves it where it froze for the next scene); blocks a script had not reached (cut off by a pin, or after a wait for an end that never comes). A forever/while loop cut off by a pin and a hold at the end (a wait with nothing after it) are expected and not reported — but a line or a glide cut off part-way inside a loop is. Something that ends exactly when the scene ends (a glide landing, a for running out) lost nothing, and "exactly" allows for rounding: what was due to end at most a microsecond after the scene (0.1 + 0.2 is 0.30000000000000004, not 0.3) is not a loss. The one exception is an exit, which either has landed or has not: one that rounding put a hair after the scene's end has not landed, so its character stays on stage, just out of frame, and the message says so ("lands 5.6e-17 s after the scene ends (rounding)"). Amounts are printed to 3 decimals, more when 3 would print two different amounts alike ("0.3 of 0.3001 s done"). A does without for (or with for Infinity) and a caption without for end with their scene by definition. A scene ended by stopAll says so ("ended at 1 s by "stop all"") instead of advising a wait, and lists what the stopAll cut off in the other scene scripts as well: a line or a glide cut off part-way (an exit frozen on stage), blocks they had not reached. The script that runs the stopAll means to stop there, so the blocks after it (in it, or in the other branches of its own script) are not losses, but a line or a glide cut off in its other branches is. Ending a script also ends its inner scopes (a for, a while) and what was started in them, so a caption or a does … for started there whose for had not run out is cut off by the stopAll itself, in any script (the stopping one too, as the same caption at scene scope is cut off by the scene's end), and reported with the amount as of the stopAll; what the scene itself started stays until the scene ends and is reported then. When the next scene is pinned, the stage holds after the stopAll until the pin: the stopAll's cut-offs get a message of their own ("was stopped at 1 s by "stop all" and held its stage until the pinned start of "b" at 3 s: …"), and the scene's end reports what the pin cut off, as usual. The plan (§7.7) finds all of these, so they are in rt.warnings as soon as startCartoon returns, and a run that follows the plan repeats exactly the same messages, which are not added a second time. A run follows the plan unless the scene scripts poll or loop without waiting and the renderer steps off the plan's frames (§7.7); such a run can cut things off at other times than the plan did and then adds its own messages beside the plan's.
  • When a scene starts: its place/mood apply, behaviours already running hear {"on":"scene","name"} hats (a behaviour started by the scene itself starts with its start hats), and its scripts start.
  • When a scene ends, everything it started ends with it: its does behaviours, its captions, its lines. Placement, place and grade persist into the next scene.
  • cut {place, mood}: the place changes; the grade becomes mood, else the place entry's grade, else "normal". cut {mood} changes only the grade. The scene's hat place/mood work the same way.
  • Captions: the frame's caption is the latest-started caption still showing (a caption block or a line). When it ends, an earlier one still showing returns. A newer caption block of the same scope that ends no earlier hides an older one for good (only their scope's end cuts caption blocks short, and it cuts both at once), so the director forgets the older one (§7.7).

7.7 The plan: rt.timeline, rt.duration

startCartoon first runs the director alone on a twin runtime (no behaviours: the director cannot read characters, so its timing does not depend on them), stepping on the cartoon's own frames t0 + k/fps (t0 = the time startCartoon was called, k = 1, 2, …) up to maxDuration. So before the first frame:

  • rt.timeline is the complete record list in time order: scene {name, place, grade}, say, does {who, behaviour, also?}, caption {text}, cut {place, grade} (dur = until the next cut or the scene end), place {who, x?, y?, h?, facing?}, enter {who, from}, exit {who, to}, mood {who, mood} — each with t (runtime seconds) and dur (seconds actually run).
  • rt.duration is the cartoon's length (null if it does not end within maxDuration).
  • does without for learns the rest of its scene from the plan. The plan equals the run exactly when the scene scripts do not poll (while, until) or loop without waiting; when they do, it matches a renderer stepping on the same frames, stepTo(t0 + k/fps) (§0), whatever t0 is. Tested: plan = run, also from an off-grid t0. Page globals (rt.vars) are copied into the plan as they are at startCartoon; a page that changes them later gets a run that may differ from the plan.

Cost. Planning and playing take time proportional to the number of blocks the scene scripts run (plus one step per frame for the plan's grid). The director keeps only what is still running: does behaviours and captions whose for has not run out, indexed by the scope they belong to and by character. A caption without for (or with a for of Infinity) is not one of them: it lasts until its scope ends and nothing can cut it shorter, so all it needs is its timeline record's dur set then; its scope keeps a list of such open records (one reference per record, i.e. part of the timeline's own growth), and a caption whose end is not a number (for: "=0/0") keeps nothing at all. Replacing a does, a for running out, a scope ending all remove what they end at once. For display it keeps only the captions that may still show: the lines being spoken, and per scope a stack of caption blocks that no newer one hides for good (§7.6), newest on top with ends decreasing towards it — a new caption pops every one it hides and every one that has ended, and a frame pops the ones that ended by its time, so a frame looks at one caption per scope and line. A caption that ends no earlier than the ones before it in its scope clears them, so a loop of captions holds one or two, whatever its mix of for; the stack grows only while each caption ends earlier than every one before it, and then each one on it will show again when the newer ones end (unless the scope ends first). So a scene that loops for an hour does not slow down as it goes, in the plan or at 24 fps (tested: a 10-minute loop of does, does … for, say, caption … for and together — 150,000 timeline records, 60,000 lines — plans in about 0.2 s on a laptop, where it took 10 s before v0.3.1 and grew quadratically, and the director's working state stays under a hundred entries from 30 s to 10 minutes; a 10-minute loop of captions without for between captions with for, in the scene's scope and in a for's, with lines, holds at most 8 captions for display and plays all 14,400 frames in about 0.25 s, each with the expected caption; before, the display set grew by one caption per loop, and an hour of such a loop took 94 s to play in an earlier version, 0.24 s now). What does grow, by design, is the timeline (one record per block run) and what the program itself keeps running: does … also without for adds a behaviour that lives until its scope ends, and a caption without for holds its timeline record open until then (its dur is known only when the scope ends), even once a newer caption hides it: its scope's list of open records grows with the timeline, never the live items (tested: a 5-minute loop of 100 captions a second without for, with for Infinity and with for NaN, in the scene's scope and in a for's, keeps at most 4 live items, scopes and displayed captions, where an earlier version held one live item per caption, 540,001 after 9 minutes; the 10-minute caption loop above now holds 23 live items at its end, 10,959 before). The runtime's event queue holds one entry per waiting thread and per pending timer (a scene pin, a does … for's stop): a thread that is rescheduled leaves its old entry behind, and a while/until polling inside a for reschedules every step, so the queue is compacted whenever it doubles, and the stop timer of a does … for that was replaced early is cancelled. So a script that polls inside a 10-minute for, in a cartoon or in a behaviour on the live clock, keeps a queue of a few dozen entries (tested; it grew by one entry per step before). Compaction never changes the order events run in.

A runtime runs one cartoon at a time. Once it has ended (frame().scene === null after rt.duration), another may start on the same runtime, at the current time, on a fresh stage (nobody placed, no moods; characters and the page's behaviours are kept). Starting one while another is still running is an error. A startCartoon that throws never leaves a cartoon running (§6).

7.8 rt.frame()

{ t, scene: name | null, place: name | null, background, grade: "normal" | …, caption: text | null,
  characters: { id: { x, y, h, facing, visible, pose: rt.pose(id), talking: 0 | 1 } },
  order: [id, …] }

background is the place entry's background (or the place name). Without a cartoon, every character is visible at its default placement.

  • order lists every character id once, in the order the characters were added: first the ones the page added with addCharacter, in the order it added them; then the cast members that were not there yet, in the cast's key order (a cast is a JSON object, so this is JavaScript's Object.keys order: ids that are array indices, such as "7", come first in ascending order, then the others as written; the Python port reads the cast the same way). It is the order to draw them in, first drawn first (behind), the same in every frame, and the same in both engines. Each frame gets its own array.
  • characters has exactly one own key per character id, each defined rather than assigned, so an id such as __proto__ or constructor is a key like any other and never the object's prototype; look a character up by its id with an own-property test, never with in or a bare lookup of an id that may not exist. Its key order is not meaningful: JavaScript lists array-index ids first whatever order they were added in, the Python port lists them in the order added. Iterate order.
  • The default placement (§7.2: spread in cast order, then the other characters) is not this order: a cartoon spreads its cast first, whatever the page added before.

7.9 Text view (engine/text.js)

toText prints scenes as a screenplay-like outline: cast: A = Sunny, B = Grump · scene "Morning" at park · A says "Good morning, Grump!" (happy) · B does annoyed for 2 s · B also does wag · caption "One bite later..." for 1.6 s · cut to park (bloom) · A enters from the left in 2 s · A exits to the left in 2.4 s · place A at x 0.3, facing left in 1 s · B looks happy. An inline behaviour is printed indented under its does. One-way for now; parsing it back (the screenplay view, A: Hello! (happy)) is Phase 3.

7.10 Not built yet

sing {who, text, at} (lines timed to a song), camera blocks (zoom, pan), clip on say (a recorded audio clip instead of TTS), interactive scene hats (key/click in a cartoon).

8. The skeleton (v0.4: engine/runtime.js, engine/desugar.js, tests/skeleton_engine.test.mjs)

Every character is a tree of bones (plan §1d): hips → torso → head, upper arm → forearm → hand, thigh → shin → foot, tails, ears, hair. v0.4 adds what makes the tree act like a skeleton: chains (a tail whips, a back bends), follow-through (ears flop after a hop), reach (a hand on the hip), views (front, side, back drawings) and joint limits (in profile an elbow or a knee bends one way only; seen head on it bends either way on screen, docs/SHAPES.md §3.4). The engine computes channels and two new pose keys; the renderer (ui/shapes.js, docs/SHAPES.md) draws them, and the Python port (py/c4c_runtime) runs the engine's half with the same bits (§8.9). Exact time, determinism and step independence (§0) hold for all of it.

8.1 The shared interface (the engine and the renderer both build to exactly this)

  • Chains. A chain is a run of nodes with the same role whose segments are numbered from the root of the chain: part:<role>#<i> (i = 0 nearest the body). Tails (role tail, 4 segments), spines (role torso: torso#0 = the belly / waist bend, torso#1 = the chest), ears and hair where they look good. Every segment is a child of the previous one, so the general channel part:<role> (no index) rotates segment 0 and the rest follow: exactly today's look for every existing move (backward compatible). Index channels add per-segment bends. Quadruped legs keep #i = leg number: the meaning of #i depends on the role (§2). The engine writes segment 0 on the general channel (part:tail, part:ear:1) and segment i ≥ 1 on #i (the chain motion and the friendly blocks never write #0; a raw channel block still can). So a part a template draws in one piece moves with the chain's first segment, and on a chain the root (which reads the general channels and its #0) turns by exactly its share. How the renderer reads them (ui/shapes.js applyPose, docs/SHAPES.md §3.2): a node that is no chain follower reads the general channels and its index ones (engine/parts.js partAngle); a chain follower (segment i ≥ 1 of a chain the template draws) reads only its index channels part:<role>#i and part:<role>:<side>#i (plus curl × the general channel where a template says so), never the general channel again (swingPart tail 25 turns a four-segment tail at its root, as a one-piece tail turned, not 100° at its tip); and the last segment a template draws (a one-piece part: its only one) also reads the index channels of every segment beyond it (v0.4.1), so a part drawn in fewer pieces shows the whole bend.
  • Reach. pose.reach = { "<role>:<side>": { x, y, w } } for hands and feet (role hand or foot, side −1 / 1). x, y are character-local 1080p pixels with the origin at the feet centre, x toward the character's own front / right as drawn, y up; w is the 0..1 blend weight (ramped by the block; the pose holds it to 0..1). Character-local means in the character's own frame: the renderer draws the back view as the front mirrored and mirrors a point with it (docs/SHAPES.md §3.5), so a point stays where it is relative to the character. A named spot is { spot, w } instead (spot ∈ hip head chest mouth; the block's own field is at): the renderer resolves the spot on the limb's own side (ui/shapes.js reads r.spot, else finite r.x and r.y). v0.4.1: a spot moved by an offset is { spot, dx, dy, w }, dx and dy fractions of the character's height (dx toward the character's front in profile and outward seen head on, so the left limb's dx goes left as its spot is on the left; dy up); the renderer resolves the spot, then adds dx × and dy × the character's drawn height. An offset of 0, 0 is the spot itself, { spot, w }. (The renderer places a spot for the character's own body shape, and follows an offset beyond the limb's reach only as far as the limb reaches: docs/SHAPES.md §3.5.) The renderer solves 2-bone IK (upper limb + lower limb) toward the target, blending with the FK pose by w; a template may declare a spot one of its limbs cannot reach (a dog's front paws and its hips), and then the reach leaves that limb as the rest of the pose has it (ui/shapes.js canReach). The renderer also draws a reaching limb at the depth its target implies (in front of the body seen from the front; behind it from behind for the chest and the mouth).
  • View. pose.view is "front" | "side" | "back", or null = the template's default (front for people, side for animals drawn in profile). The latest writer wins, like mood. The renderer draws the template's tree for that view; the switch is instant in the renderer, and the friendly turnView makes it look like a turn by squashing sx to 0 and back around the switch.
  • Joint limits are a renderer rule: each template node may declare limits: {min, max} in degrees (counter-clockwise positive, relative to rest); the renderer clamps the summed angle (every channel the node reads, §2); the pose data is unchanged (rt.pose reports the unclamped sum).
  • Follow-through is engine-side and deterministic: it produces ordinary part channels (§8.3).

8.2 Chains

chain {ch, shape = sin, amp = 1, w | hz | every (default every 1 s), phase = 0, decay = 0, lag = 0.08, falloff = 1, segments = 4, sync = local} is a motion (it does not wait; it lives until its scope ends; start-or-keep; §2). ch is one part channel without an index (part:tail, part:ear:1); segment 0 is ch itself, segment i ≥ 1 is the channel ch#i (§8.1):

t_0 = tl,  t_i = tl − i·lag  (tl: the motion's clock, as for wave: local or since the behaviour started)
segment i = 0                                    when t_i < 0  (the wave has not reached it yet)
segment i = amp · f_i · shape(w·t_i + phase)     otherwise, f_0 = 1, f_i = f_(i−1) · falloff

with the wave shapes of §3 (dampsin fades by e^(−decay·t_i), the segment's own clock). amp is live; w, phase, decay, lag, falloff are fixed when the motion starts. f_i is a running product (not pow), so both engines get the same bits. Every segment channel is written from the start (0 until the wave arrives).

Friendly chain blocks (desugared; part words and the "lift outward" rule of §4; a side-less pair moves both sides mirrored). A chain role is spread over its segments — tail 4, torso ("body") 2, ear 2, hair 2 (engine/parts.js CHAIN_SEGMENTS; segments overrides, a whole number 1–16). Any other part is not a chain: it moves whole (the plain channel, no index), and a segments there is ignored (a warning). (v0.4 also listed the neck, with 2 segments; v0.4.1 does not, since no template draws a neck: a block that names it is a warning, §8.6.)

blockdefaultsbecomes
swish {part, deg, every / hz / w, phase, lag, falloff, segments, sync} ("swish tail 40° every 0.8 s")tail, 40°, every 0.8 s, lag 0.08 s, falloff 1chain {ch, shape: sin, amp: ±deg/n, …speed, lag, falloff, segments: n} per side — deg is the whole part's swing, shared by its n segments, so the tip swings about deg (the segments add up); a part that is no chain: wave {ch, amp: ±deg} (= swingPart)
flop {…the same} ("flop ears 25° every 1.2 s")ears, 25°, every 1.2 s, lag 0.15 s, falloff 1.3 (the tips move more)the same
bend {part, deg, over, ease, segments} ("bend body 20° in 0.5 s")body, 20°ramp {to: {ch: ±deg/n, ch#1: ±deg/n, … ch#(n−1)}} — the angle shared by the segments (the tip turns deg in all); no chain: ramp {to: {ch: ±deg}}
bow {deg, over, ease} ("bow 30° in 0.5 s")30°bend body −deg: forward, toward the front as drawn (clockwise); bow 0 stands up straight

A formula is divided as text: deg: "=x" over 3 segments is "=(x) / 3"; numbers are divided as numbers. segments: 1 writes the part's own channel only. A template that draws the part in one piece (a cat's ears, a person's hair, a bird's tail, a dog's body) shows the whole bend (v0.4.1: its piece reads every segment's channel; in v0.4 it showed only segment 0's share, deg / n); one with more segments than the block writes leaves the rest straight (the monster's third antenna segment). A body standing on legs that hang from it turns only as far as its planted legs follow (the renderer eases it into that limit, docs/SHAPES.md §3.7): through the bow pack (40°) a dog's body tips about 22°, a cat's 24°, a monster's 30°; the person and the kid, standing on their hips, bow 40°.

8.3 Follow-through

follow {ch (or a list), source, gain = 1, hz = 2, damping = 0.3} is a motion: its channels lag the source channel like a damped spring, y'' = w²·(u − y) − 2·damping·w·y' with w = 2π·hz, following the source's value u; each channel gets rest + gain·(y − u) (rest: 0, or 1 for sx sy). gain is live; hz (held to 0..60) and damping (below 0 is 0) are fixed at the start, a value that is not a number counting as 0. A spring of 0 Hz (a formula giving 0, a negative number or NaN) has no pull: the motion adds nothing (its channels stay at rest) and never ticks.

  • The source is the value the character's pose has on that exact channel key (dy, rot, part:head, part:torso#1 …), from every instance's base channels and motions, combined as in a pose (§2) — leaving out every follow motion's output (a follower never follows a follower, so the order followers run in never matters). mood is not a number (an error).
  • The grid. A follow motion that starts at t0 ticks at t0 + k/240 for k = 0, 1, 2, … (FOLLOW_RATE = 240; h = 1/240). The scheduler runs the ticks in time order with the block events, after the events at the same time (a block at a tick's time is seen by that tick), so the state at T depends only on what happened up to T: stepping at 24 fps, 30 fps, odd steps or one jump gives the same bits (tested). At tick k: read u = the source at t0 + k/240, then k = 0, or y or v not finite: y = u, v = 0 otherwise: v = (v + h·(w2·(u − y))) / den; y = y + h·v (w2 = w·w, den = 1 + h·(2·damping·w)) — semi-implicit Euler with the damping implicit (stable for every hz ≤ 60 and every damping ≥ 0), only + − × / on doubles in exactly this order, so the Python port gets the same bits from the same source values. A NaN or an Infinity in the source makes that tick's output NaN, as in any motion, and the next tick starts settled again (the spring recovers as soon as the source does). The output at T is rest + gain·(y − u) of the last tick at or before T (y and u of that tick): a step of 1/240 s. Because the tick integrates with the source read at its end, the grid answers the continuous spring one tick early (tested within 1 px of a 40 px step against the exact solution, under-, critically and over-damped). That holds for springs up to 6 per second: a faster one swings further than the continuous spring (at 30 per second and no damping its first swing is 43.5 px for a 40 px step, at 60 per second 64.6 px), so validation warns about a spring hz above 6 (v0.4.1, §8.6).
  • A follower stops when its scope ends (the motion is removed) or its instance stops; its next start begins settled again (y = u). It costs one source read per tick (the motions that may write that channel). rt.stepTo(Infinity) returns once nothing is left to run; a follower that is still running ticks for ever then, as a forever with a wait in it runs for ever.
  • Inputs: a source that reads loud/talking at tick times sees the inputs as set before the step, so such a follower is as step-dependent as a poll (§0); likewise a source motion whose live value draws random draws at every tick. Programs without live inputs or random in their sources are exact.

Friendly: letFollow {part, after = body, amount = 1, hz, damping, segments} — "let the [ears] follow [the body]". after is body or a part word. It becomes one follow per source, gain = amount × f with the factor f worked out first as a double, then multiplied (a formula amount becomes the text "=(amount) * <f>", f written as JavaScript's String(f)):

  • body: the bounce dy per side with f = (sign × 0.4) / n (degrees per pixel; sign the friendly side, so a hop flops both ears outward together), then the tilt rot and the spine's segments (part:torso, part:torso#1) each with f = 1 / n on every target channel, both sides alike (the part keeps its angle for a moment while the body turns, then catches up).
  • a part word: its own channels, the plain one and, for a chain role, its segments #1 … #(m−1) (m: the role's own count), each with f = 1 / n on every target channel. A pair named without a side (ears, arms) is both sides' channels: a target on one side follows the same side (f = 1 / n); a target with no side (the hair, the tail) follows the pair's friendly angle, f = (side × 0.5) / n (half the right side, minus half the left: both sides swinging outward together are one swing). The targets are the part's channels (sided): the plain channel and, for a chain role, #1 … #(n−1) (the gain shared over n segments, so the tip lags by the whole amount and the chain curves).

8.4 Reach

reach {part, to: {x, y} | at: spot (+ dx, dy), over = 0.4, ease = smooth, hold?} and release {part, over = 0.4, ease}:

  • part is a hand or a foot: right hand, left foot, hands / feet (both), paws (the front paws: hands) …; anything else is an error. Without part: the right hand. A four-legged body's front paws are its hands (v0.4.1): front paws and front left paw are hand:-1 / hand:1 (the renderer moves the front legs for them), back paws the feet; a paw named without front or back is a front paw (paws, left paw: hands; a later fix in v0.4.1, before which reach paws for the mouth moved a dog's back paws, which reach no spot). (In v0.4 "front" was dropped: front paws named the back paws.)
  • The limb's weight glides to 1 (reach) or 0 (release) over over with ease, exactly like a ramp of a base channel: the script waits over; cut off by a scope end or a stop it freezes; one value per limb per instance, the last writer wins. It starts from the limb as the pose has it when the block runs (whichever move reached or released it last), so a move can take over a hand another move put somewhere: a release keeps that target (the spot, or the point frozen where it is) and glides the weight down from there. The pose holds the weight to 0..1 (a snappy glide overshoots; the weight does not).
  • at names a spot (hip head chest mouth): the pose carries {spot, w}. to: {x, y} is a point (numbers or formulas, evaluated when the block runs) in the interface's character-local coordinates (§8.1): x toward the character's own front / right as drawn, the same for either side (a point is a place, not an angle: reach hands to x 80, y 300 puts both hands at that one point, and a left hand reaching forward in a side view has a positive x like the right), y up from the feet; the pose carries {x, y, w} exactly as given. A limb already reaching for a point (w > 0, in this move or another) glides its point to the new one over the same over and ease; from a spot to a point or to another spot (or from nothing) the target changes at once.
  • (v0.4.1) at with dx, dy (numbers or formulas, evaluated when the block runs; either may be left out, 0): the spot moved by fractions of the character's height, dx toward the character's front in profile and outward seen head on, dy up. A spot is anatomy; the offset makes a place near it that fits every character, whatever its size ("hand up beside the head": reach right hand at head dx 0.1 dy -0.03). The pose carries {spot, dx, dy, w}; with no offset (or 0, 0) exactly {spot, w}. A limb already reaching the same spot (w > 0) glides its offset from the one it has (none is 0, 0) to the new one over the same over and ease, as a point glides; another spot or a point is set at once. A release keeps the spot and its offset. dx, dy with a point (to) or without at are an error.
  • hold: s (friendly): reach …, wait s, release {part, over, ease} — reach, hold, let go the same way.
  • In the pose, per limb, the instance whose reach / release ran last wins (ties: the later instance), with its own weight and target; a limb never reached by it is left out. A stopped instance's reach is gone at once (like its offsets). pose.reach is {} when nothing reaches.

8.5 Views

view {to} sets the view (front side back; to is required) at once; it is per instance, the latest write wins across instances (ties: the later instance), and pose.view is null until a move sets one. A stopped instance's view is gone. Friendly turnView {to = side, over = 0.24} ("turn to the side in 0.24 s") becomes together { for over: drive sx "=abs(cos(pi*local/over))" } { wait over/2; view {to} }: the width squashes to 0 and back (multiplying whatever sx the move has), the view switches at exactly the narrowest point, and the script waits over. A turn of 0 s (or less) switches at once (a warning).

8.6 Validation

Errors: a chain ch that is not one part channel without an index; segments that is not a whole number 1–16 (a formula included); a follow without ch or source, or with an empty list of channels, a source that is not one channel, mood as ch or source; a view without to; a spring hz ≤ 0 or > 60, damping < 0 (numbers as written); a reach / release part that is not a hand or foot; reach without to or at, or with both; a point that is not {x, y} (both numbers or formulas, nothing else); dx / dy without at (v0.4.1); dx / dy that are not numbers or formulas; an unknown spot or view; a friendly chain block's part that is a raise (lift:) or a chain role with an index (part:tail#2); an unknown after. Warnings (odd values): a chain down a leg (its #i is the leg number), down a part nothing draws as a chain, or a role nothing draws; segments on a part that is not a chain; a part no character draws (v0.4.1: the neck, engine/parts.js UNDRAWN_ROLES: swish neck, bend neck, letFollow neck, letFollow ears after neck, a raw part:neck in any block: "no character draws a neck, so … moves nothing"); a lag below 0 or above 1 s; a falloff ≤ 0 or above 2; a spring hz above 6 (v0.4.1, §8.3); a turnView of 0 s or less; a negative hold. A raw channel in a primitive skeleton block (chain {ch: "part:elbow"}, follow {ch, source}) of a well-formed role nothing draws is a warning, as for every primitive block (§2); the friendly blocks name parts, so there an unknown part is an error (§4). Every skeleton block moves a character, so in a scene it is an error (use does).

8.7 Text view

sin wave down the tail chain · every 1 s · amp 20 · 0.1 s later each segment · each × 0.8 · 4 segments · tail 0.5 × behind dy, springing 2 per second, damping 0.3 · reach hands for the hip in 0.4 s · reach right hand to x 80, y 300, hold 1 s, let go · reach right hand for the head + 0.1 out and -0.03 up (× its height) in 0.4 s · let go with right hand in 0.3 s · show the side view · swish tail 30° · every 0.7 s · flop ears 25° · every 1.2 s · bend body 20° in 0.5 s · bow 35° in 0.6 s · let the ears follow the head × 2 · turn to the back in 0.24 s.

8.8 Packs

Written with the friendly blocks, in packs/skeleton/, their permanent home (manifests like every behaviour pack, type: "behaviour", ids c4c:beh:<slug>; tools/packs.mjs checks and hashes them and lists them in packs/index.json with file: "skeleton/<slug>.json", so the site's pages load them like the others; the parity fixture records them in its skeleton_runs, skeleton_packs, pose_at and pose_at_seq sections and the Python port runs them from there): whip_tail (swish tail, flop ears, a bob, the hair follows), bow (1.2.0: turn to the side, bow 40° easing in and out (1.1.0 started fast, ease: out, and a dog's knees snapped into the fold) and back, the head, hair and ears follow, turn to the front; a dog or a cat bows as in play, its feet planted; the bird, which has no side view, bows toward the viewer with a nod, its wings sweeping out at its sides), hands_on_hips (reach both hands for the hips, a sway, the head tilting; a dog or a cat cannot reach its hips and stands on four legs), wave_and_look (1.3.0: the head tilts toward the right hand, which goes up beside the head with the offset form, at: head plus dx, dy (0.09, −0.065), and waves out and in three times between (0.13, −0.11) and (0.07, −0.02), lets go, the head comes back; every character in its own view: the elbow out and the forearm up, a dog's or a cat's front paw raised in front of its chest; 1.1.0 aimed so near the head spot that the elbow went up beside the face, 1.2.0's inner end (0.045, −0.03) put the hand on the temple), floppy_hop (a hop; the ears, tail, hair and hands follow), look_around (side, the other way and back, front, back, front, over and over). They run on 0, 2 and 4 legs with finite poses, the same whatever the stepping (tested), and tests/skeleton_drawing.test.mjs draws every one on every template. packs/behaviours/ holds the 34 packs shipped before v0.4 (tests/skeleton_engine.test.mjs fails if a pack there uses a v0.4 block: the older Python studio's PackPoser reads that folder and its old rigs cannot draw reach or view).

8.9 Not built yet, and the Python port

The renderer side (chained templates, IK toward pose.reach, view trees, joint limits, depth, planted feet) is ui/shapes.js (docs/SHAPES.md). Scene-level views (view {who} in a scene) and a reach toward another character ("point at the dog") are not built. The Python port (py/c4c_runtime) runs §8: chain, follow (the tick grid in its scheduler, exactly as above), reach / release with offsets, view, the friendly blocks' desugaring (the same output keys and formula text), validation's messages, the pose keys reach / view, and warm pose_at; the parity fixture holds it to the JS engine on the six packs of §8.8 (run from packs/skeleton/, which stays their home) and on the skeleton scenarios, within 1e-9 (docs/PYTHON_PORT.md).