Building an AI Seeker That Feels Fair
The first AI I wrote was omniscient
It knew exactly where you were. It walked straight to you and tagged you in four seconds. Obviously that's terrible game design, but it was useful as a debugging tool — I could test tagging, scoring, and round flow without actually playing. The real AI came later, and getting it right was harder than I expected.
Suspicion, not knowledge
The key insight: the AI doesn't know where you are. It suspects. A floating-point variable called ai_susp tracks suspicion from 0.0 (oblivious) to 1.0+ (certain). When suspicion reaches 1.0 and the AI is close enough, it catches you.
Suspicion rises when three conditions are all true: the AI can see you (line of sight), you're within its vision cone, and you're within range:
var to_me := me.global_position - ai.global_position
var d := to_me.length()
var facing := Vector3(sin(ai.rotation.y), 0, cos(ai.rotation.y))
var in_cone := to_me.normalized().dot(facing) > 0.35
var seen := false
if d < 24.0 and in_cone:
var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
var q := PhysicsRayQueryParameters3D.create(
ai.global_position + Vector3(0, 1.2, 0),
me.global_position + Vector3(0, 0.6, 0))
q.collision_mask = 1
if not space.intersect_ray(q):
seen = trueThe dot product check (> 0.35) creates roughly a 140° forward cone. The raycast tests line-of-sight through world geometry only (collision mask 1, ignoring lizards on layer 2). If a wall is between you and the AI, you're hidden even if you're five feet away.
What makes suspicion rise faster?
Four factors multiply into the suspicion gain:
if seen:
var mism := 1.0 - me.blend_pct / 100.0
var move_mult := 3.0 if me.moved_recently() else 1.0
var near_mult := clampf(2.2 - d / 12.0, 0.4, 2.2)
ai_susp += delta * (0.10 + GS.diff * 0.05) * (0.15 + mism * 1.7) * move_mult * near_mult- Camouflage mismatch (
mism) — poorly painted lizards get spotted faster. If your camo is 90%, mismatch is only 0.1 and suspicion crawls. At 20% camo, mismatch is 0.8 and you're basically glowing. - Movement (
move_mult) — if you moved in the last 1.4 seconds, suspicion builds 3x faster. This is the core tension of the game: movement gets you killed, but sometimes you have to reposition. - Distance (
near_mult) — closer = faster detection. At 2 meters it's 2.2x. At 24 meters it's 0.4x. This creates a smooth gradient instead of a hard detection radius. - Difficulty — base rate scales with the
GS.diffsetting. Hard mode AI is about 50% more perceptive than easy mode.
When the AI loses sight of you, suspicion slowly decays:
else:
ai_susp = maxf(0.0, ai_susp - delta * 0.06)The decay rate (0.06/s) is deliberately slow. Breaking line of sight doesn't immediately reset the AI — it takes about 17 seconds to go from full suspicion back to zero. That's enough time for the AI to walk around a corner and re-spot you. It creates a "close call" feeling that's really satisfying when you survive it.
Patrol routes and investigation
When not investigating, the AI walks between waypoints:
func _ai_next_wp(first := false) -> void:
var chase_p: float = [0.25, 0.4, 0.55][GS.diff]
if not first and me and randf() < chase_p and ai_susp > 0.15:
ai_wp = me.global_position + Vector3(randf_range(-5, 5), 0, randf_range(-5, 5))
else:
ai_wp = world.patrol[randi() % world.patrol.size()]
ai_wp_t = randf_range(2.0, 4.0)There's a chance (25%–55% depending on difficulty) that instead of picking a random patrol point, the AI heads toward the player's area — but with 5 meters of random offset. It doesn't beeline to you. It investigates the area. That offset is what makes it feel fair. You see the AI coming your way and think "does it know?" — sometimes it walks right past, sometimes it gets close enough for suspicion to spike.
The ai_wp_t timer gives the AI 2–4 seconds at each waypoint before picking a new one. At maximum suspicion, the AI drops all pretense and chases directly:
if ai_susp >= 1.0:
ai_wp = me.global_position
speed = 4.6The tension meter
The player sees suspicion as a tension bar on the HUD:
ui.phase_chip.text = "Tension " + "|".repeat(int(ai_susp * 10)) + ".".repeat(maxi(0, 10 - int(ai_susp * 10)))It looks like Tension |||....... — a simple 10-segment bar. Full bar means you're about to get caught. Watching it fill up while the AI stares in your direction is genuinely nerve-wracking, even though it's just a single float behind the scenes.
What I got wrong first
My first suspicion system was binary: the AI either saw you or it didn't, with a fixed 3-second timer before it tagged you. The problem was that it felt arbitrary. There was no tension — you were fine, fine, fine, then suddenly dead. The analog suspicion system with multiple scaling factors gives you a gradient. You can feel the danger building and make decisions: do I stay still and hope my camo holds, or do I break cover and run (which spikes suspicion but maybe I can get behind a wall in time)?
That decision space is what makes the game fun. And it all comes from one float.
Written by Oliver
Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.