WebSocket: messages, WSS, closing and reconnect
A complete local echo client/server: UTF-8, binary messages, limits and connection lifetime.
Run echo
Add WsTextClient.cs, WsEchoServer.cs and WsEchoDemo.cs to a Reckfell 1.0.0 C# project. Create a separate Entity-root level, attach WsEchoDemo, build and press F6. Output shows sending Error.Ok, WebSocket echo: Привет 🌍 and normal close code 1000.
The demo creates a real loopback TCP/WebSocket server on a free port and a separate client in one process. No external service is used. To run an echo server alone, attach WsEchoServer to an Entity: exported Port defaults to 8767 and binds 127.0.0.1.
WsEchoDemo.cs
using Reckfell;
public partial class WsEchoDemo : Entity
{
public override void _Ready()
{
var server = new WsEchoServer { Port = 0 }; AddChild(server);
if (server.StartError != Error.Ok) return;
var client = new WsTextClient(); AddChild(client);
client.Opened += () => ReckfellApi.Print($"WebSocket send: {client.SendText("Привет 🌍")}");
client.Received += message => { ReckfellApi.Print($"WebSocket echo: {message}"); client.Close(); };
client.Finished += (code, reason) => ReckfellApi.Print($"WebSocket close: {code}, {reason}");
Error start = client.Start($"ws://127.0.0.1:{server.LocalPort}/echo");
if (start != Error.Ok) ReckfellApi.Print($"WebSocket start: {start}");
}
}WsTextClient.cs
using System;
using System.Diagnostics;
using System.Text;
using Reckfell;
public partial class WsTextClient : Entity
{
public const int MaxMessageBytes = 1024;
public double TimeoutSeconds { get; set; } = 3;
public bool Busy => _socket is not null;
public bool IsOpen => _socket?.GetReadyState() == WebSocketPeer.State.Open && !_closing;
public string LastMessage { get; private set; } = "";
public int ReceivedCount { get; private set; }
public int CloseCode { get; private set; } = -1;
public string CloseReason { get; private set; } = "";
public TlsOptions? Trust { get; set; }
public event Action? Opened;
public event Action<string>? Received;
public event Action<int, string>? Finished;
private WebSocketPeer? _socket;
private readonly Stopwatch _wait = new();
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
private bool _opened, _closing;
public Error Start(string url)
{
if (Busy) return Error.Busy;
if (!IsInsideTree()) return Error.Unconfigured;
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) ||
(uri.Scheme != "ws" && uri.Scheme != "wss") || uri.UserInfo.Length != 0 ||
!double.IsFinite(TimeoutSeconds) || TimeoutSeconds <= 0) return Error.InvalidParameter;
_socket = new WebSocketPeer { InboundBufferSize = 65536, OutboundBufferSize = 65536,
MaxQueuedPackets = 32, SupportedProtocols = ["reckfell.echo.v1"] };
_opened = _closing = false; ReceivedCount = 0; LastMessage = ""; CloseCode = -1; CloseReason = "";
Error error = _socket.ConnectToUrl(url, uri.Scheme == "wss" ? Trust ?? TlsOptions.Client() : null);
if (error != Error.Ok) { Release(); return error; }
_wait.Restart(); return Error.Ok;
}
public Error SendText(string message)
{
if (!IsOpen) return Error.Unconfigured;
try { if (StrictUtf8.GetByteCount(message) > MaxMessageBytes) return Error.InvalidData; }
catch (EncoderFallbackException) { return Error.InvalidData; }
return _socket!.SendText(message);
}
public void Close() => BeginClose(1000, "done");
private void BeginClose(int code, string reason)
{
if (_socket is null || _closing) return;
_closing = true; _socket.Close(code, reason); _wait.Restart();
}
public override void _Process(double delta)
{
if (_socket is null) return;
_socket.Poll();
var state = _socket.GetReadyState();
if (state == WebSocketPeer.State.Open && !_opened)
{ _opened = true; Opened?.Invoke(); if (_socket is null) return; }
if (state == WebSocketPeer.State.Open && !_closing)
for (int i = 0; i < 8 && !_closing && _socket.GetAvailablePacketCount() > 0; i++)
{
byte[] bytes = _socket.GetPacket();
if (_socket.GetPacketError() != Error.Ok) { BeginClose(1011, "read-error"); break; }
if (!_socket.WasStringPacket()) { BeginClose(1003, "text-required"); break; }
if (bytes.Length > MaxMessageBytes) { BeginClose(1009, "message-too-large"); break; }
try { LastMessage = StrictUtf8.GetString(bytes); }
catch (DecoderFallbackException) { BeginClose(1007, "invalid-utf8"); break; }
ReceivedCount++; Received?.Invoke(LastMessage); if (_socket is null) return;
}
if (_socket.GetReadyState() == WebSocketPeer.State.Closed)
{ Finish(_socket.GetCloseCode(), _socket.GetCloseReason()); return; }
if ((!_opened || _closing) && _wait.Elapsed.TotalSeconds >= TimeoutSeconds) Finish(-1, "timeout");
}
private void Finish(int code, string reason)
{ CloseCode = code; CloseReason = reason; Release(); Finished?.Invoke(code, reason); }
private void Release() { _socket?.Dispose(); _socket = null; _wait.Stop(); }
public override void _ExitTree() => Release();
}WsEchoServer.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Reckfell;
public partial class WsEchoServer : Entity
{
private sealed class Connection(StreamPeerTcp stream, WebSocketPeer socket)
{
public readonly StreamPeerTcp Stream = stream;
public readonly WebSocketPeer Socket = socket;
public readonly Stopwatch Wait = Stopwatch.StartNew();
public WebSocketPeer.State Previous = WebSocketPeer.State.Connecting;
}
[Export] public int Port { get; set; } = 8767;
public int LocalPort => _listener?.IsListening() == true ? _listener.GetLocalPort() : 0;
public int ClientCount => _connections.Count;
public int EchoedMessages { get; private set; }
public Error StartError { get; private set; } = Error.Unconfigured;
private TcpServer? _listener;
private readonly List<Connection> _connections = new();
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
public override void _Ready()
{
if (Port < 0 || Port > 65535) { StartError = Error.InvalidParameter; return; }
_listener = new TcpServer(); StartError = _listener.Listen((ushort)Port, "127.0.0.1");
if (StartError != Error.Ok) ReckfellApi.Print($"WebSocket listen: {StartError}");
}
public override void _Process(double delta)
{
if (_listener?.IsListening() != true) return;
for (int i = 0; i < 4 && _listener.IsConnectionAvailable(); i++)
{
StreamPeerTcp stream = _listener.TakeConnection();
if (_connections.Count >= 4) { stream.DisconnectFromHost(); stream.Dispose(); continue; }
var socket = new WebSocketPeer { InboundBufferSize = 65536, OutboundBufferSize = 65536,
MaxQueuedPackets = 32, SupportedProtocols = ["reckfell.echo.v1"] };
if (socket.AcceptStream(stream) != Error.Ok) { socket.Dispose(); stream.DisconnectFromHost(); stream.Dispose(); continue; }
_connections.Add(new Connection(stream, socket));
}
for (int i = _connections.Count - 1; i >= 0; i--)
{
Connection connection = _connections[i]; WebSocketPeer socket = connection.Socket; socket.Poll();
var state = socket.GetReadyState();
if (state != connection.Previous) connection.Wait.Restart();
connection.Previous = state;
if (state == WebSocketPeer.State.Closed || (state != WebSocketPeer.State.Open && connection.Wait.Elapsed.TotalSeconds > 3))
{ Remove(i); continue; }
if (state != WebSocketPeer.State.Open) continue;
for (int n = 0; n < 8 && socket.GetAvailablePacketCount() > 0; n++)
{
byte[] bytes = socket.GetPacket();
if (socket.GetPacketError() != Error.Ok) { socket.Close(1011, "read-error"); break; }
if (bytes.Length > WsTextClient.MaxMessageBytes) { socket.Close(1009, "message-too-large"); break; }
Error error;
if (socket.WasStringPacket())
{
try { error = socket.SendText(StrictUtf8.GetString(bytes)); }
catch (DecoderFallbackException) { socket.Close(1007, "invalid-utf8"); break; }
}
else error = socket.Send(bytes, WebSocketPeer.WriteMode.Binary);
if (error != Error.Ok) { socket.Close(1011, "send-error"); break; }
EchoedMessages++;
}
}
}
private void Remove(int index)
{
Connection connection = _connections[index]; connection.Socket.Dispose();
connection.Stream.DisconnectFromHost(); connection.Stream.Dispose(); _connections.RemoveAt(index);
}
public void Stop()
{ _listener?.Stop(); _listener?.Dispose(); _listener = null; for (int i = _connections.Count - 1; i >= 0; i--) Remove(i); }
public override void _ExitTree() => Stop();
}Connection and messages
ConnectToUrl returns a startup Error. After Error.Ok, keep calling Poll and check GetReadyState; the handshake has not necessarily succeeded. States are Connecting, Open, Closing and Closed. WsTextClient polls in _Process and emits Opened after Open.
The server accepts TCP through TcpServer and passes the stream to WebSocketPeer.AcceptStream. Both sides negotiate the demonstration subprotocol reckfell.echo.v1. Agree SupportedProtocols and message format with your own server; that name is not a universal game protocol.
WsTextClient accepts only UTF-8 text. SendText rejects sending before opening, while closing, invalid strings and payloads above 1024 bytes. These are bytes, not displayed characters. Read packets with GetPacket, then check GetPacketError and WasStringPacket. Binary data does not automatically become text.
The echo server preserves type: SendText returns text; Send with WriteMode.Binary returns binary data. A separate WebSocketPeer client tests binary roundtrip. For game JSON, parse and validate a schema first; WebSocket does not validate field values.
Closing and restarting
Close initiates normal closing rather than immediately destroying a connection. Continue Poll until Closed or your deadline. Code 1000 denotes normal closing; an abrupt loss without that code should not be displayed as successful completion.
Finished occurs after Closed or timeout in this example. Start may then be reused. Concurrent Start returns Error.Busy. _ExitTree releases the connection when its owner is removed without promising Finished; a deleted screen must not depend on that signal for UI cleanup.
Do not reconnect every frame. Define bounded attempts and delay. Repeat required authentication/subscription after reconnecting; ConnectToUrl alone does not restore previous subscriptions or confirmed state.
Limits and WSS
The server retains at most four connections, accepts at most four new ones per frame and processes up to eight messages per connection per frame. Buffers are bounded to 65536 bytes and 32 incoming packets; an application message is limited to 1024 bytes. An oversized message closes with 1009. This local fixture is not production authentication, rate limiting or public-service abuse protection.
TimeoutSeconds bounds opening and closing waits. Stopwatch measures real elapsed time, but _Process checks it; owner pause/ProcessMode affects continued processing.
Use wss://... for WSS; the client selects verified TlsOptions.Client. For a private CA, load TlsTrust from the HTTPS guide and assign Trust before Start. A TLS server needs a certificate and matching private key; AcceptStream receives an already established TLS stream. Ordinary WsEchoServer above serves WS: changing the URL letters to wss does not add server TLS.
Tests cover text/binary bytes, subprotocol, message bounds, normal closing, abrupt server loss, restarting and deadline. A real TLS stream with a trusted test certificate and matching IP SAN verifies WSS. This does not cover every WSS refusal, proxy, NAT, browser restriction or other-device export.