Stirred Ink
Full-page experience · Open playground
Advanced Pibbl application. Open Stirred Ink in the editor. The example already exists in the learning catalog; this page reuses its canonical source.
What to inspect
Section titled “What to inspect”Watch the moving body disturb the ink and compare the material swatches. Pause the simulation and toggle stirring in the existing controls. Changing the connection between motion and texture should change the visual field, not turn the texture into a physics solver.
Prerequisites
Section titled “Prerequisites”Understand texture ownership, reaction-diffusion messages, and composition boundaries. The source imports only public Pibbl entries, including @pibbl/core/physics/2d; no private helpers or external image assets are needed. The physics runtime is supplied by the package.
How the composition works
Section titled “How the composition works”The physics body exposes committed position and generation signals. A useReaction maps the position into the texture region, which starts at (0, 180) and spans 600 by 300 logical units. u = x / 600 and v = (y - 180) / 300 are normalized coordinates. Displacement becomes du and dv, scaled by an application-chosen factor of 2.5.
The bridge skips the first sample, a generation change, zero motion, and a jump larger than 40 units. These guards prevent a reset or teleport from smearing across the entire texture. They are recipe choices, not universal Pibbl thresholds.
The ink’s 256 by 128 simulation is independent of the display size. The same root owns the texture, physics world, and playback. Dispose its returned controller to release the composition. The source shows pause control explicitly.
Adapt carefully
Section titled “Adapt carefully”When moving or resizing the ink region, update the coordinate conversion as well as the rectangle. When changing body speed, inspect bounded stir displacement and teleport handling. Test pause, reset, resize, and repeated mount/dispose before adding another body.
This is a one-way visual coupling. Texture forces do not move the body, and the field is not a physical fluid simulation. The existing showcase is Canvas-centered; its painted controls do not create native HTML semantics. For a public HTML site, keep control semantics in native HTML as in the button recipe.
Detailed companion · Playground instructions
Related APIs
Section titled “Related APIs”Complete source
Section titled “Complete source”Host setup for this source: use a canvas with width 600 and height 580, compile with jsx: "react-jsx" and jsxImportSource: "@pibbl/core", import its default mount, and call const controller = mount(canvas) after attaching the canvas. Call controller.dispose() before removing it.
stirred-ink.example.tsx
Section titled “stirred-ink.example.tsx”/** @jsxImportSource @pibbl/core */import { pibbl, Group, Rectangle, Circle, useSignal, useReaction, useRef, useTexture, usePlayback, drive, tween, repeat, sequence, wait, type Texture, type PibblController, Text, signal,} from "@pibbl/core";import { marble, paper, cellular, reactionDiffusion, type InkMessage,} from "@pibbl/core/textures";import { PhysicsWorld2D, pibblPhysicsBody2D, collider2D, dynamicBody2D, staticBody2D,} from "@pibbl/core/physics/2d";export default function mount(canvas: HTMLCanvasElement): PibblController { const paused = signal(false); const stirring = signal(true); let ink: Texture<InkMessage>; function Materials() { const detail = useSignal(0.2); usePlayback( repeat( sequence( drive(detail, tween({ from: 0.2, to: 0.9, duration: 3000 })), wait(16), ), { iterations: Infinity, direction: "alternate" }, ), { autoplay: true }, ); const value = detail.get(); const a = useTexture(marble({ detail: value }), { resolution: { width: 96, height: 96 }, }); const b = useTexture(paper({ detail: value }), { resolution: { width: 96, height: 96 }, }); const c = useTexture(cellular({ detail: value }), { resolution: { width: 96, height: 96 }, }); return ( <Group> {[a, b, c].map((texture, i) => ( <Rectangle key={i} style={{ left: i * 200, top: 0, width: 192, height: 160, fill: texture }} /> ))} </Group> ); } function Ink() { const body = pibblPhysicsBody2D(); ink = useTexture(reactionDiffusion({ seed: 42, preset: "stripes" }), { resolution: { width: 256, height: 128 }, palette: ["#101f2b", "#265869", "#77b8ac", "#f3d69d"], }); const previous = useRef<readonly number[] | undefined>(undefined); useReaction(() => { const active = stirring.get(); const [x, y] = body.position.get(); const generation = body.generation.get(); const last = previous.current; previous.current = [x, y, generation]; if ( !active || !last || last[2] !== generation || Math.hypot(x - last[0], y - last[1]) > 40 || (x === last[0] && y === last[1]) ) return; ink.send({ type: "stir", u: x / 600, v: (y - 180) / 300, du: ((x - last[0]) / 600) * 2.5, dv: ((y - last[1]) / 300) * 2.5, radius: 22, }); }); return ( <PhysicsWorld2D gravity={[0, 0]}> <Rectangle style={{ left: 0, top: 180, width: 600, height: 300, fill: ink }} /> <Rectangle physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })} style={{ left: 0, top: 180, width: 600, height: 8, fill: "#6c9699" }} /> <Rectangle physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })} style={{ left: 0, top: 472, width: 600, height: 8, fill: "#6c9699" }} /> <Rectangle physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })} style={{ left: 0, top: 180, width: 8, height: 300, fill: "#6c9699" }} /> <Rectangle physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })} style={{ left: 592, top: 180, width: 8, height: 300, fill: "#6c9699" }} /> <Group physics={dynamicBody2D({ handle: body, linearVelocity: [180, 130], allowSleep: false, collider: false, mass: 1, })} style={{ translateX: 300, translateY: 300 }} > <Circle physics={collider2D({ restitution: 1, friction: 0, })} style={{ radius: 9, fill: "#77b8ac66", stroke: "#e3fff1", strokeWidth: 2, }} /> </Group> </PhysicsWorld2D> ); } function Button({ x, label, onClick, }: { x: number; label: string; onClick: () => void; }) { return ( <Group style={{ translateX: x, translateY: 520 }}> <Rectangle keyboardFocusable onClick={onClick} style={{ width: 192, height: 40, cornerRadius: 8, fill: "#265869", cursor: "pointer", }} /> <Text pointerEvents="none" style={{ left: 96, top: 25, font: "14px Arial", textAlign: "center", fill: "#ffffff", }} > {label} </Text> </Group> ); } function Controls() { return ( <> <Text style={{ left: 0, top: 500, font: "14px Arial", fill: "#cadfd9" }}> A moving body drags the ink; the reaction reshapes its wake. </Text> <Button x={0} label={paused.get() ? "Resume reaction" : "Pause reaction"} onClick={() => { paused.set(!paused.get()); ink.send({ type: "pause", paused: paused.get() }); }} /> <Button x={200} label={stirring.get() ? "Stop stirring" : "Start stirring"} onClick={() => stirring.set(!stirring.get())} /> <Button x={400} label="Reset ink" onClick={() => ink.send({ type: "reset" })} /> </> ); } return pibbl( canvas, <> <Rectangle pointerEvents="none" style={{ width: 600, height: 580, fill: "#101f2b" }} /> <Materials /> <Ink /> <Controls /> </>, );}Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision c321a83. ALPHA — NOT FOR PRODUCTION USE.