6. Rats
Rat Hunt, step 6: rigged, animated rats from gen-creature, moved with makeCrowd and makeBrickStamper, that flee from you and are caught with a tap.
This is the step that makes it a game. Eight rats wander the valley, run when you come close, and a tap, a click or the Space bar catches the one you are pointing at. Catch them all before nightfall and you win.
Rats from a generator
generateCreature builds a
rigged entity: a voxel model whose voxels each belong to a bone, a skeleton, and animation
clips (idle, walk, turn-left, turn-right, run, sniff, death). Make one model per
coat and share them:
import { atScale, generateCreature, PRESETS as RATS } from "@voxolith/gen-creature";
const RAT_KINDS = ["rat", "grey rat", "lab rat", "black rat"];
// atScale sizes a preset for 10 voxels per metre; 0.7 is as small as a rat gets.
const ratModels = RAT_KINDS.map((k, i) => generateCreature(atScale(RATS[k], 10), seededRandom(seed + 100 + i), k).entity);The presets are stylised, big game rats. atScale
sizes one for a world with a fixed number of voxels per metre, but never below the size at which
legs would get thinner than two voxels. At 10 voxels per metre that floor wins, and each rat is
about 43 voxels (over four metres) long and 12 tall. Hence giant rats. (realSizeAt(RATS.rat, 10)
from the same package is false for that reason; a rat is real size from about 84 voxels per metre,
see sizing for a world.)
Rats need palette slots like everything else. Add them to the palette section:
const ratBase = ratModels.map((e, i) => palette.allocate(e.model.roles, RAT_KINDS[i]).base);A crowd
A moving model cannot be stamped once and left: every frame it has to be taken out of the bricks where it was and written where it is now, in its current pose. Two engine pieces do that:
makeBrickStamperplaces "movers" in the world and, on each commit, restores exactly what they covered before writing them at their new places. Nothing else in the world is touched.makeCrowdposes each member from its animator, bakes the pose into voxels (cached, since rats in the same clip frame and heading look identical) and hands the result to the stamper, within a time budget per frame.
import { makeBrickStamper } from "@voxolith/engine";
import { makeAnimator, makeCrowd, type CrowdMember } from "@voxolith/engine/animation";
interface Rat extends CrowdMember { state: "wander" | "flee" | "caught"; speed: number; turn: number; timer: number; }
const crowd = makeCrowd({ stamper: makeBrickStamper(renderer, { size: SIZE }), near: 200, keepModels: true });near: 200 animates every rat within 200 voxels at the full 12 frames a second (further ones
step at a lower rate). keepModels: true keeps each baked pose's voxels, which the tap test
below reads.
Other ways to draw a crowd
This step stamps the rats into the world, so they are part of what a ray hits. A crowd can also
hand its poses to an instance layer (makeCrowd({ instances: layer }), cheapest when rats share
a few clips), or pose them on the GPU from one rest model (rigged: true, for unique poses).
Animation says when to choose which.
A CrowdMember is an id, the entity, a
cache key for its look (variant), an animator, a palette base, a position and a yaw in radians.
The Rat type adds the game's own state. Spawn eight on dry ground, away from the player:
const blocked = (x: number, z: number) =>
x < 10 || z < 10 || x > SIZE.x - 10 || z > SIZE.z - 10 || terrain.waterAt(Math.round(x), Math.round(z)) || insideHouse(x, z, 8);
const rats: Rat[] = [];
const RAT_COUNT = Math.max(1, Math.min(20, Number(params.get("rats") ?? 8)));
for (let i = 0; i < RAT_COUNT; i++) {
const k = i % ratModels.length;
let x = 0, z = 0;
do { x = 20 + rng() * (SIZE.x - 40); z = 20 + rng() * (SIZE.z - 40); }
while (blocked(x, z) || Math.hypot(x - player.x, z - player.z) < 80);
const anim = makeAnimator(ratModels[k], "idle");
rats.push({ id: i + 1, entity: ratModels[k], variant: RAT_KINDS[k], anim, base: ratBase[k], x, y: 0, z, yaw: rng() * Math.PI * 2, state: "wander", speed: 0, turn: 0, timer: rng() * 3 });
}
let caught = 0;
let over = false;makeAnimator plays an entity's clips:
play(id, { fade }) cross-fades to another clip, update(dt) advances time, and finished()
says when a clip that does not loop has ended.
Behaviour
What a rat does is the game's business, not the engine's. Each frame a rat either wanders (picks a clip and a new heading every couple of seconds), flees (runs straight away from the player when they come within 70 voxels), or, once caught, plays its death and is removed:
const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));
function think(r: Rat, dt: number): void {
r.timer -= dt;
if (r.state === "caught") {
if (r.anim.finished() && r.timer <= 0) {
crowd.remove(r.id);
rats.splice(rats.indexOf(r), 1);
}
r.anim.update(dt);
return;
}
const away = Math.hypot(r.x - player.x, r.z - player.z);
if (away < 70 && r.state !== "flee") {
// Too close: run directly away from the player.
r.state = "flee";
r.turn = Math.atan2(r.x - player.x, r.z - player.z);
r.speed = 45;
r.timer = 2 + rng();
r.anim.play("run", { fade: 0.1 });
} else if (r.timer <= 0) {
r.state = "wander";
const clip = ["walk", "walk", "idle", "sniff"][Math.floor(rng() * 4)];
r.anim.play(clip, { fade: 0.25 });
r.speed = clip === "walk" ? 14 : 0;
r.turn = r.yaw + (rng() - 0.5) * 2.4;
r.timer = 1.5 + rng() * 3;
}
if (r.speed > 0) {
r.yaw += Math.max(-4 * dt, Math.min(4 * dt, wrap(r.turn - r.yaw)));
const nx = r.x + Math.sin(r.yaw) * r.speed * dt, nz = r.z + Math.cos(r.yaw) * r.speed * dt;
if (blocked(nx, nz)) { r.turn = r.yaw + Math.PI * (0.6 + rng() * 0.8); }
else { r.x = nx; r.z = nz; }
}
r.y = terrain.heightAt(r.x, r.z) + 1;
r.anim.update(dt);
}A fleeing rat runs at 45 voxels a second, faster than you walk (40) and slower than you run (80),
so catching one means sprinting after it. A yaw of 0 faces +Z, as for the player, so a heading
towards (dx, dz) is atan2(dx, dz).
Catching a rat
A tap happens on the screen; the rat is in the world. makeRay
turns a screen point into a ray from the camera. Then, for each rat:
rayAABBtests the ray against the box of the rat's current pose, which throws out most rats at once;voxelRaycastwalks the ray through that pose's voxels, so a tap between the legs or past the tail misses.
The pose is the one the crowd last stamped (crowd.last(id)), placed exactly as the stamper
placed it: anchor at the rat's rounded position. terrain.pick finds where the ray meets the
ground, so a rat behind a hill cannot be caught through it, and REACH limits it to 9 metres.
const REACH = 90; // 9 m
function grabAt(clientX: number, clientY: number): void {
if (over) return;
const { origin, dir } = makeRay(canvas, view(), clientX, clientY);
const len = Math.hypot(dir[0], dir[1], dir[2]);
// Nothing behind the ground counts, and nothing out of reach.
let best = REACH / len;
const hit = terrain.pick(origin, dir, { maxT: REACH });
if (hit) best = Math.min(best, ((hit[0] - origin[0]) * dir[0] + (hit[1] - origin[1]) * dir[1] + (hit[2] - origin[2]) * dir[2]) / (len * len));
let target: Rat | null = null;
for (const r of rats) {
const pose = crowd.last(r.id);
if (r.state === "caught" || !pose?.model) continue;
const m = pose.model;
// Where the stamper put this pose: the anchor sits at the rat's position.
const o: Vec3 = [Math.round(r.x - m.anchor[0]), Math.round(r.y - m.anchor[1]), Math.round(r.z - m.anchor[2])];
const box = rayAABB(origin, dir, o, [o[0] + m.size.x, o[1] + m.size.y, o[2] + m.size.z]);
if (box === null || box > best) continue;
const exact = voxelRaycast(m.size, m.data, [origin[0] - o[0], origin[1] - o[1], origin[2] - o[2]], dir, best);
if (exact && exact.t < best) { best = exact.t; target = r; }
}
if (!target) return;
target.state = "caught";
target.timer = 1;
target.anim.play("death", { fade: 0.1 });
caught++;
}The distances are all measured in units of dir, which makeRay does not normalise: rayAABB
and voxelRaycast both return t in those units, and the reach and the ground hit are converted
to match. Trees and the house do not block a catch; to add that, test the ray against them
with the same two functions.
Now connect it to the input. recogniseGestures
tells a tap from a drag by the total distance the pointer travelled, so looking around with a
drag never catches anything. With the mouse captured the pointer does not move, so a click
catches at the crosshair in the middle of the screen:
// A tap or click grabs where it lands; with the mouse captured, at the crosshair.
const centre = () => { const b = canvas.getBoundingClientRect(); return [b.left + b.width / 2, b.top + b.height / 2] as const; };
recogniseGestures(input, { tap: (t) => (input.locked() ? grabAt(...centre()) : grabAt(t.x, t.y)) }, { longPressMs: 0 });And add a grab action for the keyboard and gamepad to the action map from step 3:
grab: button("key:Space", "pad:A"),Winning and losing
function finish(text: string): void {
over = true;
input.unlock();
message.textContent = text;
message.hidden = false;
}const message = document.getElementById("message")!; goes next to status at the top. The
frame now moves the player only while the game is on, updates the rats and the crowd, and
checks the end:
if (!over) movePlayer(dt);
const ground = terrain.heightAt(player.x, player.z) + EYE;
player.eyeY += (ground - player.eyeY) * Math.min(1, dt * 10);
if (!over && actions.pressed("grab")) grabAt(...centre());
for (const r of [...rats]) think(r, dt);
const f = view();
crowd.update(rats, f.camPos);
if (!over) {
clock += dt;
if (caught === RAT_COUNT) finish(`All ${RAT_COUNT} rats caught with ${Math.ceil(DAY_SECONDS - clock)} s of daylight to spare.`);
else if (clock >= DAY_SECONDS) finish(`Night fell. You caught ${caught} of ${RAT_COUNT} rats.`);
}think can remove a rat from the list, so the loop walks a copy ([...rats]). crowd.update
needs the camera position for its distance levels, and render with the same frame f. The
status line counts the catch:
status.textContent = `${caught}/${RAT_COUNT} rats · ${Math.max(0, Math.ceil(DAY_SECONDS - clock))} s to nightfall`;Run it
Walk towards a rat and it bolts; sprint after it and click (or tap, or press Space) when it is
under the crosshair. It plays its death animation and disappears. ?rats=3 makes a quicker game.
The creature example goes further with the same parts: it hits a rat
at a bone, opens a wound where you tapped (wound) and takes limbs off (sever). See
Animation and Picking.
Next: performance and deploying.
5. Day and night
Rat Hunt, step 5: a game clock that drives timeOfDay and atmosphereFrame from morning to dusk, and point lights that come on as it gets dark.
7. Performance and deploying
Rat Hunt, step 7: a quality menu over QUALITY_PRESETS, adaptive render resolution with makePerf, then a GitHub Actions workflow that publishes to Pages.