The NPC Framework provides a comprehensive, platform-agnostic system for creating and managing NPCs in the Argonath Systems game server.
The framework follows the Accessor Pattern to maintain platform independence. All Hytale-specific code is isolated in the adapter layer.
04-framework-npc/
├── api/ # Public API (NPCDefinition, builders)
├── behavior/ # Sound, movement, animation behaviors
├── config/ # Configuration loading
├── event/ # NPC lifecycle events
├── loot/ # Loot table integration
├── narrative/ # Name generation, personality traits
├── origin/ # Multi-dimensional origin system
└── spawn/ # Spawn and appearance configuration
NPCDefinition blacksmith = NPCDefinitionBuilder.create("blacksmith_thorin")
.displayName("Thorin Ironforge")
.profession("blacksmith")
.wealthLevel(3)
.backstory("A skilled dwarven craftsman...")
// Multi-layered origin
.origin(CompositeOrigin.builder()
.geographic("city", "Major City")
.cultural("dwarven", "Dwarven Kingdoms")
.social("artisan", "Skilled Artisan")
.build())
// Personality traits
.addPersonalityTrait("hardworking")
.addPersonalityTrait("perfectionist")
// Appearance
.appearance(NPCAppearance.builder()
.entityType("hytale:dwarf")
.skin("skins/dwarf_blacksmith")
.mainHand("item:blacksmith_hammer")
.scale(0.9f)
.build())
// Behaviors
.soundBehavior(NPCSoundBehavior.builder()
.onInteract("npc:dwarf_greet")
.onDeath("npc:dwarf_death")
.build())
.movementBehavior(NPCMovementBehavior.walking())
.animationBehavior(NPCAnimationBehavior.humanoid())
// Loot
.lootTable("loot:dwarf_blacksmith")
// Spawn location
.spawn(NPCSpawnConfig.at("world", 100, 64, 200))
.build();
The unified interface representing a complete NPC template.
public interface NPCDefinition {
String getTemplateId();
String getDisplayName();
String getProfession();
int getWealthLevel();
Optional<CompositeOrigin> getOrigin();
Set<String> getPersonalityTraits();
Optional<NPCAppearance> getAppearance();
Optional<NPCSoundBehavior> getSoundBehavior();
Optional<NPCMovementBehavior> getMovementBehavior();
Optional<NPCAnimationBehavior> getAnimationBehavior();
Optional<NPCLootConfig> getLootConfig();
Optional<NPCSpawnConfig> getSpawnConfig();
Optional<String> getBackstory();
Optional<String> getDialogTreePath();
boolean isInvulnerable();
boolean isInteractable();
}
Fluent builder for creating NPC definitions.
NPCDefinitionBuilder builder = NPCDefinitionBuilder.create("npc_id")
.displayName("NPC Name")
.profession("merchant")
.wealthLevel(2) // 0-5 scale
.invulnerable(false)
.interactable(true);
NPCs can have origins across multiple dimensions:
| Dimension | Examples |
|---|---|
| Geographic | village, city, rural, nomadic, foreign |
| Cultural | francia, roman, byzantine, persian |
| Social | peasant, artisan, merchant, noble |
| Religious | devout, secular, clergy |
| Occupational | farmer, smith, scholar, soldier |
CompositeOrigin origin = CompositeOrigin.builder()
.geographic("village", "Small Village")
.cultural("francia", "Frankish Kingdom")
.social("peasant", "Peasant Class")
.religious("devout", "Devout Believer")
.build();
// Query dimensions
origin.getGeographic(); // Optional<OriginDimension>
origin.getCultural(); // Optional<OriginDimension>
// Generate narrative
String narrative = origin.toNarrativeString();
// "a peasant from Small Village in Frankish Kingdom"
Configure available origin values:
OriginRegistry registry = OriginRegistry.withDefaultTypes();
registry.registerDimension("geographic",
new OriginDimension("castle", "Castle", "A fortified stronghold"));
// Or load from config
OriginRegistry registry = configLoader.loadOriginRegistry();
Event-driven sound configuration:
NPCSoundBehavior sounds = NPCSoundBehavior.builder()
.onInteract("npc:greet")
.onHit("npc:pain")
.onDeath("npc:death")
.onKill("npc:victory")
.onSpawn("npc:spawn")
.volume(0.8f)
.pitchVariation(0.2f)
// Ambient sounds
.addAmbient("idle", "npc:idle_hum", 0.3f, 30, 120)
.build();
NPCMovementBehavior movement = NPCMovementBehavior.builder()
.style(MovementStyle.WALK)
.baseSpeed(1.0f)
.canSprint(true)
.canCrouch(false)
// Wandering behavior
.wander(20.0f, 5, 30, true) // radius, minWait, maxWait, stayInArea
// Or patrol path
.patrol("path:market_square")
.build();
// Presets
NPCMovementBehavior.stationary();
NPCMovementBehavior.walking();
NPCAnimationBehavior animations = NPCAnimationBehavior.builder()
.idle("anim:idle")
.walk("anim:walk")
.run("anim:run")
.interact("anim:greet")
.attack("anim:attack")
.death("anim:death")
// Custom animations
.wave("anim:wave")
.fidget("anim:fidget", 0.3f, 60) // 30% chance, 60s cooldown
.build();
// Preset
NPCAnimationBehavior.humanoid();
NPCLootConfig loot = NPCLootConfig.builder()
.lootTable("loot:goblin_warrior")
.dropRateModifier(1.5f)
.luckInfluence(0.8f)
// Guaranteed drops
.addGuaranteedDrop("item:goblin_ear", 1)
.addGuaranteedDrop("item:gold_coin", 5, 10) // 5-10 coins
// Chance drops
.addChanceDrop("item:rare_gem", 1, 0.05f) // 5% chance
.dropsOnDeath(true)
.dropsOnPickpocket(false)
.build();
// Shorthand
NPCLootConfig.fromTable("loot:skeleton");
NPCLootConfig.none();
Automatically drops loot on NPC death events:
NPCLootService lootService = new NPCLootService(
lootTableAccessor,
eventAccessor,
npcId -> lootConfigs.get(npcId),
npcId -> wealthLevels.get(npcId)
);
lootService.start(); // Begin listening to death events
All events in the accessor framework must implement AccessorEvent for compile-time type safety:
// AccessorEvent - base interface for all events
public interface AccessorEvent {
default String getEventName();
default long getTimestamp();
}
// CancellableEvent - for events that can be cancelled
public interface CancellableEvent extends AccessorEvent {
boolean isCancelled();
void setCancelled(boolean cancelled);
}
// NPCEvent - base for all NPC events
public interface NPCEvent extends AccessorEvent {
String getNpcId();
}
This ensures that only proper event types can be registered:
// ✅ Compiles - NPCDeathEvent extends NPCEvent extends AccessorEvent
eventAccessor.register(NPCDeathEvent.class, event -> { ... });
// ❌ Won't compile - String doesn't extend AccessorEvent
eventAccessor.register(String.class, s -> { ... });
| Event | Trigger |
|---|---|
NPCSpawnEvent |
NPC spawns in world |
NPCInteractEvent |
Player interacts with NPC |
NPCDamageEvent |
NPC takes damage |
NPCDeathEvent |
NPC dies |
NPCKillEvent |
NPC kills something |
// Subscribe to events
eventAccessor.register(NPCDeathEvent.class, event -> {
System.out.println("NPC " + event.npcId() + " died at " +
event.x() + ", " + event.y() + ", " + event.z());
if (event.wasKilledByPlayer()) {
UUID killer = event.killerId();
// Award XP, update quests, etc.
}
});
// Event data
NPCInteractEvent event = ...;
event.npcId(); // String
event.playerId(); // UUID
event.interactionType(); // CLICK, APPROACH, TRADE, DIALOGUE, etc.
Traits with weights for random selection:
WeightedPersonalityTrait trait = new WeightedPersonalityTrait(
"ambitious",
"Ambitious",
"Driven to succeed",
TraitCategory.ECONOMIC,
1.0f, // Base weight
new String[]{} // Conflicting traits
);
// With conflicts
WeightedPersonalityTrait greedy = WeightedPersonalityTrait.weighted(
"greedy", "Greedy", TraitCategory.ECONOMIC, 0.6f
).withConflicts("generous");
PersonalityTraitRegistry registry = PersonalityTraitRegistry.withDefaults();
// Select random traits
Set<String> traits = registry.selectTraits(
TraitSelectionConfig.builder()
.minTraits(2)
.maxTraits(4)
.maxPerCategory(1)
.excludeTrait("greedy")
.build()
);
// Returns e.g. {"brave", "curious", "hardworking"}
NPCAppearance appearance = NPCAppearance.builder()
.entityType("hytale:human")
.skin("skins/blacksmith_male")
.modelVariant("burly")
// Equipment
.mainHand("item:hammer")
.offHand("item:tongs")
.helmet("item:leather_cap")
.chestplate("item:leather_apron")
// Visual effects
.scale(1.1f)
.glowing(false)
.nametagColor("§6")
// Custom data
.customData("beard_style", "long")
.build();
// Shorthand
NPCAppearance.humanoid("skins/villager");
NPCSpawnConfig spawn = NPCSpawnConfig.builder()
.world("overworld")
.position(100.5, 64.0, -200.5)
.rotation(90.0f, 0.0f) // yaw, pitch
.spawnType(SpawnType.RESPAWNING)
.respawnDelay(600) // seconds
.persistent(true)
.spawnGroup("market_vendors")
.build();
// Shorthand
NPCSpawnConfig.at("world", 100, 64, 200);
NPCs can be loaded from a directory of YAML or JSON files:
config/npcs/
├── blacksmith.yml # Single NPC definition
├── merchants/
│ ├── trader_marcus.yml
│ └── vendor_elara.yml
└── guards/
├── city_guard.yml
└── night_watch.yml
The NPCConfigStore provides full CRUD operations for NPC configurations:
// Initialize the store
Path npcConfigDir = Path.of("config/npcs");
NPCConfigStore store = new NPCConfigStore(npcConfigDir);
// Load all NPCs from directory
Map<String, NPCDefinition> allNPCs = store.loadAll();
// Load a specific NPC
Optional<NPCDefinition> blacksmith = store.load("blacksmith");
// Save a new NPC
NPCDefinition newNPC = NPCDefinitionBuilder.create("new_merchant")
.displayName("New Merchant")
.profession("merchant")
.build();
store.save(newNPC);
// Save to a subdirectory
store.save(newNPC, "merchants");
// Update an existing NPC
NPCDefinition updated = NPCDefinitionBuilder.create("blacksmith")
.displayName("Updated Blacksmith")
.profession("blacksmith")
.wealthLevel(4)
.build();
store.update(updated);
// Delete an NPC
boolean deleted = store.delete("old_npc");
// Hot reload a specific NPC
store.reload("blacksmith");
// Reload all NPCs
store.reloadAll();
// Get all loaded NPCs
Collection<NPCDefinition> all = store.getAll();
// Get all template IDs
Set<String> ids = store.getAllIds();
// Get NPCs by profession
List<NPCDefinition> merchants = store.getByProfession("merchant");
// Get NPCs by faction
List<NPCDefinition> gondorNPCs = store.getByFaction("gondor");
// Check if NPC exists
boolean exists = store.exists("blacksmith");
Convert NPCDefinitions back to configuration maps for saving:
NPCDefinitionSerializer serializer = new NPCDefinitionSerializer();
// Serialize to Map (can be written as YAML or JSON)
Map<String, Object> configData = serializer.serialize(npcDefinition);
origins.yml:
dimension_types:
geographic:
display_name: "Geographic Origin"
priority: 100
required: true
values:
- id: village
display_name: "Village"
description: "A small rural settlement"
- id: city
display_name: "City"
description: "A major urban center"
personality-traits.yml:
categories:
economic:
traits:
- id: greedy
weight: 0.6
conflicts: [generous]
- id: generous
weight: 0.8
conflicts: [greedy]
NPCConfigLoader loader = new NPCConfigLoader(originConfig, traitConfig);
OriginRegistry origins = loader.loadOriginRegistry();
PersonalityTraitRegistry traits = loader.loadPersonalityTraitRegistry();
NPCDefinitionParser parser = new NPCDefinitionParser();
NPCDefinition npc = parser.parse("blacksmith_01", configMap);
Event-driven sound playback:
NPCSoundService soundService = new NPCSoundService(
soundAccessor,
eventAccessor,
npcId -> soundBehaviors.get(npcId),
npcId -> locations.get(npcId)
);
soundService.start(); // Subscribe to events
// Later...
soundService.stop(); // Cleanup
Event-driven loot drops:
NPCLootService lootService = new NPCLootService(
lootTableAccessor,
eventAccessor,
npcId -> lootConfigs.get(npcId),
npcId -> wealthLevels.get(npcId)
);
lootService.start();
Configure combat AI including aggression, factions, and abilities:
NPCCombatBehavior combat = NPCCombatBehavior.builder()
.combatEnabled(true)
.aggression(NPCCombatBehavior.AggressionLevel.DEFENSIVE)
.attackRange(2.0f)
.chaseRange(20.0f)
.retreatAt(0.2f) // Retreat at 20% health
// Faction relationships
.faction("gondor")
.hostileTo("mordor", "isengard")
.friendlyTo("rohan", "shire")
// Call for help
.callsForHelp(true)
.helpCallRange(25.0f)
// Combat style
.combatStyle(NPCCombatBehavior.CombatStyle.MELEE)
.ability("power_strike")
.ability("shield_bash")
// Blocking
.canBlock(true)
.blockChance(0.3f)
.build();
// Presets
NPCCombatBehavior.pacifist(); // Will never fight
NPCCombatBehavior.defensive("gondor"); // Fights when attacked
NPCCombatBehavior.aggressive("mordor", "gondor", "rohan"); // Attacks on sight
NPCCombatBehavior.guard("city_guard"); // Defends area
| Level | Behavior |
|---|---|
PASSIVE |
Will never attack |
DEFENSIVE |
Only attacks when attacked first |
AGGRESSIVE |
Attacks hostiles on sight |
BERSERK |
Attacks everything on sight |
Define time-based activities for NPCs:
NPCScheduleBehavior schedule = NPCScheduleBehavior.builder()
.at("06:00", ScheduleAction.WAKE_UP, null)
.at("07:00", ScheduleAction.WORK, "shop_counter")
.at("12:00", ScheduleAction.BREAK, "tavern")
.at("13:00", ScheduleAction.WORK, "shop_counter")
.at("18:00", ScheduleAction.REST, "home")
.at("21:00", ScheduleAction.SLEEP, "bedroom")
.loopDaily(true)
.defaultActivity("IDLE")
.build();
// Query current activity
Optional<ScheduleEntry> activity = schedule.getCurrentActivity(LocalTime.of(10, 30));
// Returns: WORK at shop_counter
// Check if NPC is active
boolean active = schedule.isActiveAt(LocalTime.of(23, 0));
// Returns: false (sleeping)
// Presets
NPCScheduleBehavior.shopkeeper(); // Standard shop hours
NPCScheduleBehavior.nightGuard(); // Active at night
NPCScheduleBehavior.farmer(); // Early riser, works fields
NPCScheduleBehavior.travelingMerchant(); // Present only during day
| Action | Description |
|---|---|
WAKE_UP |
NPC wakes and becomes active |
WORK |
NPC goes to work location |
BREAK |
NPC takes a break |
REST |
Reduced interaction |
SLEEP |
No interaction |
PATROL |
Follows patrol path |
TRADE |
Trading activities |
ARRIVE / LEAVE |
For traveling NPCs |
SPAWN / DESPAWN |
Visibility control |
Configure social interactions:
NPCSocialBehavior social = NPCSocialBehavior.builder()
.awarenessRadius(10.0f)
.interactionRadius(3.0f)
.greetsPlayers(true)
.greetingCooldown(60) // seconds
.remembersPlayers(true)
.tracksReputation(true)
.personality(SocialPersonality.FRIENDLY)
// Reputation-gated dialogs
.requireReputation("secret_quest", 500)
.requireReputation("advanced_items", 200)
.unlockDialog("basic_greeting")
.build();
// Check dialog availability
boolean canAccess = social.isDialogUnlocked("secret_quest", playerRep);
// Get all available dialogs
Set<String> dialogs = social.getAvailableDialogs(playerReputation);
// Presets
NPCSocialBehavior.friendly();
NPCSocialBehavior.formal();
NPCSocialBehavior.shy();
NPCSocialBehavior.hostile();
Configure merchant/vendor NPCs:
NPCTradeBehavior trade = NPCTradeBehavior.builder()
.tradingEnabled(true)
.tradeList("blacksmith_goods")
.restockInterval(3600) // 1 hour
.reputationRequired(100)
// Dynamic pricing
.dynamicPricing(true)
.minPriceMultiplier(0.5f)
.maxPriceMultiplier(2.0f)
// Haggling
.hagglingEnabled(true)
.maxDiscount(0.15f) // 15% max discount
.maxHaggleAttempts(3)
// Currencies
.acceptCurrency("gold")
.acceptCurrency("silver")
// Faction discounts
.factionDiscount("gondor", 0.1f) // 10% off
.factionDiscount("shire", 0.2f) // 20% off
.build();
// Price calculation
int finalPrice = trade.calculatePrice(basePrice, supplyModifier, playerFaction);
// Presets
NPCTradeBehavior.disabled();
NPCTradeBehavior.shopkeeper("general_store");
NPCTradeBehavior.travelingMerchant("rare_goods");
NPCTradeBehavior.blackMarket("contraband");
For persisting dynamic NPC state across server restarts:
NPCInstanceData instance = new NPCInstanceData();
instance.setNpcInstanceId(UUID.randomUUID());
instance.setNpcTemplateId("blacksmith_thorin");
instance.setCustomName("Thorin the Wise");
// Location
instance.setLocationX(100.0);
instance.setLocationY(64.0);
instance.setLocationZ(200.0);
instance.setWorldName("overworld");
// State
instance.setCurrentBehavior("WORKING");
instance.setAlive(true);
instance.setHealth(100.0);
// Inventory (for merchants)
instance.getInventory().put("iron_sword", 5);
instance.setCurrencyAmount(500L);
// Quest state
instance.setQuestGiver(true);
instance.getAvailableQuests().put("forge_the_ring", true);
// Interaction tracking
instance.getPlayerInteractionCounts().put(playerId, 10);
stop() on services during plugin shutdownNPCMovementBehavior.walking() for common patterns