Version: 1.0 Date: 2026-01-21 Status: Implemented Branch: NPCs
This document outlines the requirements for enhancing the NPC (Non-Player Character) system in TalesMUD. The goal is to transform the current basic NPC implementation into a robust system supporting three distinct NPC archetypes: Standard NPCs, Enemies, and Merchants - all built on a unified base with specialized traits.
Key additions include:
- NPC Templates for spawnable/instanced NPCs
- Enhanced Enemy system with combat integration
- Merchant system with inventory and trading
- Configurable behaviors (movement, aggro, respawn)
- Basic NPC entity with name, description, race, class, level, HP
- Minimal
EnemyTraitandMerchantTrait(type string only) - Dialog system integration (
DialogID,IdleDialogID) - SQLite repository with CRUD operations
- Lua scripting module (
tales.npcs.*) - Empty game loop hooks (
updateNPC(),handleNPCUpdates())
| Area | Gap |
|---|---|
| Templates | No template system for spawnable NPCs |
| Instancing | No way to spawn multiple instances from a single definition |
| Enemies | No combat stats, loot tables, aggro behavior |
| Merchants | No inventory, pricing, buy/sell mechanics |
| AI/Behavior | Game loop hooks exist but are empty |
| Movement | No patrol or wander behavior |
After analyzing the codebase, the recommended approach is to keep NPC as a unified base with optional trait composition rather than splitting into separate entity types.
Rationale:
- Existing Pattern - The codebase already uses this pattern (
EnemyTrait,MerchantTrait) - Flexibility - NPCs can combine traits (e.g., an enemy that also sells items when defeated)
- Shared Behavior - Movement, dialog, room presence are common to all types
- Simpler Storage - Single table, single repository, consistent API
- Scripting Consistency - One
tales.npcs.*module handles all types
Trade-off: Slightly more complex NPC struct vs. multiple simpler entity types
┌─────────────────────────────────────────────────────────────────┐
│ NPC Template │
│ (Definition - stored in DB, used as blueprint) │
│ - IsTemplate: true │
│ - Defines base stats, traits, behaviors │
│ - Not placed in rooms directly │
└─────────────────────────────────────────────────────────────────┘
│
│ Spawner creates instances
▼
┌─────────────────────────────────────────────────────────────────┐
│ NPC Instance │
│ (Runtime copy - exists in game world) │
│ - IsTemplate: false │
│ - TemplateID: references source template │
│ - InstanceID: unique suffix (e.g., "Rat-abc123") │
│ - Has current HP, position, state │
│ - Can be killed, respawned, etc. │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Singleton NPC │
│ (Unique NPC - only one in world) │
│ - IsTemplate: false │
│ - TemplateID: empty │
│ - Unique quest givers, merchants, bosses │
└─────────────────────────────────────────────────────────────────┘
| Field | Type | Description |
|---|---|---|
IsTemplate |
bool | If true, this NPC is a template (blueprint) |
TemplateID |
string | For instances: ID of source template |
InstanceSuffix |
string | Unique suffix for instanced NPCs (e.g., "abc123") |
Display Name Logic:
- Template:
"Rat"(stored name) - Instance:
"Rat"(display) but internally"Rat-abc123"for targeting
| Field | Type | Description |
|---|---|---|
SpawnRoomID |
string | Room where NPC spawns/respawns |
RespawnTime |
duration | Time to respawn after death (0 = no respawn) |
WanderRadius |
int | Rooms away from spawn it can wander (0 = stationary) |
PatrolPath |
[]string | Ordered list of room IDs for patrol route |
| Field | Type | Description |
|---|---|---|
IsDead |
bool | Currently dead (awaiting respawn) |
DeathTime |
time.Time | When NPC died (for respawn timing) |
State |
string | FSM state: "idle", "combat", "patrol", "dead" |
Replace the minimal EnemyTrait with a comprehensive combat configuration.
type EnemyTrait struct {
// Classification
CreatureType string `json:"creatureType"` // "beast", "humanoid", "undead", "elemental", "construct", "demon", "dragon", "aberration"
CombatStyle string `json:"combatStyle"` // "melee", "ranged", "magic", "swarm", "brute", "agile"
Difficulty string `json:"difficulty"` // "trivial", "easy", "normal", "hard", "boss"
// Combat Stats
AttackPower int32 `json:"attackPower"` // Base damage
Defense int32 `json:"defense"` // Damage reduction
AttackSpeed float64 `json:"attackSpeed"` // Attacks per second
// Behavior
AggroRadius int `json:"aggroRadius"` // Rooms away to detect players (0 = passive)
AggroOnSight bool `json:"aggroOnSight"` // Auto-attack on sight
CallForHelp bool `json:"callForHelp"` // Alert nearby enemies when attacked
FleeThreshold float64 `json:"fleeThreshold"` // HP % to flee (0 = never)
// Rewards
XPReward int64 `json:"xpReward"` // XP granted on kill
GoldDrop Range `json:"goldDrop"` // Min/max gold drop
LootTableID string `json:"lootTableID"` // Reference to loot table
// Scripts
OnAggroScript string `json:"onAggroScript"` // Script when entering combat
OnDeathScript string `json:"onDeathScript"` // Script when killed
OnFleeScript string `json:"onFleeScript"` // Script when fleeing
}
type Range struct {
Min int32 `json:"min"`
Max int32 `json:"max"`
}Replace the minimal MerchantTrait with trading functionality.
type MerchantTrait struct {
// Classification
MerchantType string `json:"merchantType"` // "general", "weapons", "armor", "magic"
// Inventory
Inventory []MerchantItem `json:"inventory"` // Items for sale
RestockTime duration `json:"restockTime"` // Time to restock (0 = infinite stock)
// Pricing
BuyPriceModifier float64 `json:"buyPriceModifier"` // Multiplier for buying from players (e.g., 0.5)
SellPriceModifier float64 `json:"sellPriceModifier"` // Multiplier for selling to players (e.g., 1.0)
// Restrictions
RequiredReputation int32 `json:"requiredReputation"` // Min reputation to trade
AcceptedItemTypes []string `json:"acceptedItemTypes"` // Item types merchant will buy
// Currency
Currency string `json:"currency"` // "gold", "tokens", custom currency
// Scripts
OnTradeScript string `json:"onTradeScript"` // Script after successful trade
}
type MerchantItem struct {
ItemTemplateID string `json:"itemTemplateId"` // Reference to item template
Stock int32 `json:"stock"` // Current stock (-1 = unlimited)
MaxStock int32 `json:"maxStock"` // Max stock after restock
PriceOverride int32 `json:"priceOverride"` // Custom price (0 = use item base price)
}A mechanism to spawn NPC instances from templates.
type NPCSpawner struct {
ID string `json:"id"`
TemplateID string `json:"templateId"` // NPC template to spawn
RoomID string `json:"roomId"` // Where to spawn
MaxInstances int `json:"maxInstances"` // Max alive at once
SpawnInterval time.Duration `json:"spawnInterval"` // Time between spawns
InitialCount int `json:"initialCount"` // Spawn on world load
// Runtime state
ActiveInstances []string `json:"activeInstances"` // IDs of current instances
LastSpawnTime time.Time `json:"lastSpawnTime"`
}- Spawner checks
MaxInstancesbefore spawning - Instance gets unique suffix:
fmt.Sprintf("%s-%s", template.Name, shortUUID()) - Instance copies template data but has own HP, state
- On instance death: remove from
ActiveInstances, start respawn timer - Respawn creates new instance with fresh stats
-- Template/Instance management
tales.npcs.spawnFromTemplate(templateId, roomId) -- Create instance
tales.npcs.isTemplate(id) -- Check if template
tales.npcs.getTemplateId(id) -- Get source template
tales.npcs.getInstancesOfTemplate(templateId) -- Get all instances
-- Enemy functions
tales.npcs.getAggroTargets(id) -- Get characters in aggro range
tales.npcs.attack(npcId, targetId) -- Initiate attack
tales.npcs.flee(id) -- Make NPC flee
tales.npcs.dropLoot(id, roomId) -- Spawn loot in room
-- Merchant functions
tales.npcs.getInventory(id) -- Get merchant inventory
tales.npcs.buyFromPlayer(npcId, charId, itemId) -- NPC buys item
tales.npcs.sellToPlayer(npcId, charId, itemId) -- NPC sells item
tales.npcs.restockMerchant(id) -- Force restock
-- Behavior
tales.npcs.setState(id, state) -- Set FSM state
tales.npcs.getState(id) -- Get current state
tales.npcs.patrol(id) -- Start patrol behavior
tales.npcs.wander(id) -- Random movement within radiusFor each NPC:
1. Skip if IsTemplate == true
2. Check respawn timer if dead
3. Based on State:
- "idle": Check aggro radius, run idle behaviors
- "patrol": Move along patrol path
- "combat": Execute combat logic
- "fleeing": Move away from threat
4. Check idle dialog timer
5. Execute periodic scripts if defined
For each Spawner:
1. Remove dead instances from ActiveInstances
2. If len(ActiveInstances) < MaxInstances:
- If SpawnInterval elapsed: spawn new instance
| Command | Description |
|---|---|
look <npc> |
View NPC description |
talk <npc> |
Start dialog (if has DialogID) |
attack <npc> |
Initiate combat (if enemy) |
trade <npc> / shop <npc> |
Open trade UI (if merchant) |
buy <item> from <npc> |
Purchase item |
sell <item> to <npc> |
Sell item |
| Command | Description |
|---|---|
/npc create <name> |
Create new NPC |
/npc spawn <templateId> |
Spawn instance from template |
/npc kill <npc> |
Kill NPC |
/npc respawn <npc> |
Force respawn |
/npc teleport <npc> <room> |
Move NPC to room |
/spawner create <templateId> |
Create spawner in current room |
type NPC struct {
*entities.Entity
traits.BelongsUser
traits.CurrentRoom
// Identity
Name string `json:"name"`
Description string `json:"description"`
// Template System
IsTemplate bool `json:"isTemplate"`
TemplateID string `json:"templateId,omitempty"`
InstanceSuffix string `json:"instanceSuffix,omitempty"`
// Stats
Race enums.Race `json:"race"`
Class enums.Class `json:"class"`
Level int32 `json:"level"`
CurrentHitPoints int32 `json:"currentHitPoints"`
MaxHitPoints int32 `json:"maxHitPoints"`
// Behavior Configuration
SpawnRoomID string `json:"spawnRoomId,omitempty"`
RespawnTime time.Duration `json:"respawnTime,omitempty"`
WanderRadius int `json:"wanderRadius,omitempty"`
PatrolPath []string `json:"patrolPath,omitempty"`
// State
IsDead bool `json:"isDead"`
DeathTime time.Time `json:"deathTime,omitempty"`
State string `json:"state"` // "idle", "combat", "patrol", "dead"
// Dialog
DialogID string `json:"dialogId,omitempty"`
IdleDialogID string `json:"idleDialogId,omitempty"`
IdleDialogTimeout time.Duration `json:"idleDialogTimeout,omitempty"`
// Behavior Traits (optional)
EnemyTrait *EnemyTrait `json:"enemyTrait,omitempty"`
MerchantTrait *MerchantTrait `json:"merchantTrait,omitempty"`
// Metadata
Created time.Time `json:"created"`
}CREATE TABLE IF NOT EXISTS npc_spawners (
id TEXT PRIMARY KEY,
data TEXT NOT NULL
);| Method | Endpoint | Description |
|---|---|---|
| POST | /api/npcs/:id/spawn |
Spawn instance from template |
| GET | /api/npcs/templates |
List all NPC templates |
| GET | /api/npcs/instances |
List all NPC instances |
| GET | /api/npcs/instances/:templateId |
List instances of specific template |
| POST | /api/spawners |
Create spawner |
| GET | /api/spawners |
List spawners |
| PUT | /api/spawners/:id |
Update spawner |
| DELETE | /api/spawners/:id |
Delete spawner |
| Method | Endpoint | Change |
|---|---|---|
| GET | /api/npcs |
Add ?isTemplate=true/false filter |
| POST | /api/npcs |
Accept enhanced trait structures |
- Update NPC struct with template fields
- Add
IsTemplate,TemplateID,InstanceSuffix - Implement
spawnFromTemplatein service layer - Add Lua function
tales.npcs.spawnFromTemplate() - Update API with template filtering
- Expand
EnemyTraitstruct - Add combat stats, aggro settings, loot config
- Implement basic combat loop in game tick
- Add Lua combat functions
- Implement loot drop system
- Expand
MerchantTraitstruct - Implement merchant inventory system
- Add buy/sell transaction logic
- Add Lua merchant functions
- Implement restock timer
- Create
NPCSpawnerentity - Add spawner repository and service
- Implement spawn/despawn logic in game loop
- Add spawner API endpoints
- Add Lua spawner functions
- Implement NPC state machine
- Add patrol behavior
- Add wander behavior
- Implement aggro detection
- Add flee behavior
- Can create NPC templates and spawn multiple instances
- Instances have unique identifiers and independent state
- Enemies can aggro, attack, die, drop loot, and respawn
- Merchants can buy/sell items with proper pricing
- Spawners maintain correct instance counts
- NPCs can patrol or wander between rooms
- All functionality accessible via Lua scripting
- Backward compatible with existing NPC data
- Combat System Scope: Separate PRD - this PRD covers NPC structure, templates, and spawning only
- Merchant System: Separate PRD - excluded from initial implementation
- Loot Tables: Embedded in EnemyTrait (not separate entity)
- Instance Persistence: Memory only - instances are recreated on server restart
- Currency: Gold only
{
"name": "Rat",
"description": "A mangy sewer rat with beady red eyes.",
"isTemplate": true,
"level": 1,
"maxHitPoints": 15,
"currentHitPoints": 15,
"enemyTrait": {
"creatureType": "beast",
"combatStyle": "swarm",
"difficulty": "trivial",
"attackPower": 3,
"defense": 0,
"attackSpeed": 1.5,
"aggroRadius": 0,
"aggroOnSight": false,
"xpReward": 10,
"goldDrop": {"min": 0, "max": 2}
},
"respawnTime": "30s",
"wanderRadius": 1
}{
"name": "Gareth the Blacksmith",
"description": "A burly man with soot-stained arms and a friendly smile.",
"isTemplate": false,
"level": 10,
"dialogId": "blacksmith-dialog",
"merchantTrait": {
"merchantType": "weapons",
"buyPriceModifier": 0.4,
"sellPriceModifier": 1.0,
"inventory": [
{"itemTemplateId": "iron-sword", "stock": 5, "maxStock": 5},
{"itemTemplateId": "iron-shield", "stock": 3, "maxStock": 3}
],
"restockTime": "1h",
"acceptedItemTypes": ["weapon", "armor"]
}
}{
"name": "Goblin Scout",
"description": "A sneaky goblin carrying a crude dagger.",
"isTemplate": true,
"level": 3,
"maxHitPoints": 35,
"race": {"id": "goblin", "name": "Goblin"},
"class": {"id": "rogue", "name": "Rogue"},
"dialogId": "goblin-taunt",
"enemyTrait": {
"creatureType": "humanoid",
"combatStyle": "agile",
"difficulty": "easy",
"attackPower": 8,
"defense": 2,
"aggroRadius": 1,
"aggroOnSight": true,
"callForHelp": true,
"xpReward": 25,
"goldDrop": {"min": 5, "max": 15},
"lootTableID": "goblin-loot"
},
"respawnTime": "2m"
}