zone-server owns a raw TCP socket (Netty, not WebSocket). Every message, in both directions, is one Envelope protobuf message — a big oneof covering every leaf message type — wrapped in a 4-byte big-endian length prefix. The client-side half of this same protocol is documented in Networking; this page covers the server side.

The wire format

bnet-messages/src/main/proto/ is the single source of truth for every message. envelope.proto imports every leaf .proto file and multiplexes them into one oneof, with field numbers grouped into manually maintained ranges per domain:

  message Envelope {
  oneof message {
    // The most frequent messages, in the field numbers that take one byte.
    StateBatchSMSG state_batch = 3;
    DamageEntitySMSG damage_entity = 4;

    // SYSTEM & ACCOUNT 100
    Authentication authentication = 100;
    AuthenticationSuccess authentication_success = 102;
    Ping ping = 120;
    ChatCMSG chat_cmsg = 122;

    // MAP 200
    WorldInfoSMSG world_info = 200;
    ChunkDataSMSG chunk_data = 203;

    // INVENTORY 300 · MASTER & BESTIA 400 · ENTITY & COMPONENTS 500 · SOCIAL & PARTY 600
    ...
  }
}
  

Naming convention: client → server messages end in CMSG (AttackEntityCMSG), server → client messages end in SMSG (DamageEntitySMSG); a handful of bidirectional/shared messages have no suffix (Master, Ping/Pong). Field numbers within a range are hand-assigned sequentially — adding a message means taking the highest number in its block and adding one, never reusing or leaving a gap.

Entity state: one batch per client per tick

Entity state (every synced component, and the vanish of an entity) does not travel as one envelope per component. Each sync sends every client one StateBatchSMSG:

  message StateBatchSMSG {
  uint64 server_tick = 1;              // 0 when sent outside the tick's sync
  repeated EntityUpdate updates = 2;
}
message EntityUpdate {
  fixed64 entity_id = 1;               // once, for all of the entity's components
  repeated ComponentDelta components = 2;
  VanishEntitySMSG vanish = 3;         // set when the entity left the view or the world
}
message ComponentDelta {
  oneof component {
    PositionComponent position = 1;    // the most frequent components take tags 1-15
    PathComponentSMSG path = 2;
    ...
  }
}
  

The component messages themselves have no entity_id any more; EntityUpdate names the entity once. server_tick lets the client order state in time. A component message sent on its own, in answer to a request (a skill list, say), is a batch of one with tick 0.

On the server, a changed component’s update is built once per tick as an EntityUpdate and the same instance goes into the batch of every client that sees the entity. StateBatchSMSG frames a batch from each update’s cached bytes, so a shared update is serialised once, however many clients get it: an embedded message and a bytes field look the same on the wire.

A path is a DeltaPath: the first waypoint, then each following one as three small numbers, its difference to the last (DeltaPaths on the server, DeltaPathConvert on the client). A 24-step leg is about 85 bytes instead of about 330. Vec3 uses sint64, so a negative coordinate stays small too.

These changes made the wire incompatible with older clients, so the protocol version is 2.

Codegen is two separate pipelines that both have to run after editing a .proto file: Kotlin classes regenerate automatically on the next Gradle build; the C# classes consumed by the Godot client are generated by a manual step, bnet-messages/gen-protobuf.bat, and the output is committed to the repo alongside the .proto change.

The Netty pipeline

Built in SocketServer.kt, one ClientMessageHandler instance per connection:

  ch.pipeline().addLast(
  SlowConsumerGuard(unwritableTimeoutSeconds, maxWriteBacklogBytes), // drops a client that stops reading
  IdleStateHandler(readIdleTimeoutSeconds, 0, 0, SECONDS),           // reports a silent client
  LengthFieldBasedFrameDecoder(MAX_FRAME_LENGTH, 0, 4, 0, 4), // 4-byte length prefix, 1 MB max frame
  ProtobufDecoder(EnvelopeProto.Envelope.getDefaultInstance()),
  BigEndianLengthFieldPrepender(),                            // outbound: length prefix + Envelope
  ClientMessageHandler(handlerContext)
)
  

BigEndianLengthFieldPrepender sizes its buffer to the frame and serialises the envelope straight into it, so a message is serialised once and never copied. EnvelopeFraming is the one definition of the frame; the chunk fan-out uses it to frame a message once for many clients and passes the bytes through the encoder untouched.

The socket binds to socket.ip-address/socket.port (127.0.0.1:8090 in dev, zone-server/src/main/resources/application.yml), started by SocketServerBootRunner as the very last boot step (see Architecture).

Liveness and backpressure

The server must notice two kinds of broken client: one that is gone, and one that is still there but no longer reads. Both are closed through the normal disconnect path, so their master leaves the world.

ProblemDetectionSetting (socket.*)
Client is gone (half-open TCP)Nothing arrives for 30 s. ClientMessageHandler sends Disconnected("IDLE_TIMEOUT") and closes.read-idle-timeout-seconds
Client stopped readingThe channel stays unwritable for 10 s, or more than 4 MiB wait to be sent. SlowConsumerGuard closes it.unwritable-timeout-seconds, max-write-backlog-bytes

The client keeps an idle connection alive with a Ping every 10 s, so a silent player is not dropped. A channel counts as unwritable above 256 KiB of unsent bytes and recovers below 64 KiB (write-buffer-high-bytes, write-buffer-low-bytes).

Netty never refuses a write, so the guard is the only bound on what one connection may queue. Writes to a busy channel still go out. The one exception is the chunk stream: the manifest and the chunk push skip a busy channel and retry on a later tick, because a chunk payload is large and the stream can send it again. Everything else, chunk patches included, is sent once and never repeated, so skipping it would leave the client wrong for good.

A disconnect notice waits at most 2 s for the bytes to leave. A peer that never takes them is closed anyway.

Inbound: socket → Envelope → CMSG → handler

graph LR
  A[Netty frame] --> B["Envelope (protobuf)"]
  B --> C[ClientMessageHandler]
  C -->|not yet authed| D[AuthenticationProcessor]
  C -->|authed| E[MessageEnvelopeReceivedEvent]
  E --> F[BnetMessageProcessorAdapter]
  F -->|"handler's WireDecoder"| G["internal CMSG"]
  G --> H[InMessageProcessor]
  H --> J["AccountInbox<br/>tick or IO lane"]
  J --> I["IncomingMessageHandler&lt;T&gt; bean"]
  1. ClientMessageHandler.channelRead0 — if the channel isn’t authenticated yet, the first message must be an Authentication envelope, routed to authenticateChannel. Its protocol version must match the zone’s, or the connection is closed with PROTOCOL_MISMATCH (see Authentication). Once authenticated, every further message is wrapped in a MessageEnvelopeReceivedEvent and published as a plain Spring ApplicationEvent.

  2. BnetMessageProcessorAdapter.handleMessageEnvelopeReceived (an @EventListener) looks up the handler for the envelope’s case and reads the raw protobuf into an internal CMSG with that handler’s decoder. Each handler declares the case it answers:

      override val wire = decoder(MessageCase.ATTACK_ENTITY) { accountId, envelope ->
      AttackEntityCMSG.fromBnet(accountId, envelope.attackEntity)
    }
      

    An envelope no handler takes closes the connection with UNKNOWN_MESSAGE. The log names only its case and unknown field numbers, never its content, because a message can carry a login token. The adapter refuses to start when a case a client sends has no handler, so a message added to the envelope cannot silently go unanswered; a case counts as client-sent unless its message type ends in SMSG, it is the handshake’s Authentication, or it is on a short list the server sends under another name. A decoder that returns null (a well-formed but semantically invalid payload, e.g. an equip slot ordinal this server version doesn’t know) drops the message with a warning rather than tearing down the connection.

  3. InMessageProcessor.submit() finds the one handler for the message’s Kotlin class among every Spring-injected IncomingMessageHandler<*> bean — dispatch is by class, not a string or int tag — and puts the message into the sender’s AccountInbox, which runs an account’s messages one at a time and in order. A TickMessageHandler runs on the tick thread and gets the World; an IoMessageHandler runs on an IO thread, for handlers that need the database. Everything up to here ran on the Netty event loop and only decoded. See Threads and the Tick.

      @Component
    class AttackEntityHandler(...) : TickMessageHandler<AttackEntityCMSG> {
      override val handles = AttackEntityCMSG::class
      override fun handle(world: World, msg: AttackEntityCMSG): Boolean { ... }
    }
      

    Handlers resolve the entity to act on via ConnectionInfoService.getActiveEntityId(accountId) — never a client-supplied entity id — so a handler always acts on behalf of whatever entity the account currently has selected (master or an owned Bestia), with no separate ownership check needed.

Outbound: SMSG → OutMessageProcessor → channel

An SMSG implementation provides toBnetEnvelope(): EnvelopeProto.Envelope. OutMessageProcessor offers two shapes:

  fun sendToPlayer(playerId: Long, msg: SMSG)                            // one specific account
fun sendToObserversOf(world: WorldView, entityId: EntityId, msg: SMSG) // everyone who sees entityId
  

sendToObserversOf sends a one-off event (a hit, a chat line, a heal) to the same accounts that get the entity’s component state: the accounts holding the chunk it stands in, plus the player’s own account (EntityAudience, see ECS). So a client never sees a health drop without the hit. It reads the audience under the world lock, so a handler thread may call it. ChannelRegistry.broadcast serialises the message once and writes the same bytes to every recipient.

What happens next depends on the thread. On the tick, a send joins the account’s batch in TickOutbox; when the tick ends, each account’s batch is written in the order it was sent and flushed once (ChannelRegistry.sendMessages). A client therefore gets one flush per tick, not one per changed entity, and since channel.write only queues onto the channel’s event loop, the tick never waits on the network. From any other thread, the send goes out at once. Chunk data is the exception: NettyChunkFanOut encodes a chunk once and writes the shared bytes to every holder directly.

ChannelRegistry (account id → Channel) and ConnectionInfoService (account id → session: selected master, owned entities, currently active entity) are the two session maps; there is no single unified Session object combining them. A disconnect removes the account from both. Its bestias stay in the world: each carries an OwnedBestia component, and selecting the master again rebuilds the session’s owned entities from the world.

Movement requests

MoveActiveEntity carries a path of adjacent tiles. MoveActiveEntityHandler checks it step by step from the entity’s position and cuts it at the first step that is not adjacent or not walkable. An empty path is a stop.

A path sent with append = true continues the walk under way instead of replacing it: it is checked from the walk’s last waypoint and added to its end. The client sends the next leg this way about one round trip before the current one ends, so a long walk does not pause at every leg. An appended path that does not join the walk is ignored; without a walk under way it starts a new one like any other path. The server then sends the whole remaining path again, as it does for any path change.

Dirty-component sync, not full-state broadcast

zone-server doesn’t broadcast full entity state every tick. Every syncable ECS component implements Dirtyable (tracks its own dirty flag — mutating it through its own setters marks it dirty) and reports who should receive it via SyncTargets (PublicInRange, OwnerOnly, or an explicit Accounts set). After each tick, ZoneEngine takes the components that became dirty from the dirty log, builds each one’s toEntityMessage(), and routes it to its audience — so e.g. Position goes to everyone who sees the entity while Inventory or skill points go only to the owning account. Everything one client is told in one sync goes out as one StateBatchSMSG (see above). See ECS for the mechanism in full.

Adding a new message type end-to-end

Worked through once already for ActivateSkillCMSG/ActivateSkillHandler (zone-server/.../battle/attack/) — use those as a template.

  1. Proto: new file under bnet-messages/src/main/proto/messages/<domain>/ (*_cmsg.proto / *_smsg.proto), then wire it into envelope.proto: an import line plus a field in the oneof inside the correct numbered range.
  2. Kotlin CMSG (incoming): data class XyzCMSG(...) : CMSG with a companion fun fromBnet(accountId: Long, proto: ...): XyzCMSG.
  3. Handler: @Component class XyzHandler(...) : TickMessageHandler<XyzCMSG> with override val wire = decoder(MessageCase.XYZ) { ... } — auto-discovered, no manual registry entry. It must be an IoMessageHandler<XyzCMSG> instead as soon as anything it calls reaches the database. Boot fails if you forget the handler.
  4. Kotlin SMSG (outgoing), if a reply/broadcast is needed: implement toBnetEnvelope(). Use the one-off broadcast shape (DamageEntitySMSG, sent via sendToObserversOf) for events, or the Dirtyable-backed entity-state shape (SkillPointsSMSG) for actual persistent component state that should auto-sync on change — don’t use the state shape for one-off events. A state message is an EntitySMSG: it implements writeTo(update) and gets a ComponentDelta field instead of an Envelope field.
  5. C# client wrappers + regenerate: see the client-side steps and the gen-protobuf.bat note in client Networking.

Last updated 05 Oct 2026, 23:42 +0200 . history