Vellum Online — browser MMORPG on microservices
A persistent world where the bots come in through the same front door as real players.
- .NET 8
- YARP
- Node.js
- Socket.IO
- TypeScript
- Python
- FastAPI
- Three.js
- PostgreSQL
- Redis
- MongoDB
- RabbitMQ
The problem
A real-time multiplayer game puts pressure on everything at once: latency, shared state, server authority and cost per player. It also has a problem business software does not: an empty world is a dead world, and at the start there are no players in it.
Outcome
A browser MMORPG live at vellumonline.cc, with a dozen coordinated services, a world populated by automated accounts that genuinely play it, and an MCP interface an AI agent can connect to and play through like anyone else.
How the services divide
The system is split by domain, not by development convenience:
| Service | Technology | Role |
|---|---|---|
api-gateway |
.NET 8 + YARP | The single entry point: HTTP and WebSocket reverse proxy |
identity-service |
.NET 8 | Registration, login, sessions |
catalog-service |
.NET 8 | Items, recipes, equipment |
world-content-service |
.NET 8 | Zones, resources, world content |
social-service |
.NET 8 | Guilds, parties, chat |
i18n-service |
.NET 8 | Localised text |
physics-server |
Node.js + Socket.IO | Simulation, combat, movement |
npc-service |
TypeScript | The ambient player population |
ai-service |
Python + FastAPI | NPC state machines and ML |
game-client |
Three.js + Vite | 3D browser client |
game-mcp |
TypeScript | MCP interface for agents |
Underneath: PostgreSQL for relational data, Redis for cache, MongoDB for documents and RabbitMQ for inter-service events.
Why physics runs alone
The simulation loop has a constraint no other service has: it must finish every frame inside a fixed window. Putting it in the same process as domain logic means a slow inventory query becomes a visible stutter on screen. Separating it also lets it scale on a different metric — concurrent players per zone, not requests per second.
The decision I am proudest of
The ambient players that populate the world have no privileged access at all. No internal API, no
database connection, no shortcut: they register through POST /api/auth/login at the gateway and
play over Socket.IO, exactly like a human being’s client.
It was the harder road and it paid off twice.
First: the bots cannot drift from the game. When a mechanic changes they see what a real player sees — not an internal view somebody forgot to update.
Second, and less obvious: they became a permanent end-to-end test. Every function a bot uses — equipping, picking a target it can actually hurt, gathering, crafting, selling on the market — is a function somebody is continuously exercising against the live system. More than once the first signal that something had broken came from how the bots behaved, not from a test suite.
The bots also carry constraints the game does not impose but good sense does: they buy from real players’ listings and never from their own fleet-mates, because an economy where the bots trade with each other produces fake prices and useless statistics.
A world an agent can play
game-mcp exposes the game as an MCP server: authentication through the gateway, a Socket.IO session
holding a live snapshot of the world, and the full catalogue of actions — movement, combat,
gathering, crafting, market, quests, fishing, farming, chat, zone transfer.
Observation-only actions read the local snapshot without touching the server, so an agent can look around as much as it likes without generating traffic. It is the same principle as the bots, taken further: if the public interface is enough to play the game, it is enough for an automated agent too — and the game does not need to know who is on the other side.
What it taught me
That the right number of microservices is the one the domain boundaries impose, not the one that looks modern — and that the expensive part is not writing them, it is keeping the contracts between them in agreement.
And that constraining yourself to the public front door, where you can, pays for itself: every time I resisted opening an internal shortcut, the system got easier to verify.