In this guide
Host-authoritative sessions, contracts, prediction, TLS, and REST calls.
Networking & HTTP
Multiplayer sessions use a host-authoritative network contract for spawning, ownership, and message validation. HTTP/HTTPS is a separate asynchronous service for REST backends; it needs no multiplayer contract.
Network contract
Declare a contract asset in the project:
"network_contract": "asset://network/arena_contract"
The NetworkContract asset declares replicated prefab keys, field writers, message names and directions, size and rate limits, and payload schemas. The runtime includes the contract and referenced schemas in a compatibility hash; incompatible packets are rejected during session setup.
| Policy | Values | Meaning |
|---|---|---|
| Senders / writers | server, owner, all | Who may send a message or write a replicated field; only the server may spawn |
| Disconnect | despawn, return_to_server, transfer_by_game_policy | What happens to that player’s entities |
| Message targets | owned_entity, entity, none | What the message is addressed to |
Session flow
local NetworkSession = require("demi.network.session")
-- Host:
NetworkSession.host(39420) -- boolean
NetworkSession.start_session({
scene_id = "scene://game",
seed = 42,
})
-- Client:
NetworkSession.connect("127.0.0.1", 39420) -- boolean: attempt started
start_session(metadata) sends metadata from the host; it does not load the named scene or seed the simulation. Check process_events() for connection and session events.
Poll the session each frame:
function Game:on_update(dt)
local update = NetworkSession.process_events()
for _, e in ipairs(update.events) do
print(e.name, e.sender_id)
end
end
process_events() returns an update table with connected, disconnected, session_started, session, messages, and events. The events array contains accepted gameplay and prediction events; invalid contract traffic is rejected before delivery. On an owning peer, call update_entity(network_id, dt) to publish contract-declared replicated fields at the configured interval.
Diagnostics expose the lifecycle phase, including closed, connected, authenticated, ready, and active:
local d = NetworkSession.diagnostics()
-- d.phase, d.session_epoch, d.contract_hash, d.sent_messages, d.rejected_messages
print(NetworkSession.is_connected()) -- boolean
print(NetworkSession.is_host()) -- boolean
print(NetworkSession.sender_id()) -- string peer ID
print(NetworkSession.current_session())-- metadata table or nil
Authority and spawning
-- Host only:
local id = NetworkSession.spawn("player", entity_id, owner_id) -- contract prefab key
NetworkSession.transfer(network_id, new_owner)
NetworkSession.despawn(network_id)
if NetworkSession.has_authority(network_id) then
-- safe to apply local gameplay for this entity
end
local owner = NetworkSession.owner(network_id)
local nid = NetworkSession.network_id_for_owner(owner_id)
NetworkSession.bind_local_entity(network_id, entity_id)
spawn accepts a key in replicated_prefabs and an existing local entity ID; it returns a string network ID or nil. transfer, despawn, and bind_local_entity return booleans. Binding is permitted only for the owning peer. has_authority tests ownership; individual field and message rules still apply. Late joiners receive retained spawn state.
Messages
local ok = NetworkSession.send("player_join", nil, { color = { 0.2, 0.4, 0.8, 1.0 } })
-- second argument is a network entity ID when the contract requires a target
Only contract-declared message names are accepted. A payload must match its declared schema, finite-number rules, and size limits. The validation pipeline checks header, protocol, contract hash, epoch, sequence, size, structure, schema, sender, destination, ownership generation, and rate before delivery.
Prediction and reconciliation
Optional, for responsive movement on a locally owned client entity:
NetworkSession.configure({
input_queue_capacity = 64,
input_future_window = 8,
input_head_of_line_timeout = 0.5,
input_max_per_tick = 8,
snapshot_buffer = 32,
interpolation_delay = 0.1,
extrapolation_limit = 0.25,
prediction_history_limit = 128,
prediction_visual_decay = 12.0,
})
local owned_id = NetworkSession.network_id_for_owner(NetworkSession.sender_id())
NetworkSession.enable_prediction({
network_id = owned_id,
input_message = "move_input", -- declared owner-to-server owned_entity message
state = { x = 0, y = 0 },
apply = function(state, input)
return {
x = state.x + input.dx * input.dt,
y = state.y + input.dy * input.dt,
}
end,
})
The apply callback returns a deterministic next controller state. The host consumes inputs and publishes authoritative snapshots.
Related calls and what they return:
| Call | Returns |
|---|---|
NetworkSession.publish_snapshot(nid, state) | Host publishes authoritative state; returns a boolean. |
NetworkSession.predict_input(nid, input) | Applies and sends one client input; returns its sequence number or nil. |
NetworkSession.take_inputs(nid) | Host receives ordered accepted input tables, each with seq. |
NetworkSession.prediction_state(nid) | Current predicted state table, or nil. |
NetworkSession.prediction_visual_offset(nid) | Render-only offset table keyed by state field, or nil. |
NetworkSession.remote_state(nid) | Interpolated remote state table, or nil. |
NetworkSession.prediction_diagnostics() | Per-channel counters under channels. |
NetworkSession.reset_prediction(nid, state) | Rebases to a supplied state; returns a boolean. |
On the host, historical 2D queries use recorded collision circles for lag-compensated hits:
NetworkSession.configure({
query_history_capacity = 64,
query_history_max_entities = 128,
query_history_rewind_ticks = 8,
})
NetworkSession.record_query_snapshot(server_tick, {
{ entity_id = nid, x = px, y = py, radius = 0.5 },
})
local hit = NetworkSession.historical_raycast(
requested_tick, origin_x, origin_y, direction_x, direction_y, max_distance
) -- 2D circle hit table or nil
Low-level transport
local Network = require("demi.network")
print(Network.available()) -- true when transport is compiled in
Network.host(port) -- ENet
Network.host_dtls(port, cert_path, key_path) -- DTLS
Network.connect(host, port)
Network.send(bytes, reliable, peer_id, channel) -- raw bytes payload
local events = Network.events() -- connected, disconnected, and message events
Use this layer for custom protocols. Most games want demi.network.session instead.
TLS
local TlsServer = require("demi.network.tls.server")
local TlsClient = require("demi.network.tls.client")
TlsServer.listen(port, cert_path, key_path)
local ok = TlsClient.connect(host, port, trusted_cert_path, server_name)
if not ok then print(TlsClient.error()) end
TlsClient.send(payload)
local events = TlsClient.events() -- receive queue
print(TlsClient.is_connected()) -- boolean
The TLS client verifies the certificate chain against the supplied trust file and verifies the server name. connect starts the connection; poll events() or is_connected() for the result. demi.network.crypto provides random_token(), password_hash(password, salt[, iterations]), and secure_equals(a, b). Tokens are hex strings.
HTTP / HTTPS
local Http = require("demi.network.http")
local request, err = Http.post("https://api.example.com/login", {
json = { user = "hero" }, -- JSON body + Content-Type
headers = { ["X-Trace"] = "1" },
timeout_ms = 10000,
connect_timeout_ms = 2000,
max_response_bytes = 1024 * 1024,
})
if not request then
print("could not start request:", err)
return
end
Http.get, Http.post, and Http.request (for PUT/PATCH/DELETE/HEAD) return a request handle immediately, or nil, error when the call could not be started. Poll the handle:
function Game:on_update()
if not request:done() then return end
local response = request:response()
if response.ok then
-- status 200–299
print(response.status)
print(response.body) -- raw string
local data = response.json -- parsed table, or nil + response.json_error
if data then print(data.user) end
else
print(response.status, response.error, response.error_code)
end
end
request:done()— boolean, true when finished (success or failure).request:response()— response table while done,nilwhile pending.request:cancel()— abort; garbage collection also cancels.- Response fields:
ok,status,body,error,error_code,json,json_error,headers(lowercase names, array values). - Use either
jsonorbodyon the request, not both. - Redirects come back as 3xx — follow them yourself if you want to.
- Android projects with an explicit build block need
android.permission.INTERNETinbuild.android.permissionswhen they use network services. Projects without an authored build block receive the engine's default permission.
Dedicated server
A windowless dedicated server runs the same project data and Lua gameplay. See minimal_2d_android_server in the examples tour.