Skip to content

WebSocket (Lua API)

Realtime, bidirectional messaging with a dedicated server — the thing Http cannot do. Available globally as WebSocket. Every callback fires on the main thread from the engine's per-frame pump, so you can touch node properties, asset references and UI widgets directly from inside one without locking.

This is a client. It is not a replacement for the engine's UDP multiplayer (Network.*), and on web builds it is unrelated to the WebGL2 target's UDP relay — a dedicated server that speaks WebSocket serves native and browser clients uniformly, with no relay in the middle.

local ws, err = WebSocket.Connect('ws://127.0.0.1:9002/', { protocols = { 'echo.v1' } })
if ws == nil then
    Log.Error('connect failed: ' .. err)
    return
end

ws:SetOpenCallback(function()
    ws:SendText('hello')
end)

ws:SetMessageCallback(function(data, isBinary)
    Log.Debug('got ' .. #data .. ' bytes, binary=' .. tostring(isBinary))
end)

ws:SetClosedCallback(function(code, reason, wasClean)
    Log.Debug('closed ' .. code .. ' ' .. reason)
end)

Keep a reference to the connection. The Lua handle owns it — if you drop it and the garbage collector runs, the connection closes. Store it on self, not in a local that goes out of scope.

Top-level functions

WebSocket.IsAvailable() → bool

False on platforms with no transport at all. Check it before showing a "connect" button.

WebSocket.GetMissingDependencyMessage() → string

Why not, when IsAvailable() is false. Empty string when available.

WebSocket.Connect(url [, options]) → connection or nil, error

url is ws://host[:port][/path] or wss://.... The port defaults to 80 for ws and 443 for wss.

Returns a connection handle immediately — it starts in the Connecting state and reaches Open some frames later. On a malformed URL, or wss:// on a build with no TLS transport, it returns nil plus a message.

options is an optional table:

Key Type Meaning
protocols array of strings Sec-WebSocket-Protocol offers, most preferred first
headers string→string map Extra handshake headers. Native only — browsers cannot set them, and web builds log a warning and ignore this
maxQueuedBytes integer Per-direction queue cap (default 1 MiB)

Connection: callbacks

Passing nil clears a callback. Setting one replaces the previous.

ws:SetOpenCallback(fn) — fn()

The handshake completed. GetState() is Open and GetSelectedProtocol() is final by the time this fires.

ws:SetMessageCallback(fn) — fn(data, isBinary)

data is a Lua string and is 8-bit clean, so binary payloads survive intact.

While a message callback is set, messages are NOT queued for GetPacket(). The callback always wins.

ws:SetErrorCallback(fn) — fn(message)

A transport or protocol error. A Closed callback always follows.

ws:SetClosedCallback(fn) — fn(code, reason, wasClean)

Fires exactly once per connection. wasClean is true only when both sides completed the close handshake.

Ordering is always Open → Message* → Closed. A failure before Open produces Error → Closed(1006, "", false).

Connection: actions

ws:SendText(str) → bool

ws:SendBinary(str) → bool

Return false — never raise — when the connection is not Open or the outgoing queue is full. A full queue also logs a warning once per connection.

Text frames are not UTF-8 validated. Send JSON and you will be fine; send raw bytes and you should use SendBinary.

ws:Close([code [, reason]])

Begins the close handshake. Defaults to 1000 and "". Safe to call in any state, including twice.

Connection: polling

An alternative to callbacks, mirroring Godot's WebSocketPeer. Only works when no message callback is set.

ws:GetState() → WebSocketState

ws:GetAvailablePacketCount() → integer

ws:GetPacket() → data, isBinary or nil

while ws:GetAvailablePacketCount() > 0 do
    local data, isBinary = ws:GetPacket()
    -- ...
end

ws:GetSelectedProtocol() → string

The negotiated subprotocol, or "" until Open / when none was negotiated.

ws:GetCloseCode() → integer

ws:GetCloseReason() → string

0 and "" until the connection closes.

ws:WasDowngraded() → bool

True when the build was compiled with POLYPHASE_WS_DOWNGRADE_WSS=1 and it rewrote a wss:// URL to ws://. Off everywhere by default — see Development → WebSocket Client.

WebSocketState

WebSocketState = { Connecting = 0, Open = 1, Closing = 2, Closed = 3 }

Limits and behaviour

Outgoing queue 1 MiB (override with options.maxQueuedBytes)
Incoming queue 1 MiB, only used when no message callback is set
Max single message 16 MiB — larger is a protocol error, which closes the connection
Ping Answered with Pong inside the transport; never surfaced to Lua
Fragmentation Incoming fragments are reassembled; outgoing messages are never fragmented

Godot correspondence

Godot WebSocketPeer Polyphase
connect_to_url(url) WebSocket.Connect(url, options)
poll() implicit — the engine ticks the subsystem
get_ready_state() ws:GetState() / WebSocketState
send_text(t) / send(bytes) ws:SendText(s) / ws:SendBinary(s)
get_available_packet_count() ws:GetAvailablePacketCount()
get_packet() + was_string_packet() ws:GetPacket() → data, isBinary
close(code, reason) ws:Close(code, reason)
get_close_code() / get_close_reason() ws:GetCloseCode() / ws:GetCloseReason()

Testing locally

The engine ships a dependency-free echo server:

node Tools/ws-echo/echo.js

See Tools/ws-echo/README.md.