React-redux via UniRx + Redux.NET for Unity3D += Zenject
React-redux is a modern design pattern employed on websites and mobile apps, including Facebook.com. This project seeks to port its principles and practices to Unity3D. To understand how Reactive Programming and Redux can be incorporated into your project, follow these steps:
First, read this well written blog post on reactive programming in Unity3D using UniRx. This takes a classic character movement example and decouples reading keyboard/mouse inputs -> converting to velocity -> applying movement to a character.
Now, you should be convinced that reactive programming will lead to more efficient and maintanable code when compared with classical, imperative coding.
Note that there is still coupling between inputs and movement. What if you wanted to move the character regardless of input (e.g., on a timer or because of an event trigger)? You would have to create a reactive situation for both observing the event and handling the movement for each variation.
Enter Redux. Instead of components observing events and then rendering changes, they observe said events and then mutate a global state object (via actions and pure functions called "reducers"). Other components who's sole purpose is to render changes will then observe changes to the state and then render.
In the aforementioned example, CharacterController.Move would only care that a Vector3 state change occurreded using Redux. It would not care where that change came from.This can be done by:
Now, the CharacterController only knows that state.velocity has changed and responds accordingly. This has a few benefits:
For a standard CounterButton example, take a look at the Example below. Example _Scenes
also includes the aforementioned CharacterMover and CounterButton examples.
Download or clone this repo. While Reduxity has dependencies on UniRx and redux.NET, these are included in this repo. You can also use Reduxity with Zenject for Direct Injection.
Using Zenject is highly recommended and encouraged. A full tutorial on how to use Zenject is forthcoming since it is a bit heady at first.
We recommend you use Reduxity with Zenject to encourage more modular, easier-to-test code. Once you get the hang of it, it will lead to less code that is easier to reason and less dependent on MonoBehaviours.
Example for this is in Reduxity.ZenjectPlayerMovementExamply.unity
First, let us set up Zenject. This is boilerplate for every Zenject project.
SceneContext
in Hierarchy > Create > Zenject > SceneContext. This is the entryway into your scene.+
under Installers and ScriptableObjectInstallersNext, let's set up the State.
State
script. This will house your nested state object and initialize a default state.IInitializable
Zenject class like CharacterState : IInitializable
. Specify state properties.void Initialize()
, set up the default state for this object.State
class to include each of the nodes.Next, set up Actions and Reducers. Actions take input parameters and are dispatched to the Redux store. They feed those parameters as a payload to reducers that will modify a new state object.
Next, set up the App, which will initialize the store with a default state and reducers. Here, you will inject each reducer and the state. Note that the App contains a public Store
, which will be your method to dispatch Actions.
Finally, create Components that listen to changes in the State.
Zenject can take some time to decipher. Here are a few gotchas:
1
and then drag the GameObject into Element 0
. Then, inject and use the bound instance without needing to do GetComponent
or public GameObject
.CharacterController
, do not drag in the Transform
.Element 0
.ID
in the ZenjectBindingScript
. Then, you can reference this injected GameObject by ID via an Identifier.Note: Direct injection via Zenject is the preferred method for using this library.
1) Set up State.cs
. Don't forget you need to create a function to initialize state with default values.
public class State {
public CounterState Counter { get; set; }
/* default state at app start-up */
public State initialize() {
return new State {
Counter = new CounterState {
count = 0
}
};
}
}
public class CounterState {
public int count { get; set; }
}
2) Set up your Actions and Reducers. Note that Reduxity follows the Ducks Module Proposal for bundling.
public class Action {
// actions must have a type and may include a payload. The payload will
// need to be specified in properties of the Action. See the CharacterMover
// module for an example of this.
public class Increment: IAction {}
public class Decrement: IAction {}
}
// reducers handle state changes by taking the previousState, applying an action,
// and then returning a new State without mutating the previousState. In theory,
// this can create a record of states that DevTools can reveal for easy debugging.
public static class Reducer {
public static State Reduce(State previousState, IAction action) {
// Debug.Log($"reducing with action: {action}");
if (action is Action.Increment) {
// always pass previousState and action cast as the action type
// this will lead to more easily replicable reducers
return Increment(previousState, (Action.Increment)action);
}
if (action is Action.Decrement) {
return Decrement(previousState, (Action.Decrement)action);
}
return previousState;
}
// include previousState and action in the constructor to make it faster and
// easier to replicate reducers later
public static State Increment(State previousState, Action.Increment action) {
// always return a new State in order to note mutate the previousState
return new State {
Counter = new CounterState {
count = previousState.Counter.count + 1
}
};
}
public static State Decrement(State previousState, Action.Decrement action) {
return new State {
Counter = new CounterState {
count = previousState.Counter.count - 1
}
};
}
}
3) Set up the Store:
public class App : MonoBehaviour {
public static IStore<State> Store { get ; private set; }
private void Awake () {
// initialize store with default values
State initialState = new State {}.Initialize();
// generate Store
Store = new Store<State>(
ReducerCombiner<State>.CombineReducers(
new Dictionary<string, Func<object, IAction, IState>> {
{"Character", Movement.Reducer.Reduce},
{"Camera", Look.Reducer.Reduce}
}
),
initialState
);
}
}
4) Observe events and dispatch actions to the store:
App.Store.Dispatch(new Counter.Action.Increment {});
5) Subscribe to state changes. At this point, you can use Selectors to filter for a specific node in the state tree.
App.Store.Subscribe(store => {
Debug.Log($"going to change count to: {store.Counter.count}");
})
// clean up subscribable when game object is destroyed in order to not leak memory
.AddTo(this);
6) Render state changes!
A standard example that demonstrate simple actions without a payload
An example of player movement via PC input observables. This implements a React-redux version of player movement from this awesome article
An example of player movement with keyboard inputs and camera looking with mouse inputs. This implements a React-redux version of player movement and mouse looking from the aforementioned article. Specifically, it demonstrates:
ReducerCombiner.CombineReducers
(see Redux's combineReducers
docsAll of the above but using Zenject instead of static functions and new Class()
intializers.
An async example of using UniRx's WWWObservable with Redux Thunk via redux.NET-thunk is provided. tl;dr instead of dispatching an Action, you dispatch an ActionCreator that returns an Action (after the async process is done), which is then dispatched to the store.
A port of redux.NET-thunk is included for dispatching async actions. This allows you to dispatch an Action Creator that returns an Action. Within the Action Creator, we recommend you dispatch an action to store in state that an async process is happening. Then, update state upon success or failure with another dispatched action. See the Async example for reference on how to use this with UniRx.
A port of redux-logger is included for automatically logging dispatched actions and the current state before applying the action. Currently, this is only tested with Zenject and adjustable within Zenject's ScriptableObjectInstaller that is adjustable in runtime Settings. Note that using a verbose
LogLevel setting will result in dumping each action and state object, which could adversely affect performance in development mode.
While Redux docs suggest this is an anti-pattern, it also provides situations where this could be desired. Ultimately, it's up to you. Using ReducerCombiner.CombineReducers()
can help to keep your reducers cleaner.
Because I need to figure out how to efficiently deep clone state objects before they hit the reducer. Until then, be careful that you do not mutate the whole state.
Working on it, but this will likely be integrated with Zenject. If you don't know what this is, check out Redux DevTools.
There is plenty of reading material you can find on the benefits of direct injection at the Zenject Repo. With that aside, I found that Zenject's separation + initialization logic, runtime serialization of fields, and its easy-to-use Settings Installer make it easier to reason about a more complex Redux project.
The downside is that Direct Injection can take some time to wrap your head around and Zenject does not have any good tutorials. Don't worry, this is on the roadmap.
Read more about Redux Read more about Reactive Programming