EvaluatedApplications.Tracer
2.0.2
See the version list below for details.
dotnet add package EvaluatedApplications.Tracer --version 2.0.2
NuGet\Install-Package EvaluatedApplications.Tracer -Version 2.0.2
<PackageReference Include="EvaluatedApplications.Tracer" Version="2.0.2" />
<PackageVersion Include="EvaluatedApplications.Tracer" Version="2.0.2" />
<PackageReference Include="EvaluatedApplications.Tracer" />
paket add EvaluatedApplications.Tracer --version 2.0.2
#r "nuget: EvaluatedApplications.Tracer, 2.0.2"
#:package EvaluatedApplications.Tracer@2.0.2
#addin nuget:?package=EvaluatedApplications.Tracer&version=2.0.2
#tool nuget:?package=EvaluatedApplications.Tracer&version=2.0.2
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
- Quick start
- Core concepts and gotchas
- PathfindingWorld — the root factory
- Navmesh baking
- Single-agent pathfinding
- Multi-agent swarm ticking
- Layered navmeshes (ground / air / water)
- Dynamic obstacles
- Adaptive path budgeting
- Population simulation (NavSim)
- Line-of-sight and vision
- Fog of war
- Influence maps
- Congestion analysis
- Combat engagement
- Steering behaviors and the Navigator
- Grid tactics (bake-free tile lane)
- Population helpers
- Dependency injection
- EvalApp pipeline integration
- 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.Vector2in whatever world-space units you baked the mesh with (meters, tiles-as-floats, pixels — your choice). Grid-tactics helpers (section 15) use plaininttile 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.NavMeshHandleis opaque; you never see the triangle list. - Unreachable / off-mesh targets never throw.
ISingleAgentService.QueryPathAsyncreturns an empty list;IMultiAgentService.TickAsyncreturnsPathResultDto.PathFound = falsewith emptyWaypoints. The onlyArgumentExceptions are on empty input toBakeAsync/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,ConflictRadiusare all world-space distances in your baking units. - Blocking vs excluding.
INavMeshBakerService.BakeAsync'sblockedZonesexcludes 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'snewBlockedZonesis always the full replacement set, not a delta — pass empty to unblock everything since the last bake orResetBase(). - Thread-safety.
NavMeshHandle/HierarchicalNavMeshHandle/LayeredNavMeshHandleare immutable — safe to read from any thread and to swap atomically between ticks. The stateful services returned byPathfindingWorld.Create*persist tuner state to disk per instance; two services sharing the sameserviceKey(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.TickAsynccaches guidance per goal, not per agent: 500 agents converging on one exit compute that guidance once, then every agent reuses it for free. This is why large swarms stay cheap; seeTickOptionsin section 4. - License. Proprietary — see the License section at the end of this page.
PathfindingWorld's optionallicenseKeyonly 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) — it is far cheaper.
| 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, so a crowd of 500
agents heading to the same point is nearly as cheap as pathing one.
| 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
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);
Gotcha: EvalApp.IAppBuilder.WithContext holds a single global context (last call
wins). If your app also calls WithContext for another package (e.g. a database SDK) on the
same builder, one context silently replaces the other. Build one Eval.App(...) per concern
if you need both.
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 | Versions 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. |
-
net10.0
- EvaluatedApplications.EvalApp (>= 3.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
-
net8.0
- EvaluatedApplications.EvalApp (>= 3.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
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 |