Crafty · Chapter 4 Non-Playable Character (NPC) AI

Chapter 4 · Crafty: Building a Voxel Sandbox Game

Non-Playable Character (NPC) AI

Non-playable characters (NPCs) bring the world to life. Crafty's NPC system uses lightweight state-machine components that attach to GameObject entities, driving movement, rotation, and animation. This chapter covers the built-in NPC types — ducks, ducklings, pigs, creepers, bees, fireflies, and cats and kittens — and shows how the pattern can be extended for more complex behaviors.

§ 4.1

NPC Architecture#

Every NPC is a GameObject with a MeshRenderer for its visual representation and an AI component that implements the Component interface:

abstract class Component {
  readonly gameObject: GameObject;
  start?(): void;
  update?(dt: number): void;
  destroy?(): void;
}

The AI component owns the NPC's internal state, movement velocity, yaw rotation, and references to child GameObject nodes (such as the head) for animation. It reads the World for ground and water collision and accesses DuckAI.playerPos — a static field written once per frame by the game loop — to sense the player's position.

All NPC components share common infrastructure:

§ 4.2

The AI State Machine#

Two state-machine variants: ducks have idle/wander/flee with player-distance preemption; pigs have just idle/wander
Two state-machine variants: ducks have idle/wander/flee with player-distance preemption; pigs have just idle/wander

The three NPC types implement one of two state-machine patterns:

PatternStatesUsed by
StatelessdriftFireflies
Single-statefollowDucklings
Two-stateidle ↔ wanderPigs
Three-stateidle ↔ wander ↔ fleeDucks
Four-state (hostile)idle ↔ wander → chase → detonateCreepers
Four-state (friendly)idle ↔ wander → follow → sitCats, kittens

Idle#

The NPC stands still, bobbing gently. A random timer counts down; when it expires, the NPC transitions to wander. In the three-state variant, the NPC also checks player distance each frame and enters flee immediately if the player comes within a threshold radius.

case 'idle': {
  this._timer -= dt;
  if (playerDist2 < 36) {           // 6 blocks — flee radius
    this._enterFlee();
  } else if (this._timer <= 0) {
    this._pickWanderTarget();
  }
  break;
}

Wander#

The NPC picks a random target position within a distance range and walks toward it at a constant speed:

private _pickWanderTarget(): void {
  const angle = Math.random() * Math.PI * 2;
  const dist  = 3 + Math.random() * 8;
  this._targetX = go.position.x + Math.cos(angle) * dist;
  this._targetZ = go.position.z + Math.sin(angle) * dist;
  this._hasTarget = true;
  this._state = 'wander';
  this._timer = 6 + Math.random() * 6;
}

Movement toward the target uses simple direct steering — the NPC moves in a straight line to the waypoint at a fixed speed:

const dx = this._targetX - gx;
const dz = this._targetZ - gz;
const dist = Math.sqrt(dist2);
go.position.x += (dx / dist) * 1.5 * dt;
go.position.z += (dz / dist) * 1.5 * dt;
this._yaw = Math.atan2(-(dx / dist), -(dz / dist));

The yaw is updated each frame so the NPC's model faces the direction of travel. When the NPC arrives within 0.5 blocks of the target, or when a wander timer expires, it returns to idle.

Flee#

Ducks flee from the player when within detection range (6 blocks). The flee behavior moves the duck directly away from the player at a higher speed (4.0 m/s vs 1.5 m/s wander). Once the distance exceeds 14 blocks, the duck returns to idle:

case 'flee': {
  if (playerDist2 > 196) {         // 14 block safe distance
    this._enterIdle();
    break;
  }
  const nx = dist > 0 ? -dpx / dist : 0;
  const nz = dist > 0 ? -dpz / dist : 0;
  go.position.x += nx * 4.0 * dt;
  go.position.z += nz * 4.0 * dt;
  this._yaw = Math.atan2(-nx, -nz);
  break;
}

Pigs are docile — they never flee and remain in the two-state cycle indefinitely.

Follow#

Ducklings use a single-state follow behavior instead of the idle/wander pattern. Rather than selecting random targets, each duckling tracks its parent duck's position and maintains a polar offset so the brood spreads naturally around the parent:

case 'follow': {
  this._offsetAngle += dt * 0.25;
  const tx = this._parent.position.x + Math.cos(this._offsetAngle) * this._followDist;
  const tz = this._parent.position.z + Math.sin(this._offsetAngle) * this._followDist;
  // steer toward target position
  const dx = tx - go.position.x;
  const dz = tz - go.position.z;
  const dist = Math.sqrt(dx * dx + dz * dz);
  const speed = dist > 2.5 ? 3.5 : 1.8;
  go.position.x += (dx / dist) * speed * dt;
  go.position.z += (dz / dist) * speed * dt;
  break;
}

The duckling does not check player distance directly — it inherits the parent's flee behavior automatically by following the parent.

Chase#

Creepers (and future hostile mobs) add a chase state that preempts idle and wander when the player enters the detect radius (8 blocks). The creeper moves toward the player at a higher speed (1.8 m/s) and faces the player directly:

case 'chase': {
  const dx = playerPos.x - go.position.x;
  const dz = playerPos.z - go.position.z;
  const dist = Math.sqrt(dx * dx + dz * dz);
  if (dist > 12) { this._state = 'idle'; break; }           // lose interest
  if (dist < 1.8) { this._state = 'detonate'; break; }       // close enough
  go.position.x += (dx / dist) * 1.8 * dt;
  go.position.z += (dz / dist) * 1.8 * dt;
  this._yaw = Math.atan2(-dx / dist, -dz / dist);
  break;
}

Hysteresis between the detect radius (8 blocks) and lose radius (12 blocks) prevents the creeper from rapidly flickering between chase and idle when the player is at the boundary.

Detonate#

When the creeper reaches touching distance (1.8 blocks), it enters detonate — it stops moving and begins a 2.5-second fuse countdown. The creeper's body flashes between green and white at an accelerating rate:

case 'detonate': {
  this._detonateElapsed += dt;
  if (playerDist2 > 36) { this._exitDetonate(); break; }     // player escaped
  if (this._detonateElapsed >= 2.5) { this._explode(); break; }
  const flashInterval = Math.max(0.08, 0.5 - this._detonateElapsed * 0.18);
  const on = Math.floor(this._detonateElapsed / flashInterval) % 2 === 0;
  this._setColor(on ? CREEKER_GREEN : WHITE);
  break;
}

If the player retreats beyond 6 blocks during the fuse, the creeper cancels detonation and returns to idle. If the full 2.5 seconds elapse, the creeper explodes — destroying blocks within a 4-block spherical radius and emitting an 80-particle fire burst. See §4.6 for the full explosion mechanics.

Follow (seek) and Sit#

Cats and kittens share the creeper's "preempt idle/wander when the player is near" structure, but with a friendly outcome. The follow state steers toward the player rather than away, and once the cat is close enough it transitions to sit instead of attacking. Hysteresis between the attract radius (10 blocks) and the lose radius (16 blocks) keeps the cat committed once it starts approaching:

case 'follow': {
  if (playerDist2 > LOSE_SQ) {        // gave up — player out of range
    this._enterIdle();
    break;
  }
  if (playerDist2 < SIT_SQ) {         // reached the player — sit down
    this._state = 'sit';
    break;
  }
  const dist = Math.sqrt(playerDist2);
  const nx = dpx / dist;
  const nz = dpz / dist;
  this._yaw = Math.atan2(-nx, -nz);   // face the player
  moving = this._tryStep(go, nx, nz, FOLLOW_SPEED, dt);
  break;
}

The sit state stops all movement, keeps the cat facing the player, and stands the cat back up (follow) only when the player walks away. The seated pose is produced by smoothly tilting the body backward — see §4.9.

case 'sit': {
  if (playerDist2 > STAND_SQ) {       // player left — stand and follow again
    this._state = 'follow';
    break;
  }
  const dist = Math.sqrt(playerDist2);
  if (dist > 0.001) {
    this._yaw = Math.atan2(-dpx / dist, -dpz / dist);   // keep facing the player
  }
  break;
}
§ 4.3

Duck AI#

Duck AI
Duck AI

DuckAI (crafty/game/components/duck_ai.ts) implements the full three-state machine. Ducks are amphibious — they walk on land and float on water:

Concentric flee zones around the player (6-block trigger inside, 14-block safe ring outside) — the gap creates hysteresis so the duck doesn't flicker between flee and idle
// If the block below is water, float on the surface
const blockBelow = this._world.getBlockType(
  Math.floor(gx), Math.floor(groundY - 1), Math.floor(gz));
if (blockBelow === BlockType.WATER) {
  go.position.y = groundY;  // float on water surface
} else {
  go.position.y = groundY;  // stand on solid ground
}

The duck's model has a child GameObject named Duck.Head which is animated with a sinusoidal bob. The bob frequency and amplitude differ between idle and wander states:

this._bobPhase += dt * (this._state === 'wander' ? 6 : 2);
const bobAmp = this._state === 'wander' ? 0.018 : 0.008;
this._headGO.position.y = this._headBaseY + Math.sin(this._bobPhase) * bobAmp;

Player position is fed to ducks via a static field DuckAI.playerPos, written once per frame by the game loop. This avoids coupling the AI to a specific player component.

§ 4.4

Duckling AI#

Ducling AI
Ducling AI

DucklingAI (crafty/game/components/duckling_ai.ts) implements a follow behavior rather than the idle/wander/flee pattern. Each duckling tracks its parent duck's world position and maintains a personalised polar offset so the brood spreads naturally:

Each duckling steers toward parent + polar offset; offsetAngle drifts at 0.25 rad/s so the brood gently swirls around the parent
Each duckling steers toward parent + polar offset; offsetAngle drifts at 0.25 rad/s so the brood gently swirls around the parent
constructor(parent: GameObject, world: World) {
  this._parent = parent;
  this._offsetAngle = Math.random() * Math.PI * 2;
  this._followDist  = 0.55 + Math.random() * 0.5;
}

Each frame, the duckling computes its target position as the parent's position plus the polar offset, then steers toward it:

this._offsetAngle += dt * 0.25;  // gentle swirl
const tx = this._parent.position.x + Math.cos(this._offsetAngle) * this._followDist;
const tz = this._parent.position.z + Math.sin(this._offsetAngle) * this._followDist;

The duckling adjusts its speed based on distance — faster when lagging behind, creating a realistic catching-up motion:

const speed = dist > 2.5 ? 3.5 : 1.8;
go.position.x += nx * speed * dt;
go.position.z += nz * speed * dt;

Ducklings do not check for the player directly — they simply follow their parent, so they inherit the parent's flee behavior automatically.

§ 4.5

Pig AI#

Ducling AI
Ducling AI

PigAI (crafty/game/components/pig_ai.ts) implements a simpler two-state machine (idle ↔ wander) with no flee response. Pigs are larger and slower than ducks:

PropertyDuckDucklingPigBeeCreeperFireflyCat
Statesidle/wander/fleefollowidle/wanderidle→approach→hoveridle/wander/chase/detonatedrift (stateless)idle/wander/follow/sit
Wander speed1.5 m/s1.8–3.5 m/s1.2 m/s2.5 m/s1.0 m/s0.9 m/s (3D)1.3 m/s
Player speed4.0 m/s (flee)———1.8 m/s (chase)—2.8 m/s (follow)
Idle bob0.008 @ 2 Hz0.004 @ 2 Hz0.005 @ 1.5 Hz0.15 vert. @ 2.5 Hz—— (glow pulse)0.006 @ 2 Hz
Wander bob0.018 @ 6 Hz0.012 @ 7 Hz0.014 @ 4 Hz0.15 vert. @ 2.5 Hz—— (glow pulse)0.016 @ 7 Hz
Hover bob———0.15 vert. @ 7.5 Hz———
WaterYes (float)No*AvoidsNo (flies)AvoidsNo (flies)Avoids
GravityYesYesYesNoYesNoYes

*Ducklings use ground collision only — they do not float on water. Avoids means the land animal refuses to step onto or spawn on a water surface — see §4.10. Kittens share the cat's CatAI verbatim (only the mesh scale and color differ), so the Cat column applies to both.

The pig's wander behavior is identical in structure to the duck's but uses slightly different parameters: slower speed, longer wander distances, and a different head bob signature.

§ 4.6

Creeper AI#

Creeper AI
Creeper AI

CreeperAI (crafty/game/components/creeper_ai.ts) implements a hostile four-state machine that introduces the first truly threatening NPC in Crafty. Creepers spawn in any biome (unlike passive mobs which are restricted to GrassyPlains) and are distinguished by their tall green silhouette and explosive demise.

Four creeper states with transitions: idle ↔ wander, both preempted by chase when the player is within 8 blocks; chase → detonate at 1.8 blocks; detonate → idle if the player retreats beyond 6 blocks, otherwise → explode after 2.5 seconds
PropertyValue
Statesidle / wander / chase / detonate
Wander speed1.0 m/s
Chase speed1.8 m/s
Detect radius8 blocks
Lose radius12 blocks (hysteresis)
Touch radius1.8 blocks (triggers detonate)
Detonate cancel radius6 blocks
Detonate fuse2.5 seconds
Explosion radius4 blocks (spherical)
BiomeAny

Four-State Machine#

Idle → Wander. The creeper stands still or wanders aimlessly at 1.0 m/s, using the same timer-based target selection as pigs. Both states preemptively transition to chase if the player comes within the detect radius (8 blocks).

Chase. The creeper moves toward the player at 1.8 m/s. If the player moves beyond 12 blocks the creeper loses interest and returns to idle. If the player comes within touching distance (1.8 blocks), the creeper enters the detonate state.

Detonate. The creeper stops moving and begins flashing between green and white at an accelerating rate — the flash interval starts at 0.5 s and decreases by 0.18 s per second elapsed, capped at 0.08 s:

const flashInterval = Math.max(0.08, 0.5 - this._detonateElapsed * 0.18);

While detonating, the creeper checks whether the player has retreated beyond the cancel radius (6 blocks). If so, it restores its green color and returns to idle with a short cooldown timer:

if (playerDist2 > DETONATE_CANCEL_RADIUS_SQ) {
  this._exitDetonate();
  break;
}

If the full 2.5-second fuse elapses without the player escaping, the creeper explodes.

Explosion#

The explosion has two phases:

  1. Particle burst — a static onExplode callback fires at the creeper's position, triggering an 80-particle burst via the game's explosionPass. The explosion config uses fire-colored particles with color_over_lifetime (orange → dark transparent) and size_over_lifetime (grow → shrink) modifiers.

  2. Block destruction — all blocks within a 4-block spherical radius are destroyed via world.mineBlock(). For each destroyed block, a static onBlockDestroyed callback fires, routing the edit through the game's persistence and synchronization system:

                ┌─ Local mode → dedup against savedWorld.edits[]
CreeperAI       │                → push to edit log
  └─ mineBlock ─┤                → stash for chunk re-entry
  └─ onBlockDestroyed ─┤         → mark save dirty
                       └─ Network mode → stash + sendBlockBreak to server
Each block a creeper destroys fires onBlockDestroyed, which branches on multiplayer mode: local saves dedup against savedWorld.edits[], push to the edit log, and mark the world dirty; network mode stashes the edit and sends a block-break to the authoritative server

This ensures creeper explosions are persisted in local saves and replicated to all players in multiplayer games. The explosion respects the break→place dedup rule — if a player placed a block and a creeper later destroys it, both edits remain in the log so replay clears the placed block first.

Flash Visual#

The color change is applied directly to the child MeshRenderer materials at runtime:

private _setColor(color: [number, number, number, number]): void {
  for (const child of go.children) {
    const renderer = child.getComponent(MeshRenderer);
    if (renderer && renderer.material instanceof PbrMaterial) {
      renderer.material.albedo = color;
      renderer.material.markDirty();
    }
  }
}

The creeper uses a single CREEPER_GREEN color for both body and head (unlike earlier NPCs which use different colors for different body parts).

Spawning#

Creepers spawn during chunk generation with an 8% probability per XZ chunk column, using the same AnimalMeshes interface as passive mobs. Unlike ducks and pigs, they have no biome filter — they can appear in any biome:

if (Math.random() < CREEPER_CHANCE) {
  const wx = Math.floor(baseX + Math.random() * CHUNK_SIZE);
  const wz = Math.floor(baseZ + Math.random() * CHUNK_SIZE);
  spawnCreeper(wx, wz, world, scene, meshes.creeperBody, meshes.creeperHead);
}

The mesh stands twice as tall as a pig, with a body half-height of 0.60 and leg half-height of 0.20, giving the creeper its distinctive towering silhouette.

§ 4.7

Bee AI#

Bee AI
Bee AI

BeeAI (crafty/game/components/bee_ai.ts) implements a flying NPC with a three-state machine — idle, wander, and hover — that drifts through GrassyPlains biomes and periodically investigates nearby flowers. Unlike ground NPCs, bees have no gravity; they fly at a fixed altitude above the terrain.

Flight Mechanics#

Bees maintain altitude via terrain tracking rather than gravity. Each frame the ground height below the bee is sampled, and the bee's Y position is lerped toward the target height at 3× per second, giving smooth transitions:

this._verticalPhase += dt * 2.5;
const hoverOffset = Math.sin(this._verticalPhase) * 0.15;
const targetBase = this._flowerTarget
  ? this._flowerTarget.y - 0.1         // just above flower
  : groundY + this._flightAltitude;     // normal cruising
go.position.y += (targetBase + hoverOffset - go.position.y) * Math.min(1, 3.0 * dt);

The _verticalPhase oscillates all parts (body bob, wing flap) and runs continuously in every state. When the bee acquires or loses a _flowerTarget, the lerp carries it smoothly between cruising altitude (2.5–4.0 blocks above ground) and the hover height (0.1 blocks below the flower block center, i.e. resting on the flower's visual top).

Side-view altitude profile of a bee: it cruises in a terrain-tracking band 2.5–4.0 blocks above ground, descends along the approach toward a flower detected within 6 blocks to hover at flower.y − 0.1, then climbs smoothly back to the cruise band on departure

State Machine#

idle → (flower found) → approach (wander toward flower XZ)
idle → (no flower)    → wander (random direction)
approach → (arrived)  → hover (fine 3D positioning on flower)
hover → (timer done)  → wander (fly away, clear flower target)
wander → (done)       → idle

Idle#

The bee hovers in place with a vertical bob. A random timer (3–6 seconds) counts down. When it expires, the bee scans for nearby flower blocks (6-block radius). If found, it enters the approach sub-mode: the wander target is set to the flower's XZ coordinates and the state switches to wander, causing the bee to fly toward the flower at 2.5 m/s. If no flower is found, a random wander target is picked instead:

case 'idle': {
  this._timer -= dt;
  if (this._timer <= 0) {
    if (this._findNearestFlower()) {
      this._enterApproachFlower();
    } else {
      this._pickWanderTarget();
    }
  }
  break;
}
private _enterApproachFlower(): void {
  this._targetX = this._flowerTarget.x;
  this._targetZ = this._flowerTarget.z;
  this._hasTarget = true;
  this._state = 'wander';
  this._timer = 6;
}

During the approach flight the terrain tracking targets _flowerTarget.y - 0.1 instead of the normal cruising altitude, so the bee smoothly descends to just above the flower as it flies toward it. By the time it arrives, the Y is already correct — no pop.

Wander#

The bee flies toward a target position at 2.5 m/s. Directional yaw is computed from the movement vector:

private _pickWanderTarget(): void {
  const angle = Math.random() * Math.PI * 2;
  const dist  = 8 + Math.random() * 8;
  this._targetX = go.position.x + Math.cos(angle) * dist;
  this._targetZ = go.position.z + Math.sin(angle) * dist;
  this._hasTarget = true;
  this._state = 'wander';
  this._timer = 8 + Math.random() * 4;
  this._flowerTarget = null;
}

Steering uses direct-to-target movement. When the target is reached (within 1.0 block in XZ), the bee checks whether a _flowerTarget is set — if so, it transitions to hover for fine positioning; otherwise it returns to idle:

case 'wander': {
  ...
  if (dist2 < 1.0) {
    if (this._flowerTarget) {
      this._enterHover();
    } else {
      this._enterIdle();
    }
    break;
  }
  go.position.x += (dx / dist) * 2.5 * dt;
  go.position.z += (dz / dist) * 2.5 * dt;
  this._yaw = Math.atan2(-(dx / dist), -(dz / dist));
  break;
}

If the wander timer expires before reaching the target, the bee idles in place.

Hover#

When the bee reaches the flower's XZ position (approach completed), it enters hover — a fine 3D positioning mode that steers toward the flower at a reduced speed (0.8 m/s). The hover Y targets _flowerTarget.y - 0.1, placing the bee's body center right at the flower's visual top:

TypeScript
case 'hover': {
  this._hoverElapsed += dt;
  if (this._hoverElapsed >= this._hoverDuration) {
    this._pickWanderTarget();     // fly away after hovering
    break;
  }
  const targetY = this._flowerTarget.y - 0.1 + Math.sin(this._verticalPhase * 3) * 0.15;
  const dx = this._flowerTarget.x - gx;
  const dz = this._flowerTarget.z - gz;
  const dy = targetY - go.position.y;
  const dist = Math.sqrt(dx * dx + dz * dz + dy * dy);
  if (dist > 0.01) {
    go.position.x += (dx / dist) * 0.8 * dt;
    go.position.z += (dz / dist) * 0.8 * dt;
    go.position.y += (dy / dist) * 0.8 * dt;
  }
  this._yaw = Math.atan2(-dx, -dz);
  break;
}

After 4–8 seconds the hover timer expires, and instead of idling in place the bee calls _pickWanderTarget(). This clears _flowerTarget and sets a random wander destination, causing the bee to fly away. The terrain tracking lerp smoothly carries it back up to cruising altitude. Once it reaches the wander target, it idles and may find a different flower — this prevents the bee from getting stuck re-hovering the same flower.

If the flower is broken during hover, _flowerTarget becomes stale (the key is removed from flowerPositions), and on the next frame the null check transitions to _pickWanderTarget() immediately.

Flower Detection#

Flower positions are cached in a shared static Set<string>, keyed as "x:y:z". The cache is populated when chunks are generated (scanning each new chunk for BlockType.FLOWER) and updated in real-time via World.onBlockSet / World.onBlockBeforeRemove callbacks wired in main.ts:

TypeScript
// main.ts
world.onBlockSet = (wx, wy, wz, blockType) => {
  if (blockType === BlockType.FLOWER) {
    BeeAI.flowerPositions.add(`${wx}:${wy}:${wz}`);
  }
};
world.onBlockBeforeRemove = (wx, wy, wz, blockType) => {
  if (blockType === BlockType.FLOWER) {
    BeeAI.flowerPositions.delete(`${wx}:${wy}:${wz}`);
  }
};

The bee iterates this set to find the nearest flower within 6 blocks, using squared-distance comparison:

TypeScript
private _findNearestFlower(): boolean {
  let nearestDist2 = FLOWER_DETECT_RADIUS_SQ;
  let found = false;
  for (const key of BeeAI.flowerPositions) {
    const [fx, fy, fz] = key.split(':').map(Number);
    const dx = fx + 0.5 - go.position.x;
    const dy = fy + 0.5 - go.position.y;
    const dz = fz + 0.5 - go.position.z;
    const d2 = dx * dx + dy * dy + dz * dz;
    if (d2 < nearestDist2) { /* record nearest */ }
  }
  ...
}

Parameters#

PropertyValue
Statesidle → approach (wander) → hover
Wander speed2.5 m/s
Hover speed0.8 m/s
Flight altitude2.5–4.0 blocks above ground
Vertical bob (idle/wander)0.15 @ 2.5 Hz
Vertical bob (hover)0.15 @ 7.5 Hz
Flower detect radius6 blocks
Wander distance8–16 blocks
Idle duration3–6 seconds
Hover duration4–8 seconds
Wander timer8–12 seconds
Y lerp rate3× per second
BiomeGrassyPlains only
Spawn probability0.10 per column
Clearance required3 blocks above spawn

Model#

The bee GameObject hierarchy consists of nine child nodes, each with its own MeshRenderer and material:

ChildMeshMaterialPosition
Bee.BodyYellow body box (0.24×0.18×0.30)Yellow, roughRoot center
Bee.Stripe1Black stripe band (0.26×0.18×0.06)Black, rough(0, 0, +0.06)
Bee.Stripe2Black stripe bandBlack, rough(0, 0, +0.14)
Bee.HeadBrown head box (0.18×0.14×0.08)Brown, rough(0, +0.05, −0.19)
Bee.EyeLTiny black eye (0.04×0.04×0.02)Black, rough(−0.045, +0.05, −0.22)
Bee.EyeRTiny black eyeBlack, rough(+0.045, +0.05, −0.22)
Bee.WingLTranslucent wing (0.24×0.01×0.12)White, 45% alpha(−0.10, +0.10, 0)
Bee.WingRTranslucent wingWhite, 45% alpha(+0.10, +0.10, 0)

Both wings are animated identically with a fast sinusoidal flap (18 Hz, ±0.4 rad) that runs continuously across all states:

TypeScript
this._wingPhase += dt * 18;
const wingAngle = Math.sin(this._wingPhase) * 0.4;
const q = Quaternion.fromAxisAngle(new Vec3(1, 0, 0), wingAngle);
this._wingL.rotation = q;
this._wingR.rotation = q;

Spawning#

Bees spawn during chunk generation with 10% probability per column in GrassyPlains biomes, using the same onChunkAdded mechanism described in §4.14. Unlike ducks (which may spawn ducklings), bees always spawn as solitary individuals with a 3-block vertical clearance check:

TypeScript
if (Math.random() < BEE_CHANCE) {
  const wx = Math.floor(baseX + Math.random() * CHUNK_SIZE);
  const wz = Math.floor(baseZ + Math.random() * CHUNK_SIZE);
  const topY = world.getTopBlockY(wx, wz, 200);
  if (topY > 0 && world.getBiomeAt(wx, topY, wz) === BiomeType.GrassyPlains) {
    if (world.getBlockType(wx, topY + 1, wz) !== BlockType.NONE) return;
    if (world.getBlockType(wx, topY + 2, wz) !== BlockType.NONE) return;
    if (world.getBlockType(wx, topY + 3, wz) !== BlockType.NONE) return;
    spawnBee(wx, wz, world, scene, body, stripe, head, eye, wing);
  }
}
§ 4.8

Firefly AI#

Firefly AI
Firefly AI

Fireflies are Crafty's first ambient NPC — tiny self-illuminating cubes that only appear at night, drift aimlessly a few blocks above the terrain, and fade away at sunrise. They are unusual in two ways: they have no state machine (just a continuous drift), and they are not spawned by chunk generation. Instead a dedicated swarm manager keeps a small population alive around the player and recycles instances as the player moves and as night falls and lifts.

Stateless Drift#

FireflyAI (crafty/game/components/firefly_ai.ts) has no FSM. Each firefly continuously drifts toward a randomly chosen wander point a few blocks away — in all three axes, staying in a band above the ground — and repicks the point on arrival or after a short timer:

private _pickTarget(): void {
  const angle = Math.random() * Math.PI * 2;
  const dist = 2 + Math.random() * 5;
  const tx = go.position.x + Math.cos(angle) * dist;
  const tz = go.position.z + Math.sin(angle) * dist;
  const ground = this._world.getTopBlockY(Math.floor(tx), Math.floor(tz), 200);
  const baseY = ground > 0 ? ground : go.position.y - 2;
  this._target.set(tx, baseY + 1.5 + Math.random() * 3.5, tz);
  this._timer = 1.5 + Math.random() * 3;
}

Movement is a normalized step toward the target — the same direct steering the ground NPCs use for wander, but applied in full 3D rather than just XZ:

const dist = Math.hypot(dx, dy, dz);
if (dist > 0.001) {
  const step = Math.min((0.9 * dt) / dist, 1);
  go.position.x += dx * step;
  go.position.y += dy * step;
  go.position.z += dz * step;
}
if (this._timer <= 0 || dist < 0.4) {
  this._pickTarget();
}

A slow tumble rotates the cube around a random fixed axis so the spark is never perfectly static:

this._spinAngle += this._spinRate * dt;
go.rotation = Quaternion.fromAxisAngle(this._spinAxis, this._spinAngle);

Glow Pulse#

In place of a head bob, the firefly animates the intensity of its PointLight. A sine pulse is multiplied by the owner's glow field — a 0–1 master fade the swarm manager drives across sunset and sunrise (§4.8) — so the light both flickers softly and fades in/out with the night:

this._pulse += dt * 3.0;
const pulse = 0.6 + 0.4 * Math.sin(this._pulse);
this._light.intensity = BASE_INTENSITY * go.glow * pulse;

Emissive Body + Point Light#

The Firefly entity (crafty/game/entities/firefly_entity.ts) owns three things: a tiny emissive cube MeshRenderer, a short-range PointLight, and the FireflyAI component. The cube's emissive factor is pushed well above 1 so it reads as a bright self-lit spark in the HDR pipeline (and blooms):

const GLOW_EMISSIVE: [number, number, number] = [3.4, 4.0, 1.0];
...
const mat = new PbrMaterial({
  albedo: [0.04, 0.05, 0.02, 1],
  emissiveFactor: GLOW_EMISSIVE,
  emissiveMap: Firefly._emissiveTex!,   // white 1×1 — lets the factor through
  roughness: 0.6,
});

const light = f.addComponent(new PointLight());
light.color = new Vec3(0.85, 1.0, 0.4);   // greenish-yellow
light.radius = 1.8;                        // a small pool of glow around the spark
light.castShadow = false;

The deferred GBuffer computes emission as emissiveMap.rgb × emissiveFactor, so a non-black emissive map is required for the factor to produce any glow at all — hence the white 1×1 texture. The cube is tiny (0.03 blocks) and its PointLight casts no shadow, giving each firefly a small local pool of greenish light without the cost of a shadow map.

Night-Only Swarm Management#

The swarm manager (crafty/game/firefly_spawner.ts) is what makes fireflies an ambient, time-of-day effect rather than a static mob. It computes a smooth nightFactor in [0, 1] — 0 during the day, 1 in full night, ramping over the hour around sunset (18:00→19:00) and sunrise (05:00→06:00):

function nightFactor(hours: number): number {
  const h = wrapHours(hours);
  if (h >= 19 || h < 5) {
    return 1;
  }
  if (h >= 18) {
    return h - 18;   // sunset ramp 18→0 .. 19→1
  }
  if (h < 6) {
    return 6 - h;    // sunrise ramp 5→1 .. 6→0
  }
  return 0;          // daytime
}

Each frame the manager recycles fireflies that the player has outrun or that should not exist by day, gradually populates the swarm near the player at a fixed ramp rate, then drives the per-firefly glow and scale from the night factor (smoothstepped) so the whole swarm fades in at dusk and out at dawn:

const nf = nightFactor(timeOfDay);

// Recycle by day, or once a firefly drifts past the despawn radius.
for (let i = active.length - 1; i >= 0; i--) {
  const f = active[i];
  const dx = f.position.x - px, dz = f.position.z - pz;
  if (nf <= 0 || dx * dx + dz * dz > DESPAWN_RADIUS * DESPAWN_RADIUS) {
    release(f);
    active.splice(i, 1);
  }
}
if (nf <= 0) { spawnAccum = 0; return; }

// Ramp the swarm in near the player.
spawnAccum += SPAWN_PER_SEC * dt;
while (active.length < MAX_FIREFLIES && spawnAccum >= 1) {
  spawnAccum -= 1;
  /* pick a point in the spawn ring, sample ground, reuse a pooled firefly */
}

// Sunset/sunrise fade: smoothstep the scale and drive the light glow.
const s = nf * nf * (3 - 2 * nf);
for (const f of active) {
  f.glow = nf;
  f.scale.set(s, s, s);
}
Firefly swarm lifecycle: a nightFactor curve over 24 hours (1 at night, ramping down 05→06 at sunrise and up 18→19 at sunset, 0 by day) drives a smoothstepped swarm scale and glow; a top-down view shows pooled fireflies spawning within SPAWN_RADIUS 22 and recycled once they drift past DESPAWN_RADIUS 44, all tracking the moving player

Pooling#

Because the swarm is constantly created and destroyed — every night, and continuously as the player walks — recycled fireflies are pushed onto a pool and reused rather than rebuilt. This avoids churning the GPU material and light buffers each time a firefly despawns:

const release = (f: Firefly): void => {
  scene.remove(f);
  f.glow = 0;
  pool.push(f);
};
...
const f = pool.pop() ?? Firefly.create(world);
f.prepare(sx, sy, sz);
scene.add(f);
active.push(f);

Parameters#

PropertyValue
BehaviorStateless 3D drift
Drift speed0.9 m/s
Wander distance2–7 blocks (3D)
Repick timer1.5–4.5 seconds (or on arrival)
Tumble rate0.4–1.2 rad/s (random fixed axis)
Glow pulse0.6 ± 0.4, base intensity 1.4
Lightgreenish-yellow, radius 1.8, no shadow
Max swarm size16
Spawn radius22 blocks (ring around player)
Despawn radius44 blocks
Spawn ramp6 per second
Active windowNight only (fade over sunset/sunrise hour)
GravityNo (free-floating)
§ 4.9

Cat and Kitten AI#

CatAI (crafty/game/components/cat_ai.ts) is Crafty's first friendly NPC — a passive land animal that idles and wanders like a pig, but is drawn toward the player when nearby and sits down once it reaches them. The same component drives both the full-size cat and the smaller kitten; the two entities (crafty/game/entities/cat_entity.ts) differ only in mesh scale and color, so a kitten behaves exactly like a cat.

Four cat states with transitions: idle ↔ wander, both preempted by follow when the player is within 10 blocks; follow → sit at 1.4 blocks; sit → follow when the player moves beyond 3 blocks; follow → idle if the player gets more than 16 blocks away
PropertyValue
Statesidle / wander / follow / sit
Wander speed1.3 m/s
Follow speed2.8 m/s
Attract radius10 blocks (enter follow)
Lose radius16 blocks (give up — hysteresis)
Sit radius1.4 blocks (enter sit)
Stand radius3.0 blocks (leave sit)
Sit pitch0.55 rad body tilt, lerped at 6×/s
Idle bob0.006 @ 2 Hz
Wander bob0.016 @ 7 Hz
Tail swish±0.5 rad @ 6 Hz moving, ±0.3 rad @ 3 Hz resting
BiomeGrassyPlains only (never on water — §4.10)
Spawn probability0.12 per column
Kitten litter50% chance of 3 kittens (scale 0.6)
GravityYes

Four-State Machine#

Idle → Wander. Identical in structure to the pig: a random timer drives aimless wandering at 1.3 m/s. Both states preemptively transition to follow the moment the player comes within the attract radius (10 blocks).

Follow. The cat trots toward the player at 2.8 m/s, facing them directly. It gives up and returns to idle if the player gets more than 16 blocks away (the gap between the 10-block attract radius and the 16-block lose radius is hysteresis, preventing flicker at the boundary). When the cat closes within the sit radius (1.4 blocks), it transitions to sit.

Sit. The cat stops moving, keeps turning to face the player, and tilts its body back into a seated pose. If the player walks beyond the stand radius (3.0 blocks), the cat stands up and resumes follow — so it will trail the player around, sitting whenever they pause and rising whenever they move on. The full follow and sit case bodies are shown in §4.2.

The Sit Pose#

Rather than a skeletal animation, the seated pose is a procedural body tilt. A _sit factor is lerped toward 1 while sitting and 0 otherwise, and the Cat.Body child is pitched backward about its local X axis by that fraction of SIT_PITCH. Because the lerp is frame-rate-independent (Math.min(1, dt * 6)), the cat eases smoothly into and out of the pose:

const sitTarget = this._state === 'sit' ? 1 : 0;
this._sit += (sitTarget - this._sit) * Math.min(1, dt * 6);
if (this._bodyGO) {
  this._bodyGO.rotation = Quaternion.fromAxisAngle(_X_AXIS, this._sit * SIT_PITCH);
}

The head bob is scaled by (1 - this._sit) so it fades out as the cat settles, while the tail keeps swishing in every state — brisk while moving, a slow content sway at rest:

if (this._headGO) {
  this._bobPhase += dt * (moving ? 7 : 2);
  const bobAmp = (moving ? 0.016 : 0.006) * (1 - this._sit);
  this._headGO.position.y = this._headBaseY + Math.sin(this._bobPhase) * bobAmp;
}
if (this._tailGO) {
  this._tailPhase += dt * (moving ? 6 : 3);
  const swishAmp = moving ? 0.5 : 0.3;
  this._tailGO.rotation = Quaternion.fromAxisAngle(_Z_AXIS, Math.sin(this._tailPhase) * swishAmp);
}

Model#

The cat GameObject hierarchy is three procedurally-built boxes (crafty/game/assets/cat_mesh.ts), each a child node with its own MeshRenderer:

ChildMeshNotes
Cat.BodySleek body + four slim legsLifted CAT_BODY_LIFT × scale so the feet rest on the ground; pitched back for the sit pose
Cat.HeadHead with two pointed ears + snoutBobs while moving
Cat.TailSlender box rising from a pivot at its baseSwishes side-to-side about its base

The tail mesh extends upward from the origin so the Cat.Tail node, placed at the rear of the body, can rotate about its base to swish without drifting. The adult cat is ginger; the kitten is gray at 0.6× scale. The child node names are Cat.* for both entities, so the shared CatAI.onAttach() finds them regardless of which entity it is attached to.

Spawning#

Cats spawn during chunk generation with a 12% probability per GrassyPlains column. When a cat spawns, there is a 50% chance it brings a litter of three kittens scattered around it — each kitten runs its own CatAI, so the whole family is independently drawn to the player rather than rigidly following the parent (contrast the duckling's parent-follow in §4.4):

if (Math.random() < CAT_CHANCE) {
  const wx = Math.floor(baseX + Math.random() * CHUNK_SIZE);
  const wz = Math.floor(baseZ + Math.random() * CHUNK_SIZE);
  const parent = Cat.spawn(wx, wz, world, scene);
  if (parent && Math.random() < KITTEN_CHANCE) {
    for (let k = 0; k < KITTEN_COUNT; k++) {
      Kitten.spawn(parent, world, scene);
    }
  }
}

Like the other land animals, cats and kittens refuse to spawn on a water surface — see the next section.

§ 4.10

Water Avoidance#

Ducks are amphibious and float on water (§4.3), but the other land animals — pigs, creepers, cats, and kittens — should stay on dry land: they neither walk onto water nor spawn on it. This is enforced by a single shared predicate and two guard points (movement and spawn).

Top-down view of a cat refusing to step onto a pond — its step into the water is rejected and it wanders elsewhere, while a duck floats on the surface; alongside, the isWaterAt predicate samples the column surface and is applied at two guard points: the movement step and the spawn check

Detecting a Water Surface#

isWaterAt() (crafty/game/terrain_query.ts) samples the top of a column and checks whether the block just beneath the surface is water — the same test the duck uses to decide when to float, factored out for reuse:

export function isWaterAt(world: BlockWorld, x: number, z: number): boolean {
  const topY = world.getTopBlockY(Math.floor(x), Math.floor(z), 200);
  if (topY <= 0) {
    return false;                 // ungenerated column — don't block movement
  }
  const below = world.getBlockType(Math.floor(x), Math.floor(topY - 1), Math.floor(z));
  return isBlockWater(below);
}

Returning false for columns with no terrain is deliberate: an animal near the edge of a not-yet-generated chunk keeps moving rather than freezing against an imaginary shoreline.

Guarding Movement#

Each walking AI moves by writing go.position.x/z directly. To block water, that write is wrapped in a _tryStep() helper that only commits the step if the destination column is dry, returning whether the move was taken:

private _tryStep(go: NPCEntity, nx: number, nz: number, speed: number, dt: number): boolean {
  const stepX = go.position.x + nx * speed * dt;
  const stepZ = go.position.z + nz * speed * dt;
  if (isWaterAt(this._world, stepX, stepZ)) {
    return false;                 // blocked by water — don't step
  }
  go.position.x = stepX;
  go.position.z = stepZ;
  return true;
}

The two movement modes respond to a blocked step differently:

Guarding Spawns#

GrassyPlains columns can contain ponds and lakes, so the biome filter alone is not enough. Each land animal's spawn() adds an isWaterAt() guard after the existing ground and biome checks:

if (isWaterAt(world, wx, wz)) {
  return null;                    // never spawn on a pond or lake surface
}

Ducks and ducklings skip both guards (ducks float; ducklings follow their parent), and bees and fireflies skip them too — they fly, so being over water is harmless. The helper is also inert in the crafty_entity_viewer sample, whose MockWorld reports no water, so the demo animals roam freely.

§ 4.11

Gravity and Ground Collision#

All NPC components share the same gravity and ground collision logic:

TypeScript
// Apply gravity
this._velY -= 9.8 * dt;
go.position.y += this._velY * dt;

// Sample ground height from the voxel world
const groundY = this._world.getTopBlockY(
  Math.floor(gx), Math.floor(gz), Math.ceil(go.position.y) + 4);

// Snap to ground if below surface
if (groundY > 0 && go.position.y <= groundY + 0.1) {
  go.position.y = groundY;
  this._velY = 0;
}

The +4 offset in getTopBlockY provides a search range above the NPC's current position, ensuring the function can find the ground even if the NPC fell through a hole. The small 0.1-unit tolerance prevents jittering when the NPC is exactly at ground level.

Ducks extend this with a water check — if the block directly below is BlockType.WATER, the duck floats at the water's surface instead of sinking.

§ 4.12

Head Bob Animation#

Six waveforms: each NPC has its own amplitude/frequency in idle and wander, giving each a distinct gait without skeletal animation
Six waveforms: each NPC has its own amplitude/frequency in idle and wander, giving each a distinct gait without skeletal animation

Each NPC model has a child GameObject for the head (e.g., Duck.Head, Pig.Head, Duckling.Head). The AI component finds this child in onAttach() and animates its local Y offset each frame:

TypeScript
onAttach(): void {
  for (const child of this.gameObject.children) {
    if (child.name === 'Duck.Head') {
      this._headGO = child;
      this._headBaseY = child.position.y;
      break;
    }
  }
}

The bob is a simple sine wave whose frequency and amplitude vary by state — faster and larger when the NPC is moving, slower and subtler when idle. This gives each NPC a distinctive gait without requiring skeletal animation.

§ 4.13

Animated Models and Skeletal Animation#

AnimatedModel pipeline: sample clip at time t into per-joint TRS arrays, walk the skeleton to compute joint matrices, upload to GPU for skinned vertex transformation

Beyond the simple head-bob NPCs, Crafty supports full skeletal animation via the AnimatedModel component (src/engine/components/animated_model.ts). This component plays GLTF animation clips on skinned meshes:

TypeScript
class AnimatedModel extends Component {
  readonly model: GltfModel;
  readonly skeleton: Skeleton;
  currentClip: string | null = null;
  speed: number = 1.0;
  loop: boolean = true;
  readonly jointMatrices: Float32Array;

  play(clipName: string, loop = true): void { /* ... */ }
  pause(): void { /* ... */ }
  resume(): void { /* ... */ }
  stop(): void { /* ... */ }
}

Each frame, AnimatedModel.update() advances the clip time, samples the animation at that time into per-joint TRS arrays, and recomputes the final joint matrices via the Skeleton:

TypeScript
update(dt: number): void {
  if (!this._clip || this._paused) return;
  this._time += dt * this.speed;
  if (this.loop) {
    this._time = this._time % this._clip.duration;
  }
  this._resetToPose();
  sampleClip(this._clip, this._time, this._translations, this._rotations, this._scales);
  this.skeleton.computeJointMatrices(this._translations, this._rotations, this._scales, this.jointMatrices);
}

The joint matrices are consumed by the skinned variant of the world geometry pass, which renders the animated mesh with GPU skinning. NPCs that need full-body animation (e.g. a character walking, waving, or performing actions) use AnimatedModel instead of the simple head-bob approach.

§ 4.14

Mob Spawning Mechanics#

Rather than scanning the world periodically, Crafty spawns mobs as a side effect of chunk generation. The setupAnimalSpawning() function in crafty/game/animal_spawner.ts hooks into the World.onChunkAdded callback so every new chunk triggers a spawn attempt for its XZ column:

TypeScript
const prev = world.onChunkAdded;
world.onChunkAdded = (chunk, chunkMesh) => {
  prev?.(chunk, chunkMesh);          // preserve renderer callback
  if (chunk.aliveBlocks === 0) return;
  const key = `${cx}:${cz}`;
  if (processedColumns.has(key)) return;  // deduplicate
  processedColumns.add(key);
  _spawnInColumn(cx, cz, world, scene, meshes);
};

Column-Level Deduplication#

A chunk column covers all Y-layers for one XZ coordinate. Since Crafty generates terrain in vertical layers (chunks), the same column may trigger onChunkAdded multiple times as lower or higher layers load. The processedColumns set prevents animals from piling up:

cx:cz ──→ processedColumns Set ──→ skip if already seen
                                    add on first sight
                                    spawn all mob types once

This also covers world reload — the set is created fresh per setupAnimalSpawning() call, so re-entering the world re-rolls mob placement.

Spawn Conditions#

Each mob type has an independent spawn roll per column:

MobProbabilityBiome FilterSize
Duck0.15GrassyPlains only1–2 adults + optional 5 ducklings
Pig0.20GrassyPlains only, not on water1–2 adults or babies (25% baby chance)
Bee0.10GrassyPlains onlySingle (requires 3-block clearance)
Creeper0.01Any, not on waterSingle
Cat0.12GrassyPlains only, not on water1 adult + optional 3 kittens (50% chance)

Every spawn checks that the column has solid ground (getTopBlockY > 0). Ducks additionally check if the surface block is water — they can float on the surface or stand on solid ground — while the land animals (pig, creeper, cat, kitten) reject water columns outright via isWaterAt() (§4.10):

const topY = world.getTopBlockY(wx, wz, 200);
if (topY <= 0) {
  return null;                                       // no ground
}
const biome = world.getBiomeAt(wx, topY, wz);
if (biome !== BiomeType.GrassyPlains) {
  return null;                                       // biome filter
}
const blockBelow = world.getBlockType(Math.floor(wx), Math.floor(topY - 1), Math.floor(wz));
const spawnY = blockBelow === BlockType.WATER ? Math.floor(topY - 0.05) : topY;

Creepers have the loosest filter — any biome, 1% per column — making them a rare but universal threat.

Spawn Resolution#

The spawn functions construct GameObject hierarchies with MeshRenderer components and AI components attached, then add them to the scene. Group spawns (ducklings, baby pigs) are decided by a secondary probability roll:

// Ducks: 25% chance of 5 ducklings per spawned duck
const parent = Duck.spawn(wx, wz, world, scene);
if (parent && Math.random() < DUCKLING_CHANCE) {
  for (let d = 0; d < DUCKLING_COUNT; d++) {
    Duckling.spawn(parent, world, scene);
  }
}

// Pigs: 25% chance of baby instead of adult
Pig.spawn(wx, wz, world, scene, Math.random() < BABY_PIG_CHANCE);

// Cats: 50% chance of a litter of 3 kittens per spawned cat
const cat = Cat.spawn(wx, wz, world, scene);
if (cat && Math.random() < KITTEN_CHANCE) {
  for (let k = 0; k < KITTEN_COUNT; k++) {
    Kitten.spawn(cat, world, scene);
  }
}

Summary of Spawn Flow#

Chunk generated
  └─ onChunkAdded fires
       └─ aliveBlocks > 0?
            └─ Column already processed?
                  └─ _spawnInColumn()
                       ├─ DUCK_CHANCE  (0.15) → spawnDuck + optional ducklings
                       ├─ PIG_CHANCE   (0.20) → spawnPig (adult or baby)
                       ├─ BEE_CHANCE   (0.10) → spawnBee
                       ├─ CAT_CHANCE   (0.12) → spawnCat + optional kittens
                       └─ CREEPER_CHANCE (0.01) → spawnCreeper

Mob spawning is strictly a generation-time event — there is no respawn timer or despawn-on-distance system. Once spawned, NPCs persist in the scene until the world is unloaded.

Fireflies are the exception (§4.8). They are not tied to chunk generation at all: a per-frame swarm manager (crafty/game/firefly_spawner.ts) maintains a player-centric population that ramps in at dusk, is recycled as the player moves or as day breaks, and is pooled between nights. They are the only NPC with both a spawn-on-distance and a despawn-on-distance rule.

§ 4.15

Extending the NPC System#

The component-based architecture makes it straightforward to add new NPC types:

  1. Define the NPC GameObject — create a GameObject with a MeshRenderer and optional child nodes.
  2. Implement the AI component — extend Component with a state machine, movement logic, and animation.
  3. Register in the game loop — ensure DuckAI.playerPos (or equivalent) is updated each frame.

Future NPC types could include:

Each new type follows the same pattern: a state machine + Component.update() + World queries, with visual variety provided by different meshes, animations, and parameter tuning.

4.16 Summary#

The NPC AI system demonstrates several patterns:

Further reading: