EvaluatedApplications.Tracer 2.0.4

There is a newer version of this package available.
See the version list below for details.
dotnet add package EvaluatedApplications.Tracer --version 2.0.4
                    
NuGet\Install-Package EvaluatedApplications.Tracer -Version 2.0.4
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EvaluatedApplications.Tracer" Version="2.0.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EvaluatedApplications.Tracer" Version="2.0.4" />
                    
Directory.Packages.props
<PackageReference Include="EvaluatedApplications.Tracer" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EvaluatedApplications.Tracer --version 2.0.4
                    
#r "nuget: EvaluatedApplications.Tracer, 2.0.4"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EvaluatedApplications.Tracer@2.0.4
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EvaluatedApplications.Tracer&version=2.0.4
                    
Install as a Cake Addin
#tool nuget:?package=EvaluatedApplications.Tracer&version=2.0.4
                    
Install as a Cake Tool

Tracer

Fast pathfinding and game-AI SDK for .NET: navmesh pathfinding, multi-agent movement, dynamic obstacles, line-of-sight and fog-of-war, influence maps, combat engagement, and tactical behaviors — for real-time simulations with many moving agents (crowds, RTS unit groups, tactical squads). Built on the EvalApp pipeline runtime, so every stage is resource-gated and adaptively tuned to your frame budget.

This page is the only documentation shipped with the package — it is binary-only (source is not distributed), so every public type and method is covered below with a one-line purpose and a working example. All examples were compiled and run against the real package before publishing; every printed // => line is real output, not a guess.

Table of contents

  1. Quick start
  2. Core concepts and gotchas
  3. PathfindingWorld — the root factory
  4. Navmesh baking
  5. Single-agent pathfinding
  6. Multi-agent swarm ticking
  7. Layered navmeshes (ground / air / water)
  8. Dynamic obstacles
  9. Adaptive path budgeting
  10. Population simulation (NavSim)
  11. Line-of-sight and vision
  12. Fog of war
  13. Influence maps
  14. Congestion analysis
  15. Combat engagement
  16. Steering behaviors and the Navigator
  17. Grid tactics (bake-free tile lane)
  18. Population helpers
  19. Dependency injection
  20. EvalApp pipeline integration
  21. License

Quick start

dotnet add package EvaluatedApplications.Tracer
using System.Numerics;
using System.Collections.Immutable; // only needed later, for section 8 (NavSim)
using Tracer;
using Tracer.Models;

var world = new PathfindingWorld();

// Bake a navmesh from a 4x4 square made of two triangles.
var vertices  = new List<Vector2> { new(0, 0), new(4, 0), new(4, 4), new(0, 4) };
var triangles = new List<(int A, int B, int C)> { (0, 1, 2), (0, 2, 3) };
var baker = world.CreateNavMeshBakerService();
var mesh  = await baker.BakeAsync(vertices, triangles);

// Query a single smoothed path.
var single    = world.CreateSingleAgentService();
var waypoints = await single.QueryPathAsync(mesh, new Vector2(0, 0), new Vector2(4, 4));
// waypoints.Count => 2

// Or tick a whole swarm of agents at once (agents sharing a goal reuse one solve).
var agents = new List<AgentDto> { new(Id: 1, Position: new Vector2(0, 0), Goal: new Vector2(4, 4)) };
var multi  = world.CreateMultiAgentService();
var result = await multi.TickAsync(agents, mesh, tickNumber: 0);
// result.Paths.Count => 1, result.Pressure => 0.0 (no TargetFrameMs supplied)

The rest of this page continues from this snippet — world, mesh, and agents below are the ones created here, unless a section says otherwise. Every snippet below was verified against the real package; as sections introduce new types, add their namespace the same way you would in any C# file: Tracer.Abstractions (interfaces, AdaptiveOptions), Tracer.Services (GameMath), Tracer.Behaviors, Tracer.Helpers, Tracer.Extensions (AddTracer), Tracer.Integration, Microsoft.Extensions.DependencyInjection, EvalApp.

Core concepts and gotchas

  • Coordinates. Everything is System.Numerics.Vector2 in whatever world-space units you baked the mesh with (meters, tiles-as-floats, pixels — your choice). Grid-tactics helpers (section 15) use plain int tile coordinates instead — a separate, bake-free lane.
  • Navmesh is triangles, not a tile grid. Bake via INavMeshBakerService — 2 triangles per walkable square cell if you're sourcing from a grid. NavMeshHandle is opaque; you never see the triangle list.
  • Unreachable / off-mesh targets never throw. ISingleAgentService.QueryPathAsync returns an empty list; IMultiAgentService.TickAsync returns PathResultDto.PathFound = false with empty Waypoints. The only ArgumentExceptions are on empty input to BakeAsync/FromTriangles (you cannot construct a zero-triangle handle through the public API).
  • Costs are in world units, not percentages: NavMeshBlockedZone.Radius, InfluenceSourceDto.FalloffRadius, VisionProfile.Range, ConflictRadius are all world-space distances in your baking units.
  • Blocking vs excluding. INavMeshBakerService.BakeAsync's blockedZones excludes covered triangles from the mesh entirely (triangle count drops). IDynamicObstacleService. UpdateAsync's zones isolate triangles in place (clears their neighbours; triangle count never changes). UpdateAsync's newBlockedZones is always the full replacement set, not a delta — pass empty to unblock everything since the last bake or ResetBase().
  • Thread-safety. NavMeshHandle/HierarchicalNavMeshHandle/LayeredNavMeshHandle are immutable — safe to read from any thread and to swap atomically between ticks. The stateful services returned by PathfindingWorld.Create* persist tuner state to disk per instance; two services sharing the same serviceKey (and hence the same tuner-state path) should not be driven concurrently from different threads. The stateless services (ILineOfSightService, ICongestionService, IFogOfWarService) have no shared state and are safe to call concurrently.
  • Performance — goal sharing. IMultiAgentService.TickAsync caches guidance per goal, not per agent: agents converging on one exit share one guidance computation, and a cached goal costs nothing against a MaxNewFieldsPerFrame budget. Per-agent work (including, by default, conflict resolution) remains and grows with agent count; see the measured figures in section 4.
  • License. Proprietary — see the License section at the end of this page. PathfindingWorld's optional licenseKey only toggles whether adaptive tuning attaches — it never gates a capability off.

1. PathfindingWorld — the root factory

PathfindingWorld is the single entry point. It vends every stateful service, each isolated under its own tuner-state subdirectory so independent levels/zones don't share adaptive-tuning history.

Member Purpose
new PathfindingWorld(licenseKey?, tunerStatePath?) Create the world. tunerStatePath defaults to {TempPath}/tracer.
LicenseMode Unlicensed or Licensed — read-only, set once at construction.
CreateMultiAgentService(key="default") Swarm tick service (section 4).
CreateSingleAgentService(key="single") Point-to-point query service (section 3).
CreateNavMeshBakerService(key="baker") Navmesh baking service (section 2).
CreateDynamicObstacleService(key="obstacles") Runtime obstacle updates (section 6).
CreateInfluenceMapService(key="influence") Influence map computation (section 11).
CreateEngagementService(key="engagement", gameMath?) Combat resolution (section 13).
CreateAdaptivePathfindingService(key="adaptive") Load-aware clustering (section 7).
CreateNavigator(key="navigator") Fluent behavior + pathfinding composition (section 14).
CreateLineOfSightService() Stateless LOS/vision queries (section 9).
CreateCongestionService() Stateless per-tick congestion map (section 12).
CreateFogOfWarService() Stateless fog-of-war grid (section 10).
var world1 = new PathfindingWorld();                              // free tier, default tuner path
var world2 = new PathfindingWorld(tunerStatePath: "C:/game/nav");  // custom tuner path
Console.WriteLine(world1.LicenseMode);                             // => Unlicensed

2. Navmesh baking

INavMeshBakerService turns raw triangle geometry into an opaque NavMeshHandle.

Member Purpose
BakeAsync(vertices, triangles, blockedZones?, ct) Bake a flat navmesh; optionally exclude obstacle polygons.
BakeHierarchicalAsync(vertices, triangles, blockedZones?, simplificationOptions?, ct) Bake a fine mesh plus an auto-simplified coarse LOD.
NavMeshHandle.FromTriangles(triangles) Build a handle directly from NavMeshTriangleDefinitions (no pipeline, no bake service).
NavMeshHandle.TriangleCount Walkable triangle count.
NavMeshHandle.HasLineOfSight(from, to) Straight-line visibility within the mesh.
NavMeshHandle.FindNearestTriangle(point) Snap an off-mesh point to its nearest triangle (VP-tree, O(log N)); -1 only for an empty mesh (unreachable via the public API).
HierarchicalNavMeshHandle.Fine / .Combined The fine-only mesh vs. the fine+coarse mesh used for pathfinding.
SimplificationOptions(MinClusterSize=4, MaxTurningAngleDeg=30) Controls how fine triangles cluster into coarse ones.
var blockedZones = new List<IReadOnlyList<Vector2>>
{
    new List<Vector2> { new(10, 10), new(11, 10), new(11, 11) } // excluded if it covers triangles
};
var meshWithHole = await baker.BakeAsync(vertices, triangles, blockedZones);
// meshWithHole.TriangleCount => 2 (this zone lies outside the 4x4 square, so nothing is excluded)

var hierarchical = await baker.BakeHierarchicalAsync(
    vertices, triangles, blockedZones: null, new SimplificationOptions(MinClusterSize: 2));
// hierarchical.FineTriangleCount => 2, hierarchical.CombinedTriangleCount => 2

var manualMesh = NavMeshHandle.FromTriangles(new List<NavMeshTriangleDefinition>
{
    new(0, new Vector2(0,0), new Vector2(4,0), new Vector2(4,4), new List<int>{1}),
    new(1, new Vector2(0,0), new Vector2(4,4), new Vector2(0,4), new List<int>{0}),
});
Console.WriteLine(manualMesh.FindNearestTriangle(new Vector2(2, 2))); // => 0
Console.WriteLine(manualMesh.HasLineOfSight(new Vector2(0,0), new Vector2(4,4))); // => True

BakeAsync/FromTriangles throw ArgumentException on an empty vertex/triangle list — the only two public ways to obtain a NavMeshHandle, so a handle can never have zero triangles.

3. Single-agent pathfinding

ISingleAgentService is for point-to-point queries where each agent has its own distinct goal. For large swarms sharing goals, prefer IMultiAgentService (section 4), which solves each goal once.

Member Purpose
QueryPathAsync(navMesh, start, goal, ct) One smoothed path. Empty list if unreachable/off-mesh.
QueryPathBatchAsync(navMesh, queries, ct) Many (start, goal) pairs in parallel, same order in, same order out.

QueryPathAsync is already shown in the Quick Start above (single.QueryPathAsync(...)). Continuing with the same single and mesh, here's the batch overload:

var batch = await single.QueryPathBatchAsync(mesh, new[]
{
    (new Vector2(0,0), new Vector2(4,4)),
    (new Vector2(0,0), new Vector2(99,99)),   // off-mesh goal
});
Console.WriteLine(batch[1].Count > 0); // => False (unreachable)

4. Multi-agent swarm ticking

IMultiAgentService is the core swarm tick: pass every agent once per frame, get one PathResultDto per agent back. Agents sharing a goal reuse one cached solve.

Measured (24x24 open grid, 1152 triangles, 500 agents at random positions, first tick, median of 7, i9-11950H, machine under other load): with conflict resolution at the default (unlimited radius) a shared goal costs about 190 ms, because every converging agent conflicts with its neighbours. With TickOptions(PathfindingMode.Explicit, ConflictRadius: 0.01f) the same tick costs about 3.8 ms, against about 22 ms for 500 agents with 500 distinct goals. Cost still grows with agent count (one agent is about 0.2 ms), so a large crowd on one goal is cheaper than the same crowd on many goals, not as cheap as a single agent. For big crowds, set ConflictRadius to the distance at which you actually need avoidance.

Member Purpose
TickAsync(agents, navMesh, tickNumber, options?, ct) One tick against a flat mesh.
TickAsync(agents, layeredNavMesh, tickNumber, options?, ct) Overload for LayeredNavMeshHandle (section 5).
AgentDto(Id, Position, Goal, Radius=0.5, MaxSpeed=3.5, GoalChanged=false, MovementLayer=Ground) SDK-facing agent snapshot.
TickOptions(Mode, MaxNewFieldsPerFrame, ConflictRadius, TargetFrameMs=16.67, SurroundingMode, SurroundingRadius, MaxDirectChasers=1) Per-tick scheduling knobs (see below).
PathfindingMode.None / .Explicit / .Tuned Scheduling modes TickOptions.Mode selects.
TickResult(Paths, Pressure, ElapsedMs) Tick output: per-agent paths, a 0-1 load gauge, raw elapsed ms.
PathResultDto(AgentId, Waypoints, PathFound, ConsecutiveFailures, IsStale) Per-agent result.

The basic call is already shown in the Quick Start above (multi.TickAsync(agents, mesh, 0)). Continuing with the same agents/multi/mesh, here's an explicit TickOptions:

var tuned = new TickOptions(
    Mode: PathfindingMode.Tuned, TargetFrameMs: 16.67,
    SurroundingMode: true, MaxDirectChasers: 1);
var result2 = await multi.TickAsync(agents, mesh, tickNumber: 1, tuned);
// result2.Paths.Count => 1

Path guarantee. Every waypoint and every segment of a swarm path lies on walkable navmesh triangles, including paths the conflict step sidesteps (a sidestep that would leave the mesh is shrunk, then dropped, so that agent keeps its unshifted path). Wall clearance is not guaranteed: paths are shortest-path corners that pass within 0.1 world units of a mesh corner whatever AgentDto.Radius is (the radius drives conflict detection and sidestep distance only). QueryPathAsync smooths the same way and may cut a corner by up to about 0.1 units. If agents need real clearance, grow your obstacles by the agent radius before baking.

Only agents with GoalChanged = true get a fresh path; GoalChanged = false agents are forwarded unchanged at zero cost. Pressure is only meaningful in Tuned mode (or any mode with a TargetFrameMs to compare against) — otherwise it reads 0.0. SurroundingMode makes a group converging on one goal spread across distinct approach corridors automatically (emergent encirclement) — MaxDirectChasers controls how many agents bypass that staging and chase directly.

5. Layered navmeshes (ground / air / water)

LayeredNavMeshHandle lets ground, air, and water units navigate on separate meshes while still being ticked together in one TickAsync call.

Member Purpose
LayeredNavMeshHandle.Create((layer, mesh), ...) Build from one or more layer-mesh pairs.
LayeredNavMeshHandle.FromGround(mesh) Convenience wrapper for a single-layer (Ground) world.
.GetHandle(layer) / .TryGetHandle(layer, out handle) Look up the mesh for a layer.
.Layers / .LayerCount Introspect what's registered.
MovementLayer [Flags] enum: Ground, Air, Water, All Which plane an agent/attack occupies.
AgentDto.MovementLayer Selects which mesh the agent paths on.
var airMesh  = await baker.BakeAsync(vertices, triangles); // a separate mesh for flying units
var layered  = LayeredNavMeshHandle.Create(
    (MovementLayer.Ground, mesh),
    (MovementLayer.Air,    airMesh));
Console.WriteLine(layered.LayerCount); // => 2

var groundOnly     = LayeredNavMeshHandle.FromGround(mesh);
var layeredResult  = await multi.TickAsync(agents, layered, tickNumber: 2);
// layeredResult.Paths.Count => 1

An agent whose MovementLayer has no entry in the LayeredNavMeshHandle is forwarded unchanged with PathFound = false — it never throws. Each layer's flow-field cache is independent, so swapping one layer's mesh does not invalidate another layer's cached guidance.

6. Dynamic obstacles

IDynamicObstacleService blocks and unblocks regions of an already-baked mesh at runtime without a full re-bake.

Member Purpose
UpdateAsync(current: NavMeshHandle, newBlockedZones, ct) Apply the full replacement set of blocked zones to a flat mesh.
UpdateAsync(current: HierarchicalNavMeshHandle, newBlockedZones, ct) Same, re-simplifying the coarse mesh afterward.
ResetBase() Call after a full re-bake so the next UpdateAsync adopts the new geometry as the pristine baseline.
NavMeshBlockedZone(Center, Radius) A circular exclusion zone; a triangle is blocked when its centroid is within Radius of Center (on-boundary counts as blocked).
var obstacles    = world.CreateDynamicObstacleService();
var zone         = new NavMeshBlockedZone(Center: new Vector2(2, 2), Radius: 0.5f);
var blockedMesh  = await obstacles.UpdateAsync(mesh, new[] { zone });
// blockedMesh.TriangleCount == mesh.TriangleCount -- isolation, not exclusion (see Core concepts)

var restoredMesh = await obstacles.UpdateAsync(blockedMesh, Array.Empty<NavMeshBlockedZone>());
obstacles.ResetBase(); // call this right after any full re-bake, not after every UpdateAsync

newBlockedZones is always the full replacement set, not a delta — an empty enumerable restores every triangle blocked since the base was captured (first call, or last ResetBase()). UpdateAsync throws ArgumentException if current has zero triangles — unreachable in practice since no public API can produce a zero-triangle handle.

7. Adaptive path budgeting

IAdaptivePathfindingService is a drop-in replacement for IMultiAgentService that automatically switches strategy under load: below a threshold it delegates straight through (zero overhead); above it, it clusters nearby agents with similar goals and paths only cluster representatives, synthesizing follower paths via formation offsets.

Member Purpose
AdaptiveTickAsync(agents, navMesh, tickNumber, pipelineLoad, options?, ct) One adaptive tick; pipelineLoad is a 0.0-1.0 signal (e.g. a previous TickResult.Pressure).
AdaptiveOptions(GroupingThreshold=0.70, GroupRadius=3.0, MaxGroupSize=8, TargetFrameMs=16.67) Tunes when/how clustering kicks in.
AgentGroup(RepresentativeId, FollowerIds, GoalCenter) A representative + its followers sharing one pathfinding solve.
var adaptive        = world.CreateAdaptivePathfindingService();
var adaptiveOptions = new AdaptiveOptions(GroupingThreshold: 0.70f, GroupRadius: 3.0f, MaxGroupSize: 8);
var adaptiveResult  = await adaptive.AdaptiveTickAsync(agents, mesh, tickNumber: 0, pipelineLoad: 0.9f, adaptiveOptions);
// adaptiveResult.Paths.Count => 1

8. Population simulation (NavSim)

NavSim is the highest-level, population-as-a-black-box API: declare named populations with compute-weight shares, and the SDK runs every population's pathfinding strategy internally (each population gets its own IAdaptivePathfindingService under the hood).

Member Purpose
NavSim.Create(world) Start a NavSimBuilder.
.WithFrameBudget(ms) Overall frame budget target (default 16.67ms).
.AddPopulation(name, maxCount, computeWeight) Register a named population.
.Build() Produce the ISimulationService. Call once at level load.
ISimulationService.TickAsync(SimTickInput, ct) Advance every population by one tick.
SimTickInput(NavMesh, TickNumber, Populations) / PopulationInput(Name, Agents, ComputeWeight) Per-tick input.
SimTickResult.GetPopulation(name) / PopulationTickResult(Name, Paths, Pressure, ElapsedMs) Per-tick output.
var sim = NavSim.Create(world)
    .WithFrameBudget(16.67)
    .AddPopulation("creeps", maxCount: 100, computeWeight: 1.0f)
    .Build();

var simInput = new SimTickInput(mesh, TickNumber: 0, Populations: ImmutableArray.Create(
    new PopulationInput("creeps", agents, ComputeWeight: 1.0f)));

var simResult  = await sim.TickAsync(simInput);
var creepResult = simResult.GetPopulation("creeps");
// creepResult.Paths.Count => 1

9. Line-of-sight and vision

ILineOfSightService combines range checks, cone/sector vision, and navmesh walkability.

Member Purpose
HasLineOfSight(mesh, from, to) Point-to-point LOS using navmesh edge walking.
QueryVisibility(mesh, observer, vision, targets) One observer vs. many targets; fail-fast order range → cone → mesh LOS.
QueryAllPairs(mesh, agents) All-pairs visibility batch, each agent with its own VisionProfile.
VisionProfile(Range, FieldOfViewDeg=360, FacingAngleRad=0) 360 = omnidirectional; less = cone vision.
VisibilityResult(ObserverId, TargetId, IsVisible, Distance, InRange, InCone, HasMeshLos) Per-pair result with each check broken out.
var los      = world.CreateLineOfSightService();
var canSee   = los.HasLineOfSight(mesh, new Vector2(0, 0), new Vector2(4, 4)); // => True

var observer = new AgentDto(1, new Vector2(0, 0), new Vector2(0, 0));
var vision   = new VisionProfile(Range: 10f, FieldOfViewDeg: 90f, FacingAngleRad: 0f);
var targets  = new List<AgentDto> { new(2, new Vector2(3, 0), new Vector2(3, 0)) };

var visResults = los.QueryVisibility(mesh, observer, vision, targets);
// visResults.Count(r => r.IsVisible) => 1

var allPairs = los.QueryAllPairs(mesh, new[] { (observer, vision), (targets[0], vision) });
// allPairs.Length => 2

10. Fog of war

IFogOfWarService manages a grid of per-cell visibility state across ticks — independent of the navmesh pathfinding, shares only NavMeshHandle for grid bounds.

Member Purpose
CreateGrid(mesh, options?) Create a FogOfWarGrid sized to the mesh's bounds.
Update(grid, observers, mesh) Mark cells visible this tick; demotes previously-visible cells per FogMode.
GetVisibility(grid, worldPosition) Query a single cell's visibility.
FogOfWarOptions(CellSize=1, Mode=ExploredPersists, MaxRevealRadius=20) Grid resolution + fog behavior + perf cap.
FogMode.ExploredPersists / .FullyHidden StarCraft-style memory vs. full re-fog on leaving vision.
CellVisibility.Hidden / .Explored / .Visible Per-cell state.
FogOfWarGrid[col, row] indexer, .AtWorld, .WorldToCol/.WorldToRow, .CellCenter, .GetSnapshot/.RestoreSnapshot Direct grid access for custom rendering/networking.
var fow     = world.CreateFogOfWarService();
var fogGrid = fow.CreateGrid(mesh, new FogOfWarOptions(CellSize: 1f, Mode: FogMode.ExploredPersists, MaxRevealRadius: 20));
fow.Update(fogGrid, new[] { (observer, vision) }, mesh);

var cellVis = fow.GetVisibility(fogGrid, new Vector2(1, 1)); // => Visible
Console.WriteLine(fogGrid.AtWorld(new Vector2(1, 1)));       // => Visible
Console.WriteLine(fogGrid.WorldToCol(new Vector2(1, 1)));    // => 1
Console.WriteLine(fogGrid.CellCenter(0, 0));                 // => <0.5, 0.5>

var snapshot = fogGrid.GetSnapshot();     // ReadOnlyMemory<CellVisibility>, for serialization
fogGrid.RestoreSnapshot(snapshot.Span);   // round-trip it back
Console.WriteLine(fogGrid[0, 0]);         // => Visible

11. Influence maps

IInfluenceMapService computes one combined influence float per navmesh triangle from a set of positioned emitters (e.g. "how controlled/dangerous is this area") — useful for AI tactical decisions.

Member Purpose
ComputeAsync(navMesh, sources, ct) One float per triangle; 0 = no influence, higher = stronger.
InfluenceSourceDto(Position, Strength, FalloffRadius) An emitter: peak value at distance 0, zero at FalloffRadius.
var influence = world.CreateInfluenceMapService();
var sources   = new List<InfluenceSourceDto> { new(Position: new Vector2(0, 0), Strength: 10f, FalloffRadius: 5f) };
var values    = await influence.ComputeAsync(mesh, sources);
// values[0] => 10.00 (triangle 0 contains the source itself)

Sources whose position falls outside the navmesh are silently excluded; an empty sources list returns all zeros.

12. Congestion analysis

ICongestionService is a cheap (O(n), single-pass spatial hash), per-tick snapshot of crowd density and flow — feeds the Queue/FlowLane steering behaviors (section 14) but is usable standalone for any congestion-aware logic.

Member Purpose
ComputeMap(agents, previousPositions?, cellSize=2) Build a CongestionMap for the current tick. Pass null positions on tick 1 (flow reads zero).
CongestionMap.GetDensity(worldPos) Agent count in that cell.
CongestionMap.GetFlowDirection(worldPos) Normalized average movement direction (or Zero if idle).
CongestionMap.IsCongested(worldPos, threshold=3) true when density >= threshold.
CongestionMap.GetFlowCoherence(worldPos) 0..1; 1 = everyone moving the same way, 0 = chaotic/no agents.
var congestionService = world.CreateCongestionService();
var congestion = congestionService.ComputeMap(agents, previousPositions: null, cellSize: 2f);

Console.WriteLine(congestion.GetDensity(new Vector2(0, 0)));        // => 1
Console.WriteLine(congestion.GetFlowDirection(new Vector2(0, 0)));  // => <0, 0> (no previous positions yet)
Console.WriteLine(congestion.IsCongested(new Vector2(0, 0)));       // => False
Console.WriteLine(congestion.GetFlowCoherence(new Vector2(0, 0)));  // => 0

13. Combat engagement

IEngagementService resolves attacker-vs-defender combat between two groups using spatial partitioning and parallel per-defender resolution. Consumers supply their own agent type extending GameAgent — the SDK never touches your extra fields but preserves them through.

Member Purpose
ResolveAsync<TAgent>(attackers, defenders, radius, ct) Resolve with built-in RPG math; returns outcomes and agents with damage applied.
ResolveAsync<TAgent>(attackers, defenders, radius, resolver, ct) Same, but you supply the per-pair resolution function.
ResolveOutcomesAsync<TAgent>(attackers, defenders, radius, ct) Default math, outcomes only — no damage applied (you apply it yourself).
ResolveOutcomesAsync<TAgent>(attackers, defenders, radius, resolver, ct) Custom resolver, outcomes only.
GameAgent AgentDto + Attack/Defense/Health/MaxHealth/AttackSpeed/AttackRange/MaxAttacksPerTick/AttackLayerMask.
IGameMath Pluggable RPG formulas: HitChance, DamageRoll, IsCritical, CritDamage, CooldownTicks, and default-implemented DodgeChance/BlockChance/DamageModifier (return 0/0/1.0 unless overridden).
GameMath The built-in IGameMath, tunable via GameMathOptions.
GameMathOptions(BaseHitChance, CritChance, CritMultiplier, MinDamage, ArmorScaling, RangeFalloff, BaseCooldown, MaxDodgeChance, MaxBlockChance) + .DamageTypeModifiers Tune the default formulas; register per-damage-type resistance functions.
EngagementOutcome(SourceId, TargetId, Damage, Hit, Crit, Kind, DamageTypeId) + .Miss(...)/.Dodged(...)/.Blocked(...) factories One attack's result; OutcomeKind explains why (Hit/Miss/Dodge/Block/CriticalHit/Graze).
EngagementResult<TAgent>(Outcomes, UpdatedAttackers, UpdatedDefenders) Return shape of the damage-applying overloads.
var attackers = new List<GameAgent> { new(Id: 10, Position: new Vector2(0,0),   Goal: new Vector2(0,0),   Attack: 20f, Defense: 5f) };
var defenders = new List<GameAgent> { new(Id: 20, Position: new Vector2(0.5f,0), Goal: new Vector2(0.5f,0), Attack: 10f, Defense: 5f, Health: 50f, MaxHealth: 50f) };

var engagement   = world.CreateEngagementService();
var engageResult = await engagement.ResolveAsync(attackers, defenders, radius: 2f, ct: default);
// engageResult.Outcomes.Length => 2 (bidirectional: both sides attack each tick)
// engageResult.UpdatedDefenders[0].Health => 32.5 (damage already applied)

var outcomesOnly = await engagement.ResolveOutcomesAsync(attackers, defenders, radius: 2f, ct: default);
// outcomesOnly.Length => 2, no Health change on attackers/defenders themselves

// Custom resolver: you decide what happens per attacker/defender pair.
Func<GameAgent, GameAgent, float, IGameMath, IEnumerable<EngagementOutcome>> customResolver =
    (a, d, dist, math) => new[] { EngagementOutcome.Miss(a.Id, d.Id) };
var customResult = await engagement.ResolveAsync(attackers, defenders, radius: 2f, customResolver, ct: default);
// customResult.Outcomes[0].Kind => Miss

// Custom math (tuning the built-in formulas, or implement IGameMath yourself for different mechanics).
var customMath = new GameMath(new GameMathOptions(BaseHitChance: 0.8f, CritChance: 0.15f));
Console.WriteLine(customMath.HitChance(20, 5, 1, 5));  // => 0.95
Console.WriteLine(customMath.DamageRoll(20, 5));       // => 17.50
Console.WriteLine(customMath.CooldownTicks(1f));       // => 10

var engagementWithMath = world.CreateEngagementService(gameMath: customMath);

Per-agent MaxAttacksPerTick budget: overflow hits beyond the cap are demoted to Miss, never dropped — total outcome count is unchanged either way. AttackLayerMask (a MovementLayer combination) restricts which defender layers an attacker can reach; the default All preserves "everyone can attack everyone in range" behavior.

14. Steering behaviors and the Navigator

INavigatorService composes pluggable INavigationBehaviors with pathfinding: each tick, every behavior scores itself, the highest score wins, its goal is evaluated, then the winning goal is pathfound. Build one via the fluent NavigatorBuilder.

Member Purpose
world.CreateNavigator(key?) Start a NavigatorBuilder.
.WithMesh(NavMeshHandle \| HierarchicalNavMeshHandle) Required. The hierarchical overload pathfinds on .Combined.
.WithBehavior(INavigationBehavior) / .WithBehavior<T>() Add a behavior instance or by type (parameterless ctor). Defaults to SeekBehavior if none added.
.WithStrategy(INavigationStrategy) / .WithStrategy<T>() Set stuck-detection/fallback strategy. Defaults to DefaultNavigationStrategy.
.WithCongestion(ICongestionService?) Enable congestion data for QueueBehavior/FlowLaneBehavior.
.Build() Produces the INavigatorService. Throws InvalidOperationException if no mesh was set.
INavigatorService.TickAsync(agents, tickNumber, ct) Score → evaluate → pathfind, once per tick.
SeekBehavior Baseline: pathfind straight to the agent's goal. Always scores 1.0.
WanderBehavior(radius=3, seed?) Random walkable point within radius; ambient/idle movement.
PatrolBehavior(waypoints, arrivalDistance=0.5) Cycles through waypoints in order.
FleeBehavior(fleeRadius=5) Moves away from the nearest agent within range; scores 2.0 when threatened.
QueueBehavior(congestionThreshold=4, queueSpacing=1.2, lookAheadDistance=3) Orderly queueing when congestion is detected ahead; scores 1.5.
FlowLaneBehavior(laneOffset=0.6, opposingFlowThreshold=0.3, minDensity=2) Right-hand lane offset under opposing-flow congestion; scores 1.3.
DefaultNavigationStrategy(stuckThreshold=5) Stuck after N consecutive path failures; fallback is Retry.
INavigationStrategy / FallbackAction.Retry/.Stop/.Wander Implement for custom stuck-handling.
NavigationContext(Mesh, TickNumber, Pressure, NearbyAgents, Congestion?) What a behavior sees each tick.
BehaviorResult(Goal, Priority, ForceRepath=false) What a behavior returns.
NavigationResult(Paths, Pressure, ElapsedMs) / NavigationPathResult(AgentId, Waypoints, PathFound, ActiveBehavior, ...) Tick output, including which behavior won per agent.
var navigator = world.CreateNavigator()
    .WithMesh(mesh)
    .WithBehavior(new SeekBehavior())
    .WithBehavior(new WanderBehavior(radius: 2f, seed: 42))
    .WithBehavior(new PatrolBehavior(new List<Vector2> { new(0,0), new(4,4) }, arrivalDistance: 0.5f))
    .WithBehavior(new FleeBehavior(fleeRadius: 5f))
    .WithBehavior(new QueueBehavior())
    .WithBehavior(new FlowLaneBehavior())
    .WithStrategy(new DefaultNavigationStrategy(stuckThreshold: 5))
    .WithCongestion()
    .Build();

var navResult = await navigator.TickAsync(agents, tickNumber: 0);
// navResult.Paths.Length => 1, navResult.Paths[0].ActiveBehavior => "Seek"

Stuck handling: every tick, NavigatorService asks your INavigationStrategy IsStuck(agent, lastPath, consecutiveFailures); if true, OnPathFailed picks the fallback — Retry (default) leaves the goal alone, Stop zeroes Waypoints/PathFound and relabels ActiveBehavior to "Stopped", Wander re-paths to an internally sampled nearby point and relabels ActiveBehavior to "Wander (fallback)".

15. Grid tactics (bake-free tile lane)

Tracer.Helpers.GridTactics is a separate, pure/static lane for tile or square-grid tactics games that don't want a navmesh at all. The grid is supplied as predicates (passable/blocksSight), so it works with any tilemap representation.

Member Purpose
LineOfSight(x0, y0, x1, y1, blocksSight) Bresenham tile LOS; endpoints are never tested.
Reachable(x0, y0, maxSteps, passable, diagonals=false) BFS movement-range flood; returns a cost dictionary keyed by tile.
InCover(x, y, blocksSight, diagonals=false) true if adjacent to a sight-blocking tile.
CoverFrom(x, y, threatX, threatY, blocksSight) Directional cover toward a specific threat.
NearestInRange<T>(x, y, targets, position, range, blocksSight, include?) Nearest Chebyshev-in-range target with clear LOS, as an index (-1 if none).
bool[,] walls = new bool[10, 10];
walls[5, 5] = true;
bool BlocksSight(int x, int y) => x is >= 0 and < 10 && y is >= 0 and < 10 && walls[x, y];
bool Passable(int x, int y)    => x is >= 0 and < 10 && y is >= 0 and < 10 && !walls[x, y];

bool sees  = GridTactics.LineOfSight(0, 0, 9, 9, BlocksSight);         // => False (wall at (5,5) blocks it)
var reach  = GridTactics.Reachable(0, 0, maxSteps: 3, Passable);       // => 10 reachable tiles (incl. start)
bool cover = GridTactics.InCover(4, 5, BlocksSight);                   // => True (adjacent to the wall)

var gridTargets = new List<(int X, int Y)> { (3, 3), (6, 6) };
int nearest = GridTactics.NearestInRange(0, 0, gridTargets, t => t, range: 10, BlocksSight);
// nearest => 0 (index of (3,3))

Use this instead of ILineOfSightService/ISingleAgentService for tile/square-grid games — no bake step needed at all.

16. Population helpers

Small standalone utilities for common swarm-management problems; none require a PathfindingWorld.

Member Purpose
AgentBudget.Allocate(pressure, currentCount, maxCount, morale) Despawn sizing: how many agents to keep given load and morale.
GoalSpreading.Spread(agents, availableGoals, options?) Nearest-unassigned agent-to-goal matching. GoalSpreadingOptions(AllowReuse=false).
SpawnPool.GetSpawnPoints(candidates, count, options?) Greedy min-spacing pick of spawn points. SpawnPoolOptions(MinSpacing=1).
int keep = AgentBudget.Allocate(pressure: 0.8f, currentCount: 500, maxCount: 500, morale: 0.5f);
// keep => 160

var spread = GoalSpreading.Spread(
    agents, new List<Vector2> { new(4, 4), new(10, 10) }, new GoalSpreadingOptions(AllowReuse: false));
// spread.Length => 1, one (AgentId, Goal) pair per input agent that got a goal

var spawnPoints = SpawnPool.GetSpawnPoints(
    new List<Vector2> { new(0, 0), new(0.1f, 0), new(5, 5) }, count: 2, new SpawnPoolOptions(MinSpacing: 1f));
// spawnPoints.Length => 2 (the two candidates close together collapse to one pick)

17. Dependency injection

Tracer.Extensions.ServiceCollectionExtensions.AddTracer registers Tracer with Microsoft.Extensions.DependencyInjection.

Member Purpose
services.AddTracer(configure?) Registers PathfindingWorld plus 5 singletons: IMultiAgentService, INavMeshBakerService, ISingleAgentService, IDynamicObstacleService, IInfluenceMapService.
TracerOptions.LicenseKey / .TunerStatePath Configuration passed through to the underlying PathfindingWorld.
using Microsoft.Extensions.DependencyInjection;
using Tracer.Extensions;

var services = new ServiceCollection();
services.AddTracer(options => { options.TunerStatePath = "C:/game/nav"; });
var provider = services.BuildServiceProvider();

var diWorld = provider.GetRequiredService<PathfindingWorld>();
var diMulti = provider.GetRequiredService<IMultiAgentService>();
// diWorld.LicenseMode => Unlicensed

Adaptive pathfinding, engagement, LOS, congestion, fog-of-war, simulation, and the navigator are not DI-registered — resolve PathfindingWorld from the container and call its Create* methods directly for those.

18. EvalApp pipeline integration

Tracer.Integration plugs a PathfindingWorld and its services into an EvalApp pipeline as a shared global context, for consumers already building their game loop on EvalApp.

Member Purpose
builder.WithTracer(world) Attaches a TracerContext carrying just the World.
builder.WithEngagement(world, service) Same, plus EngagementService.
builder.WithAdaptive(world, service) Same, plus AdaptiveService.
builder.WithSimulation(world, service) Same, plus SimulationService.
TracerContext.World / .EngagementService / .AdaptiveService / .SimulationService The context's exposed properties (GlobalContext subclass).
using EvalApp;
using Tracer.Integration;

var appBuilder = Eval.App("my-game").WithTracer(world);

WithContext takes one object, and WithTracer(world) is shorthand that sets that object to a TracerContext. When the app needs Tracer and something else (a database service, a clock), make the context object your own and let it hold each of them, instead of calling a second With... extension (the last call would replace the first) or building one app per concern:

using System.Numerics;
using EvalApp;
using Tracer;
using Tracer.Abstractions;
using Tracer.Integration;
using Tracer.Models;

var world = new PathfindingWorld();
var vertices  = new List<Vector2> { new(0, 0), new(4, 0), new(4, 4), new(0, 4) };
var triangles = new List<(int A, int B, int C)> { (0, 1, 2), (0, 2, 3) };
var mesh = await world.CreateNavMeshBakerService().BakeAsync(vertices, triangles);

// Your context holds the Tracer pieces and any other shared state the steps need.
var game = new GameContext(new TracerContext(world), world.CreateMultiAgentService());

Eval.App("Game")
    .WithContext(game)
    .DefineDomain("Nav", new NavDomain())
        .DefineTask<SwarmTick>("Swarm")
            .AddStep<PathSwarm>("PathSwarm")
        .Run(out ICompiledPipeline<SwarmTick> pipeline)
    .Build();

var agents = new List<AgentDto> { new(Id: 1, Position: new Vector2(0, 0), Goal: new Vector2(4, 4), GoalChanged: true) };
var result = await pipeline.RunAsync(new SwarmTick(agents, mesh, 1));
Console.WriteLine(result.GetData().Found);    // => 1

public sealed record SwarmTick(IReadOnlyList<AgentDto> Agents, NavMeshHandle Mesh, int Number, int Found = 0);

public sealed class GameContext(TracerContext tracer, IMultiAgentService multi) : GlobalContext
{
    public TracerContext Tracer { get; } = tracer;
    public IMultiAgentService Multi { get; } = multi;
}

public sealed class NavDomain : DomainContext { }

public sealed class PathSwarm : ContextPureStep<GameContext, NavDomain, SwarmTick>
{
    protected override async ValueTask<SwarmTick> TransformAsync(
        SwarmTick t, GameContext app, NavDomain domain, CancellationToken ct)
    {
        var r = await app.Multi.TickAsync(t.Agents, t.Mesh, t.Number, null, ct);
        return t with { Found = r.Paths.Count(p => p.PathFound) };
    }
}

Tracer itself does not use raw Parallel, Task.Run or threads: every heavy stage is an EvalApp ForEach with adaptive parallelism, so there is no hand-rolled fan-out for a caller to replace.

License

Proprietary — this package ships the compiled library only; see LICENSE for the full terms. Installing and using unmodified copies for internal evaluation and non-commercial development or testing is licensed; commercial, production, or revenue-generating use requires a separate commercial licence from the owner. No feature on this page is gated behind a tier or a licence key — every capability described works identically with or without one. Copyright (c) 2026 Dongyang Stephen Chen, trading as Evaluated Applications. All rights reserved.

PathfindingWorld's optional licenseKey constructor parameter exists but has no effect on which capabilities are available — see Core concepts and gotchas above.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.1 59 11/10/2026
3.0.0 84 11/9/2026
2.0.5 85 11/9/2026
2.0.4 129 10/19/2026
2.0.3 107 10/15/2026
2.0.2 113 10/3/2026
2.0.1 109 10/3/2026
2.0.0 124 10/2/2026
1.2.1 112 10/2/2026
1.2.0 131 9/4/2026
1.1.2 128 8/26/2026
1.1.1 125 8/25/2026
1.1.0 215 8/9/2026
1.0.1 141 7/29/2026
1.0.0 143 7/25/2026