Skip to main content

Extending AirPath

AirPath is designed to be extensible. This guide covers common extension points and patterns for customizing the system to your needs.

Extension Points Overview

Extension PointInterface/Base ClassUse Case
Height ProviderIHeightProviderCustom terrain sources
Pathfinding ModeComponent patternCustom start/end selection
AgentComponent patternCustom movement behavior
ConfigurationConfigurationBaseCustom settings assets
Event ListenerSubscribe<T>()Custom reactions to events

Custom Height Providers

The most common extension is creating custom height providers for non-Unity-Terrain scenarios.

Interface Definition

public interface IHeightProvider
{
float CellSize { get; }
Vector3 Origin { get; }
int GridWidth { get; }
int GridHeight { get; }

float GetHeightAt(int x, int y);
float[] SampleHeights(int samplesPerDimension);
}

Example: Perlin Noise Terrain

using UnityEngine;
using PlatypusIdeas.AirPath.Runtime.Core.Height;

public class PerlinHeightProvider : MonoBehaviour, IHeightProvider
{
[Header("Grid Settings")]
[SerializeField] private float worldSize = 500f;
[SerializeField] private int resolution = 64;

[Header("Noise Settings")]
[SerializeField] private float noiseScale = 0.02f;
[SerializeField] private float heightMultiplier = 50f;
[SerializeField] private Vector2 noiseOffset;

private float[] _cachedHeights;

public float CellSize => worldSize / resolution;
public Vector3 Origin => transform.position;
public int GridWidth => resolution;
public int GridHeight => resolution;

public float GetHeightAt(int x, int y)
{
if (x < 0 || x >= resolution || y < 0 || y >= resolution)
return 0f;

float worldX = Origin.x + x * CellSize;
float worldZ = Origin.z + y * CellSize;

float noise = Mathf.PerlinNoise(
(worldX + noiseOffset.x) * noiseScale,
(worldZ + noiseOffset.y) * noiseScale
);

return noise * heightMultiplier;
}

public float[] SampleHeights(int samplesPerDimension)
{
var heights = new float[samplesPerDimension * samplesPerDimension];

for (int y = 0; y < samplesPerDimension; y++)
{
for (int x = 0; x < samplesPerDimension; x++)
{
int index = y * samplesPerDimension + x;
heights[index] = GetHeightAt(x, y);
}
}

_cachedHeights = heights;
return heights;
}
}

Example: Mesh-Based Height Provider

using UnityEngine;
using PlatypusIdeas.AirPath.Runtime.Core.Height;

public class MeshHeightProvider : MonoBehaviour, IHeightProvider
{
[SerializeField] private MeshCollider meshCollider;
[SerializeField] private float worldSize = 100f;
[SerializeField] private int resolution = 64;
[SerializeField] private float raycastHeight = 100f;

public float CellSize => worldSize / resolution;
public Vector3 Origin => transform.position;
public int GridWidth => resolution;
public int GridHeight => resolution;

public float GetHeightAt(int x, int y)
{
float worldX = Origin.x + x * CellSize + CellSize * 0.5f;
float worldZ = Origin.z + y * CellSize + CellSize * 0.5f;

var rayOrigin = new Vector3(worldX, raycastHeight, worldZ);

if (Physics.Raycast(rayOrigin, Vector3.down, out RaycastHit hit, raycastHeight * 2f))
{
return hit.point.y;
}

return 0f;
}

public float[] SampleHeights(int samplesPerDimension)
{
var heights = new float[samplesPerDimension * samplesPerDimension];

for (int y = 0; y < samplesPerDimension; y++)
{
for (int x = 0; x < samplesPerDimension; x++)
{
heights[y * samplesPerDimension + x] = GetHeightAt(x, y);
}
}

return heights;
}
}

Example: Flat Plane Provider

public class FlatHeightProvider : MonoBehaviour, IHeightProvider
{
[SerializeField] private float worldSize = 100f;
[SerializeField] private int resolution = 64;
[SerializeField] private float constantHeight = 0f;

public float CellSize => worldSize / resolution;
public Vector3 Origin => transform.position;
public int GridWidth => resolution;
public int GridHeight => resolution;

public float GetHeightAt(int x, int y) => constantHeight;

public float[] SampleHeights(int samplesPerDimension)
{
var heights = new float[samplesPerDimension * samplesPerDimension];

for (int i = 0; i < heights.Length; i++)
{
heights[i] = constantHeight;
}

return heights;
}
}

Height Provider Guidelines

  • Cache heights when possible — SampleHeights() is called during initialization
  • Handle out-of-bounds gracefully — return 0 or clamp to valid range
  • Thread safetyGetHeightAt() may be called from jobs (avoid Unity API)
  • Origin alignment — Ensure Origin matches your world setup

Custom Pathfinding Modes

Create modes for specific gameplay needs.

Pattern

public class CustomPathfindingMode : MonoBehaviour
{
private PathfindingManager _manager;

public void Initialize(PathfindingManager manager)
{
_manager = manager;
}

public void UpdateMode()
{
// Your logic to determine start/end positions
// Call _manager.RequestPath(start, end) when appropriate
}

public void Cleanup()
{
// Clean up resources
}
}

Example: Waypoint Patrol Mode

using System.Collections.Generic;
using UnityEngine;
using PlatypusIdeas.AirPath.Runtime.Core.Pathfinding;
using PlatypusIdeas.AirPath.Runtime.Events;

public class PatrolMode : MonoBehaviour
{
[SerializeField] private List<Transform> waypoints;
[SerializeField] private float arrivalDistance = 3f;
[SerializeField] private bool loop = true;

private PathfindingManager _manager;
private int _currentWaypointIndex;
private Vector3 _lastPosition;

public void Initialize(PathfindingManager manager)
{
_manager = manager;
_currentWaypointIndex = 0;

this.Subscribe<PathCalculatedEvent>(OnPathCalculated);

RequestPathToCurrentWaypoint();
}

public void UpdateMode()
{
if (waypoints == null || waypoints.Count == 0)
return;

// Check if reached current waypoint
var currentWaypoint = waypoints[_currentWaypointIndex];
var distance = Vector3.Distance(_lastPosition, currentWaypoint.position);

if (distance < arrivalDistance)
{
AdvanceToNextWaypoint();
}
}

private void AdvanceToNextWaypoint()
{
_currentWaypointIndex++;

if (_currentWaypointIndex >= waypoints.Count)
{
if (loop)
{
_currentWaypointIndex = 0;
}
else
{
Debug.Log("Patrol complete");
return;
}
}

RequestPathToCurrentWaypoint();
}

private void RequestPathToCurrentWaypoint()
{
if (waypoints == null || waypoints.Count == 0)
return;

var start = _manager.transform.position;
var end = waypoints[_currentWaypointIndex].position;

// Use PathfindingService directly or trigger via manager
Debug.Log($"Requesting path to waypoint {_currentWaypointIndex}");
}

private void OnPathCalculated(PathCalculatedEvent evt)
{
if (evt.Success && evt.WorldPath.Count > 0)
{
_lastPosition = evt.WorldPath[evt.WorldPath.Count - 1];
}
}

public void Cleanup()
{
EventBusExtensions.Unsubscribe<PathCalculatedEvent>(OnPathCalculated);
}
}

Example: Click-to-Move Mode (RTS Style)

using UnityEngine;
using PlatypusIdeas.AirPath.Runtime.Core.Pathfinding;

public class ClickToMoveMode : MonoBehaviour
{
[SerializeField] private Camera gameCamera;
[SerializeField] private LayerMask groundLayer;
[SerializeField] private KeyCode moveKey = KeyCode.Mouse1;

private PathfindingManager _manager;
private PathfindingService _pathfindingService;

public void Initialize(PathfindingManager manager, PathfindingService service)
{
_manager = manager;
_pathfindingService = service;

if (gameCamera == null)
gameCamera = Camera.main;
}

public void UpdateMode()
{
if (Input.GetKeyDown(moveKey))
{
TryMoveToMousePosition();
}
}

private void TryMoveToMousePosition()
{
var ray = gameCamera.ScreenPointToRay(Input.mousePosition);

if (Physics.Raycast(ray, out RaycastHit hit, 1000f, groundLayer))
{
var start = _manager.transform.position;
var end = hit.point;

var result = _pathfindingService.CalculatePath(start, end, 10f);

if (result.Success)
{
Debug.Log($"Path found with {result.Path.Count} waypoints");
// Dispatch path to your movement system
}
}
}
}

Custom Agents

Build agents with specific movement behaviors.

Minimal Agent Template

using System.Collections.Generic;
using UnityEngine;

public abstract class BaseAgent : MonoBehaviour
{
public abstract Vector3 CurrentPosition { get; }
public abstract void SetPath(List<Vector3> path, bool teleportToStart = true);
public abstract void StopMovement();
}

Example: Vehicle Agent (Ground-Based)

using System.Collections.Generic;
using UnityEngine;

public class VehicleAgent : BaseAgent
{
[SerializeField] private float maxSpeed = 15f;
[SerializeField] private float acceleration = 5f;
[SerializeField] private float turnSpeed = 90f;
[SerializeField] private float waypointRadius = 3f;

private List<Vector3> _path;
private int _currentIndex;
private float _currentSpeed;
private bool _isMoving;

public override Vector3 CurrentPosition => transform.position;

public override void SetPath(List<Vector3> path, bool teleportToStart = true)
{
_path = new List<Vector3>(path);
_currentIndex = 0;
_currentSpeed = 0f;
_isMoving = true;

if (teleportToStart && _path.Count > 0)
{
transform.position = _path[0];
_currentIndex = 1;
}
}

public override void StopMovement()
{
_isMoving = false;
_currentSpeed = 0f;
}

private void Update()
{
if (!_isMoving || _path == null || _currentIndex >= _path.Count)
return;

var target = _path[_currentIndex];
target.y = transform.position.y; // Keep on ground

var direction = (target - transform.position).normalized;

// Rotate toward target
if (direction != Vector3.zero)
{
var targetRotation = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.RotateTowards(
transform.rotation,
targetRotation,
turnSpeed * Time.deltaTime
);
}

// Accelerate/decelerate based on angle to target
var angleToTarget = Vector3.Angle(transform.forward, direction);
var speedMultiplier = Mathf.Clamp01(1f - angleToTarget / 90f);

var targetSpeed = maxSpeed * speedMultiplier;
_currentSpeed = Mathf.MoveTowards(_currentSpeed, targetSpeed, acceleration * Time.deltaTime);

// Move forward
transform.position += transform.forward * _currentSpeed * Time.deltaTime;

// Check waypoint arrival
if (Vector3.Distance(transform.position, target) < waypointRadius)
{
_currentIndex++;

if (_currentIndex >= _path.Count)
{
StopMovement();
}
}
}
}

Example: Flying Agent with Banking

using System.Collections.Generic;
using UnityEngine;

public class BankingFlyerAgent : BaseAgent
{
[SerializeField] private float moveSpeed = 20f;
[SerializeField] private float turnSpeed = 3f;
[SerializeField] private float maxBankAngle = 45f;
[SerializeField] private float bankSpeed = 2f;
[SerializeField] private float waypointRadius = 5f;

[SerializeField] private Transform modelTransform; // Child with visual

private List<Vector3> _path;
private int _currentIndex;
private bool _isMoving;
private float _currentBank;

public override Vector3 CurrentPosition => transform.position;

public override void SetPath(List<Vector3> path, bool teleportToStart = true)
{
_path = new List<Vector3>(path);
_currentIndex = 0;
_isMoving = true;

if (teleportToStart && _path.Count > 0)
{
transform.position = _path[0];
if (_path.Count > 1)
{
transform.LookAt(_path[1]);
}
}
}

public override void StopMovement()
{
_isMoving = false;
}

private void Update()
{
if (!_isMoving || _path == null || _currentIndex >= _path.Count)
return;

var target = _path[_currentIndex];
var direction = (target - transform.position).normalized;

// Calculate turn direction for banking
var turnDirection = Vector3.Cross(transform.forward, direction).y;
var targetBank = -turnDirection * maxBankAngle;
_currentBank = Mathf.Lerp(_currentBank, targetBank, bankSpeed * Time.deltaTime);

// Apply banking to model
if (modelTransform != null)
{
modelTransform.localRotation = Quaternion.Euler(0, 0, _currentBank);
}

// Rotate toward target
var targetRotation = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRotation,
turnSpeed * Time.deltaTime
);

// Move forward
transform.position += transform.forward * moveSpeed * Time.deltaTime;

// Check waypoint arrival
if (Vector3.Distance(transform.position, target) < waypointRadius)
{
_currentIndex++;

if (_currentIndex >= _path.Count)
{
StopMovement();
}
}
}
}

Custom Configurations

Extend ConfigurationBase for your own settings.

Pattern

using UnityEngine;
using PlatypusIdeas.AirPath.Runtime.Configuration;

[CreateAssetMenu(menuName = "Pathfinding/Configurations/My Custom Config")]
public class MyCustomConfiguration : ConfigurationBase
{
[Header("My Settings")]
[SerializeField] private float myValue = 10f;
[SerializeField] private bool myFlag = true;

public float MyValue => myValue;
public bool MyFlag => myFlag;

public override bool Validate()
{
bool valid = true;

if (myValue < 0)
{
Debug.LogWarning($"[{ConfigurationName}] MyValue cannot be negative");
myValue = 0;
valid = false;
}

return valid;
}

public override void ResetToDefaults()
{
myValue = 10f;
myFlag = true;
}
}

Example: Difficulty Configuration

[CreateAssetMenu(menuName = "Pathfinding/Configurations/Difficulty Config")]
public class DifficultyConfiguration : ConfigurationBase
{
[Header("AI Settings")]
[SerializeField, Range(0.5f, 2f)] private float aiSpeedMultiplier = 1f;
[SerializeField, Range(0.1f, 1f)] private float pathUpdateFrequency = 0.5f;
[SerializeField] private bool aiUsesShortcuts = false;

[Header("Player Assistance")]
[SerializeField] private bool showPathPreview = true;
[SerializeField] private float pathPreviewDistance = 50f;

public float AISpeedMultiplier => aiSpeedMultiplier;
public float PathUpdateFrequency => pathUpdateFrequency;
public bool AIUsesShortcuts => aiUsesShortcuts;
public bool ShowPathPreview => showPathPreview;
public float PathPreviewDistance => pathPreviewDistance;

public override bool Validate()
{
return true;
}

public override void ResetToDefaults()
{
aiSpeedMultiplier = 1f;
pathUpdateFrequency = 0.5f;
aiUsesShortcuts = false;
showPathPreview = true;
pathPreviewDistance = 50f;
}
}

Event System Integration

Build reactive systems using the event bus.

Subscribing to Events

using PlatypusIdeas.AirPath.Runtime.Events;

public class PathfindingAnalytics : MonoBehaviour
{
private int _totalPathsCalculated;
private float _totalCalculationTime;
private int _failedPaths;

private void Start()
{
this.Subscribe<PathCalculatedEvent>(OnPathCalculated);
this.Subscribe<PathfindingErrorEvent>(OnError);
this.Subscribe<BoundaryViolationEvent>(OnBoundaryViolation);
}

private void OnDestroy()
{
EventBusExtensions.Unsubscribe<PathCalculatedEvent>(OnPathCalculated);
EventBusExtensions.Unsubscribe<PathfindingErrorEvent>(OnError);
EventBusExtensions.Unsubscribe<BoundaryViolationEvent>(OnBoundaryViolation);
}

private void OnPathCalculated(PathCalculatedEvent evt)
{
_totalPathsCalculated++;
_totalCalculationTime += evt.CalculationTime;

if (!evt.Success)
_failedPaths++;

Debug.Log($"Paths: {_totalPathsCalculated}, " +
$"Avg Time: {_totalCalculationTime / _totalPathsCalculated:F2}ms, " +
$"Failed: {_failedPaths}");
}

private void OnError(PathfindingErrorEvent evt)
{
Debug.LogError($"Pathfinding error: {evt.ErrorMessage}");

if (evt.IsCritical)
{
// Handle critical errors
}
}

private void OnBoundaryViolation(BoundaryViolationEvent evt)
{
Debug.LogWarning($"Boundary violation at {evt.OriginalPosition}, " +
$"clamped to {evt.ClampedPosition}");
}
}

Publishing Custom Events

// Define custom event
public class CustomPathEvent
{
public Vector3 Position { get; }
public string Message { get; }

public CustomPathEvent(Vector3 position, string message)
{
Position = position;
Message = message;
}
}

// Publish
EventBusExtensions.Publish(new CustomPathEvent(
transform.position,
"Agent reached checkpoint"
));

// Subscribe
this.Subscribe<CustomPathEvent>(evt =>
{
Debug.Log($"{evt.Message} at {evt.Position}");
});

Integration Patterns

With Animation System

public class AnimatedAgent : MonoBehaviour
{
[SerializeField] private Animator animator;
[SerializeField] private float moveSpeed = 5f;

private static readonly int SpeedHash = Animator.StringToHash("Speed");
private static readonly int FlyingHash = Animator.StringToHash("IsFlying");

private List<Vector3> _path;
private int _currentIndex;
private float _currentSpeed;

public void SetPath(List<Vector3> path)
{
_path = path;
_currentIndex = 0;
animator.SetBool(FlyingHash, true);
}

private void Update()
{
if (_path == null || _currentIndex >= _path.Count)
{
_currentSpeed = Mathf.Lerp(_currentSpeed, 0f, Time.deltaTime * 5f);
animator.SetFloat(SpeedHash, _currentSpeed);
return;
}

// Movement logic...
_currentSpeed = Mathf.Lerp(_currentSpeed, moveSpeed, Time.deltaTime * 3f);
animator.SetFloat(SpeedHash, _currentSpeed);
}

private void OnPathComplete()
{
animator.SetBool(FlyingHash, false);
}
}

With Behavior Tree / FSM

// Example integration point for behavior trees
public class PathfindingBTNode
{
private PathfindingService _pathfindingService;
private List<Vector3> _currentPath;

public bool HasPath => _currentPath != null && _currentPath.Count > 0;
public List<Vector3> CurrentPath => _currentPath;

public bool RequestPath(Vector3 start, Vector3 end, float heightOffset)
{
var result = _pathfindingService.CalculatePath(start, end, heightOffset);

if (result.Success)
{
_currentPath = result.Path;
return true;
}

_currentPath = null;
return false;
}

public void ClearPath()
{
_currentPath = null;
}
}

With Object Pooling

public class PooledAgentManager : MonoBehaviour
{
[SerializeField] private GameObject agentPrefab;
[SerializeField] private int poolSize = 20;

private Queue<GameObject> _pool = new();
private List<GameObject> _activeAgents = new();

private void Start()
{
// Pre-populate pool
for (int i = 0; i < poolSize; i++)
{
var agent = Instantiate(agentPrefab, transform);
agent.SetActive(false);
_pool.Enqueue(agent);
}

this.Subscribe<PathCalculatedEvent>(OnPathCalculated);
}

public GameObject SpawnAgent(Vector3 position)
{
GameObject agent;

if (_pool.Count > 0)
{
agent = _pool.Dequeue();
}
else
{
agent = Instantiate(agentPrefab, transform);
}

agent.transform.position = position;
agent.SetActive(true);
_activeAgents.Add(agent);

return agent;
}

public void ReturnAgent(GameObject agent)
{
agent.SetActive(false);
_activeAgents.Remove(agent);
_pool.Enqueue(agent);
}

private void OnPathCalculated(PathCalculatedEvent evt)
{
// Assign path to active agents
foreach (var agent in _activeAgents)
{
var agentComponent = agent.GetComponent<BaseAgent>();
agentComponent?.SetPath(evt.WorldPath, teleportToStart: false);
}
}
}

Best Practices

Keep Extensions Loosely Coupled

  • Use events instead of direct references where possible
  • Implement interfaces rather than inheriting from concrete classes
  • Use ScriptableObjects for configuration

Test Extensions Thoroughly

  • Verify height providers return sensible values at boundaries
  • Test pathfinding modes with edge cases (no path found, target out of bounds)
  • Profile custom agents for performance

Document Your Extensions

  • Add XML documentation to public methods
  • Include usage examples in comments
  • Create configuration tooltips with [Tooltip("...")]

Follow AirPath Patterns

  • Use the event bus for cross-system communication
  • Extend ConfigurationBase for settings
  • Implement Validate() for configuration validation

See Also