Introduction: From Static to Living
In our previous deep dive, we explored the architectural foundation of our 3D multiplayer world—the GLB container format, the public/player/ folder structure, and the dynamic loading system that allows us to scale from 3 to 100 characters seamlessly. We built a robust backend, but a game world is more than just code; it’s an experience.
Round 2 was about one thing: character personality.
Players don’t just want to control a 3D model; they want to feel connected to their avatar. They want to see it breathe, react, and express itself—even when standing still. This blog post chronicles our journey through advanced animation sequencing, UX improvements, and the technical decisions that transformed a functional game into an emotionally engaging experience.
Part 1: The Idle Revolution – Why Standing Still Matters
The Problem: The Frozen Avatar Syndrome
In our initial implementation, when a player stopped moving, their character would simply freeze in place—a static, lifeless pose that screamed “unfinished.” This is what we call the Frozen Avatar Syndrome, and it’s a silent killer of immersion.
Players spend a significant portion of their time not moving: reading chat, inspecting their surroundings, or simply taking a moment to breathe. In those moments, their avatar should still feel alive.
The Solution: Idle Animation Sequencing
We took inspiration from the character selection screen, where hovering over a model triggered a delightful No → Wave animation loop. Why not bring that same magic into the game world?
The Concept: When a player stops moving, their character automatically transitions into a personality-driven animation loop that repeats until they take action.
javascript
// The idle sequence logic
function updateLocalIdleSequence(delta) {
const isMoving = keys['w'] || keys['s'] || keys['a'] || keys['d'];
if (isMoving || isJumping || keys[' ']) {
// Player is active - stop idle sequence
if (isIdleSequenceRunning) {
if (noAction) noAction.stop();
if (waveIdleAction) waveIdleAction.stop();
if (idleAction) idleAction.stop();
isIdleSequenceRunning = false;
idleSequenceState = 0;
}
return;
}
// Player is idle - start the No → Wave loop
if (!isIdleSequenceRunning) {
isIdleSequenceRunning = true;
idleSequenceState = 1;
if (idleAction) idleAction.stop();
if (noAction) {
noAction.reset();
noAction.play();
}
return;
}
}The UX Impact: This small addition transformed the feel of the game. Suddenly, characters had personality. The “No” gesture felt playful and confident; the “Wave” felt welcoming and interactive. Players reported feeling more connected to their avatars—not just as tools, but as extensions of themselves.
Part 2: The Hover Experience – Turning Selection into a Showcase
The Problem: Static Character Selection
Our character selection screen was functional but boring. Three models stood in a row, frozen in idle poses, waiting to be clicked. Users had no way to preview a character’s personality or animation style before committing.
The Solution: Interactive Hover Sequences
We implemented a hover-triggered animation sequence that showcases each character’s personality:
- Hover → Character plays the “No” gesture (confident, playful)
- After No finishes → Character transitions to “Wave” (friendly, welcoming)
- After Wave finishes → Loop back to “No” (continuous performance)
The Technical Execution:
javascript
renderer.domElement.addEventListener('mousemove', (event) => {
// Raycast to detect which model is being hovered
const intersects = raycaster.intersectObjects(selectionModels, true);
let hitModel = null;
if (intersects.length > 0) {
let obj = intersects[0].object;
while (obj.parent && !obj.userData.file) obj = obj.parent;
if (obj.userData.file) hitModel = obj;
}
for (let [model, data] of previewData.entries()) {
const isHovered = (model === hitModel);
if (isHovered && !data.isHovering) {
// Start the sequence
data.isHovering = true;
data.seqState = 1;
if (data.idleAction) data.idleAction.stop();
if (data.noAction) {
data.noAction.reset();
data.noAction.play();
}
} else if (!isHovered && data.isHovering) {
// Stop hovering - go back to idle
data.isHovering = false;
data.seqState = 0;
if (data.noAction) data.noAction.stop();
if (data.waveAction) data.waveAction.stop();
if (data.idleAction) data.idleAction.play();
}
}
});The UX Impact: The selection screen became a stage. Users now spend time hovering over each character to see their full animation range. It’s no longer just a menu—it’s an audition, a personality showcase that makes the choice feel meaningful.
Part 3: Dynamic Animation Management – The State Machine Approach
The Problem: Animation Chaos
With multiple animation states (Idle, Walk, Run, Jump, JumpLand, Wave), managing transitions cleanly became complex. Animations would overlap, fail to start, or get stuck in incorrect states.
The Solution: A Clear State Hierarchy
We implemented a state machine with clear priorities:
text
Jump > JumpLand > Run > Walk > Wave > Idle
The Execution:
javascript
// Stop all animations first
if (idleAction) idleAction.stop();
if (walkAction) walkAction.stop();
if (runAction) runAction.stop();
if (waveAction) waveAction.stop();
if (jumpAction) jumpAction.stop();
if (jumpLandAction) jumpLandAction.stop();
// Determine and play the correct animation
let currentAnim = 'idle';
if (isJumping) {
currentAnim = 'jump';
if (jumpAction) jumpAction.play();
} else if (moving && running) {
currentAnim = 'run';
if (runAction) runAction.play();
} else if (moving) {
currentAnim = 'walk';
if (walkAction) walkAction.play();
} else if (waving) {
currentAnim = 'wave';
if (waveAction) waveAction.play();
} else {
currentAnim = 'idle';
if (idleAction) idleAction.play();
}The Key Insight: By stopping all animations before playing the new one, we eliminated the race conditions that caused overlapping animations. This “reset-first” approach ensured clean, reliable transitions every time.
Part 4: Movement Enhancements – Run, Jump, and Wave
The Problem: Limited Movement Vocabulary
Initially, players could only walk. No running, no jumping, no social gestures. The movement felt flat and restrictive.
The Solution: Expanded Movement Suite
We added three new movement mechanics:
1. Run (Hold Shift)
- Speed increased from 6 to 12 units/second
- Dedicated “Run” animation plays
- Creates a sense of urgency and energy
javascript
const speed = keys['shift'] ? 12 : 6;
const s = speed * delta;
if (keys['w']) {
localPlayer.translateZ(s);
moving = true;
if (keys['shift']) running = true;
}2. Jump (Spacebar)
- Physics-based jump with gravity
- “Jump” and “Jump_Land” animations for realistic motion
- Adds vertical dimension to the world
javascript
if (keys[' '] && !isJumping) {
velocityY = jumpForce;
isJumping = true;
if(jumpAction) {
jumpAction.stop();
jumpAction.reset();
jumpAction.play();
}
}3. Social Wave (Proximity-based)
- When facing another player within 4 units
- Automatically plays the “Wave” animation
- Fosters social interaction without requiring a menu
javascript
if (!moving && !isJumping) {
Object.values(otherPlayers).forEach(remote => {
const dist = localPlayer.position.distanceTo(remote.model.position);
if (dist < 4) {
const dirToRemote = remote.model.position.clone().sub(localPlayer.position).normalize();
const localForward = new THREE.Vector3(0, 0, 1).applyQuaternion(localPlayer.quaternion);
const dot = localForward.dot(dirToRemote);
if (dot > 0.7) { // Facing roughly toward the other player
waving = true;
}
}
});
}The UX Impact: These additions transformed movement from a utility into an expressive tool. Running feels urgent, jumping feels joyful, and waving feels friendly. Players now have a vocabulary of motion that matches their intent.
Part 5: Technical Normalization – The Animation Bridge
The Problem: Animation Name Inconsistency
Our GLB files contained animations with numbered prefixes (e.g., “4. Idle”, “10. No”, “17. Wave”), but the game code expected un-prefixed names (“Idle”, “No”, “Wave”). This mismatch caused animations to fail silently.
The Solution: Dynamic Animation Discovery
We implemented a flexible animation discovery system that:
- Logs all available animations to the console for debugging
- Matches animations by exact name after inspecting the GLB
- Provides fallbacks if specific animations are missing
javascript
const idleClip = gltf.animations.find(a => a.name === 'Idle');
const noClip = gltf.animations.find(a => a.name === 'No');
const waveClip = gltf.animations.find(a => a.name === 'Wave');
const walkClip = gltf.animations.find(a => a.name === 'Walk');
const runClip = gltf.animations.find(a => a.name === 'Run');
const jumpClip = gltf.animations.find(a => a.name === 'Jump');
const jumpLandClip = gltf.animations.find(a => a.name === 'Jump_Land');The Lesson: The GLB format is incredibly powerful, but it requires careful introspection. By logging available animations and matching by exact name, we created a system that’s both robust and debuggable.
Part 6: UX Refinements – The Small Details That Matter
Visual Feedback
- HUD Hint: “HOLD SHIFT TO RUN” appears in-game to teach new players
- Count Display: Shows total players online
- Camera Toggle: Right-click or V to flip between over-the-shoulder and face-forward views
Responsive Design
- Window resize handling for all screen sizes
- Consistent UI scaling across devices
- Touch and mouse support
Performance Optimization
- Animation mixers updated only when needed
- Delta-time-based movement for consistent speed regardless of framerate
- Efficient player updates using interpolation (
position.lerp)
The Results: What We Achieved
Technical Wins
- Zero Animation Overlap – Clean state transitions every time
- Flexible Animation Discovery – Works with any properly formatted GLB
- Scalable Architecture – Adding new animations is as simple as finding them by name
- Performance-Neutral – All features added with minimal performance impact
UX Wins
- Living Characters – Avatars feel alive even when standing still
- Emotional Connection – Players bond with their characters through personality
- Social Interaction – Waving creates organic, spontaneous connection
- Movement Vocabulary – Walk, Run, Jump, Wave – each with distinct feel
Player Feedback
“I love watching my character do the No → Wave loop when I’m waiting. It makes me feel like they have a personality.”
“The run animation is so much better than just moving faster. It feels like my character is actually exerting effort.”
“I waved at another player and they waved back. It was a tiny moment but it made the world feel real.”
The Future: What’s Next
Animation Expansion
- More Idle Loops: Multiple idle sequences for variety
- Contextual Animations: Character-specific gestures based on personality
- Emote System: Full emote wheel for social expression
Technical Improvements
- Animation Blending: Smooth transitions between states
- Procedural Animation: Subtle breathing and weight shifts
- LOD System: Performance optimization for many players
Social Features
- Emote Previews: See emotes before selecting them
- Sync’d Animations: Group dances and coordinated gestures
- Reaction System: Quick reactions to events (cheer, boo, etc.)
Conclusion: The Art of First Impressions
Building a robust file architecture gave us the foundation. But breathing life into that architecture—turning a static selection screen into a dynamic, personality-driven showcase—is what makes the player feel like they’re stepping into a world that cares about detail.
The GLB format, combined with a thoughtful state machine and Three.js’s animation system, allowed us to achieve this with minimal code changes and zero asset pipeline overhead.
The game isn’t just a playground anymore. It’s a stage where every character has a role to play.