BB – Deep Dive: Binder
Posted: February 4, 2025
Last Updated: February 4, 2025
In our Deep Dive on Builders we looked at how I can develop new interfaces that connect the data on Unity’s ECS Entities with any number of user inputs through UI elements. When it comes to changing values on the subject entity, InGameBuilders are only there for creating BindingObjects from subject components and unpacking BindingObjects back into subject components. The Binder is a generalized structure that is responsible for actually changing the data in the BindingObject and synchronizing this change across multiple input objects.
I put off making this post for a long time for the very simple reason that I’m still not sure if what I’ve done is a good idea. However, despite the chance that the Binder may still have significant changes in its future, I think I’ve sat on it long enough to know that it’s not going anywhere.
First, let’s set up some responsibilities for the Binder:
- Setup: Automatically find and store BuilderBindings in child GameObjects.
- Input-to-Object: Given an input object, a field name, and a BindingObject, modify the corresponding BindingObject field to match the input object value.
- Object-to-Input: Given an input object, a field name and a BindingObject, modify the input object value to match the corresponding BindingObject field.
- Synchronization: When changing a data value, synchronize all related input fields to have that value.
- Dynamic Bindings: Manage a changing list of BuilderBindings to support widgets and variable-length lists.
SETUP:
So we know that it’s the Binder’s responsibility to track and manage BuilderBindings. How we do this is a little more involved, though. Let’s take a look at the Binder’s fields and properties.
public class Binder : MonoBehaviour {
// Some basic setup fields
public bool findChildBindingGroups = true;
public bool includeInactiveChildren = true;
public bool noSetup = false;
// This Binder's BuilderBindings
public List<BuilderBinding> bindings;
public List<BuilderBindingGroup> bindingGroups;
// Binders found in children of this Binder
public List<Binder> subBinders;
// Widgets are like custom/special input objects
public List<Widget> widgets;
// Dynamic bindings
List<Action> storedActions;
List<Action<BuilderBinding>> storedSingleActions;
List<object> orderedStoredActions;
public HashSet<BuilderBinding> dynamicBindings;
public bool hasBeenSetup { get; private set; }
// ...
}
The first few fields are mostly important for Binder setup. They used to be a lot more important when I was still using SubBinders, but now they are a little obsolete and findChildBindingGroups and includeInactiveChildren usually stay true while noSetup stays false. The BuilderBinding, Binder, and Widget lists are just there to store references to those which we find in the children of this Binder. Finally, we have some funny business with storedActions, storedSingleActions, and dynamicBindings. The actions are just there to store callbacks that should be applied to input objects when new dynamic bindings are added to the Binder. As for dynamicBindings, because it is constantly being added to and removed from, I chose a HashSet as a data structure to ensure we don’t repeat bindings and to make removing specific bindings easier, though I don’t think I ever actually use this in any meaningful way because I just operate off the ‘bindings’ field. Like I said- artifacts.
Next, we can get into some of the actual setup.
public class Binder : MonoBehaviour {
// ...
public virtual void SetupBinder() {
// We are calling SetupBinder, so it is now set up.
hasBeenSetup = true;
// Initialize a bunch of fields
if (bindings == null) { bindings = new List<BuilderBinding>(); }
bindings.Clear();
storedActions = new List<Action>();
storedSingleActions = new List<Action<BuilderBinding>>();
orderedStoredActions = new List<object>();
dynamicBindings = new HashSet<BuilderBinding>();
// Find sub-binders to exclude their bindings from my bindings and
// exclude myself from list of subbinders
subBinders = GetComponentsInChildren<Binder>(true).ToList();
subBinders.Remove(this);
// Setup and record bindings from all sub binders to exclude their bindings from my own
List<BuilderBindingGroup> exclude = new List<BuilderBindingGroup>();
foreach (Binder subBinder in subBinders) {
subBinder.SetupBinder();
exclude.AddRange(subBinder.bindingGroups);
}
// Get all child binding groups
if (findChildBindingGroups) {
// Find binding groups in children
bindingGroups = GetComponentsInChildren<BuilderBindingGroup>(includeInactiveChildren).ToList();
// Exclude all subBinder bindinggroups from my own binding groups
foreach (BuilderBindingGroup e in exclude) {
bindingGroups.Remove(e);
}
// Remove all bindings that are not enabled
bindingGroups.RemoveAll(bg => !bg.bindingEnabled);
}
// Special flag for annoying dynamic binding reasons.
if (noSetup) { return; }
// Add all bindings from group to my bindings
foreach (BuilderBindingGroup bindingGroup in bindingGroups) {
// Need doom check to make sure we are not binding a dynamic binding
// on a gameobject that has just been destroyed
if (bindingGroup.doomed || !bindingGroup.bindingEnabled) { continue; }
if (!bindingGroup.built) bindingGroup.BuildBindings();
bindings.AddRange(bindingGroup.bindings);
}
// Add myself as the parent to all bindings
foreach (BuilderBinding binding in bindings) {
binding.parentBinder = this;
}
}
// ...
}
It’s not the most elegantly-written method ever, but it does what I need it to do. At the end of the day, it just finds BuilderBindingGroup MonoBehaviours in its children, sets them up, and tracks their BuilderBindings.
INPUT-TO-OBJECT:
Now let’s talk a bit about how we change the BindingObjects that the InGameBuilders pass into the Binder. The goal is fairly simple: take a BindingObject and a BuilderBinding, and change the BindingObject using information in the BuilderBinding which connects a field name with an input object (a UI element).
public class Binder : MonoBehaviour {
// ...
public virtual object ModifyBindingObjectToSingleBinding(object bo, BuilderBinding binding, string bindingSubPath=null) {
bool inRecursiveCall = bindingSubPath != null;
string path = inRecursiveCall ? bindingSubPath : binding.name;
// Check if there is an indexable list along the path to desired set field
// Parse the path up to that point if so.
string fieldListPath = GetFirstIListFieldName(bo, path);
if (fieldListPath != null) {
// Retrieve the IList field at the subpath and
// apply ModifyBindingObjectToSingleBinding to
// each element specified by the BuilderBinding
IList fieldList = bo.GetNestedFieldValue(fieldListPath) as IList;
foreach (int idx in binding.indices) {
fieldList[idx] = ModifyBindingObjectToSingleBinding(fieldList[idx], binding, path.Substring(fieldListPath.Length+1));
}
}
// Perform base-level change of value on the BindingObject
else {
if (binding.behaviourType == typeof(Slider)) {
float sliderVal = ((Slider)binding.behaviour).value;
bo = SetBindingValue(bo, path, binding.valueType, sliderVal);
}
else if (binding.behaviourType == typeof(InteractiveSlider)) {
float sliderVal = ((InteractiveSlider)binding.behaviour).value;
bo = SetBindingValue(bo, path, binding.valueType, sliderVal);
}
// Handle a bunch of input types...
// ...
else if (binding.behaviourType == typeof(RegistryPrefabField)) {
Debug.Log($"ModifyBindingObjectToSingleBinding123: binding has RegistryPrefabField behaviourType");
Entity prefab = ((RegistryPrefabField)binding.behaviour).prefab;
bo = SetBindingValue(bo, path, binding.valueType, prefab);
}
}
// Only modify related interactors from the base call instance
if (!inRecursiveCall) {
ModifyRelatedInteractorsToSubject(bo, binding);
}
return bo;
}
// ...
}
To clarify the above code, first, we have to figure out if the field name path contains an IList somewhere along the line. This supports a single IList field along the path to the desired field because BuilderBinding has an indicies field which contains all the indices to which the binding applies. If there is no List along the way, then we can directly use reflection (in our SetBindingValue method) to change that field in the BindingObject to the value on our input object. Finally, there is a call to ModifyRelatedInteractorsToSubject, which we will address later.
Let’s walk through this with a hypothetical example. Let’s say we have a translation widget input that is connected to a BuilderBinding with field=enemyMovementNodes.position and indices=[0,1,3]. When we move that widget, a callback is performed to ModifyBindingObjectToSingleBinding, passing this BuilderBinding in as an argument. Along the field path, enemyMovementNodes is a List of MovementNodes, so we retrieve the current value of this list field from the BindingObject, then for each index in [0,1,3], we call ModifyBindingObjectToSingleBinding recursively, passing in string position as the field path within a MovementNode to the field we want. For each of these recursive calls, we end up at the else statement (line 21) where we modify the input BindingObject so that the position field at that MovementNode element matches the float2 position of the moved widget. In the original method call, you can also see how the output of the recursive calls is assigned back to the element at each index of the enemyMovementNodes list.
OBJECT-TO-INPUT:
That’s cool and all, but what about if we want to go in the reverse direction? In other words, what about if we want to change an input object to match the value in the BindingObject? This might happen when you are opening an existing creation and want the builder inputs to change to match the values from the loaded creation. That’s where ModifyInteractorToSubject comes in.
public class Binder : MonoBehaviour {
// ...
public void ModifyInteractorToSubject(object bo, BuilderBinding binding, string bindingSubPath=null) {
object bindingValue = null;
string path = bindingSubPath == null ? binding.name : bindingSubPath;
try {
// Immediately try for the non-list-level version of the field
bindingValue = bo.GetNestedFieldValue(path);
} catch (NullReferenceException) {
// If something is wrong with that,
// then try to get the value acting as if
// the field is within a list.
string fieldListPath = GetFirstIListFieldName(bo, path);
if (fieldListPath == null) {
Debug.LogError($"Nested field '{path}' does not contain any IList fields on path. Cannot call modify interactor on specific field list element.");
} else {
IList fieldList = bo.GetNestedFieldValue(fieldListPath) as IList;
// DO SOME SPECIAL CHECKS FOR UPDATING AN INTERACTOR TO SINGLE OR MULTIPLE INDICES OF A BINDING OBJECT
// If only 1 index, then just use that
if (binding.indices == null) { return; }
if (binding.indices.Count < 1) { Debug.LogError($"Trying to access an indexable field/property of binding object, but binding contains no indices"); }
if (binding.indices.Count == 1) {
ModifyInteractorToSubject(fieldList[binding.indices[0]], binding, path.Substring(fieldListPath.Length+1));
}
// Want to check if all the values are the same across each binding index,
// if so, then we can update the interactor to that common value
else {
object fieldValue = fieldList[binding.indices[0]];
foreach (int idx in binding.indices) {
if (fieldValue != fieldList[idx]) { return; }
}
ModifyInteractorToSubject(fieldValue, binding, path.Substring(fieldListPath.Length+1));
}
return;
}
}
if (bindingValue == null) { return; }
if (bindingValue.GetType() == typeof(FPStat)) { bindingValue = (float4)((FPStat)bindingValue); }
if (binding.behaviourType == typeof(Slider)) {
// Set the Slider value parameter
SetBindingValue(binding.behaviour, "value", BindingValueType.FLOAT, bindingValue);
}
else if (binding.behaviourType == typeof(InteractiveSlider)) {
SetBindingValue(binding.behaviour, "value", BindingValueType.FLOAT, bindingValue);
}
// Handle a bunch of input types...
// ...
else if (binding.behaviourType == typeof(RegistryPrefabField)) {
SetBindingValue(binding.behaviour, "_prefab", BindingValueType.PREFAB_ENTITY, bindingValue);
}
}
// ...
}
Here, the try/catch block essentially fishes around the BindingObject for the given field, handling the cases where the field is within a list and the cases where the binding maps to multiple indices of a list. If a final bindingValue is found, then we move on to just setting the input object’s value to the bindingValue.
Since I know exactly which value field to use for each input type, I could just access the field directly. However, there are special conversions that happen in SetBindingValue to help change data into a relevant format for the target field (like changing strings into float2’s, etc), so I continue to use that here.
SYNCHRONIZATION:
The point of all this, in case it wasn’t clear, was to automatically bind one or more particular inputs to a data field on the builder subject in the ECS world. We’ve seen how to link data to inputs and vice versa, but there is a little more to synchronizing multiple inputs to a single field. Let’s go back to our call to ModifyRelatedInteractorsToSubject called from ModifyBindingObjectToSingleBinding.
public class Binder : MonoBehaviour {
// ...
private void ModifyRelatedInteractorsToSubject(object bo, BuilderBinding binding) {
// Check all my bindings
foreach (BuilderBinding b in bindings) {
if (CheckIsRelated(binding, b)) {
ModifyInteractorToSubject(bo, b);
}
}
// Check all my widget bindings
foreach (Widget widget in widgets) {
if (widget is DetailsWidget) {
foreach (BuilderBinding b in ((DetailsWidget)widget).bindings) {
if (CheckIsRelated(binding, b)) {
ModifyInteractorToSubject(bo, b);
}
}
} else {
if (CheckIsRelated(binding, widget.binding)) {
ModifyInteractorToSubject(bo, widget.binding);
}
}
}
// Check the bindings in my subbinders, too
foreach (Binder subBinder in subBinders) {
subBinder.ModifyRelatedInteractorsToSubject(bo, binding);
}
}
/// <summary>
/// Checks if either binding is a subfield of the other binding.
/// Returns true if either binding name is a subfield of the other.
/// </summary>
/// <param name="sibling1">Potential binding.</param>
/// <param name="sibling2">Potential binding.</param>
/// <returns>Bool saying if the bindings are related and not the same.</returns>
private bool CheckIsRelated(BuilderBinding sibling1, BuilderBinding sibling2) {
return sibling1 != sibling2 &&
sibling1.indices != null && sibling2.indices != null &&
sibling1.indices.Any(i => sibling2.indices.Contains(i)) &&
((sibling1.name+".").Contains(sibling2.name+".") ||
(sibling2.name+".").Contains(sibling1.name+"."));
}
// ...
}
Now you can see how this street goes both ways. If we want a single data field to map to potentially multiple inputs, we have a way to update any of those inputs using data already on the subject. Note also how CheckIsRelated works. As an example, if we have a single input object (e.g. translation widget) representing a 2d position (float2) named ‘position‘ with fields x and y, and a different input text box referencing field ‘position.x‘, then a change to either the position widget or the text box should update the other input object to match.
Here’s a full example of what happens with multiple input objects for the same field:
- A Slider (s1) and a text input field (t1) both map to a data field on the builder’s binding object (let’s say field ‘fpe.numBullets‘, or subfield ‘numBullets‘ within field ‘fpe‘ of our FiringPointBuilder’s BindingObject).
- The user types the value ‘2’ into t1.
- The change in t1 triggers a callback to FiringPointBuilder’s ModifySubjectToSingleParameter method which sets up a new BindingObject based on the current values of the subject entity and passes this object and the triggered BuilderBinding into the Binder’s ModifyBindingObjectToSingleBinding method.
- ModifyBindingObjectToSingleBinding searches the BindingObject for the field ‘fpe.numBullets‘ given in the BuilderBinding. It first finds the field ‘fpe‘ of type FiringPointElement, and then searches the FiringPointElement for the field ‘numBullets‘.
- ModifyBindingObjectToSingleBinding then accesses the BuilderBinding’s behaviour field (text field t1), and depending on its type, accesses the relevant value field that contains the user’s newly input value (t1.text).
- ModifyBindingObjectToSingleBinding then uses reflection methods to set the field in BindingObject to the user’s converted input value (bindingObject.fpe.numBullets ← t1.text).
- ModifyBindingObjectToSingleBinding then calls ModifyRelatedInteractorsToSubject.
- ModifyRelatedInteractorsToSubject then checks the input BuilderBinding (field=fpe.numBullets, input=t1) against the field names of all other bindings, if they are related, then it calls ModifyInteractorToSubject on the related binding, passing in the newly changed BindingObject as reference. It finds a related BuilderBinding (field=fpe.numBullets, input=s1).
- ModifyInteractorToSubject then works like a reverse ModifyBindingObjectToSingleBinding, taking the value in a field of the BindingObject and using reflection to set the value field of the input object in the BuilderBinding (s1.value ← bindingObject.fpe.numBullets).
- ModifyBindingObjectToSingleBinding passes back the modified BindingObject to the FiringPointBuilder for unpacking into the subject entity.
Bravo! What we have at the end of this process is a set of BuilderBindings whose input objects all have synced values and a modified BindingObject that can be passed back to the invoking InGameBuilder to properly unpack into the builder subject (see section User Input of the Builders Deep Dive for SetSubjectValues).
If things were easy, then this would be the end of it, mostly. Unfortunately, I like complaining too much to have things like this. I want things to by more dynamic. I want to be able to add and remove bindings from the Binder whenever I want and not throw a wrench into everything, so let’s do just that.
DYNAMIC BINDINGS:
So, what’s the big deal? We have a Binder, it sets itself up by gathering up all the BuilderBindings underneath itself, and then we’re good to go, right? Strictly speaking, this is true. If we didn’t want any widgets and wanted to place strict limits on things like the number of elements that could be added to a subject, then we could pre-lay out all the binding groups in the Unity Editor and it would all work like a charm. I do want widgets and don’t want those arbitrary element caps, however, so having dynamic bindings that can change over time is the way to go.
The whole dynamic binding process is remarkably similar to the SetupBinder method, just split into more parts for control. Essentially, it works like this:
- We have a GameObject with child BuilderBindingGroups we want to add to the Binder.
- We pass the candidate object into the Binder.
- The Binder finds the BuilderBindingGroups and their related BuilderBindings in the children of the candidate.
- The Binder applies some setup logic to the bindings.
- The Binder passes the BuilderBindings back as output.
- Now the output bindings can be tracked by the Binder.
Here’s a closer look:
public class Binder : MonoBehaviour {
// ...
public List<BuilderBinding> BindObjectToIndex(GameObject newObj, List<int> indices, bool replaceIndex=true) {
// Similar setup to SetupBinder
List<BuilderBindingGroup> newBindingGroups = newObj.GetComponentsInChildren<BuilderBindingGroup>(includeInactiveChildren).ToList();
List<Binder> newBinders = newObj.GetComponentsInChildren<Binder>(true).ToList();
if (newBinders.Contains(this)) { newBinders.Remove(this); }
List<BuilderBindingGroup> exclude = new List<BuilderBindingGroup>();
// This is a leftover artifact, no longer really relevant
foreach (Binder subBinder in newBinders) {
subBinder.SetupBinder();
}
// Add all bindings from group to list of bindings
List<BuilderBinding> newBindings = new List<BuilderBinding>();
foreach (BuilderBindingGroup bindingGroup in newBindingGroups) {
if (!bindingGroup.built) bindingGroup.BuildBindings();
newBindings.AddRange(bindingGroup.bindings);
// Add myself as the parent to all bindings
foreach (BuilderBinding binding in bindingGroup.bindings) {
binding.parentBinder = this;
if (!bindingGroup.binderNoDynamicIndexSet) {
binding.indices = indices;
}
}
}
return newBindings;
}
// Mostly an alias for BindObjectToIndex
public List<BuilderBinding> CreateDynamicBindings(GameObject interactor, List<int> indices) {
List<BuilderBinding> newBindings = BindObjectToIndex(interactor, indices);
return newBindings;
}
// ...
}
BindObjectToIndex is a special method similar to SetupBinder but vitally different in the fact that: 1) it doesn’t automatically add the BuilderBindings to its own bindings list, and 2) it takes in a number of indices as an argument to apply to each new binding.
We also need something to optionally track created bindings in this Binder which will allow them to be synchronized with the rest of the bindings.
public class Binder : MonoBehaviour {
// ...
public void AddDynamicBindings(List<BuilderBinding> toAdd, object info) {
// Update display element to match newly bound value
foreach (BuilderBinding binding in toAdd) {
ModifyInteractorToSubject(info, binding);
}
AddStoredListenersToBindings(toAdd);
dynamicBindings.UnionWith(toAdd);
bindings.AddRange(toAdd);
}
public void AddStoredListenersToBindings(List<BuilderBinding> newBindings) {
// Add existing listeners to the new bindings IN ORDER
foreach (object action in orderedStoredActions) {
if (action is Action) {
foreach (BuilderBinding binding in newBindings) {
AddOnChangeListener((Action)action, binding);
}
}
else if (action is Action<BuilderBinding>) {
foreach (BuilderBinding binding in newBindings) {
AddOnChangeSingleListener((Action<BuilderBinding>)action, binding);
}
}
}
}
private void AddOnChangeSingleListener(Action<BuilderBinding> action, BuilderBinding binding) {
if (binding.behaviourType == typeof(Slider)) {
((Slider)binding.behaviour).onValueChanged.AddListener(delegate {action(binding);});
} else if (binding.behaviourType == typeof(TMP_InputField)) {
((TMP_InputField)binding.behaviour).onEndEdit.AddListener(delegate {action(binding);});
// Handle other input types...
// ...
} else if (binding.behaviourType == typeof(RegistryPrefabField)) {
((RegistryPrefabField)binding.behaviour).onPrefabChange.AddListener(delegate {action(binding);});
}
}
public void StoreOnChangeSingleListener(Action<BuilderBinding> action) {
// Save action
orderedStoredActions.Add(action);
storedSingleActions.Add(action);
}
public void StoreOnChangeListener(Action action) {
// Save action
orderedStoredActions.Add(action);
storedActions.Add(action);
}
public void RemoveDynamicBindings(IEnumerable<BuilderBinding> oldBindings) {
if (oldBindings == null) { return; }
RemoveStoredListenersFromBindings(oldBindings);
bindings.RemoveAll(binding => oldBindings.Contains(binding));
dynamicBindings.ExceptWith(oldBindings);
}
// ...
}
Here, AddDynamicBindings goes through each input binding, modifies their input object values to match that in the BindingObject, adds stored callbacks to them, then tracks the bindings in the Binder.
AddStoredListenersToBindings does just that. It takes the stored callback methods and adds them as listeners to the relevant fields of each input object for each binding. You can store these callbacks with StoreOnChangeListener and its single variant.
Finally, remove dynamic bindings from the Binder with RemoveDyanmicBindings.
CONCLUSION:
With all that done, we now have a system that can:
- Automatically change a BindingObject given a field, a BuilderBinding, and an input object.
- Automatically change multiple input objects to match a given field in a BindingObject.
- Dynamically add and remove BuilderBindings from the Binder and add and remove the Binder’s callbacks from each BuilderBinding.
There’s a bit more in the current Binder, but we won’t go into detail here. Widgets are treated a little specially because they also have to play nicely with the WidgetManager and be more quickly identifiable/tagged, so for the most part the Binder just acts upon their internal bindings.