Architecture

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 the ReadablePacket object. 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._client is volatile - NIO completion handlers read it from arbitrary threads.
  • Connection._closed is an AtomicBoolean - guards close() against double-close races when a read failure and a write failure fire concurrently.
  • Client._writing is an AtomicBoolean - at most one thread is actively writing for a given client at a time.
  • Client._packetsToWrite is a ConcurrentLinkedQueue - the outbound queue is lock-free.
  • PENDING_CLIENTS is a ConcurrentLinkedQueue - the fair-send mechanism is lock-free at the queue level.

The tuning knobs - config/Network.ini

  • ThreadPoolSize - completion pool size. Falls back to processors x 4 if set to less than 1.
  • ThreadPriority - JVM thread priority for both pools. Default NORM_PRIORITY.
  • ShutdownWaitTime - seconds to wait for in-flight I/O on shutdown. Default 5.
  • UseNagle - if false, sets TCP_NODELAY on 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.