gale.ai

gale.ai provides small, composable pieces to build autonomous characters (vehicles, people, animals, or any kind of creature): a Kinematic body, steering behaviors, a behavior tree, a decision tree, a shared Blackboard, and generic graphs with search algorithms (for pathfinding and beyond). They are combined through the Agent class, but you can also use each piece on its own.

Steering only

A wandering agent driven only by a steering behavior:

from gale.ai.agent import Agent
from gale.ai.steering import Wander

agent = Agent(x=100, y=100, max_speed=120)
agent.set_steering_behavior(Wander(agent.kinematic))

# In your game loop:
agent.update(dt)

Other steering behaviors (Seek, Flee, Arrive, Pursue, Evade, Separation, ObstacleAvoidance, …) work the same way: build one with the character’s Kinematic and whatever target it needs, then pass it to set_steering_behavior. Use BlendedSteering or PrioritySteering to combine several of them.

Behavior tree

An agent whose steering is decided by a behavior tree, so it chases a player when close and wanders otherwise:

from gale.ai.agent import Agent
from gale.ai.behavior_tree import Action, BehaviorTree, Condition, Selector, Sequence, Status
from gale.ai.steering import Kinematic, Pursue, Wander

player = Kinematic()
wander = None


def close_to_player(agent) -> bool:
    return (player.position - agent.kinematic.position).length() < 150


def chase(agent, dt) -> Status:
    agent.set_steering_behavior(Pursue(agent.kinematic, player))
    return Status.RUNNING


def patrol(agent, dt) -> Status:
    agent.set_steering_behavior(wander)
    return Status.RUNNING


agent = Agent(x=0, y=0, max_speed=120)
wander = Wander(agent.kinematic)
agent.set_brain(
    BehaviorTree(
        Selector(
            [
                Sequence([Condition(close_to_player), Action(chase)]),
                Action(patrol),
            ]
        )
    )
)

# In your game loop:
agent.update(dt)

Decision tree

The same decision could be expressed with a DecisionTree instead of a behavior tree:

from gale.ai.decision_tree import ActionNode, DecisionNode, DecisionTree

agent.set_brain(
    DecisionTree(
        DecisionNode(
            test=close_to_player,
            true_branch=ActionNode(lambda agent: chase(agent, 0)),
            false_branch=ActionNode(lambda agent: patrol(agent, 0)),
        )
    )
)

Blackboard

Every Agent owns a Blackboard: a shared key-value store, the same role a Blackboard plays alongside Behavior Trees in engines such as Unreal Engine. It lets a behavior tree’s (or decision tree’s) nodes, an agent’s steering behaviors, and any external system (a perception system, a quest system, another agent) read and write data about the agent without needing direct references to one another — they only need to agree on a key.

Continuing the chase-or-patrol example above, a perception system could set "has_target" whenever it spots the player, and the behavior tree would just check the blackboard instead of computing the distance to the player itself:

from gale.ai.behavior_tree import Action, BehaviorTree, Condition, Selector, Status


def has_target(agent) -> bool:
    return agent.blackboard.get("has_target", False)


def chase(agent, dt) -> Status:
    agent.set_steering_behavior(Pursue(agent.kinematic, player))
    return Status.RUNNING


agent.set_brain(
    BehaviorTree(Selector([Sequence([Condition(has_target), Action(chase)]), Action(patrol)]))
)

# Somewhere in a perception system, unrelated to the behavior tree:
agent.blackboard.set("has_target", True)

You can also react immediately to a value changing, instead of waiting for the next tick to notice it, by registering an observer:

def on_target_spotted(key, old_value, new_value):
    if new_value:
        print("Spotted!")

agent.blackboard.observe("has_target", on_target_spotted)

Pass a Blackboard explicitly to Agent (instead of letting it create its own) to share one across several agents, for instance so an entire guard squad reacts together to "team_alerted":

from gale.ai.blackboard import Blackboard

squad_blackboard = Blackboard({"team_alerted": False})
guard1 = Agent(x=0, y=0, blackboard=squad_blackboard)
guard2 = Agent(x=50, y=0, blackboard=squad_blackboard)

guard1.blackboard.set("team_alerted", True)
guard2.blackboard.get("team_alerted")  # True: same blackboard instance

Using it with Factory

Since Agent accepts x and y as its first two constructor arguments, it plugs directly into gale.factory.Factory:

from gale.ai.agent import Agent
from gale.factory import Factory

agent_factory = Factory(Agent)
agent = agent_factory.create(50, 50, {"max_speed": 150})

Graphs

gale.ai.graph provides a generic Graph (nodes connected by weighted edges, directed or not), plus three specializations built on top of it:

  • NavGraph: nodes are 2D positions (waypoints); an edge’s weight defaults to the euclidean distance between the positions it connects.

  • DependencyGraph: a directed graph of prerequisite/build-order relationships (skill trees, quest chains, build pipelines), with topological_sort and has_cycle.

  • StateGraph: a directed graph of a state space (every reachable configuration of a puzzle and the moves between them). Since the full state space is normally too large to write out by hand, StateGraph.expand builds it automatically from a starting state and a function that yields the valid transitions out of any state. Each transition can optionally carry an action label (for instance, a description of the move that produced it), recoverable afterwards with get_action/actions_for_path — the states along a path don’t always make it obvious what to actually do to go from one to the next.

from gale.ai.graph import DependencyGraph, NavGraph

# NavGraph: pathfinding waypoints, weights default to distance.
nav_graph = NavGraph()
nav_graph.add_edge((0, 0), (100, 0))
nav_graph.add_edge((100, 0), (100, 100))
nav_graph.add_edge((0, 0), (0, 100), weight=1000)  # a slow, hazardous shortcut

# DependencyGraph: a tiny skill tree.
skills = DependencyGraph()
skills.add_dependency("fireball", depends_on="magic_missile")
skills.add_dependency("meteor", depends_on="fireball")
build_order = skills.topological_sort()  # e.g. ["magic_missile", "fireball", "meteor"]

Search algorithms

gale.ai.search provides depth_first_search, breadth_first_search, dijkstra, and a_star. All four share the same signature, search(start, goal, graph_or_neighbors_fn, ...), and return the list of nodes from start to goal (both included), or None if goal is unreachable. graph_or_neighbors_fn can be a Graph (or any of its subclasses above) or a plain callable node -> iterable of (neighbor, weight) pairs, so these functions work just as well over an implicit graph you never materialize.

import math

from gale.ai.graph import NavGraph
from gale.ai.search import a_star, dijkstra, path_cost

nav_graph = NavGraph()
nav_graph.add_edge((0, 0), (100, 0))
nav_graph.add_edge((100, 0), (100, 100))

def heuristic(node, goal):
    return math.hypot(goal[0] - node[0], goal[1] - node[1])

path = a_star((0, 0), (100, 100), nav_graph, heuristic)
total_distance = path_cost(nav_graph, path)

A path found this way is a sequence of waypoints, which pairs naturally with the steering behaviors above — for instance, Seek (or Arrive for the last waypoint) towards each point in turn, advancing to the next one once the character gets close enough.

depth_first_search and breadth_first_search ignore weights (they only care about the number of edges), while dijkstra and a_star find the cheapest path by total weight — a_star additionally takes a heuristic(node, goal) callable to focus the search towards the goal instead of expanding outward evenly, which is faster as long as the heuristic never overestimates the real remaining cost.

These functions aren’t limited to spatial pathfinding — any state-space problem works too. Here they solve the Towers of Hanoi optimally by searching a StateGraph built from the puzzle’s legal moves:

from gale.ai.graph import StateGraph
from gale.ai.search import breadth_first_search


def hanoi_successors(state):
    # state is a tuple of 3 tuples (one per peg) listing disk sizes
    # from bottom to top. Each transition is labeled with the
    # (source_peg, target_peg) move that produces it.
    for source in range(3):
        if not state[source]:
            continue

        disk = state[source][-1]

        for target in range(3):
            if target == source:
                continue

            if state[target] and state[target][-1] < disk:
                continue

            next_state = list(state)
            next_state[source] = state[source][:-1]
            next_state[target] = state[target] + (disk,)
            yield tuple(next_state), 1, (source, target)


n = 3
start = (tuple(range(n, 0, -1)), (), ())
goal = ((), (), tuple(range(n, 0, -1)))

graph = StateGraph.expand(start, hanoi_successors)
solution = breadth_first_search(start, goal, graph)
print(len(solution) - 1)  # 7 moves: optimal for 3 disks (2**n - 1)

# The states alone don't spell out what to actually do; recover the
# (source_peg, target_peg) move behind each step of the solution:
moves = graph.actions_for_path(solution)