Fully animated 3D battles in a mobile web game with three.js and CC0 KayKit models
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:
- every animation clip works on every character, so it lives once in a single
anims.glb, - each of 15 classes is assembled from KayKit parts across ten models (body, armour, weapon), all on that skeleton,
- a recolour shader tints armour per class while leaving skin and hair alone.
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 renderer kept drawing while off-screen, behind menus. It now skips frames when hidden,
- the three.js loop ran on every screen, including the shop. The home screen dropped from 47 to 30 frames per second, and the shop to 0. Fights still run at full rate, which is fair, because that's when something is moving.
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:
- leather creak when you pick up an item, a leather thud when you drop it,
- a blade swing and a soft thunk for hits,
- a bow release with an arrow whistle, a muted thud for shields,
- a harp arpeggio for a win, cello pizzicato for a loss.
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.