From summer
Walks NPC AI design from perception through action, intent, personality knobs, and defeat handling. Outputs a GDScript state-machine stub and node tree for enemies, bosses, companions, civilians, or wave-spawned mobs. Trigger on 'enemy', 'NPC', 'behavior'.
How this skill is triggered — by the user, by Claude, or both
Slash command
/summer:design-npc**/*.gd**/*.tscnThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
A good NPC is legible and *alive*. The player can read its mood, predict its next move, and feel the consequence of theirs — and the four enemies in a patrol squad don't all behave like clones of one designer-authored script. This skill walks the canonical NPC AI pattern from the inside out: what the NPC perceives, what it *wants* (intent), what it *does* (action), what tells signal each state,...
A good NPC is legible and alive. The player can read its mood, predict its next move, and feel the consequence of theirs — and the four enemies in a patrol squad don't all behave like clones of one designer-authored script. This skill walks the canonical NPC AI pattern from the inside out: what the NPC perceives, what it wants (intent), what it does (action), what tells signal each state, and how it dies.
Core principle: behavior is what the player sees. Two layers separate doing from wanting: an action state machine the body runs, and an intent layer above it that reads perception + personality and tells the action SM what to aim at.
Second principle: identical NPCs are predictable, and predictable is boring. Every spawned instance jitters its personality knobs so a squad of four grunts produces emergent variety with zero designer authoring.
Open with the question. Wait.
What's the NPC for? Pick the closest:
- Basic enemy (melee/ranged grunt, dies in 1–3 hits)
- Boss (named, scripted phases, 30–120 sec fight)
- Quest-giver (talks, gives objectives, doesn't fight)
- Friendly companion (follows player, helps in combat)
- Civilian / ambient (reacts to events, no combat AI)
- Wave-spawned mob (cheap, many at once, simple behavior)
Each archetype maps to a different complexity budget:
| Archetype | Action states | Intents | Perception | Personality range |
|---|---|---|---|---|
| Basic enemy | 4 (calm / alert / aggressive / defeated) | 5 (idle / investigate / hunt / kill / retreat) | Sight cone + LOS raycast | Wide |
| Boss | 6–10 (per phase) | 5–7 incl. RESCUE_ALLY | Sight + hearing + scripted | Narrow, biased high |
| Quest-giver | 2 (idle / talking) | 1 (idle) | Trigger area | None — fixed |
| Companion | 5 | 4 (follow / support / engage / retreat) | Sight + group | Narrow, biased low aggression |
| Civilian | 3 (wander / panic / dead) | 2 (idle / retreat) | Hearing | Caution biased high |
| Wave mob | 2 (charge / dead) | 1 (kill) | Direct line to player | None |
Walk them in order. Don't skip. The pillars are Perception → Personality → Intent → Action → Telegraph → Defeat.
Pick the minimum. Over-perception = unfair AI.
| Sensor | Node | Cost | Use case |
|---|---|---|---|
| Sight cone | RayCast3D + dot product check | Cheap | Standard enemy |
| Sphere proximity | Area3D with sphere shape | Cheap | "Felt your presence" / aggro range |
| Hearing event | Signal from world events | Cheapest | Alert nearby NPCs |
| Group awareness | Shared signal bus (autoload) | Cheap | Pack tactics, rescue intent |
Recommended setup for a basic enemy:
Enemy (CharacterBody3D)
├── MeshInstance3D
├── CollisionShape3D
├── Vision (Area3D) # sphere shape, radius = sight range
│ └── CollisionShape3D
└── Sight (RayCast3D) # confirms LOS for candidates inside Vision
Two-step (Area3D filter, then RayCast LOS) is the right default. Don't raycast to every potential target every frame.
Every NPC instance gets four knobs, each in [0.0, 1.0], declared as @export_range so designers can tune the base per archetype, then randomized per-instance at _ready so spawned siblings differ.
| Knob | What it controls | High value reads as | Low value reads as |
|---|---|---|---|
aggression | How fast the intent layer escalates to KILL; shorter pre-attack hesitation | Berserker that closes distance | Cautious skirmisher that waits |
patience | How long the NPC stays in INVESTIGATE before giving up; tolerance for circling without attacking | Will hunt the player across the level | Loses interest quickly, returns to patrol |
caution | How eagerly it breaks line-of-sight when hurt; how often it backs off after a hit trade | Kites and uses cover | Trades blows recklessly |
punishment | Probability of swapping intent to KILL when the player is recovering, reloading, healing | Punishes whiffs and animation locks | Lets the player breathe |
Threshold mapping (used inside the intent layer): engage_threshold = 2.0 - aggression (higher aggression = engage faster), search_duration = 4.0 + patience * 6.0 (patient NPC searches up to 10s), break_los_chance = caution * (1 - hp_ratio) (cautious AND hurt = break LOS), and should_punish() = randf() < punishment (rolled when the player is recovering / reloading).
Declare the knobs as @export_range(0.0, 1.0) so each archetype scene tunes them per-instance, and jitter them by +/- personality_jitter (default 0.15) inside _ready — same script + same archetype scene produces NPCs whose feel is shaped by four numbers alone. Full example in the script stub at step 7.
The intent layer sits above the action SM. It runs each tick, reads perception + personality + group state, and writes a single current_intent. The action SM reads current_intent to pick its transitions; it never sets intent itself. See _decide_intent in the stub (step 7) for the canonical priority order: RESCUE_ALLY > RETREAT (low HP + caution) > KILL (pressure built) > INVESTIGATE (lost LOS, within memory) > HUNT (LOS, out of range) > PATROL / IDLE.
Five-to-seven intents is the sweet spot:
| Intent | When | Effect on action SM |
|---|---|---|
IDLE | No target, no patrol route | Action SM stays in Calm |
PATROL | No target, route exists | Calm, walks the path |
INVESTIGATE | Lost LOS, within memory window | Alert, moves to last-known position |
HUNT | LOS, out of attack range | Alert/Aggressive, closes distance |
KILL | LOS, in attack range, pressure built | Aggressive, executes attack |
RETREAT | Low HP and high caution, OR over-extended | Alert, moves away, breaks LOS |
RESCUE_ALLY | Allied NPC emitted needs_help | Alert, moves to ally |
The action SM's job is now narrow: given an intent, what locomotion + animation + hitbox state matches?
Four states is plenty for a basic enemy. They are the visible mood, not the goal.
enum State { CALM, ALERT, AGGRESSIVE, DEFEATED }
| State | Reads intent | Animation | Audio | VFX |
|---|---|---|---|---|
CALM | IDLE, PATROL | Breathing loop, look-around | Ambient | None |
ALERT | INVESTIGATE, HUNT, RETREAT, RESCUE_ALLY | Posture up, head tracks target | "Huh?" / radio | Eye glow |
AGGRESSIVE | KILL | Combat pose, weapon raised, swing windup | Battle cry | Muzzle flash / windup glow |
DEFEATED | (terminal) | Death anim → ragdoll | Death sound | Loot drop / dissolve |
Action transitions are computed from current_intent and a few action-local conditions (HP, animation locks). The intent layer does not transition action states directly — it sets the goal, action follows.
Telegraph is non-negotiable for any attack. 0.3–0.7 sec of windup the player can react to. Faster than 0.3 = "unfair"; slower than 0.7 = "boring". Personality bleeds into telegraph length:
func attack_windup_time() -> float:
# high aggression = shorter hesitation, but never below 0.25
return maxf(0.25, attack_telegraph * (1.5 - aggression))
Every aggressive action has at least one of: animation windup, audio cue, VFX glow, particle trail, hitbox preview. Cheap mobs can collapse all three into one anim — but the windup must exist.
Plan it. "It just disappears" is bad. Choices:
queue_free.died(global_position) for the gameplay layer.ally_defeated so squadmates can update their RESCUE_ALLY checks (or panic).When you spawn a squad of four with the same archetype but personality jittered per-instance, you get behavioral roles for free.
Worked example — four basic_grunts with aggression after jitter at 0.3 / 0.5 / 0.7 / 0.9:
| Instance | aggression | Emergent role | Why |
|---|---|---|---|
| 1 | 0.3 | Sniper | Slow to engage, stays at range, builds pressure slowly |
| 2 | 0.5 | Anchor | Holds the line, neither rushes nor retreats |
| 3 | 0.7 | Flanker | Closes mid-fight, looks for openings |
| 4 | 0.9 | Berserker | Rushes immediately, short windups |
No designer authored "the sniper". The jitter on a single knob produced a four-role squad. Stack caution and patience on top and you get richer roles (a high-caution / low-aggression NPC reads as "the careful one who hangs back and supports"). The squad feels coordinated even though each NPC is making local decisions.
Implementation note: spawners should call randomize() once at _ready and let each NPC's own _ready handle the jitter. Don't try to author roles centrally — the variation is the design.
summer_get_scene_tree
summer_inspect_node "./World"
Identify where the NPC will live (an Enemies parent, or directly under World). Confirm the player exists for line-of-sight.
I'm about to design a basic patrol-and-chase enemy with the personality + intent layer. Scene tree: Enemy (CharacterBody3D) + Vision Area3D + Sight RayCast3D + CollisionShape3D + MeshInstance3D. Script
scripts/enemy_ai.gdwith personality knobs (aggression / patience / caution / punishment), intent enum (IDLE / PATROL / INVESTIGATE / HUNT / KILL / RETREAT / RESCUE_ALLY), action SM (Calm / Alert / Aggressive / Defeated). Telegraph 0.3–0.7 sec, scaled by aggression. Drop loot on death, emitdiedsignal. May I create the scene + script?
Preferred (Summer MCP):
summer_add_node(parent="./World/Enemies", type="CharacterBody3D", name="Enemy")
summer_add_node(parent="./World/Enemies/Enemy", type="CollisionShape3D", name="Body")
summer_add_node(parent="./World/Enemies/Enemy", type="MeshInstance3D", name="Mesh")
summer_add_node(parent="./World/Enemies/Enemy", type="Area3D", name="Vision")
summer_add_node(parent="./World/Enemies/Enemy/Vision", type="CollisionShape3D", name="VisionShape")
summer_add_node(parent="./World/Enemies/Enemy", type="RayCast3D", name="Sight")
summer_set_prop(path="./World/Enemies/Enemy/Sight", key="enabled", value="true")
summer_set_prop(path="./World/Enemies/Enemy/Sight", key="target_position", value="Vector3(0, 0, -12)")
Save the Vision sphere as standalone .tres (do NOT inline sub_resource — see references/mcp-tools-reference.md § "Trap"):
summer_set_prop(path="./World/Enemies/Enemy/Vision/VisionShape", key="shape", value="res://shapes/vision_sphere.tres")
Connect signals:
summer_connect_signal(from="./World/Enemies/Enemy/Vision", signal="body_entered", to="./World/Enemies/Enemy", method="_on_body_entered")
Fallback (no MCP): write the scene as .tscn text. Ask the user to paste their existing scene first, then propose a unified diff.
scripts/enemy_ai.gd:
class_name EnemyAI
extends CharacterBody3D
enum State { CALM, ALERT, AGGRESSIVE, DEFEATED }
enum Intent { IDLE, PATROL, INVESTIGATE, HUNT, KILL, RETREAT, RESCUE_ALLY }
# Tuning
@export var move_speed: float = 3.5
@export var attack_range: float = 2.0
@export var attack_telegraph: float = 0.5
@export var attack_damage: int = 10
@export var max_health: int = 30
@export var memory_duration: float = 8.0
# Personality (base, jittered at spawn)
@export_range(0.0, 1.0) var aggression: float = 0.5
@export_range(0.0, 1.0) var patience: float = 0.5
@export_range(0.0, 1.0) var caution: float = 0.3
@export_range(0.0, 1.0) var punishment: float = 0.5
@export var personality_jitter: float = 0.15
@onready var sight: RayCast3D = $Sight
@onready var vision: Area3D = $Vision
@onready var anim: AnimationPlayer = $AnimationPlayer
signal died(position: Vector3)
signal needs_help(who: Node3D)
signal ally_defeated(who: Node3D)
var _state: State = State.CALM
var current_intent: Intent = Intent.IDLE
var _target: Node3D = null
var _last_seen_pos: Vector3 = Vector3.ZERO
var _memory_timer: float = 0.0
var _intent_timer: float = 0.0
var _aggression_buildup: float = 0.0
var _retreat_desire: float = 0.0
var _health: int = 30
func _ready() -> void:
_health = max_health
aggression = clampf(aggression + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
patience = clampf(patience + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
caution = clampf(caution + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
punishment = clampf(punishment + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
vision.body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
if _state == State.DEFEATED:
return
_update_memory(delta)
_update_intent(delta)
_update_action(delta)
move_and_slide()
# --- Intent layer (above action SM) -----------------------------------------
func _update_intent(delta: float) -> void:
_intent_timer += delta
if _state == State.AGGRESSIVE:
_aggression_buildup += delta * (1.0 - patience) * 0.5
else:
_aggression_buildup = maxf(_aggression_buildup - delta * 0.3, 0.0)
_retreat_desire = maxf(_retreat_desire - delta * 0.4, 0.0)
var prev: Intent = current_intent
current_intent = _decide_intent()
if current_intent != prev:
_intent_timer = 0.0
func _decide_intent() -> Intent:
if _target == null:
return Intent.IDLE
var dist: float = global_position.distance_squared_to(_target.global_position)
var attack_sq: float = attack_range * attack_range
if _retreat_desire > 0.5:
return Intent.RETREAT
if _aggression_buildup > (2.0 - aggression) and dist < attack_sq:
return Intent.KILL
if not _has_line_of_sight(_target):
return Intent.INVESTIGATE if _intent_timer < (4.0 + patience * 6.0) else Intent.IDLE
if dist < attack_sq * 4.0:
return Intent.HUNT
return Intent.HUNT
# --- Action SM (reads intent) -----------------------------------------------
func _update_action(delta: float) -> void:
match current_intent:
Intent.IDLE, Intent.PATROL:
_enter_state(State.CALM)
velocity = Vector3.ZERO
Intent.INVESTIGATE:
_enter_state(State.ALERT)
_move_toward(_last_seen_pos, delta)
Intent.HUNT:
_enter_state(State.ALERT)
if _target != null:
_move_toward(_target.global_position, delta)
Intent.KILL:
_enter_state(State.AGGRESSIVE)
velocity = Vector3.ZERO
_try_attack()
Intent.RETREAT:
_enter_state(State.ALERT)
if _target != null:
_move_toward(global_position * 2.0 - _target.global_position, delta)
Intent.RESCUE_ALLY:
_enter_state(State.ALERT)
# locomotion handled by ally-position lookup elsewhere
func _enter_state(new_state: State) -> void:
if _state == new_state:
return
_state = new_state
match new_state:
State.CALM:
anim.play("idle")
State.ALERT:
anim.play("alert")
State.AGGRESSIVE:
anim.play("ready")
State.DEFEATED:
anim.play("death")
died.emit(global_position)
ally_defeated.emit(self)
# --- Helpers ----------------------------------------------------------------
func _move_toward(target_pos: Vector3, _delta: float) -> void:
var dir: Vector3 = (target_pos - global_position).normalized()
velocity = dir * move_speed
look_at(target_pos, Vector3.UP)
func _try_attack() -> void:
if _state != State.AGGRESSIVE or _target == null:
return
anim.play("attack_windup")
var windup: float = maxf(0.25, attack_telegraph * (1.5 - aggression))
await get_tree().create_timer(windup).timeout
if _state != State.AGGRESSIVE or _target == null:
return
if global_position.distance_squared_to(_target.global_position) < (attack_range * 1.2) * (attack_range * 1.2):
if _target.has_method("take_damage"):
_target.take_damage(attack_damage)
anim.play("attack_recover")
func _update_memory(delta: float) -> void:
if _target != null and _has_line_of_sight(_target):
_last_seen_pos = _target.global_position
_memory_timer = memory_duration
else:
_memory_timer = maxf(_memory_timer - delta, 0.0)
if _memory_timer <= 0.0:
_target = null
func _has_line_of_sight(target: Node3D) -> bool:
sight.target_position = sight.to_local(target.global_position)
sight.force_raycast_update()
if not sight.is_colliding():
return true
return sight.get_collider() == target
func _on_body_entered(body: Node3D) -> void:
if body.is_in_group("player") and _state == State.CALM:
_target = body
_last_seen_pos = body.global_position
_memory_timer = memory_duration
func take_damage(amount: int) -> void:
if _state == State.DEFEATED:
return
_health -= amount
var hp_ratio: float = float(_health) / float(max_health)
if randf() < caution * (1.0 - hp_ratio):
_retreat_desire = 1.0
if _health <= 0:
_enter_state(State.DEFEATED)
await get_tree().create_timer(2.0).timeout
queue_free()
Key design notes embedded above:
aggression, patience, caution, punishment are tuned per-archetype and jittered per-instance._update_intent / _decide_intent) sits above the action SM (_update_action).current_intent. It does not write intent.caution * (1 - hp_ratio) chance to set _retreat_desire, so cautious + hurt = back off.died signal is the gameplay-layer hook; ally_defeated is the squad hook.These are the base values; the spawn-time personality_jitter (default 0.15) widens them further.
| Archetype | aggression | patience | caution | punishment | Notes |
|---|---|---|---|---|---|
basic_grunt | 0.5–0.7 | 0.3–0.5 | 0.2–0.4 | 0.4–0.6 | Wants to engage; not too smart |
ranged_skirmisher | 0.3–0.5 | 0.5–0.7 | 0.6–0.8 | 0.5–0.7 | Kites, breaks LOS, punishes reload |
heavy_brute | 0.7–0.9 | 0.2–0.4 | 0.1–0.3 | 0.6–0.8 | Closes fast, low retreat |
boss | 0.7–0.9 | 0.7–0.9 | 0.5–0.7 | 0.8–1.0 | Patient, punishing, never panics |
wave_mob | 0.9–1.0 | 0.0–0.1 | 0.0 | 0.0 | Single intent: KILL |
civilian | 0.0 | 0.8–1.0 | 0.7–0.9 | 0.0 | Retreat-only |
companion | 0.4–0.6 | 0.6–0.8 | 0.5–0.7 | 0.3–0.5 | Supports, doesn't over-commit |
Set base values via @export in the scene's inspector, not hard-coded in script. That keeps the same enemy_ai.gd reusable across archetypes.
summer_save_scene
summer_get_script_errors
summer_play
# user tests
summer_stop
If the enemy doesn't aggro, common causes:
player group.enabled = false or collide_with_areas = true blocking on its own Vision area.aggression close to 0 — check inspector values.| Don't | Do | Why |
|---|---|---|
| 360° perception | Sight cone or sphere with raycast LOS | "Sees through walls" feels unfair |
| Instant attack | 0.3–0.7 sec telegraph, scaled by aggression | Player needs to read the swing |
| All NPCs identical at spawn | Jitter personality knobs in _ready | Identical NPCs are predictable; predictable is boring |
| Action SM writes intent | Intent layer writes intent, action reads | Tangled SMs are unmaintainable |
| One enum for action + intent | Two enums, two SMs | Different time scales, different inputs |
| Hard-coded role per spawn ("this is the sniper") | Let personality variation produce roles | Squad emergence comes for free; authored roles fight the system |
| Behavior tree for a grunt | Action SM with 4 states + intent layer | BT overhead unjustified at this complexity |
| Per-frame group lookup | Cache target on aggro, listen for ally_defeated | Tree walks aren't free |
distance_to in _physics_process | distance_squared_to for comparisons | Square root costs at scale |
| Same animation in all states | Distinct anim per action state | The state machine is invisible; animation makes it visible |
queue_free() instantly on death | Death anim → 2 sec → free | Instant despawn breaks immersion + skips loot drops |
| Inline SphereShape3D for Vision Area3D | Standalone .tres | Inline sub_resources break SetResourceProperty |
| 100+ enemies with full FSM | Wave-mob archetype (no intent layer, single KILL intent) | Cheap mobs need cheap brains |
No died / ally_defeated signals | Emit both | Gameplay + squad logic both need hooks |
caution knob plus randomized reaction time (50–250 ms) reads as skill, not aimbot.move_toward.current_intent from inside an action callback. Intent is computed by the intent layer from inputs (perception + personality + group). Letting action callbacks write intent inverts the architecture.This skill writes scenes and scripts. Always ask before each step. Group related writes into one ask. See references/collaborative-protocol.md.
For a fully-rigged enemy with anim controller + loot drop:
→ template-id: template-3d-fps — ships with a sample patrol enemy
→ For higher-complexity AI (boss with phases, behavior trees), defer to ai-and-npcs/behavior-trees/SKILL.md.
references/mcp-tools-reference.md — full MCP tool listreferences/godot-version.md — Godot 4.5 API notesreferences/collaborative-protocol.md — "May I write" patternreferences/gd-style.md — typed GDScript conventionsai-and-npcs/state-machine-npc/SKILL.md — FSM pattern deep diveai-and-npcs/behavior-trees/SKILL.md — when complexity demands a BTai-and-npcs/perception-sight-and-hearing/SKILL.md — sensor patternsai-and-npcs/navmesh-pathfinding/SKILL.md — NavigationAgent3D for movementai-and-npcs/llm-driven-dialogue/SKILL.md — Summer's wedge for talking NPCsai-and-npcs/boss-patterns/SKILL.md — phase transitions, mechanicsvisual-effects/recipes/hit-spark/SKILL.md — hit flash recipevisual-effects/recipes/dissolve/SKILL.md — death dissolve recipevisual-effects/game-feel/SKILL.md — screen-feel discipline (camera shake, hit-stop)scripting-patterns/state-machine-patterns/SKILL.md — generic FSM patternsnpx claudepluginhub summerengine/summer-engine-agent --plugin summerDesign maintainable AI behavior structures for decision-making, navigation, combat, and systemic interaction.
Unity NPC behavior design-to-code translation. Perception system architecture, decision layer patterns, action execution pipeline, faction & relationship systems, NPC memory & forgetting, crowd & squad coordination. DESIGN INTENT format: INTENT/WRONG/RIGHT/SCAFFOLD/DESIGN HOOK. Based on Unity 6.3 LTS.
Implements state machines in Godot 4.3+ using enum-based, node-based, and resource-based FSM patterns with trade-offs for each complexity level.