Tool Spotlight - Fluxity Part 3
· July 10, 2024In the Part 1, we looked at how we’re using Flux in Unity for games. In Part 2, we talked pros and cons. In part 3 we’re going to look at our learnings, findings, and other ‘of note’ musings on using Fluxity and Flux pattern in general.
Not very C#-ey, not very Unity-ey
We haven’t really made a point of talking about this as good or bad, as it hasn’t really struck us directly as a pro or a con. But in sitting back and reviewing some of our code, and talking it over with our developers, we noted that the very Flux/Fluxity centric aspects of the game/codebase, don’t really look like c# code you might otherwise stumble upon. There’s not a deep inheritance hierarchy to be aware of, there’s not a shotgun blast of interfaces to contend with on the class, there’s often not even a class. When there is, it’s not very Object-Oriented (OO), meaning it’s job isn’t to abstract complexity, hide internals, and handle messaging with the outside world. It’s also not very similar to other random Unity code you might stumble into. There’s not a lot of private serialised fields everywhere, so that values can be tweaked in the inspector. There’s not exposed fields for Unity to Dependency Inject (DI) link your components or objects together in the inspector. A lot of what might have usually been drag and drop in the inspector might now be in code. This probably would be a negative on a larger team, but our teams are programmer heavy, the time for the programmer to bind the code vs. expose to Unity and then drag and drop via inspector is probably not a concern, but other disciplines definitely aren’t going to want to write c# snippets for trivial method invocation. There has been, what you could argue is, an obvious downside here, the quick hack to just try something is not so much a quick hack in this structure. Wanting to try something that expands an existing thing probably means adding to or expanding the states and the reducers that manipulate them. E.g. To hack in a quick knob for design to tweak enemy accuracy based on player facing direction, you need a location for that tweakable value to live, a way to change it, and then FeatureObserve it in the enemy. There’s not Finds, and GetComponents, there’s FeatureObservers or Effects. It is naturally pushing us away from behaviour as component, to logic as systems that operate on collections. Feels much closer to Entity-Component-System (ECS) a lot of the time, than it does GameObject Model (GOM).
By Convention
A number of the parts of how we set up and use Fluxity have settled on, by convention. This aligns with being more functional than OO, the args you take and where you are bound/closed over/invoked, determines what’s happening not your base class or the interfaces you meet. States do not inherit from anything, they are just a struct. Commands presently inherit from ICommand but that’s only to support IL2cpp, we (at least I, Steve) would rather they too could be free floating structs. We’ve touched on this a bit in previous entries. We initially looked into making them records, but the feature set available to us in Unity’s mono at the time would have required some fiddly workaround, and would still have ended up with them being a class under hood, not a struct. We’ve also found it most comfortable to create a single file that contains: the struct for the state, the commands that deal directly with it, and the reducers that bind the state and commands together. Initially we’re sticking to our old OO guns of 1 type per file, but ultimately that doesn’t make sense and you could argue doesn’t apply. That rule of thumb is talking about object types. These aren’t objects in the way OO wants to deal in, they are data bundles and pure functions.
public struct SpectatorCameraState
{
public SpectatorCameraMode Mode;
public static SpectatorCameraState Create()
{
return new SpectatorCameraState
{
Mode = SpectatorCameraMode.Off,
};
}
}
public class NextSpectatorCameraModeCommand : ICommand
{
}
public class FixedToggleSpectatorCameraCommand : ICommand
{
}
public static class SpectatorCameraReducers
{
internal static void RegisterAll(FluxityRegisterFeatureContext<SpectatorCameraState> context)
{
context
.Reducer<NextSpectatorCameraModeCommand>(OnNextMode)
.Reducer<FixedToggleSpectatorCameraCommand>(OnFixedToggle)
;
}
private static SpectatorCameraState OnNextMode(SpectatorCameraState state, NextSpectatorCameraModeCommand command)
{
state.Mode = state.Mode >= SpectatorCameraMode.Fixed ? SpectatorCameraMode.Off : state.Mode + 1;
return state;
}
private static SpectatorCameraState OnFixedToggle(SpectatorCameraState state, FixedToggleSpectatorCameraCommand command)
{
state.Mode = state.Mode == SpectatorCameraMode.Fixed ? SpectatorCameraMode.Off : SpectatorCameraMode.Fixed;
return state;
}
}
You may also note there by convention, States have a static Create method, and Reducers live together in a static class that also deals with their registration. This consolidation has helped with some of the logical bounds and ‘volume of files’ friction we’ve talked about in other entries in the series.
Similarly there are things you simply don’t do by convention. Such as directly changing the list that is stored in a state. We don’t want to enforce via an immutable lists or a readonly interface, these might be nice type hints to the user about what they should and should not be doing but the enforcement would fall over at some point, we don’t want to create read only interfaces for all elements of Unity’s GOM, and we do want to be able to hold a list of GameObjects in a state. Interestingly, we have only afoul of this potential foot-gun once. A quick attempt to compare a before and after ‘snapshot’ of a set of data would reveal zero changes as internally they’d point to the same thing, fairly quickly identified and worked around. That we haven’t run into this more often I can only attribute to two things. Games programmers are already used to being given shared access to large data sets that they aren’t allowed to modify. And secondly, when close to the feature observer you are either notified of change or to poll for latest data when needed, if you wanted to be changing that data you’d need to be in a reducer anyway.
Less services in DI
This makes sense when you stop to think about it but it’s not something we planned or intended from the outset. In some of our older projects it was not uncommon to have 30+ services being set up in DI. A good portion of those were mostly about getting data from one place to another, or holding some shared set of data and operations on it for others to leverage. Those all move to Flux, the nice knock on being there are less classes we write that are part of the DI system, and those that are, are less riddled with it. They are often less total lines of code and have less system events they need to reason about. In our newer Fluxity heavy projects, many of the services that we do have in DI are part of the bridge, within an Effect, getting data or messages from within the Flux pattern to objects that are not participating in Flux at all. An example of this might be a visual feedback system for something like bullet impacts, it needs a single call whenever a shot is fired with the context of what was fired, where, and what it hit, but it doesn’t need to observe any state or dispatch anything, so it becomes a service and an Effect to bridge from the Command that moves through Flux, out to it’s interface.
No one wants to write Effects
This is strongly related to the above point. A lot of effects are bridging; being either data transformations between some concept that exists in Fluxity land and some system that is entirely disconnected from it, or simply getting a message dispatch into some system. This is obviously very necessary, we like the external elements being unaware of Fluxity, it’s inline with keeping those classes simple, it lets us share those systems more easily between projects or leverage external packages. Presently we are grouping effects as they relate to either the domain it is calling into, or the domain is being invoked by. This is probably part of why we don’t like it. If I need a new effect to deal with impact sounds, do I make a new Effect Method in the existing ProjectileImpactEffects, or in the existing SfxEffects, or do I make a new Effect class entirely. Another reason we don’t like writing them is they tend to have a lot more boilerplate than a Reducer, by design they are about invoking something that can’t be a FeatureObserver. So a new class with some private fields, and inject with assigns for all of the injects, and then the bind for the methods, and then the actual method itself that does the call. Often 20+ lines for the 1 or two you actually want.
We made our Reducers Pure, so that’s more Effects
Reducers should be pure, absolutely. Does that make things more troublesome in some cases, absolutely. Flux is about determinism, so allowing context to leak into Reducers would break the promise. The unexpected friction or learning here, is that in moving to Fluxity, some existing patterns/structures in the code are ill-fitted in ways we hadn’t anticipated. If previously you had a blind call, and expected the other end to figure out if it should be ignored or gather context to make the decision, that does not map to a Reducer well. Not without pulling that context and logic into the dispatching function, which you probably don’t want to do or you would have done so in the first place. But you know you want a Reducer, and you are correct you do want a Reducer to happen but it might be one that already exists being triggered by a new Effect. This was a bit unexpected, we end up in a number of cases with ‘layered’ Commands, one that only routes to an Effect and another that is a more direct state update payload. These end up feeling like the external and internal layers you might imagine on an API.
Everything is more loosely coupled
Remove one reducer or effect and the whole thing probably just keeps trucking along, but in an entirely untested and undefined state of behaviour. That’s cool, but at the same time it’s not directly something we wanted, it’s a by-product. It’s fun that you can do that, it makes for funny bug videos. You might expect, or actually prefer, that the game simply fail to run at all, or explode terribly if you try to do that. Ultimately, this is perhaps an area we should investigate to see if we can get some specific value out of. There’s also a vibe here that is a bit different. In previous structures, you tend to push towards this feeling that your box of spaghetti wires is rock solid because if it wasn’t working it would have crashed or flung itself apart. This isn’t by any means true but it is often the feeling you get about the code base that would explode if anything was wrong. Once you experience this, everything is running ‘correctly’ but the game/system is in an entirely degenerate state, you get the vibe that this is less of a car driving down the road, more it’s 4 hamster wheels that happen to all be traveling in the same direction. It’s herding cats. It always was. But now it becomes very clear that cat herding is one of the primary responsibilities of you the programmer and the system itself.
Build for Scale, used by a team of 2.
Part of the drive for Flux, one of the original Flux vidoes, is about managing many developers working on a common codebase/dataset/class. We aren’t hundreds of developers, the most developers we’ve ever had working on a project with Fluxity in it simultaneously, is 3. Most commonly it’s been 2, and sometimes that’s 1 developer and the other just reviewing PRs. In previous parts of this series we’ve already covered Pros and Cons and usage, but to reframe a bit of that here, there’s some non-zero overhead we are paying both for; lines of code, runtime performance, and cognitive load. In a large team, you could justify that along the lines of, “Well without it, it simply wouldn’t be possible for our team to collaborate at all. We’d have an entire team sitting around 1 keyboard.” We can’t really A/B it but working this way has not in my estimation made us meaningfully slower, and having Fluxity in the project provides a giant flashing neon sign of a common metaphor. It might be more cumbersome in some ways, but at least there’s only 1 cumbersome way that dispatching and bridging is occurring, not dozens of isolated whatever you felt like at the time mechanisms scattered around the codebase.
In our next part of the series, we’ll be looking at the future for Fluxity and our usage of it.