← All projects
ReactThree.jsRust

axel.land

A browser-based remake of ActiveWorlds. Explore 3D worlds, meet other players, and build.

RoleDesign & engineering
Year2024 - ongoing
TypePersonal project

Virtual worlds from the late 1990s proved that persistent shared spaces could feel magical. You wandered into someone else’s build, read a sign they left years ago, bumped into a stranger in a plaza. Most of that infrastructure never made it to the modern web. Today’s browser games are usually isolated experiences or thin wrappers around native engines.

axel.land is the bet that a full multiplayer world can run in a tab with nothing installed. It is a modern remake of ActiveWorlds: explorable worlds connected through a universe server, rebuilt for the web with WebTransport, a custom game protocol, and a stack split across four services.

The problem

ActiveWorlds could assume a native client and a proprietary protocol. A browser remake has to earn every millisecond inside sandboxed tabs with strict memory limits. You need streaming terrain, physics, other players, and user content, all without freezing the page while a sector full of props loads.

I wanted to see whether that whole stack could live on the web: authentication, world routing, server simulation, and a client fast enough to keep up. Not a tech demo of a plaza. A real world you can enter, explore, and share.

What it does

The experience is deliberately simple to enter:

  1. Sign in as a guest tourist or registered citizen
  2. Connect to the universe and pick a world (the lobby is the default)
  3. Drop into a shared 3D space with other players
  4. Walk around, chat, and explore props placed in the world

Three things define the product:

  • Nothing to install. The world runs in the browser. No plugin, no client download.
  • Persistent spaces. Worlds keep the objects people place. Come back later and they are still there.
  • Local presence. Chat stays tied to where you are, so conversations feel like they happen in a place, not in a global feed.

Worlds are made of sectors: 128-meter cubes of terrain and objects that stream in and out as you move. Props can carry surfaces such as text signs, picture panels, and custom materials. Chat is proximity-based within your current sector.

How it works

Authentication and worlds

The auth API is a TypeScript service built on better-auth, with MongoDB for persistence and Redis for sessions.

It handles email/password login, verification, password reset, and anonymous tourist sessions for visitors who want to try a world without registering. Registered users pick a username and become citizens. Worlds are modeled as organizations, each with API keys that world servers use to authenticate back to the platform.

Cloudflare Turnstile protects sensitive endpoints. JWTs issued here are what the universe and world servers trust when a client connects.

Universe routing

The universe server is a Rust Axum service that sits between clients and world servers. Players connect over WebSocket with their auth token. World servers connect on a separate channel.

When a player requests entry into a world, the universe forwards that request to the matching world server. The world server responds with a WebTransport URI and a short-lived session token. The universe relays that back to the client, which then opens a direct connection to the world. After that handoff, the universe steps out of the hot path for gameplay.

Session state and world registry data live in Redis, so multiple universe instances can share presence and routing.

World simulation

Each world runs as an independent Rust process built on Bevy ECS. This is where the game actually happens.

  • WebTransport handles real-time client connections (via Aeronet)
  • Rapier3D runs server-authoritative physics and character movement
  • Sectors load and unload dynamically based on who is nearby, with reference counting and timed cleanup
  • Props are persistent objects stored in per-sector SQLite files: models, positions, rotations, and attached surfaces
  • Entity sync broadcasts player positions, animations, and sector changes with a custom binary delta format. Full snapshots on join, relative deltas afterward
  • Chat is validated, rate-limited, and delivered to other clients in the same sector

Models are shared resources. Many props can reference the same GLB, loaded once and reference-counted until nothing needs it. When a sector has no nearby players, it idles briefly and then unloads.

Browser client

The web client is an Astro shell around a React application. Almost all of the game runs off the main thread in dedicated Web Workers:

  • Network worker for the WebTransport socket, protobuf control messages, and entity delta decoding
  • Physics worker for Rapier WASM simulation and client-side prediction
  • Renderer worker for the Three.js scene on an OffscreenCanvas, GLTF loading, and instanced meshes for props
  • Texture worker for sign and billboard textures generated from surface data

The main thread owns a bitecs ECS world and SharedArrayBuffer views that workers read and write each frame. Input is sampled on the main thread, encoded as compact binary commands, and sent to the server. Remote players are interpolated between server snapshots.

This layout exists because browsers punish the main thread. Rendering, physics, and networking all compete for the same budget. Pushing them into workers keeps interaction responsive even when a busy sector is loading.

Networking

Control messages such as login, enter world, teleport, sector queries, and chat use Protocol Buffers. High-frequency data uses purpose-built binary formats:

  • Input commands are delta-compressed each tick
  • Entity updates carry quantized positions, packed quaternions, and sector changes with per-field change flags
  • Sector payloads ship as Brotli-compressed blobs of prop data, with client-side caching by version

The client requires WebTransport, SharedArrayBuffer, and OffscreenCanvas. An unsupported-browser gate explains those requirements instead of failing silently.

Architecture

Browser (web)  →  auth API  →  accounts & sessions

  universe server  →  routes players to the right world

  world server  →  simulates physics, entities, and world data

Splitting the universe from the world server was the key architectural decision. It mirrors how ActiveWorlds separated universe login from world hosting, and it keeps simulation isolated so one busy world does not stall routing for everyone else.

What I learned

Remaking ActiveWorlds for the web forced trade-offs the original never had to make. The hardest problems were not rendering. Three.js handles that well. The hard part was coordination: getting four services to agree on identity, routing a player to the right world server, and keeping entity state consistent when packets arrive out of order.

Sector streaming with reference counting turned out to be the right granularity for persistence and performance. Worlds can grow large without holding everything in memory, and SQLite per sector keeps load times predictable.

A multiplayer world in a browser is less about one clever library and more about drawing hard lines: what belongs on the main thread, what belongs in a worker, what belongs on the server, and what should never block a frame.

Stack

Client

  • Astro, React, and Tailwind CSS
  • Three.js, Rapier WASM, and bitecs
  • Web Workers, SharedArrayBuffer, and WebTransport
  • Protocol Buffers and Comlink

Services

  • auth: TypeScript, Express, better-auth, MongoDB, Redis
  • universe: Rust, Axum, Redis, WebSocket
  • server: Rust, Bevy ECS, Rapier3D, Aeronet WebTransport, SQLite
Next projectMakeSubs