Home

Awesome

quasilyte/pathing

Build Status PkgGoDev

A very fast & zero-allocation, grid-based, pathfinding library for Go.

Overview

This library has several things that make it much faster than the alternatives. Some of them are fundamental, and some of them are just a side-effect of the goal I was pursuing for myself.

Some of the limitations you may want to know about before using this library:

  1. Its max path length per BuildPath() is limited
  2. Only 4 tile kinds per Grid are supported

Both of these limitations can be worked around:

  1. Connect the partial results to traverse a bigger map
  2. Use different "layers" for different biomes

To learn more about this library and its internals, see this presentation.

When to use this library?

If you answer "yes" to both, consider using this library.

Some games that use this library:

Quick Start

$ go get github.com/quasilyte/pathing

This is a simplified example. See the full example if you want to learn more.

func main() {
	// Grid is a "map" that stores cell info.
	const cellSize = 40
	g := pathing.NewGrid(pathing.GridConfig{
		// A 5x4 map.
		WorldWidth:  5 * cellSize,
		WorldHeight: 4 * cellSize,
		CellWidth:   cellSize,
		CellHeight:  cellSize,
	})

	// We'll use Greedy BFS pathfinder (A* is also available).
	bfs := pathing.NewGreedyBFS(pathing.GreedyBFSConfig{})

	// Tile kinds are needed to interpret the cell values.
	// Let's define some.
	const (
		tilePlain = iota
		tileForest
		tileMountain
	)

	// Grid map cells contain "tile tags"; these are basically
	// a tile enum values that should fit the 2 bits (max 4 tags per Grid).
	// The default tag is 0 (tilePlain).
	// Let's add some forests and mountains.
	//
	// The result map layout will look like this:
	// . . m . . | [m] - mountain
	// . . f . . | [f] - forest
	// . . f . . | [.] - plain
	// . . . . .
	g.SetCellTile(pathing.GridCoord{X: 2, Y: 0}, tileMountain)
	g.SetCellTile(pathing.GridCoord{X: 2, Y: 1}, tileForest)
	g.SetCellTile(pathing.GridCoord{X: 2, Y: 2}, tileForest)

	// Now we need to tell the pathfinding library how to interpret
	// these tiles. For instance, which tiles are passable and not.
	// We do that by using layers. I'll define two layers here
	// to show you how it's possible to interpret the grid differently
	// depending on the layer.
	normalLayer := pathing.MakeGridLayer([4]uint8{
		tilePlain:    1, // passable
		tileMountain: 0, // not passable
		tileForest:   0, // not passable
	})
	flyingLayer := pathing.MakeGridLayer([4]uint8{
		tilePlain:    1,
		tileMountain: 1,
		tileForest:   1,
	})

	// Our map with markers will look like this:
	// . . m . . | [m] - mountain
	// . A f B . | [f] - forest
	// . . f . . | [.] - plain
	// . . . . . | [A] - start, [B] - finish
	startPos := pathing.GridCoord{X: 1, Y: 1}
	finishPos := pathing.GridCoord{X: 3, Y: 1}

	// Let's build a normal path first, for a non-flying unit.
	p := bfs.BuildPath(g, startPos, finishPos, normalLayer)

	// The path reads as: Down, Down, Right, Right, Up, Up.
	fmt.Println(p.Steps.String(), "- normal layer path")

	// You can iterate the path.
	for p.Steps.HasNext() {
		fmt.Println("> step:", p.Steps.Next())
	}

	// A flying unit can go in a straight line.
	p = bfs.BuildPath(g, startPos, finishPos, flyingLayer)
	fmt.Println(p.Steps.String(), "- flying layer path")
}

Some terminology hints:

Note that it's possible to convert between the GridCoord and world positions via the Grid type API.

Greedy BFS paths quality

This library provides both greedy best-first search as well as A* algorithms.

You may be concerned about the Greedy BFS vs A* results. Due to a couple of tricks I used during the implementation, an unexpected thing happened: some of the paths are actually better than you would expect from a Greedy BFS.

<table> <tr> <td>A* (21 steps)</td> <td>Greedy BFS (27 steps)</td> <td>This library's BFS (21 steps)</td> <tr> <td> <img src="https://github.com/quasilyte/pathing/assets/6286655/ba657850-8321-4586-80bd-5e466fa3504c"> </td> <td> <img src="https://github.com/quasilyte/pathing/assets/6286655/bef9228a-2b0b-4f6d-a5a3-c676c96149e5"> </td> <td> <img src="https://github.com/quasilyte/pathing/assets/6286655/b1da357d-5a9c-40b2-a0d0-e8c6a4bbfdea"> </td> </tr> </table>

This library worked well enough for me even without A*. You still may want to use A* if you need to have different movement costs for tiles.

In general, A* always build an optimal path and can handle cost-based pathfinding. Greedy BFS requires less memory and works faster.

Benchmarks & Performance

See _bench folder to reproduce the results.

# If you're using Linux+Intel processor, consider doing this
# to reduce the noise and make your results more stable:
$ echo "1" | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo

# Running and analyzing the benchmarks:
$ cd _bench
$ go test -bench=. -benchmem -count=10 | results.txt
$ benchstat results.txt

Time - ns/op:

Libraryno_wallsimple_wallmulti_wall
quasilyte/pathing BFS3525635316927
quasilyte/pathing A*201403584644756
fzipp/astar94836715542901842812
beefsack/go-astar4539399393001032581
kelindar/tile107632 ns169613 ns182342 ns
s0rg/grid181603911541171189989
SolarLune/paths658875151586046114856

Allocations - allocs/op:

Libraryno_wallsimple_wallmulti_wall
quasilyte/pathing BFS000
quasilyte/pathing A*000
fzipp/astar200836773600
beefsack/go-astar52913471557
kelindar/tile333
s0rg/grid297619001759
SolarLune/paths719963687001

Allocations - bytes/op:

Libraryno_wallsimple_wallmulti_wall
quasilyte/pathing BFS000
quasilyte/pathing A*000
fzipp/astar337336511908722690
beefsack/go-astar4365393122130731
tile1231183295065763
s0rg/grid996889551976740523
SolarLune/paths235168194768230416

I hope that my contribution to this lineup will increase the competition, so we get better Go gamedev libraries in the future.

Some of my findings that can make these libraries faster:

If you want to learn more details, look at my library implementation and/or see these slides.