Description
Changelog
Reviews (0)

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 (both RelayRateLimit, 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_failed fires for every failed connection attempt; the client keeps retrying. If every broker refuses for good (bad credentials, not authorized) closed follows. 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_dropped fires 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 via save().
  • 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 (see reliable_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 ready or closed (or poll state): a rejected or timed-out connection never emits ready.
    • An incoming request (peer_connection_requested) must be accepted within RelayClient.handshake_timeout_s (10 s by default), after that accept() 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() and X25519Keypair.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=false then maps to a real unordered/unreliable data channel.
  • Set raw_payloads = true and send PackedByteArrays for state updates; var_to_bytes is 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_limit to 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 FreelayMultiplayerPeer for a MultiplayerPeerExtension built 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 FreelayMultiplayerPeer does).
  • With outbound_limit on, 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 = 0 there 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 on resumed if 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_urls refuses the connection for good (bad credentials, not authorized), closed is emitted with the reason; plain connection failures are reported through connection_failed while the client keeps retrying.
  • broker_urls is 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_var without object decoding, so safe). For cross-language interop or compact state use raw_payloads.
  • The bundled addons/ed25519 binaries 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.

Reviews

Freelay has no reviews yet.

Login to write a review.

Consider supporting the creators!

If you enjoyed this asset consider supporting its creator. Follow the link below.