System guide · Headless TypeScript NPCs

TypeScript game AI with navigation that stays outside the render loop.

Treat NPC intelligence as four cooperating layers: decisions choose an intention, pathfinding plans a route, locomotion advances a headless agent, and React Three Fiber presents the resulting position and heading.

Do not make one update function responsible for every kind of AI.

Decision

A behavior tree or schedule chooses patrol, follow, go-to, wander, or idle.

Global navigation

A* finds a valid route around static walls. HPA* reduces search work on large grids.

Local locomotion

The agent consumes the route at a world-units-per-second speed and avoids moving obstacles.

Presentation

An R3F component copies position and heading into a group. It does not own the rules.

This split lets the same NPC run in a browser, a deterministic test, or a Node.js simulation. It also makes failures diagnosable: a bad decision is different from an unreachable destination, a blocked crowd, or a model with the wrong forward axis.

Build one navigation grid, then ask pure functions for routes.

Overworld's grid A* uses eight-direction movement, prevents diagonal corner-cutting, inflates circular obstacles by the agent radius, and smooths the result when line of sight permits.

import {
  createNavGrid,
  findPath,
} from '@overworld-engine/ai'

const grid = createNavGrid({
  bounds: { minX: 0, maxX: 80, minZ: 0, maxZ: 80 },
  cellSize: 1,
  agentRadius: 0.45,
  obstacles: level.colliders.map(({ x, z, radius }) => ({
    x, z, radius,
  })),
})

const route = findPath(grid, [4, 6], [62, 51])
if (route === null) {
  showUnreachableFeedback()
}

Tile maps can use createNavGridFromCells so one wall value blocks exactly one cell. When doors or obstacles change, update the grid deliberately. A route is a snapshot of navigability, not a promise that the world will remain still.

Let a headless agent own movement state, not a mesh.

import { createAgent } from '@overworld-engine/ai'

const guard = createAgent({
  grid,
  position: [4, 6],
  speed: 1.8,
  random: seededRandom,
  avoid: {
    obstacles: () => crowdColliders,
    lookahead: 1.5,
    agentRadius: 0.4,
    stuckAfterMs: 1200,
  },
})

guard.patrol(
  [[4, 6], [20, 6], [20, 18]],
  { pauseMs: 800 },
)

// Browser, server, or test loop:
const status = guard.update(deltaMs)

The agent reports plain position, heading, movement state, behavior, and arrival events. Inject a random source for reproducible wandering. Pass elapsed time intoupdate so movement stays frame-rate independent and can be replayed.

Dynamic avoidance is a deterministic local steering layer. It deflects the current step around moving circles without rewriting the planned route. If every direction remains blocked, the agent can replan after a configured timeout.

Compose intentions with a behavior tree and a shared blackboard.

import {
  createBehaviorTree,
  sequence,
  selector,
  condition,
  goToAction,
  patrolAction,
} from '@overworld-engine/ai'

const tree = createBehaviorTree(
  selector(
    sequence(
      condition(({ blackboard }) => blackboard.alert),
      goToAction(guard, alarmPosition),
    ),
    patrolAction(guard, patrolPoints, { pauseMs: 800 }),
  ),
  { alert: false },
)

Sequences and selectors remember a running child, while conditions can re-evaluate changing world facts. Keep perception and combat policy in your game layer; write those facts into the blackboard or expose them through conditions. The framework supplies composition and agent actions, not a universal enemy brain.

For ambient NPCs, schedules can map phases such as day, dusk, and night to declarative behaviors. That is often clearer than forcing every shopkeeper into a combat-style decision tree.

Use one driver and make the scene adapter visually thin.

import { NPCWalker } from '@overworld-engine/ai'

function Guard() {
  return (
    <NPCWalker
      agent={guard}
      tree={tree}
      rotationOffset={Math.PI}
    >
      <GuardModel />
    </NPCWalker>
  )
}

Passing the tree makes NPCWalker tick the decision tree and agent once per frame. Do not also call agent.update elsewhere or the NPC will double-step. If your simulation owns the clock, use driven={false}; the component will only mirror the latest position and heading.

Scale route planning separately from NPC count.

For large grid worlds, createHierarchicalGrid builds an HPA*-style cluster graph. findPathHierarchical searches that smaller graph, refines each segment in bounded windows, and falls back to full-grid A* when needed. Rebuild the hierarchy after changing the underlying grid.

  • Test blocked targets, narrow corners, unreachable regions, and dynamic doors.
  • Inject time and randomness; assert plain agent status without mounting React.
  • Measure visited nodes before adopting hierarchical navigation.
  • Keep animation state derived from locomotion instead of feeding it back into AI.

This package is grid navigation, behavior composition, schedules, and local avoidance. It is not a navmesh generator, crowd simulator, perception model, animation graph, or machine-learning runtime.

Put AI inside a complete renderer-independent RPG architecture

Connect headless NPC state to quests, dialogue, networking, and an R3F scene.

Connect moving NPCs to proximity and interaction →Read the React Three Fiber RPG architecture guide →