Skip to content

Stirred Ink

Stopped
The complete example source and integration notes are available below.

Read as Markdown

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.

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.

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.

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.

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

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.

/** @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 built with @pibbl/core 0.0.2, revision c321a83. ALPHA — NOT FOR PRODUCTION USE.