NetClient
A lightweight WebSocket networking library for browser games. Rooms, relays, host management, tags, metadata, and binary support โ all in one small file.
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.
-
Download netClient.jsCopy
netClient.jsinto your project folder alongside your HTML file. -
Import it as an ES moduleyour-game.js
import NetClient from "./netClient.js";NoteYour script tag must usetype="module"for ES module imports to work. -
Add
type="module"to your script tagindex.html<script type="module" src="./your-game.js"></script> -
Point to a server NetClient connects to any WebSocket server that speaks its protocol. Self-host
server.json Node.js, or deploy it for free on Render.Free hosting tipRender's free tier supports persistent WebSocket connections. Deployserver.jswithnode server.js.
Quick Start
Get two players connected and talking in under 20 lines.
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
Creates a new NetClient instance. Does not connect until connect() is called.
| Parameter | Type | Description |
|---|---|---|
| url | string | WebSocket server URL, e.g. "wss://your-server.onrender.com" |
| gameName | string | Optional. 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
| Property | Type | Description |
|---|---|---|
| net.playerId | number | null | Your assigned player ID. null before assignedId fires. |
| net.roomId | string | null | Current room's 5-character alphanumeric code (e.g. "AB3XY"), or null. |
| net.ownerId | number | null | Player ID of the current room host, or null. |
| net.isHost | boolean | true if you are the room host. Updated automatically on host handoff. |
Connection
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.
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.
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
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().
| Parameter | Type | Default | Description |
|---|---|---|---|
| tags | string[] | [] | Extra tags. game:<gameName> is always added automatically. |
| maxClients | number | 8 | Maximum number of players including the host. A room with maxClients: 4 fits 1 host + 3 clients. |
| isPrivate | boolean | false | If true, adds the "private" tag and hides the room from listRooms(). Players can still join directly by room code. |
| metaData | object | {} | Arbitrary JSON attached to the room, e.g. { name: "Bob's Lobby", map: "desert" }. |
net.createRoom([], 8, false, { name: "My Lobby", map: "forest" });
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.
| Parameter | Type | Description |
|---|---|---|
| roomId | string | The 5-character room code. Case-insensitive. |
net.joinRoom("AB3XY");
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.
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.
roomList will never fire. Register your listener before calling listRooms(), and implement a timeout in userland if needed.| Parameter | Type | Description |
|---|---|---|
| tags | string[] | 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
Broadcasts a JSON payload to every other player in the room. The sender never receives their own message. Fires relay on all recipients.
| Parameter | Type | Description |
|---|---|---|
| payload | object | Any JSON-serialisable object. |
net.sendRelay({ x: player.x, y: player.y, anim: "run" });
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 });
Sends a private JSON payload to one specific player in the room. Fires tellPlayer on the target's client only.
| Parameter | Type | Description |
|---|---|---|
| playerId | number | The numeric ID of the target player. |
| payload | object | Any JSON-serialisable object. |
net.tellPlayer(98234, { msg: "You were hit!", damage: 25 });
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
| Event | Callback Args | Description |
|---|---|---|
| connected | none | WebSocket opened successfully. |
| disconnected | none | WebSocket 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
| Event | Callback Args | Description |
|---|---|---|
| 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
| Event | Callback Args | Description |
|---|---|---|
| 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
| Event | Callback Args | Description |
|---|---|---|
| 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.
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" });
Fully replaces the room's metadata with the provided object โ all existing keys are discarded. Fires roomUpdated on all players with the new object.
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" });
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
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.
-
Install dependencies
npm install ws -
Start the server locally
node server.js # Server listening on port 8080 # Wake endpoint: http://localhost:8080/wake -
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
| Variable | Default | Description |
|---|---|---|
| PORT | 8080 | Port 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)
<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 }
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.
const ORIGIN_WHITELIST = [
"https://yourgame.com",
"http://localhost:3000",
];
| Value | Behaviour |
|---|---|
| [] | Allow all origins (default) |
| null | Allow all origins |
| ["https://yourgame.com"] | Only allow listed origins. Rejected connections receive HTTP 403. |
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.
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.
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.
// 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();
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.
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());
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.