Language Reference
VMD Primitive DSL
The complete reference for Video Markdown — every primitive, command, and pattern. Write your first scene in minutes.
Video Markdown (VMD) — Created by Sohan Dananjaya
Quick Start
A VMD file is plain YAML. Every element is either a primitive (static or single animation) or a morph (transforms through multiple states). The shape: line is always one single line — never break it.
concept: "My First Scene"total_duration: 5background: "#080c14" subtitles: - "" phases: intro: duration: 5 elements: circle: type: primitive shape: circle[r:1.0 fill:#4fc3f7/0.8 at:(0,0)] -> fadeIn(0.6s, 0.3s) label: type: primitive shape: text[text:"HELLO" fontSize:32 fill:#ffffff/0.9 at:(0,-2.5)] -> fadeIn(0.5s, 0.8s)
Render: vmd render my-scene.vmd --output out.mp4
File Structure
| Property | Type / Values | Notes |
|---|---|---|
concept | string | Title of the animation |
total_duration | number | Sum of all phase durations in seconds |
background | #hex | Canvas background color |
subtitles | list | Always present. Include at least one empty string - "" |
phases | map | Named phases, each with duration + elements |
concept: "Concept Title"total_duration: 30background: "#080c14" subtitles: - "" phases: scene_name: duration: 30 description: "One sentence — what happens here" elements: element_name: type: primitive | morph shape: shape[...] -> command(...)
Canvas
x: -8.0 → +8.0 (16 units wide)y: -6.0 → +6.0 (12 units tall)center: (0, 0)positive Y is UP
All coordinates are in logical units. Position with at:(x,y). Canvas is always 16×12 units regardless of output resolution.
Primitives
All primitives support morph chains and all animation commands.
circle
circle[r:0.8 fill:#4fc3f7/0.7 stroke:#4fc3f7/0.4 strokeWidth:1.5 at:(0,0)]
| Property | Type / Values | Notes |
|---|---|---|
r | number | Radius |
fill | #hex/opacity | Fill color |
stroke | #hex/opacity | Stroke color |
strokeWidth | number | Stroke thickness |
at | (x,y) | Center position |
rectangle
rectangle[w:3.0 h:1.5 fill:#4fc3f7/0.2 stroke:#4fc3f7/0.45 strokeWidth:1.5 at:(0,0)]
square / ellipse
square[w:1.2 fill:#6bffb8/0.3 stroke:#6bffb8/0.6 strokeWidth:1.5 at:(0,0)]ellipse[w:3.0 h:1.2 fill:#4fc3f7/0.15 stroke:#4fc3f7/0.4 strokeWidth:1.5 at:(0,0)]
line
# Static lineline[from:(-4,0) to:(4,0) stroke:#4fc3f7/0.5 strokeWidth:1.5] # Draws itself (morph from zero length)line[from:(-4,0) to:(-4,0) stroke:#4fc3f7/0.5 strokeWidth:1.5] ~> line[from:(-4,0) to:(4,0) stroke:#4fc3f7/0.5 strokeWidth:1.5] (1.8s, ease.inOut, 3s)
triangle
triangle[size:0.9 fill:#4fc3f7/0.2 stroke:#4fc3f7/0.5 strokeWidth:1.5 at:(0,0) direction:up]
direction: up (default) · down · left · right. Also accepts rotation:180deg.
star
star[points:5 outerRadius:0.6 innerRadius:0.28 fill:#ffd54f/0.25 stroke:#ffd54f/0.6 strokeWidth:1.5 at:(0,0)]
regularPolygon
regularPolygon[sides:6 r:0.65 fill:#4fc3f7/0.2 stroke:#4fc3f7/0.45 strokeWidth:1.5 at:(0,0)]
functionGraph
functionGraph[fn:"sin(x)" xStart:-6.5 xEnd:6.5 segments:150 stroke:#4fc3f7/0.3 strokeWidth:1.5 at:(0,0)]
Supported functions: sin cos tan exp log sqrt pow. Use as world terrain at low opacity.
parametric
parametric[paramX:"2.5*cos(t)" paramY:"1.0*sin(t)" tStart:0 tEnd:6.28 segments:100 stroke:#4fc3f7/0.15 strokeWidth:1 at:(0,0)]
curve2d
curve2d[dxFn:"-y-z" dyFn:"x+a*y" dzFn:"b+z*(x-c)" a:0.2 b:0.2 c:5.7 seed:(0.1,0,0) steps:8000 dt:0.01 project:"xz" stroke:#4fc3f7/0.8 colorMap:"turbo" colorMode:"t"]
Plots a curve that has no closed form — chaotic systems like Lorenz/Rössler only exist as the result of numerically integrating derivatives step by step, so functionGraph/parametric can't express them. You write dxFn/dyFn/dzFn (the system's derivatives, not positions) plus a seed starting point; the engine RK4-integrates and draws the resulting path. project picks which two of the three integrated axes become the 2D plane. There's no camera here to compensate for a large system — use pointScale and autoCenter:true to fit it on screen. Trace it in over time with -> draw(duration, delay) instead of fadeIn.
group
# Icon with aura — one actor, one unitgroup{at:(0,0.5)} [ circle[r:1.2 fill:#4fc3f7/0.12 stroke:#4fc3f7/0.3 strokeWidth:1 at:(0,0)], icon[icon:"lucide:server" size:2.0 color:#4fc3f7 at:(0,0)] ] -> fadeIn(0.8s, 1s)
Entire group moves with moveTo. All children comma-separated on the same line.
image
image[src:"images/icons/photo.jpg" w:4 h:2.6 fit:cover radius:0.2 at:(0,0)] -> fadeIn(0.8s, 0.5s) # Crossfade between imagesimage[src:"images/a.jpg" w:4 h:2.6 fit:cover at:(0,0)] ~> image[src:"images/b.jpg" w:4 h:2.6 fit:cover at:(0,0)] (1.4s, ease.inOut, 4s) # Crop wipe revealimage[src:"images/a.jpg" w:6 h:3.0 fit:cover crop:(-3,-1.5,0,3) at:(0,0)] ~> image[src:"images/a.jpg" w:6 h:3.0 fit:cover crop:(-3,-1.5,6,3) at:(0,0)] (1.8s, ease.inOut, 2s)
| Property | Type / Values | Notes |
|---|---|---|
src | string | Must start with images/ (repo-relative) |
w / h | number | Canvas units. Omit one to preserve aspect ratio |
fit | cover | contain | Fill mode |
radius | number | Corner radius |
grayscale | 0–1 | Desaturation — interpolates in morph |
blur | number | Blur radius — interpolates in morph |
tint | #hex/opacity | Color overlay — interpolates in morph |
crop | (x,y,w,h) | Crop region — interpolates (use for wipe reveals) |
Icons
Icons are first-class actors. Treat them like characters — every icon in the scene does something. It fades in, changes color, morphs, moves, or scales in response to events. A static icon is decoration. Remove it or give it a role.
icon[icon:"lucide:database" size:2.0 color:#6bffb8 at:(0,0.5)] -> fadeIn(0.7s, 1s) -> scale(1.08, 2s, ease.inOut, 1.5s)icon[icon:"mdi:rocket" size:1.4 color:#ff8fab at:(-5,-2)] -> fadeIn(0.4s, 1.2s) -> moveTo((5,2), 4.5s, ease.inOut, 1.5s)
| Property | Type / Values | Notes |
|---|---|---|
icon | "prefix:name" | Required. Exact: mdi:server · lucide:zap · logos:react |
size | number | 1 ≈ 50px. Lead: 1.5–2.5. Background: 0.6–1.2 |
color | #hex/opacity | Icon color with opacity: color:#4fc3f7/0.7 |
opacity | 0–1 | Multiplies color opacity |
at | (x,y) | Position |
rotation | Ndeg | Static orientation at spawn |
flip | horizontal | vertical | both | Mirror the icon |
| Library | Count | Style |
|---|---|---|
mdi | 7,638 icons | Expressive, filled. Creatures, machines, environments, personality. |
lucide | 1,771 icons | Clean outline. System components, data flows, technical diagrams. |
logos | 2,091 icons | Brand identities. Only when the brand is the subject. |
Figures & Characters
A figure is a fully-rigged 2D character — a chest, neck, head, two arms, two legs, eyes, and a mouth, all driven by one shared skeleton. Use one when a scene needs an actor with a body, not just an icon. Every figure is designed inline in an avatars: block, then animated with gesture() — no separate rigging file or tool.
avatars: hero: figure[name:"hero"]{root[shape:none] chest[...] neck[...] head[...] eye_l[...] eye_r[...] mouth[...] shoulder_l[...] shoulder_r[...] elbow_l[...] elbow_r[...] hand_l[...] hand_r[...] hip_l[...] hip_r[...] knee_l[...] knee_r[...] foot_l[...] foot_r[...]} phases: scene1: elements: hero: type: figure shape: figure[use:"hero" at:(0,-1.9) scale:85] -> gesture("wave", 3s, 0.5s)
| Property | Type / Values | Notes |
|---|---|---|
use | "name" | References an avatar defined in the avatars: block, by its key |
at | (x,y) | Root position — feet land roughly here |
scale | number | Character height in canvas units. 85 ≈ standing adult |
The 19-point contract
Every figure must shape all 19 of these points or it fails to render. They form two chains off a fixed parent: the spine, and a pair each of arms and legs.
| Points | Group | Notes |
|---|---|---|
| root → chest → neck → head | spine | root has shape:none; the others carry the head/torso design |
| eye_l · eye_r · mouth | face | offset off head |
| shoulder_l/r → elbow_l/r → hand_l/r | arms | shoulder is an offset off chest, elbow/hand chain from it |
| hip_l/r → knee_l/r → foot_l/r | legs | hip is an offset off root, knee/foot chain from it |
Reuse a point's length, direction, offset, and width as-is unless deliberately resizing the body — the gesture library is tuned against the contract's default angles. For a new design, only change shape, fill, and add extra[...] accessories.
Shapes & the joint-cap rule
| Shape | Properties | Notes |
|---|---|---|
circle | r, fill | Joint markers — shoulder, hip, head |
ellipse | w, h, fill, angle | Eyes, mouth, eyebrows, lens-shaped accessories |
capsule | width, widthEnd, fill | Contract chain-points only — draws the tapered bone segment |
path | points, smoothing | Local-offset point list, Catmull-Rom smoothed — hair, capes |
Color shoulder_l/r and hip_l/r to match chest's own fill, not the limb beyond them. Put any fabric→skin or trouser→shoe color change at hand_l/r or foot_l/r instead — the renderer auto-paints a joint cap there in that point's color, which covers the seam for free.
Design checklist
| Item | Values | Why |
|---|---|---|
| Proportions | neck length:0.22, head length:0.20, head r:0.33–0.34 | Person-like head-to-body ratio |
| Joint sizing | shoulder/hip r 0.13–0.14, elbow/knee/hand/foot capsule width tapered toward the joint | Scaled relative to head r |
| Shading | shadeFill two shades darker, shadeOpacity ~0.2, shadeScale 0.75 | Avoids a flat sticker look on the head |
| Cosmetic layer | pupils on eye_l/r, role-tagged eyebrows, 2–3 finger extras per hand | Bare capsule hands read as mittens without it |
| Verify | render happy / sad / surprised / angry + one plain gesture | Catches clipping or off-model accessories before shipping |
Accessories
Extras attach to a point and inherit its position and rotation every frame. Never use capsule on an extra — only circle, ellipse, or path.
# Glasses — two lenses + a bridge, all on headextra[attachTo:"head" shape:ellipse w:0.16 h:0.13 fill:none offset:(-0.15,0.08)]extra[attachTo:"head" shape:ellipse w:0.16 h:0.13 fill:none offset:(0.15,0.08)]extra[attachTo:"head" shape:ellipse w:0.10 h:0.02 fill:#2b2b2b offset:(0,0.08)] # Pupils — attach to the eye itself, not head, to get gaze tracking for freeextra[attachTo:"eye_l" shape:circle r:0.032 fill:#211a16]extra[attachTo:"eye_r" shape:circle r:0.032 fill:#211a16] # Eyebrows — role tag is required for them to react to expressionsextra[attachTo:"head" shape:ellipse w:0.10 h:0.026 fill:#2b211c angle:0.08 offset:(-0.15,0.10) role:"eyebrow_l"]extra[attachTo:"head" shape:ellipse w:0.10 h:0.026 fill:#2b211c angle:-0.08 offset:(0.15,0.10) role:"eyebrow_r"]
Gestures
Chain gesture(name, duration, delay) after a figure exactly like any other command. Each gesture animates a specific set of joints — body language and lip-sync, not random motion.
| Gestures | Purpose |
|---|---|
| wave · head-tilt · talk | Greeting, listening, speaking |
| look-left · look-right · look-up | Gaze direction |
| point-left · point-right · point-up | Directing attention off-body |
| explain · shrug · idle | Default presenting poses |
| happy · sad · surprised · angry | Emotion |
| walk | Locomotion — pair with moveTo for travel |
| flinch · laugh | Reaction beats |
Combining gestures
Gestures merge by joint, not by overwrite — two gestures touching different joints can run at the same time. Give them the same delay to layer them; give them increasing delays to sequence them.
# Sequential: explain plays alone for 1.5s, then wave + talk play together for the next 3.5sfigure[use:"hero" at:(3,-1.9) scale:85] -> gesture("explain", 1.5s, 0s) -> gesture("wave", 3.5s, 1.5s) -> gesture("talk", 3.5s, 1.5s) # Concurrent: same delay on every gesture — walk, talk, and an emotion all play at oncefigure[use:"hero" at:(-3,-0.45) scale:85] -> gesture("walk", 7s, 0s) -> gesture("talk", 7s, 0s) -> gesture("happy", 7s, 0s) -> moveTo((3,-0.45), 7s, 0s)
Animation Commands
All commands chain with -> and apply to every primitive and icon. All times are in seconds.
| Command | Notes |
|---|---|
fadeIn(dur, delay) | Fade from transparent to target opacity |
fadeOut(dur, delay) | Fade to transparent |
moveTo((x,y), dur, ease, delay, path) | Move to canvas position. path optional — see below. |
rotate(Ndeg, dur, ease, delay) | Rotate by angle. 360deg for full spin. |
scale(factor, dur, ease, delay) | 1.0 = unchanged. 1.4 = 40% larger. |
draw(dur, delay, ease) | curve2d only — traces the point array in from the seed instead of fading the whole curve in at once |
# Chain freelyshape: circle[r:0.5 fill:#4fc3f7/0.8 at:(-5,0)] -> fadeIn(0.5s, 0.3s) -> moveTo((5,0), 3s, ease.inOut, 0.8s) -> fadeOut(0.4s, 3.8s) # Spinshape: icon[icon:"mdi:api" size:1.4 color:#ffd54f at:(0,0)] -> fadeIn(0.6s, 1s) -> rotate(360deg, 5s, linear, 1.8s)
Non-linear movement
moveTo's optional 5th arg, path:"expr(t)", bends the straight line into a curve instead of moving linearly. t runs 0→1 (already eased). The formula is a perpendicular offset off the direct line — moveTo still owns the actual start/end point, so it always lands exactly on (x,y) as long as the formula resolves to 0 at t=1.
shape: icon[icon:"mdi:basketball" size:0.8 color:#ff8a3d at:(-6,0)] -> fadeIn(0.3s, 0s) -> moveTo((6,0), 4s, ease.out, 0.3s, path:"1.2*abs(sin(t*4*PI))*(1-t)")
| Formula | Effect |
|---|---|
"H*t*(1-t)" | Projectile arc — the actual shape of gravity |
"H*abs(sin(t*N*PI))*(1-t)" | Decaying bounces, N = bounce count |
"H*sin(t*N*PI)*exp(-t*K)" | Damped oscillation (spring/pendulum wobble) |
Only Math.* functions and PI/TAU are available — no access to other elements or scene state. Back-and-forth motion needs the formula's sign flipped on the return leg (the perpendicular direction depends on which way the line points) — see scenes/movepath_test.vmd for a working pendulum.
Physics commands
Standalone physics-driven animations, not just easing curves — each takes named parameters instead of a duration.
| Command | Notes |
|---|---|
spring(stiffness:N damping:N mass:N velocity:N) | Overshoots then settles — actors arriving with weight |
bounce(stiffness:N damping:N mass:N) | Rebound motion for impact-like landings |
elastic(stiffness:N damping:N mass:N) | Exaggerated overshoot with wobble — comedic, surprise arrivals |
shape: icon[icon:"mdi:rocket" size:1.5 color:#4fc3f7 at:(0,0)] -> spring(stiffness:100 damping:10 mass:1)
Text-only commands
| Command | Notes |
|---|---|
typewriter(dur, delay) | Characters appear one by one with soft 0.12s ramp each |
typewriter(dur, delay, cursor) | Same + blinking caret that fades after completion |
wordReveal(stagger, delay) | Words drift upward into position. stagger = per-word interval (0.12s–0.25s sweet spot) |
focusPull(dur, delay) | Starts blurred + spread, collapses to sharp. Minimum 0.8s. |
focusPull(dur) ~> ... | Place before a morph — blurs through transition, lands sharp |
shape: text[text:"INITIALIZING" fontSize:22 fill:#ffd54f/0.85 at:(0,-2)] -> typewriter(2.0s, 1.0s, cursor)shape: text[text:"write animations like code" fontSize:16 fill:#4fc3f7/0.7 at:(0,1)] -> wordReveal(0.18s, 2.5s)shape: text[text:"DEADLOCK" fontSize:52 fill:#ff6b9d/0.9 at:(0,0)] -> focusPull(1.6s, 0.5s)shape: text[text:"O(n²)" fontSize:44 fill:#4fc3f7/0.9 at:(0,0)] -> focusPull(1s) ~> text[text:"O(log n)" fontSize:44 fill:#6bffb8/0.9 at:(0,0)] (1.6s, ease.out, 2s)
Morphing
The ~> operator transforms one state into another. Chain any number of steps. Delay is the absolute start time within the phase — not relative to the previous step.
# Shape to shapeshape: circle[r:0.9 fill:#4fc3f7/0.4 at:(0,0)] ~> square[w:1.8 fill:#6bffb8/0.9 at:(0,0)] (1.2s, ease.inOut, 2s) # Multi-stepshape: text[text:"pending" fontSize:28 fill:#fff/0.5 at:(0,0)] ~> text[text:"running" fontSize:28 fill:#4fc3f7/0.85 at:(0,0)] (0.5s, ease.out, 2s) ~> text[text:"done" fontSize:28 fill:#6bffb8/0.9 at:(0,0)] (0.5s, ease.out, 5s) # Icon morph — crossfades position, size, color, opacityshape: icon[icon:"mdi:fish" size:1.6 color:#ff9f43 at:(-4,0)] ~> icon[icon:"mdi:dolphin" size:2.0 color:#60a5fa at:(0,0)] (2.0s, ease.inOut, 2s) # Shape to icon — data becoming structuredshape: circle[r:0.2 fill:#38bdf8 at:(-4,0)] ~> icon[icon:"lucide:binary" size:0.9 color:#38bdf8 at:(0,0)] (1.6s, ease.inOut, 3s) # Materialize then morphshape: icon[icon:"lucide:server" size:2.0 color:#4fc3f7/0.3 at:(0,0)] -> fadeIn(0.6s, 0.5s) ~> icon[icon:"lucide:server" size:2.0 color:#4fc3f7 at:(0,0)] (0.5s, ease.out, 4s)
Interpolates smoothly: at:(x,y) · r · w · h · icon size · opacity · strokeWidth · stroke color · fill color · icon color · fontSize · letterSpacing · image grayscale · blur · tint · crop
Snaps at 50% midpoint: icon name · image src · fontWeight · fontStyle
Easing
| Easing | Notes |
|---|---|
ease.out | Fast start, decelerates to stop. Components activating, actors landing. |
ease.in | Slow start, accelerates. Urgency, pressure, approaching failure. |
ease.inOut | Smooth both ends. Natural travel, breathing motion. |
linear | Constant speed. Running processes, orbits, signal streams. |
ease.spring | Overshoots then snaps back. Actors arriving with weight. |
ease.elastic | Exaggerated overshoot with wobble. Comedic, surprise arrivals. |
bounce | Rebound easing for impact-like motion. |
Colors & Opacity
Color format: #RRGGBB with optional opacity suffix — e.g. #4fc3f7/0.6. State colors carry fixed meaning. Never reassign them.
| Hex | Name | Meaning |
|---|---|---|
#4fc3f7 | cyan | alive, active, working, healthy, data in motion |
#ffd54f | amber | cinematic moment only — one per scene, never before it |
#ffffff | white | labels, neutral structural elements |
#ff6b9d | pink | broken, failed, overloaded, blocked, under attack |
#6bffb8 | mint | resolved, correct, complete, the answer found |
#c77aff | violet | deep system, unknown, beneath the surface, AI layer |
Opacity roles
background / ambient fill /0.06–0.20 stroke /0.15–0.35resting / seed state fill /0.25–0.40 stroke /0.45–0.65active / interactable fill /0.40–0.65 stroke /0.65–0.85triggered / fired fill /0.65–0.85 stroke /0.85–1.00cinematic / hero fill /0.85–1.00 stroke /1.00text inside filled shape fill /0.85 minimumlabel whispers fill /0.28–0.35
Text
Text is physical matter — it morphs, travels, builds itself, carries values. All standard animation commands apply.
# Solid texttext[text:"LABEL" fontSize:32 fill:#4fc3f7/0.9 at:(0,0)] # Bold italictext[text:"CRITICAL" fontSize:30 fill:#ff6b9d/0.9 fontWeight:bold fontStyle:italic at:(0,0)] # Left-aligned story pointtext[text:"O(n) comparisons per pass" fontSize:16 fill:#ffffff/0.82 textAlign:left at:(-7,-1)] # Hollow outline texttext[text:"WAITING" fontSize:52 outline:#4fc3f7/0.28 strokeWidth:1 at:(0,1.2)] # Seed state — dim+spread contracts to bright+tighttext[text:"DEADLOCK" fontSize:52 fill:#ff6b9d/0.18 letterSpacing:0.22 at:(0,0)] ~> text[text:"DEADLOCK" fontSize:52 fill:#ff6b9d/0.9 letterSpacing:0 at:(0,0)] (1.0s, ease.out, 6s) # Formula assemblytext[text:"f(x)" fontSize:46 fill:#4fc3f7/0.9 at:(0,0.5)] -> fadeIn(0.6s, 3s) ~> text[text:"f(x) = x²" fontSize:46 fill:#4fc3f7/0.9 at:(0,0.5)] (0.5s, ease.out, 5s) ~> text[text:"f(x) = x² + 1" fontSize:46 fill:#ffd54f/0.9 at:(0,0.5)] (0.5s, ease.out, 7s)
| Property | Type / Values | Notes |
|---|---|---|
text | string | Content. Escape-aware: \n \t \" \\ \uXXXX |
fontSize | number | Size in pixels |
fill | #hex/opacity | Solid filled text |
outline | #hex/opacity | Hollow stroke-only text. Do not combine with fill. |
strokeWidth | number | Stroke thickness for outline text |
font | string | Font family (default Arial) |
fontWeight | normal | bold | Snaps at 50% during morph — use for activation moments |
fontStyle | normal | italic | Snaps at 50% during morph |
letterSpacing | number | 0 = default · 0.22 = spread · -0.03 = tighter. Interpolates in morph. |
textAlign | left | center | right | at:(x,y) is the alignment anchor (default center) |
Text width limits
fontSize:64 → 8 chars fontSize:36 → 18 charsfontSize:52 → 10 chars fontSize:30 → 22 charsfontSize:46 → 12 chars fontSize:22 → 30 charsfontSize:40 → 14 chars fontSize:16 → 44 chars
3D Primitives
Every 3D phase needs a camera and a light element. A shape is routed to the 3D renderer by having a 3-element at:(x,y,z) — a 2-element at:(x,y) stays 2D even on a shape named cube.
camera[at:(0,3,-8) lookAt:(0,0,0) fov:50 fog:0 fogColor:#000000 orbit:(0,20,0)]light[direction:(-1,-1,-1) intensity:0.9 ambient:0.2 specular:0 shininess:32]
orbit:(rx,ry,rz) spins the camera around lookAt in degrees/second — orbit:(0,20,0) is a slow horizontal turntable.
Universal 3D properties
fill:#hex stroke:#hex opacity:N at:(x,y,z)rotate3d:(rx,ry,rz) # fixed rotation in degreesspin3d:(rx,ry,rz) # continuous spin, degrees/second, adds on top of rotate3dscaleX:N scaleY:N scaleZ:Nemissive:true # glows at fill color, ignores lightingrepeat:N offset:(dx,dy,dz)
Shapes
sphere[r:1 fill:#4fc3f7]cube[size:1 fill:#f472b6]cylinder[r:0.5 h:2 fill:#a3e635]cone[r:0.5 h:2 fill:#ffd54f]torus[size:2 r:0.3 fill:#4fc3f7]hemisphere[r:1 fill:#f472b6]prism[sides:6 r:1 h:2 fill:#a78bfa]plane[w:6 h:4 fill:#1e293b]line3d[from:(0,0,0) to:(3,0,0) r:0.05 fill:#64748b]
surface3d builds any smooth surface from three math expressions — (x,y,z) = f(u,v):
surface3d[xFn:"expr" yFn:"expr" zFn:"expr" uRange:[0,6.2832] vRange:[0,3.1416] segments:32 stacks:32 colorMap:"viridis" colorMode:"height"]
surface is the simpler height-map version — z = f(x,y):
surface[fn:"sin(x)*cos(y)" xRange:[-3,3] yRange:[-3,3] colorMap:"viridis"]
3D pitfalls
| Pitfall | Fix |
|---|---|
emissive:true looks invisible | Fill is too dark — needs a hex channel above #30 |
fill:#hex/0.5 opacity is ignored | 3D opacity is a separate opacity:N property, not baked into fill |
Solid surface3d disappears from some angles | Back-face culling — use wireframe:true or a second copy with reversed uRange |
spin3d jumps at a phase boundary | It resets to rotate3d at the start of every phase — match the end angle in the next phase's rotate3d |
fadeIn does nothing on a 3D shape | Use type: morph with opacity:0.0 → 1.0 instead |
Design Patterns
Seed state — object permanence
Every element exists from second zero. Initialize lead actors at low opacity and morph them to full brightness when they activate. Nothing materializes from a void.
lead_actor: type: morph shape: icon[icon:"lucide:server" size:2.0 color:#4fc3f7/0.3 at:(0,0.5)] -> fadeIn(0.6s, 0.5s) ~> icon[icon:"lucide:server" size:2.0 color:#4fc3f7 at:(0,0.5)] (0.5s, ease.out, 4s)
Timeline causality
Element A completes at T. Element B responds at T + 0.3s. The viewer feels the logic caused it.
packet: type: morph shape: circle[r:0.18 fill:#38bdf8 at:(-4,0.5)] ~> circle[r:0.18 fill:#38bdf8 at:(2,0.5)] (1.5s, ease.inOut, 2s) # Packet ends at 3.5s. Database activates at 3.8s (+0.3s)database: type: morph shape: icon[icon:"lucide:database" size:1.8 color:#6bffb8/0.3 at:(3,0.5)] ~> icon[icon:"lucide:database" size:1.8 color:#6bffb8 at:(3,0.5)] (0.5s, ease.out, 3.8s)
Three-layer structure
Every scene has three simultaneous layers. Design all three before placing any element.
# Background layer — world identity, dim, slow or staticbg_icon: type: primitive shape: icon[icon:"mdi:server" size:1.1 color:#4fc3f7/0.12 at:(-5.5,1.2)] -> fadeIn(1s, 0.2s) # Actor layer — lead character at full opacitymain_actor: type: morph shape: icon[icon:"lucide:database" size:2.0 color:#6bffb8/0.3 at:(0,0.5)] -> fadeIn(0.7s, 1s) ~> icon[icon:"lucide:database" size:2.0 color:#6bffb8 at:(0,0.5)] (0.5s, ease.out, 4s) # Detail layer — small ambient elements, independent motionbubble: type: primitive shape: circle[r:0.1 fill:#4fc3f7/0.5 at:(-3,-2.8)] -> fadeIn(0.4s, 4s) -> moveTo((-3,-1), 2.5s, ease.out, 4.5s)
The cinematic moment
One per scene. Always one. Amber. Large. When it appears all active elements dim to 0.2–0.3 opacity. The word is the most precise name for what the animation just showed.
cinematic: type: morph shape: text[text:"DEADLOCK" fontSize:64 fill:#ffd54f/0.9 at:(0,0.5)] -> fadeIn(1.2s, 22s) ~> text[text:"DEADLOCK" fontSize:18 fill:#ffd54f/0.3 at:(0,0.5)] (1.5s, ease.inOut, 25s) lbl_1: type: primitive shape: text[text:"mutex" fontSize:13 fill:#ffffff/0.3 at:(-4.5,-3.2)] -> fadeIn(0.6s, 26.5s)lbl_2: type: primitive shape: text[text:"starvation" fontSize:13 fill:#ffffff/0.3 at:(0,-3.2)] -> fadeIn(0.6s, 27s)lbl_3: type: primitive shape: text[text:"cycle" fontSize:13 fill:#ffffff/0.3 at:(4.5,-3.2)] -> fadeIn(0.6s, 27.5s)
Rules & Limits
| Rule | Why |
|---|---|
shape: is always one line | Parser fails immediately on line breaks |
subtitles: must be present with - "" | Required field — omitting causes a parse error |
image src must start with images/ | Wrong path renders blank silently — no error thrown |
icon name must be exact prefix:name | Wrong names render as a placeholder — verify before use |
| One cinematic moment per scene | Amber is sacred. One. Never two. Never zero. |
| Amber appears nowhere before the cinematic beat | Not on any icon, text, or shape before that moment |
| Delayed elements must be spawn-gated | First keyframe at opacity 0 or off-canvas |
| Maximum two lead actors per scene | Viewer must always know exactly what to follow |
| Every icon acts | A static icon is decoration — give it a role or remove it |
| Text inside a filled shape is a separate element | Place at the same center. fill minimum /0.85 |
Video Markdown (VMD) — Created by Sohan Dananjaya
Explore and start writing →