| name | fxgl-ai-pathfinding |
| description | Implement AI movement and pathfinding in FXGL — set up an AStarGrid, find paths using AStarPathfinder, attach AStarMoveComponent or RandomAStarMoveComponent to entities, implement GOAP (Goal-Oriented Action Planning) with world state and action preconditions, add SenseAI for vision and hearing, set up waypoint patrol routes, generate dungeons and mazes procedurally. Use this skill when making enemies chase the player, implementing patrol behaviours, building GOAP NPC AI, adding pathfinding to a tile-based game, or generating procedural levels.
|
| triggers | ["A*","pathfinding","AStarGrid","AStarMoveComponent","GOAP","enemy AI","patrol","chase","SenseAI","dungeon generation","maze","waypoint","enemy movement"] |
| compatibility | Java 17+, FXGL 21.x
|
| category | fxgl/ai |
| tags | ["fxgl","java","javafx","ai","pathfinding"] |
| metadata | {"author":"fxgl-skills","version":"1.0","fxgl-version":"21.1"} |
| allowed-tools | ["Read","Write","Edit","Bash"] |
FXGL AI — Pathfinding, GOAP & Sense AI
A* Pathfinding Setup
Build the grid from the world
private AStarGrid grid;
@Override
protected void initGame() {
setLevelFromMap("level1.tmx");
grid = AStarGrid.fromWorld(getGameWorld(), 32, 32);
getGameWorld().getEntitiesByType(EntityType.OBSTACLE).forEach(e -> {
int cx = (int)(e.getX() / 32);
int cy = (int)(e.getY() / 32);
grid.getCell(cx, cy).setState(CellState.NOT_WALKABLE);
});
}
Find a path manually
List<AStarCell> path = grid.getAStarSearch().findPath(
(int)(startX / 32),
(int)(startY / 32),
(int)(goalX / 32),
(int)(goalY / 32)
);
if (path != null) {
path.forEach(cell -> {
double worldX = cell.getX() * 32;
double worldY = cell.getY() * 32;
System.out.println("Step: " + worldX + ", " + worldY);
});
}
AStarMoveComponent (enemy follows player)
@Spawns("enemy")
public Entity newEnemy(SpawnData data) {
AStarMoveComponent astar = new AStarMoveComponent(new AStarGridView(grid));
return entityBuilder(data)
.type(EntityType.ENEMY)
.view("enemy.png")
.bbox(BoundingShape.box(32, 32))
.with(astar)
.with(new EnemyAIComponent())
.build();
}
public class EnemyAIComponent extends Component {
private AStarMoveComponent astar;
private double recalcTimer = 0;
@Override
public void onUpdate(double tpf) {
recalcTimer += tpf;
if (recalcTimer >= 0.5) {
recalcTimer = 0;
Entity player = getGameWorld().getSingleton(e -> e.isType(EntityType.PLAYER));
astar.moveToCell(
(int)(player.getX() / 32),
(int)(player.getY() / 32)
);
}
}
}
RandomAStarMoveComponent (wandering)
RandomAStarMoveComponent wander = new RandomAStarMoveComponent(new AStarGridView(grid));
wander.setMoveSpeed(120);
wander.setMinWanderDistance(3);
wander.setMaxWanderDistance(8);
entity.addComponent(wander);
Waypoint Patrol
WaypointMoveComponent patrol = new WaypointMoveComponent();
patrol.setSpeed(100);
patrol.setLooping(true);
patrol.addWaypoint(new Point2D(100, 200));
patrol.addWaypoint(new Point2D(500, 200));
patrol.addWaypoint(new Point2D(500, 400));
patrol.addWaypoint(new Point2D(100, 400));
entity.addComponent(patrol);
patrol.pause();
patrol.resume();
GOAP (Goal-Oriented Action Planning)
Define world state
Map<String, Boolean> worldState = new HashMap<>();
worldState.put("hasAmmo", true);
worldState.put("hasWeapon", false);
worldState.put("enemyDead", false);
worldState.put("inRange", false);
Map<String, Boolean> goal = new HashMap<>();
goal.put("enemyDead", true);
Define actions
public class FindWeaponAction extends GoapAction {
public FindWeaponAction() {
addEffect("hasWeapon", true);
setCost(2.0f);
}
@Override
public boolean checkProceduralPrecondition(Entity agent) {
return !getGameWorld().getEntitiesByType(EntityType.WEAPON).isEmpty();
}
@Override
public boolean perform(Entity agent) {
Entity weapon = getGameWorld().getClosestEntity(agent,
e -> e.isType(EntityType.WEAPON));
agent.getComponent(AStarMoveComponent.class).moveTo(weapon.getPosition());
if (agent.getPosition().distance(weapon.getPosition()) < 20) {
weapon.removeFromWorld();
return true;
}
return false;
}
}
public class AttackAction extends GoapAction {
public AttackAction() {
addPrecondition("hasWeapon", true);
addPrecondition("inRange", true);
addEffect("enemyDead", true);
addEffect("hasAmmo", false);
setCost(1.0f);
}
@Override
public boolean perform(Entity agent) {
agent.getComponent(AttackComponent.class).attack();
return true;
}
}
public class MoveInRangeAction extends GoapAction {
public MoveInRangeAction() {
addPrecondition("hasWeapon", true);
addEffect("inRange", true);
setCost(1.5f);
}
@Override
public boolean perform(Entity agent) {
Entity player = getGameWorld().getSingleton(e -> e.isType(EntityType.PLAYER));
if (agent.getPosition().distance(player.getPosition()) < 100) {
return true;
}
agent.getComponent(AStarMoveComponent.class).moveTo(player.getPosition());
return false;
}
}
Run the planner
List<GoapAction> availableActions = List.of(
new FindWeaponAction(),
new MoveInRangeAction(),
new AttackAction()
);
Queue<GoapAction> plan = GoapPlanner.plan(agentEntity, availableActions, worldState, goal);
if (plan != null) {
GoapAction current = plan.poll();
}
Sense AI (Vision + Hearing)
SenseComponent sense = new SenseComponent(250.0, 120.0);
sense.setOnEntered(other -> {
if (other.isType(EntityType.PLAYER)) {
isPlayerDetected = true;
getComponent(AStarMoveComponent.class).moveTo(other.getPosition());
}
});
sense.setOnLeft(other -> {
if (other.isType(EntityType.PLAYER)) {
isPlayerDetected = false;
getComponent(WaypointMoveComponent.class).resume();
}
});
entity.addComponent(sense);
Dungeon Generation
DungeonConfig config = new DungeonConfig()
.gridWidth(40)
.gridHeight(40)
.minRoomSize(5)
.maxRoomSize(12)
.maxRooms(15);
DungeonGenerator generator = new DungeonGenerator(config);
Grid2D<DungeonCell> dungeon = generator.generate();
dungeon.forEach((cell, x, y) -> {
int worldX = x * TILE_SIZE;
int worldY = y * TILE_SIZE;
switch (cell.getType()) {
case FLOOR -> spawn("floor", worldX, worldY);
case WALL -> spawn("wall", worldX, worldY);
case CORRIDOR -> spawn("floor", worldX, worldY);
case DOOR -> spawn("door", worldX, worldY);
case BOSS_ROOM -> spawn("bossFloor",worldX, worldY);
}
});
Room startRoom = generator.getRooms().get(0);
spawn("player", startRoom.getCenterX() * TILE_SIZE, startRoom.getCenterY() * TILE_SIZE);
Room bossRoom = generator.getRooms().get(generator.getRooms().size() - 1);
spawn("boss", bossRoom.getCenterX() * TILE_SIZE, bossRoom.getCenterY() * TILE_SIZE);
Maze Generation
MazeGenerator mazeGen = new MazeGenerator(20, 15);
Grid2D<MazeCell> maze = mazeGen.generate();
maze.forEach((cell, x, y) -> {
if (cell.hasTopWall()) spawnWall(x, y, "top");
if (cell.hasLeftWall()) spawnWall(x, y, "left");
if (cell.hasRightWall()) spawnWall(x, y, "right");
if (cell.hasBottomWall()) spawnWall(x, y, "bottom");
});
Gotchas
- Rebuild
AStarGrid after every level load — the grid caches cell states from the
world; stale grids cause enemies to walk through walls spawned in the new level.
- Recalculate paths at intervals, not every frame — A* on a 40×40 grid costs ~0.5ms.
At 60fps with 10 enemies, that's 300ms/s wasted. Recalc every 0.5s max.
AStarGrid.fromWorld marks only STATIC physics bodies as NOT_WALKABLE — dynamic
entities (other enemies) are ignored. Handle entity-entity avoidance separately.
- GOAP planner returns
null when no plan is possible with the given actions. Always
null-check and handle with a default behaviour (idle, wander, alert).
WaypointMoveComponent requires exact world coordinates (pixels) not grid coordinates.
Multiply grid cell indices by tile size.
- Dungeon generation is random — use
DungeonConfig.seed(long) for reproducible layouts
(e.g., seeded from the current level number for consistent procedural content).
- SenseAI field-of-view is from the entity's forward direction — make sure your enemy
entity faces the direction of travel (update
entity.setRotation(angle) each frame).