NetClient

A lightweight WebSocket networking library for browser games. Rooms, relays, host management, tags, metadata, and binary support โ€” all in one small file.

Zero Dependencies WebSocket ES Module Browser v1.2.0
โ–ถ  Try the Live Demo
๐Ÿšช

Room Management

Create, join, and leave rooms with max client limits and privacy controls.

๐Ÿ“ก

Relay Messaging

Broadcast JSON payloads to all players or target a specific player or the host.

๐Ÿ‘‘

Host Handoff

Automatic host reassignment when the current host disconnects.

โšก

Binary Support

Send raw ArrayBuffers for high-frequency position updates and game state.

๐Ÿท๏ธ

Tags & Metadata

Tag rooms for filtering, and attach custom metadata like lobby names.

๐Ÿ”Œ

Simple Events

Clean net.on() event API โ€” no boilerplate, just callbacks.

Installation

NetClient is a single ES module file with no dependencies. Drop it into your project and import it.

  1. Download netClient.jsCopy netClient.js into your project folder alongside your HTML file.
  2. Import it as an ES module
    your-game.js
    import NetClient from "./netClient.js";
    NoteYour script tag must use type="module" for ES module imports to work.
  3. Add type="module" to your script tag
    index.html
    <script type="module" src="./your-game.js"></script>
  4. Point to a server NetClient connects to any WebSocket server that speaks its protocol. Self-host server.js on Node.js, or deploy it for free on Render.
    Free hosting tipRender's free tier supports persistent WebSocket connections. Deploy server.js with node server.js.

Quick Start

Get two players connected and talking in under 20 lines.

game.js
import NetClient from "./netClient.js";

const net = new NetClient("wss://your-server.onrender.com", "myGame");
net.connect();

net.on("roomCreated", (roomId) => {
  console.log("Room ready:", roomId); // Share this code with others
});

net.on("relay", (fromId, payload) => {
  updatePlayer(fromId, payload);
});

net.on("error", (msg) => console.warn("NetClient error:", msg));

// Trigger room creation from user action, not automatically on connect
document.getElementById("createBtn").onclick = () => net.createRoom([], 4, false);

Constructor

NEWnew NetClient(url, gameName?)

Creates a new NetClient instance. Does not connect until connect() is called.

ParameterTypeDescription
urlstringWebSocket server URL, e.g. "wss://your-server.onrender.com"
gameNamestringOptional. Rooms are auto-tagged game:<gameName>. listRooms() filters by this automatically so players from different games never see each other's lobbies. Defaults to "defaultGame" โ€” always set this explicitly or instances sharing a server will see each other's rooms.
const net = new NetClient("wss://gamebackend-dk2p.onrender.com", "demoGame");

Instance Properties

PropertyTypeDescription
net.playerIdnumber | nullYour assigned player ID. null before assignedId fires.
net.roomIdstring | nullCurrent room's 5-character alphanumeric code (e.g. "AB3XY"), or null.
net.ownerIdnumber | nullPlayer ID of the current room host, or null.
net.isHostbooleantrue if you are the room host. Updated automatically on host handoff.

Connection

CALLnet.connect()

Opens the WebSocket connection. The connected event fires when the socket is open, followed immediately by assignedId from the server. Calling connect() while already connected is a no-op.

connect() is non-blocking โ€” do not call room methods until connected or assignedId fires.

CALLnet.disconnect()

Closes the WebSocket connection. All state (playerId, roomId, ownerId, isHost) is fully reset before disconnected fires. The server handles room cleanup and host reassignment automatically.

Implicit disconnect If the player closes the tab or navigates away without calling disconnect(), the server detects the dropped connection via its 30-second heartbeat and cleans up automatically. playerLeft fires for that player on all remaining clients.

Rooms

CALLnet.createRoom(tags?, maxClients?, isPrivate?, metaData?)

Creates a new room and makes you the host. Fires roomCreated on success. Room IDs are 5-character alphanumeric codes (e.g. "AB3XY") โ€” share this with other players so they can call joinRoom().

ParameterTypeDefaultDescription
tagsstring[][]Extra tags. game:<gameName> is always added automatically.
maxClientsnumber8Maximum number of players including the host. A room with maxClients: 4 fits 1 host + 3 clients.
isPrivatebooleanfalseIf true, adds the "private" tag and hides the room from listRooms(). Players can still join directly by room code.
metaDataobject{}Arbitrary JSON attached to the room, e.g. { name: "Bob's Lobby", map: "desert" }.
net.createRoom([], 8, false, { name: "My Lobby", map: "forest" });
CALLnet.joinRoom(roomId)

Joins an existing room by its ID. Fires roomJoined on success, or error if the room doesn't exist, is full, or has the "closed" tag. Room IDs are case-insensitive.

ParameterTypeDescription
roomIdstringThe 5-character room code. Case-insensitive.
net.joinRoom("AB3XY");
CALLnet.leaveRoom()

Leaves the current room. Fires leftRoom on this client. All other room members receive playerLeft. If you are the host, the server automatically promotes the next player.

CALLnet.listRooms(tags?)

Requests a list of public rooms. Rooms tagged "private" or "closed" are always excluded. The current gameName tag is included automatically. Fires roomList with results.

No built-in timeoutIf the server is slow or the response is lost, roomList will never fire. Register your listener before calling listRooms(), and implement a timeout in userland if needed.
ParameterTypeDescription
tagsstring[]Optional additional tags to filter by, e.g. ["ranked"].
net.on("roomList", (rooms) => {
  // each room: roomId, ownerId, playerCount, maxClients, tags, metaData
  rooms.forEach(r => console.log(r.roomId, r.playerCount + "/" + r.maxClients, r.metaData));
});
net.listRooms(); // Always register listener before calling

Messaging

CALLnet.sendRelay(payload)

Broadcasts a JSON payload to every other player in the room. The sender never receives their own message. Fires relay on all recipients.

ParameterTypeDescription
payloadobjectAny JSON-serialisable object.
net.sendRelay({ x: player.x, y: player.y, anim: "run" });
CALLnet.tellOwner(payload)

Sends a private JSON payload to the room host only. Fires tellOwner on the host. Has no effect if called by the host. Use for actions that need host validation (shooting, scoring, state changes).

net.tellOwner({ action: "shoot", targetId: 4521 });
CALLnet.tellPlayer(playerId, payload)

Sends a private JSON payload to one specific player in the room. Fires tellPlayer on the target's client only.

ParameterTypeDescription
playerIdnumberThe numeric ID of the target player.
payloadobjectAny JSON-serialisable object.
net.tellPlayer(98234, { msg: "You were hit!", damage: 25 });
CALLnet.sendBinary(buffer)

Sends a raw ArrayBuffer to all other players in the room. The sender never receives their own message. The server prepends the sender's player ID as a 4-byte big-endian uint32 before forwarding. Recipients receive the original buffer (without the prepended ID) via the binary event.

Use binary for high-frequency data (position, physics) where JSON overhead matters. Use sendRelay for infrequent game events.

// Sender
const buf = new ArrayBuffer(8);
const dv  = new DataView(buf);
dv.setFloat32(0, player.x);
dv.setFloat32(4, player.y);
net.sendBinary(buf);

// Receiver
net.on("binary", (fromId, buffer) => {
  const dv = new DataView(buffer);
  const x  = dv.getFloat32(0);
  const y  = dv.getFloat32(4);
  updatePlayerPosition(fromId, x, y);
});

Events

Register listeners with net.on(eventName, callback). Multiple listeners per event are supported and all will fire. There is no off() method โ€” listeners persist for the lifetime of the instance. Use a flag variable to gate a callback if you need to stop handling an event.

Connection Events

EventCallback ArgsDescription
connectednoneWebSocket opened successfully.
disconnectednoneWebSocket closed or lost. All state (playerId, roomId, ownerId, isHost) is fully reset before this fires.
assignedId(playerId: number)Server assigned you a unique numeric ID. Fires immediately after connected.
error(message: string)Server error. Possible values: "Already in a room", "Room does not exist", "Room full", "Room Closed".

Room Events

EventCallback ArgsDescription
roomCreated(roomId, metaData)You created a room. net.isHost is already true. Your player ID is available as net.playerId.
roomJoined(roomId, ownerId, maxClients, metaData)You joined a room. Your player ID is available as net.playerId.
leftRoom(roomId)You left via leaveRoom(). net.roomId is null after this fires.
playerJoined(playerId: number)Another player joined your room.
playerLeft(playerId: number)Another player left or disconnected. Clean up their state here.
roomList(rooms: array)Response to listRooms(). Each room: roomId, ownerId, playerCount, maxClients, tags, metaData.

Host Events

EventCallback ArgsDescription
makeHost(oldHostId: number)You were promoted to host. net.isHost is already true when this fires.
reassignedHost(newHostId, oldHostId)Fired on all non-host players when host changes. net.ownerId is already updated.
roomUpdated(metaData: object)Host called updateMeta() or setMeta(). Fires on all players. metaData is the full resulting object.
roomTagAdded(tag, tags: string[])Host added a tag. tags is the full updated array.
roomTagRemoved(tag, tags: string[])Host removed a tag. tags is the full updated array.

Message Events

EventCallback ArgsDescription
relay(fromId, payload)Another player called sendRelay().
tellOwner(fromId, payload)A player called tellOwner(). Only fires on the host's client.
tellPlayer(fromId, payload)A player called tellPlayer() targeting you specifically.
binary(fromId, buffer: ArrayBuffer)Binary data received. The sender's ID has already been stripped from the buffer.

Host Controls

These methods only take effect if you are the current room host (net.isHost === true). The server silently ignores them from non-hosts.

HOSTnet.updateMeta(metaData)

Merges new key/value pairs into the room's metadata โ€” existing keys not in the argument are preserved. Fires roomUpdated on all players with the full merged result.

// { name: "My Lobby", map: "desert" } โ†’ { name: "My Lobby", map: "forest" }
net.updateMeta({ map: "forest" });
HOSTnet.setMeta(metaData)

Fully replaces the room's metadata with the provided object โ€” all existing keys are discarded. Fires roomUpdated on all players with the new object.

Full replaceUnlike updateMeta(), setMeta() discards all previous keys. If your roomUpdated handler merges into a local copy with Object.assign, replace the local copy entirely instead.
// { name: "My Lobby", map: "desert" } โ†’ { map: "forest" }
net.setMeta({ map: "forest" });
HOSTnet.addTag(tag)

Adds a string tag to the room. Fires roomTagAdded for all players. game:* tags cannot be added this way.

net.addTag("closed");  // Lock the room โ€” no new joins allowed
net.addTag("ranked"); // Custom tag for lobby filtering
HOSTnet.removeTag(tag)

Removes a tag from the room. Fires roomTagRemoved for all players. game:* tags cannot be removed.

net.removeTag("closed"); // Re-open the room

Server Setup

The companion server.js is a Node.js WebSocket server using the ws package. It handles all room logic, host reassignment, and message routing. HTTP and WebSocket share a single port.

  1. Install dependencies
    npm install ws
  2. Start the server locally
    node server.js
    # Server listening on port 8080
    # Wake endpoint: http://localhost:8080/wake
  3. Deploy to Render (free) Fork the repo on GitHub, create a new Web Service on Render pointing at your fork, set the start command to node server.js, and deploy.
    Cold startsRender's free tier spins down after 15 minutes of inactivity. Use the wake endpoint with an uptime monitor like UptimeRobot to prevent this โ€” or expect the first connection after idle to take 30โ€“60 seconds.

Environment Variables

VariableDefaultDescription
PORT8080Port the server listens on. Shared by HTTP and WebSocket. Render sets this automatically.

Wake Endpoint

The server exposes a public HTTP endpoint at GET /wake that always returns 200 regardless of the origin whitelist. Use it to warm up the server before connecting, or as a health check.

HTTP (fetch)

index.html
<script>
  (async () => {
    try {
      const res  = await fetch("https://your-server.onrender.com/wake");
      const data = await res.json();
      console.log("[wake] Server awake at", new Date(data.timestamp).toLocaleTimeString());
    } catch (e) {
      console.warn("[wake] Server may be cold-starting...", e);
    }
  })();
</script>

Response

{ "status": "awake", "timestamp": 1234567890123 }

WebSocket

Also works over an open WebSocket connection:

ws.send(JSON.stringify({ type: "wake" }));
// โ†’ { type: "awake", timestamp: 1234567890123, playerId: 98234 }
UptimeRobot Point a free UptimeRobot monitor at https://your-server.onrender.com/wake on a 5-minute interval to keep the server alive on Render's free tier.

Origin Whitelist

By default the whitelist is empty, allowing connections from any origin. To restrict access, edit ORIGIN_WHITELIST at the top of server.js. Origins must include the protocol.

server.js
const ORIGIN_WHITELIST = [
  "https://yourgame.com",
  "http://localhost:3000",
];
ValueBehaviour
[]Allow all origins (default)
nullAllow all origins
["https://yourgame.com"]Only allow listed origins. Rejected connections receive HTTP 403.
Wake is always public GET /wake bypasses the whitelist entirely so uptime monitors and external health checks can always reach it.

Server Behaviour

Heartbeat / Timeout

The server pings all connected clients every 30 seconds using WebSocket ping frames. Clients that do not respond with a pong are terminated and removed from their room. playerLeft fires for players who drop without calling leaveRoom().

Host Reassignment

When the host disconnects or calls leaveRoom(), the server promotes the first remaining client in the room to host. The new host receives makeHost with the old host's ID. All other clients receive reassignedHost with both IDs. If no clients remain, the room is deleted.

Room Capacity

The maxClients limit includes the host. A room with maxClients: 4 supports 1 host + 3 clients. Joining a full room returns an error event with "Room full".

Closed Rooms

Adding the "closed" tag prevents new players from joining โ€” joinRoom() returns an error event with "Room Closed". It does not disconnect existing players. Remove the tag with net.removeTag("closed") to re-open the room.

Binary Protocol

When the server receives a binary (non-JSON) message, it prepends the sender's player ID as a 4-byte big-endian uint32, then forwards the combined buffer to all other clients in the room. The binary event on recipients provides the parsed fromId and the original buffer with the prepended bytes already stripped.

In-Memory State

All room and player state lives in the server process. A server restart wipes everything โ€” rooms, players, and metadata. Do not build persistence assumptions on top of room state.

Room Filtering

listRooms() excludes rooms tagged "private" or "closed", and always filters by the instance's game:<gameName> tag so results are automatically scoped to your game.

Example: Basic Room

A minimal host/join flow with a shared room code.

lobby.js
import NetClient from "./netClient.js";

const net = new NetClient("wss://your-server.onrender.com", "myGame");
net.connect();

document.getElementById("createBtn").onclick = () => {
  net.createRoom([], 8, false, { name: "My Game" });
};

net.on("roomCreated", (roomId) => {
  document.getElementById("roomCode").textContent = roomId;
});

document.getElementById("joinBtn").onclick = () => {
  const code = document.getElementById("codeInput").value.trim();
  net.joinRoom(code);
};

net.on("roomJoined", (roomId, ownerId, maxClients, metaData) => {
  console.log(`Joined room ${roomId}. Host: ${ownerId}. Lobby: "${metaData.name}"`);
});

net.on("playerJoined", (id) => console.log("Player joined:", id));
net.on("playerLeft",  (id) => console.log("Player left:",  id));
net.on("error",        (msg) => console.warn("Error:", msg));

Example: Relaying State

Broadcast your player's position every frame and sync incoming state from others. Use playerJoined to re-send your state so newcomers can see you.

sync.js
const players = {};
let myId = null;

net.on("assignedId", (id) => { myId = id; });

net.on("roomJoined", () => {
  players[myId] = { x: 100, y: 100, color: randomColor() };
  net.sendRelay(players[myId]); // Announce yourself
});

net.on("playerJoined", () => {
  net.sendRelay(players[myId]); // Re-send state so newcomer can see you
});

net.on("playerLeft", (id) => { delete players[id]; });

net.on("relay", (fromId, data) => {
  if (!players[fromId]) players[fromId] = {};
  Object.assign(players[fromId], data);
});

function loop() {
  const me = players[myId];
  if (me) {
    handleInput(me);
    net.sendRelay({ x: me.x, y: me.y });
  }
  renderAll(players);
  requestAnimationFrame(loop);
}
loop();

Example: Binary Position Updates

Use sendBinary for high-frequency position updates to reduce JSON serialisation overhead. Pack custom data layouts with DataView.

binary-sync.js
// Layout: [float32 x][float32 y][float32 rotation] = 12 bytes
function sendPosition(x, y, rotation) {
  const buf = new ArrayBuffer(12);
  const dv  = new DataView(buf);
  dv.setFloat32(0, x);
  dv.setFloat32(4, y);
  dv.setFloat32(8, rotation);
  net.sendBinary(buf);
}

net.on("binary", (fromId, buffer) => {
  const dv       = new DataView(buffer);
  const x        = dv.getFloat32(0);
  const y        = dv.getFloat32(4);
  const rotation = dv.getFloat32(8);
  updateRemotePlayer(fromId, x, y, rotation);
});

function loop() {
  handleInput(player);
  sendPosition(player.x, player.y, player.rotation);
  renderAll();
  requestAnimationFrame(loop);
}
loop();
Mix binary and JSON Use sendBinary for per-frame data like position and rotation. Use sendRelay for infrequent events like shooting, picking up items, or chat messages.

Example: Full Game Loop

A complete pattern with room locking, host authority, and clean teardown.

game.js
import NetClient from "./netClient.js";

const net = new NetClient("wss://your-server.onrender.com", "shooter");
const players = {};
let myId = null;

net.connect();
net.on("assignedId", (id) => (myId = id));

net.on("roomCreated", (roomId) => { showLobby(roomId); });
net.on("roomJoined",  (roomId) => { showLobby(roomId); });

// Host locks and starts the match
function startGame() {
  if (!net.isHost) return;
  net.addTag("closed");
  net.sendRelay({ type: "start" });
  initGame();
}

// Apply incoming state and events
net.on("relay", (fromId, data) => {
  if (data.type === "start") { initGame(); return; }
  if (data.type === "hit")   { applyHit(data.targetId, data.by); return; }
  if (!players[fromId]) players[fromId] = {};
  Object.assign(players[fromId], data);
});

// Host validates and broadcasts authoritative events
net.on("tellOwner", (fromId, data) => {
  if (!net.isHost) return;
  if (data.action === "shoot") {
    net.sendRelay({ type: "hit", targetId: data.targetId, by: fromId });
  }
});

// Handle host promotion mid-game
net.on("makeHost", () => {
  console.log("You are now the host");
});

net.on("playerLeft", (id) => { delete players[id]; });
window.addEventListener("beforeunload", () => net.disconnect());
Pattern: Host as authority Use tellOwner() for actions that need validation (shooting, scoring, state changes). Use sendRelay() for frequent position/animation state that doesn't need checking. This keeps gameplay fair without a dedicated authoritative server.

Known Limitations

No existing players on join

roomJoined gives you the host ID and room metadata, but no list of other players already in the room. The recommended pattern is to relay your state immediately on join, and re-send it when a new player joins so they can see you:

net.on("roomJoined", () => {
  net.sendRelay({ type: "hello", x: player.x, y: player.y }); // user-defined type
});
net.on("playerJoined", () => {
  net.sendRelay({ type: "hello", x: player.x, y: player.y }); // Re-send state so newcomer can see you
});
net.on("relay", (fromId, data) => {
  if (data.type === "hello") players[fromId] = { x: data.x, y: data.y };
});

This works well in practice but requires all existing players to be connected and listening. A future version may include a players array in the roomJoined payload.

No reconnection

If a player's connection drops, they receive a new playerId on reconnect and cannot reclaim their previous slot. Design your game state around players leaving and rejoining as new players.

No listRooms timeout

listRooms() fires roomList when the server responds, but there is no built-in timeout if the response is slow or lost. Always register your listener before calling listRooms(), and implement a timeout in userland if needed:

const timeout = setTimeout(() => console.warn("listRooms timed out"), 5000);
net.on("roomList", (rooms) => {
  clearTimeout(timeout);
  // handle rooms
});
net.listRooms();

No off() method

Listeners registered with net.on() persist for the lifetime of the instance. Use a flag variable to gate a callback if you need to stop handling an event.

In-memory state only

All room and player state lives in the server process. A server restart wipes everything. Do not build persistence assumptions on top of room state.

Silent send when disconnected

Calling any messaging or room method before connected fires, or after a disconnect, silently does nothing. Check the browser console for WebSocket errors if messages seem to be disappearing.