Build a stealth game in FXGL — implement a conical vision field for guards with configurable range and angle, line-of-sight raycasting blocked by wall entities, a three-state detection machine (unaware/suspicious/alerted), a detection meter that fills while in sight and decays out of sight, waypoint patrol routes that pause on detection, a noise system where player actions emit a radius heard by nearby guards, hiding spots (shadows/lockers) that suppress detection, non-lethal takedown mechanics, and an alarm system where alerted guards call for backup. Use this skill when building a stealth game, espionage game, guard-avoidance puzzle, or any game requiring line-of-sight and detection mechanics.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Build a stealth game in FXGL — implement a conical vision field for guards with configurable range and angle, line-of-sight raycasting blocked by wall entities, a three-state detection machine (unaware/suspicious/alerted), a detection meter that fills while in sight and decays out of sight, waypoint patrol routes that pause on detection, a noise system where player actions emit a radius heard by nearby guards, hiding spots (shadows/lockers) that suppress detection, non-lethal takedown mechanics, and an alarm system where alerted guards call for backup. Use this skill when building a stealth game, espionage game, guard-avoidance puzzle, or any game requiring line-of-sight and detection mechanics.
triggers
["stealth","guard","line of sight","detection","FOV cone","patrol","noise","hiding","shadow","alert","suspicious","takedown","vision cone"]
// 3. Line of sight: check if any WALL entity intersects the ray
return
private
boolean
wallBlocksLOS
(Point2D from, Point2D to)
// Sample points along the ray at TILE/2 intervals
int
steps
=
int
2.0
for
int
i
=
1
double
t
=
double
double
x
=
double
y
=
Point2D
point
=
new
Point2D
boolean
wallHit
=
if
return
true
return
false
private
void
updatePatrolling
(double tpf, boolean canSee, Entity player)
if
PlayerComponent
pc
=
double
rate
=
2
1
if
0.5
else
0
private
void
updateSuspicious
(double tpf, boolean canSee, Entity player)
if
1
1.5
if
1.0
else
0
0.5
if
0
private
void
updateAlerted
(double tpf, boolean canSee, Entity player)
if
else
private
void
updateSearching
(double tpf, boolean canSee, Entity player)
if
return
if
0
0
else
// Move to last known position
private
void
transitionTo
(DetectionState newState)
switch
case
case
case
"sounds/alert.wav"
case
0
private
void
chasePlayer
(Entity player)
AStarMoveComponent
astar
=
int
int
private
void
alertNearbyGuards
()
300
private
void
updateFOVVisual
()
// Update the FOV cone arc — see visual section below
private
angleToVector
(double angleDegrees)
double
rad
=
return
new
Point2D
FOV Cone Visual
// Draw the cone as a JavaFX Arc or Path node on the guard entity's viewprivate Arc fovCone;
privatevoidinitFOVVisual(Entity guard) {
fovCone = newArc();
fovCone.setType(ArcType.ROUND);
fovCone.setRadiusX(VISION_RANGE);
fovCone.setRadiusY(VISION_RANGE);
fovCone.setLength(VISION_HALF_ANGLE * 2);
fovCone.setFill(Color.color(1, 1, 0, 0.15));
fovCone.setStroke(Color.color(1, 1, 0, 0.4));
guard.getViewComponent().addChild(fovCone);
}
// In GuardComponent.updateFOVVisual():privatevoidupdateFOVVisual() {
fovCone.setStartAngle(-entity.getRotation() - VISION_HALF_ANGLE);
// Color shifts red when suspicious/alerted
fovCone.setFill(switch (state) {
case PATROLLING -> Color.color(1, 1, 0, 0.12);
case SUSPICIOUS -> Color.color(1, 0.5, 0, 0.20);
case ALERTED -> Color.color(1, 0, 0, 0.30);
case SEARCHING -> Color.color(1, 0.3, 0, 0.20);
});
}
Noise System
publicclassNoiseEmitter {
publicstaticvoidemit(Entity source, double radius) {
// Find all guards within radius
getGameWorld().getEntitiesByType(EntityType.GUARD)
.stream()
.filter(guard -> guard.getCenter().distance(source.getCenter()) <= radius)
.forEach(guard -> {
GuardComponentgc= guard.getComponent(GuardComponent.class);
gc.hearNoise(source.getCenter());
});
}
}
// In GuardComponent:publicvoidhearNoise(Point2D noisePos) {
if (state == DetectionState.PATROLLING || state == DetectionState.SUSPICIOUS) {
lastKnownPos = noisePos;
transitionTo(DetectionState.SUSPICIOUS);
entity.setRotation(Math.toDegrees(Math.atan2(
noisePos.getY() - entity.getCenter().getY(),
noisePos.getX() - entity.getCenter().getX()
)));
}
}
// In PlayerComponent.onUpdate — emit noise based on action:if (isRunning && isMoving) NoiseEmitter.emit(entity, 200);
elseif (isMoving) NoiseEmitter.emit(entity, 60);
// Throw object at position:publicstaticvoidthrowDistractionObject(Point2D target) {
NoiseEmitter.emit(target, 300);
}
Hiding Spot Mechanic
// Shadow/locker entities in the level:@Spawns("hidingSpot")public Entity newHidingSpot(SpawnData data) {
return entityBuilder(data)
.type(EntityType.HIDING_SPOT)
.bbox(BoundingShape.box(data.get("width"), data.get("height")))
.with(newPhysicsComponent()) // sensor
.build();
}
// Player hiding stateprivatebooleanplayerIsHiding=false;
@OverrideprotectedvoidinitPhysics() {
onCollisionBegin(EntityType.PLAYER, EntityType.HIDING_SPOT, (player, spot) -> {
playerIsHiding = true;
player.getViewComponent().setOpacity(0.4); // visual: semi-transparent
player.getComponent(PlayerComponent.class).setHiding(true);
});
onCollisionEnd(EntityType.PLAYER, EntityType.HIDING_SPOT, (player, spot) -> {
playerIsHiding = false;
player.getViewComponent().setOpacity(1.0);
player.getComponent(PlayerComponent.class).setHiding(false);
});
}
// In GuardComponent.canSeePlayer:PlayerComponentpc= player.getComponent(PlayerComponent.class);
if (pc.isHiding()) returnfalse; // can't see player in hiding spot
Takedown Mechanic
// Player can knock out a guard from behind when undetected
onKeyDown(KeyCode.E, () -> {
if (playerIsHiding) return;
EntitynearestGuard= getGameWorld()
.getEntitiesByType(EntityType.GUARD)
.stream()
.filter(g -> g.getCenter().distance(player.getCenter()) < 50)
.filter(g -> isApproachingFromBehind(g))
.findFirst().orElse(null);
if (nearestGuard != null) {
nearestGuard.getComponent(GuardComponent.class).knockout();
}
});
privatebooleanisApproachingFromBehind(Entity guard) {
Point2DtoPlayer= player.getCenter().subtract(guard.getCenter());
Point2Dfacing= angleToVector(guard.getRotation());
return facing.dotProduct(toPlayer.normalize()) < -0.5; // behind = dot < 0
}
// In GuardComponent:publicvoidknockout() {
state = null; // disable AI
patrol.pause();
entity.getViewComponent().setOpacity(0.5);
// After 30 seconds: guard wakes up
runOnce(() -> {
entity.getViewComponent().setOpacity(1.0);
transitionTo(DetectionState.PATROLLING);
}, Duration.seconds(30));
}
Player Detection HUD
// Show detection meter bar per guard or global danger level@OverrideprotectedvoidinitUI() {
RectangledetectionBg=newRectangle(200, 12, Color.color(0.2, 0.2, 0.2, 0.8));
RectangledetectionBar=newRectangle(0, 12, Color.YELLOW);
StackPanemeter=newStackPane(detectionBg, detectionBar);
StackPane.setAlignment(detectionBar, Pos.CENTER_LEFT);
addUINode(meter, 20, 20);
// Update each frame
getGameTimer().runAtInterval(() -> {
doublemaxDetection= getGameWorld().getEntitiesByType(EntityType.GUARD)
.stream()
.mapToDouble(g -> g.getComponent(GuardComponent.class).getDetectionMeter())
.max().orElse(0);
detectionBar.setWidth(200 * maxDetection);
detectionBar.setFill(maxDetection > 0.7 ? Color.RED
: maxDetection > 0.3 ? Color.ORANGE
: Color.YELLOW);
}, Duration.millis(50));
}
Gotchas
LOS raycasting samples must be fine-grained enough — sampling at TILE/2 intervals
prevents the ray from skipping through thin walls. For very thin walls, decrease the step size.
Detection rate modifier for running — a player running should be spotted 2-3× faster.
Pass PlayerComponent.isRunning() into the detection calculation, not just the state.
Alert propagation should only go to nearby guards — alerting ALL guards globally breaks
large levels. Use a radius (e.g. 300px) for the initial call-for-backup propagation.
Guard rotation must be updated for facing direction — patrol.resume() does NOT update
entity.getRotation(). Update rotation manually in onUpdate based on movement direction for LOS.
wallBlocksLOS is O(walls × steps) — for large levels with many walls, this can be slow
if called every frame per guard. Cache per guard per N frames (every 3 frames is imperceptible).
Hide the player entity in hiding spots, don't make it invisible — semi-transparent (opacity 0.4)
looks better than invisible and makes it clear the player is hiding, not gone.
Guard patrol must resume at the next waypoint, not the nearest — WaypointMoveComponent
resumes from where it stopped. If you need the guard to return to a specific post, call
moveTo(postPosition) before resume().