Networking
zone-server’s Netty pipeline, the Envelope protobuf wire format, message dispatch from socket to handler and back, and the dirty-component sync model.
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.
| Problem | Detection | Setting (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 reading | The 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<T> bean"]
ClientMessageHandler.channelRead0— if the channel isn’t authenticated yet, the first message must be anAuthenticationenvelope, routed toauthenticateChannel. Its protocol version must match the zone’s, or the connection is closed withPROTOCOL_MISMATCH(see Authentication). Once authenticated, every further message is wrapped in aMessageEnvelopeReceivedEventand published as a plain SpringApplicationEvent.BnetMessageProcessorAdapter.handleMessageEnvelopeReceived(an@EventListener) looks up the handler for the envelope’s case and reads the raw protobuf into an internalCMSGwith 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 inSMSG, it is the handshake’sAuthentication, or it is on a short list the server sends under another name. A decoder that returnsnull(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.InMessageProcessor.submit()finds the one handler for the message’s Kotlin class among every Spring-injectedIncomingMessageHandler<*>bean — dispatch is by class, not a string or int tag — and puts the message into the sender’sAccountInbox, which runs an account’s messages one at a time and in order. ATickMessageHandlerruns on the tick thread and gets theWorld; anIoMessageHandlerruns 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.
- Proto: new file under
bnet-messages/src/main/proto/messages/<domain>/(*_cmsg.proto/*_smsg.proto), then wire it intoenvelope.proto: animportline plus a field in theoneofinside the correct numbered range. - Kotlin CMSG (incoming):
data class XyzCMSG(...) : CMSGwith a companionfun fromBnet(accountId: Long, proto: ...): XyzCMSG. - Handler:
@Component class XyzHandler(...) : TickMessageHandler<XyzCMSG>withoverride val wire = decoder(MessageCase.XYZ) { ... }— auto-discovered, no manual registry entry. It must be anIoMessageHandler<XyzCMSG>instead as soon as anything it calls reaches the database. Boot fails if you forget the handler. - Kotlin SMSG (outgoing), if a reply/broadcast is needed: implement
toBnetEnvelope(). Use the one-off broadcast shape (DamageEntitySMSG, sent viasendToObserversOf) for events, or theDirtyable-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 anEntitySMSG: it implementswriteTo(update)and gets aComponentDeltafield instead of anEnvelopefield. - C# client wrappers + regenerate: see the client-side steps and the
gen-protobuf.batnote in client Networking.
Last updated 05 Oct 2026, 23:42 +0200 .