Install
$ agentstack add skill-summerengine-summer-engine-agent-design-npc ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
/design-npc — Design an NPC's Behavior End-to-End
Overview
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.
Steps
1. Pick the NPC archetype
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 |
2. Walk the design pillars
Walk them in order. Don't skip. The pillars are Perception → Personality → Intent → Action → Telegraph → Defeat.
a) Perception — what does this NPC sense?
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.
b) Personality — four floats that make this NPC not a clone
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() 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?
d) Action SM — what the body is currently doing
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.
e) Telegraph — every aggressive action has a tell
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.
f) Defeat — what happens when it dies
Plan it. "It just disappears" is bad. Choices:
- Death animation plays, body stays for N sec, then
queue_free. - Ragdoll: switch CollisionShape from CharacterBody3D to RigidBody3D for weight.
- Loot drop: spawn pickup at death position.
- Cleanup: leave any group, disconnect signals, free children.
- Score / quest hook: emit
died(global_position)for the gameplay layer. - Group signal: emit
ally_defeatedso squadmates can update theirRESCUE_ALLYchecks (or panic).
3. Group emergence — varied personality across a squad
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.
4. Read the scene to find an attach point
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.
5. Propose the bundle
> 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.gd with 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, emit died signal. May I create the scene + script?
6. Build it
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.
7. Drop the script
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 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) 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 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() ==
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [SummerEngine](https://github.com/SummerEngine)
- **Source:** [SummerEngine/summer-engine-agent](https://github.com/SummerEngine/summer-engine-agent)
- **License:** MIT
- **Homepage:** https://summerengine.com/
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.