Server-Client Sync
Reflex provides a quick way to sync the server's shared state with clients using broadcasters and receivers.
- 🌎 How to share state between the server and clients
- 🛰️ How to create broadcasters and receivers
- 🔒 Recipes for protecting data the client shouldn't have access to
Sync state
Reflex is designed to be used in any environment, on the client and the server. However, in game development, many cases come up where you need to send state from the server to clients. This is where the concept of shared slices comes in.
Sharing state
Shared slices are producers that are managed by the server and synced with clients. To create shared slices, we'll follow this file hierarchy:
shared
├── slices
│ ├── calendar
│ └── todos
└── remotes
Your shared slices
module should look something like this:
- TypeScript
- Luau
import { CombineStates } from "@rbxts/reflex";
import { calendarSlice } from "./calendar";
import { todosSlice } from "./todos";
export type SharedState = CombineStates<typeof slices>;
export const slices = {
calendar: calendarSlice,
todos: todosSlice,
};
Exporting SharedState
as a type makes it easier to create typed selectors without importing across the client/server boundary.
import { SharedState } from "shared/slices";
export const selectPlayers = (state: SharedState) => state.players;
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local calendar = require(script.calendar)
local todos = require(script.todos)
export type SharedState = {
calendar: calendar.CalendarState,
todos: todos.TodosState,
}
export type SharedActions = calendar.CalendarActions & todos.TodosActions
return {
calendar = calendar.calendarSlice,
todos = todos.todosSlice,
}
Exporting SharedState
and SharedActions
helps to build a fully-typed root producer.
In this example, we have two shared producer slices: calendar
and todos
. They are put together in a map and returned by shared/slices
. The contents of these files are not important - they're just like any other producer - but if you want to see how to write producers, check out this guide
Using a map of shared slices makes it easy to add them to your root producer. In your root producer file, you can import the shared slices and spread them into your root producer:
- TypeScript
- Luau
import { InferState, combineProducers } from "@rbxts/reflex";
import { slices } from "shared/slices";
import { fooSlice } from "./foo";
import { barSlice } from "./bar";
export type RootState = InferState<typeof producer>;
export const producer = combineProducers({
...slices,
foo: fooSlice,
bar: barSlice,
});
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local slices = require(ReplicatedStorage.shared.slices)
local foo = require(script.foo)
local bar = require(script.bar)
export type RootProducer = Reflex.Producer<RootState, RootActions>
export type RootState = slices.SharedState &
foo.FooState &
bar.BarState
export type RootActions = slices.SharedActions &
foo.FooActions &
bar.BarActions
local rootSlices = {
foo = foo.fooSlice,
bar = bar.barSlice,
}
for name, slice in slices do
rootSlices[name] = slice
end
return Reflex.combineProducers(rootSlices) :: RootProducer
Libraries like Sift can make it easier to merge tables in Luau.
Now that you have your shared state set up, and include them in both your client and server's root producer, you can now use createBroadcaster
to send state to clients.
Creating a broadcaster
You should call createBroadcaster
on the server when your game initializes, either before or after you create your root producer. It receives your shared producer map and a function that sends actions to the clients, and returns a broadcaster object. Make sure you've set up remotes as well:
You need to define your own remotes to use createBroadcaster
. We recommend RbxNet, or Remo, which is used in the examples on this page.
You will need two remote events:
dispatch(player: Player, actions: BroadcastAction[])
- This is the remote event that will be fired when the server dispatches actions to clients.start(player: Player)
- This is the remote event that the clients will fire once they are ready to receive state from the server.
- TypeScript
- Luau
import { createBroadcaster } from "@rbxts/reflex";
import { remotes } from "shared/remotes";
import { slices } from "shared/slices";
import { producer } from "./producer";
const broadcaster = createBroadcaster({
producers: slices,
dispatch: (player, actions) => {
remotes.dispatch.fire(player, actions);
},
});
remotes.start.connect((player) => {
broadcaster.start(player);
});
producer.applyMiddleware(broadcaster.middleware);
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local remotes = require(ReplicatedStorage.shared.remotes)
local slices = require(ReplicatedStorage.shared.slices)
local producer = require(script.Parent.producer)
local broadcaster = Reflex.createBroadcaster({
producers = slices,
dispatch = function(player, actions)
remotes.dispatch:fire(player, actions)
end,
})
remotes.start:connect(function(player)
broadcaster:start(player)
end)
producer:applyMiddleware(broadcaster.middleware)
This sets up a broadcaster that sends shared actions to the clients when they're dispatched. Once the middleware is applied, Reflex will begin syncing dispatched actions to the clients.
createBroadcaster
receives the following options:
producers
: Your shared slices. This is used to determine which state and actions should be sent to the client.dispatch
: A user-defined callback that sends shared dispatched actions to the clients. It receives an array of actions and a player to send them to.hydrateRate?
: The rate in seconds at which the server should send the latest state to the clients. The default is60
, which means that every minute, every client passed tostart
will re-hydrate their store with the latest state.beforeDispatch?
andbeforeHydrate?
for filtering state and actions before a client receives them.
It returns a broadcaster object, which has two properties:
middleware
: A Reflex middleware that helps do some of the heavy lifting for you. You should apply this middleware to your root producer. If you have any middlewares that change dispatched arguments, you should apply them after this middleware to ensure that the arguments are preserved.start
: A method that marks the player as ready to begin receiving shared state and actions. This should be called by the client in abroadcastReceiver
.
Make sure your shared state can be sent over a remote! Objects that use non-string keys or certain values will not be sent over intact. See the troubleshooting page for more information on this common pitfall.
Creating a receiver
Once you have your broadcaster set up, you can use createBroadcastReceiver
to initialize the client state with the server's shared state and keep it in sync.
- TypeScript
- Luau
import { createBroadcastReceiver } from "@rbxts/reflex";
const receiver = createBroadcastReceiver({
start: () => {
remotes.start.fire();
},
});
remotes.dispatch.connect((actions) => {
receiver.dispatch(actions);
});
producer.applyMiddleware(receiver.middleware);
local Reflex = require(ReplicatedStorage.Packages.Reflex)
local receiver = Reflex.createBroadcastReceiver({
start = function()
remotes.start.fire()
end,
})
remotes.dispatch:connect(function(actions)
receiver:dispatch(actions)
end)
producer:applyMiddleware(receiver.middleware)
This code will call start
when the middleware is applied, and hydrate the client's state with the server's shared state. Calling dispatch
will send the actions from the broadcaster to the client's store and enable automatic hydration.
It's thread-safe, so it's safe to apply the middleware at any time, and you can even use your producer before the server's state is received.
Privacy
Filtering actions
You can use the beforeDispatch
option to filter or modify actions before they are sent to the client. This is useful if you have sensitive data you don't want to share between clients, or if you want to prevent certain actions from being dispatched to the client.
This example will prevent the sensitive
action from being dispatched to the client if the player's Name is not the first argument:
- TypeScript
- Luau
const broadcaster = createBroadcaster({
producers: slices,
dispatch: (player, actions) => {
remotes.dispatch.fire(player, actions);
},
beforeDispatch: (player, action) => {
if (action.name === "sensitive" && action.arguments[0] !== player.Name) {
return;
}
return action;
},
});
local broadcaster = Reflex.createBroadcaster({
producers = slices,
dispatch = function(player, actions)
remotes.dispatch:fire(player, actions)
end,
beforeDispatch = function(player, action)
if action.name == "sensitive" and action.arguments[1] ~= player.Name then
return
end
return action
end,
})
Filtering state
You can use the beforeHydrate
option to filter or modify state before it is sent to the client. This is useful if you store sensitive data you don't want to share with clients.
This example filters out all private data except for the current player's data:
- TypeScript
- Luau
const broadcaster = createBroadcaster({
producers: slices,
dispatch: (player, actions) => {
remotes.dispatch.fire(player, actions);
},
beforeHydrate: (player, state) => {
return {
...state,
private: {
[player.Name]: state.private[player.Name],
},
};
},
});
local broadcaster = Reflex.createBroadcaster({
producers = slices,
dispatch = function(player, actions)
remotes.dispatch:fire(player, actions)
end,
beforeHydrate = function(player, state)
local newState = table.clone(state)
newState.private = {
[player.Name] = state.private[player.Name],
}
return newState
end,
})
Summary
- Shared state is synced between the server and client using a broadcaster and a receiver.
- The broadcaster is responsible for sending state and actions to the receiver.
- The receiver is responsible for dispatching actions from the broadcaster.
- You can use the
beforeDispatch
andbeforeHydrate
options to filter actions and state before they are sent to the client.