Our social 3D world was peaceful—too peaceful. To add some excitement, we decided to implement Thunder Mode, a survival challenge where players dodge procedurally generated lightning strikes. However, adding a complex “game mode” to a social engine often leads to “spaghetti code” and performance death.
Here is how we built a high-performance, modular weather system that creates zero load on the server and stays silky smooth on mobile.
1. The Strategy: Complete Modularity
The biggest mistake in web game dev is cramming every new feature into the main animate() loop. Instead, we built thunder.js as a Self-Contained Module.
When you load the script, it automatically injects its own CSS, creates its own HTML overlays (Health bars, toggle buttons), and manages its own internal state. If we remove the script, the game still runs perfectly.
The “Zero-Server-Load” Logic: We don’t sync lightning positions through the server. Instead, each client calculates its own “personal” lightning. This means the server only handles player positions, while the heavy math happens locally on the user’s device.
2. Visual Optimization: The “Double-Layer” Bolt
Initially, we used full-screen blue flashes and camera shakes to simulate thunder. Result: FPS dropped to 10 on mobile. CSS background changes trigger “layout reflows,” which are expensive.
We pivoted to a purely 3D visual approach using a Two-Layer Bolt system:
The Core: A thin, bright white TubeGeometry.
The Glow: A thicker blue tube using AdditiveBlending.
A static game is a boring game. We implemented a difficulty scalar that increases every second you stay alive. This variable controls four things simultaneously:
Spawn Rate: From one bolt every 2 seconds down to four bolts per second.
Warning Time: The red “danger zone” circle stays on the ground for less time.
Max Bolts: Increases the cap on how many bolts can be on screen at once (up to 15).
Spawn Radius: As you survive, lightning fills a wider area around you.
4. Integrating GLB Death Animations
Manual character rotation looked “fake.” We wanted to use the professional animations built into our GLB models.
The Challenge: If you simply play the death animation in a loop, the character keeps dying and standing up repeatedly. To fix this, we implemented a State-Change Check.
code JavaScript
// Inside the main animation loopconst isHit = thunder.isDead || thunder.isKnockedDown;if (isHit) {// Only trigger the animation ONCE when the state changesif (lastAnim !=='death') { deathAction.reset().setLoop(THREE.LoopOnce).play(); deathAction.clampWhenFinished =true; // Stay on the last frame } anim ='death';} else { anim ='idle';}lastAnim = anim;
5. Framing the Chaos: UI Positioning and Depth
To improve the user experience, we made two major aesthetic adjustments:
The 40% Rule: We adjusted the camera’s look at target to Y=3.5. This tilts the view slightly upward, pushing the character selection slider to the bottom 40% of the screen. This keeps the characters visible but leaves the top of the screen clear for the environment.
Atmospheric Clarity: We thinned the fog by changing the far distance from 75 to 150. This makes the world feel larger and gives players more time to see the environment while they are dodging strikes.
Expanded Roster: We expanded the character pool, supporting a full lineup of models from 01.glb to 09.glb, giving players more ways to express themselves before the storm hits.
6. The “Wasted” Loop: Delayed Restart
Survival games need a moment of impact. When a player finally loses all health, we don’t show the menu immediately. We added a 2-second delay where the camera lingers on the fallen character before the “START AGAIN” button fades in. This makes death feel meaningful and gives the player a moment to breathe before jumping back in.
The Result
By keeping the code modular, we managed to:
Maintain 60 FPS: By avoiding CSS-based screen effects and using low-poly 3-sided tubes for bolts.
Improve Visuals: The additive blue glow looks far more “electrifying” than a simple white line.
Keep the Engine Clean: The main index.html only needs one or two lines to “talk” to the Thunder System.
Next time you see a red ring on the floor, you have exactly 0.8 seconds to move—Good Luck!
Moving a 3D character on a smartphone usually means cluttering the screen with ugly joysticks and transparent buttons. For our multiplayer project, we wanted something better. We built an Invisible Gesture Controller that turns the entire screen into a touch-sensitive pad.
The Strategy: Mapping Keys to Gestures
Our game engine already listens for specific keys like W (Walk), Shift (Run), and Space (Jump). Instead of rewriting the engine, we built a “Touch Wrapper” that translates finger movements into these virtual key presses.
1. Browser Normalization (CSS)
To make a web game feel like a native app, you have to kill the browser’s default behaviors. Without this, the phone will try to zoom or “select text” while you are trying to play.
code CSS
/* Add this to stop mobile browser interference */body { touch-action: none; /* Disable pinch-to-zoom and scroll */ user-select: none; /* Disable text highlighting */-webkit-touch-callout: none; /* Disable long-press menus */-webkit-tap-highlight-color: transparent; /* Remove blue tap boxes */}
2. The Touch Logic (JavaScript)
This is the heart of the mobile support. It handles rotation, toggle-walking, sprinting, and vertical swipes for actions.
Rotation: Sliding your finger left or right updates the localPlayer.rotation.y. It’s sensitive and smooth, allowing for quick 360-degree turns.
The “Tap to Move” Toggle: On a keyboard, you hold ‘W’. On mobile, holding your finger down is tiring. We changed it to a Toggle: Tap once to start walking, tap again to stop.
Double-Tap to Sprint: Just like Minecraft or other mobile titles, a quick double-tap switches the state from Walking to Running (Shift).
Swipe Down for Selfie: We mapped the Camera Flip (V) to a downward swipe. It’s an intuitive gesture for switching perspectives.
Swipe Up to Jump: A vertical flick triggers the jump physics instantly.
The Result
By using gestures instead of buttons, we kept the screen 100% clean. This maximizes the immersion of the 3D world and makes the game feel like a premium mobile app rather than a website.
Next time you log in from your phone, try the swipe-down to see your character’s face!
In most multiplayer games, when a player disconnects, their character simply vanishes. This makes the world feel empty and temporary. We decided to change that by building a Persistent Ghost System.
Now, when you leave our world, your avatar stays exactly where you left it for 12 hours, serving as a digital footprint of your visit.
The Problem: The “Empty Room” Syndrome
3D social spaces feel lonely if you aren’t there at the exact same time as someone else. We wanted a way for players to “leave their mark,” allowing the world to look populated even during off-peak hours.
The Solution: Ghost Conversion
We implemented a server-to-client bridge that handles “ghosting” in three tiny but powerful steps.
1. Server-Side: The 12-Hour Timer
Instead of deleting the player data on a disconnect event, the server rebrands the player. We change their ID to a ghost_ prefix and start a long-running timer.
code JavaScript
// On Disconnect: Convert to Ghostconst ghostId ="ghost_"+ socket.id;players[ghostId] = players[socket.id]; // Keep the datadelete players[socket.id]; // Remove the active socket// Set the 12-hour expirationsetTimeout(() => {delete players[ghostId]; io.emit('player_left', ghostId);}, 12*60*60*1000);
2. Client-Side: Visual “Offline” Cues
To make sure active players can distinguish between live users and ghosts, we added visual shaders. When the client sees an ID starting with ghost_, it automatically:
Sets the model to 50% opacity.
Appends (OFFLINE) to the name tag.
Forces the model into an Idle state.
code JavaScript
if (id.startsWith('ghost_')) { child.material.transparent =true; child.material.opacity =0.5; // Visual hint that the player is gone}
3. Physical Presence (No Ghosting)
Even though these characters are “offline,” they aren’t just holograms. We kept the Collision Detection active against them. You cannot walk through a ghost. This creates a sense of solid, physical history in the world—if a group of friends stood in a circle before logging off, that circle remains as a physical barrier for 12 hours.
Why it works
Anti-Cloning: If a player logs back in, the server automatically finds and “kills” their old ghost before spawning their new live character. This prevents the world from becoming cluttered with clones of the same person.
Memory Efficiency: By using a simple Map and setTimeout, the server handles the persistence without needing a heavy database.
Social Proof: New players entering the world see a crowd of characters, making the project feel popular and alive.
Conclusion
Persistence doesn’t always require a massive database or a 24/7 server farm. With just a few lines of logic and some clever ID prefixing, we turned a temporary room into a persistent world where your presence actually matters.
Next time you log in, say hi to the ghosts of the players who came before you!
Building a Smooth Multiplayer 3D Character Selection World
In our latest development sprint, we transformed a basic 3D multiplayer space into a polished, interactive character hub. We focused on three main pillars: User Experience, Animation Flexibility, and Physical Presence.
Here is a breakdown of what we’ve built.
1. The “Carousel” Selection System
Instead of a traditional 2D menu, we moved character selection into the 3D world.
Mouse-Scroll Interaction: Users can cycle through characters by simply scrolling their mouse wheel.
The Centering Effect: We used linear interpolation (Lerp) to smoothly slide the selected character to the center of the screen while rotating it, giving the player a 360-degree preview of their avatar.
2. Dynamic Animation Mapping (Fuzzy Search)
Different GLB models often come with wildly different animation naming conventions. A Robot might have RobotArmature|Walk, while a human might just have Walk.
Keyword Matching: We implemented a “Fuzzy Search” logic that looks for keywords like Idle, Walk, Run, or Jump.
The Skeleton Fallback: Some models, like our Skeleton, are missing specific animations (like a Walk). We built a fallback system that automatically detects a missing Walk and replaces it with the Run animation, ensuring the character never “glides” in a T-pose.
3. Social Interaction: Proximity Waving
To make the world feel alive, we added a social layer.
Proximity Detection: When a player stands still near one or more other players, the system triggers a “Wave” or “Thumbs Up” animation automatically.
Social Immersion: This small detail transforms the space from a lonely void into a social gathering hub.
4. No More “Ghosting”: Collision Detection
In early versions, players could walk through each other like ghosts. We solved this by implementing a Circle-Circle Collision system:
Physical Boundaries: Every player now has a collision radius.
The “Push-Back” Logic: If two players get too close, the math calculates the overlap and gently pushes the local player back, giving everyone a solid physical presence in the world.
5. Enhanced Camera & Physics
We kept the core gameplay tight with:
Jump Physics: Gravity-based jumping with force and landing detection.
View Toggling: Players can press ‘V’ or Right-Click to flip the camera between a standard third-person follow and a front-facing “Selfie” view.
Technical Highlights:
Engine: Three.js (WebGL)
Networking: Socket.io for real-time position and animation syncing.
Assets: Optimized CC0 GLTF models.
What’s Next? We are looking into adding customizable textures and a chat system to further enhance the multiplayer social experience!
Looking for the logic?
The secret to the animation flexibility was this helper function we developed:
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 logicfunctionupdateLocalIdleSequence(delta) {const isMoving = keys['w'] || keys['s'] || keys['a'] || keys['d'];if (isMoving || isJumping || keys['']) {// Player is active - stop idle sequenceif (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 loopif (!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 hoveredconst 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(); } } elseif (!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:
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
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 =newTHREE.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
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.
The backbone of our 3D world is built upon a highly organized file architecture and the use of the industry-standard GLB (GL Transmission Format Binary). Because the game needs to load massive amounts of data—meshes, textures, and complex animations—simultaneously for up to 100 players, the way these files are structured is critical to performance.
Here is a breakdown of how we managed the GLB assets and the project’s file hierarchy.
1. The GLB: The “All-in-One” 3D Container
We chose GLB over traditional formats (like OBJ or FBX) because it is the “JPEG of 3D.” In our game, each character (01, 02, and 03) is a single binary file that contains:
The Mesh: The actual 3D body of the character.
The Textures: The colors, skins, and materials embedded directly into the file so they don’t get lost.
The Skeleton (Rig): The internal bone structure that allows the character to move.
The Animation Library: This is the most important part. Each GLB carries its own “clip” library. When the game starts, our script scans the GLB for specific names like “Walk” and “Wave”. By packaging everything in one file, we ensure that when a player joins, the character doesn’t appear “naked” or “frozen”—everything loads in one single network request.
2. The Project Architecture
To manage the relationship between the 3D assets and the game logic, we implemented a strict “Root-Public” structure. This separation ensures that the 3D models stay secure and the game runs smoothly.
The Root Level:
server.js: This is the “brain” of the world. It doesn’t handle graphics; it only handles “coordinates.” It tracks where Player 01 is and tells the other 99 players.
package.json: The manifest that ensures the environment has the correct tools to sync the players.
The Public Folder (The Game’s Front Door):
index.html: The “Canvas.” This file contains the Three.js engine that renders the world. It is the only file the user actually “sees.”
player/ (The Asset Vault): We created a dedicated sub-folder specifically for our 3D models. Inside this folder live 01.glb, 02.glb, and 03.glb.
3. Dynamic Path Management
A key feature of our management system is how we handle file paths. Instead of hard-coding every character, the game uses a dynamic loading system.
When a user clicks on a 3D character during selection, the game stores that character’s filename (e.g., “02.glb”).
The script then looks into the public/player/ folder and pulls that specific file.
This structure allows us to add 10 or 100 new characters just by dropping a new GLB into the folder and adding its name to a list, without changing a single line of the game’s core code.
4. Technical Normalization
Inside the file structure, we managed a major challenge: Scale. 3D artists often export models at different sizes. One GLB might be 100 times larger than another. To manage this, we built a Normalization Layer into the loading process. Every time a file is pulled from the player/ folder, the game “measures” the model’s dimensions and automatically scales it to a standard height. This ensures that in our file structure, every GLB is treated as a uniform “Player” regardless of its original size.
By using this clean, modular structure, we created a game that is easy to update and incredibly fast to load, ensuring that the 3D “High-Key” studio experience is seamless for every player who enters the world.