Hosting, joining, authority, and messages.
Sessions & contracts
Multiplayer means several machines run copies of the same game and exchange session state. A session carries connection identity, metadata such as scene and seed, and network entity ownership. A network contract declares which messages exist, who may send them, and what happens on disconnect. The runtime enforces these rules at its protocol boundary.
What a NetworkContract asset is
A NetworkContract is an asset declaring the multiplayer rules of a game. The project points at it from demi.project.json:
"network_contract": "asset://network/arena_contract"
network_contract— theasset://ID of the contract asset. Required only for networked games.
Every peer computes a contract hash from the contract content. The hash is checked at session setup: peers running different rules cannot join the same session, so a mismatch is rejected before any gameplay message flows.
Policy: who may do what
The contract answers three policy questions in advance:
| Policy | Allowed values | What it decides |
|---|---|---|
| senders / writers | server, owner, all | Who may send a given message or write a given piece of state. Spawning and transfer are server-only, regardless of message policy. |
| disconnect | despawn, return_to_server, transfer_by_game_policy | What happens to a player's entities when they leave: destroy them, hand control back to the server, or let game code decide. |
| message targets | owned_entity, entity, none | Which entity a message may address: only an entity the sender owns, any entity, or no entity at all (session-level messages). |
Validation rejects messages that violate these declared rules before Lua sees them. Game-specific effects such as damage still need authoritative gameplay checks.
Session phases
A session moves through named phases. Inspect diagnostics().phase and secure_ready to observe protocol progress:
| Phase | Meaning |
|---|---|
closed | No session. The transport is idle. |
connected | A transport link exists; handshake not finished. |
authenticated | The contract protocol handshake has been accepted. This phase does not by itself provide encrypted transport or account authentication. |
ready | Handshake progression immediately before protocol activation. |
active | The contract protocol is active; game code still owns scene and simulation setup. |
reconnecting | A lifecycle phase defined by the protocol; automatic Lua session reconnect is not integrated. |
The normal path is closed → connected → authenticated → ready → active. The host reaches active when hosting starts; the client reaches it after the accepted handshake. The current Lua session resets on client disconnect; it does not automatically resume a dropped connection.
Hosting and connecting
Import the session service explicitly and either host or connect:
local Session = require("demi.network.session")
local ok = Session.host(7777)
if ok then
Session.start_session({ scene_id = "scene://game/arena", seed = 42 })
end
require("demi.network.session")— imports the session service table.Session.host(port)— opens an ENet host on the given UDP port; configured certificates select DTLS. Returns a boolean.Session.start_session(metadata)— stores and sends metadata such asscene_idandseedfrom the host. It returns nothing and does not itself load a scene or seed the simulation. See Simulation & seeds.
local ok = Session.connect("192.168.1.10", 7777)
print(Session.is_connected()) -- true once the transport link is up
Session.connect(host, port)— starts connecting a client to a host. Returns a boolean for "connection attempt started".Session.is_connected()— returnstruewhile a transport link exists,falseotherwise.Session.is_host()— returnstrueon the server peer,falseon clients.Session.sender_id()— returns this peer's string sender ID, assigned during the handshake on clients.Session.current_session()— returns the current session table ornilwhen none is active.
Receiving validated messages
Incoming traffic is checked before it reaches Lua: header, protocol version, contract hash, epoch, sequence, size, message structure, finite numbers, payload schema, sender identity, destination, ownership generation, and rate limits. Only messages that pass arrive in the event queue.
function Game:on_update(dt)
local update = Session.process_events()
for _, msg in ipairs(update.events) do
if msg.name == "player_join" then
self:handle_join(msg.sender_id, msg.data)
end
end
end
Session.process_events()— drains network events and returns an update table withconnected,disconnected,session_started,session,messages, andevents. The events array includes validated game messages and prediction events.msg.name— a declared game message name or a prediction event such asprediction_corrected. Dispatch only the event types your game handles.msg.sender_id— string ID derived from the transport peer for game messages, rather than a sender ID supplied in the payload.msg.data— the validated payload as a Lua table. Array-like payloads round-trip as numeric arrays.
Tests and game code consume the same queues. Poll state in tests when gameplay legitimately drains connection events.
Entities, IDs, and ownership
The server issues string network IDs and ownership generations. A generation counter prevents stale operations from a previous owner from applying.
local network_id = Session.spawn("player", entity_id, owner_id)
print(network_id) -- string network ID or nil (host only)
Session.spawn(prefab_key, entity_id, owner)— host only. Uses areplicated_prefabskey and an existing local entity ID, then assigns an owner. Returns a string network ID ornil.
Session.transfer(network_id, new_owner_id) -- move ownership
Session.despawn(network_id) -- remove the replicated entity
Session.transfer— reassigns ownership tonew_owner_idand advances the ownership generation; returns a boolean.Session.despawn— removes the replicated entity from the session; returns a boolean.
if Session.has_authority(network_id) then
-- this peer decides that entity's state
end
local owner = Session.owner(network_id) -- string owner ID or nil
local nid = Session.network_id_for_owner(owner_id) -- string network ID or nil
Session.bind_local_entity(network_id, local_entity_id) -- boolean; owner only
Session.has_authority(network_id)— returns whether this peer owns the entity. Contract field writer rules still govern publication.Session.owner(network_id)— returns the string owner ID, ornilfor an unknown entity.Session.network_id_for_owner(owner_id)— returns the first network ID that owner controls, ornil.Session.bind_local_entity(network_id, local_entity_id)— on the owning peer, links a network ID to an existing local scene entity for publication; returns a boolean.
Late joiners receive retained spawn state: every still-live replicated entity is re-spawned for them from the session's spawn record, so a new client sees the arena as it is, not as it was at match start.
Sending messages
local ok = Session.send("player_join", nil, { color = { 0.2, 0.4, 0.8, 1.0 } })
Session.send(name, target, data)— queues a message declared in the contract.name— the message name from the contract.target— a string network entity ID when the contract's target rule requires one, ornilfor anonetarget. The rule is declared in the contract, not passed as an option.data— the payload. It must satisfy the contract's schema and finite-number and size limits.- Returns a boolean for local validation and queueing. Remote delivery is subject to the receiver's checks.
Watching session health
local d = Session.diagnostics()
print(d.phase, d.session_epoch, d.contract_hash)
print(d.sent_messages, d.received_messages, d.rejected_messages)
print(d.secure_accepted_messages, d.secure_rejected_messages)
Session.diagnostics()— returns a table withphase,session_epoch,contract_hash, transport and security counters,secure_ready, andlast_error.
Counters grow whenever messages are accepted or rejected, so a rising rejection count is a policy or version mismatch worth investigating before it becomes a player complaint.
Keeping time in sync
Replicated entities advance through the session service at the simulation rate:
function Game:on_fixed_update(fixed_dt)
Session.update_entity(network_id, fixed_dt)
end
Session.update_entity(network_id, dt)— on the owning peer, captures contract-declared replicated fields from the bound local entity and publishes them when its send interval elapses. Returns a boolean. Remote presentation is updated byprocess_events().
Smoothing and rewinding remote entities are the job of Prediction. TLS and custom transports are covered in HTTP & TLS.