Skip to content
Fungi

Define a custom actor

Give a Vishnu cartridge its own actor, appearance, and movement.

The starter library supplies a tree and a wandering cat. A cartridge can define another actor with actor(): attach the components it needs, give it a visual id, and add it to the cartridge’s actors list. Place it in scene, or let a rule spawn it later.

Body and Traversal make an actor eligible for native movement; they do not move it by themselves. A deterministic behavior chooses a destination and submits move(). The native runtime checks the route and applies the movement.

Add this small snail to the Acorn starter’s cartridge.ts. The unique acorn.snail visual id below uses the library cat figure as temporary artwork; replace that binding with your cartridge’s art when it is ready.

TS
import {
actor,
action,
behavior,
Body,
component,
Destination,
MEADOW_VERTICAL_METRES,
move,
Position,
predicate,
query,
Traversal,
Visual,
type VisualBinding,
} from "vishnu";
import { cat, libraryVisuals, tree } from "vishnu/library";
const SnailRace = component<{ finishX: number; nextAt: number }>(
"acorn.snail-race",
{ version: 1, fields: { finishX: "number", nextAt: "number" } },
);
const snailIsReady = predicate("acorn.snail-ready", {
reads: [SnailRace, Destination],
test: (subject, context) =>
context.clock.now >= subject.get(SnailRace).nextAt &&
!context.query(query(Destination)).some((row) => row.id === subject.id),
});
const crawl = action("acorn.snail-crawl", {
reads: [SnailRace, Position],
writes: [SnailRace],
facts: ["terrainSurfaces"],
exclusive: "movement",
run(subject, context) {
const race = subject.get(SnailRace);
const at = subject.get(Position);
if (at.x >= race.finishX) return;
const x = Math.min(race.finishX, Math.floor(at.x) + 1);
const z = Math.round(at.z);
const surface = context.terrainSurfaces([[x, z]])[0];
if (!surface) return;
context.action(
move(subject.id, {
x,
y: (surface.cell[1] + 0.5) * MEADOW_VERTICAL_METRES,
z,
frame: null,
}),
);
context.write(SnailRace, subject.id, {
...race,
nextAt: context.clock.now + 1,
});
},
});
const snailMovement = behavior(
"acorn.snail-movement",
(scene) =>
scene
.find(SnailRace, Position, Body, Traversal)
.where(snailIsReady)
.do(crawl),
{ every: 1 },
);
export const snail = actor("acorn.snail")
.with(Position)
.with(Body, { speed: 0.5 })
.with(Traversal, { clearanceCells: 1, maxStepCells: 1 })
.with(Visual, { sprite: "acorn.snail", label: "Snail" })
.with(SnailRace, { finishX: 0, nextAt: 0 })
.behaves(snailMovement);
// Add `snail` to the existing actors list, SnailRace to components,
// and place(snail, { at: [-3, 3] }) to the existing scene.
// Merge this binding into page.visuals beside ...libraryVisuals.
const snailVisuals = {
...libraryVisuals,
"acorn.snail": {
kind: "figure",
key: "cat",
worldRole: "actor",
motion: { kind: "foot", stride: 0.25 },
} satisfies VisualBinding,
};

In the existing defineCartridge call, add the actor and its component, place it on the meadow, and use the merged visual bindings:

TS
actors: [tree, cat, snail],
components: [TreeOrder, SnailRace],
scene: [place(tree, { at: [0, 0] }), place(snail, { at: [-3, 3] })],
page: {
// Keep the Acorn subtitle and its other page settings.
subtitle: "Plant acorns on a meadow; every third tree lures a cat.",
visuals: snailVisuals,
},

Visual stores the sprite id and readable label on the actor. page.visuals maps that id to presentation art; the example reuses the library’s cat figure so the snippet runs without an art pack. The simulation identity stays acorn.snail, independent of that temporary picture.

Add this test to cartridge.test.ts. It checks the snail’s own movement rule, not just that its definition typechecks:

TS
test("the snail crawls to the finish", () => {
const world = testWorld(acorn);
try {
world.until(() => world.find(snail).some(({ x }) => x >= 0), {
within: 10,
what: "the snail crossing the finish",
});
} finally {
world.dispose();
}
});

A behavior can read the projectile impacts of each step: a round striking a body, a cannonball hitting a hull. Pass consumesImpacts: true, and its predicates and actions see context.impacts. Each impact reaches the behavior once. Behaviors that don’t ask see no impacts, and their compiled system is unchanged.

TS
const takeHits = action("acorn.take-hits", {
reads: [Hull],
writes: [Hull],
run(ship, context) {
const hits = context.impacts.filter((hit) => hit.targetId === ship.id);
if (hits.length)
context.write(Hull, ship.id, { hull: ship.get(Hull).hull - hits.length });
},
});
const struck = behavior(
"acorn.struck",
(scene) => scene.find(Hull).do(takeHits),
{ consumesImpacts: true },
);

If the actor is a dressed, human-like character, the cartridge can also own a Cast and a paper-doll pack. That art pipeline is separate from Visual and Body; use it when a biped look needs authored heads, clothes, or poses.

TERMINAL
vishnu check && vishnu test && vishnu build