Vav Labs
Back to blog

Godot pathfinding / 2026-07-16 / 16 min read

Verified as of Godot 4.7.1 stable

Pac-Man-Style Ghost AI in Godot: Build the Full Maze Chase

Build a Pac-Man-style maze chase in Godot with TileMapLayer, buffered grid movement, shared Dijkstra fields, ghost modes, tunnels, and reservations.

Maze Chase Starter running in Godot with an authored maze, player, four pursuers, pellets, mode state, and field-overlay control.

Interactive tutorial scene

Play the maze-chase starter in your browser

This is the exact small Godot scene taught below: one authored maze, buffered player turns, four target policies, chase/scatter/frightened modes, wrap tunnels, and next-cell reservations. Launch it before reading, then press F to expose the shared field.

Loads only after launch. Requires WebAssembly and WebGL 2.

HTML transcript

  1. The authored scene starts with one 21×17 maze, a player marker, four pursuers, pellets, four power pellets, two lives, and chase mode active.
  2. Arrow keys or WASD buffer a cardinal turn and apply it at the next legal committed cell.
  3. The four pursuers share movement code while hunter, ambusher, weaver, and drifter policies choose different target fields.
  4. Press F to toggle the player-centered field overlay and press R to restart the tutorial scene.

Fallback coverage

  • The poster is a real Chromium capture of the exact tutorial Web export.
  • The source ZIP contains the authored Godot 4.7.1 scene and all tutorial scripts.
  • The visible article includes the same movement, field, policy, reservation, tunnel, collectible, and contact code.
  • The source and Web receipts bind the tutorial package and browser export separately.

The direct answer

Build a Pac-Man-style maze chase in Godot around committed grid cells. Keep the maze topology in a TileMapLayer-backed board, store each actor's current cell and target cell, and interpolate only the visible Node2D position. Buffer the player's requested direction so the turn can be accepted before the next intersection.

For several pursuers chasing the same player, rebuild one player-centered Dijkstra field when the player commits to a new cell. Every direct pursuer can read that same field and choose one legal downhill neighbor. A different personality changes the target: a cell ahead of the player, a scatter corner, a target reflected around another pursuer, or a distance-dependent switch.

During ordinary movement, reject immediate reversal unless the pursuer reaches a dead end. Before the next pursuer chooses, reserve the target cell selected by the previous one. Treat a wrap tunnel as a real graph edge in both movement and field expansion.

A ghost needs a target policy, not a private pathfinder.

Build the tutorial in this order

The Web player above is the assembled tutorial result. Work through these layers in order so each step leaves the scene runnable before the next system is added.

If your painted TileMap import is the part you need to replace, use TileMap to Navigation Grid in Godot. For deeper field math, continue with Dijkstra Maps for Roguelike AI. This tutorial stays on the complete small game loop.

StepAddWorking result
1Author the maze boardWalkable cells, collectibles, spawn markers, cell centers, and wrap rows are inspectable in the editor.
2Add one grid actorThe committed cell stays authoritative while the visible position interpolates.
3Buffer player inputA requested turn is remembered and applied at the next legal intersection.
4Build the player-centered fieldEvery reachable maze cell has a downhill answer toward the player.
5Add four target policiesPursuers share movement code but produce direct, ambush, weave, and distance-switch behavior.
6Add chase, scatter, and frightened modesTarget fields, reversal rules, and contact outcomes change together.
7Add reservations, tunnels, pellets, and resetThe small maze chase is playable as one complete loop.

Use five small layers

The downloadable scene keeps these responsibilities in four scripts: maze_board.gd, grid_actor.gd, maze_rules.gd, and maze_chase_demo.gd.

Every code block below is an exact fragment from those packaged files. The complete project and verifier are in the download.

LayerOwnsDoes not own
Maze boardWalkable cells, wrap rows, collectibles, spawn markers, cell centersPursuer personality
Grid actorCurrent cell, target cell, direction, interpolationChoosing a destination
Field solverCost from every reachable cell to a targetChase/scatter timers
Pursuer policyWhich target or field one pursuer readsRendering or contact outcome
Game stateModes, score, lives, contact, reset, field invalidationLow-level neighbor relaxation

Keep the authored board readable

The starter uses a MazeBoard that extends TileMapLayer. Its original 21×17 maze lives in the scene as exported layout_text; the @tool board draws it in the editor before the game runs. This avoids imported art and keeps the topology inspectable in one file.

This is an asset-free teaching choice, not the only production workflow. If your maze is painted with TileSet custom data, replace parse_layout() with the normalized cell import from the TileMap-to-grid guide. Keep the rest of the movement and AI contracts unchanged.

The two cached values matter. A field scan asks for many neighbors; it should not recalculate the board width and wrap rows inside every neighbor lookup.

func _refresh_layout() -> void:
	rows = MazeRulesScript.parse_layout(layout_text)
	_grid_size_cache = MazeRulesScript.grid_size(rows)
	_wrap_rows_cache = MazeRulesScript.wrap_rows(rows)
	if tile_set == null:
		tile_set = TileSet.new()
	if tile_set != null:
		tile_set.tile_size = Vector2i(cell_size, cell_size)
	queue_redraw()

Keep the cell authoritative

Each GridActor stores cell, target_cell, direction, and is_moving. The visible position interpolates between centers. cell changes only after the move finishes:

The Boolean return is the commit signal. The game uses it to collect a pellet, rebuild player-dependent fields, and resolve contact once the new cell is real.

func advance(delta: float) -> bool:
	if not is_moving:
		return false
	_move_elapsed += delta
	var ratio := clampf(_move_elapsed / move_duration, 0.0, 1.0)
	position = _move_start.lerp(_move_target, ratio)
	if ratio < 1.0:
		return false
	cell = target_cell
	position = _board.cell_to_local_center(cell)
	is_moving = false
	return true

Buffer the player's next turn

Input actions update _buffered_direction. At each committed cell, the mover tries that request first and continues straight only when the requested turn is still blocked:

The exact keyboard, D-pad, swipe, or touch surface is separate. All of them should set the same buffered cardinal request.

func _update_player(delta: float) -> void:
	var committed := player.advance(delta)
	if committed:
		_collect_player_cell()
		_rebuild_player_field()
		_check_contacts()

	if player.is_moving:
		return

	if _can_move(player.cell, _buffered_direction):
		player.begin_move(board.neighbor(player.cell, _buffered_direction))
		return
	if _can_move(player.cell, player.direction):
		player.begin_move(board.neighbor(player.cell, player.direction))

Do I need AStarGrid2D for every pursuer?

AStarGrid2D is appropriate when one actor needs one point-to-point route. Several pursuers chasing the same player ask a different-shaped question: from every reachable maze cell, which neighbor leads toward this one target?

The AStarGrid2D complete reference covers the point-to-point API. This tutorial uses a shared field because it needs a next-step answer everywhere on a small maze.

SituationUse
One actor needs one complete route from A to BAStarGrid2D.get_id_path()
Several actors share the player as targetOne player-centered Dijkstra field
A policy targets ahead of the playerOne cached field seeded at the projected target
Each pursuer scatters toward a different cornerCache one field per active corner target
Frightened pursuers move away from the playerOne rescanned flee field shared by the group

Build one field from the target

The solver seeds the target at zero and expands outward through cardinal neighbors. A middle row can wrap because the same topology helper is used during expansion:

The full Dijkstra-map guide owns weighted fields, source bias, composed maps, flee-map theory, and INF-safe scoring. The starter keeps uniform cardinal cost so the integration remains visible.

static func build_field(
		rows: PackedStringArray,
		targets: Array[Vector2i]
) -> PackedInt32Array:
	var size := grid_size(rows)
	var field := PackedInt32Array()
	field.resize(size.x * size.y)
	field.fill(INF)
	var wrapping := _wrap_rows_fast(rows, size)
	var frontier := PackedInt32Array()
	var read_index := 0

	for target: Vector2i in targets:
		if not _is_walkable_fast(rows, target, size):
			continue
		var index := _index_of(target, size.x)
		if field[index] == 0:
			continue
		field[index] = 0
		frontier.append(index)

	while read_index < frontier.size():
		var current_index := frontier[read_index]
		read_index += 1
		var current := Vector2i(
				current_index % size.x, current_index / size.x)
		var next_cost := field[current_index] + 1
		for direction: Vector2i in CARDINALS:
			var next := _neighbor_fast(current, direction, size, wrapping)
			if not _is_walkable_fast(rows, next, size):
				continue
			var next_index := _index_of(next, size.x)
			if next_cost >= field[next_index]:
				continue
			field[next_index] = next_cost
			frontier.append(next_index)

	return field

Rebuild on committed player cells

The player field changes when the player enters a new cell. Policy fields depending on that player state are invalidated at the same boundary:

Do not rebuild because one pursuer advanced one cell. Reading the field does not invalidate it. The flee field is also absent during ordinary play; it is built only while frightened mode needs it.

func _rebuild_player_field() -> void:
	player_field = MazeRulesScript.build_field(board.rows, [player.cell])
	if _mode == MODE_FRIGHTENED:
		flee_field = MazeRulesScript.build_flee_field(
				board.rows, player_field)
	_field_cache.clear()
	board.set_field(player_field)

Personality comes from the target policy

The four pursuers share the same movement code. Their policy changes the field they read:

_field_for_target() normalizes a requested target to a legal cell and caches the result for the current player revision. A different personality does not require another pathfinding class.

RoleTarget policyResult
HunterPlayer's committed cellDirect pressure using the shared player field
AmbusherLegal cell four steps ahead of the playerCuts toward the player's current heading
WeaverProjected player target reflected around the hunterApproaches from another direction
DrifterPlayer field at range; scatter corner when closeAlternates pursuit and retreat
func _field_for_pursuer(pursuer: GridActor) -> PackedInt32Array:
	if _mode == MODE_SCATTER:
		return _field_for_target(_scatter_target(pursuer.role))

	match pursuer.role:
		"hunter":
			return player_field
		"ambusher":
			return _field_for_target(_projected_player_target(4))
		"weaver":
			var projected := _projected_player_target(2)
			var hunter_delta := projected - hunter.cell
			return _field_for_target(
					MazeRulesScript.nearest_walkable(
							board.rows,
							projected + hunter_delta,
							projected))
		"drifter":
			var distance := _manhattan(pursuer.cell, player.cell)
			return _field_for_target(_scatter_target(pursuer.role)) \
					if distance <= 5 else player_field
	return player_field

Make the game modes explicit

The starter alternates twelve seconds of chase with four seconds of scatter. A power pellet interrupts that schedule for six seconds of frightened mode:

A finished game may animate a captured pursuer back to a home cell through a separate returning state. This small starter teleports it to spawn so the tutorial stays on navigation and mode boundaries.

ModeFieldReversalContact
ChasePlayer or policy targetNormally rejectedPlayer loses a life
ScatterPer-pursuer corner targetNormally rejectedPlayer loses a life
FrightenedShared rescanned flee fieldDead-end fallback still appliesPursuer returns to spawn and awards points
func _set_mode(next_mode: StringName) -> void:
	_mode = next_mode
	_mode_elapsed = 0.0
	if _mode == MODE_FRIGHTENED:
		flee_field = MazeRulesScript.build_flee_field(
				board.rows, player_field)
	else:
		flee_field = PackedInt32Array()
	for pursuer: GridActor in pursuers:
		pursuer.frightened = _mode == MODE_FRIGHTENED
	_update_hud()

Reserve the chosen target before the next pursuer moves

Current occupancy is not enough. Two pursuers can start in different cells and choose the same empty neighbor during the same frame.

The update builds one occupied set, processes pursuers in a stable order, and adds every accepted target before the next choice:

The claims are ephemeral. The set is rebuilt on the next update because this is real-time next-cell selection, not turn ownership. The reservation-table guide owns persistent claims, simultaneous turns, confirmation, and undo.

func _update_pursuers(delta: float) -> void:
	for pursuer: GridActor in pursuers:
		if pursuer.advance(delta):
			_check_contacts()

	var occupied := {}
	for pursuer: GridActor in pursuers:
		occupied[pursuer.motion_target_cell()] = true

	for pursuer: GridActor in pursuers:
		if pursuer.is_moving:
			continue
		occupied.erase(pursuer.cell)
		var next := _choose_pursuer_step(pursuer, occupied)
		if next != pursuer.cell and pursuer.begin_move(next):
			occupied[next] = true
		else:
			occupied[pursuer.cell] = true

Treat the side tunnel as an edge

The middle row is open at both ends. neighbor() maps a horizontal step beyond one edge to the cell on the other edge:

If movement wraps but the field does not, the tunnel looks open and the AI treats it as a wall.

  • Use the same topology for player legality.
  • Use it for pursuer legality.
  • Use it for Dijkstra expansion.
  • Use it for direction reconstruction.
  • Use it for interpolation across the screen edge.
func neighbor(cell: Vector2i, direction: Vector2i) -> Vector2i:
	var next := cell + direction
	if direction.y == 0 and _wrap_rows_cache.has(cell.y):
		if next.x < 0:
			next.x = _grid_size_cache.x - 1
		elif next.x >= _grid_size_cache.x:
			next.x = 0
	return next

Collect pellets only after the move commits

Pellet state changes at the same cell boundary that updates navigation:

Interpolation frames never collect the same pellet repeatedly. A power pellet changes score and mode in one transaction.

func _collect_player_cell() -> void:
	var collected := board.consume_at(player.cell)
	if collected == &"pellet":
		_score += 10
	elif collected == &"power":
		_score += 50
		_frightened_remaining = 6.0
		_set_mode(MODE_FRIGHTENED)

	if board.collectible_count() == 0:
		_round_over = true
		_update_hud("Maze clear. Press R to run the scene again.")
	else:
		_update_hud()

Resolve contact from the current mode

The starter checks same-cell contact and actors crossing in opposite directions. The active mode decides the outcome:

The outcome does not depend on which sprite callback happened first.

func _check_contacts() -> void:
	if _round_over:
		return
	for pursuer: GridActor in pursuers:
		var same_cell := pursuer.cell == player.cell
		var crossed := pursuer.cell == player.target_cell \
				and pursuer.target_cell == player.cell
		if not same_cell and not crossed:
			continue
		if _mode == MODE_FRIGHTENED:
			_score += 200
			pursuer.teleport_to(pursuer.spawn_cell)
			_update_hud("%s returned to the maze house." % pursuer.role.capitalize())
		else:
			_lose_life()
		return

What to update, and when

This table is the maintenance contract. Field rebuilds should follow fact changes, not be added to arbitrary render callbacks.

EventRebuild fields?Reservation effectState effect
Player commits a cellRebuild player field; clear player-dependent target cacheRebuilt during the next pursuer updateNone
Pursuer commits a cellUsually noIts committed/target cell enters the current occupied setNone
Power pellet is collectedBuild the flee fieldNext choices read frightened occupancyEnter frightened
Frightened timer expiresDiscard flee field; ordinary targets rebuild on demandNext choices use ordinary occupancyResume chase
Chase/scatter timer changesCache new target fields on demandNext choices use the new policyChase or scatter
Maze topology changesReparse topology and rebuild all fieldsClear current claimsNone
Player is caughtRebuild from reset cellsClear transient claimsLose life or end run

Run the verified starter

The source ZIP contains the exact scene and code shown in this article. Download the Godot 4.7.1 source project, then read the machine-readable verification receipt.

The receipt reports 16/16 checks after the exact ZIP was extracted and rerun. It covers the authored maze, buffered turns, committed cells, shared fields, target policies, flee rescan, no-reverse behavior, dead-end fallback, reservations, tunnel wrapping, collectibles, contact mode, scene startup, local resource dependencies, and the package rerun.

Measurement-gap: this is a correctness receipt, not a performance dataset. It does not measure FPS, throughput, allocations, memory, or commercial-game readiness.

For the larger browser-playable result, open Dijkstra's Ghost. It has four hunters, ten mazes, weighted routes, touch controls, a live field overlay, and a separate public receipt. The starter is not a copy of that product's source.

Common failure modes

Most broken maze chases cross one of the boundaries above. Match the visible symptom to the state contract before replacing the solver.

The player reaches an intersection before the turn is accepted

Input is being tested only on the exact intersection frame. Keep the most recent cardinal request and retry it at every committed cell.

The visible actor is in one cell and the rules use another

World position has replaced grid state. Store cell and target_cell separately; update the authoritative cell only when interpolation completes.

All pursuers stack or form one merged sprite

Shared navigation does not imply shared occupancy. Include moving target cells in the occupied set before the next pursuer chooses.

A pursuer shakes between two corridor cells

The selection rule allows immediate reversal. Exclude the opposite direction during ordinary movement and retry with reversal only if no other move exists.

The ambusher aims into a wall

Normalize the projected target before seeding a field. The starter uses nearest_walkable() with the player cell as a safe fallback.

The tunnel works for the player but not the pursuers

Movement and field expansion have different topology functions. Route both through the same wrap-aware neighbor rule.

The game pauses briefly on every player cell

The field builder is probably recomputing topology inside its inner loop. Cache dimensions and wrap rows once per board revision, use packed queues, and build the flee field only when frightened mode is active.

Where PathForge goes further

This starter has one fixed one-cell movement profile, static topology, uniform cost, and a small number of target fields.

PathForge is being built for the larger capability layer: multi-size clearance, dynamic blockers, tactical movement profiles, scheduled queries, and editor-visible diagnostics. The standalone project above proves the native small-game baseline; PathForge addresses the broader grid-navigation workflow.

Frequently asked questions

How do I build Pac-Man-style ghost AI in Godot?

Keep the maze and actors on cardinal grid cells, rebuild one player-centered Dijkstra field when the player commits a move, and let direct pursuers choose a legal neighboring cell with a lower field value. Add personality by changing the target seed, not by giving every pursuer a separate random steering rule.

Should every ghost use its own AStarGrid2D path?

No when several ghosts share the player as their target. One reverse Dijkstra field gives every reachable cell a next-step answer. Use AStarGrid2D when one actor needs one complete point-to-point path, and cache another field only when a pursuer policy has a genuinely different target.

How do I make the player turn smoothly at intersections?

Buffer the latest requested cardinal direction. When a cell move commits, try the buffered direction first; if it is still blocked, continue straight when possible. Interpolate the Node2D, but keep the committed cell authoritative.

How do I stop ghosts from reversing constantly?

Exclude the direction opposite current_direction during chase and scatter. If no other legal neighbor remains, repeat the selection once with reversal allowed so the actor can leave a dead end.

How do I stop two ghosts moving into the same tile?

Build an occupied set containing every pursuer's current or active target cell. After a pursuer selects a new target, add it immediately before the next pursuer chooses.

How do wrap tunnels work with pathfinding?

Represent the two edge cells as neighbors in the same helper used by movement and Dijkstra expansion. Do not implement wrapping only as a visual teleport.

Does this recreate the original PAC-MAN ghost algorithms?

No. Pac-Man-style describes the maze-chase genre. The starter uses original code-drawn actors, an original maze, and its own hunter, ambusher, weaver, and drifter policies.