This is the third in a series of posts regarding the development process of a rhythm hell dodge-em-up game that I’ve been working on, this time focusing on the custom configuration system I made for it. The first post which covers some of the overall design details of this game can be found here.

Why a custom configuration system?

When starting on the development of the rhythm shmup prototype, it became clear that I would need a strong configuration system in order to set up content for the game. I needed an easy way to statically schedule bullet patterns and animations in sync with the game’s sound track, and I needed to be able to do a lot of it in order to achieve the level of detail that I wanted. My solution was to build a custom configuration system which I call Rumia.

Why the name?

Well, “custom configuration system” is a mouthful, and the one I was making became complex enough that it deserved to be a proper noun.

But like, why the name Rumia?

Oh there’s a professional-sounding reason, Rumia is an acronym for… uh… let’s say Rhythmic Uncanny Model Interface Amalgamation.

Okay but really... why the name Rumia?

Fine, the real reason is because I used an arrangement of A Soul as Red as a Ground Cherry, a theme song of Rumia from Touhou, and “Rumia” seems like a cool name for a configuration system.

Unity editor interface

First, here’s what the interface for Rumia looks like:

Rumia interface

This ScriptableObject instance serves to represent a single 4/4 measure divided by beat. Here, I can schedule specific actions from dropdown lists, down to the level of sixteenth notes if desired. Multiple actions can be scheduled for each instant as well.



For better organization, I can use multiple of these ScriptableObject instances that play out at the same time, each one of them corresponding to a specific Pattern instance at runtime. In my prototype, I had a collection of ScriptableObject instances that represented an enemy moving and firing patterns in sync with the piano melody, and another for an enemy following the base and synth response. To organize further, I separated each of these collections into ScriptableObject instances for specific functionality like moving around, animation flares, and firing bullets.

The result of all of this is an easily-modifiable and well-visualized interface for configuring bullet patterns and mapping them to the game’s soundtrack!

Custom editor script

Now to go deeper into the details of how Rumia works.

First of all, how does the Unity editor know what actions to display in each note’s dropdown list in the inspector? With the help of custom editor scripting.

Specifically, I wrote a custom editor script that takes a Pattern prefab (a prefab with a Pattern script attached) that I supply and constructs a configurable RumiaAction for each possible note in the measure. The script scans the supplied Pattern for any methods marked with a custom tag to determine which methods to make available in the dropdown list.

#if UNITY_EDITOR
    
    ...

    [CustomEditor(typeof(RumiaMeasure))]
    public class RumiaMeasureEditor : Editor {
        public override void OnInspectorGUI() {
            // Draw the default inspector
            DrawDefaultInspector();
            RumiaMeasure measure = target as RumiaMeasure;
            if (measure == null) 
                return;
            Pattern pattern = measure.Pattern;
            if (!pattern)
                return;
            
            List<RumiaAction> choices = pattern.GetAllRumiaActions();
            
            ...

            // Handle each group of RumiaActions to be scheduled for this instant in time
            for (int i = 0; i < SIZE; i++) {
                ...
                List<RumiaAction> rumiaActionList = measure.RumiaActionLists[i].RumiaActions;
                List<string> choiceParameterList = measure.ChoiceParameterLists[i].ChoiceParameters;
                
                ...
                
                // Handle each individual RumiaAction
                for (int j = 0; j < rumiaActionList.Count; j++) {
                    ...

                    // Draw the RumiaAction field
                    RumiaAction updatedRumiaAction = rumiaActionList[j];
                    string updatedChoiceParameter = choiceParameterList[j];
                    DrawRumiaActionField(choices, ref updatedRumiaAction, ref updatedChoiceParameter);
                    rumiaActionList[j] = updatedRumiaAction;
                    choiceParameterList[j] = updatedChoiceParameter;
                }
                
                ...
            }
                        
            // Save the changes back to the object
            EditorUtility.SetDirty(target);
        }
        
        ...
    }
#endif

Each of these constructed RumiaActions can be configured via the inspector interface - to allow for this, the custom editor script displays each selectable method in a dropdown list for each action.

private void DrawRumiaActionField(List<RumiaAction> choices, ref RumiaAction rumiaAction, ref string choiceParameter) {
    // Get the name of the currently selected RumiaAction
    string currentName = rumiaAction.ActionName;
    Type currentType = rumiaAction.GetSubRumiaAction()?.GetParameterType();
        
    // Get all of the RumiaAction names
    List<string> choiceNames = choices.Select(e => e.ActionName).ToList();
                        
    // Get the ID of the currently selected RumiaAction
    int currentIndex = choices.First(e => e.ActionName.Equals(currentName)).ID;
        
    // Present the RumiaActions by a list of names and allow us to select a new one
    int chosenIndex = EditorGUILayout.Popup(currentIndex, 
            choices.Select(e => e.ActionName).ToArray());
    
    // Update the selected choice
    rumiaAction = choices[chosenIndex];
    
    ...
}

With this, when the scheduled time for an action elapses, the corresponding method chosen from the dropdown list is invoked on the matching pattern instance that was created at runtime.

As for scheduling, I made the class RumiaController that configured patterns get fed through, and the timings for each of these scheduled actions are determined when the scene is loaded in. Here, measure counts and rhythm within each measure are translated to seconds so that the trigger-time of each action can be determined.

public class RumiaController : MonoBehaviour {
    ...
    
    private IEnumerable<SchedulableAction> ScheduleRumiaMeasureList(IEnumerable<RumiaMeasure> measures, int measureNumber, bool forceOnStartMeasure) {
        List<SchedulableAction> ret = new List<SchedulableAction>();
        
        // Iterate through each measure
        foreach (RumiaMeasure measure in measures.Where(measure => measure != null)) {
            // Iterate through each instant (32nd note) in the measure
            for (int k = 0; k < ACTIONS_PER_MEASURE; k++) {
                RumiaActionList[] rumiaActionLists = measure.RumiaActionLists;
                ChoiceParameterList[] choiceParameterLists = measure.ChoiceParameterLists;
                if (rumiaActionLists[k] == null)
                    continue;
                
                // Iterate through each RumiaAction assigned to this instant
                List<RumiaAction> rumiaActions = rumiaActionLists[k].RumiaActions;
                List<string> choiceParameters = choiceParameterLists[k].ChoiceParameters;
                for (int l = 0; l < rumiaActions.Count; l++) {
                    RumiaAction rumiaAction = rumiaActions[l];
                    // Get the serialized parameter to pass into the RumiaAction when it comes time to invoke it
                    string parameter = choiceParameters[l];

                    // If the action is "None", ignore it
                    if (rumiaAction.ActionName.Equals(RumiaAction.NoneString))
                        continue;

                    // Factor in the start measure, which measure we're currently on, and which part of the measure we're currently on
                    int elapsedThirtySecondNotes =
                        Config.StartMeasure * ACTIONS_PER_MEASURE + measureNumber * ACTIONS_PER_MEASURE + k;
                    float triggerTime = timingController.GetThirtysecondNoteTime() * elapsedThirtySecondNotes;

                    ...
                                            
                    SchedulableAction schedulableAction =
                        new SchedulableAction(triggerTime, rumiaAction, GetPatternInstance, parameter);
                    ret.Add(schedulableAction);
                }
            }
        }

        return ret;
    }
}

On every frame update, the RumiaController checks to see if any scheduled actions should be triggered. To trigger each action, it just invokes the matching method found in the instantiated pattern prefab.

private void Update() {
    ...
        
    timeElapsed += Time.deltaTime;
    int actionsCompleted = 0;
    foreach (SchedulableAction schedulableAction in queuedActions) {
        if (schedulableAction.TriggerTime < timeElapsed) {
            schedulableAction.PerformAction();
            actionsCompleted++;
        } else {
            // Since we assume that the queuedActions list is ordered by FireTime, we know that none of the remaining shots should be fired yet
            break;
        }
    }

    queuedActions.RemoveRange(0, actionsCompleted);
}

Serialization

Now for the nittiest of gritty.

The biggest challenge I encountered when making this system was figuring out how to serialize each configured action (so that the configured patterns would actually be saved) in a way that would allow the configured actions to appear in the Unity inspector.

The reason this was a challenge is due to an idiosyncrasy of Unity - namely, the Unity inspector does not handle polymorphism well when trying to serialize and display a collection of objects in the inspector.

I’ll clarify what that means with respect to this project. Each configurable measure (the thing that gets displayed in the Unity inspector) holds a collection of RumiaActions which keep track of the chosen methods and schedule those methods to be invoked at runtime, as discussed earlier. However, a piece of functionality that I wanted to add was the ability to pass along parameter values for these methods - if I want a “spawn the enemy” action to occur, I would also want to be able to configure a vector representing the position to spawn the enemy at. So, I need to be able to define a method and a vector for that RumiaAction. Then, maybe for another RumiaAction I would want to be able to configure the invocation of a method that takes in, say, a boolean parameter.

Configurable parameters

So, not all RumiaActions should take in the same parameter values, and most of them probably won’t need any parameters at all. My custom editor script needed to be able to display configurable parameter fields based on the actual parameters that the desired method requires. For this reason, I wanted RumiaActions to have several subclasses corresponding to different parameter types they could take in - a VectorRumiaAction, a BooleanRumiaAction, etc.

This is where the challenge I mentioned earlier comes in - if I provided a bunch of subclasses of RumiaAction and tried to configure them in the Unity inspector, I would not actually be able to do so because the Unity inspector can not serialize or display a collection of objects polymorphically in the inspector. Each entry into a RumiaAction collection would need to be of type RumiaAction rather than of any of its subclasses.

My way around this was admittedly messy. Instead of creating subclasses of RumiaAction, I put all of the parameter-type-specific functionality into subclasses of a new ISubRumiaAction interface within the RumiaAction class. When creating a RumiaAction, an ISubRumiaAction corresponding to the method’s parameter type is chosen and assigned to a field within the RumiaAction.

[Serializable]
public class RumiaAction {
    ...
    
    public IntSubRumiaAction Int;
    public FloatSubRumiaAction Float;

    public ISubRumiaAction GetSubRumiaAction() {
        switch (type) {
            ...
            case RumiaActionType.Int:
                return Int;
            case RumiaActionType.Float:
                return Float;
            ...
        }
    }
    
    ...
    
    private static RumiaAction CreateIntRumiaAction() {
        RumiaAction act = new RumiaAction {type = RumiaActionType.Int, Int = new IntSubRumiaAction()};
        return act;
    }

    private static RumiaAction CreateFloatRumiaAction() {
        RumiaAction act = new RumiaAction {type = RumiaActionType.Float, Float = new FloatSubRumiaAction()};
        return act;
    }
    
    ...
    
    public interface ISubRumiaAction {
        void InvokeRumiaAction(string serializedParameter);
        void GenerateRumiaActionEvent(MethodInfo method, Pattern target);
        Type GetParameterType();
    }

    ...
}

This way, a RumiaAction instance keeps track of the underlying type of the parameter value passed into it and handles all of the serialization, deserialization, and retrieval of that value when requested. This “fake” polymorphism allows for the effect of polymorphism to be retained (we can store different type-specific values inside a RumiaAction) while still fulfilling the Unity inspector’s requirement of each element in a serialized collection having the same exact type (the RumiaAction class does not have any child classes).

How is all of this actually serialized into the saved asset file? Each ISubRumiaAction class has a SerializeParameter(…) method and a DeserializeParameter(string serializedParameter) method, and their implementations serialize/deserialize the parameter values to/from strings. For example, this is how FloatSubRumiaActions serialize/deserialize their stored values:

[Serializable]
public class FloatSubRumiaAction : ISubRumiaAction {
    public FloatEvent OnRumiaAction;

    public void InvokeRumiaAction(string serializedParameter) {
        OnRumiaAction.Invoke(DeserializeParameter(serializedParameter));
    }

    ...

    public static string SerializeParameter(float b) {
        return $"{b:N4}"; // 4 decimal places
    }

    public static float DeserializeParameter(string serializedParameter) {
        if (serializedParameter == null || serializedParameter.Equals(""))
            return default;
        if (float.TryParse(serializedParameter, out float ret))
            return ret;
        throw new Exception("FloatSubRumiaAction: Unexpected serialized parameter value '"
                            + serializedParameter + "'");
    }
}

Final thoughts

With the help of Rumia, it becomes much easier to create additional content for the game. When I want to design a new bullet pattern with specific functionality, I can tag each method that I want to be configurable and then later select from those methods when configuring the timing of specific actions. The custom editor scripting works with the Unity inspector to provide an easy-to-read interface through which to configure this content. The mimicked polymorphism built into RumiaActions allows for flexibility in what sorts of things I can configure while still allowing for the serialization of these configured actions.

I’m continuing to work on a full version of this rhythm shmup project, but for now a finished prototype is available for play at this Download link.