📖 Developer Guide

CGRelay — Complete Implementation Guide

Everything you need to add multiplayer to your Godot 4 game using the CGRelay system. Read top to bottom on your first integration.

1. Overview

CGRelay is a game-agnostic WebSocket relay server running on Render. It does not run your game logic — it only routes messages between connected players. Your game logic lives entirely in your Godot client.

The relay works like this: every player connects to the server. When a player sends data, the server forwards it to all other players in the same game and zone. That's it.

What the relay doesWhat you do in your game
Accepts WebSocket connectionsConnect using CGClient.gd
Validates game ID and versionSet correct VERSION and GAME_ID
Routes messages between peersSend data with send_realtime / send_once / send_on_change
Manages zones and peer listsListen to signals and react to peer events
Stores "once" data for late joinersSend setup data with send_once()
Handles kicks and bansListen to on_kicked signal
Important CGRelay is designed for casual games. There is no server-side authority — your game client is responsible for all game logic. This is perfect for turn-based games, party games, and simple co-op. It is not suitable for competitive games where cheat prevention is required.

2. Setup

Step 1 — Add CGClient.gd to your project

You receive one file: CGClient.gd. Place it anywhere in your project — res://scripts/CGClient.gd is recommended.

Add it as an Autoload in Project Settings → Autoload so it's accessible from anywhere in your game:

# Project Settings → Autoload # Name: CGClient # Path: res://scripts/CGClient.gd
Do NOT add CGClient as a child node manually in each scene. It must be an Autoload so it persists across scene changes and keeps the connection alive.

Step 2 — Set your constants

Open CGClient.gd and set the two constants at the top:

CGClient.gd
const VERSION = "0.0.1" # Must exactly match the version in your package JSON const GAME_ID = "your_game_id" # Your game ID given by CGRelay const SERVER_URL = "wss://godotgameserver.onrender.com"
VERSION must match exactly. If your package JSON says "version": "0.0.1" then VERSION in CGClient must also be "0.0.1". A mismatch will cause every join to be rejected with UPDATE_REQUIRED.

Step 3 — Remove the auto-join from _ready()

By default CGClient calls join() in _ready(). You probably want to control when the player connects — for example after a menu screen. Comment out or remove the auto-join:

CGClient.gd
func _ready(): # join(SERVER_URL) ← comment this out pass

Then call it yourself when the player is ready to connect:

your_menu.gd or game.gd
func _on_play_button_pressed(): CGClient.join(CGClient.SERVER_URL)

3. Connecting to the Server

join(url, zone_name)

Connects to the relay and joins your game. After calling this, CGClient automatically sends request_join once the WebSocket connects.

ParameterTypeDefaultDescription
urlStringThe server URL. Always use CGClient.SERVER_URL
zone_nameString"default"The zone to join. Leave as "default" unless your game uses zones.
Example
# Simple join CGClient.join(CGClient.SERVER_URL) # Join a specific zone (advanced) CGClient.join(CGClient.SERVER_URL, "lobby_1")

Handling the join result

After calling join(), connect to these two signals to know if the player was accepted or rejected:

your_game.gd
func _ready(): CGClient.join_accepted.connect(_on_joined) CGClient.join_rejected.connect(_on_rejected) CGClient.join(CGClient.SERVER_URL) func _on_joined(peer_list: Array): # peer_list = array of peer IDs already in your zone print("Joined! Other peers: ", peer_list) # Start your game, show the game scene, etc. get_tree().change_scene_to_file("res://scenes/game.tscn") func _on_rejected(reason: String, server_version: String, game_id: String): # Show error to player based on reason match reason: "UPDATE_REQUIRED": show_popup("Please update your game to version " + server_version) "SERVER_FULL": show_popup("Server is full. Try again later.") "UNKNOWN_GAME": show_popup("Game not found. Contact support.") "MAINTENANCE": show_popup("Server is under maintenance. Try later.") "BANNED": show_popup("You have been banned.")

All rejection reasons

ReasonCauseFix
UNKNOWN_GAMEGAME_ID not found on serverCheck GAME_ID matches your package exactly
UPDATE_REQUIREDVERSION mismatchUpdate VERSION in CGClient to match server package
SERVER_FULLmax_peers reachedIncrease max_peers in package or wait for a slot
MAINTENANCEServer in maintenance modeWait for maintenance to end
BANNEDUser ID is bannedPlayer is banned by admin

4. Sending Data

CGRelay has three send functions, each for a different type of data. Understanding which one to use is the most important part of your integration.

send_realtime(data, position)

Use for data that changes every frame — player position, rotation, animation state. The server may throttle this based on your game type profile. Delta compression is applied automatically — only changed values are forwarded to other peers.

ParameterTypeRequiredDescription
dataDictionaryYesAny key-value data. Keep keys short.
positionVector3NoPlayer world position. Used for proximity filtering if enabled.
Example — syncing player transform
func _process(_delta): # Send position, rotation and animation state every frame CGClient.send_realtime({ "px": position.x, "py": position.y, "pz": position.z, "ry": rotation.y, "anim": current_animation }, position) # pass position for proximity filtering
Keep dictionary keys short. Use "px" not "position_x". Every byte in a realtime packet is sent many times per second. Short keys reduce bandwidth significantly.

send_on_change(data)

Use for data that changes occasionally — health, ammo, score, game state flags. Send this only when the value actually changes, not every frame.

Example — syncing health when it changes
var _last_sent_health = -1 func take_damage(amount: int): health -= amount health = clamp(health, 0, 100) # Only send if value actually changed if health != _last_sent_health: CGClient.send_on_change({"hp": health, "uid": my_user_id}) _last_sent_health = health

send_once(data)

Use for data that is set once and must be received by all peers, including players who join later. The server stores this data and automatically sends it to any new joiner.

Perfect for: map state, item pickups that have been collected, spawn point assignments, game setup data.

Example — broadcasting spawn points at game start
func _on_joined(peer_list: Array): # If I am the first player (host), I set up the game state if peer_list.is_empty(): var spawn_data = { "spawns": [ {"x": 10, "z": 5}, {"x": -10, "z": 5}, {"x": 0, "z": -10} ], "map": "forest", "seed": randi() } CGClient.send_once(spawn_data) # Any player who joins after this will receive spawn_data automatically
send_once data persists until the server restarts. If you call send_once with the same key twice, the second call overwrites the first. Use this to update persistent game state.

send_to(target_id, tier, data)

Sends data directly to a specific peer only. Other players do not receive it. Use for private messages, direct challenges, or peer-specific data.

Example — sending a private message to one player
# Send a trade request directly to peer with ID 12345 CGClient.send_to(12345, "on_change", { "action": "trade_request", "item": "sword" })

5. Receiving Data

All CGClient signals

Connect to these signals anywhere in your game. They fire automatically when the server sends data.

join_accepted (peer_list: Array)
Fired when the server accepts your join request. peer_list contains the IDs of all other peers already in your zone.
join_rejected (reason: String, server_version: String, game_id: String)
Fired when the server rejects your join. See rejection reasons table above.
peer_joined (id: int)
A new player joined your zone. Use this to spawn their character in your game.
peer_left (id: int)
A player disconnected or left your zone. Use this to remove their character.
relay_received (sender_id: int, tier: String, data: Dictionary)
The main data signal. Fired whenever any peer sends realtime, on_change, or once data. sender_id is who sent it. tier tells you which send function they used. data is their dictionary.
snapshot_received (sender_id: int, data: Dictionary)
Fired specifically when a full snapshot is received (when delta compression is active). data contains the complete state, not just changed values.

Handling relay_received

This is the main signal you'll use. It fires for all three tiers.

your_game.gd
func _ready(): CGClient.relay_received.connect(_on_data) CGClient.peer_joined.connect(_on_peer_joined) CGClient.peer_left.connect(_on_peer_left) func _on_data(sender_id: int, tier: String, data: Dictionary): match tier: "realtime": # Update the visual position of the remote player if remote_players.has(sender_id): remote_players[sender_id].update_from_data(data) "on_change": # Update a non-visual game state if data.has("hp"): update_health_bar(sender_id, data["hp"]) "once": # Setup data arrived — this could be from a late join if data.has("spawns"): setup_spawn_points(data["spawns"])

Delta compression and snapshots

When delta compression is active (fps, br, mmo, casual game types), the server only forwards keys that have changed since the last frame. This means your relay_received data dictionary may be incomplete — it only contains the changed fields.

CGClient automatically merges incoming delta data with the last known full state for each peer in _peer_states. The relay_received signal already gives you the merged full state, not just the delta. You do not need to handle merging yourself.

Every 60 frames (configurable), the server sends a full snapshot with all fields. When this happens, snapshot_received fires instead of relay_received. You can use this to fully re-sync a peer's state.

Handling snapshots for full re-sync
func _ready(): CGClient.snapshot_received.connect(_on_snapshot) CGClient.relay_received.connect(_on_data) func _on_snapshot(sender_id: int, data: Dictionary): # Full state — safe to reset the peer's entire visual state if remote_players.has(sender_id): remote_players[sender_id].full_reset(data) func _on_data(sender_id: int, tier: String, data: Dictionary): if tier == "realtime": # data is already the merged full state (CGClient handles merging) if remote_players.has(sender_id): remote_players[sender_id].update_position( Vector3(data.get("px", 0), data.get("py", 0), data.get("pz", 0)) )

6. Peer Events — Spawning and Removing Players

When a player joins or leaves, you need to create or remove their representation in your game world.

multiplayer_manager.gd — full peer lifecycle example
var remote_players: Dictionary = {} # peer_id -> Node var my_peer_id: int = 0 func _ready(): CGClient.join_accepted.connect(_on_joined) CGClient.peer_joined.connect(_on_peer_joined) CGClient.peer_left.connect(_on_peer_left) CGClient.relay_received.connect(_on_data) func _on_joined(peer_list: Array): # My own peer ID — Godot assigns this automatically my_peer_id = multiplayer.get_unique_id() # Spawn all players already in the zone for id in peer_list: _spawn_remote_player(id) func _on_peer_joined(id: int): # A new player arrived after me _spawn_remote_player(id) func _on_peer_left(id: int): # Player disconnected if remote_players.has(id): remote_players[id].queue_free() remote_players.erase(id) func _spawn_remote_player(id: int): var player = REMOTE_PLAYER_SCENE.instantiate() player.name = str(id) add_child(player) remote_players[id] = player func _on_data(sender_id: int, tier: String, data: Dictionary): if tier == "realtime" and remote_players.has(sender_id): remote_players[sender_id].apply_state(data)
Get your own peer ID with: multiplayer.get_unique_id() — available after join_accepted fires.

7. Zones

Zones let you split players within the same game into groups. Players in different zones do not receive each other's relay messages.

Zones are optional. If your game type has use_zones: false (like turnbased), everyone is in the "default" zone regardless of what you pass.

When to use zones

  • Multiple game rooms or lobbies running simultaneously
  • Large world split into regions — only players in the same region sync
  • Matchmaking — each match is a zone

How to use zones

Pass a zone name when joining. All players who pass the same zone name end up together:

Example — room-based multiplayer
# Player joins room "room_abc123" CGClient.join(CGClient.SERVER_URL, "room_abc123") # All players who call join() with "room_abc123" are in the same zone # Players in "room_xyz456" never see their messages

Zone overflow

If a zone reaches zone_max peers (defined in your package), the server automatically assigns new players to zone_name_overflow, then zone_name_overflow2. You don't need to handle this — it happens automatically. Just be aware that players may end up in an overflow zone if yours is full.

Zone names are arbitrary strings. Use any naming convention — "lobby_1", "match_abc", "region_north". The server treats them as opaque identifiers.

8. The Package JSON

The package JSON tells the server about your game. You create it using the CGRelay Feeder tool and push it to the server once before launch (and again when you update).

Example package JSON
{ "game_id": "my_party_game_v1", "game_type": "casual", "version": "0.0.1", "max_peers": 10, "sync": { "radius": 0, "realtime": ["position", "rotation"], "on_change": ["health", "score"], "once": ["spawns", "map_seed"] } }
FieldRequiredDescription
game_idYesUnique ID for your game. Must match GAME_ID in CGClient.
game_typeYesOne of: fps, br, mmo, casual, turnbased, custom. Sets relay behavior.
versionYesMust match VERSION in CGClient exactly.
max_peersYesMaximum concurrent players. Must match your subscription peer count.
sync.radiusNoProximity radius in units. 0 = disabled. Players beyond radius don't receive realtime updates.
sync.realtimeNoField names you plan to send with send_realtime (documentation only).
sync.on_changeNoField names for send_on_change (documentation only).
sync.onceNoField names for send_once (documentation only).
Whenever you change version in your package, update VERSION in CGClient too. Both must match or every player gets UPDATE_REQUIRED on join.

9. Game Types and What They Do

The game_type in your package sets the server-side relay profile — how often realtime data is forwarded, whether delta compression is on, and whether zones are used.

Game TypeRealtime IntervalDeltaZonesBest For
fpsEvery frameYesYes (30 max)First-person shooters
brEvery frameYesYes (50 max)Battle royale
mmoEvery 3 framesYesYes (50 max)Open world, MMO
casualEvery 3 framesYesYes (50 max)Casual co-op, party games
turnbasedNever (999)NoNoTurn-based, card games, board games
customConfigurableConfigurableConfigurableCustom requirements
For most casual games, use "casual" or "turnbased". "casual" gives you zones + delta compression + 3-frame throttle. "turnbased" turns off all realtime sync — perfect if your game only needs on_change and once.

10. Common Game Patterns

Pattern — Turn-Based Game

A card game, chess, or board game where players take turns. No realtime sync needed. Use game_type: turnbased and only send_on_change.

turn_game.gd
var current_turn: int = 0 # peer ID whose turn it is var my_id: int = 0 func _ready(): CGClient.join_accepted.connect(_on_joined) CGClient.relay_received.connect(_on_data) func _on_joined(peer_list: Array): my_id = multiplayer.get_unique_id() # If I'm the first player, I go first if peer_list.is_empty(): current_turn = my_id CGClient.send_on_change({"turn": my_id, "state": get_board_state()}) func end_my_turn(): if current_turn != my_id: return # not my turn CGClient.send_on_change({ "turn": next_player_id(), "state": get_board_state(), "last_move": last_move }) func _on_data(sender_id: int, tier: String, data: Dictionary): if tier == "on_change": if data.has("turn"): current_turn = data["turn"] if data.has("state"): apply_board_state(data["state"])

Pattern — Casual Co-op (Moving Characters)

Players can see each other moving around. Use game_type: casual with realtime for movement and on_change for stats.

player.gd — my own player
func _process(_delta): # Handle input and move var dir = Input.get_vector("left", "right", "up", "down") velocity = dir * speed move_and_slide() # Send my state to others CGClient.send_realtime({ "x": position.x, "y": position.y, "fx": int(dir.x > 0) - int(dir.x < 0), # facing direction "a": current_anim })
remote_player.gd — other players
func apply_state(data: Dictionary): # Smoothly move to reported position var target = Vector2(data.get("x", position.x), data.get("y", position.y)) position = position.lerp(target, 0.3) # Update animation if data.has("a"): animation_player.play(data["a"])

Pattern — Lobby System (Wait for all players)

Show a lobby screen where players see who is connected, then one player starts the game.

lobby.gd
var players_ready: Dictionary = {} # peer_id -> name func _ready(): CGClient.join_accepted.connect(_on_joined) CGClient.peer_joined.connect(_on_peer_joined) CGClient.peer_left.connect(_on_peer_left) CGClient.relay_received.connect(_on_data) CGClient.join(CGClient.SERVER_URL, "lobby_main") func _on_joined(peer_list: Array): var my_id = multiplayer.get_unique_id() players_ready[my_id] = player_name # Announce myself to others CGClient.send_on_change({"joined": player_name, "id": my_id}) _update_lobby_ui() func _on_peer_joined(id: int): # Introduce myself to the newcomer CGClient.send_to(id, "on_change", {"joined": player_name, "id": multiplayer.get_unique_id()}) func _on_peer_left(id: int): players_ready.erase(id) _update_lobby_ui() func _on_data(sender_id: int, tier: String, data: Dictionary): if data.has("joined"): players_ready[data["id"]] = data["joined"] _update_lobby_ui() if data.has("start_game"): get_tree().change_scene_to_file("res://scenes/game.tscn") func _on_start_pressed(): # Only the first player (lowest ID) can start if multiplayer.get_unique_id() == players_ready.keys().min(): CGClient.send_on_change({"start_game": true})

11. Error Handling

on_kicked

You must implement on_kicked in your client script (it's already in CGClient as an RPC stub). Listen to it to handle being kicked:

CGClient.gd — already included
# This is already in CGClient.gd — add your handling here @rpc("authority", "reliable") func on_kicked(reason: String): print("Kicked: ", reason) # Disconnect and go back to menu multiplayer.multiplayer_peer = null get_tree().change_scene_to_file("res://scenes/menu.tscn")

Connection lost

Handle server disconnection in your game:

your_game.gd
CGClient._peer.connection_closed.connect(_on_disconnected) func _on_disconnected(): # Server dropped the connection show_popup("Disconnected from server") get_tree().change_scene_to_file("res://scenes/menu.tscn")

Common mistakes

ProblemCauseFix
Always get UNKNOWN_GAMEGAME_ID doesn't match packageCopy game_id from your package JSON exactly
Always get UPDATE_REQUIREDVERSION mismatchMatch VERSION in CGClient to package version field
RPC checksum error in consoleon_kicked missing from clientAdd on_kicked RPC stub to CGClient
No data received from othersNot connected to relay_received signalConnect the signal in _ready()
Data received but position is wrongUsing delta without mergingUse _peer_states[id] from CGClient or use snapshot_received
send_once data not received by late joinersgame_type doesn't have once enabledAdd "once" key to sync in package JSON

12. Pre-Launch Checklist

Go through this before you share your game with players.

1

CGClient.gd is added as an Autoload in Project Settings

2

GAME_ID in CGClient matches game_id in your package JSON exactly (case sensitive)

3

VERSION in CGClient matches version in your package JSON exactly

4

Package JSON has been pushed to the server using the Feeder tool

5

join_accepted and join_rejected signals are connected and handled

6

peer_joined and peer_left signals are connected — remote players spawn and despawn

7

relay_received signal is connected and all tiers are handled

8

on_kicked RPC stub is in CGClient with actual handling code

9

max_peers in package matches your CGRelay subscription peer count

10

Tested with at least 2 devices or instances simultaneously

You're ready to launch. If all 10 boxes are checked and two instances can connect and exchange data, your integration is complete.

Ready to get your Game ID?

Contact to get set up with CGRelay. Rs. 50/month base + Rs. 10 per peer.

Get Started → Back to CGRelay →