-
Notifications
You must be signed in to change notification settings - Fork 1
HANDLERS AND COMMANDS
This page follows a packet from the moment it arrives on a WebSocket to the moment domain logic runs, and explains the two dispatch styles you'll meet along the way. Read REALM-LAYOUT first if you haven't; this page assumes you know what a realm looks like.
- The WebSocket transport reads bytes and
networking/codecdecodes them into frames, each one acodec.Packet: auint16header plus an opaque payload. - The connection's
Session(innetworking/connection) unwraps security first, so by the time anything downstream sees the packet, it's plaintext. Handlers never know or care whether the connection was encrypted. - The session looks the header up in its
HandlerRegistry, a plainmap[uint16]Handlerthat every realm populated at startup, and invokes the registered handler. - The handler decodes the payload into a typed struct, resolves who sent it, and either calls a domain service directly or dispatches a typed command. Domain logic runs, results are projected back out as outbound packets (PROJECTIONS).
Every inbound packet is its own package under networking/inbound/, exposing exactly three things: a Header constant, a Payload struct, and a Decode function. Decoders validate the header before touching the payload, and they only decode; there are no packet constructors on the inbound side. This is the real craft packet:
package craft
import "github.com/niflaot/pixels/networking/codec"
const Header uint16 = 3591
type Payload struct {
AltarItemID int64
RecipeName string
}
func Decode(packet codec.Packet) (Payload, error) {
if packet.Header != Header {
return Payload{}, codec.ErrUnexpectedHeader
}
values, err := codec.DecodePacketExact(packet, codec.Definition{codec.Int32Field, codec.StringField})
if err != nil {
return Payload{}, err
}
return Payload{AltarItemID: int64(values[0].Int32), RecipeName: values[1].String}, nil
}codec.Definition describes the field layout (Int32Field, StringField, BooleanField, and so on), and DecodePacketExact fails if any bytes are missing or left over. Malformed wire data is a protocol error; a valid packet asking for something impossible is a domain concern and must never disconnect the client.
Request/response realms with no ongoing simulation, such as crafting or moderation's call-for-help intake, register plain handler functions. A feature's handlers package holds a Handler struct with its dependencies and a Register function that binds each header to a method:
// Register installs user-facing call-for-help adapters.
func Register(registry *netconn.HandlerRegistry, runtime *moderationruntime.Context) {
handler := Handler{Context: runtime}
_ = registry.Register(inreport.Header, handler.callForHelp(false))
_ = registry.Register(inreportim.Header, handler.callForHelp(true))
_ = registry.Register(incfhpending.Header, handler.pending)
_ = registry.Register(incfhdelete.Header, handler.deletePending)
}Each method decodes, resolves the acting player, calls the service, and sends a response. The realm's module.go invokes these Register functions against the shared registry at startup:
// RegisterConnectionHandlers registers every crafting realm packet adapter.
func RegisterConnectionHandlers(handlers *realmconn.Handlers, recipes *recipehandlers.Handler, ...) {
recipehandlers.Register(handlers.Inbound, recipes)
recyclerhandlers.Register(handlers.Inbound, recycler)
exchangehandlers.Register(handlers.Inbound, exchange)
}Handlers stay thin on purpose. Decoding, actor resolution, and translating domain errors into localized responses is all a handler does; everything with consequences lives in the service it calls.
Realms whose behavior interacts with live, mutable room state (room, pet, furniture interactions) don't call services directly from the network goroutine. They wrap the request in a typed command and hand it to a dispatcher. The contract lives in internal/command:
// Command describes a typed runtime command.
type Command interface {
CommandName() Name
}
// Envelope wraps a command with runtime metadata.
type Envelope[T Command] struct {
Command T
Metadata Metadata // PlayerID, ConnectionID, CreatedAt
}
// Handler handles one typed command.
type Handler[T Command] interface {
Handle(context.Context, Envelope[T]) error
}A command is a plain struct naming an intent (room.enter, navigator.search, a furniture pickup) plus the data needed to execute it. command.NewDispatcher wraps a Handler[T] with validation, optional middleware, and structured logging of every dispatch: command name, player ID, connection ID, timestamp. The packet handler's whole job becomes: decode, build the command, dispatch.
Why the extra layer? Three reasons. Commands give every state-changing action a uniform audit trail in the logs. Middleware (permission checks, throttles) composes without touching handler bodies. And commands are how work crosses from "the goroutine this connection runs on" into "the context of a live room" safely.
The shared Brigodier tree also owns first-party room-chat commands registered by internal/realm/admin. They use the configured PIXELS_COMMAND_PREFIX, are consumed before normal speech, and require concrete permission nodes. These commands do not use the public plugin SDK and cannot be disabled with a plugin scope. See COMMANDS for the operational command list and the packet-trace lifecycle.
The room world is a simulation: rollers roll, pets wander, Wired effects fire after delays, game timers count down. That simulation advances on a tick, driven by the contracts in internal/tick:
// Tick describes one runtime tick.
type Tick struct {
At time.Time
Delta time.Duration
Sequence uint64
}
// Target handles ticks.
type Target interface {
Tick(context.Context, Tick) error
}The dividing line is simple. Anything that must observe or mutate the continuous simulation happens inside the tick, scheduled through the room's own runtime. Anything that's a self-contained request, like opening a catalog page, reading your wallet, or crafting an item, executes immediately on the connection's goroutine. This is also a hard rule for feature design: realms never spawn their own goroutines or time.AfterFunc timers per entity. A freeze ball that explodes in two seconds is a deadline registered on the room's scheduler, not a timer floating around the runtime. That keeps everything that touches a room serialized through one place, which is why the room engine needs no locks around its world state.
Handlers distinguish three failure classes, and the distinction matters:
- Protocol errors (bad header, malformed payload): returned as errors, which terminates the connection. Only broken clients produce these.
-
Expected domain failures (recipe sold out, no permission, room full): translated into a localized response through
pkg/i18nand sent as a normal packet. The session always survives these. - Infrastructure failures (database down): logged with context and surfaced as a generic failure to the player, without leaking internals.
A useful invariant to remember when writing a handler: nothing a well-behaved client can do should ever disconnect it.
Pixels
Getting Started
Architecture
Architecture Internals
Authentication
Users
Navigator
Inventory
Furniture
Rooms
Decoration
Games
Plugins
- PLUGINS-OVERVIEW
- PLUGINS-CREATING
- PLUGINS-LISTENERS
- PLUGINS-EVENTS-REALMS
- PLUGINS-EVENTS-ECONOMY-ROOMS
- PLUGINS-EVENTS-MODERATION-TRADES
- PLUGINS-EVENTS-COMMERCE-WORLD
- PLUGINS-EVENT-FURNITURE-MOVE
- PLUGINS-EVENT-FURNITURE-PICKUP
- PLUGINS-EVENT-ROOM-CREATE
- PLUGINS-EVENT-MARKETPLACE-LIST
- PLUGINS-EVENT-MARKETPLACE-BUY
- PLUGINS-EVENT-PLAYER-PROFILE-UPDATE
- PLUGINS-EVENT-BOT-SPEECH
- PLUGINS-EVENT-GROUP-MEMBERSHIP-CHANGE
- PLUGINS-EVENT-MESSENGER-FRIEND-REQUEST
- PLUGINS-EVENT-MESSENGER-FRIEND-ACCEPT
- PLUGINS-EVENT-CRAFTING-CRAFT
- PLUGINS-WIRED
- WIRED
- PLUGINS-COMMANDS
- PLUGINS-SDK
- PLUGINS-DEPLOYMENT