Skip to main content

Server-Client Sync

Reflex provides a quick way to sync the server's shared state with clients using broadcasters and receivers.

what you'll learn
  • 🌎 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:

shared/slices/index.ts
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,
};
tip

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;

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:

Root producer
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,
});

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:

prerequisites

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.

Server
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);

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:

  1. producers: Your shared slices. This is used to determine which state and actions should be sent to the client.

  2. 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.

  3. hydrateRate?: The rate in seconds at which the server should send the latest state to the clients. The default is 60, which means that every minute, every client passed to start will re-hydrate their store with the latest state.

  4. beforeDispatch? and beforeHydrate? for filtering state and actions before a client receives them.

It returns a broadcaster object, which has two properties:

  1. 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.

  2. start: A method that marks the player as ready to begin receiving shared state and actions. This should be called by the client in a broadcastReceiver.

pitfall

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.

Client
import { createBroadcastReceiver } from "@rbxts/reflex";

const receiver = createBroadcastReceiver({
start: () => {
remotes.start.fire();
},
});

remotes.dispatch.connect((actions) => {
receiver.dispatch(actions);
});

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:

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;
},
});

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:

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],
},
};
},
});

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 and beforeHydrate options to filter actions and state before they are sent to the client.