Kzeroko KTM2

NPC emotion runtime

Emotion reservoirs, complete temperament tuning, reaction formulas, persistence and body-emote contracts.

Updated Sep 19, 2026 17 min read

Emotional simulation belongs to UniqueNpcEntity and is implemented by internal.ai.emotion.UniqueNpcEmotionManager. The shared NpcEmotionManager is an internal interface, not a public facade; other NPCs use a no-op implementation. See NPC moods and personalities for player meanings and dialogue fields.

What it does

Events add impulse and memory, context sensing changes situational targets, and the manager resolves primary and secondary moods. Only the primary mood ordinal travels through entity tracked data. Reservoirs remain server-side, while dialogue obtains a separate snapshot. NpcEmoteManager handles body animations independently of emotional intensity and emoji stickers.

Runtime model

Reservoirs and time

Each mood stores Impulse, Memory and Context, each clamped to 0–100. Displayed intensity is min(100, Impulse + Memory + Context). Stimulation sanitises raw intensity to finite 0–100, then multiplies by temperament reactivity × responseScale(mood). Retention, clamped to 0–1, splits that amount into memory and impulse; each reservoir caps separately. Soothing does not apply temperament multipliers: it removes impulse, then context, then memory, using at most 100 sanitised intensity.

State updates every 20 ticks and mood resolution runs on a separate 20-tick schedule. Recovery uses elapsed world ticks divided by 20, capped at 7 days; time moving backwards resets the baseline first. Context movement uses at most 2 seconds per update. Impulse/memory lose seconds × 100 / lifetime × recoveryScale; sensitive moods additionally multiply recovery by resilience. Residuals below 0.01 become zero.

Construction advances composed context to 9. NBT reading first clears every reservoir; an absent or mismatched schema does not reinsert that initial 9, so later sensing approaches the target gradually. The idle counter advances once per sample, without converting offline seconds into samples.

Mood tuning

Lifetimes are seconds for a full 100-intensity reservoir to empty at recovery multiplier 1, not a fixed duration for every stimulus. Context rates are intensity per second. A true resilience column applies the extra resilience recovery multiplier.

Mood keyImpulse lifetimeMemory lifetimeContext riseContext fallActivation thresholdresilience
composed553008.012.00.0false
joyful909006.07.07.0false
affectionate12018005.05.08.0false
flustered3221013.015.06.0false
excited3624012.014.07.0false
focused4518015.018.08.0false
bored483007.012.08.0false
uneasy659609.08.07.0true
hurt13521005.05.08.0true
angry80135010.07.08.0true
exhausted21027007.05.08.0true

Primary selection starts with composed, then follows enum order, accepting a mood at its threshold only when strictly stronger than the current candidate. Equal intensity retains the earlier candidate. A different candidate replaces the current mood if any condition holds: lead of at least 3.5, current mood below its threshold, or 60 ticks since the last switch. Completed dwell does not also require a 3.5 lead. Secondary selection takes the strongest active non-composed mood other than the primary, also keeping the earlier candidate on a tie.

Event wrappers, stimulate(MoodImpulse) and soothe request resolution no later than the next server tick rather than immediately synchronising a switch. The internal three-argument stimulate(Mood,float,float) does not request fast resolution itself; the reaction model uses it to combine changes.

Context targets

Each sample clears targets, takes the maximum of overlapping sources, multiplies by that mood’s responseScale and clamps to 0–100. Reactivity does not apply.

SituationRaw target
Ordinary / sleepingcomposed 9.0 / 22.0
Busy POIfocused 34.0 × energy
Target exists or combat phase is not NONEfocused 16.0 × courage; excited 23.0 × energy × courage
Awake and health fraction below 0.50exhausted 38.0 × danger; also uneasy 25.0 × danger × inverse(courage) in combat
More than 25 idle samplesbored 30.0 × clamp((idleSeconds - 25) / 100, 0, 1) × energy

Here danger = (0.50 - healthRatio) / 0.50. Idling requires no sleep, active POI, combat, dialogue or tracked item use, idle navigation and total velocity squared below 0.0025. Any failed condition resets the counter. inverse(t) = clamp(2 - t, 0.45, 1.55). Leaving a situation approaches zero at the fall rate; clearing the target does not immediately clear the context reservoir.

Temperament tuning

The manager captures its personality profile from NPC configuration at construction. Traits must be finite and positive. Mood-scale arrays must match the mood count and are copied on construction; a missing personality profile fails registry initialisation. Unspecified response/recovery scales are 1.00.

TraitUse
temperAnger and attack excitement.
resilienceRecovery of sensitive moods; exhaustion relief on consumption.
energyExcitement, work focus and boredom.
empathyHurt from friendly damage or negative gift signals.
prideAnger from hostile damage or negative gift signals.
bondAffinityAffection and other positive gift reactions; friendly-damage hurt.
courageCombat focus/excitement; inverse contribution to uneasiness.
composureInverse contribution to fluster.
reactivityAll event intensity; not context targets.
Personality keytemperresilienceenergyempathypridebondAffinitycouragecomposurereactivity
default1.001.001.001.001.001.001.001.001.00
tsundere1.300.950.950.951.251.101.050.821.12
optimistic0.901.301.081.000.901.051.151.050.95
yandere1.120.781.001.301.081.450.950.721.22
gloomy0.880.780.901.120.920.950.820.940.96
kuudere0.821.250.880.921.050.921.101.420.72
genki1.051.051.381.000.951.051.180.861.18
loyal1.081.181.051.181.081.351.221.051.05
shy0.780.920.821.150.821.220.780.721.08
dojikko0.920.921.081.080.881.120.840.681.18
lazy0.721.020.680.920.820.900.921.080.78
tennen0.781.281.121.000.781.081.020.760.92
chatterbox0.981.051.281.080.921.181.000.781.20
mukuchi0.721.240.780.951.020.821.081.480.62
ojou_sama1.180.881.050.921.420.981.020.821.18
onee_san0.781.321.001.300.921.151.181.300.88
tomboy1.221.121.280.921.080.981.340.881.08
bookworm0.751.120.761.080.920.920.921.280.78
trickster1.021.121.340.780.821.001.150.721.22
cowardly0.680.820.821.120.721.080.580.681.20
justice1.201.201.151.121.081.021.421.001.05
workaholic0.921.051.120.921.050.881.101.051.05
chuunibyou1.120.981.220.881.240.921.280.621.22
Personality keyresponseScalerecoveryScale
default——
tsundereangry 1.30, flustered 1.65, affectionate 0.78angry 0.90, flustered 1.15
optimisticjoyful 1.35, hurt 0.72, uneasy 0.75hurt 1.35, uneasy 1.30
yandereaffectionate 1.50, hurt 1.40, angry 1.20affectionate 0.72, hurt 0.68, angry 0.78
gloomyhurt 1.45, bored 1.18, joyful 0.72hurt 0.62, bored 0.82
kuuderecomposed 1.30, flustered 0.55, excited 0.65angry 1.30, uneasy 1.30
genkijoyful 1.25, excited 1.45, bored 1.35bored 1.30, hurt 1.15
loyalaffectionate 1.30, hurt 1.20, focused 1.20uneasy 1.20
shyflustered 1.75, affectionate 1.20, uneasy 1.30, angry 0.62flustered 0.88
dojikkoflustered 1.55, uneasy 1.25, joyful 1.18flustered 1.15
lazycomposed 1.35, bored 0.72, excited 0.60, exhausted 1.38exhausted 0.78
tennenjoyful 1.45, hurt 0.62, angry 0.58hurt 1.42
chatterboxexcited 1.42, bored 1.35, joyful 1.20, composed 0.72bored 1.25
mukuchicomposed 1.42, flustered 0.42, excited 0.55, angry 0.70angry 1.30
ojou_samaangry 1.38, bored 1.32, flustered 1.18angry 0.82
onee_sanaffectionate 1.32, focused 1.18, angry 0.68, uneasy 0.72hurt 1.28
tomboyexcited 1.42, focused 1.18, uneasy 0.58, flustered 0.72bored 1.30
bookwormfocused 1.45, composed 1.22, bored 0.62, excited 0.58focused 0.85
tricksterexcited 1.52, joyful 1.28, bored 1.42, hurt 0.62bored 1.35
cowardlyuneasy 1.75, exhausted 1.28, angry 0.52, excited 0.58uneasy 0.72
justiceangry 1.32, focused 1.28, excited 1.22, uneasy 0.55hurt 1.28
workaholicfocused 1.60, exhausted 1.32, bored 0.62, composed 0.62focused 0.78, exhausted 0.85
chuunibyouexcited 1.52, focused 1.20, flustered 1.20, uneasy 0.65excited 0.82

Built-in reaction formulas

The table gives stimuli before the common temperament response multiplier. s = clamp(amount / max(1, maxHealth), 0, 1); d = clamp((0.45 - healthRatio) / 0.45, 0, 1); m = clamp(abs(favorGain) / 30, 0, 1). R(x,a,b) = a + (b-a) × x² × (3-2x); inverse is defined above. The ordinary damage path notifies emotions only after successfully applied damage, separately from combat’s attempted-hit hostility counter.

EventMoodRaw intensityMemory retention
Friendly/trusted player damagehurt(8 + 48s) × empathy × bondAffinityR(s, 0.35, 0.86)
Same eventangry(2 + 17s) × temperR(s, 0.12, 0.48)
Other damageuneasy(3 + 25s) × inverse(courage)R(s, 0.10, 0.56)
Other living attackerangry(5 + 31s) × temper × prideR(s, 0.12, 0.64)
Damage with current targetexcited(2 + 10s) × energy × courageR(s, 0.0, 0.20)
Post-hit health below 0.45exhausted5 + 22dR(d, 0.18, 0.66)
Same low-health eventuneasy(4 + 18d) × inverse(courage)R(d, 0.16, 0.72)
Consumed eat/drink category or food stackjoyful5 × max(0.85, bondAffinity)0.08
Same consumptionexcited2.4 × energy0.0
Negative gift signalhurt(5 + 14m) × empathyR(m, 0.22, 0.62)
Same negative signalangry(2 + 10m) × prideR(m, 0.08, 0.35)
Zero gift signalflustered2 × inverse(composure)0.0
Positive gift signaljoyful(7 + 15m) × bondAffinityR(m, 0.08, 0.28)
Same positive signalaffectionate(5 + 18m) × bondAffinityR(m, 0.24, 0.66)
Same positive signalflustered(3 + 10m) × bondAffinity × inverse(composure)R(m, 0.0, 0.20)
Attack performedexcited4 × energy × max(0.85, temper)0.0
Same attackfocused2.5 × courage0.0

Additional soothing: consumption removes bored 4 and exhausted 3 × resilience; a zero gift removes bored 2; a positive gift removes bored 6, hurt 2 + 4m and angry 1 + 3m; an attack removes bored 4. The negative gift-signal branch does not soothe. Gift acceptance belongs to the interaction layer; the emotion method receives only an integer signal and does not validate an item.

Contracts

Interface and saved fields

BaseNpcEntity.getEmotionManager() returns the shared internal interface. The disabled implementation reports composed, intensity 0, a snapshot without secondary mood and the default temperament; events and persistence are no-ops. Unique NPCs invoke the active manager from role ticks, successful damage, item use and role-NBT hooks.

InterfaceEffect
serverTickAdvance recovery, context targets and primary resolution
onDamageTaken, onItemConsumed, onGiftReceived, onAttackPerformedBuilt-in reaction wrappers
stimulate(MoodImpulse)Apply profile-scaled stimulus; null payload/mood rejected
soothe(Mood,float)Soothe a mood; null mood rejected, nonfinite intensity treated as 0
currentMood, intensity, snapshot, temperamentRead current primary, total intensity, presentation snapshot and captured profile
writeToNbt, readFromNbtSave or clear then restore versioned state

MoodImpulse clamps intensity to 0–100 and retention to 0–1, treating nonfinite values as 0. Fleeting uses retention 0; lingering uses the supplied retention. Snapshot normalisation divides each intensity by 100 and clamps to 0–1.

FieldTypeDefaultWhat it does
NpcEmotioncompoundAbsentRoot of schema-controlled state.
NpcEmotion.Schemainteger0Must equal 1. Missing/mismatched schema leaves cleared state.
NpcEmotion.CurrentMoodstringcomposedCase-insensitive mood key; unknown/blank falls back, surrounding whitespace is not trimmed.
NpcEmotion.LastChangeTicklong0World tick of last dominant switch; read without time clamp.
NpcEmotion.LastUpdateTicklong0Last recovery tick; absent root/reset uses an unset sentinel instead. Future time resets to current time on tick.
NpcEmotion.IdleSecondsinteger0Non-negative idle sample counter, not offline elapsed seconds.
NpcEmotion.StatescompoundEmptyOne compound per known lowercase mood key; unknown entries ignored.
NpcEmotion.States.<mood>.Impulsenumber0Finite 0–100; out-of-range clamped, nonnumeric/nonfinite becomes 0.
NpcEmotion.States.<mood>.Memorynumber0Same validation; retained event intensity.
NpcEmotion.States.<mood>.Contextnumber0Same validation; current situational intensity, not the target.

Only Schema 1 is accepted. Missing mood compounds remain cleared; loading enumerates current keys, so unknown fields are not retained for later recovery and old emotion roots are not migrated. Persistence uses keys while client tracking uses enum ordinals; enum order changes require matching clients and servers. Load synchronisation does not invoke the mood-change hook; normal dominant switches invoke onMoodChanged. Direct setMood changes the tracker, not server reservoirs, and the manager can overwrite it later.

Body emotes

NPC registerDefaultEmotes and registerEmotes populate definitions lazily, then the manager caches them. The base default-registration hook is empty. Array indices synchronise to clients, so order must match. Duplicate IDs do not throw: ID lookup takes the last registration while the idle pool retains every flagged entry.

FieldTypeDefaultWhat it does
idstringRequiredNon-null, nonempty; whitespace is accepted. Duplicate lookup resolves to last registration.
animationKeystringRequiredNon-null, nonempty Gecko clip key; constructor does not verify asset existence.
durationTicksintegerRequiredClamped to at least 1; limits both looping and non-looping playback.
loopbooleanRequiredLoop clip when true; otherwise play and hold until duration ends.
priorityintegerRequiredClamped to 0–1000; ordinary interruption needs at least current priority.
cooldownTicksintegerRequiredClamped to at least 0; applies both local and global cooldown at start.
flagsinteger bitmaskRequired1 idle pool; 2 navigation lock; 4 movement lock, also implies navigation lock. Unknown bits ignored by these checks.
interruptiblebooleanRequiredWhether ordinary requests may replace this emote; force bypasses it.

tryEmote respects cooldowns and the current emote’s interruption/priority rules. forceEmote bypasses those checks, but still obeys physical state and ordinary dialogue locking. queueEmote holds a single pending ID: later requests replace it and an unknown ID clears it. When idle, the manager consumes that slot and attempts one forced start; failure does not retain the request.

Playback requires the server side, a living nonremoved NPC, no sleep, swimming, flight/hover, riding, climbing, tracked item use, busy POI, combat phase or attackable target, and horizontal velocity squared at most 0.0001. Losing eligibility stops playback. Navigation locking also forgets WALK_TARGET/PATH and disables sprinting. Movement locking clears forward/sideways input, not velocity.

Per-emote and global cooldowns start immediately at the maximum of current and defined values, and continue ticking through dialogue pauses. Looping clips still end at durationTicks. Ordinary dialogue clears the queue and stops non-session emotes. playDialogueEmote requires the player’s active dialogue to target this NPC, binds its session UUID, and stops when the session changes or becomes invalid.

Base idle scheduling uses at least 80 idle ticks, a random 120–320-tick check interval and chance 0.45. The first eligible check can happen immediately; there is no guarantee of waiting a full interval first. It samples the idle pool once, without retrying another entry if that emote is cooling. Leaving dialogue resets the random check interval. This manager does not persist emote state, queue or cooldowns.

Extending it

Call emotion methods on the server tick/event path; the active manager does not automatically route every mutation to the server. Subclass the unique NPC layer and choose a profile through NpcConfig.Builder.personality. A new mood requires aligned Mood, MoodCatalog, reaction/context logic, personality arrays and both language entries. A new personality needs a complete TemperamentProfiles registration. These are code definitions, not data-pack reload surfaces.

Register body-emote definitions and react through onEmoteStarted/onEmoteStopped. There is no automatic one-to-one mapping from emotions to emotes. Verify Gecko resources when changing animation keys: a valid constructor does not establish that a clip exists. Keep this internal interface distinct from a stable public API.

Verifying

  1. Send equivalent events to a unique NPC and ordinary citizen. Only the former should change reservoirs; the latter stays composed/0.
  2. Apply low- and high-retention stimuli to the same mood; inspect splitting, linear recovery and soothing order.
  3. Test gifts, applied damage, avoided damage, eating, attacking, work and dialogue. Dialogue must not accumulate boredom.
  4. Test the 3.5 lead, 60-tick dwell and below-threshold switch paths, observing the primary mood from another client.
  5. Save, unload and advance world time; check recovery cap, context step and idle count. Also test backwards time, unknown moods, missing fields, wrong schema and nonfinite values.
  6. Test duplicate emote IDs, forced/ordinary replacement, single-slot queue, loop duration, cooldowns and session replacement. Movement or combat must stop playback.
  7. After code tuning changes, run language assembly and Java compilation, then check tracked state with matching client/server registration order.