Hands-on walkthrough: Tutorials — First motion.
Shared motion system for runtime HUD, editor chrome, and general gameplay values. Implementation lives under src/hikari/sdk/src/motion/. Exports: hi.motion (full kit) plus root alias hi.Tween (and hi.easing for the easing module). Other types are hi.motion.Spring / Driver / Motions / Transition / Transaction / Preset / Easing.
Goal
Modern UI motion (tween, spring, enter/exit, stagger, interruptible retarget, reduced motion) under hot-path constraints: plain values, no per-frame heap, no dual APIs for game vs editor, one draw path unchanged.
Non-goals
- Skeletal / clip animation (Shinra / model pipeline).
- GPU particle systems or shader timeline graphs.
- Auto-magic layout morphing without caller-stored previous geometry.
- Per-widget heap timelines, string-keyed animators, or callback graphs on the hot path.
- A second compositor / transform stack. Motion outputs numbers; UI paints them.
Where it sits
gameplay / GameSubsystem / entity data
editor host (spinners, panel open, toast)
│
▼
motion.* (easing, tween, spring, driver, presets, Motions bus)
│
├─► world.ui styles / colors / Length / progress (immediate)
└─► RetainedUi setStyle / setIconRotation / alpha (retained)
│
▼
ui.Compositor → one UI draw| Layer | Owns | Must not own |
|---|---|---|
motion/ | Curves, drivers, springs, presets, Motions bus | Widgets, documents, GPU |
Immediate ui/ | Rebuild + sample caller-owned drivers into styles | A second tween registry |
Retained editor/ui/ | Dirty invalidation when driven values change | Duplicate easing math |
Core model
Curves (easing.zig)
Easing + apply(t), plus lerp, clamp01, repeat, pingpong, wave, spin / spinMs.
Drivers
Plain structs — store next to animated state. Poll done(); no hot-path closures.
| Type | Role |
|---|---|
Tween / TweenVec2 / TweenVec3 / TweenColor | Duration + curve; delay, plays, yoyo |
Spring / SpringVec2 | Interruptible; setTarget keeps velocity |
Driver | Union of tween / spring / hold |
Hold (via Driver.hold) | Constant |
Transaction | Interruptible from/to presentation (begin / retarget / update) |
Driver.make(from, to, Spec) builds from a Spec (.tween, .spring, or .snap).
Spring
SwiftUI-style (response, damping_fraction) → internal stiffness/damping. Settles when position and velocity are within eps, then snaps.
Spring.update clamps a delayed frame to a bounded elapsed interval and integrates it in at most 1/60-second steps. This keeps the semi-implicit solver stable after debugger pauses, blocking native work, or idle-frame gaps instead of flinging UI surfaces past their authored travel.
Presets (Preset)
snappy / gentle / bouncy springs; fade_fast / fade_panel / slide_panel / pop tweens. Themes do not redefine physics.
Transitions
Transition recipes (fade_in, panel_in, pop_in, …) seed a TransitionState. Caller owns show/hide lifecycle; sample opacity/offset/scale each frame.
Stagger: tweenSpecStagger(base, index, slot) or staggerDelay.
Motions bus
Dense Motions table keyed by u64 + Channel (no string IDs). drive / retarget / tick / get / active. Respects setReduceMotion. Use Motions.keyWidget(index, gen) for retained WidgetIds.
Immediate UI usually skips the bus — store Spring/Tween on entity/subsystem state.
FLIP
motion.flip.invert(prev_origin, curr_origin) — caller stores previous rect.
Reduced motion
ui.AccessibilityPrefs.reduce_motion (editor Settings + prefs.json). motion.resolve(spec, reduce_motion) → .snap. Exposed on UiContext.reduce_motion / RetainedUi.reduce_motion after applyAccessibility. Editor spinners freeze when set.
Integration
Immediate UI
self.panel_x.setTarget(open_x); // Spring
const x = self.panel_x.update(ctx.dt);
// Host code: world.ui.vStack(...); game modules:
const panel = ui.vStack(.{ .position = .{ x, 16 } });
defer ui.end(panel);Do not put drivers inside UiContext.
Retained
Mutate properties (setIconRotation, setOpacity, setShift) + invalidate. Spinners use motion.spinMs(now_ms, period) unless reduce_motion.
Editor overlays use editor/ui/overlay_motion.zig (OverlayMotion): settings / confirm / asset picker run panel_in/panel_out; compile splash uses fade; status/recompile loaders pop opacity with Preset.fade_fast.
Frame timing
Drive from sim/UI dt. Wall-clock only via spin / spinMs. Motions.active() is a pacing hint for hosts.
Performance contract
- No heap on
update/tick/lerpColor. ReserveMotionscapacity up front. - Settled drivers are free (
tickearly-outs;active_count). - One curve table — controls never copy easing formulas.
- Compositor unchanged: one atlas, one stream, one UI draw.
- No string IDs in the bus.
- Color RGB lerps in linear light; alpha straight (
lerpColor/withOpacity).
Package layout
src/hikari/sdk/src/motion/
easing.zig tween.zig spring.zig driver.zig
color.zig preset.zig transition.zig reduce.zig
bus.zig flip.zig transaction.zig motion.zigForbidden patterns
- Second copy of ease-out-cubic in a control file.
- Growth inside
update/tick. - Putting
MotionsinsideUiContextframe state. - Driving motion from the render thread.
- Default-on animation for every stock control — opt-in at the host.