DEMIENGINEDOCUMENTATION Everything is free
Game systems

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.

PolicyValuesMeaning
Senders / writersserver, owner, allWho may send a message or write a replicated field; only the server may spawn
Disconnectdespawn, return_to_server, transfer_by_game_policyWhat happens to that player’s entities
Message targetsowned_entity, entity, noneWhat 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:

CallReturns
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, nil while 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 json or body on 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.INTERNET in build.android.permissions when 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.