Quick answer: A WebRTC DataChannel that “silently” closes is almost never silent at the protocol level — your code just missed the state change because poll() was not called, or the session died due to NAT rebinding because no keepalive was sent. Call poll() every frame, send a ping every 5–10 seconds, and treat missing-pong as a failure signal.
You set up a peer-to-peer match with Godot’s WebRTCPeerConnection and WebRTCDataChannel, everything works in local testing, and even a short internet match looks fine. Then, during a five-minute session between two real players on different networks, one side stops receiving packets. No state_changed signal. No errors in the log. The channel’s get_ready_state() returns STATE_CLOSED but your code never saw it close. Let’s fix this properly.
The Symptom
Mid-session, outbound packets via channel.put_packet() succeed without error. Inbound packets stop arriving. When you finally check channel.get_ready_state(), it is STATE_CLOSED. Logs show no ice_connection_state_changed emission to "disconnected" or "failed" — or if they do, they are long before your code noticed.
The failure happens on real networks, especially when one peer is behind a consumer NAT or on mobile. Local testing on the same machine or LAN never reproduces it. Sessions that stay busy with traffic rarely fail; sessions with idle gaps (inventory screens, pause menus) fail more often.
What Causes This
1. You stopped polling. Godot’s WebRTC is cooperative: WebRTCPeerConnection.poll() drives both signal emission and packet queue processing. Some code gates polling behind “connected” state or pauses during loading screens. Any gap in polling drops state-change notifications.
2. NAT rebinding. Consumer routers age out idle UDP mappings in 30 seconds or less. If your game sends no packets during a loading screen, the router’s port forward vanishes. When traffic resumes, the router assigns a new external port, packets reach the closed old port, and the session dies.
3. ICE connection loss treated as closed. When ICE transitions to disconnected, WebRTC does not tear down the channel immediately; it waits for reconnection. If you watch only state_changed on the channel, you miss the intermediate ICE state and only see the final STATE_CLOSED far later.
4. Mobile background throttling. iOS and Android suspend UDP sockets when the app backgrounds. The OS never notifies WebRTC, so the connection appears alive but drops packets.
5. Firewall middleboxes. Corporate networks sometimes strip UDP traffic after an idle threshold or block certain STUN behaviours.
The Fix
Step 1: Poll every frame, always.
extends Node
var peer: WebRTCPeerConnection
var state_channel: WebRTCDataChannel # reliable, ordered
var input_channel: WebRTCDataChannel # unreliable, unordered
func _process(_delta: float) -> void:
# Always poll, regardless of connection state
if peer:
peer.poll()
_drain_channels()
_maybe_send_keepalive()
func _drain_channels() -> void:
for ch in [state_channel, input_channel]:
if ch == null:
continue
while ch.get_available_packet_count() > 0:
var data = ch.get_packet()
_handle_packet(data)
Step 2: Keepalive with ping/pong timeout. Use the unreliable channel. Losing one ping is fine; losing three in a row is a failure signal.
const PING_INTERVAL := 5.0 # seconds
const PONG_TIMEOUT := 15.0 # 3 missed pings
var _last_ping_sent := 0.0
var _last_pong_recv := 0.0
func _maybe_send_keepalive() -> void:
var now := Time.get_ticks_msec() / 1000.0
if now - _last_ping_sent >= PING_INTERVAL:
var packet := PackedByteArray([0x01]) # 0x01 = ping
input_channel.put_packet(packet)
_last_ping_sent = now
if _last_pong_recv > 0 and now - _last_pong_recv > PONG_TIMEOUT:
push_warning("No pong in %.1fs, treating session as dead" % (now - _last_pong_recv))
_handle_disconnect("keepalive_timeout")
Step 3: Subscribe to ICE state transitions, not just channel state. The ICE signal fires earlier and tells you about reconnect attempts.
func _setup_peer() -> void:
peer = WebRTCPeerConnection.new()
peer.initialize({
"iceServers": [
{"urls": ["stun:stun.l.google.com:19302"]},
{"urls": ["turn:turn.example.com:3478"],
"username": "guest", "credential": "guest"}
]
})
peer.ice_connection_state_changed.connect(_on_ice_state)
peer.session_description_created.connect(_on_session_created)
func _on_ice_state(state: int) -> void:
print("ICE state: ", state)
match state:
WebRTCPeerConnection.STATE_DISCONNECTED:
# try to ride it out for a few seconds
_start_reconnect_timer()
WebRTCPeerConnection.STATE_FAILED:
_handle_disconnect("ice_failed")
Step 4: Include a TURN server. Symmetric NATs cannot be traversed with STUN alone. Without a TURN relay, one in ten real-world pairs will fail to connect at all or drop mid-session.
Step 5: Handle platform pause/resume.
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_PAUSED:
push_warning("App paused; assuming session will be killed by OS")
_handle_disconnect("app_paused")
elif what == NOTIFICATION_APPLICATION_RESUMED:
_attempt_reconnect()
Why This Works
WebRTC in Godot is built on libdatachannel (or a WebAssembly shim on HTML5 exports). Both expose a polled interface: the library parses incoming SCTP packets and emits state transitions only when you call poll(). If you skip frames, you skip signals. This is why a connection that “closes silently” often did emit a close event — your code was looking elsewhere at the time.
The keepalive fixes two problems at once. First, a constant trickle of packets every 5 seconds keeps every NAT and stateful firewall along the path refreshed well inside their 30-second idle window. Second, the pong arrival gives you an application-layer liveness signal that does not depend on ICE state, so you can declare a session dead and trigger a reconnect flow before the user notices.
ICE disconnected is a transient state: the stack is trying to reconnect. If you immediately tear down on disconnected, you miss automatic recovery. Waiting for failed or the pong timeout, whichever comes first, gives better UX.
"Trust pings, not connection states. Network stacks lie; timestamps do not."
Related Issues
For general Godot networking problems, see Fix: Godot Multiplayer RPC Not Received. If your signaling server drops long-lived WebSocket connections, check Fix: Godot WebSocket Disconnects After Idle.
Poll every frame. Ping every few seconds. Nothing else saves you from real-world networks.