I Built a Multiplayer 3D World That Runs in a Browser Tab — With Zero 3D Model Files
Building Solo World, Part 1 of 3: the architecture, the stack, and why the server owns everything.
Open soloworld.in and, a few seconds later, you are standing in a lantern-lit village. A river winds past a windmill. There is a library, a tea stall, a post office and a market. Walk far enough and you reach an island you can only get to by boat, a city with its own metro and airport, a desert you cross on a camel, caves, a temple and a valley of dinosaurs.
Everyone else you see is a real person, in the same world, at the same time.
There is no install, no launcher and no download screen. It is a browser tab. And there is not a single 3D model file, texture or sound file in the repository. Every tree, roof, boat, dinosaur and engine hum is generated by code at runtime.
I built Solo World on my own, starting in mid-September 2026. At the time of writing, around 350 players have walked in: about 135 with accounts and the rest as guests. The weekly leaderboard drew 90 players in its first week.
This series is about the engineering, not the game design. I’m a developer by day, and most of what I learned here was the hard way, by measuring something I had assumed was fine.
- Part 1 (this post): The Architecture, the stack, and the one rule that shaped everything: the server owns the truth.
- Part 2: Multiplayer. Making ten network updates a second look smooth at 120 frames a second, and the race conditions hiding in joins, reloads and logins.
- Part 3: Performance. How a world that started at ~60,000 scene nodes and ~5,200 draw calls became something a phone can run.
The constraints I set before writing any code
Every architecture decision in this post comes back to five constraints I picked on day one.
- A browser tab and nothing else. No install, no plugin, no “download our launcher”. If it doesn’t load from a link, it doesn’t exist.
- Phones are first-class. Not “works on mobile” as an afterthought. A mid-range phone has to hold a playable frame rate.
- Multiplayer from the first commit. Bolting multiplayer onto a single-player game later is how you end up rewriting it. Every feature was designed against a shared world from the start.
- Guests can walk straight in. A signup wall kills curiosity. You can explore as a guest in seconds. An account is what makes progress, coins and friends stick.
- One developer, a small hosting bill. No Kubernetes, no Redis cluster, no microservices. One frontend deploy, one backend process, one database.
That last constraint did more design work than any of the others. When you can’t throw infrastructure at a problem, you have to understand it.
The stack, layer by layer
The whole system fits on one page: a static 3D client on a CDN, one long-lived Node process for HTTP and realtime, and Postgres as the single source of truth.
The whole system: a static client, one stateful server process, one database.
- UI shell: React 18 + Vite + TypeScript. Fast builds, route-level code splitting, one language end to end.
- 3D: React Three Fiber + three.js + drei. Scenes as components; the world is just more JSX.
- Physics: Rapier (WASM). Fast, and it runs a fixed 1/60 s step right in the browser.
- Client state: Zustand. Tiny, and it lets you read state outside React, which matters a lot (Part 3).
- HUD: Material UI v5. Menus, chat, marketplace and settings without reinventing forms.
- Realtime: Colyseus 0.16. Rooms, join/leave/reconnect and message routing over WebSockets.
- HTTP API: Fastify 5. Fast, schema-friendly, and it can share a port with Colyseus.
- Validation: Zod. Every message and request body is parsed, never trusted.
- Database: PostgreSQL on Supabase, via Sequelize. Transactions, constraints, and 79 reversible migrations so far.
- Hosting: Vercel for the client, Render for the server. A CDN for static files, a real long-running process for sockets.
Two choices deserve a word.
React Three Fiber turns a scene into a component tree. A hut is a component. A village is a list of huts. That is wonderful for building a world quickly, and dangerous for performance, because React will happily re-render that tree when you didn’t mean it to. Part 3 is largely about that trap.
Colyseus gives you rooms, matchmaking, reconnection and a binary state-sync protocol out of the box. I ended up using the rooms and the messaging heavily, and deliberately not using its automatic state sync for anything that moves. More on that in Part 2.
The backend follows a boring, strict layout on purpose: thin controllers, business logic in services, Colyseus handlers separate from HTTP routes, and no database query in a controller or a React component. Boring structure is what lets one person keep 60 realtime message handlers and 80-odd services in their head.
Everything is code: a world with no asset pipeline
The assets/models, assets/textures, assets/icons and assets/fonts folders in the client contain exactly one file each: a .gitkeep.
Every building, vehicle, animal and tree is assembled from primitives in TypeScript. Every sound, from a coin pickup to the crop duster’s engine, is synthesized with the Web Audio API at runtime. There are no .glb, .png or .mp3 files to load, cache or license.
That sounds like a stunt. In practice it bought three real things.
1. The world is data, and data can be shared. Layouts live in plain TypeScript constants: where each hut stands, how wide each road is, where the river bends. The renderer reads them. So does movement collision, which is resolved against that data, never against meshes. So does an offline script that generates the server’s ~445 approved coin spawn points by flood-filling the same walkable area the player moves through. One source of truth, three consumers.
2. Sounds can be driven, not just played. A synthesized engine is a set of oscillators and gain nodes, so its pitch can follow a vehicle’s live speed and the ambience can follow your distance to a river. With recorded audio you’d need a library of pre-rendered variants.
3. The look is one patch, not 280 edits. The world is rendered like a hand-painted oil landscape: visible brushwork, matte pigment, soft light, distance dissolving into haze. It’s built from about 280 meshStandardMaterial declarations across about 30 components. Instead of touching any of them, the client patches MeshStandardMaterial.prototype.onBeforeCompile once, at module scope, before the canvas mounts, and splices its GLSL into three.js’s own shader. A full-screen paint pass runs on top, and only on devices that can afford it.
The village map is an SVG drawn from the same layout constants the 3D world and collision use.
Get Hemanth R’s stories in your inbox
Join Medium for free to get updates from this writer.
The cost is just as real, and I’ll be honest about it in Part 3. Procedural primitives don’t come with level-of-detail chains, so distant objects are either fully built or not built at all. And when every mesh declares its own geometry and material inline, the GPU sees thousands of unique objects where an artist’s pipeline would have shared a handful. That one decision is still the biggest remaining performance cost in the village.
One process, one port: deploying a realtime server
The client is easy. It’s a static Vite build, so Vercel serves it from a CDN with an SPA rewrite and a strict set of security headers. One detail worth knowing: Rapier ships as WebAssembly, so the Content-Security-Policy needs ‘wasm-unsafe-eval’ in script-src, or physics never starts.
The server is the interesting part. A Colyseus room is a long-lived, stateful process holding open WebSockets. That rules out serverless functions entirely: they are short-lived and stateless by design. So the backend runs as one ordinary Node 22 process on Render.
Hosts like Render expose a single public port per service. Locally I wanted Fastify and Colyseus on separate ports for clarity. The fix is a few lines: if the two configured ports are equal, Colyseus attaches its WebSocket transport to Fastify’s own HTTP server.
const sharePort = appConfig.colyseusPort === appConfig.port;
const colyseusHttpServer = sharePort ? app.server : createServer();
const gameServer = new ColyseusServer({
transport: new WebSocketTransport({ server: colyseusHttpServer }),
// This process owns signal handling, so shutdown happens in one order.
gracefullyShutdown: false,
});
if (sharePort) {
await app.ready(); // routes loaded first
await gameServer.listen(appConfig.port, '0.0.0.0');
} else {
await app.listen({ port: appConfig.port, host: '0.0.0.0' });
await gameServer.listen(appConfig.colyseusPort);
}That gracefullyShutdown: false line is there because of a real bug. Colyseus registers its own SIGTERM handler by default, which raced with mine and logged a confusing “already shutting down” error on shutdown. Now one handler shuts things down in a fixed order: the game rooms first, then HTTP, then the database pool.
The same process also runs a few background jobs on unref’d timers: one finalizes each week’s and month’s leaderboard winners, one releases adopted stray dogs nobody has fed, and one samples event-loop lag for diagnostics. No cron service, no queue. For a world this size, a single process is not a compromise. It’s the right size.
Rule #1: the client is a renderer, not a referee
If there is one idea that runs through all three parts, it’s this: anything that matters is decided on the server. The browser draws the world and reports what the player is trying to do. It never decides who owns what, what something costs, or whether a reward was earned.
In practice that means:
- Every inbound message and request is parsed with Zod. A malformed message is dropped. It never crashes the room and never disconnects the sender.
- The client never sends a price, a reward amount or a distance. Purchase endpoints take an item id and nothing else; the controller’s schema strips every other key before the handler runs.
- Where a rule depends on location (you can only buy an apartment while standing at the building), the server uses the position the game room has already accepted, not anything in the HTTP request.
Coins are the clearest worked example, because coins are money.
There is no “collect coin” message. The obvious design is a client that says “I touched coin 42”. It’s also trivially cheatable. Instead, on every player:move the server has already validated, it sweeps the segment between the player’s previous and new position against the active coins. Sweeping the segment, not just testing the new point, matters: a running player or a boat moves 0.5 to 0.8 units between samples and would otherwise step straight over a coin.
When the sweep hits, one transaction does two writes:
- 1. Claim it. Postgres row-locks the coin; of any number of concurrent claims, exactly one gets a row back.
UPDATE world_coins
SET status = 'collected', …
WHERE id = :coinId AND status = 'active'
RETURNING value;
- 2. Credit the purse with the coin's own stored value.
INSERT INTO user_coin_purses (user_id, balance, …)
VALUES (:userId, :value, …)
ON CONFLICT (user_id) DO UPDATE
SET balance = user_coin_purses.balance + :value;Two players touching the same coin in the same tick is not an edge case you handle. It’s a case the database makes impossible. Both writes commit together or not at all, CHECK constraints stop a balance going negative, and a respawned coin gets a new id, so an id can be collected once, ever.
On top of that: a per-user ceiling on collections per minute, position jumps nobody could have walked or ridden are ignored, and only a session that joined with a valid access token is eligible at all. Guests see the world; they don’t earn money in it.
Identity: guests first, accounts second
The guest path came first, and accounts were layered on top of it without changing it. A Colyseus join with a userId takes the signed-up path; a join without one is the original guest flow, untouched. Both kinds of player share one room.
Two identifiers do two different jobs, and mixing them up is a classic multiplayer bug:
- userId is the person. It’s persistent, and it’s what positions, purses and friendships hang off.
- sessionId is the connection. It’s new every time a socket opens.
A display name is never identity. Guests who leave the name box empty get a generated, village-flavoured name like GoldenFox or FrostyHeron. The first version gave every unnamed guest the literal name “Guest”, and the second unnamed guest to arrive was rejected because “Guest” was taken.
Authentication is deliberately conventional, because conventional is what you can reason about:
- Passwords are hashed with bcrypt (12 rounds). Login returns the same generic error for “no such user” and “wrong password”, and runs a dummy bcrypt comparison on the no-such-user path so both take about the same time. Timing is a quiet way to enumerate usernames.
- Access tokens are short-lived JWTs (15 minutes) kept in memory only, never in localStorage.
- Refresh tokens are opaque, high-entropy values in an httpOnly cookie scoped to /api/auth. Only their SHA-256 hash is stored, so a database leak alone can’t log anyone in. Every refresh rotates the token.
- Sessions are absolute, not sliding: 24 hours from login, however often the token rotates. A proactive refresh just before each access token expires keeps an idle tab logged in without any activity polling.
- One active session per user, enforced by a partial unique index, not by application code.
Rotation sounds simple until two refreshes race each other. That bug, and the fix, is in Part 2.
One last piece of database hygiene. Supabase auto-generates a public REST API over your tables, and I don’t use it: the Node server is the only client. So every table has Row Level Security enabled with zero policies. For Supabase’s public API that’s a deny-all. For my server, which connects as the table owner, it changes nothing. The catch to remember: each new table needs its own ENABLE ROW LEVEL SECURITY in its migration.
What I’d tell you before you start
Pick hosting by the shape of your process, not by habit. A WebSocket server is a long-lived, stateful process. Put the static client on a CDN and the stateful server somewhere that runs a real process. Don’t try to make one platform do both.
Decide what the server owns on day one. Retro-fitting authority is miserable, because every client feature quietly assumes it can decide things. Start from “the client renders, the server referees” and every later feature has an obvious shape.
Push invariants down to the database. Conditional UPDATEs, unique indexes and CHECK constraints are concurrency control you don’t have to test for every code path. They hold under a race you never thought of.
Make the world data, then let many things read it. Keeping layouts as plain constants meant the renderer, the collision system and the server’s coin spawner all agreed about where things are, for free.
Write the rules down where your tools can see them. The repo has a short CLAUDE.md and SKILLS.md at its root: server-authoritative state, transactions for anything valuable, never write a position every frame, no database queries in controllers. I work with an AI coding assistant, and those files are the contract it reads before touching anything. They turned out to be the best onboarding doc the project has. The READMEs follow the same discipline: each change records what was measured, what changed, and what’s still a known limitation.
Next: Part 2, multiplayer
Early on, players told me the deployed build got badly laggy once more than two people were in the village, and that dragging the camera felt delayed. The server, it turned out, was barely awake. In Part 2 I’ll cover how Solo World sends position updates: interest management by distance, a 171-byte message squeezed to 43, interpolation that survives network jitter, and the race conditions hiding in page reloads and token refreshes.
Come and walk around: soloworld.in. Guests welcome, no signup needed.









