This repository contains code for a proof-of-concept, multiplayer voxel browser game engine. Use at your own risk. The following README is a look at the technical design and architecture of the engine. For more information on the history and motivation of this project checkout the companion blog post.
This engine is multi-threaded using web workers. The main thread handles the browser APIS: input, networking, and rendering. There is one simulation worker thread that runs the game simulation loop. The simulation worker thread then renders using the offscreen canvas API. Messages are passed to the worker thread over the browser postmessage api. The server runs the same worker simulation thread, but messages are passed over the network instead. This separation of concerns keeps the simulation running at max tick rate and supports the main render thread at smooth 60 FPS.
The asset pipeline was built around the aomesher and the complementary ao-shader. Voxels are stored in a 3-Dimensional 32bit integer array ndarrays The ndarrays are created in x,y, and z dimensions that match the source voxel file dimensions. The voxel color palette data is stored within the ndarray as a 32-bit integer.
The asset pipeline parses .vox
and .qb
format voxel models and converts to a custom .aomesh
format. .vox
models are used for static environment models. .qb
models are used for segmented character models later bound to skeletal animation data. The .qb
files store a combination of joint + color values. The .aomesh
format is an interleaved binary vertex format that captures follows the aomesher vertex format
The vertices are stored in 32bit arrays; Each vertex is a 32bit integer made up of the following 8bit values:
x, y, z, ambient occlusion, normal_x, normal_y, normal_z, tex_id
There is an interesting byte optimization where we pack both joint_id
+ palette_id
into the last text_id
byte.
You can generate the .aomesh
models with the npm run build:meshes
command.
Player avatars are customization through segmented player meshes. The player meshes are stored in quiblce .qb files and broken into separate meshes. They are then converted to vertices format at runtime and bound to skeleton joint index ids.
The animation system uses Skeletal animation system. The skeletal animations live in a Blender file then the animations are exported to JSON using Landon.
The animations are linked to .qb
files at build time in the QBToAOVerts.js script. The Blender to JSON animation pipeline is depressingly brittle. There is a list of Landon and Blender dependencies documented in the Landon export script.
The ECS does not follow a pure ECS pattern with a strict flat buffer memory arrangement. The engine uses a custom entity component system powered by mobx. It’s more of a mixin system. Entities are classes composed of mixins. Each mixin is a class that has its related state. Mixins then use mobx’s computed
and observable
helpers to derive entity state dynamically.
The engine has a stateless rendering function influenced by React. An offscreen canvas reference is passed from the main thread to the simulation worker. The game state from the ECS is passed to a render function. The rendering function uses react-regl
, which is a wrapper for Regl. Most of the rendering action happens within the RenderStore.ts. The RenderStore imports shader code, draws commands, and then serializes the game state from the workers and passes it to the GPU. The render function is called on each tick of the game loop.
- Broad phase
The broad phase collision uses AABB to AABB. Each frame checks all entities for AABB collisions. You could further optimize the broad phase with some spatial partitioning quadtree system or something.
- Narrow phase
Narrow phase collision uses swept sphere triangle collision based on the fantastic paper Improved Collision Detection and Response” by Kasper Fauerby. When a broad phase collision is detected, the narrow phase detection starts. The narrow phase currently only happens on player collision capsules against environment entities.
This gif shows the players’ client position and server position as a ‘ghost.’
The networking code uses WebRTC. The WebRTC connection runs in unreliable mode, which supposedly removes some overhead reliability checks and is equivalent to running in UDP instead of TCP. The server uses a node.js WebRTC implementation and acts as a headless peer. A Signalhub server acts as a broker for the WebRTC handshake. In production, a Coturn server establishes STUN/TURN Nat punch through.
Entities can use the NetworkReplicated to replicate their network state. The NetworkRollback component is a naive rollback reconciliation algorithm implementation that players use to buffer and reconcile inputs when synchronized from the server. Server message payloads are serialized using kiwi-schema
Running the production server on the most affordable Digital Ocean instance, I successfully conducted play tests with 8-10 players connected simultaneously. This server load handling is satisfactory for a death match gameplay design, meeting our expectations. While we didn’t test further scaling of the server, it’s possible that more powerful instances could accommodate many more players.
This code base was built against node v12.21.0
and hasn’t been tested with later versions. Try upgrading at your own risk.
Clone this repo and npm install
You can start the engine by executing the following:
npm run dev
The dev command is a combined command that will startup several concurrent processes. It will take ~15 seconds to startup due to some unfortunate sequential sleep commands. The app will be accessible on https://localhost:3000 when ready.
There is a set of debugging flags in the browser worker thread. You can toggle the debug flags to provide debug rendering features: https://github.com/kevzettler/multiplayer-voxel-browser-game-engine/blob/master/src/browser.worker.ts#L18-L28
There are deployment files in the /devops directory. They will need some modification to get working. I stripped out all the secrets and domain-specific information. There is a docker-compose file that demonstrates the service dependencies needed to run a production copy of this engine. I ran the complete engine on the lowest-tier digital ocean instance with at least eight players connected to a server instance.
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.