2D Platformer Pathfinding

I scrapped my Navigation nodes and used A* mapping for platformer pathfinding.

I found this great tutorial by TheSolarString and finally made an A* level map, instead of relying on Navigation nodes. The code I copied from the tutorial was in C# but I tried my best to translate it to GDScript. Here’s a look at how my A* level map turned out.


I’m using this asset pack by Pixel Frog from itch.io. The dots are PackedScene instances of a Sprite2D, being modulated to indicate their properties (left edge, right edge, left wall, right wall, fall point). The lines are the connections. The red lines are unidirectional A* path connections for falling from tiles not reachable by jump height. I am so happy about how this turned out and would love to share the translated code of the A* Map Generator. Keep in mind, though, that the TileMapLayer this script is connected to should only contain the walkable tiles, and all other tiles in it should be empty (the trees you see in mine are on a different TileMapLayer, except for the foreground treetops which are in the same TileMapLayer node as the ground tiles).

Here’s the code for the A* Map Generator in GDScript:

@tool
extends TileMapLayer

class PointInfo:
	var IsFallTile := false
	var IsLeftEdge := false
	var IsRightEdge := false
	var IsLeftWall := false
	var IsRightWall := false
	var IsPositionPoint := false
	var PointID: int
	var Position: Vector2
	
	func _init(pointID : int, _position : Vector2):
		PointID = pointID
		Position = _position
		pass
	
	func to_dict() -> Dictionary:
		return {
			"IsFallTile": IsFallTile,
			"IsLeftEdge": IsLeftEdge,
			"IsRightEdge": IsRightEdge,
			"IsLeftWall": IsLeftWall,
			"IsRightWall": IsRightWall,
			"IsPositionPoint": IsPositionPoint,
			"PointID": PointID,
			"Position": [Position.x, Position.y] # Serialize Vector2 as array
		}

@export_category("Navigation")
@export var ShowDebugGraph = true
@export var JumpDistance := 3
@export var JumpHeight := 2

const CELL_IS_EMPTY = -1
const MAX_TILE_FALL_SCAN_DEPTH = 500

var _astarGraph := AStar2D.new()
var _usedTiles : Array[Vector2i]
var _graphPoint : PackedScene
var _pointInfoList : Array[PointInfo]

func _ready():
	#if Engine.is_editor_hint():
	_graphPoint = preload("res://scenes/levels/graph_point.tscn")
	_usedTiles = get_used_cells()
	BuildGraph()

func _draw():
	if ShowDebugGraph:
		ConnectPoints()

func BuildGraph() -> void:
	AddGraphPoints()

func DrawDebugLine(to : Vector2, from : Vector2, color : Color) -> void:
	if ShowDebugGraph:
		draw_line(to, from, color)

func AddGraphPoints() -> void:
	for tile in _usedTiles:
		AddLeftEdgePoint(tile)
		AddRightEdgePoint(tile)
		AddLeftWallPoint(tile)
		AddRightWallPoint(tile)
	for tile in _usedTiles:
		AddFallPoint(tile)

func TileAlreadyExistInGraph(tile: Vector2i) -> int:
	var localPos = map_to_local(tile)
	if _astarGraph.get_point_count() > 0:
		var pointId = _astarGraph.get_closest_point(localPos)
		
		if _astarGraph.get_point_position(pointId) == localPos:
			return pointId
	return -1

func AddVisualPoint(tile: Vector2i, color = null, _scale: float = 1.0) -> void:
	if not ShowDebugGraph: return
	
	var visualPoint : Sprite2D = _graphPoint.instantiate() as Sprite2D
	
	if color:
		visualPoint.modulate = color
	
	if _scale != 1.0 and _scale > 0.1:
		visualPoint.scale = Vector2(_scale, _scale)
	
	visualPoint.position = map_to_local(tile)
	add_child(visualPoint)

func GetPointInfo(tile: Vector2i) -> PointInfo:
	for pointInfo in _pointInfoList:
		if pointInfo.Position == map_to_local(tile):
			return pointInfo
	return null

# ================================= Connect Graph Points =============================== #

func ConnectPoints():
	for p1 in _pointInfoList:
		ConnectHorizontalPoints(p1)
		ConnectJumpPoints(p1)
		ConnectFallPoints(p1)

func ConnectFallPoints(p1 : PointInfo):
	if p1.IsLeftEdge || p1.IsRightEdge:
		var tilePos = local_to_map(p1.Position)
		tilePos.y += 1
		
		var fallPoint = FindFallPoint(tilePos)
		if fallPoint:
			var pointInfo = GetPointInfo(fallPoint)
			var p1Map := local_to_map(p1.Position)
			var p2Map := local_to_map(pointInfo.Position)
			
			if p1Map.distance_to(p2Map) <= JumpHeight:
				_astarGraph.connect_points(p1.PointID, pointInfo.PointID)
				DrawDebugLine(p1.Position, pointInfo.Position, Color.YELLOW)
			else:
				_astarGraph.connect_points(p1.PointID, pointInfo.PointID, false)
				DrawDebugLine(p1.Position, pointInfo.Position, Color.RED)

func ConnectJumpPoints(p1 : PointInfo):
	for p2 in _pointInfoList:
		ConnectHorizontalPlatformJumps(p1, p2)
		ConnectDiagonalJumpRightEdgeToLeftEdge(p1, p2)
		ConnectDiagonalJumpLeftEdgeToRightEdge(p1, p2)
	pass

func ConnectDiagonalJumpRightEdgeToLeftEdge(p1 : PointInfo, p2 : PointInfo):
	if p1.IsRightEdge:
		var p1Map := local_to_map(p1.Position)
		var p2Map := local_to_map(p2.Position)
		
		if p2.IsLeftEdge \
			&& p2.Position.x > p1.Position.x \
			&& p2.Position.y > p1.Position.y \
			&& abs(p2Map.y - p1Map.y) < JumpHeight \
			&& abs(p2Map.x - p1Map.x) < JumpDistance:
			
			_astarGraph.connect_points(p1.PointID, p2.PointID)
			DrawDebugLine(p1.Position, p2.Position, Color.YELLOW)

func ConnectDiagonalJumpLeftEdgeToRightEdge(p1 : PointInfo, p2 : PointInfo):
	if p1.IsLeftEdge:
		var p1Map := local_to_map(p1.Position)
		var p2Map := local_to_map(p2.Position)
		
		if p2.IsRightEdge \
			&& p2.Position.x < p1.Position.x \
			&& p2.Position.y > p1.Position.y \
			&& abs(p2Map.y - p1Map.y) < JumpHeight \
			&& abs(p2Map.x - p1Map.x) < JumpDistance:
			
			_astarGraph.connect_points(p1.PointID, p2.PointID)
			DrawDebugLine(p1.Position, p2.Position, Color.YELLOW)

func ConnectHorizontalPlatformJumps(p1 : PointInfo, p2 : PointInfo):
	if p1.PointID == p2.PointID:
		return
	
	if p2.Position.y == p1.Position.y && p1.IsRightEdge && p2.IsLeftEdge:
		if p2.Position.x > p1.Position.x:
			var p1Map = local_to_map(p1.Position)
			var p2Map = local_to_map(p2.Position)
			
			if abs(p2Map.x - p1Map.x) < JumpDistance:
				_astarGraph.connect_points(p1.PointID, p2.PointID)
				DrawDebugLine(p1.Position, p2.Position, Color.ORANGE)

func ConnectHorizontalPoints(p1 : PointInfo):
	if p1.IsLeftEdge || p1.IsLeftWall || p1.IsFallTile:
		var closest : PointInfo = null
		for p2 in _pointInfoList:
			if p1.PointID == p2.PointID:
				continue
			if (p2.IsRightEdge || p2.IsRightWall || p2.IsFallTile) && p2.Position.y == p1.Position.y && p2.Position.x > p1.Position.x:
				if not closest:
					closest = PointInfo.new(p2.PointID, p2.Position)
				if p2.Position.x < closest.Position.x:
					closest.Position = p2.Position
					closest.PointID = p2.PointID
		
		if closest:
			if not HorizontalConnectionCannotBeMade(p1.Position, closest.Position):
				_astarGraph.connect_points(p1.PointID, closest.PointID)
				DrawDebugLine(p1.Position, closest.Position, Color.GREEN)

func HorizontalConnectionCannotBeMade(p1 : Vector2, p2 : Vector2) -> bool:
	var startScan : Vector2i = local_to_map(p1)
	var endScan : Vector2i = local_to_map(p2)
	
	for i in range(startScan.x, endScan.x):
		if get_cell_source_id(Vector2i(i, startScan.y)) != CELL_IS_EMPTY || get_cell_source_id(Vector2i(i, startScan.y + 1)) == CELL_IS_EMPTY:
			return true
	return false

# ====================================================================================== #

# =================================== Tile Fall Points ================================= #
func GetStartScanTileForFallPoint(tile: Vector2i):
	var tileAbove = Vector2i(tile.x, tile.y - 1)
	var point = GetPointInfo(tileAbove)
	
	if point == null: return null
	
	var tileScan = Vector2i.ZERO
	
	if point.IsLeftEdge:
		tileScan = Vector2i(tile.x - 1, tile.y - 1)
		return tileScan
	elif point.IsRightEdge:
		tileScan = Vector2i(tile.x + 1, tile.y - 1)
		return tileScan
	return null
# ====================================================================================== #

func FindFallPoint(tile: Vector2i):
	var scan = GetStartScanTileForFallPoint(tile)
	if scan == null: return null
	
	var tileScan = Vector2i(scan)
	var fallTile : Vector2i
	
	for i in range(1, MAX_TILE_FALL_SCAN_DEPTH + 1):
		if get_cell_source_id(Vector2i(tileScan.x, tileScan.y + 1)) != CELL_IS_EMPTY:
			fallTile = tileScan
			break
		tileScan.y += 1
	return fallTile

func AddFallPoint(tile: Vector2i):
	var fallTile = FindFallPoint(tile)
	if fallTile == null: return
	var fallTileLocal = Vector2i(map_to_local(fallTile))
	var existingPointId = TileAlreadyExistInGraph(fallTile)
		
	if existingPointId == -1:
		var pointId = _astarGraph.get_available_point_id()
		var pointInfo = PointInfo.new(pointId, fallTileLocal)
		pointInfo.IsFallTile = true
		_pointInfoList.append(pointInfo)
		_astarGraph.add_point(pointId, fallTileLocal)
		AddVisualPoint(fallTile, Color.ORANGE, 0.5)
	else:
		single(_pointInfoList, func(p): return p.PointID == existingPointId).IsFallTile = true
		AddVisualPoint(fallTile, Color.ORANGE, 0.4)

# ================================== Tile Edge & Wall Graph Points ===================== #
func AddLeftEdgePoint(tile: Vector2i) -> void:
	if TileAboveExists(tile):
		return
	if get_cell_source_id(Vector2i(tile.x - 1, tile.y)) == CELL_IS_EMPTY:
		var tileAbove = Vector2i(tile.x, tile.y - 1)
		var existingPointId = TileAlreadyExistInGraph(tileAbove)
		
		if existingPointId == -1:
			var pointId = _astarGraph.get_available_point_id()
			var pointInfo = PointInfo.new(pointId, Vector2i(map_to_local(tileAbove)))
			pointInfo.IsLeftEdge = true
			_pointInfoList.append(pointInfo)
			_astarGraph.add_point(pointId, Vector2i(map_to_local(tileAbove)))
			AddVisualPoint(tileAbove, Color.YELLOW)
		else:
			single(_pointInfoList, func(p): return p.PointID == existingPointId).IsLeftEdge = true
			AddVisualPoint(tileAbove, Color.BLUE, 0.75)

func AddRightEdgePoint(tile: Vector2i) -> void:
	if TileAboveExists(tile):
		return
	if get_cell_source_id(Vector2i(tile.x + 1, tile.y)) == CELL_IS_EMPTY:
		var tileAbove = Vector2i(tile.x, tile.y - 1)
		var existingPointId = TileAlreadyExistInGraph(tileAbove)
		
		if existingPointId == -1:
			var pointId = _astarGraph.get_available_point_id()
			var pointInfo = PointInfo.new(pointId, Vector2i(map_to_local(tileAbove)))
			pointInfo.IsRightEdge = true
			_pointInfoList.append(pointInfo)
			_astarGraph.add_point(pointId, Vector2i(map_to_local(tileAbove)))
			AddVisualPoint(tileAbove, Color.LIGHT_GRAY)
		else:
			single(_pointInfoList, func(p): return p.PointID == existingPointId).IsRightEdge = true
			AddVisualPoint(tileAbove, Color.BLUE, 0.75)

func AddLeftWallPoint(tile: Vector2i) -> void:
	if TileAboveExists(tile):
		return
	if get_cell_source_id(Vector2i(tile.x - 1, tile.y - 1)) != CELL_IS_EMPTY:
		var tileAbove = Vector2i(tile.x, tile.y - 1)
		var existingPointId = TileAlreadyExistInGraph(tileAbove)
		
		if existingPointId == -1:
			var pointId = _astarGraph.get_available_point_id()
			var pointInfo = PointInfo.new(pointId, Vector2i(map_to_local(tileAbove)))
			pointInfo.IsLeftWall = true
			_pointInfoList.append(pointInfo)
			_astarGraph.add_point(pointId, Vector2i(map_to_local(tileAbove)))
			AddVisualPoint(tileAbove, Color.DARK_RED)
		else:
			single(_pointInfoList, func(p): return p.PointID == existingPointId).IsLeftWall = true
			AddVisualPoint(tileAbove, Color.BLUE, 0.75)

func AddRightWallPoint(tile: Vector2i) -> void:
	if TileAboveExists(tile):
		return
	if get_cell_source_id(Vector2i(tile.x + 1, tile.y - 1)) != CELL_IS_EMPTY:
		var tileAbove = Vector2i(tile.x, tile.y - 1)
		var existingPointId = TileAlreadyExistInGraph(tileAbove)
		
		if existingPointId == -1:
			var pointId = _astarGraph.get_available_point_id()
			var pointInfo = PointInfo.new(pointId, Vector2i(map_to_local(tileAbove)))
			pointInfo.IsRightWall = true
			_pointInfoList.append(pointInfo)
			_astarGraph.add_point(pointId, Vector2i(map_to_local(tileAbove)))
			AddVisualPoint(tileAbove, Color.DARK_RED)
		else:
			single(_pointInfoList, func(p): return p.PointID == existingPointId).IsRightWall = true
			AddVisualPoint(tileAbove, Color.BLUE, 0.75)

func TileAboveExists(tile: Vector2i) -> bool:
	if get_cell_source_id(Vector2i(tile.x, tile.y - 1)) == CELL_IS_EMPTY:
		return false
	
	return true
# ====================================================================================== #

# ======== Helpers ======== #
func single(array: Array, predicate: Callable) -> PointInfo:
	var result = null
	var found := false
	for element in array:
		if predicate.call(element):
			if found:
				push_error("single(): more than one matching element.")
				return null
			result = element
			found = true
	if not found:
		push_error("single(): no matching element.")
		return null
	return result
# ======================== #

P.S. I don’t know much about how to use tool scripts and I can’t seem to make this one auto update when I modify the TileMap. Feel free to modify this code as you see fit! (And also point out errors if there are any)

3 Likes

This is really an interesting solution. I tried doing something like this a while ago, but I ended up with a solution that required a lot of manual setup so I scraped it.

Are the nodes autogenerated?

If you mean those colored points, then yes they are autogenerated by the code. Those are just instances of a Sprite2D scene with a circle sprite. Even the connections between them are handled by the code. The points are stored in the _pointInfoList array and the A* map is stored in the _astarGraph variable.

2 Likes

This is really good. I need to try it out in my game.

2 Likes

@mrdicerack I made some changes to make it update in the editor with export_tool_button.

@export_tool_button("Rebuild Graph") var rebuild_graph_action = rebuild_graph

func _ready():
	_graphPoint = preload("graph point scene")
	BuildGraph()

func rebuild_graph():
	_usedTiles.clear()
	_pointInfoList.clear()
	_astarGraph.clear()
	
	##Remove all visual points
	for child in get_children():
		remove_child(child)
		child.queue_free()
		
	BuildGraph()
	queue_redraw()

	print("Graph Rebuilt!")

func BuildGraph() -> void:
	_usedTiles = get_used_cells()
	AddGraphPoints()

Once you make a change to your tilemap, just click on the rebuild graph button and it will redraw everything.

Note that if you add a script to your graph_point scene, you also need to make it a tol script.

image

2 Likes

Curious what’s wrong or differences with the A* implementation in Godot navigation versus the one you rolled yourself?
Cheers !

1 Like

Thanks for this! I really didn’t know how to auto update it except for reopening the scene. I’m still learning tool scripts atm and I think they’re fun!

1 Like

I initially used a Navigation Layer added in the TileSet but it required to have NavigationLink2D nodes for ledges and edge to edge jumps, which I found very tiresome. Using a baked Navigation Mesh from your TileMap is a bit processor heavy especially on large levels. It works for the bare minimum, but it has a lot of unused navigation space.
This implementation, as others have also said in reddit, is the more appropriate way of doing path finding in platformer games. It is still the same A* navigation that Godot uses, except the points and connections the A* use come from that script, where it basically assigns points only to critical areas on the map. This saves a lot of processing power, and also more accurately shows how each point connects with another (e.g. A left edge connected to a right edge means a possible jump). At least that’s what I learned from the tutorial. I’m still figuring out how to make the auto navigations more smooth.

1 Like