DEMIENGINEDOCUMENTATION Everything is free
Networking & HTTP · Game systems

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 — the asset:// 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:

PolicyAllowed valuesWhat it decides
senders / writersserver, owner, allWho may send a given message or write a given piece of state. Spawning and transfer are server-only, regardless of message policy.
disconnectdespawn, return_to_server, transfer_by_game_policyWhat happens to a player's entities when they leave: destroy them, hand control back to the server, or let game code decide.
message targetsowned_entity, entity, noneWhich 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:

PhaseMeaning
closedNo session. The transport is idle.
connectedA transport link exists; handshake not finished.
authenticatedThe contract protocol handshake has been accepted. This phase does not by itself provide encrypted transport or account authentication.
readyHandshake progression immediately before protocol activation.
activeThe contract protocol is active; game code still owns scene and simulation setup.
reconnectingA 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 as scene_id and seed from 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() — returns true while a transport link exists, false otherwise.
  • Session.is_host() — returns true on the server peer, false on 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 or nil when 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 with connected, disconnected, session_started, session, messages, and events. The events array includes validated game messages and prediction events.
  • msg.name — a declared game message name or a prediction event such as prediction_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 a replicated_prefabs key and an existing local entity ID, then assigns an owner. Returns a string network ID or nil.
Session.transfer(network_id, new_owner_id)   -- move ownership
Session.despawn(network_id)                  -- remove the replicated entity
  • Session.transfer — reassigns ownership to new_owner_id and 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, or nil for an unknown entity.
  • Session.network_id_for_owner(owner_id) — returns the first network ID that owner controls, or nil.
  • 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, or nil for a none target. 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 with phase, session_epoch, contract_hash, transport and security counters, secure_ready, and last_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 by process_events().

Smoothing and rewinding remote entities are the job of Prediction. TLS and custom transports are covered in HTTP & TLS.