UYLLII

Blog › Engineering

Engineering

Fully animated 3D battles in a mobile web game with three.js and CC0 KayKit models

By David4 min read
Backpack Battle feature graphic

For Backpack Battle I wanted fights that look animated, not illustrated. In Tavern Heroes I'd used a 2D cut-out rig, and the fights there are honestly the weakest part of the game. A 2D rig looks stiff next to real animation.

My first prototype for Backpack Battle went a different way: the items were the actors (swords swinging, arrows flying) and the fighters were still portraits that breathed. Technically neat, and not what I wanted. I wanted bodies that move.

The approach: rigged 3D, free assets

The fighters are 3D models rendered with three.js, in the same Capacitor web app as the rest of the game. The models come from KayKit's Adventurers and Skeletons packs, which are CC0 (public domain). They're low-poly, consistent in style, and they share one skeleton with 76 to 95 animation clips each.

A shared skeleton is the key. It means:

No paid tools, no GPU needed for the build. The models went through gltf-transform with meshopt compression, which took them from 23 MB to 2.4 MB. That matters a lot in an app download.

The bugs on the way

No weapons showed. Characters had empty hands. GLTFLoader quietly renames nodes with dots in their names, so handslot.r became handslotr and my lookup found nothing. Bone and slot names are now matched by regular expression.

8,800 orphan accessors per file. Removing unused clips with gltf-transform's Animation.dispose() left their samplers and accessors behind, so files stayed big. Everything is now disposed properly, and a models script prints sizes so a regression is obvious.

The camera on tall phones. On a tall, narrow screen the camera ended up too close and cut off the fighters. It now uses a long lens and sets its distance from the screen's aspect ratio.

Slow-motion recordings. I record fights in headless Chromium for review, and with software rendering they play slowly. Fight length is measured from the game clock, never from the video.

Layout: what the player looks at

The fight screen is a vertical stack: the enemy's pack at the top, the fighters in the middle, your pack at the bottom. Items in each pack light up as they trigger, so you can see why you're winning or losing, and a breakdown after the fight shows damage by item.

My first attempt was a full-screen 3D arena with tiny thumbnails of the packs. It looked impressive and told you nothing. In an autobattler, the backpack is the game, and the fight should show you the backpack at work.

Performance and battery

A 3D render loop on a phone is a battery hazard. Two problems turned up in a later audit:

The audio context never slept either, which keeps a phone awake in the background. That turned out to be true in every game I'd made. See the battery post.

Sound: from awful to subtle

The first set of sound effects was generic arcade clips at full volume, chosen by file name. In playtesting, they were simply awful. A fantasy fight needs to sound like leather and steel, not a slot machine.

The replacement is 37 CC0 foley recordings chosen by description and measurement: loudness, brightness (spectral centroid) and attack time, checked against a written brief of how each action should feel:

In code: master volume ×0.45, music at 0.22, random takes and slight pitch variation so repeats don't grate, and a 9 kHz low-pass to take the edge off. The rule now: sounds are picked against a feel brief with loudness limits, never by name alone, and then checked by ear on a phone.

Would I use a web stack for 3D again?

For this game, yes. Low-poly characters, a handful on screen, short fights: three.js handles it comfortably on mid-range phones. For a game with hundreds of animated enemies on screen at once, it's a different story. That's the next post, and it doesn't end well.