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.
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
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().