Phaser or Three.js for a browser game? Lessons from 2D and 3D games on one testable core

How a three-game superhero suite uses Three.js for its 3D arena and open city, Phaser 3 for a 2.5D brawler, and one engine-free core for rules, input, audio and tests. The trade-offs, and a checklist.

DSDilawar Singh 8 min read 3 views
Phaser or Three.js for a browser game? Lessons from 2D and 3D games on one testable core

"Should we build it in Phaser or Three.js?" is usually the first technical question on a browser game project, and it is often asked as if the answer has to cover the whole product. It doesn't. You can pick the engine per game and still share most of the code, as long as the rules don't live inside either engine.

This post works through that decision using a real codebase: Suryaveer vs Krodh, our free suite of three superhero games. Two of the games are 3D and use Three.js. One is a 2.5D side-scroller and uses Phaser 3. All three share one hub, one save, one input layer, one sound system and the same pattern for their rules engines. Here is what each engine was chosen for, what it costs, and how the shared core keeps the suite testable.

The short answer

If your game is mostly… Lean towards Why
Sprites on a flat or layered 2D stage Phaser 3 Sprites, texture atlases, animations, depth sorting, particles and scaling come built in
A free camera moving through a 3D space Three.js You need a scene graph, perspective camera, lights and meshes; Phaser does not do this
2D gameplay with a 3D look Either, carefully A 3D renderer with a locked camera, or 2D sprites rendered from 3D, both work
Several of the above in one product Both, behind a shared core Each game gets the right renderer; rules, input and audio stay engine-free

The rest of this post explains why the last row worked for Suryaveer, and where it didn't come free.

What the three games needed

The suite has three games with very different needs:

  • 3D Arena Fighter: a one-on-one boss fight on a circular plaza. The camera follows the hero around the boss, the props break apart and the hero switches between four elemental stances.
  • Arcade Beat 'Em Up: three side-scrolling stages with lanes of depth, waves of enemies and a boss at the end of each. Characters walk up and down the screen as well as left and right.
  • City Protector: an open 3D city on a 10 × 10 block grid with a river, bridges, parks and construction sites. The hero flies, and the camera has to show both the streets and the skyline.

Two of these are real 3D problems: a camera that moves freely, perspective, lighting and depth that the player navigates. The beat 'em up is not. It is a classic 2.5D brawler: every fighter is drawn on a flat stage, and "depth" is just a lane value that moves them up the screen and decides who is drawn in front.

Why Three.js for the arena and the city

Three.js gives you a scene graph, a perspective camera, lights, materials and a WebGL renderer, and almost nothing else. For the two 3D games, that was the right amount.

What the code actually uses it for:

  • Characters as jointed rigs. The hero and the boss are groups of primitive meshes (capsules, boxes and so on) with joints the game rotates each frame. They use cel shading (MeshToonMaterial with a three-step ramp) and an inverted-hull outline for the inked comic look. There are no model files.
  • Instancing for crowds and buildings. City Protector draws its crowd of 260 people as a single InstancedMesh, and the arena's background towers are instanced too. That keeps draw calls low on phones.
  • A shared environment module. src/core/three-env.ts sets up the renderer, a gradient sky dome, the light rig and a quality preset, and it resizes the canvas with a ResizeObserver. On tall phone screens it widens the field of view so the action stays in frame.
  • A quality switch. Shadows, antialiasing and a pixel ratio of up to 2 are only used on the High setting on non-touch devices. Touch devices always get the lighter preset (no shadows, pixel ratio capped at 1.5), and players can pick Fast (for older devices) in Settings.

What Three.js does not give you is just as important. There is no game loop, no input handling, no sprite system, no audio mixer and no UI layer. You build or borrow all of that. For a 3D game that's a fair trade, because you would replace most of a 2D engine's versions of those anyway.

Why Phaser for the beat 'em up

You could build a side-scroller in Three.js with an orthographic camera. The suite doesn't, because Phaser already solves the things a 2D brawler spends most of its time on:

  • Texture atlases and animation frames. Each fighter pose is drawn once with Canvas 2D paths, then registered with Phaser as a texture with textures.addCanvas().
  • Depth sorting. Every fighter's display depth is set from its lane value, so characters further down the screen are drawn in front of those behind them. In Phaser this is one setDepth() call per sprite.
  • Parallax layers and particles. The three stage themes are painted as layers, and hit effects and the Power Blast beam use Phaser's built-in tools.
  • Scaling. The scene works in a fixed 1600 × 900 logical space with Scale.FIT, so the stage letterboxes cleanly on any screen.

Just as telling is what the suite switches off in Phaser. The game config sets audio: { noAudio: true } and disables Phaser's keyboard, mouse and touch input. Phaser is used as a renderer and nothing more, because input and sound are handled by the suite's own shared modules. That is what lets the 2D game sit beside the 3D games without feeling different to play.

The shared core that makes mixing engines work

Using two engines in one product only works if as little as possible depends on either of them. In Suryaveer, the engine-free layer is most of the game:

Module What it does Engine-specific?
games/*/logic.ts The rules: combat, AI, waves, missions, scoring No: pure TypeScript, no DOM
core/input.ts Keyboard, mouse-drag look, touch joystick and buttons, read as one snapshot per tick No
core/audio.ts Synthesised sound effects and four music loops on the Web Audio API No
core/storage.ts One LocalStorage save for settings and per-game bests No
core/rng.ts A seeded random number generator No
ArenaGame.ts, CityGame.ts Draw the engine's state with Three.js Yes
BrawlerGame.ts Draws the engine's state with Phaser Yes

Two contracts hold it together.

One fixed-step loop, two implementations

Every rules engine advances in fixed 1/60-second ticks. For the 3D games, loop() in three-env.ts adds real elapsed time to an accumulator and runs the tick at 60 Hz (at most five steps per frame), then renders once per animation frame. The Phaser scene does exactly the same thing inside its update() method. The renderer is different; the timing contract is identical. Game speed never depends on the display's refresh rate, and the engines don't know which renderer is calling them.

One interface for every game

The hub doesn't know which engine a game uses. Each game receives a small Host object (the stage element, the HUD and touch layers, modal helpers, exit() and restart()) and returns a GameHandle with dispose() and a debug() snapshot. Adding a fourth game, in either engine or neither, means implementing that interface.

Testing without a browser

Because the rules engines import nothing from Three.js, Phaser or the DOM, they can be tested in Node with Vitest. The repository has 40 unit tests across the three engines (counted from tests/arena.test.ts, tests/brawler.test.ts and tests/city.test.ts), plus 7 Playwright browser tests that launch every game, check a 2× high-DPI screen and play on a phone in landscape. The engines are seeded and deterministic, so a test can replay a whole fight. (We wrote about using that to balance the boss fight in Let a bot playtest your boss fight.)

If your rules live inside Phaser scenes or Three.js render loops, you lose this. Engine choice then becomes a testing decision as well as a rendering one.

The costs of using two engines

Mixing engines is not free. These are the real trade-offs in this codebase:

  1. Download size. Phaser and Three.js are each large. Vite splits them into separate chunks, and the README puts the whole client at about 520 KB gzipped. Phaser loads only when the beat 'em up starts. Three.js, though, is needed at start-up, because the hub renders its portraits from the real 3D models. If your hub didn't need 3D, you could defer both.
  2. Two mental models. Phaser thinks in scenes, sprites and display lists. Three.js thinks in meshes, materials and cameras. The team has to know both, and code reviews have to cover both.
  3. Consistent art across dimensions. The 2D fighters (src/art/figures.ts) and the 3D rigs (src/art/models.ts) are separate artwork that must look like the same characters. A costume change happens in two places. The ?gallery page, which shows every 2D pose, helps you check that.
  4. Duplicated plumbing. Resizing, pausing and cleanup are written once per renderer. Each game's dispose() has to free its own resources: WebGL geometries and the renderer for the 3D games, the Phaser game instance for the brawler.

A decision checklist for your project

  • Does the camera need to move freely in 3D? If not, start with Phaser.
  • Do you have more than one game or mode? Then decide the engine per game, not per product.
  • Can the rules run without a renderer? Keep them in plain TypeScript modules, so you can test them and swap engines later.
  • Who owns input and audio? Pick one owner. If you mix engines, turn off the engines' own input and audio and use one shared layer.
  • What is your download budget? Measure the engine chunks and load each one only when its game starts.
  • What devices matter? Plan a quality preset for 3D on phones from the start, not after launch.
  • Who makes the art? Code-built characters avoid an asset pipeline but need a developer to change them. Two dimensions means two sets of art.

Where to go from here

If you are planning a browser game with more than one mode, or deciding between 2D and 3D, the full source of Suryaveer vs Krodh is a free download from the DEV3 marketplace for anyone who signs in. It is a working example of Three.js and Phaser behind one shared, tested core. The case study covers how the suite was built, and our team can adapt it or build a game like it for your project.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles