From Bytes to a Damage Number
The lifecycle of a single packet, end to end.
gameserver/network/ - the pieces
The acceptor
────────────
commons/network/ConnectionManager selector loop. Accepts new
sockets, hands each a fresh
GameClient.
Per-connection context
─────────────────────
GameClient account name, Player ref,
per-session Blowfish key,
ConnectionState (handshake /
authenticated / in-world).
Crypt + dispatch
────────────────
Encryption (Blowfish + XOR) per-session keys, generated
at handshake, never reused.
GamePacketHandler opcode ─► packet class.
Packet classes
──────────────
clientpackets/ inbound (RequestX, EnterWorld, ...)
serverpackets/ outbound (CharInfo, SystemMessage, ...)
loginserverpackets/ traffic over LoginServerThread
Opcode + string tables
──────────────────────
ClientPackets.java inbound opcode ─► factory.
ExClientPackets.java extended (two-byte) opcodes.
ServerPackets.java outbound opcodes.
SystemMessageId.java client-side string IDs.
NpcStringId.java NPC dialogue string IDs.
LIFECYCLE OF A PACKET
client ── encrypted bytes ──► Encryption.decrypt
│
▼
GamePacketHandler.handlePacket
│
looks up opcode in ClientPackets
│
▼
new RequestSomething().read(buffer)
│
▼
RequestSomething.run() (ThreadPool task)
│
mutates Player / World / DB
│
▼
player.sendPacket(new ResultPacket(...))
│
▼
Encryption.encrypt ──► TCP back to client
The pieces, one at a time
ConnectionManager
Lives in commons/network. A non-blocking selector loop that accepts inbound sockets, hands each one a fresh GameClient, and reads packets off the wire. Both the LoginServer and the GameServer plug their own packet handler into the same reactor.
GameClient
One per connected socket. Holds the player's account name, the active Player reference once EnterWorld succeeds, the per-session Blowfish key, and the current ConnectionState (handshake / authenticated / in-world).
Encryption
Per-session Blowfish (with an opening XOR pass on the very first packet). Keys are generated at handshake and never reused across sessions. The crypt layer sits between raw bytes and the packet parser, everything above it sees plain payloads, everything below sees ciphertext.
GamePacketHandler & opcode tables
Every inbound packet starts with an opcode byte (and sometimes a second opcode for "extended" packets). ClientPackets.java and ExClientPackets.java are giant enums that map each opcode to a factory for the corresponding clientpackets/RequestX class. ServerPackets.java does the same in reverse for outbound packets.
clientpackets/ and serverpackets/
One class per logical packet. Each RequestX class has:
- A
read(buffer)step that decodes the binary payload into Java fields. - A
run()step that performs the actual game action, this is where damage is computed, the database is touched, broadcast packets are sent.
Each outbound packet class is the inverse: it carries a snapshot of state and serializes itself into the wire format the client expects.
loginserverpackets/
A small set of packets that travel over the bridge socket between GameServer and LoginServer, not over a client connection. Things like ServerStatus, PlayerAuthRequest, PlayerAuthResponse.
The multithreaded design
Four moving parts make this network layer scale: the two-pool split between I/O and packet execution, the global fair-send queue, the pooled buffer allocator, and opt-in backpressure. Each gets its own whiteboard, paired with the prose that explains it.
Java AIO, not classic NIO selector loop
The reactor is built on java.nio.channels.AsynchronousServerSocketChannel - the Java AIO API, not the older single-thread NIO selector pattern. The win is that completion handlers can be dispatched from any thread in the channel group's pool, so the server scales with the available cores without the operator wiring up a custom dispatcher.
The two pools - why the separation is load-bearing
The single most important property of the Mobius network layer is that the I/O completion pool never runs game logic. Concretely:
- Pool 1 ("Server") reads the 2-byte header, allocates a payload buffer from the resource pool, reads the payload, hands the bytes to
Client.decrypt(), and constructs theReadablePacketobject. All sub-millisecond. - Pool 2 ("PacketExecutor") runs
ReadablePacket.run(). This is where the game lives; DB reads, world-grid lookups, broadcasts to nearby players, mutation of player state. Allowed to take milliseconds; cannot block I/O.
Both pools default to processors x 4 threads. Both are configurable through config/Network.ini. If packet logic ran on Pool 1, one slow DB read or 2000-player broadcast would stall every other socket whose bytes arrive while you are working, that is the failure mode this split exists to prevent.
Two pools, handing off
socket bytes
│
▼
POOL 1 "Server-N" threads (I/O completion)
─────────────────────────────
• read 2-byte header from the channel
• read N payload bytes
• Client.decrypt() in place
• build ReadablePacket
│
▼ PacketExecutor.execute(packet)
POOL 2 "PacketExecutor-N" threads
──────────────────────────────────
• ReadablePacket.run()
• DB hits, world-grid lookups, broadcasts
• mutate Player / Clan / World
• queue outbound packets
Pool 1 is bounded; Pool 2 is core-bounded with an unbounded
queue. Idle threads time out after 1 minute.
The accept loop re-arms before processing
AcceptConnectionHandler.completed() calls _socketChannel.accept() before it does anything with the newly accepted channel. The next incoming connection is queued by the OS while the new client is being initialised. Even on failure the loop is re-armed, a transient error does not stop the server from accepting future connections.
The fair-send queue is global, not per-client
There is exactly one static ConcurrentLinkedQueue<Client> PENDING_CLIENTS shared across every connected client. When any client wants to send, it offers itself to the queue and polls a (possibly different) client back out to actually transmit. The effect is round-robin without anyone having to call it "round-robin": no client can hog the write pipeline. A per-client AtomicBoolean _writing guarantees at most one thread is actively writing for any given client at any moment.
Fair-send queue - one global PENDING_CLIENTS for all clients
client A wants to send ──┐
│
client B wants to send ──┤──► offer self to PENDING_CLIENTS
│
client C wants to send ──┘
│
▼
poll ONE client
(may not be the offerer)
│
▼
that client writes its next
queued packet, releases its
_writing flag
Buffer pooling - zero garbage in the steady state
Every read and every write borrows a ByteBuffer from ResourcePool. Header reads borrow from the 2-byte header pool. Payload reads borrow from a size-matched pool. Outbound writes assemble a gather array of pooled buffers. After the packet is fully consumed (read) or fully sent (write), the buffer is returned. Operators can declare extra buffer pools for known frequent packet sizes via BufferPool.X.Size / BufferPool.X.BufferSize to avoid the generic segment-pool fallback.
The buffer pool - zero garbage in the steady state
ResourcePool (one per server)
│
├── BufferPool for HEADER_SIZE (2 bytes)
│
├── BufferPool for segment size (default 64KB)
│
├── BufferPool for X bytes ◄ declared in Network.ini
├── BufferPool for Y bytes via BufferPool.<tag>.Size
└── ... and BufferPool.<tag>.BufferSize
read ──► borrow buffer from size-matched pool
│
▼
receive bytes from socket, parse, execute
│
▼
recycle buffer back to its pool
Packet dropping is opt-in per packet class
The protocol cannot tolerate a dropped login or a dropped SystemMessage - those are flow-critical. But a UserInfo update broadcast to a player whose outbound queue is already a thousand packets behind is purely cosmetic; dropping it lets the server catch up. WritablePacket.canBeDropped() defaults to false; specific subclasses (animations, secondary broadcasts) override it to true. When the per-client outbound queue exceeds DropPacketThreshold, droppable packets are silently discarded; the rest still go through.
Backpressure - opt-in packet dropping
per-client outbound queue depth > DropPacketThreshold (250 default)
│
▼
for each new packet about to be queued:
│
┌──────────────────────────────────────────────┐
│ │
▼ ▼
packet.canBeDropped() canBeDropped()
returns TRUE returns FALSE
│ │
▼ ▼
silently discard always sent
(animations, cosmetic (login, char data,
UserInfo broadcasts) SystemMessage, ...)
Concurrency invariants you can rely on
Connection._clientisvolatile- NIO completion handlers read it from arbitrary threads.Connection._closedis anAtomicBoolean- guardsclose()against double-close races when a read failure and a write failure fire concurrently.Client._writingis anAtomicBoolean- at most one thread is actively writing for a given client at a time.Client._packetsToWriteis aConcurrentLinkedQueue- the outbound queue is lock-free.PENDING_CLIENTSis aConcurrentLinkedQueue- the fair-send mechanism is lock-free at the queue level.
The tuning knobs - config/Network.ini
ThreadPoolSize- completion pool size. Falls back toprocessors x 4if set to less than 1.ThreadPriority- JVM thread priority for both pools. DefaultNORM_PRIORITY.ShutdownWaitTime- seconds to wait for in-flight I/O on shutdown. Default 5.UseNagle- if false, setsTCP_NODELAYon every accepted socket. Default false (Nagle off, low latency).DropPackets+DropPacketThreshold- enable backpressure and set the per-client queue depth at which droppable packets start being shed. Defaults: off, 250.BufferSegmentSize- default buffer size for the generic pool.BufferPool.X.Size+BufferPool.X.BufferSize- declare extra buffer pools for hot packet sizes.BufferPool.AutoExpandCapacity- whether pools grow under load. Default true.BufferPool.InitFactor- pre-allocation factor applied at startup.