StarChild — Petting System: A Technical Retrospective
Project: StarChild (Mobile iOS, Unreal Engine 5, C++) Role: Senior Unreal developer System scope: Touch-driven petting interaction, IK resolution, physics response, emotional state, micro-animation triggers
The Goal
The core promise of StarChild is a virtual dog that feels real when you touch it. That meant the petting system couldn't be a simple "touch collider → play reaction animation" loop. The dog needed to respond to where it was touched, how it was stroked, and what mood it was in — all in real time on a mobile device, all expressed through IK, morph targets, animation montages, haptics, and sound.
This is how that system was built, iterated, and argued over.
Architecture Overview
The system is split across four main components plus a data layer:
UPettingInteractorComponent — lives on the player/camera. Owns the touch trace (line, sphere, or capsule), computes stroke velocity and direction, and dispatches hit info to the receiver.
UPettingReceiverComponent — lives on the dog. Resolves a weighted IK offset from the hit, drives the PID controller, manages emotional state, and fires the trigger system.
UPettingMicroReactionComponent — listens to trigger events from the receiver and executes the full reaction payload: animation montages, morph pulses, VFX, SFX, haptics.
UPettingProfileDataAsset — the data backbone. A UDataAsset subclass that maps emotional states to socket configurations, IK offsets, face nudge directions, and reaction pools.
All tunable values are EditAnywhere, BlueprintReadWrite UPROPERTYs. Nothing is hardcoded. The designer owns the feel; the code owns the math.
Stage 1: The Closest Socket
The first implementation was deliberately simple: find the closest named socket to the hit location and use it as the key into the data asset.
The skeletal mesh had a set of named sockets placed along the dog's body — head, ears, back, belly, flanks. The hit location from the trace was compared to each socket's world position, and the nearest one won. That socket name was then used to look up an IK offset vector from the data asset, keyed by both socket name and current emotional state.
hitSocket = argmin(distance(hitLocation, socketWorldPos))
ikOffset = DataAsset[emotionalState][hitSocket].RegionOffset
It worked as a proof of concept. The dog responded differently to head pets vs belly pets. Emotional state could change the magnitude and direction of the offset — a Calm dog leaning gently into a head scratch, an Annoyed dog pulling away from a belly touch.
The problem was immediately visible in practice: the transition between sockets was a hard snap. The moment your finger crossed an invisible boundary, the IK target jumped. It felt nothing like touching something alive.
Stage 2: Multi-Socket Radius Blending
The natural next step was to stop picking a winner and start averaging contributors. Rather than selecting a single closest socket, all sockets within a meaningful radius of the hit location contribute to the final IK offset, weighted by their inverse distance.
weight[i] = 1.0 / (distance(hit, socket[i]) + epsilon)
ikOffset = sum(weight[i] * socketOffset[i]) / sum(weight[i])
This alone eliminated the snapping. The IK target now moved fluidly as the finger slid across the mesh — a genuine spatial blend across the body surface.
A second weighting pass was added on top of distance: normal agreement. Each socket in the data asset carries an ExpectedNormalCS — the expected surface normal at that location in component space — and a NormalBlendSharpness parameter. When the incoming hit normal disagreed with a socket's expected normal, that socket's weight was suppressed. This prevented belly sockets from contributing when the dog was scratched on its back, even if they happened to be geometrically close.
float normalAgreement = FVector::DotProduct(hitNormalCS, socket.ExpectedNormalCS);
float normalWeight = FMath::Pow(FMath::Max(0.f, normalAgreement), socket.NormalBlendSharpness);
weight[i] *= normalWeight;
The system now understood not just where the finger was, but what surface it was touching.
Stage 3: The PID Loop
Even with smooth spatial blending, fast finger movements produced a new artifact: the IK offset would chase the target with a raw lerp and overshoot on quick strokes, or lag noticeably on slow ones. A plain interpolation speed is a single lever — too fast and it snaps, too slow and it drags.
A 3D vector PID controller (FVectorPID) replaced the raw lerp. The proportional term drives the offset toward target, the integral term catches accumulated drift when the hand lingers in one spot, and the derivative term provides light damping on fast transitions.
error = targetOffset - currentOffset
pid_out = Kp * error + Ki * integral(error) + Kd * derivative(error)
newOffset = currentOffset + pid_out * deltaTime
The result was noticeably better. The IK goal moved with a sense of mass — it tracked the finger, but had a natural settling quality. Tuning Kp, Ki, and Kd directly in the Blueprint details panel meant the feel could be dialed in without recompilation. A separate PID instance drives the paw grab IK independently of the body petting offset.
The PID is also the mechanism through which emotional state affects the physical response. Rather than snapping IK offsets to a new magnitude when the emotional state changes, the new target offset (looked up from the incoming emotional profile) is fed into the same PID as any other target change. A dog transitioning from Calm to Excited doesn't instantly extend its reach — the IK target moves there with the same organic settling behavior as a finger moving across the mesh. Emotional transitions feel continuous rather than switched.
Stage 4: Softmax Weighting — A Design Disagreement
After a round of playtesting, the client asked for even smoother, more continuous blending across the body — specifically requesting that no single socket ever feel clearly "dominant." The solution proposed and implemented was replacing the distance-based averaging with a softmax function.
Softmax applies an exponential transformation before normalizing, controlled by a Temperature parameter:
softWeight[i] = exp(rawWeight[i] / T) / sum(exp(rawWeight[j] / T))
At high temperature, all sockets contribute nearly equally, producing very smooth output. At low temperature, the nearest socket dominates — approaching the original hard-selection behavior.
The implementation is in place and tunable via SoftmaxTemperature and StickyDominantWeightBoost on the receiver component.
I'll be direct about my position here: I think this is the wrong model for a dog, and the specific problem is face rotation. When you move your finger from the dog's ear to its underchin, a real dog doesn't smoothly arc its head through the intermediate angle — it holds the ear-scratch lean and then snaps to the chin-scratch lean. That jolt is the tactile feedback. It communicates that the dog registered a new stimulus and responded to it. The original radius blend preserved that quality: the face orientation settled on the dominant socket and stayed there until the input clearly moved to a new zone. Softmax at high temperature means the face is constantly averaging across nearby sockets, producing a head that drifts in a continuous slow arc through any transition — undeniably smooth, and not how dogs work. The transition is undeniably smoother, but it trades biological plausibility for mathematical elegance.
The client wanted it, it ships, and the parameter is fully tunable. The conversation is worth documenting because it reflects a real tension in interactive creature systems: mathematical smoothness and biological plausibility are not the same thing.
The Emotional State Layer
The data asset is structured as a TMap<EPettingEmotionalState, FPettingEmotionalStateProfile>, where each profile remaps the full set of socket offsets and face nudge parameters for that state. Six states are currently defined: Neutral, Calm, Excited, Annoyed, Defensive, Playful.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Petting")
TMap<EPettingEmotionalState, FPettingEmotionalStateProfile> EmotionalProfiles;
At runtime, the receiver resolves the active profile by querying CurrentEmotionalState. If the active state has no entry in the asset, it falls back to the DefaultFallbackState. This means a designer can add new emotional states, configure only the sockets they care about, and leave everything else falling back gracefully — no code changes required.
The emotional state itself is an external input to the petting system. It can be driven by gameplay timers, cumulative interaction history, or narrative triggers — the petting system simply consumes whatever it's given. This separation keeps the emotional logic decoupled from the interaction logic.
The Trigger System
Petting isn't just about IK position. A dog reacts — ears twitch, eyes close, it shifts its weight, it makes sounds. The trigger system connects petting input to those micro-reactions.
Sockets are grouped into named socket groups (e.g., ears, rightFlank, headTop). When the dominant socket during a petting stroke belongs to a group, a timer starts accumulating TimeInGroup. Each group carries a set of FPettingGroupTrigger entries, each defining:
MinHoldTime / MaxHoldTime — a random window within which the trigger can fire
Cooldown — minimum time before it can fire again
bFireOncePerEntry — whether it fires once per time the user enters the group, regardless of hold time
DefaultReactions + ReactionsByEmotion — a pool of weighted reaction variants, overridden per emotional state
USTRUCT(BlueprintType)
struct FPettingGroupTrigger {
float MinHoldTime;
float MaxHoldTime;
float Cooldown;
bool bFireOncePerEntry;
FPettingMicroReactionPool DefaultReactions;
TMap<EPettingEmotionalState, FPettingMicroReactionPool> ReactionsByEmotion;
};
When a trigger fires, the UPettingMicroReactionComponent picks a variant from the pool using weighted random selection with per-variant cooldowns, then executes the full payload: animation montage on a named blend slot, morph target pulse (attack/hold/release envelope), Niagara VFX, audio, and haptic feedback. Each of those is independently toggleable per variant via booleans, so a designer can mix and match without filling in every field.
The reaction system is entirely data-driven. A new emotional reaction to a belly scratch is an asset change, not a code change.
IK and the Paw Grab
The resolved IK offset from the receiver is read by the Animation Blueprint each frame via GetCurrentOffset(). It drives an IK goal bone that's blended additively on top of the base animation pose. The magnitude of the offset is modulated by the active emotional profile — a Defensive dog yields a smaller offset and may drive it in an avoidance direction.
One issue discovered during development: combining a VInterpConstantTo in the component tick with a second VInterpTo inside the Animation Blueprint produced double-interpolation — the IK goal lagged visibly even at high speed settings because two smoothing passes were stacking. The fix was removing one layer and letting the PID be the single source of temporal smoothing.
Beyond the main body IK, the system also supports paw grab IK — the player can grab the dog's front paws by touching near them. This is driven by a separate FVectorPID instance (PawOffsetPID) with its own tracking speed and release speed parameters. The paw position is projected onto a drag plane aligned to the camera, and the offset is clamped per-axis via designer-configured ranges in the FPawGrabSettings struct.
The Nudge System — A Productive Misunderstanding
Late in development, the client asked for a "nudge" system — the dog's head should react to the petting hand. We both agreed on the goal and proceeded to implement it. When we reviewed the result, it became clear we had each understood "nudge" to mean something different.
My interpretation: the dog pushes its head into the hand. Like a real dog leaning into a scratch — the head moves toward the finger, seeking more contact. This became the Auto Nudge system: when the receiver detects sustained petting above a minimum stroke speed, it fires a burst impulse in the per-socket FaceNudgeDirection defined in the data asset. The impulse decays over time via a ReturnSpeed parameter. Burst timing is randomized within BurstIntervalMin/BurstIntervalMax to prevent mechanical repetition.
The client's interpretation: the finger pushes the head. The mass of the hand should feel like it's displacing the dog's face — the head moves in response to finger velocity, as if the dog's head has light physics. This became the User Head Push system: finger velocity in screen space is accumulated and projected into component space as a continuous additive displacement on the nose IK goal. The displacement decays per-frame via configurable VelocityDecayPerFrame and DisplacementDecayPerFrame coefficients.
// Auto Nudge — dog-initiated, burst-based
struct FAutoNudgeSettings {
float BurstIntervalMin, BurstIntervalMax;
float BurstStrength;
float ReturnSpeed;
float MinStrokeSpeed;
};
// User Head Push — user-initiated, momentum decay
struct FUserHeadPushSettings {
float AccumulationRate;
float VelocityDecayPerFrame;
float DisplacementDecayPerFrame;
float MaxDisplacement;
TMap<EPetBodyRegion, float> RegionInfluence;
};
Both systems run in parallel, their outputs summed as additive offsets on top of the main petting IK. The combination is actually better than either alone — the dog leans into your hand and your hand feels like it has presence. The misunderstanding produced a more complete system than either interpretation would have on its own.
Physics and Visual Response
Beyond IK, the physical impression of petting comes from two additional systems:
Morph target pulses. The FPettingMorphPulse struct defines a lightweight ADSR-style envelope (attack, hold, release, peak) applied to a named morph target. When a trigger fires, the micro-reaction component ticks the active morphs each frame, evaluating the envelope and writing the resulting weight to the skeletal mesh. Multiple morphs can fire simultaneously with individual envelopes — a trigger on the ear group might tighten the ear canal morph and raise the brow morph at the same time.
Fur shell rendering. The dog uses shell-based fur rendering, which meant IK-driven mesh deformation had to be validated carefully against the shell geometry. Rapid IK offsets that exceeded the shell layer depth would produce visible gaps. This constrained the maximum IK offset magnitude and influenced the PID damping tuning — the visual stability of the fur was itself a design constraint on the feel of the petting response.
What This System Actually Is
The petting system is fundamentally a real-time spatial signal processor layered over a skeletal mesh. The touch input is a noisy, continuous positional signal. The socket architecture samples that signal at defined anatomical points. The weighting and PID pipeline filters and smooths it into a stable IK target. The emotional profile layer maps that target into animal-readable behavior. The trigger system translates hold duration into event-driven micro-responses.
Every value that governs this pipeline lives in a data asset. Every tunable parameter is exposed to Blueprints. The code defines the structure; the content defines the character.
Engine: Unreal Engine 5 | Platform: iOS | Language: C++


Comments