Beam v0.1.6 is out, check it out! Read more →
← All projects
Open Source · 2026 · ISC

@rbxts/wispr

@rbxts/wispr cover image
Overview

What it is

Wispr

Server-authoritative state replication system for Roblox TypeScript

Wispr provides a simple, safe, and explicit way to replicate state from server to clients using a snapshot + patch model. Built for roblox-ts.

Need help or found a bug? Join the Discord and ask:

Features

  • Server Authoritative: Only the server can mutate state, ensuring security and consistency
  • Type Safe: Full TypeScript support with type inference
  • Efficient: Snapshot + patch model minimizes bandwidth usage
  • Scoped Replication: Control which clients see which state nodes
  • Versioned Patches: Automatic handling of out-of-order patches
  • Observable: Listen to changes at specific paths or any change in a node

Installation

npm install @rbxts/wispr

Quick Start

Server Setup

import { WisprToken, createNode, patchNode, pathOf } from "@rbxts/wispr";

// Define a token for your state type
interface PlayerStats {
	gold: number;
	level: number;
	inventory: string[];
}

const PLAYER_STATS = WisprToken.create<PlayerStats>("player.stats");

// Create a node (server-only)
const node = createNode(
	PLAYER_STATS,
	{ kind: "all" }, // Scope: replicate to all clients
	{ gold: 0, level: 1, inventory: [] } // Initial state
);

// Update state with patches
patchNode(PLAYER_STATS, { type: "set", path: pathOf("gold"), value: 100 });
patchNode(PLAYER_STATS, { type: "increment", path: pathOf("level"), delta: 1 });

Client Setup

import { waitForNode, requestInitialData, pathOf } from "@rbxts/wispr";

// Initialize (call once on client startup)
await requestInitialData();

// Wait for and get the node
const node = await waitForNode(PLAYER_STATS);
const stats = node.getState();

// Listen for changes
node.listenForChange(pathOf("gold"), (newVal, oldVal) => {
	print(`Gold changed from ${oldVal} to ${newVal}`);
});

// Or listen for any change
node.listenForAnyChange(() => {
	print("Stats updated!");
});

// Listen for nodes matching a pattern (similar to ReplicaService)
import { onNodeOfClassCreated, requestInitialData } from "@rbxts/wispr";

await requestInitialData();

// Listen for any player data node (e.g., "player.data.123", "player.data.456")
onNodeOfClassCreated("player.data.", (node) => {
	print(`Player data node created: ${node.token.id}`);
	const data = node.getState();
	// Setup UI, listeners, etc. for this player's data
	node.listenForChange(pathOf("coins"), (newCoins) => {
		updateCoinsUI(newCoins as number);
	});
});

Core Concepts

Tokens

Tokens are unique identifiers for state nodes. Create them once at module level:

const PLAYER_STATS = WisprToken.create<PlayerStats>("player.stats");
const GAME_STATE = WisprToken.create<GameState>("game.state");

Paths

Paths are arrays of keys (never strings) that navigate the state tree:

pathOf("inventory", "items", "sword_001")  // ["inventory", "items", "sword_001"]
pathOf("weapons", 0, "cooldown")           // ["weapons", 0, "cooldown"]

Scopes

Control which clients receive state updates:

{ kind: "all" }                           // All clients
{ kind: "player", player: somePlayer }    // Single player
{ kind: "players", players: [p1, p2] }    // Multiple players

Patch Operations

Wispr supports various patch operations:

// Set a value
{ type: "set", path: pathOf("gold"), value: 100 }

// Delete a value
{ type: "delete", path: pathOf("oldField") }

// Increment a number
{ type: "increment", path: pathOf("level"), delta: 1 }

// Array operations
{ type: "listPush", path: pathOf("inventory"), value: "sword" }
{ type: "listInsert", path: pathOf("inventory"), index: 0, value: "potion" }
{ type: "listRemoveAt", path: pathOf("inventory"), index: 2 }

// Map operations
{ type: "mapSet", pathToMap: pathOf("players"), id: "player123", value: playerData }
{ type: "mapDelete", pathToMap: pathOf("players"), id: "player123" }

Helper Functions

Wispr provides helper functions for creating patch operations:

import { opSet, opIncrement, opListPush, opMapSet } from "@rbxts/wispr";

patchNode(PLAYER_STATS, opSet(pathOf("gold"), 100));
patchNode(PLAYER_STATS, opIncrement(pathOf("level"), 1));
patchNode(PLAYER_STATS, opListPush(pathOf("inventory"), "sword"));
patchNode(PLAYER_STATS, opMapSet(pathOf("equipment"), "weapon", swordData));

// Apply multiple operations at once
patchNodeMultiple(PLAYER_STATS, [
	opSet(pathOf("gold"), 200),
	opIncrement(pathOf("level"), 1),
]);

API Reference

Server API

// Create a new state node
createNode<T>(token: WisprToken<T>, scope: WisprScope, initialState: T): WisprServerNode<T>

// Get a node by token (server-side)
getServerNode<T>(token: WisprToken<T>): WisprServerNode<T> | undefined

// Destroy a node
destroyNode(token: WisprToken<unknown>): void

// Apply a single patch operation
patchNode(token: WisprToken<unknown>, operation: WisprPatchOp): void

// Apply multiple patch operations
patchNodeMultiple(token: WisprToken<unknown>, operations: readonly WisprPatchOp[]): void

Client API

// Request initial data (call once on startup)
requestInitialData(): Promise<void>

// Wait for a node to be created
waitForNode<T>(token: WisprToken<T>): Promise<WisprNode<T>>

// Get a node (returns undefined if not found)
getClientNode<T>(token: WisprToken<T>): WisprNode<T> | undefined

// Listen for nodes matching a token ID pattern
onNodeOfClassCreated(pattern: string, callback: (node: WisprNode) => void): () => void

WisprNode (Client)

[README truncated — view the full file on GitHub]

Install
npm install @rbxts/wispr