Motion — path sequences
The motion subsystem runs path sequences — compact strings of movement verbs (
"FD 100 RT 90 …") — on targets (the turtle, a sprite, anything with a position and heading), in the background, alongside whatever issued them. The path string is the interchange format and the engine is language-neutral: AntBASIC'sDRAW/MOVE, a Luau tool viarequire("motion"), an app, or the OS all drive the same core. One engine, several clients.
Status: design. Not built yet. This fixes the model — the path language, targets, the background/lifetime rules, compile-once, and the execution roadmap — so it can be built against a settled shape.
What it is
A motion sequence is data — an ordinary string of verbs and numbers, built however the caller likes and parsed only when applied. motion compiles that string once into a compact op list and plays it on a target over time, without blocking the caller. Because the string is the contract, anything can produce one (a program, a level editor, a tool) and anything can run one.
The subsystem owns four things: the path language (parse + check), the targets, the background executor (lifetime, timing), and the replace-on-reissue discipline. Everything above it — DRAW / MOVE in BASIC, motion.play in Luau — is a thin client.
The contract that makes all of this hold: every motion primitive is a deterministic function of a progress parameter t ∈ [0, 1]. The engine introduces no randomness and no wall-clock dependence, so the same path — with the same parameters and transform and easing — yields the same motion every run. That is the property that keeps a sequence "just data."
The path language
A path is a sequence of verbs, each taking a number or two:
| Verb (long / short) | Effect on the target |
|---|---|
FORWARD n / FD, BACK n / BK |
move n units along the heading (pen draws if down) |
LEFT d / LT, RIGHT d / RT |
turn d degrees |
SETPOS x, y, SETHEADING d, HOME |
absolute placement |
PENUP / PU, PENDOWN / PD, PENCOLOR c |
pen state (drawing targets) |
WAIT t |
hold for t — timing / animation beats |
CURVE d, r, ARC d, r |
glide a smooth curve / circular arc — bend d degrees over radius r (a Bézier under the hood) |
MOVETO x, y |
pen-up jump to a point — lift, move, drop (SVG M) |
REPEAT n ( … ) |
repeat the bracketed sub-path n times |
REPEAT n ( … ) nests: REPEAT 4 (FD 100 RT 90) is a square, REPEAT 36 (REPEAT 4 (FD 100 RT 90) RT 10) a spun square. The verbs are a mini-language with their own grammar entries, so a path is checked when compiled, a malformed one returns a column and a reason, and HELP FD works — the same machinery the language uses.
Compile once, replay
Parsing is separated from playing:
compile(path)parses and validates the string into an op list — a compact, reusable representation.play(target, path_or_oplist)runs it.
A caller that replays a path (a walk cycle, a UI flourish) compiles once and plays the op list many times; a one-off passes the string and lets play compile inline. Compilation is the single parse point, and where a bad path is reported.
Targets
A target is anything with a position and a heading: the built-in turtle (which also carries a pen and draws), a sprite (by id — motion moves it, no pen), or a caller-supplied object exposing x / y / heading. The verbs mean the same thing on all of them; the target decides what "move" does — ink on a layer, a sprite's position, a value updated each tick. That split is the point: the same path draws, moves, or animates depending only on where you send it.
The background model
play starts a sequence and returns immediately — it runs alongside the caller, advanced by the executor each tick. Its lifetime:
- it runs until the op list completes, or repeats if played with
loop, until stopped; - re-issuing to the same target replaces whatever was running there — per-target single, no stacking or queuing (the model sprites and the Copper already use);
stop(target)cancels the target's sequence;running(target)reports whether one is still playing — so a caller can await completion or fire and forget.
Timing is the executor's: FD 100 unfolds over ticks at the sequence's speed — a per-play float (playback parameters) — and WAIT inserts beats — which is what turns a draw into an animation. A speed of instant plays the whole op list in one tick (a static drawing); a finite speed animates it.
Playback parameters and per-target transforms
Two kinds of adjustment ride over a compiled path, so the same op list plays many ways without recompiling.
Per-play options, passed to play, apply to that one playback:
speed— a float, not a flag:1.0is authored rate,0.5half,2.0double, andWAITbeats scale with it."instant"(speed 0) lays the whole op list down in one tick — a static drawing.scale— a float multiplier on all distances (0.5draws the shape half-size).rotation— degrees added to the starting heading, so the whole sequence plays turned.
Per-target master transform — the more powerful control, and the scene-graph idea: each target carries an offset, scale, rotation, and speed that composes over whatever is playing on it, set once and live.
motion.transform(target, { x = 120, y = 80, scale = 2, rot = 45, speed = 1.5 })
A path is then authored in local coordinates and the target places, sizes, spins, and times it globally. Move the target's offset and everything playing on it moves with it, mid-sequence; scale the target and the running animation grows in place. One authored path, reused across positions and sizes — a walk cycle authored once, played on a dozen sprites at different points and scales.
Composition order is fixed, so the result is always predictable:
path → per-play (
scale,rotation,speed) → per-target master (offset,scale,rotation,speed)
Each stage is a plain affine transform (translate / scale / rotate) plus a time scale (the two speeds multiply). Because every stage is affine and every time factor a rational multiplier, the pipeline stays exactly deterministic — scale 0.5 is exact, speed 0.5 is "advance half as far per tick," and no stage introduces randomness or unreasonable float drift. It is still just data: the same path, played with the same parameters onto a target with the same transform, produces the same motion every time.
Curves and easing — borrowing from SVG
The path language stays "deterministic data," but SVG shows how much richer that can be without giving it up. The rule: borrow SVG's path grammar, not its rendering model.
Curves. SVG's Béziers and arcs are pure polynomials — fully deterministic — and they are motion paths, so a sprite gliding a curve is the same primitive as drawing one. AntBASIC exposes them turtle-relative — CURVE d, r, ARC d, r — which fits the beginner front door and the existing FD / RT feel; underneath they compile to the same Bézier op an SVG-style C / Q / A would, so a faithful SVG surface can be offered over the same representation later. This is the jump from "polygons" to "anything."
Absolute and relative. SVG's uppercase/lowercase (L vs l) convention, formalised: every movement verb has a relative form (turtle-style: FD, RT, CURVE) and an absolute one (SETPOS, SETHEADING, MOVETO), so a path mixes "forward 100" and "go to (x, y)" freely. Both are exact — free determinism.
Easing — the "less rigid" lever. The biggest expressiveness win that stays repeatable: an easing function reshapes how progress maps to time — linear, ease-in, ease-out, or a cubic-bezier(…) like CSS / SVG SMIL. It makes motion feel organic — accelerate, decelerate, overshoot — while remaining a deterministic function of a 0→1 parameter. Set per-play (opts.easing); a later EASE(fn) ( … ) wrapper can scope it to a sub-path if wanted.
Deliberately not taken from SVG: its styling / CSS model (a rendering concern — colour and pen live on the target, not the path) and anything time-based that isn't a pure function of progress. Those are precisely what would break "the same string always moves the same way."
So the ladder is straight moves → curves / arcs → easing → transforms — every layer a pure, composable function of progress, every layer still just data.
The executor moves down the stack
The path language and the API are the stable surface; what executes the op list migrates over time, without any client changing:
- A CM5 OS library — parses and runs the op list in software, drawing to an OS compositor layer. Pure DeMon, no FPGA; enough to build and test the whole model.
- Hardware sprites — the OS applies the op list to the chipset's sprite engine instead of a software layer.
- An EE routine — the op list handed down to the soft core and advanced there, the OS passing the data, freeing DeMon for tight timed animation. Via the
eebackend swap, the same op list runs on the simulator during development.
play(target, path) reads the same at every stage; only the thing underneath moves.
Clients
- AntBASIC —
DRAW path$(turtle) andMOVE sprite%, path$ [, LOOP]are the friendly front door, three-line wrappers overplay. - Luau —
require("motion")is the primary programmatic surface:compile/play/stop/runningfor any tool, app, or command. - The OS — composits drawing layers and drives sprites through the same engine.
The path string is the interchange format they all share: one builds it, another runs it, and the engine neither knows nor cares which.
Related
require("motion")— the Luau API · AntBASIC — theDRAW/MOVEfront door- AntOS — the compositor layers it draws to · antos_ee — the EE executor (future) · FireStorm — sprites and the Copper