← All work

Eternal Crossing

An MMO client written from scratch in Unreal Engine 5

Role
Sole developer - client networking, protocol layer, interface, world export pipeline
Period
2026 - present
Status
In development
Stack
C++ · Unreal Engine 5 · UMG · Python · Binary protocol
371
protocol opcodes, both directions
57
interface screens, login to auction house
20.3 × 12.7 km
of world exported to the server

A custom C++ MMO client in Unreal Engine 5: socket layer, opcode parsing, entity streaming, a full massively-multiplayer interface, and a pipeline that translates the engine’s world into the spatial format the server can reason about.

01Why this is not a game made from a template

Unreal ships with a great deal. It does not ship an MMO. There is no built-in client for a persistent world where hundreds of entities stream in and out of relevance, where the server is authoritative over every position and every ability used, and where the transport is a binary protocol rather than the engine’s own replication.

So the interesting part of this project is the part the engine does not do: a network client that opens the socket, parses and builds opcodes, and turns a stream of packets into a world the renderer can draw.

The client is built against an existing, fully specified wire format rather than one invented alongside it. That removes an entire category of unknowns from a solo project - the transport is not a research question, so the networking can be finished and correct long before there is a world to look at.

02The client

A net client of about five thousand lines and a game-instance subsystem that bridges it into Unreal: sockets and the handling for three hundred and seventy-one opcodes - a hundred and seventy-three inbound, a hundred and seventy-seven outbound, twenty-one either way - on one side, and delegates the gameplay layer subscribes to on the other, so no part of the game talks to a socket directly.

Streamed world entities - other players, NPCs and world objects appearing, updating and leaving as the server decides they are relevant, which is the part of an MMO client that is easy to make work and hard to make correct.

A full UMG interface stack - fifty-seven widget classes covering character and inventory, bank and mail, the auction house, guilds and charters, party and raid frames, chat and combat log, nameplates, action bars, gossip, loot, auras and cast bars, down to the cursor and the context menu. This is most of what a player actually touches, and every part of it has to agree with server state it does not own.

Headless protocol clients in Python, so the wire format can be exercised and regression-tested without launching the editor at all.

03Testing a protocol without the engine

An engine build takes minutes and a malformed packet takes seconds to reproduce, so the wire format is exercised by sixty Python harnesses that speak the protocol directly and never open the editor: authentication, character create, rename and delete, movement persistence, quests, loot, mail, guilds across two accounts, parties, trainers, vendors, the auction house, pet stabling, talents, resurrection, knockback and root, a relogin race, swim and anticheat probes, and the map, vmap and navmesh checks.

They run against the live server and mutate it on purpose - creating characters, sending mail, founding guilds - because there is no separate test realm. Each one has to leave the world in a state the next run can start from.

The runner exists because of a bad afternoon. Two harnesses were recorded as passing in the project’s own notes for weeks: one had been failing since spell mechanics were re-sourced, the other dying on an ImportError since the vmap harness was rewritten. Nothing ran the suite, so nothing could contradict the notes.

So it classifies instead of counting. Six outcomes, because “this test found a bug” and “this test could not reach a server” are not the same news: pass, fail, inconclusive - it ran and reached the server but its fixture was missing, no trainer in range, nobody dead to resurrect - plus no-server, error and timeout. Four harnesses had been exiting zero on a missing fixture and reading as passes. Thirty-three still point at a local address that stopped existing when the server moved to a host, and the runner reports that rather than counting them green.

04Teaching the server what the world looks like

The engine and the server disagree about what a world is. Unreal has meshes, partitions and collision; the server has a heightmap lattice and a separate collision structure, both from an era when a continent was a grid of tiles on disk. Neither can read the other, so the world has to be exported.

The exporter walks the loaded world and writes the tiles the server reads. Measured on a continent-scale landscape of 20.3 by 12.7 kilometres - 261 partition actors - it produced 1,176 tiles in 23 seconds without running out of memory, which settles that the straightforward approach holds at the scale this needs.

It does not hold everywhere, and the place it fails is a property of the format. A heightmap on a 4.17-yard lattice cannot express a vertical discontinuity, so at a stair or a platform edge the surface blends and the server’s idea of the ground drifts from the client’s. Measured against an oracle: 0.084 yards on flat cells, up to about 3.6 at an edge. A second pass adds real collision geometry so the floor height comes from the exact triangle rather than the grid, which closes most of it - and what remains is on the page with the number beside it.

Exporting an Unreal world into the format the server readsAn exporter walks the loaded Unreal landscape — 20.3 by 12.7 kilometres across 261 partition actors — and writes the heightmap tiles and collision geometry the server reads, producing 1,176 tiles in 23 seconds. Below, a profile shows where the two representations diverge: the client draws a hard step at a stair edge, while the server’s lattice can only ramp, leaving 0.084 yards of drift on flat cells and up to about 3.6 yards at an edge.UNREAL ENGINE 5Landscape20.3 × 12.7 km261 partition actorsmeshes · collisionWorld exporterwalks the loaded world1,176 tiles in 23 sno memory exhaustion at scaleSERVERHeightmap lattice4.17 yd spacingCollision geometrya continent as a grid of filesAND WHERE IT DOES NOT HOLDup to ~3.6 yd at an edgesolid — the step the client draws and the player walks ondashed — the ground the server believes is under themFlat cells: 0.084 ydmeasured against an oracle, not estimatedStair edges: closed by a second passfloor height taken from the exact triangle ratherthan from the lattice that cannot represent it
A heightmap on a fixed lattice cannot express a vertical discontinuity — that is a property of the format, not a bug in the exporter. Knowing the number, and where it comes from, is what makes it something to fix rather than something to discover in production.

05Decisions worth explaining

A test that cannot fail is not a test

That line is a comment in the test runner, and it was written after four harnesses were found exiting zero because the thing they meant to prove was never set up - no trainer in range, nobody dead to resurrect. They had been reading as passes. The runner now separates a harness that ran and found nothing wrong from one that ran and could not try, and it names its excluded files explicitly rather than matching a pattern, so a rename breaks the list loudly instead of dropping a test out of the suite in silence.

Nothing in the gameplay layer touches a socket

Everything network-side sits behind a subsystem that publishes delegates. It keeps Unreal’s lifecycle - which owns object destruction and level transitions - away from a socket that has its own idea of when it is finished.

A limits file where nothing is permanent

Ninety deferred items across fourteen subsystems, each tagged by which side can fix it: sixty-two client, five server, seven needing both, and sixteen merely untested - meaning the code is written and format-verified and only lacks a live end-to-end run. Because both halves are mine, no entry is a constraint to design around, only work not done yet. A tech-debt list that distinguishes "cannot" from "have not" is the difference between a backlog and an excuse.

Questions about this project?

I am happy to walk through the architecture, the parts that did not work, or the code itself.