@rbxts/wispr
v1.0.39
Published
Server-authoritative state replication system for Roblox TypeScript
Maintainers
Readme
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/wisprQuick 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 playersPatch 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[]): voidClient 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): () => voidWisprNode (Client)
// Get current state (deep copy)
getState(): T
// Get value at a specific path
getValue(path: WisprPath): unknown
// Get current version
getVersion(): number
// Listen for changes at a specific path
listenForChange(path: WisprPath, callback: (newVal: unknown, oldVal: unknown) => void): () => void
// Listen for any change in the node
listenForAnyChange(callback: () => void): () => void
// Listen for raw patches (dev/debug)
listenForRawPatch(callback: (patch: WisprPatch) => void): () => void
// Destroy the node and clean up listeners
destroy(): voidArchitecture
Wispr uses a snapshot + patch model:
- Initial State: When a node is created, clients receive a complete snapshot
- Updates: Subsequent changes are sent as small, explicit patch operations
- Versioning: Each patch has a version number; out-of-order patches are ignored
- Scoping: The server determines which clients receive which updates based on scope
This design ensures:
- Efficiency: Only changes are transmitted, not full state
- Security: Server is the single source of truth
- Reliability: Version numbers handle network issues gracefully
Example: Player Inventory
// Server
import { Players } from "@rbxts/services";
import { WisprToken, createNode, patchNode, pathOf, opIncrement, opListPush } from "@rbxts/wispr";
interface Inventory {
gold: number;
items: string[];
equipment: Record<string, EquipmentData>;
}
// Create a token factory for per-player inventories
function getPlayerInventoryToken(player: Player): WisprToken<Inventory> {
return WisprToken.create<Inventory>(`player.${player.UserId}.inventory`);
}
// Create per-player inventory
Players.PlayerAdded.Connect((player) => {
const token = getPlayerInventoryToken(player);
createNode(
token,
{ kind: "player", player }, // Scope to this specific player
{ gold: 0, items: [], equipment: {} }
);
});
// Update inventory for a specific player
function addGold(player: Player, amount: number) {
const token = getPlayerInventoryToken(player);
patchNode(token, opIncrement(pathOf("gold"), amount));
}
function addItem(player: Player, item: string) {
const token = getPlayerInventoryToken(player);
patchNode(token, opListPush(pathOf("items"), item));
}// Client
import { Players } from "@rbxts/services";
import { waitForNode, requestInitialData, pathOf, WisprToken } from "@rbxts/wispr";
await requestInitialData();
// Get the player's own inventory token
const localPlayer = Players.LocalPlayer;
if (!localPlayer) return;
const inventoryToken = WisprToken.create<Inventory>(`player.${localPlayer.UserId}.inventory`);
const inventory = await waitForNode(inventoryToken);
// Display gold
inventory.listenForChange(pathOf("gold"), (newGold) => {
updateGoldUI(newGold as number);
});
// Display items
inventory.listenForChange(pathOf("items"), (newItems) => {
updateItemsUI(newItems as string[]);
});Blink Integration
🚫 Currently Unavailable: Blink integration has been temporarily disabled due to compatibility issues. Wispr currently uses standard RemoteFunction/RemoteEvent for all networking. Blink integration may be re-enabled in a future release once the issues are resolved.
Wispr was designed to support optional integration with Blink for enhanced performance and security, but this feature is currently disabled. The configureBlink() function is available for API compatibility but will not enable Blink integration.
Wispr will always use standard RemoteFunction/RemoteEvent regardless of any Blink configuration attempts.
Future Implementations
Planned features and improvements for future releases:
Networking Enhancements
- Reliable and Unreliable Events: Support for both reliable and unreliable event types, allowing developers to choose the appropriate reliability level for different types of updates (e.g., unreliable for frequent position updates, reliable for critical state changes)
- Batch Updates: Send multiple patch operations in a single network message to reduce overhead for rapid state changes
- Delta Compression: Further optimize bandwidth by only sending the differences between consecutive state snapshots
- Custom Serialization: Allow developers to provide custom serialization/deserialization functions for specific data types
Performance Optimizations
- Client-Side Prediction: Support for optimistic updates on the client before server confirmation, reducing perceived latency
- Selective Replication: Fine-grained control over which parts of state are replicated to which clients based on distance, visibility, or custom logic
- Compression Options: Configurable compression algorithms and levels for different use cases
Developer Experience
- Middleware/Hooks System: Intercept and modify patches before they're sent or applied, enabling custom validation, logging, or transformation
- Rate Limiting Per Node: Configure rate limits on a per-node basis to prevent abuse and manage bandwidth
- Automatic Reconnection Handling: Automatic recovery from network interruptions with state synchronization
- Dev Tools: Built-in debugging utilities, network traffic visualization, and state inspection tools
- Metrics and Analytics: Built-in performance metrics for bandwidth usage, patch frequency, and replication efficiency
Changelog
[1.0.38] - 2026-01-06
- Added: Reliable and Unreliable networking for optimized bandwidth usage
UnreliableRemoteEventsupport for high-frequency updates (positions, animations)RemoteEventfor guaranteed delivery of critical updates (inventory, quest completion)- Operations now accept optional
reliability: "reliable" | "unreliable"field
- Added: New streamlined Node API for server-side mutations
node.set(path, value, reliability?)- Set a valuenode.increment(path, delta, reliability?)- Increment a numeric valuenode.delete(path, reliability?)- Delete a valuenode.insert(path, index, value, reliability?)- Insert into arraynode.remove(path, index, reliability?)- Remove from array- Convenience methods:
setReliable(),setUnreliable(),incrementReliable(), etc.
- Added: Helper functions for creating operations with explicit reliability
opSetReliable()/opSetUnreliable()opIncrementReliable()/opIncrementUnreliable()
- Changed:
patchNode()now supports reliability field in operations (backward compatible) - Added:
getUnreliableRemoteEvent()function for creating UnreliableRemoteEvent instances - Note: New remotes:
STATE_UPDATES_RELIABLEandSTATE_UPDATES_UNRELIABLE
[1.0.35] - 2025-01-05
- Disabled: Blink integration has been temporarily disabled due to compatibility issues. The
configureBlink()function remains for API compatibility but will not enable Blink integration. Wispr will always use standard RemoteFunction/RemoteEvent.
[1.0.34] - 2025-01-05
- Changed: Blink integration now uses module imports instead of string paths for better type safety
- Changed
serverBlinkPathandclientBlinkPathtoserverBlinkModuleandclientBlinkModule - Users now import Blink modules directly:
import * as serverNetwork from "server/network/network" - Improves type safety and eliminates path resolution issues
- Changed
[1.0.33] - 2025-01-05
- Added: Blink integration for enhanced performance and security
- Optional integration with Blink IDL compiler
- Support for custom casing conventions (Pascal, Camel, Snake, Kebab)
- Automatic fallback to standard remotes if Blink is not configured
- See Blink Integration section for setup instructions
[1.0.32] - 2025-01-04
- Added:
onNodeOfClassCreatedmethod for listening to nodes matching a token ID pattern (similar to ReplicaService'sReplicaOfClassCreated)
[1.0.31] - 2025-01-05
- Added: Enhanced error handling and validation in WisprClient for better reliability and debugging
[1.0.3] - 2025-01-04
- Added: Comprehensive error handling and input validation across all modules and classes
- Added: Detailed error messages for better debugging
- Added: Parameter validation for all public APIs
- Added: Type checking and validation for paths, operations, tokens, and scopes
[1.0.2] - 2025-01-04
- Fixed: Added missing exports for
getRemoteFunction,getRemoteEvent,WISPR_REMOTES, andinitializeRemotesfromWisprRemotes
[1.0.1] - 2025-01-03
- Initial release
Support
Thank you for the support!
Wispr is free to use, and your tip helps me keep maintaining and improving it. I appreciate you.
License
ISC License
Copyright (c) 2026, sawcy
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
