playsocketjs
v5.2.1
Published
WebSocket wrapper for creating simple multiplayer systems with ease.
Maintainers
Readme
PlaySocket
An optimistic-first WebSocket synchronization library. Built for creating multiplayer games & collaborative experiences with reactive web frameworks.
For an introduction to PlaySocket, visit the PlaySocket website.
Why use PlaySocket?
PlaySocket makes developing shared experiences a breeze:
- Optimistic-first: Updates apply locally right away and merge conflict-free across clients. Calling
updateStorage()triggers thestorageUpdatedevent instantly, without waiting for a server roundtrip. - Fast prototyping: No backend code beyond init is required, though server-authoritative validation and behavior are supported for production apps.
- Built for reactivity: Assign the synced storage to a reactive variable via a callback, ideal for React, Svelte & co.
- Resilient & secure: Automatic reconnection handling & strict rate-limiting.
- Lightweight: Uses WebSockets, has few dependencies, and utilizes
MessagePackfor maximum efficiency.
Installation
Install PlaySocket with your package manager of choice to get started.
npm install playsocketjsPlaySocket Client
The client-side part of PlaySocket.
Examples
[!NOTE] In production, you should always try...catch promises such as
socket.init()as they can reject. These examples omit the error handling to keep them simple.
Initializing the client:
import PlaySocket from "playsocketjs";
// Create a new instance
// Omit the ID to let the server pick one
const socket = new PlaySocket("unique-client-id", {
endpoint: "wss://example.com/socket"
});
// Set up the event handlers you need
socket.onEvent("status", status => console.log(status));
socket.onEvent("storageUpdated", storage => { /* ... */ });
const clientId = await socket.init(); // ConnectCreating a room:
// Create a new room
const roomId = await socket.createRoom();// Optionally, with initial storage
const roomId = await socket.createRoom({
players: [{ name: "Player-1", level: 35 }],
latestPlayer: null,
});Joining a room:
await socket.joinRoom("room-id"); // Join an existing roomLeaving a room:
socket.destroy(); // Destroy the instance to leaveUsing the storage update event with reactivity:
// Assign to useState(), $state(), reactive() etc. on update
const [reactiveVar, setReactiveVar] = useState({});
socket.onEvent("storageUpdated", storage => {
setReactiveVar(storage);
});<p>Players: {reactiveVar.players?.join(", ")}</p>Interfacing with the synchronized storage:
const currentState = socket.storage; // Read-only access
socket.updateStorage("players", "array-add-unique", { username: "Player4", level: 2 });
socket.updateStorage("latestPlayer", "set", "Player4");
socket.updateStorage("playerInfo", "set", { date: "22-6-2026" });
socket.updateStorage("playerInfo", "object-set-key", "color", "red")
console.log(socket.storage.players); // Log players arraySometimes it's convenient to send a traditional request to the server. For example, when you want to opt out of optimistic updates for asynchronous work, or when the validation logic would be too complex otherwise:
await socket.sendRequest("my-request-name", { fact: "You can build traditional client-server logic like this." })API
Constructor
Create a new PlaySocket instance with a specified ID and configuration options.
The ID can be set to null to let the server pick a unique one.
new PlaySocket(id?: string, options: PlaySocketOptions)| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| endpoint | string | Yes | undefined | WebSocket server endpoint (e.g., wss://example.com/socket). |
| customData | object | No | {} | Arbitrary data to pass to the "clientRegistered" server event. |
| debug | boolean | No | false | Set to true to enable extra logging. |
Methods
| Name | Parameters | Return type | Description |
|--------|------------|-------------|-------------|
| init() | - | Promise<string> | Initialize the WebSocket connection, resolves with the client ID. |
| createRoom() | initialStorage?: object, size?: number | Promise<string> | Create a new room, resolves with the room ID. Max. 500 participants. |
| joinRoom() | roomId: string | Promise<void> | Join an existing room. |
| destroy() | reason?: string | void | Leave room, close the connection, and destroy the instance. |
| updateStorage() | key: string, type: string, value: any, secondValue?: any | void | Update a key in the shared storage. |
| sendRequest() | name: string, data?: any | Promise<void> | Send a request to the server with optional attached data. |
| onEvent() | event: string, callback: Function | () => void | Register an event callback. Returns unsubscribe function. |
Events
| Event | Callback parameter | Description |
|-------|-------------------|-------------|
| status | status: string | Connection or room status changed, e.g. display in UI during joining or room creation. |
| moved | roomId: string | Moved to different room. |
| instanceDestroyed | reason: string | Instance destroyed through destroy() or error. |
| storageUpdated | storage: object | Storage state changed. Does not trigger on no-op updates. |
| hostMigrated | hostId: string | Host was changed. |
| clientJoined | clientId: string | New client joined the room. |
| clientLeft | clientId: string | A client left the room. |
Properties
| Property | Type | Description |
|----------|------|-------------|
| id | string | Client's unique ID. |
| isHost | boolean | Whether this client is currently assigned the host role. |
| participantCount | number | Number of active client connections in room. |
| storage | object | Retrieve the storage object. |
PlaySocket Server
The server-side part of PlaySocket.
Examples
Using PlaySocket as a standalone server:
import PlaySocketServer from "playsocketjs/server";
// Path defaults to "/"
const server = new PlaySocketServer();
function shutdown() {
server.stop(); // Gracefully disconnect all clients
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);Using PlaySocket together with Express.js (or other backend frameworks):
import express from "express";
import http from "node:http";
import PlaySocketServer from "playsocketjs/server";
const app = express();
const httpServer = http.createServer(app);
// Create PlaySocket server with existing HTTP server
const playSocketServer = new PlaySocketServer({
server: httpServer,
path: "/socket"
});
// Start the server
httpServer.listen(3000, () => {
console.log("Server running on port 3000.");
});
function shutdown() {
playSocketServer.stop(); // Gracefully disconnect clients
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);Validating an incoming storage update:
const server = new PlaySocketServer();
server.onEvent("storageUpdateRequested", ({ roomId, clientId, update, storage }) => {
const { key, type, value, secondValue } = server.getUpdateDetails(update);
// Block updates on all keys except for "players" and "chats"
if (!["players", "chats"].includes(key)) return false;
if (key === "chats") {
if (type !== "array-add") return false; // Only allow adding chats
if (typeof value !== "string") return false; // Only allow strings
}
});Handling an incoming request:
const server = new PlaySocketServer();
server.onEvent("requestReceived", async ({ roomId, clientId, name, data }) => {
if (name === "add-player-request") {
const players = server.getRoomStorage(roomId)?.players || [];
// Returning false or string rejects sendRequest() on the client
if (players.find(p => p.id === clientId)) return "Player already added";
server.updateRoomStorage(roomId, "players", "array-add", { id: clientId, timestamp: Date.now() });
}
});Combining PlaySocket with a custom authentication system:
const authedClients = [];
const server = new PlaySocketServer();
server.onEvent("clientRegistrationRequested", async (clientId, data) => {
try {
// Your custom auth logic...
// For example, data could contain a token
authedClients.push(clientId);
} catch (error) {
return "Error occurred during auth"; // Blocks the registration
}
});
server.onEvent("clientDisconnected", async (clientId) => {
const removeIndex = authedClients.indexOf(clientId);
if (removeIndex !== -1) authedClients.splice(removeIndex, 1);
});
API
Constructor
Create a new PlaySocket Server instance with configuration options.
new PlaySocketServer(options?: PlaySocketServerOptions)| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| port | number | No | 3000 | Port to listen on (used only if no server provided). |
| path | string | No | "/" | WebSocket endpoint path. |
| server | http.Server | No | - | Existing http server. |
| rateLimit | number | No | 20 | Messages/second rate limit. |
| debug | boolean | No | false | Enable debug logging. |
| verifyClient | function | No | - | Callback to verify connections before WebSocket upgrade. |
The verifyClient option allows you to implement custom connection verification logic, such as rate limiting, before the WebSocket handshake completes.
const server = new PlaySocketServer({
server: httpServer,
path: "/socket",
verifyClient: (info, callback) => {
const forwarded = info.req.headers["x-forwarded-for"];
const ip = forwarded ? forwarded.split(",")[0].trim() : info.req.socket.remoteAddress;
if (isRateLimited(ip)) {
return callback(false, 429, "Too Many Requests");
}
callback(true);
}
});The callback signature is callback(verified, code?, message?) where code refers to an HTTP status code and message to a rejection reason.
Methods
[!IMPORTANT] Rooms created by the server are always owned by the host ID "server". Such rooms are not auto-deleted when all participants have left, so you need to take care of their lifecycle.
| Name | Parameters | Return type | Description |
|--------|------------|-------------|-------------|
| stop() | - | void | Closes active client connections and the underlying http server (if it's standalone). |
| kick() | clientId: string, reason?: string | void | Kick a client by their client ID. |
| move() | clientId: string, roomId: string | void | Move a client that is already in a room to a different room. |
| onEvent() | event: string, callback: Function | () => void | Register a server-side event callback. Returns unsubscribe function. |
| getRoomStorage() | roomId: string, options?: { dangerouslySkipCloning?: boolean } | object | Get a snapshot of the current room storage. Pass dangerouslySkipCloning: true to get the uncloned store for performance reasons. |
| getUpdateDetails() | update: object | object | Get the details (key, type, value and secondValue) of a storage update for building validation logic. |
| updateRoomStorage() | roomId: string, key: string, type: string, value: any, secondValue?: any | void | Update a key in the shared storage of a room. |
| createRoom() | initialStorage?: object, size?: number | object | Create a server-owned room (returns object containing room ID and state).|
| destroyRoom() | roomId: string | void | Destroy a room & kick all participants. |
Events
| Event | Callback parameters | Description | Return for action |
|-------|-------------------|-------------|--------------|
| clientRegistered | clientId: string, customData: object | Client registered with the server. | - |
| clientRegistrationRequested | clientId: string, customData: object | Client requested to register. | Return false or rejection reason string to block. |
| clientDisconnected | clientId: string | Client disconnected. | - |
| clientJoinedRoom | clientId: string, roomId: string | Client joined a room. | - |
| clientJoinRequested | clientId: string, roomId: string | Client requested to join a room. | Return false or rejection reason string to block. |
| clientLeftRoom | clientId: string, roomId: string | Client left a room. | - |
| roomCreated | roomId: string | Room was created. | - |
| roomDestroyed | roomId: string | Room was destroyed. | - |
| roomCreationRequested | {clientId: string, initialStorage: object} | Client requested to create room. | Return object to override initial storage, false or rejection reason string to block. |
| storageUpdated | {clientId: string, roomId: string, update: object, storage: object} | Room storage updated. | - |
| storageUpdateRequested | {clientId: string, roomId: string, update: object, storage: object} | Client requested storage update. | Return false or rejection reason string to block the update. Callback must be synchronous. |
| requestReceived | {clientId: string, roomId?: string, name: string, data?: any} | Request from client. | Return false or rejection reason string to block. |
When multiple callbacks are registered for an event, all of them run, and for events that respond to a return value, the first non-null return value is used. A callback that throws counts as returning false.
Properties
| Property | Type | Description |
|----------|------|-------------|
| rooms | object | Retrieve the rooms object. |
Storage
Both updateStorage() and updateRoomStorage() work the same way. The only difference is that the latter takes roomId as the first argument and runs on the server. There's a limit of 100 storage keys.
Number, array and object operation types allow for conflict-free simultaneous updates. The set operation just replaces the property and ensures correct ordering.
For -matching operation types, value becomes the value to match, and secondValue the replacement. For object operations, value is the property key, and secondValue the property value.
Operation types
setnumber-incrementarray-addarray-add-uniquearray-update-matchingarray-remove-matchingobject-set-keyobject-remove-key
Example for each type:
updateStorage("color", "set", "blue")updateStorage("score", "number-increment", 25)updateStorage("players", "array-add", { name: "Player2" })updateStorage("completedLevels", "array-add-unique", 14)updateStorage("names", "array-update-matching", "Leo_cool", "TheCoolerLeo")updateStorage("missingLevels", "array-remove-matching", 14)updateStorage("levelNames", "object-set-key", "evilSea", "Evil sea")updateStorage("levelNames", "object-remove-key", "darkOcean")
License
MIT
