Gamemode code acts through a flat, id-based server facade
Status: Accepted (2026-06-24)
Context
The server core has two parts that gamemode code must not touch directly. Session is the wire plane: it decodes incoming packets, makes the decisions they call for and returns the packets to send. Roster holds the world state (connections, players, pickups, vehicles) and is private to the crate. Operator console commands and Lua resources still need to act on that state in domain terms: list players, set a wanted level, create a pickup, kick someone.
Session already carried a few such actions, so it was tempting to declare it the facade. Doing so would bind Lua to the wire plane, and the line between "what the network asked for" and "what the gamemode decided" would disappear. This builds on the reliable event lane of ADR 0007, which carries most of the facade's packets.
Decision
- The facade is its own type,
ServerApi(src/sdk.rs). It borrows the roster and the server-owned policies (spawn, clock, bans) for one batch of actions;Session::api()hands it out. Actions initiated by the gamemode live only on the facade.Sessionkeeps its inbound decisions and does not route them through the facade, because they are not gamemode actions. The roster stays crate-private, soServerApiis the only public way to change the world. - The shape is flat and id-based. Functions take typed ids (
PlayerId,PickupId,VehicleId, ...):players(),player(id),set_wanted(id, level),kick(id, reason). A typed id is the handle; no object holds a borrow of the roster. This binds trivially to Lua and keeps batch operations and listings free of borrow conflicts. - Lua handles carry only an id. The resource runtime wraps ids in opaque handles (
Player,Vehicle,Pickup, ...). Every read or action through a handle goes through the facade at call time, so a held handle never returns stale data, and an element that is gone yieldsnil, err. Player ids are reused, so a player handle also remembers its connection and can no longer act on any player once that player has left. Each player verb exists both as a method (player:kick(reason)) and as a function taking the player first (dlmp.kick_player(player, reason)); the function forwards to the method. Lua reaches the facade only while the host runs a dispatch (an event, a command, a timer, a resource start or stop). - The facade owns no sockets. An action returns
Sendvalues (recipients plus packet); the caller delivers them after the borrow ends. Events an action raises (a kick'splayer_disconnect) go into the server's event stream (ADR 0048). - Errors are typed.
ApiErrornames what was refused (PlayerNotFound,InvalidReason,ReliableQueueFull, ...) and implementsDisplay. Variants are added with the operations that need them. The console prints the message; Lua receives it asnil, err. Value validation lives in the facade, not in the parsers of its callers, so every caller gets the same checks. - Kick is a server-side primitive with a best-effort notice.
kickremoves the player by id the way a quit or a timeout does (the slot and its state are released andplayer_disconnectis raised), sends the kicked client one unreliableKickNotifywith the reason, and sendsPlayerLeaveto everyone else. A reason over 64 bytes is refused withInvalidReasonrather than silently truncated.
Consequences
- Operator commands and Lua resources share one set of actions and one set of checks.
- A new gameplay capability is a facade method plus its Lua binding; neither the wire plane nor the roster is exposed.
- A lost
KickNotifycosts nothing but the message: the kicked client ends the session on server silence.
Alternatives considered
Sessionas the facade. Rejected: Lua would bind to the wire plane.- A facade that holds
&mut Sessionand reaches the roster through it. Rejected: an extra layer of indirection with no benefit. - Handle objects that borrow the roster. Rejected: a borrowing handle conflicts with batch operations and listings, with no gain on the current surface.
- Reliable delivery of the kick notice. Rejected: resending requires keeping the connection alive in a lingering "being kicked" state; the client's server-silence timeout already covers a lost datagram.
- Truncating an over-long kick reason. Rejected: an explicit error is better for a control surface than a silently changed message.
