@quest-editor/ai
v0.1.0
Published
Monster movement AI for HeroQuest — per-type behavior + pathfinding. **Pure and engine-agnostic**: no React, no DB, no rendering, no `Math.random`. It talks only to a small `BoardQuery` port that each consumer implements, and takes an injected seeded RNG,
Downloads
176
Readme
@quest-editor/ai
Monster movement AI for HeroQuest — per-type behavior + pathfinding. Pure and
engine-agnostic: no React, no DB, no rendering, no Math.random. It talks only to a small
BoardQuery port that each consumer implements, and takes an injected seeded RNG, so the same
code drives the tracker (suggested moves) and the future online game (authoritative,
replayable). Design: ../../docs/monster-ai.md.
Status
- ✅ Pathfinding (milestone 1):
distanceField,stepToward,aStar,reachableFrom. - ✅ Behavior (milestone 2):
decideMonsterTurn— per-type policies (Mind = AI tier), target selection, approach, attack, and flee. - ⬜ Coordination (allies focus-fire), ranged/spell intents — later.
The port
Implement BoardQuery over your board state (the tracker maps it from a live session's
board_state + revealed fog; the online game from its authoritative state):
interface BoardQuery {
readonly width: number
readonly height: number
passable(t: Tile): boolean // enterable terrain (not void/rock)
occupant(t: Tile): 'hero' | 'monster' | null
blockedEdge(a: Tile, b: Tile): boolean // wall or closed/unrevealed door between adjacent tiles
lineOfSight(a: Tile, b: Tile): boolean // ranged/visibility (unused by pathfinding)
}Pathfinding
import { distanceField, stepToward, makeRng } from '@quest-editor/ai'
const rng = makeRng(seed)
// One multi-source pass from the heroes → cost-to-nearest-hero for every tile:
const field = distanceField(board, heroes /* Tile[] */)
// Each monster descends the field within its movement budget (no per-agent search):
const path = stepToward(board, field, monster.pos, MONSTER_STATS[subtype].movement, rng)- Why a distance field, not per-monster A*: the grid is tiny (~500 tiles) and many
monsters share one goal set (the heroes). Computing the field once and letting every monster
read it is
O(tiles)+O(budget)per monster — strictly cheaper than N separate searches, and it yields nearest-hero + direction + reachability for free. - Opportunity Attack: a monster may path through a hero's square (never end on it).
Passing costs
DEFAULT_HERO_PASS_COSTextra, so routes detour around heroes when cheap but flood through when that's the only way in — the chokepoint fix fromheroquest-rules. - Determinism: stable neighbor order + index tie-breaks; the injected RNG only breaks ties between equally-good steps. Same inputs + seed ⇒ identical paths.
- Recompute the field each Zargon turn (state changes); it's cheap.
aStar(board, from, to) is a fallback for "path to this exact empty tile" when a behavior
picks a specific destination.
Behavior
import { decideMonsterTurn } from '@quest-editor/ai'
// Per Zargon turn, for each living monster (recompute as the board changes):
const decisions = decideMonsterTurn({
self: { id, pos, subtype: 'goblin', body }, // body = current
heroes: [{ id, pos, subtype, body, threat }], // threat = danger score (e.g. attack dice)
board,
rng,
// stats?: { movement, mind, body } ← pass core's MONSTER_STATS to be authoritative
})
// → [{ kind:'move', path, to }, { kind:'attack', targetId, from }] | [{ kind:'wait' }]- Mind = AI tier: 0 mindless (skeleton/zombie/mummy → nearest hero), 1-2 instinct (orc aggressive; goblin = opportunistic coward), 3-4 tactical (fimir/chaos/gargoyle → highest-threat target, retreat when wounded/surrounded).
- Stats come from a built-in table mirroring
@quest-editor/core(so the package is dependency-free); passstatsto override with the canonical values. - Decisions are serializable intents — the app resolves combat and applies movement. The tracker renders them as suggestions; the online server applies them authoritatively.
