AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Design Npc

skill-summerengine-summer-engine-agent-design-npc · by SummerEngine

Use when the user wants to design an enemy, NPC, boss, companion, civilian, or wave-spawned mob's behavior. Walks perception, personality knobs, intent layer, action state machine, telegraphs, defeat handling, and group emergence — outputs a state-machine GDScript stub plus the recommended node tree. Trigger on "enemy", "NPC", "boss", "companion", "AI", "behavior", "mob", "perception", "design en…

No reviews yet
0 installs
5 views
0.0% view→install

Install

$ agentstack add skill-summerengine-summer-engine-agent-design-npc

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-summerengine-summer-engine-agent-design-npc)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
1mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

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 →
Are you the author of Design Npc? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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_defeated so squadmates can update their RESCUE_ALLY checks (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.

Versions

  • v0.1.0 Imported from the upstream source.