world – round 2 – The Animation Evolution: Breathing Life into Our 3D World


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:

  1. Hover → Character plays the “No” gesture (confident, playful)
  2. After No finishes → Character transitions to “Wave” (friendly, welcoming)
  3. 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:

  1. Logs all available animations to the console for debugging
  2. Matches animations by exact name after inspecting the GLB
  3. 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

  1. Zero Animation Overlap – Clean state transitions every time
  2. Flexible Animation Discovery – Works with any properly formatted GLB
  3. Scalable Architecture – Adding new animations is as simple as finding them by name
  4. Performance-Neutral – All features added with minimal performance impact

UX Wins

  1. Living Characters – Avatars feel alive even when standing still
  2. Emotional Connection – Players bond with their characters through personality
  3. Social Interaction – Waving creates organic, spontaneous connection
  4. 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.

https://chat.deepseek.com/