Find paths for units in a C# game with onegame’s Navigation package: a NavGrid from text, a one-shot FindPath as a level check, and a unit whose NavState searches a few iterations a tick and then follows the path, checked from a scenario with the same hash every run.
RecursiveAI.OneGame.Navigation (new in 1.11.0, stable since 1.14.0) finds paths on a grid for positions that are Fixed numbers. It gives the same answer on every node, keeps a unit’s navigation in one synced value, so saves, replays and replicas carry it, and spreads long searches over ticks. Everything below ran on onegame 1.15.2 from NuGet.
Add the package
Create the game with onegame new trail, delete art/ and the scenarios, and add the package:
dotnet add package RecursiveAI.OneGame.Navigation --version 1.15.2It isn’t part of the game template. A game that wants paths adds it, and the OnePath search library comes with it.
The game
A 12 by 7 level and a scout that walks to the cell it is told. Replace Game.cs:
using System; using System.Numerics; using OneGame.Core; using OneGame.Host; using OneGame.Navigation; using OneGame.UI; namespace Trail; /// <summary>The level: 12 by 7 cells of 16 world units, centered on the origin. Level data, built the same way on every node.</summary> public static class Level { public static readonly NavGrid Map = NavGrid.FromText(""" ............ .#####..###. .....#....#. .###.#.##.#. ...#.#..#... .#.#.####.#. .#........#. """, new NavSpace(new FVec2(-96, -56), Fixed.FromInt(16))); // top-left corner, cell size } [Entity] public partial class Scout : Entity, IView2D { [Synced] public partial FVec2 Position { get; private set; } [Synced] public partial NavState Nav { get; private set; } // A cache, not state: a restored or replicated scout rebuilds its search from Nav. private readonly NavSearcher _searcher = new(); private readonly NavDebugShapes _shapes = new(); [Command] public void Place(int x, int y) => (Position, Nav) = (Level.Map.CellCenter(new NavCell(x, y)), default); [Command] public void GoTo(int x, int y) => Nav = _searcher.Begin(Level.Map, Position, Level.Map.CellCenter(new NavCell(x, y)), NavOptions.Default); protected override void OnTick() { var nav = Nav; if (_searcher.Advance(ref nav, Level.Map, budget: 8)) { Nav = nav; } // 8 search iterations a tick if (nav.Status == NavStatus.Found) { var p = Position; Nav = nav.Follow(Level.Map, ref p, Fixed.FromInt(2)); // 2 units a tick; Arrived at the goal Position = p; } } [View] public void Draw(Draw2D d) { var map = Level.Map; for (var y = 0; y < map.Height; y++) { for (var x = 0; x < map.Width; x++) { var corner = map.CellCenter(new NavCell(x, y)); d.FillRect(new Rect(corner.X.ToSingle() - 8, corner.Y.ToSingle() - 8, 16, 16), map.IsBlocked(x, y) ? Color.Rgb(0x5B6B7C) : Color.Rgb(0x1D2733), 0, -1); } } _shapes.Clear(); _shapes.AddPath(map, Nav); // the path: walked part gray, the rest green foreach (var l in _shapes.Lines) { d.Line(l.From, l.To, Color.Rgba(l.Rgba), l.Width, 1); } foreach (var r in _shapes.Rects) { d.FillRect(new Rect(r.Position.X, r.Position.Y, r.Size.X, r.Size.Y), Color.Rgba(r.Rgba), 0, 1); } d.FillRect(new Rect(Position.X.ToSingle() - 5, Position.Y.ToSingle() - 5, 10, 10), Color.Rgb(0xF2C14E), 0, 2); } } public sealed class TrailGame : GameDefinition { public override string Name => "trail"; public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Trail.EntityTypes.Register(registry); public override void Setup(World world) { // A one-shot search as a level check: the far corner must be reachable from the near one. var route = Level.Map.FindPath(new NavCell(0, 0), new NavCell(11, 6), NavOptions.Default); if (!route.Found) { throw new InvalidOperationException($"level check: no way across ({route.Status})"); } world.Add(new Scout()); } public override void DefineScreens(ScreenSet screens) => screens.Add("main", game => Ui.Label(() => ((Scout)game.World.Entities[0]).Nav.Status.ToString()).Named("status").Padding(Length.Px(8)).TextColor(Color.White)); }
NavGrid.FromTextreads.as open,#as a wall and1to9as costed areas.NavSpaceplaces it in the world, andCellCenteris exact integer arithmetic.FindPathruns a whole search in the call and returns aNavPathwithStatus,CostandCells. Use it for tools and level checks, not for units every tick.NavStateis a synced value: the start, the goal, how far the search got, and the path.NavSearcher.Beginstarts a search,Advancespends at mostbudgetiterations of it per tick, andFollowwalks the path and turns at each waypoint. A search spends counted iterations, never time, so it is deterministic._searcheris a plain field. It holds no state the world needs.
Watch it search and walk
With the MCP client from Drive a running game over MCP, on onegame run --no-hot --headless -- --live /tmp/onegame/trail.sock:
command_send {"entity": 1, "command": "Place", "args": [0, 0]} -> {"entity":"Scout#1","command":"Place","status":"Queued"} command_send {"entity": 1, "command": "GoTo", "args": [11, 6]} -> {"entity":"Scout#1","command":"GoTo","status":"Queued"} step {"frames": 2} -> {"frame":2,"tick":2,"hash":"4dafd82e9ffc033f"} state_query {"entity": "Scout"} -> {"id":1,"type":"Scout","Position":[-88,-48],"Nav":{"status":"Searching","start":0,"goal":83,"spent":16,"next":0,"path":[]}} step {"frames": 10} -> {"frame":12,"tick":12,"hash":"e572ffb3e3296b18"} state_query {"entity": "Scout"} -> {"id":1,"type":"Scout","Position":[-72.000000008,-48],"Nav":{"status":"Found","start":0,"goal":83,"spent":0,"next":1,"path":[0,1,2,3,4,5,6,19,31,32,33,45,57,58,59,71,83]}} step {"frames": 400} -> {"frame":412,"tick":412,"hash":"d28f9866dc5c11a2"} state_query {"entity": "Scout"} -> {"id":1,"type":"Scout","Position":[88,48],"Nav":{"status":"Arrived","start":83,"goal":83,"spent":0,"next":17,"path":[0,1,2,3,4,5,6,19,31,32,33,45,57,58,59,71,83]}}
After two ticks the search had spent 16 iterations and was still going. By tick 12 it had found a 17-cell path, as cell indices (y * 12 + x), and the scout was walking. At tick 412 it stood on the center of cell (11, 6). A capture at tick 60 shows the walls in gray-blue, the walked part of the path in gray, the rest in green, and the scout as a yellow square going through the gap in the top wall.
Check where it arrives
scenarios/cross.yaml
name: cross description: The scout searches a few iterations a tick, then walks round the walls and arrives at the far corner. steps: - command: { entity: Scout, name: Place, args: [0, 0] } - command: { entity: Scout, name: GoTo, args: [11, 6] } - step: 2 - expect: state: { entity: Scout, property: Nav.status, equals: Searching } - step: 10 - expect: state: { entity: Scout, property: Nav.path.count, equals: 17 } - step: 400 expect: - state: { entity: Scout, property: Nav.status, equals: Arrived } - state: { entity: Scout, property: "Position[0]", equals: 88 } - state: { entity: Scout, property: "Position[1]", equals: 48 } - ui: { id: status, text: Arrived }
onegame test onegame test
PASS cross (38 ms, tick 412, hash d28f9866dc5c11a2) 1 passed, 0 failed PASS cross (39 ms, tick 412, hash d28f9866dc5c11a2) 1 passed, 0 failed
The same hash both times, and the same one the live session reached at tick 412. The search is part of the world, so a replay or a replica gets the same answer.
The level check
Close the gap next to the goal, making row 5 .#.#.####.##, and start the game:
onegame run --headless --frames 1error: level check: no way across (NoPath)It exits 4. Put the row back.
Good to know
- The budget changes when the path arrives, never which path it is. With
budget: 200the search finished within two ticks, and the scenario’s first check failed with “Scout.Nav.status is Found, expected Searching”. - When the map changes, a unit notices on its next tick:
NavStateremembers the grid’s content hash, and a stale state searches again from where the unit stands. Edit the map only from sim code, the same way on every node. The upstreamexamples/pathingowns its map in aTerrainentity with a synced list of walls. NavOptionssets the moves (Eightwithout corner cutting,Four,EightNoSqueeze), the unit’s width in cells, and for one-shot searchesAStar,JpsorThetaStar.- Positions must stay within ±2,097,152 world units. Outside that range the package throws
NavRangeExceptionrather than rounding. - For many units walking at once,
NavCrowdsteers a crowd round walls and each other, stepped once per tick as a table of the world.
More in Navigation.