Documentation Index
Fetch the complete documentation index at: https://mintlify.com/openfrontio/OpenFrontIO/llms.txt
Use this file to discover all available pages before exploring further.
Overview
OpenFront implements multiple pathfinding algorithms optimized for different terrain types and use cases. The system is built around a flexible adapter pattern that allows different algorithms to be composed with transformers for enhanced functionality.
Architecture
The pathfinding system is organized into several layers:
- Algorithms: Core pathfinding implementations (A*, BFS, Hierarchical)
- PathFinders: Domain-specific pathfinding interfaces
- Transformers: Composable path enhancement layers
- Steppers: Animated path traversal for UI
PathFinder Interface
All pathfinding algorithms implement a common interface:
interface PathFinder<T> {
findPath(start: T | T[], goal: T): T[] | null;
}
Starting position(s). Multiple starts enable multi-source pathfinding.
Target destination position.
Array of positions forming the path, or null if no path exists.
A* Algorithm
The core A* implementation uses an adapter pattern for flexibility:
src/core/pathfinding/algorithms/AStar.ts
export interface AStarAdapter {
neighbors(node: number, buffer: Int32Array): number;
cost(from: number, to: number, prev?: number): number;
heuristic(node: number, goal: number): number;
numNodes(): number;
maxPriority(): number;
maxNeighbors(): number;
}
export class AStar implements PathFinder<number> {
private stamp = 1;
private readonly closedStamp: Uint32Array;
private readonly gScoreStamp: Uint32Array;
private readonly gScore: Uint32Array;
private readonly cameFrom: Int32Array;
private readonly queue: PriorityQueue;
private readonly adapter: AStarAdapter;
private readonly neighborBuffer: Int32Array;
private readonly maxIterations: number;
constructor(config: AStarConfig) {
this.adapter = config.adapter;
this.maxIterations = config.maxIterations ?? 500_000;
this.neighborBuffer = new Int32Array(this.adapter.maxNeighbors());
this.closedStamp = new Uint32Array(this.adapter.numNodes());
this.gScoreStamp = new Uint32Array(this.adapter.numNodes());
this.gScore = new Uint32Array(this.adapter.numNodes());
this.cameFrom = new Int32Array(this.adapter.numNodes());
this.queue = new BucketQueue(this.adapter.maxPriority());
}
findPath(start: number | number[], goal: number): number[] | null {
this.stamp++;
if (this.stamp > 0xffffffff) {
this.closedStamp.fill(0);
this.gScoreStamp.fill(0);
this.stamp = 1;
}
const stamp = this.stamp;
// ... initialization
queue.clear();
const starts = Array.isArray(start) ? start : [start];
for (const s of starts) {
gScore[s] = 0;
gScoreStamp[s] = stamp;
cameFrom[s] = -1;
queue.push(s, adapter.heuristic(s, goal));
}
let iterations = this.maxIterations;
while (!queue.isEmpty()) {
if (--iterations <= 0) {
return null;
}
const current = queue.pop();
if (closedStamp[current] === stamp) continue;
closedStamp[current] = stamp;
if (current === goal) {
return this.buildPath(goal);
}
const currentG = gScore[current];
const prev = cameFrom[current];
const count = adapter.neighbors(current, buffer);
for (let i = 0; i < count; i++) {
const neighbor = buffer[i];
if (closedStamp[neighbor] === stamp) continue;
const tentativeG = currentG + adapter.cost(current, neighbor, prev);
if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) {
cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG;
gScoreStamp[neighbor] = stamp;
queue.push(neighbor, tentativeG + adapter.heuristic(neighbor, goal));
}
}
}
return null;
}
}
The A* implementation uses several optimization techniques:
- Stamping: Avoids array clearing by using generation stamps
- Typed Arrays: Uses Int32Array/Uint32Array for cache-friendly access
- Buffer Reuse: Neighbor buffer allocated once, reused across searches
- Bucket Queue: O(1) priority queue for integer priorities
Breadth-First Search (BFS)
BFS is used for unweighted searches and area exploration:
src/core/pathfinding/algorithms/BFS.ts
export interface BFSAdapter<T> {
neighbors(node: T): T[];
}
export class BFS<T> {
constructor(private adapter: BFSAdapter<T>) {}
search<R>(
start: T | T[],
maxDistance: number,
visitor: (node: T, dist: number) => R | null | undefined,
): R | null {
const visited = new Set<T>();
const queue: { node: T; dist: number }[] = [];
const starts = Array.isArray(start) ? start : [start];
for (const s of starts) {
visited.add(s);
queue.push({ node: s, dist: 0 });
}
while (queue.length > 0) {
const { node, dist } = queue.shift()!;
const result = visitor(node, dist);
if (result !== null && result !== undefined) {
return result;
}
if (result === null) {
continue;
}
const nextDist = dist + 1;
if (nextDist > maxDistance) {
continue;
}
for (const neighbor of this.adapter.neighbors(node)) {
if (visited.has(neighbor)) {
continue;
}
visited.add(neighbor);
queue.push({ node: neighbor, dist: nextDist });
}
}
return null;
}
}
Visitor Pattern
BFS uses a visitor pattern for flexible search termination:
- Return R: Found target, return result immediately
- Return undefined: Valid node, continue exploring
- Return null: Invalid node, skip but continue search
PathFinding Factory
The PathFinding class provides convenient factory methods for different terrain types:
src/core/pathfinding/PathFinder.ts
export class PathFinding {
static Water(game: Game): SteppingPathFinder<TileRef> {
const pf = game.miniWaterHPA();
const graph = game.miniWaterGraph();
if (!pf || !graph || graph.nodeCount < 100) {
return PathFinding.WaterSimple(game);
}
const miniMap = game.miniMap();
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
return PathFinderBuilder.create(pf)
.wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn))
.wrap((pf) => new SmoothingWaterTransformer(pf, miniMap))
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
.buildWithStepper(tileStepperConfig(game));
}
static Rail(game: Game): SteppingPathFinder<TileRef> {
const miniMap = game.miniMap();
const pf = new AStarRail(miniMap);
return PathFinderBuilder.create(pf)
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
.buildWithStepper(tileStepperConfig(game));
}
static Air(game: Game): SteppingPathFinder<TileRef> {
const pf = new AirPathFinder(game);
return PathFinderBuilder.create(pf).buildWithStepper({
equals: (a, b) => a === b,
});
}
static Stations(game: Game): SteppingPathFinder<TrainStation> {
const pf = new StationPathFinder(game);
return PathFinderBuilder.create(pf).buildWithStepper({
equals: (a, b) => a.id === b.id,
distance: (a, b) => game.manhattanDist(a.tile(), b.tile()),
});
}
}
Terrain-Specific Pathfinding
Water Pathfinding
Water pathfinding uses hierarchical pathfinding for large maps:
- Component Check: Verifies start/goal are in same water body
- Smoothing: Reduces path zigzagging
- Shore Coercing: Keeps ships near coastlines when beneficial
- Mini Map: Operates on downscaled map for performance
Hierarchical Pathfinding Algorithm (HPA*) divides the map into clusters with gateway nodes, dramatically reducing search space for long-distance paths.
Rail Pathfinding
Rail pathfinding operates on the rail network graph:
class AStarRail implements PathFinder<TileRef> {
constructor(private miniMap: GameMap) {}
findPath(start: TileRef, goal: TileRef): TileRef[] | null {
// Only traverse tiles with rail infrastructure
// Cost function accounts for rail type and connections
}
}
Air Pathfinding
Air units use simplified pathfinding:
- No terrain blocking: Can fly over any tile
- Direct paths: Minimal pathfinding overhead
- Range constraints: Limited by fuel/range
Transformers wrap pathfinders to enhance functionality:
Early rejection for unreachable goals:
class ComponentCheckTransformer implements PathFinder<TileRef> {
constructor(
private inner: PathFinder<TileRef>,
private getComponent: (t: TileRef) => number,
) {}
findPath(start: TileRef, goal: TileRef): TileRef[] | null {
if (this.getComponent(start) !== this.getComponent(goal)) {
return null; // Different water bodies, no path possible
}
return this.inner.findPath(start, goal);
}
}
Reduces path zigzagging for more natural ship movement:
class SmoothingWaterTransformer implements PathFinder<TileRef> {
constructor(
private inner: PathFinder<TileRef>,
private map: GameMap,
) {}
findPath(start: TileRef, goal: TileRef): TileRef[] | null {
const path = this.inner.findPath(start, goal);
if (!path) return null;
// Apply smoothing algorithm to reduce unnecessary turns
return this.smoothPath(path);
}
}
Maps between full resolution and downscaled pathfinding:
class MiniMapTransformer implements PathFinder<TileRef> {
constructor(
private inner: PathFinder<TileRef>,
private fullMap: GameMap,
private miniMap: GameMap,
) {}
findPath(start: TileRef, goal: TileRef): TileRef[] | null {
// Convert to mini map coordinates
const miniStart = this.toMini(start);
const miniGoal = this.toMini(goal);
// Find path on mini map
const miniPath = this.inner.findPath(miniStart, miniGoal);
if (!miniPath) return null;
// Convert back to full resolution
return miniPath.map((t) => this.toFull(t));
}
}
Mini maps are typically 4x-8x smaller than full maps, providing significant performance improvements for long-distance pathfinding.
Stepping PathFinders
Stepping pathfinders enable animated path traversal for UI:
interface SteppingPathFinder<T> extends PathFinder<T> {
step(from: T, to: T): PathStep<T>;
}
type PathStep<T> =
| { status: PathStatus.FOUND; path: T[] }
| { status: PathStatus.NOT_FOUND }
| { status: PathStatus.IN_PROGRESS; next: T };
Usage Example
const pathfinder = PathFinding.Water(game);
let currentPos = startTile;
while (currentPos !== goalTile) {
const step = pathfinder.step(currentPos, goalTile);
if (step.status === PathStatus.FOUND) {
// Path complete
return step.path;
} else if (step.status === PathStatus.IN_PROGRESS) {
// Animate move to next position
currentPos = step.next;
} else {
// No path found
return null;
}
}
Optimization Techniques
- Stamp-based Visited Sets: Avoid clearing large arrays
- Typed Arrays: Cache-friendly memory layout
- Mini Maps: Reduce search space for long paths
- Component Analysis: Early rejection of impossible paths
- Buffer Reuse: Single allocation for neighbor queries
Iteration Limits
const maxIterations = 500_000;
let iterations = maxIterations;
while (!queue.isEmpty()) {
if (--iterations <= 0) {
return null; // Prevent infinite loops
}
// ... pathfinding logic
}
Iteration limits prevent pathfinding from hanging on extremely complex queries or bugs. For very large maps, the limit may need adjustment.