跪拜 Guibai
← Back to the summary

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.

desktop.png

1. Game Introduction

World

Combat

ult.png

crater.png

Progression

Vehicle

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.

mobile.png

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

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.

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:

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.

8. Mobile Adaptation

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/):

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.

Comments

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.