Wulai: A React + Three.js Browser Action Game with Procedural Art and a Mobile Performance Budget
Online play: https://arxun.github.io/wulai/ (works on PC and mobile, mobile recommended in landscape)
Preface: I started with tokens that were about to expire, so I asked Wuye what to do. He told me to burn tokens and generate models... So I dug a picture from his Moments. Built a model, and after modeling I felt, since we have a person, why not make him fly~~~
Preface
Wulai is a low-poly 3D action game that runs directly in the browser. The protagonist, Xiao Wu, wears a red fleece jacket and uses a walking stick along the Huangshan mountain path, fighting monsters, leveling up, and switching weapons. When the cooldown is ready, he unleashes the ultimate "Wulai", smashing a big crater filled with magma. When tired, he can summon an SU7 and drift and use nitrous on the mountain road.
The entire game uses no game engine and almost no external art assets: the character, terrain, trees, rocks, monsters, and skill effects are all geometry generated procedurally from code. The only external model is the SU7. The tech stack is the familiar React + Three.js for frontend devs.
This article is split into two parts: first an introduction to the game, then the development approach, focusing on performance and load speed, plus a few pitfalls that only show up in production and on real hardware.
1. Game Introduction
World
- The map is a 260m × 260m valley of Huangshan. A winding path runs from south to north through the valley, surrounded by ridges, with a main peak in the northwest corner.
- Beyond the air wall, a ring of hillside is laid out, with distant mountains on the horizon, so you never see the "edge of the world" even when the camera hugs the map border.
- The top-left corner has a compass and map coordinates (southwest corner as origin), making it easy to find bosses and treasures.
Combat
- Combo: Press J repeatedly to execute a four-hit sequence: Thrust → Slash → Chop → Spin. Press J in midair for a dive smash. On PC, pointing at a monster turns the cursor into a red sword; left-clicking the monster works the same as pressing J.
- Skills: E for Spin Slash (spins for 3 seconds, hits all around), K for Crush (a high-multiplier heavy blow), R for the ultimate "Wulai".
- Ultimate "Wulai": A 3-second show, the shockwave reaches 13 meters, with a 10x multiplier. After landing, a magma crater with 6.5m radius appears for 5 seconds, dealing continuous damage to anything inside.
Progression
- Level cap 99; leveling raises attack, defense, health, and mana.
- Six weapons: wooden walking stick → dark iron halberd → frost staff → crimson flame spear → thunder mark blade → bloodthorn demon sword, attack power from 10 to 156, switch with number keys 1-6.
- The map holds 165 small monsters in 5 types: bluestone crab, mist fox, crimson crow, rock spirit, and mountain ghost. They get stronger at higher elevations. Two bosses guard the two corners: Xuanjia Tiankui in the northeast (4200 HP) and Scarlet Scale Nightmare Dragon in the southeast (4800 HP).
- Monsters roll drops independently from a drop table for equipment and HP pills. Weapons you already own don't drop again. A Bloodthorn Demon Sword is also hidden in the northwest corner.
Vehicle
- An SU7 is parked on the map; press F near it to board. On PC press V, on mobile tap the "Vehicle" button to summon it directly to your side.
- While driving, drifting builds nitrous: every 20 meters drifted earns one bottle, up to 3 bottles, each giving 3 seconds of nitrous that triples speed.
Mobile
Mobile uses a landscape layout: the bottom-left has a steering wheel, bottom-right a normal attack button, and skill buttons arc around the attack button. Two-finger pinch zooms the camera. When opened in portrait, the whole page rotates 90° to landscape.
Controls Overview
| Action | PC | Mobile |
|---|---|---|
| Move | WASD / Arrow keys | Bottom-left steering wheel |
| Normal combo | J, or click on monster | Bottom-right sword button |
| Spin Slash / Crush / Ultimate | E / K / R | E / Crush / Wu |
| Jump | Space | Jump |
| Eat HP pill | Q | HP |
| Board / Summon vehicle | F / V | Board / Vehicle |
2. Tech Stack
- React 18 + React Three Fiber v8 + drei v9 + three.js r168
- Vite 5 for build, Vitest for unit tests
- GitHub Actions: auto-build on push to
main, publish to GitHub Pages
3. Development Approach
1. Everything is Procedurally Generated
Not using art assets has the benefit that "changing code is changing art," and the package size is very small.
- Low-poly style baseline: organic shapes (skin, features, hands) use smooth normals; man-made objects and vegetation (clothes, hair, rocks, trees, terrain) keep per-face hard normals. Multiply each triangle face by a bit of random shading to get a "hand-painted" blocky feel.
- Loft modeling: The body, fingers, and lips are continuous surfaces lofted along a path. Shape details are written into the profile function of the cross-section, rather than stacking a bunch of spheres (which leave seams).
- Procedural textures: Skin pores and fabric textures are drawn live on canvas. Textures are multiplied with vertex colors: vertex colors decide the blocks, textures only add detail.
2. The Protagonist Was Built from a Photo
Xiao Wu's prototype is a photo taken on Huangshan. During modeling, key body parts from the photo are measured into an anchor point table of world coordinates (top of head 1.78m, eyes 1.598m, chin 1.435m...). All parts are written against this table.
Skeletal animation avoids skinning; instead, it uses "geometry moved pivot": geometry is still written in world coordinates, then translated wholesale to the joint pivot and placed inside a rotatable group. This keeps the static pose pixel-identical to before the bones, and walking/running only rotates groups.
Two details:
- Pose is passed via ref to each part, and each part's
useFramewritesrotationdirectly, so animations don't trigger React re-renders. - Gait phase advances by distance traveled (
phase += speed * dt / stride), not time, so the feet never slide on the ground. Walking and running parameters interpolate by speed, not as two separate animations.
3. Terrain Is a Pure Function
export function terrainHeight(x, z) {
const raw = baseHeight(x, z); // noise undulation + surrounding ridges + main peak
const { dist, y } = nearestPath(x, z); // distance from path, path height here
// flatten near path to road surface, slightly cut for wheel ruts
...
}
The ground mesh, vegetation distribution, monster spawns, player's foot height, and wheel contact all query this one function, so the player never floats or sinks into the ground.
Path elevation is sampled from raw terrain and smoothed along the path several times, then clamped back to near the original terrain to avoid cutting deep ditches on ridges or building high embankments in low spots.
4. Combat Is a Numeric Table
Each move is a config line:
stab: { name: '刺', time: 0.3, strike: [0.24, 0.7], mult: 1, range: 2.55, cone: 0.22, knock: 0.85 },
cone is the allowed cosine of the facing angle: 1 means straight ahead only, 0 means front half-circle, -1 means all around. Hit detection only checks three things: is it within the strike window, is the distance enough, is it inside the sector. Out of range always misses, in range always hits; the feel is very deterministic.
5. Per-Frame Changing State Goes Outside React
Player position, velocity, drift distance, and other per-frame values live in module-level singleton objects. Controllers write every frame, HUD reads every 50-100ms. With only one player in the scene, a singleton is enough, and it won't drag down React with per-frame setState.
6. Rendering Performance: Keep the Frame Rate Steady
This took the most time. The most effective practices:
Light count never changes. When the count of visible point lights in three.js changes, all lit materials recompile their shaders. Early on, skill effects carried their own point lights, causing a 2-3 second freeze on first use. Now the scene always has 4 point lights in a pool; effects merely "request" a light, the pool picks by distance and brightness, so the runtime light count is always 4.
Shader pre-warm. After entering the map, during browser idle time, compile the effect root nodes for skills and ultimate once, so the first use doesn't stall. Also, avoid switching material properties that spawn new shader variants at runtime (transparent, vertexColors, etc.). If two states are needed, build two materials ahead of time.
Batch all repeated objects. The 165 monsters and many skill particles use InstancedMesh. For multi-part monsters, bake part matrices into one geometry, then merge by material parameters into groups. Particle per-instance transparency is kept in a separate instanceAlpha attribute instead of being multiplied into color, because with tone mapping the two give different results.
No mesh raycasts for clicking monsters. To detect whether a monster is clicked, doing a mesh raycast on 165 monsters is too expensive. Each monster is approximated by a vertical capsule; ray-capsule intersection is a few lines of analytic math with no memory allocation.
Per-frame logic allocates nothing. All monsters share one useFrame, which reuses module-level Vector3 and Matrix4.
To avoid relying on gut feeling, the project has a built-in performance probe: visiting ?perf=auto automatically runs a combo, skills, ultimate, weapon switch, and drops, outputting metrics for each step. The acceptance criteria:
| Metric | Requirement |
|---|---|
Newly compiled shaders newPrograms |
0 at every step |
| Point light count | Always 4 |
| Longest frame time | No more than ~20ms |
| Draw calls | ~214 normally, ~660 during ultimate, not above baseline |
7. First Screen Load: Mobile Is the Baseline
Entering the map, the main thread must run all synchronous construction (terrain mesh, scattering trees, spawning monsters) before the first frame can be drawn, and phones are 4-6x slower than dev machines.
- Give feedback first: Write a loading overlay directly in HTML, visible before JS even downloads.
- Reduce fidelity on mobile: terrain mesh segments 176 → 120, shadow map 1024 → 512, pixel ratio fixed to 1, grass count 880 → 520. This switch must be set before the first build, otherwise it builds at desktop fidelity first.
- Don't recompute: terrain shading needs slope, originally each vertex computed height twice extra; now it queries the already-computed heights of adjacent vertices.
- Defer what's not needed for first frame: The SU7 and ambient light are split into separate bundles; after the first frame, they're attached in batches during browser idle time. The car asset is fetched with low-priority
prefetch, not competing for connections with first-screen scripts. - Enter as soon as the image appears: The overlay closes when the main scene draws its first frame, without waiting for the SU7 or skill pre-warm.
- Synchronous build has a budget: With mobile params, the measured synchronous calculation at map entry totals about 110ms (on a dev machine). Each new feature may add no more than about 20ms of synchronous time; if exceeded, reduce fidelity or move the work until after entry.
8. Mobile Adaptation
- Whole-page rotation: In portrait, rotate the entire page 90° via CSS, and convert touch coordinates by the rotation angle, so the wheel and camera drag don't invert.
- Safe area follows rotation: On iPhone landscape, there are the Dynamic Island and rounded corners on the sides. After rotating the page 90°, the physical screen's top and bottom become the page's left and right, so each rotation angle maps to different
env(safe-area-inset-*). - Two-finger pinch to zoom camera, while blocking the browser's own page zoom.
4. Pitfalls Hit
Pitfall 1: Fine in Dev, Production Throws Cannot access 'Ya' before initialization
To split bundles, I added manualChunks in vite.config.js to split business code into game and lowpoly by directory. But the character model, in lowpoly, depends on the game's combat state; the game also depends on the character model, creating a circular reference between the two bundles.
Dev loads modules one by one, so no issue; in production, the two chunks import each other, and execution order goes wrong, one module uses a variable the other hasn't initialized. The build log always had a line Circular chunk: lowpoly -> game -> lowpoly, but I ignored it.
Fix: manualChunks only for third-party libs in node_modules (three, r3f, drei won't circularly reference business code); business code is split automatically by Rollup with lazy(() => import(...)).
Lesson: After changing import structure or build config, you must build the production bundle and check the console in a preview server; treat Circular chunk warnings as errors.
Pitfall 2: Phone stuck on loading screen, can't enter
The mobile rotation shell is lazy-loaded, and I gave its Suspense a fallback={body} — before the shell arrives, render the body without the shell. The problem: the body includes the entire game canvas. The phone builds the scene and WebGL context once, then when the shell arrives, it tears it down and rebuilds. After terrain got complex, the phone simply froze on the first build.
Fix: change fallback to null; during the wait, the loading overlay covers, and the scene is built only once.
Lesson: Never put canvas or the same heavy-weight subtree as the body in Suspense fallback.
Pitfall 3: iPhone landscape, direction keys and skill buttons "disappear"
The rotation shell's width/height were previously computed in resize events by reading visualViewport. On iOS rotation, resize fires first, dimensions change later, so the read height may still be the portrait value. The shell is taller than the screen, pushing the bottom-anchored direction and skill keys off-screen, while the view and top HUD look fine, very confusing.
Fix: When no rotation is needed, the shell uses CSS 100% to fill; when rotation is needed, measure the page container's actual size with ResizeObserver—it fires after layout actually changes, so it won't read stale values.
Lesson: Don't trust viewport read in resize for layout dimensions, and don't use 100vh (on iOS it's taller than visible area).
Pitfall 4: Xiaomi long-press on direction keys pops up system image search
Xiaomi's image search, iOS magnifier, and browser long-press menus all trigger from an un-cancelled touchstart long press. Calling preventDefault in pointerdown on the button doesn't stop them.
Fix: For control areas needing hold (steering wheel, attack and skill keys, canvas), cancel touchstart uniformly. But buttons that use onClick (weapon bar, vehicle button) can't do this: once touchstart is cancelled, the browser won't fire click anymore, buttons become unclickable. So hold-type buttons all switch to pointerdown / pointerup.
Pitfall 5: Car wheels don't spin when driving
The SU7 model was compressed with gltf-transform optimize, which flattens the node hierarchy, losing child nodes like wheels that need individual rotation. Later only Draco compression (gltf-transform draco) was used, preserving the node hierarchy. Also, GLTFLoader cleans node names (Wheel.001 → Wheel001); regex when finding nodes by name must support both formats.
5. Developing with AI
This project was written together with Cursor's Agent. From the experience, making AI write code isn't hard; the hard part is stopping it from repeating mistakes. My approach: distill the conclusion of every pitfall into project rules (.cursor/rules/*.mdc) and skill docs (.cursor/skills/):
perf-first.mdc: active in every conversation. States "rendering efficiency and load speed come first; changes must not introduce bugs", plus the hard constraints from Pitfall 1 and 2.wulai-perf-standards/SKILL.md: concrete practices for light pool, shader pre-warm, batching, first-screen budget, bundling, and acceptance process; read before touching related code.mobile-controls.mdc: auto-active when changing mobile files; records long-press interception, landscape/portrait dimensions, safe areas, and real-device acceptance methods.person-model.mdc: character anchor table, skeleton sign conventions, and modeling pitfalls already hit.
After every bug fix, ask "how can this error never happen again?" and write the answer into a rule. The rules accumulate, and AI hits fewer of the same pitfalls when changing code. Acceptance is as automated as possible: headless Chrome + CDP simulating mobile portrait/landscape, safe areas, real touch, with the performance probe for metrics instead of eyeballing "looks fine".
But some issues can't be reproduced in simulators, like iOS viewport lag during rotation and Xiaomi's system image search; real devices are still needed for confirmation.
Conclusion
Building a playable 3D action game with React + Three.js, familiar to frontend devs, is more feasible than expected. The real difficulty isn't "drawing it" but "being fast and stable on mobile": lights, shaders, draw calls, first-screen synchronous computation, each needs numbers and a budget.
Come play: https://arxun.github.io/wulai/. Comments and suggestions welcome.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Oh, right, https://juejin.cn/post/7352762271003017252?searchId=20261102181103B34E42A6B618E850B6C1 SU7 came from this C.