Clay Network Plugin
P2P networking for Clayground applications using WebRTC. One node hosts, others join via a short code. No dedicated server required.
Getting Started
import Clayground.Network
Network {
id: network
maxNodes: 4
onNetworkCreated: (code) => console.log("Share this code:", code)
onNodeJoined: (nodeId) => console.log("Joined:", nodeId)
onMessageReceived: (from, data) => console.log(data)
onErrorOccurred: (msg) => console.log("Error:", msg)
}
// Host
Button { text: "Host"; onClicked: network.host() }
// Join
Button { text: "Join"; onClicked: network.join(codeInput.text) }
Network Component
Configuration Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| topology | enum | Star | Star (host relays) or Mesh (direct) |
| signalingMode | enum | Cloud | Cloud (PeerJS) or Local (LAN) |
| maxNodes | int | 8 | Max nodes (2-8) |
| autoRelay | bool | true | Host auto-relays in Star topology |
| iceServers | var | [] | Custom STUN/TURN servers |
| verbose | bool | false | Enable diagnostics and latency monitoring |
| connectionTimeout | int | 15000 | Connection timeout in ms (0 to disable) |
Read-only State
| Property | Type | Description |
|----------|------|-------------|
| networkId | string | Network code (share with others to join) |
| nodeId | string | This node's unique ID |
| isHost | bool | True if this node is the host |
| connected | bool | True when connected |
| status | enum | Disconnected, Connecting, Connected, Error |
| nodeCount | int | Number of nodes in the network |
| nodes | list | List of node IDs |
| connectionPhase | string | Current phase: "signaling", "ice", "datachannel" |
| phaseTiming | var | { signaling, ice, datachannel, total } in ms |
| latency | int | Best RTT across peers in ms (-1 if unknown) |
| peerStats | var | Per-peer stats (when verbose) |
Signals
| Signal | Description |
|--------|-------------|
| networkCreated(networkId) | Host created network successfully |
| nodeJoined(nodeId) | A node joined the network |
| nodeLeft(nodeId) | A node left the network |
| messageReceived(fromId, data) | Reliable message received |
| stateReceived(fromId, data) | State update received |
| errorOccurred(message) | Connection error |
| diagnosticMessage(phase, detail) | Diagnostic info (when verbose) |
| connectionTimedOut() | Connection attempt timed out |
Methods
| Method | Description |
|--------|-------------|
| host() | Create a network and become host |
| join(networkId) | Join using a network code |
| leave() | Disconnect from the network |
| broadcast(data) | Send reliable message to all nodes |
| broadcastState(data) | Send state update (high-frequency) |
| sendTo(nodeId, data) | Send to a specific node |
ICE Server Configuration
By default, Clayground uses Google's public STUN servers. For connections across restrictive NATs (symmetric NAT, carrier-grade NAT), add TURN servers:
Network {
iceServers: [
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
{ urls: "turn:relay.example.com:3478", username: "user", credential: "pass" }
]
}
Verbose Mode & Diagnostics
Enable verbose: true to get connection diagnostics and latency monitoring:
Network {
verbose: true
onDiagnosticMessage: (phase, detail) => {
console.log("[" + phase + "] " + detail)
}
}
This enables:
Phase tracking: connectionPhase shows "signaling", "ice", or "datachannel"
Phase timing: phaseTiming breaks down time spent in each phase
ICE candidate reporting: Shows which candidate types were discovered (host/srflx/relay)
Latency monitoring: latency updated every 2s via ping/pong
Per-peer stats: peerStats with latency, message counts, byte counts
How It Works
Connection Flow
1. Signaling - Peers discover each other via a signaling server (PeerJS cloud or LAN embedded server). Signaling is only for discovery; after connection, all data flows P2P.
2. ICE Negotiation - Peers negotiate the best connection path using ICE (Interactive Connectivity Establishment). Three candidate types:
- host - Direct LAN connection (fastest, same network)
- srflx (server reflexive) - Via STUN, discovers public IP/port. Works through most home routers.
- relay - Via TURN, relays traffic through a server. Works through restrictive NATs but adds latency.
3. Data Channel - Once ICE completes, a WebRTC data channel opens for reliable, encrypted communication.
Why Connections Sometimes Fail
When both peers are behind restrictive NATs (symmetric NAT, carrier-grade NAT), STUN alone can't establish a direct connection. STUN only discovers public IP/port, but symmetric NATs assign different ports per destination. A TURN relay server solves this by acting as a middle point.
This also explains asymmetric connectivity: it can work in one direction but not the other, because one peer may have a permissive NAT while the other has a restrictive one.
External Resources
[WebRTC overview](https://webrtc.org/)
[ICE, STUN, TURN explained (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Protocols)
[NAT traversal deep dive (Tailscale)](https://tailscale.com/blog/how-nat-traversal-works)
[coturn TURN server](https://github.com/coturn/coturn) - self-hosted TURN
[Open Relay Project](https://www.metered.ca/tools/openrelay/) - free TURN servers
Network Topologies
| Topology | Description | Best For |
|----------|-------------|----------|
| Star | All nodes connect to host. Host relays messages. | Competitive games, authoritative logic |
| Mesh | All nodes connect to each other directly. | Cooperative games, lower latency |
Message Types
| Method | Signal | Channel | Use Case |
|--------|--------|---------|----------|
| broadcast(data) | messageReceived | reliable, ordered | Chat, game events, level changes |
| broadcastState(data) | stateReceived | unordered, no retransmit, seq-guarded | Entity positions, real-time updates |
| sendTo(nodeId, data) | messageReceived | reliable, ordered | Direct messages to specific node |
State updates travel over a dedicated lossy data channel: lost packets are
never retransmitted and each update carries a per-sender sequence number, so
receivers drop stale data instead of applying it late. In Star topology the
host relays state between joiners and propagates the roster, so nodes and
nodeJoined/nodeLeft cover all participants on every node.
Multiplayer Helpers
StateInterpolator - snapshot-buffer interpolation for remote
entities (render a constant delayMs in the past, blend between states,
bounded extrapolation). Use this instead of Behavior animations.
NetworkMonitor - drop-in overlay showing per-node RTT, incoming
state rate, state age and stale-drop counts (network.syncStats /
network.peerStats / network.stateAgeMs(id) for programmatic access).
See the [Multiplayer Games guide](https://misterGC.github.io/clayground/docs/manual/multiplayer/)
for the full set of patterns (channel choice, tick rates, host authority,
shared seeds).
Signaling Modes
| Mode | Transport | Cross-Platform | Requires Internet |
|------|-----------|----------------|-------------------|
| Cloud | PeerJS server | Yes (Browser + Desktop + Mobile) | No, when using clay-dev-server as local signaling relay |
| Local | Embedded WS server | Desktop/Mobile only | No |
clay-dev-server includes a built-in PeerJS signaling relay (wss://:/peerjs), so Cloud mode works entirely offline on a LAN. The PeerJS library is vendored locally (no CDN needed) and peer IDs are generated client-side (no cloud /id endpoint needed). This enables browser-based P2P networking without any internet dependency. Install the signaling extra with: pip install clay_dev_server[signaling]
LAN codes are auto-detected: if a join code starts with 'L' and contains '-', it's treated as a LAN code.
Platform Support
| Platform | P2P (Network) | HTTP Client |
|----------|---------------|-------------|
| Desktop (Linux, macOS, Windows) | WebRTC via libdatachannel | ClayHttpClient |
| WebAssembly (Browser) | WebRTC via PeerJS | ClayHttpClient |
| Mobile (iOS, Android) | WebRTC via libdatachannel | ClayHttpClient |
ClayHttpClient
Declarative HTTP API client with auto-generated methods.
ClayHttpClient {
id: api
baseUrl: "https://api.example.com"
endpoints: {
"getUser": "GET users/{userId}",
"createPost": "POST posts {postData}"
}
bearerToken: "your-api-token"
onReply: (requestId, code, response) => console.log(JSON.parse(response))
onError: (requestId, code, error) => console.error(error)
Component.onCompleted: api.getUser(123)
}
Authentication Options
bearerToken: "your-token-here" // Direct token
bearerToken: "env:API_TOKEN" // From environment variable
bearerToken: "file:///path/to/token.txt" // From file