Game AI

What Is Game AI?

"Game AI is not about intelligence โ€” it's about the illusion of intelligence that creates engaging gameplay." โ€” Steve Rabin

Game AI โ‰  Academic AI. Academic AI seeks optimal solutions; Game AI seeks fun, believable, and performant behaviour.

Academic AI Game AI
Optimal/rational Believable/entertaining
Unlimited compute Strict budget (1-2ms/frame)
General solutions Specialised, scripted
Learning offline Authored behaviours
Explainable "Magic" is fine

1. Foundations: Movement & Navigation

Steering Behaviours (Reynolds, 1999)

import math
import random
from dataclasses import dataclass

@dataclass(frozen=True)
class Vec3:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0

    def __add__(self, o): return Vec3(self.x + o.x, self.y + o.y, self.z + o.z)
    def __sub__(self, o): return Vec3(self.x - o.x, self.y - o.y, self.z - o.z)
    def __mul__(self, s): return Vec3(self.x * s, self.y * s, self.z * s)
    def length(self): return math.sqrt(self.x**2 + self.y**2 + self.z**2)

    def normalised(self):
        l = self.length()
        return self * (1.0 / l) if l > 0 else Vec3()

@dataclass(frozen=True)
class SteeringOutput:
    linear: Vec3 = Vec3()
    angular: float = 0.0

_wander_angle = 0.0

def seek(pos, target, max_speed, velocity):
    desired = (target - pos).normalised() * max_speed
    return SteeringOutput(desired - velocity)

def flee(pos, target, max_speed, velocity):
    desired = (pos - target).normalised() * max_speed
    return SteeringOutput(desired - velocity)

def arrive(pos, target, max_speed, slow_radius, velocity):
    to_target = target - pos
    dist = to_target.length()
    if dist < 0.01:
        return SteeringOutput()
    speed = max_speed * (dist / slow_radius) if dist < slow_radius else max_speed
    desired = to_target.normalised() * speed
    return SteeringOutput(desired - velocity)

def wander(pos, forward, radius, distance, jitter, max_speed, velocity):
    global _wander_angle
    circle_centre = pos + forward * distance
    offset = Vec3(math.cos(_wander_angle) * radius, 0.0,
                  math.sin(_wander_angle) * radius)
    _wander_angle += (random.random() * 2 - 1) * jitter
    return seek(pos + offset, circle_centre + offset, max_speed, velocity)

def combine(behaviours):
    """behaviours: list of (weight, SteeringOutput) pairs."""
    linear, angular, total_weight = Vec3(), 0.0, 0.0
    for weight, b in behaviours:
        linear = linear + b.linear * weight
        angular += b.angular * weight
        total_weight += weight
    if total_weight > 0:
        linear = linear * (1.0 / total_weight)
        angular /= total_weight
    return SteeringOutput(linear, angular)
#include <cmath>
#include <cstdlib>
#include <utility>
#include <vector>

struct Vec3 {
    float x = 0, y = 0, z = 0;
    Vec3 operator+(const Vec3& o) const { return {x + o.x, y + o.y, z + o.z}; }
    Vec3 operator-(const Vec3& o) const { return {x - o.x, y - o.y, z - o.z}; }
    Vec3 operator*(float s) const { return {x * s, y * s, z * s}; }
    float length() const { return std::sqrt(x*x + y*y + z*z); }
    Vec3 normalised() const {
        float l = length();
        return l > 0 ? (*this) * (1.0f / l) : Vec3{};
    }
};

struct SteeringOutput {
    Vec3 linear{};
    float angular = 0;
};

class SteeringBehaviours {
    static inline float wanderAngle = 0.0f;
public:
    static SteeringOutput seek(Vec3 pos, Vec3 target, float maxSpeed, Vec3 velocity) {
        Vec3 desired = (target - pos).normalised() * maxSpeed;
        return {desired - velocity, 0};
    }

    static SteeringOutput flee(Vec3 pos, Vec3 target, float maxSpeed, Vec3 velocity) {
        Vec3 desired = (pos - target).normalised() * maxSpeed;
        return {desired - velocity, 0};
    }

    static SteeringOutput arrive(Vec3 pos, Vec3 target, float maxSpeed,
                                 float slowRadius, Vec3 velocity) {
        Vec3 toTarget = target - pos;
        float dist = toTarget.length();
        if (dist < 0.01f) return {};
        float speed = dist < slowRadius ? maxSpeed * (dist / slowRadius) : maxSpeed;
        Vec3 desired = toTarget.normalised() * speed;
        return {desired - velocity, 0};
    }

    static SteeringOutput wander(Vec3 pos, Vec3 forward, float radius, float distance,
                                 float jitter, float maxSpeed, Vec3 velocity) {
        Vec3 circleCentre = pos + forward * distance;
        Vec3 offset{std::cos(wanderAngle) * radius, 0, std::sin(wanderAngle) * radius};
        wanderAngle += (static_cast<float>(std::rand()) / RAND_MAX * 2 - 1) * jitter;
        return seek(pos + offset, circleCentre + offset, maxSpeed, velocity);
    }

    static SteeringOutput combine(const std::vector<std::pair<float, SteeringOutput>>& behaviours) {
        Vec3 linear{};
        float angular = 0, totalWeight = 0;
        for (const auto& [weight, b] : behaviours) {
            linear = linear + b.linear * weight;
            angular += b.angular * weight;
            totalWeight += weight;
        }
        if (totalWeight > 0) {
            linear = linear * (1.0f / totalWeight);
            angular /= totalWeight;
        }
        return {linear, angular};
    }
};
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;

public final class Vec3 {
    public final float x, y, z;

    public Vec3() { this(0, 0, 0); }
    public Vec3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
    public Vec3(float v) { this(v, v, v); }

    public Vec3 add(Vec3 o) { return new Vec3(x + o.x, y + o.y, z + o.z); }
    public Vec3 sub(Vec3 o) { return new Vec3(x - o.x, y - o.y, z - o.z); }
    public Vec3 mul(float s) { return new Vec3(x * s, y * s, z * s); }
    public Vec3 div(float s) { return new Vec3(x / s, y / s, z / s); }
    public float length() { return (float)Math.sqrt(x*x + y*y + z*z); }
    public Vec3 normalized() { float l = length(); return l &gt; 0 ? div(l) : new Vec3(); }
}

public final class SteeringOutput {
    public final Vec3 linear;
    public final float angular;
    public SteeringOutput() { this(new Vec3(), 0); }
    public SteeringOutput(Vec3 linear, float angular) { this.linear = linear; this.angular = angular; }
}

public class SteeringBehaviors {
    private static final ThreadLocalRandom RNG = ThreadLocalRandom.current();
    private static final ThreadLocal&lt;Float&gt; WANDER_ANGLE = ThreadLocal.withInitial(() -&gt; 0.0f);

    public static SteeringOutput seek(Vec3 pos, Vec3 target, float maxSpeed, Vec3 velocity) {
        Vec3 desired = target.sub(pos).normalized().mul(maxSpeed);
        return new SteeringOutput(desired.sub(velocity), 0);
    }

    public static SteeringOutput flee(Vec3 pos, Vec3 target, float maxSpeed, Vec3 velocity) {
        Vec3 desired = pos.sub(target).normalized().mul(maxSpeed);
        return new SteeringOutput(desired.sub(velocity), 0);
    }

    public static SteeringOutput arrive(Vec3 pos, Vec3 target, float maxSpeed, float slowRadius, Vec3 velocity) {
        Vec3 toTarget = target.sub(pos);
        float dist = toTarget.length();
        if (dist &lt; 0.01f) return new SteeringOutput();
        float speed = (dist &lt; slowRadius) ? maxSpeed * (dist / slowRadius) : maxSpeed;
        Vec3 desired = toTarget.normalized().mul(speed);
        return new SteeringOutput(desired.sub(velocity), 0);
    }

    public static SteeringOutput wander(Vec3 pos, Vec3 forward, float radius, float distance, float jitter, float maxSpeed, Vec3 velocity) {
        float angle = WANDER_ANGLE.get();
        Vec3 circleCenter = pos.add(forward.mul(distance));
        Vec3 offset = new Vec3(
            (float)Math.cos(angle) * radius,
            0,
            (float)Math.sin(angle) * radius
        );
        WANDER_ANGLE.set(angle + (float)(ThreadLocalRandom.current().nextDouble() * 2 - 1) * jitter);
        return seek(pos.add(offset), circleCenter.add(offset), maxSpeed, velocity);
    }

    public static SteeringOutput combine(List&lt;Map.Entry&lt;Float, SteeringOutput&gt;&gt; behaviors) {
        Vec3 linear = new Vec3();
        float angular = 0, totalWeight = 0;
        for (var entry : behaviors) {
            float weight = entry.getKey();
            SteeringOutput b = entry.getValue();
            linear = linear.add(b.linear.mul(weight));
            angular += b.angular * weight;
            totalWeight += weight;
        }
        if (totalWeight &gt; 0) {
            linear = linear.mul(1.0f / totalWeight);
            angular /= totalWeight;
        }
        return new SteeringOutput(linear, angular);
    }
}
using System;
using System.Collections.Generic;

public readonly struct Vec3
{
    public readonly float X, Y, Z;
    public Vec3(float x, float y, float z) { X = x; Y = y; Z = z; }

    public static Vec3 operator +(Vec3 a, Vec3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
    public static Vec3 operator -(Vec3 a, Vec3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
    public static Vec3 operator *(Vec3 a, float s) => new(a.X * s, a.Y * s, a.Z * s);
    public float Length() => MathF.Sqrt(X * X + Y * Y + Z * Z);
    public Vec3 Normalised() { var l = Length(); return l > 0 ? this * (1f / l) : default; }
}

public readonly struct SteeringOutput
{
    public readonly Vec3 Linear;
    public readonly float Angular;
    public SteeringOutput(Vec3 linear, float angular) { Linear = linear; Angular = angular; }
}

public static class SteeringBehaviours
{
    private static readonly Random Rng = new();
    private static float _wanderAngle;

    public static SteeringOutput Seek(Vec3 pos, Vec3 target, float maxSpeed, Vec3 velocity)
    {
        var desired = (target - pos).Normalised() * maxSpeed;
        return new SteeringOutput(desired - velocity, 0);
    }

    public static SteeringOutput Flee(Vec3 pos, Vec3 target, float maxSpeed, Vec3 velocity)
    {
        var desired = (pos - target).Normalised() * maxSpeed;
        return new SteeringOutput(desired - velocity, 0);
    }

    public static SteeringOutput Arrive(Vec3 pos, Vec3 target, float maxSpeed,
                                        float slowRadius, Vec3 velocity)
    {
        var toTarget = target - pos;
        var dist = toTarget.Length();
        if (dist < 0.01f) return default;
        var speed = dist < slowRadius ? maxSpeed * (dist / slowRadius) : maxSpeed;
        var desired = toTarget.Normalised() * speed;
        return new SteeringOutput(desired - velocity, 0);
    }

    public static SteeringOutput Wander(Vec3 pos, Vec3 forward, float radius, float distance,
                                        float jitter, float maxSpeed, Vec3 velocity)
    {
        var circleCentre = pos + forward * distance;
        var offset = new Vec3(MathF.Cos(_wanderAngle) * radius, 0,
                              MathF.Sin(_wanderAngle) * radius);
        _wanderAngle += ((float)Rng.NextDouble() * 2 - 1) * jitter;
        return Seek(pos + offset, circleCentre + offset, maxSpeed, velocity);
    }

    public static SteeringOutput Combine(IEnumerable<(float Weight, SteeringOutput Output)> behaviours)
    {
        var linear = default(Vec3);
        float angular = 0, totalWeight = 0;
        foreach (var (weight, b) in behaviours)
        {
            linear = linear + b.Linear * weight;
            angular += b.Angular * weight;
            totalWeight += weight;
        }
        if (totalWeight > 0)
        {
            linear = linear * (1f / totalWeight);
            angular /= totalWeight;
        }
        return new SteeringOutput(linear, angular);
    }
}
Vec3 = Struct.new(:x, :y, :z) do
  def self.zero
    new(0.0, 0.0, 0.0)
  end

  def +(other)
    Vec3.new(x + other.x, y + other.y, z + other.z)
  end

  def -(other)
    Vec3.new(x - other.x, y - other.y, z - other.z)
  end

  def *(scalar)
    Vec3.new(x * scalar, y * scalar, z * scalar)
  end

  def length
    Math.sqrt(x * x + y * y + z * z)
  end

  def normalised
    l = length
    l > 0 ? self * (1.0 / l) : Vec3.zero
  end
end

SteeringOutput = Struct.new(:linear, :angular) do
  def self.none
    new(Vec3.zero, 0.0)
  end
end

module SteeringBehaviours
  module_function

  def seek(pos, target, max_speed, velocity)
    desired = (target - pos).normalised * max_speed
    SteeringOutput.new(desired - velocity, 0.0)
  end

  def flee(pos, target, max_speed, velocity)
    desired = (pos - target).normalised * max_speed
    SteeringOutput.new(desired - velocity, 0.0)
  end

  def arrive(pos, target, max_speed, slow_radius, velocity)
    to_target = target - pos
    dist = to_target.length
    return SteeringOutput.none if dist < 0.01

    speed = dist < slow_radius ? max_speed * (dist / slow_radius) : max_speed
    desired = to_target.normalised * speed
    SteeringOutput.new(desired - velocity, 0.0)
  end

  def wander(pos, forward, radius, distance, jitter, max_speed, velocity)
    @wander_angle ||= 0.0
    circle_centre = pos + forward * distance
    offset = Vec3.new(Math.cos(@wander_angle) * radius, 0.0,
                      Math.sin(@wander_angle) * radius)
    @wander_angle += (rand * 2 - 1) * jitter
    seek(pos + offset, circle_centre + offset, max_speed, velocity)
  end

  # behaviours: array of [weight, SteeringOutput] pairs
  def combine(behaviours)
    linear = Vec3.zero
    angular = 0.0
    total_weight = 0.0
    behaviours.each do |weight, b|
      linear += b.linear * weight
      angular += b.angular * weight
      total_weight += weight
    end
    if total_weight > 0
      linear *= 1.0 / total_weight
      angular /= total_weight
    end
    SteeringOutput.new(linear, angular)
  end
end
from dataclasses import dataclass, field
from typing import List, Optional
import heapq

@dataclass
class NavPoly:
    vertices: List[Vec3] = field(default_factory=list)  # CCW order
    neighbors: List[int] = field(default_factory=list)  # Adjacent poly indices
    flags: int = 0    # Walk, jump, climb, water
    area: int = 0     # Ground, road, grass, etc.

@dataclass
class NavMesh:
    polys: List[NavPoly] = field(default_factory=list)
    vertices: List[Vec3] = field(default_factory=list)

    def locate_poly(self, point: Vec3) -&gt; int:
        """Find which polygon contains the point (simplified)."""
        for i, poly in enumerate(self.polys):
            if self._point_in_poly(point, poly):
                return i
        return -1

    def _point_in_poly(self, point: Vec3, poly: NavPoly) -&gt; bool:
        # Simplified 2D point-in-polygon test (assumes flat polys)
        # Real implementation would use proper 3D test
        return True  # Placeholder

    def find_path(self, start: Vec3, end: Vec3, agent_flags: int) -&gt; List[int]:
        start_poly = self.locate_poly(start)
        end_poly = self.locate_poly(end)
        if start_poly &lt; 0 or end_poly &lt; 0:
            return []

        # A* on polygon graph
        @dataclass(order=True)
        class Node:
            f: float
            poly: int = field(compare=False)
            g: float = field(compare=False)
            parent: int = field(compare=False)

        open_set = [Node(0, start_poly, 0.0, -1)]
        closed = {}
        g_scores = {start_poly: 0.0}

        while open_set:
            current = heapq.heappop(open_set)
            if current.poly == end_poly:
                return self._reconstruct_path(closed, current)

            if current.poly in closed:
                continue
            closed[current.poly] = current

            for neighbor in self.polys[current.poly].neighbors:
                # Filter by agent capabilities
                if self.polys[neighbor].flags &amp; agent_flags:
                    continue

                g = current.g + self._poly_distance(current.poly, neighbor)
                if neighbor not in g_scores or g &lt; g_scores[neighbor]:
                    g_scores[neighbor] = g
                    h = self._heuristic(neighbor, end_poly)
                    heapq.heappush(open_set, Node(g + h, neighbor, g, current.poly))

        return []  # No path

    def _poly_distance(self, a: int, b: int) -&gt; float:
        # Distance between polygon centroids
        return 1.0  # Placeholder

    def _heuristic(self, a: int, b: int) -&gt; float:
        return 1.0  # Placeholder

    def _reconstruct_path(self, closed: dict, node) -&gt; List[int]:
        path = [node.poly]
        while node.parent != -1:
            node = closed[node.parent]
            path.append(node.poly)
        return list(reversed(path))
#include <queue>
#include <unordered_map>
#include <vector>

struct NavPoly {
    std::vector<Vec3> vertices;   // CCW order
    std::vector<int> neighbours;  // Adjacent poly indices
    int flags = 0;                // Walk, jump, climb, water
    int area = 0;                 // Ground, road, grass, etc.
};

class NavMesh {
public:
    std::vector<NavPoly> polys;
    std::vector<Vec3> vertices;

    // Find which polygon contains the point (simplified).
    int locatePoly(const Vec3& point) const {
        for (int i = 0; i < static_cast<int>(polys.size()); ++i)
            if (pointInPoly(point, polys[i])) return i;
        return -1;
    }

    std::vector<int> findPath(const Vec3& start, const Vec3& end, int agentFlags) const {
        int startPoly = locatePoly(start);
        int endPoly = locatePoly(end);
        if (startPoly < 0 || endPoly < 0) return {};

        struct Node { float f; int poly; float g; int parent; };
        auto cmp = [](const Node& a, const Node& b) { return a.f > b.f; };
        std::priority_queue<Node, std::vector<Node>, decltype(cmp)> open(cmp);
        std::unordered_map<int, Node> closed;
        std::unordered_map<int, float> gScores;

        open.push({0, startPoly, 0.0f, -1});
        gScores[startPoly] = 0.0f;

        while (!open.empty()) {
            Node current = open.top();
            open.pop();
            if (current.poly == endPoly) return reconstructPath(closed, current);
            if (closed.count(current.poly)) continue;
            closed[current.poly] = current;

            for (int neighbour : polys[current.poly].neighbours) {
                // Filter by agent capabilities
                if (polys[neighbour].flags & agentFlags) continue;

                float g = current.g + polyDistance(current.poly, neighbour);
                auto it = gScores.find(neighbour);
                if (it == gScores.end() || g < it->second) {
                    gScores[neighbour] = g;
                    float h = heuristic(neighbour, endPoly);
                    open.push({g + h, neighbour, g, current.poly});
                }
            }
        }
        return {};  // No path
    }

private:
    bool pointInPoly(const Vec3&, const NavPoly&) const { return true; }  // Placeholder
    float polyDistance(int, int) const { return 1.0f; }                   // Placeholder
    float heuristic(int, int) const { return 1.0f; }                      // Placeholder

    std::vector<int> reconstructPath(const std::unordered_map<int, Node>& closed, Node node) const {
        std::vector<int> path{node.poly};
        while (node.parent != -1) {
            node = closed.at(node.parent);
            path.push_back(node.poly);
        }
        return {path.rbegin(), path.rend()};
    }
};
import java.util.*;

public class NavPoly {
    public List<Vec3> vertices = new ArrayList<>();   // CCW order
    public List<Integer> neighbours = new ArrayList<>(); // Adjacent poly indices
    public int flags = 0;   // Walk, jump, climb, water
    public int area = 0;    // Ground, road, grass, etc.
}

public class NavMesh {
    public List<NavPoly> polys = new ArrayList<>();
    public List<Vec3> vertices = new ArrayList<>();

    private record Node(float f, int poly, float g, int parent) {}

    /** Find which polygon contains the point (simplified). */
    public int locatePoly(Vec3 point) {
        for (int i = 0; i < polys.size(); i++)
            if (pointInPoly(point, polys.get(i))) return i;
        return -1;
    }

    public List<Integer> findPath(Vec3 start, Vec3 end, int agentFlags) {
        int startPoly = locatePoly(start);
        int endPoly = locatePoly(end);
        if (startPoly < 0 || endPoly < 0) return List.of();

        PriorityQueue<Node> open = new PriorityQueue<>(Comparator.comparingDouble(Node::f));
        Map<Integer, Node> closed = new HashMap<>();
        Map<Integer, Float> gScores = new HashMap<>();

        open.add(new Node(0, startPoly, 0f, -1));
        gScores.put(startPoly, 0f);

        while (!open.isEmpty()) {
            Node current = open.poll();
            if (current.poly() == endPoly) return reconstructPath(closed, current);
            if (closed.containsKey(current.poly())) continue;
            closed.put(current.poly(), current);

            for (int neighbour : polys.get(current.poly()).neighbours) {
                // Filter by agent capabilities
                if ((polys.get(neighbour).flags & agentFlags) != 0) continue;

                float g = current.g() + polyDistance(current.poly(), neighbour);
                Float best = gScores.get(neighbour);
                if (best == null || g < best) {
                    gScores.put(neighbour, g);
                    float h = heuristic(neighbour, endPoly);
                    open.add(new Node(g + h, neighbour, g, current.poly()));
                }
            }
        }
        return List.of();  // No path
    }

    private boolean pointInPoly(Vec3 point, NavPoly poly) { return true; }  // Placeholder
    private float polyDistance(int a, int b) { return 1f; }                 // Placeholder
    private float heuristic(int a, int b) { return 1f; }                    // Placeholder

    private List<Integer> reconstructPath(Map<Integer, Node> closed, Node node) {
        List<Integer> path = new ArrayList<>(List.of(node.poly()));
        while (node.parent() != -1) {
            node = closed.get(node.parent());
            path.add(node.poly());
        }
        Collections.reverse(path);
        return path;
    }
}
using System.Collections.Generic;

public class NavPoly
{
    public List<Vec3> Vertices { get; } = new();     // CCW order
    public List<int> Neighbours { get; } = new();    // Adjacent poly indices
    public int Flags { get; set; }                   // Walk, jump, climb, water
    public int Area { get; set; }                    // Ground, road, grass, etc.
}

public class NavMesh
{
    public List<NavPoly> Polys { get; } = new();
    public List<Vec3> Vertices { get; } = new();

    private readonly record struct Node(float F, int Poly, float G, int Parent);

    // Find which polygon contains the point (simplified).
    public int LocatePoly(Vec3 point)
    {
        for (var i = 0; i < Polys.Count; i++)
            if (PointInPoly(point, Polys[i])) return i;
        return -1;
    }

    public List<int> FindPath(Vec3 start, Vec3 end, int agentFlags)
    {
        var startPoly = LocatePoly(start);
        var endPoly = LocatePoly(end);
        if (startPoly < 0 || endPoly < 0) return new List<int>();

        var open = new PriorityQueue<Node, float>();
        var closed = new Dictionary<int, Node>();
        var gScores = new Dictionary<int, float> { [startPoly] = 0f };

        open.Enqueue(new Node(0, startPoly, 0f, -1), 0);

        while (open.Count > 0)
        {
            var current = open.Dequeue();
            if (current.Poly == endPoly) return ReconstructPath(closed, current);
            if (closed.ContainsKey(current.Poly)) continue;
            closed[current.Poly] = current;

            foreach (var neighbour in Polys[current.Poly].Neighbours)
            {
                // Filter by agent capabilities
                if ((Polys[neighbour].Flags & agentFlags) != 0) continue;

                var g = current.G + PolyDistance(current.Poly, neighbour);
                if (!gScores.TryGetValue(neighbour, out var best) || g < best)
                {
                    gScores[neighbour] = g;
                    var h = Heuristic(neighbour, endPoly);
                    open.Enqueue(new Node(g + h, neighbour, g, current.Poly), g + h);
                }
            }
        }
        return new List<int>();  // No path
    }

    private bool PointInPoly(Vec3 point, NavPoly poly) => true;  // Placeholder
    private float PolyDistance(int a, int b) => 1f;              // Placeholder
    private float Heuristic(int a, int b) => 1f;                 // Placeholder

    private List<int> ReconstructPath(Dictionary<int, Node> closed, Node node)
    {
        var path = new List<int> { node.Poly };
        while (node.Parent != -1)
        {
            node = closed[node.Parent];
            path.Add(node.Poly);
        }
        path.Reverse();
        return path;
    }
}
NavPoly = Struct.new(:vertices, :neighbours, :flags, :area) do
  def initialize(vertices: [], neighbours: [], flags: 0, area: 0)
    super(vertices, neighbours, flags, area)
  end
end

Node = Struct.new(:f, :poly, :g, :parent)

class NavMesh
  attr_reader :polys, :vertices

  def initialize
    @polys = []
    @vertices = []
  end

  # Find which polygon contains the point (simplified).
  def locate_poly(point)
    polys.each_with_index do |poly, i|
      return i if point_in_poly?(point, poly)
    end
    -1
  end

  def find_path(start, finish, agent_flags)
    start_poly = locate_poly(start)
    end_poly = locate_poly(finish)
    return [] if start_poly.negative? || end_poly.negative?

    # A* on the polygon graph (open list kept sorted by f; a heap
    # such as the pqueue gem would be the production choice)
    open_set = [Node.new(0.0, start_poly, 0.0, -1)]
    closed = {}
    g_scores = { start_poly => 0.0 }

    until open_set.empty?
      current = open_set.min_by(&:f)
      open_set.delete(current)

      return reconstruct_path(closed, current) if current.poly == end_poly
      next if closed.key?(current.poly)

      closed[current.poly] = current

      polys[current.poly].neighbours.each do |neighbour|
        # Filter by agent capabilities
        next unless (polys[neighbour].flags & agent_flags).zero?

        g = current.g + poly_distance(current.poly, neighbour)
        if !g_scores.key?(neighbour) || g < g_scores[neighbour]
          g_scores[neighbour] = g
          h = heuristic(neighbour, end_poly)
          open_set << Node.new(g + h, neighbour, g, current.poly)
        end
      end
    end

    []  # No path
  end

  private

  def point_in_poly?(_point, _poly) = true  # Placeholder
  def poly_distance(_a, _b) = 1.0           # Placeholder
  def heuristic(_a, _b) = 1.0               # Placeholder

  def reconstruct_path(closed, node)
    path = [node.poly]
    while node.parent != -1
      node = closed[node.parent]
      path << node.poly
    end
    path.reverse
  end
end

Where Next: From Reacting to Planning

Steering and pathfinding make agents move convincingly; the next step is making them decide convincingly. When state machines start sprouting transitions faster than you can test them, it is time to let the agent plan its own action sequences: see Goal-Oriented Action Planning (GOAP), the technique behind the AI of F.E.A.R.