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 does | What you do in your game |
| Accepts WebSocket connections | Connect using CGClient.gd |
| Validates game ID and version | Set correct VERSION and GAME_ID |
| Routes messages between peers | Send data with send_realtime / send_once / send_on_change |
| Manages zones and peer lists | Listen to signals and react to peer events |
| Stores "once" data for late joiners | Send setup data with send_once() |
| Handles kicks and bans | Listen 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.
| Parameter | Type | Default | Description |
url | String | — | The server URL. Always use CGClient.SERVER_URL |
zone_name | String | "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
| Reason | Cause | Fix |
UNKNOWN_GAME | GAME_ID not found on server | Check GAME_ID matches your package exactly |
UPDATE_REQUIRED | VERSION mismatch | Update VERSION in CGClient to match server package |
SERVER_FULL | max_peers reached | Increase max_peers in package or wait for a slot |
MAINTENANCE | Server in maintenance mode | Wait for maintenance to end |
BANNED | User ID is banned | Player 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.
| Parameter | Type | Required | Description |
data | Dictionary | Yes | Any key-value data. Keep keys short. |
position | Vector3 | No | Player 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"]
}
}
| Field | Required | Description |
game_id | Yes | Unique ID for your game. Must match GAME_ID in CGClient. |
game_type | Yes | One of: fps, br, mmo, casual, turnbased, custom. Sets relay behavior. |
version | Yes | Must match VERSION in CGClient exactly. |
max_peers | Yes | Maximum concurrent players. Must match your subscription peer count. |
sync.radius | No | Proximity radius in units. 0 = disabled. Players beyond radius don't receive realtime updates. |
sync.realtime | No | Field names you plan to send with send_realtime (documentation only). |
sync.on_change | No | Field names for send_on_change (documentation only). |
sync.once | No | Field 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 Type | Realtime Interval | Delta | Zones | Best For |
fps | Every frame | Yes | Yes (30 max) | First-person shooters |
br | Every frame | Yes | Yes (50 max) | Battle royale |
mmo | Every 3 frames | Yes | Yes (50 max) | Open world, MMO |
casual | Every 3 frames | Yes | Yes (50 max) | Casual co-op, party games |
turnbased | Never (999) | No | No | Turn-based, card games, board games |
custom | Configurable | Configurable | Configurable | Custom 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
| Problem | Cause | Fix |
| Always get UNKNOWN_GAME | GAME_ID doesn't match package | Copy game_id from your package JSON exactly |
| Always get UPDATE_REQUIRED | VERSION mismatch | Match VERSION in CGClient to package version field |
| RPC checksum error in console | on_kicked missing from client | Add on_kicked RPC stub to CGClient |
| No data received from others | Not connected to relay_received signal | Connect the signal in _ready() |
| Data received but position is wrong | Using delta without merging | Use _peer_states[id] from CGClient or use snapshot_received |
| send_once data not received by late joiners | game_type doesn't have once enabled | Add "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.