Godot Freelay
Serverless multiplayer for Godot 4 on top of plain MQTT brokers, implementing
the Freelay protocol (see freelay-spec.md): signed public channels,
end-to-end encrypted private sessions, presence with tamper resistance, and an
optional WebRTC upgrade for direct peer connections.
Dependencies
| Addon | Version | Provides |
|---|---|---|
| mqtt-node | ≥ 0.3.0 | MqttNode (WebSocket MQTT 3.1.1, LWT, binary payloads, auto-reconnect) |
| gd-ed25519 | ≥ 1.1.0 | Ed25519, Ed25519Keypair, X25519, X25519Keypair, Monocypher |
| webrtc-native (optional) | - | WebRTCPeerConnection on desktop/mobile; built into web exports |
Copy addons/mqtt-node, addons/ed25519 and addons/freelay into your
project.
For realtime games on desktop and mobile, also install
webrtc-native (unpack
the release into addons/webrtc; it is too large to bundle). Without it
everything still works, but every connection stays on the MQTT relay with
noticeably higher latency, and upgrade_to_rtc() logs a warning once. Web
exports have WebRTC built in and need nothing extra.
Quick start
See example.gd. In short:
var client := RelayClient.new()
client.app_id = "my-cool-game"
client.broker_urls = ["wss://broker.hivemq.com:8884/mqtt"]
add_child(client)
client.open()
await client.opened
var lobby := client.join("lobby") # public, signed, plaintext
await lobby.joined
lobby.send({"hello": "world"})
var conn := client.connect_peer(peer_id) # private, E2E encrypted
await conn.ready
conn.send({"secret": 42})
conn.upgrade_to_rtc() # optional direct connection
API surface
- RelayClient (Node) -
open(),close(),join(name) -> RelayChannel,connect_peer(peer_id) -> RelayPeerConnection,get_connection(peer_id),mute_peer(id). Signals:opened,reconnected,closed(reason),connection_failed(reason),reliable_dropped(count),peer_connection_requested(conn),outbound_throttled(count). Config:app_id,broker_urls,broker_username/broker_password,profile,auto_accept_peers,ice_servers,rtc_enabled,raw_payloads,reliable_qos,handshake_timeout_s,offline_buffer_size,outbound_limit,inbound_limit_per_peer(bothRelayRateLimit, disabled by default).- Freeing the node (scene change, quitting the game) signs off cleanly, so quitting needs no extra code. Reparenting it does not close it.
connection_failedfires for every failed connection attempt; the client keeps retrying. If every broker refuses for good (bad credentials, not authorized)closedfollows. Godot can lose a refusal that arrives in the same read as the broker hanging up; it then looks like a plain failure and retrying continues.reliable_droppedfires when the offline buffer (offline_buffer_size) or the rate limiter queue (max_queued_reliable) overflowed and the oldest reliable messages were discarded.
- RelayProfile -
generate(),from_seed(seed),save(path, password),load_from(path, password),peer_id,public_key. The seed never leaves the class except viasave(). - RelayChannel -
send(data, reliable=true) -> bool,leave(),peers. Signals:joined,left(reason),message(peer, data),peer_joined(peer),peer_left(peer). - RelayPeerConnection -
send(data, reliable=true) -> bool,send_relay(data) -> bool(always over the relay),accept(),reject(),close(),upgrade_to_rtc(),rtc_enabled,rtc_ice_servers,last_received_msec. Signals:ready,message(data),transport_changed("relay"|"rtc"),interrupted,resumed,closed(reason).send()returns false when the transport did not take the message (congestion); retry reliable messages later. True means accepted, not delivered (seereliable_dropped).- A connection survives either side reconnecting to the broker. It closes on FIN, after 20 s of the peer being offline, or after 60 s of silence (keepalives are sent automatically). After a broker outage of 15 s or more, your own relay connections close when you are back (the peer has given them up by then).
- To wait for a connection, wait for
readyorclosed(or pollstate): a rejected or timed-out connection never emitsready. - An incoming request (
peer_connection_requested) must be accepted withinRelayClient.handshake_timeout_s(10 s by default), after thataccept()does nothing because the initiator has given up. If players accept by hand, raise it on all clients.
- RelayPeer -
peer_id,public_key,mute()/unmute(),meta.
Internal (protocol-level) classes: Freelay, FreelayCrypto,
FreelayEnvelope, FreelayPresenceManager.
Addon API status
All gd-ed25519 calls are isolated in freelay_crypto.gd and were verified
against the v1.0.0 doc-classes:
Ed25519.sign(message, private_key, public_key)- public key passed empty and derived internally (mismatch would fail fast).Ed25519Keypair.generate()/from_seed()/get_seed()/get_public_key().X25519.generate_keypair()andX25519Keypair.get_private_key()/get_public_key().X25519.shared_secret(own_private, their_public)- returns null on low-order points; treated as fatal handshake failure.Monocypher.aead_encrypt(key, nonce, plaintext, ad)/aead_decrypt(key, nonce, ciphertext_with_tag, ad)- note the key-and-nonce-first parameter order; both return null on failure, which the adapter maps to an empty array (encrypt) or passes through (decrypt).Monocypher.blake2b(data, out_len)- null on out-of-range output length.
Realtime notes
- The relay path is MQTT over WebSocket/TLS/TCP through somebody else's broker:
expect 100-250 ms one way on a public broker and head-of-line blocking on
packet loss. It is the lobby, the signaling channel and the fallback. For
fast-paced state, call
upgrade_to_rtc()-reliable=falsethen maps to a real unordered/unreliable data channel. - Set
raw_payloads = trueand sendPackedByteArrays for state updates;var_to_bytesis convenient but adds type headers and padding to every value. - Every channel message costs 110 bytes of envelope plus one Ed25519 verification per receiver (~0.15 ms native). Use channels for lobby/chat and peer connections for gameplay traffic.
- Public brokers enforce rate limits; enable
outbound_limitto stay below them instead of being disconnected. - "Reliable" means ordered and not dropped by this client or (QoS 1) by a
congested broker. It is not an end-to-end acknowledgement. A game that cannot
lose a single message (RPC streams, host migration) needs its own sequence
numbers and retransmission on top - see TetraForce's
FreelayMultiplayerPeerfor aMultiplayerPeerExtensionbuilt that way. - Over WebRTC, messages above 16 000 bytes still go through the relay and can be
overtaken by smaller ones. When the direct link fails mid-stream, messages
that were in flight on it can arrive late, after newer ones that went over the
relay. Applications that need strict ordering across that should number their
messages (TetraForce's
FreelayMultiplayerPeerdoes). - With
outbound_limiton, unreliable messages wait while a reliable one is waiting for budget (they would otherwise overtake it and get it dropped). - Reliable messages sent while your own broker connection is down are buffered
and flushed after the reconnect; so are the ones the broker had not yet
acknowledged when the connection died (receivers drop duplicates). With
reliable_qos = 0there are no acknowledgements, and messages in flight at the moment of the drop are lost. Messages published while the other side is reconnecting are lost: clients connect with Clean Session, so the broker keeps nothing for a disconnected subscriber, whatever the QoS. Resync onresumedif that matters. - Clock skew: peers whose system clocks are minutes or hours off (up to 6 h) can still play together - in channels and when dialing each other directly, just a little slower to find: a channel member more than about 3.5 min off shows up with its next membership refresh (up to 30 s) instead of right away, and a connection request from a stranger more than 2 min off is accepted with its first retransmit (about 2 s later).
Tests
tests/ holds an end-to-end suite that runs several clients in one headless
Godot process against a local broker (it can kill connections and add latency):
cd tests/broker && npm install && node broker.js &
godot --headless --import # once
godot --headless -s tests/test_freelay.gd
godot --headless -s tests/test_freelay.gd -- test_rtc_upgrade # single test
Against any other broker (tests needing the admin API are skipped):
FREELAY_BROKER=wss://broker.hivemq.com:8884/mqtt godot --headless -s tests/test_freelay.gd
The WebRTC tests need addons/webrtc (webrtc-native) and are skipped without.
Design notes / limitations
- No gameplay trust: signatures prove sender identity, not honesty. Anti-cheat (lockstep, host authority, replays) is an application concern.
- Channel traffic is signed plaintext by design; only peer sessions are encrypted.
- The profile KDF is iterated BLAKE2b (not memory-hard). Fine against casual attackers; switch to Argon2 if/when the crypto addon exposes it.
- If every broker in
broker_urlsrefuses the connection for good (bad credentials, not authorized),closedis emitted with the reason; plain connection failures are reported throughconnection_failedwhile the client keeps retrying. broker_urlsis a fallback list, not a pool: peers that ended up on different brokers cannot see each other.- By default payloads are Godot Variants (
bytes_to_varwithout object decoding, so safe). For cross-language interop or compact state useraw_payloads. - The bundled
addons/ed25519binaries cover Windows, Linux, macOS, Android and Web (threads and no-threads exports). There is no iOS build yet; that needs a new build of gd-ed25519. Nothing in the GDScript code is platform specific. - Public brokers can still be flooded topic-wide; inbound limits and muting are local defenses only (spec §11).
Changelog for version 1.0.0
2026-10-15 10:12:56
No changelog provided for this version.