voxolith

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:

  • makeBrickStamper places "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.
  • makeCrowd poses 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:

  1. rayAABB tests the ray against the box of the rat's current pose, which throws out most rats at once;
  2. voxelRaycast walks 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.

On this page