I make stuff

screenshot
◄ Previous article

AV-Racer Devlog (6): A Simple Function-Based Menu System

Monday, August 29, 2022

est. reading time: 8 minutes

In the process of game making, you would often come to the point in the development process where you'd need to implement a simple menu system. It is also a step that is very much prone to overthinking and over-engineering, and before you know it, you end up with an inflexible system that is difficult to work with and prone to bugs.

This usually happens when menus are implemented in such a way that their content is organized in predefined and often rigidly hierarchial data structures before live execution in the main loop. Approaches like this make menu systems difficult to scale as minor changes often require tedious backtracking and reorganization.

We can, however, approach this from a different perspective by implementing menus to be executed in a logical flow without defining any data structures. I have come to use this new approach in the menu system of AV-Racer and in this article I'm going to explain how it works.

UI functions

It's quite necessary here to credit the great Casey Muratori, who conceptualized immediate-mode GUI. It is only by using code that worked based on his concepts that I came to realize this different way to write my menus. Check out this great video of him explaining the concept if you haven't already.

In AV-Racer, the game was designed to be operated without a mouse. This includes the menus. In the following example we are controlling the menu with the keyboard.

Let's start by coding a button, using whatever render tools at our disposal. The process can be broken down to two basic elements, the logic that checks if a button was highlighted/pressed, and the render call (here SR_PushText()).

Since I am using SimplyRend as the rendering engine (you can read the article about that one here), the rendering calls are queued so we can conveniently write them alongside the logic calls.

        SR_Color C_Normal = SR_Color(0.5, 0.4, 0.4, 1);
        SR_Color C_Highlight = SR_Color(0.9, 0.4, 0.4, 1);

        bool highlighted = true; 

        SR_Color Color = highlighted? C_highlight : C_Normal;
        SR_PushText(font, Color, size, SR_Point(x,y), "I am a button");

        if (highlighted && Keys->Enter)
            {

    
This is a simple frameless button. It has two states, highlighted and non-highlighted, and a simple logic that gets executed when highlighted and clicked. Since we probably want multiple buttons in a menu we can add an index variable and toggle between the buttons with the arrow keys, clamping within the limits.

        SR_Color C_Normal = SR_Color(0.5, 0.4, 0.4, 1);
        SR_Color C_Highlight = SR_Color(0.9, 0.4, 0.4, 1);

        int n = 0;
        if (Keys->UpButton)
            n -= 1;
        else if (Keys->DnButton)
            n += 1;
        n = clamp(n, 0, 2);

        bool highlighted_0 = true; 
        SR_Color Color = highlighted_0? C_highlight : C_Normal;
        SR_PushText(font, Color, size, SR_Point(x,y+ size * 0), "I am a button 0");
        if (highlighted_0 && Keys->Enter)
            {

        bool highlighted_1 = true; 
        Color = highlighted_1? C_highlight : C_Normal;
        SR_PushText(font, Color, size, SR_Point(x,y + size * 1), "I am a button 1");
        if (highlighted_1 && Keys->Enter)
            {

        bool highlighted_2 = true; 
        Color = highlighted_2? C_highlight : C_Normal;
        SR_PushText(font, Color, size, SR_Point(x,y + size * 2), "I am a button 1");
        if (highlighted_2 && Keys->Enter)
            {

    
This can get quite repetitive and difficult to read really quick. Removing buttons or changing the order also requires going through the size *n iterations and correcting those. So abstracting each button to its own function can make the thing easier to work with. Assuming our menu has little to no style change across the game, we can also keep all the styling within the function. At the end we can return a boolean to whether the button was clicked or not. We can also pass the origin point as a pointer and increment it's Y component every time the button function is called, this reduces the amount of work needed to expand and change layouts, as the incrementing gets done automatically.


        bool menu_button(char* Text, SR_Point *P, int size, bool highlighted)
        {
            SR_Color C_Normal = SR_Color(0.5, 0.4, 0.4, 1);
            SR_Color C_Highlight = SR_Color(0.9, 0.4, 0.4, 1);
            Color = highlighted? C_highlight : C_Normal;
            SR_PushText(font, Color, size, *P, Text);
            P->y += size;

            return highlighted_2 && Keys->Enter;
        }
        
    
Now within the loop, every UI button gets rendered and the button logic gets called if any of them is clicked. We can then introduce different kind of UI elements like sliders and text boxes by wrapping their logic and rendering into functions and passing them a boolean for whether they were highlighted or not, and then execute the code afterif statements.

This approach allows for modularity; every type of UI object is a function that executes its own logic and renders itself, and that function gets called based on an index to which button is highlighted. This flows well and is easy to read.

        void RunMenu()
        {
            static int n = 0;

            if (Keys->UpButton)
                n -= 1;
            else if (Keys->DnButton)
                n += 1;
            clamp(&n, 0, 2);

            SR_Point P = {x,y};
            if (menu_button("I am button 0", &P, 24, (n == 0)) )
                {
            if (menu_button("I am button 1", &P, 24, (n == 1)) )
                {
            if (menu_button("I am button 2", &P, 24, (n == 2)) )
                {

        }

        

Menu Logic

What we did above basically runs a single menu. Next, we need to think about supporting multiple menus, and the ability to switch from one to the next. We can stick to working with functions here too. We used an index variable to flip between UI elements. So, similarly, we can use an index to keep track of what menu page we're on and wrap UI functions in if statements. As follows:

 
        void RunMenu()
        {
            static int n = 0;
            static int page = 0;
            
            if (Keys->UpButton)
                n -= 1;
            else if (Keys->DnButton)
                n += 1;
            clamp(&n, 0, 2);
                
            if (page == 0)
            {
                SR_Point P = {x,y};
                if (menu_button("I am button 0", &P, 24, (n == 0)) )
                    {
                if (menu_button("I am button 1", &P, 24, (n == 1)) )
                    {
            }
            else if (page == 1)
            {
                SR_Point P = {x,y};
                if (menu_button("I am button 0", &P, 24, (n == 0)) )
                    {
                if (menu_button("I am button 1", &P, 24, (n == 1)) )
                    {
                if (menu_button("I am button 2", &P, 24, (n == 2)) )
                    {
            }
            
        }
            
To improve this we can implement a "previous menu" integer that stores the index to the last thing we visited so we can refer "back" buttons to them. The advantage of having that is we can implement graph-like menu organization without much work. For example, if we can access the options menu from both the Main Menu and the Pause Menu, do we implement a duplicate? We can instead have one option menu accessible from both menus and the "back" button of the Options menu point to wherever we just came from. If we need multi-level nesting, we can then implement an array of those in a form of a menu "history".

We can also pass parameters to the menu function to know if we are calling the main or pause menu, and implement any necessary logic here, and use switch statements to take advantage of compiler warnings for unaccounted-for cases.

Putting all of this together:


        enum menu_names
        {
            MAIN_MENU,
            PAUSE,
            OPTIONS,
            VIDEO_OPTIONS
        };
 
        

        void RunMenus(int CallType)
        { 
            static int n = 0;
            static int page = 0;
            static menu_names PREV_MENU = 0;
            static int exitparam = 0;   
                                        

            if (Keys->UpButton)
                n -= 1;
            else if (Keys->DnButton)
                n += 1;
            clamp(&n, 0, 2);

            if (CallType == 0)      
                page = MAIN_MENU;
            else if (CallType == 1)
                page = PAUSE;
            
            switch (page)
            {
                case MAIN_MENU: 
                {
                    SR_Point P = {x,y};
                    if (menu_button("Play", &P, 24, (n == 0)) )
                        exitparam = 1; 
                    if (menu_button("Options", &P, 24, (n == 1)) )
                    {
                        PREV_MENU = page;
                        page = OPTIONS;
                        n = 0;
                    }
                    if (menu_button("Exit", &P, 24, (n == 1)) )
                        exitparam = 2; 
                    break;
                }
                case PAUSE:
                {
                    SR_Point P = {x,y};
                    if (menu_button("Continue", &P, 24, (n == 0)) )
                        exitparam = 1; 
                    if (menu_button("Options", &P, 24, (n == 1)) )
                    {
                        PREV_MENU = page;
                        page = OPTIONS;
                        n = 0;

                    }
                    if (menu_button("Main Menu", &P, 24, (n == 1)) )
                    {
                        page = MAIN_MENU;
                        n = 0;
                    }
                    break;
                }
                case OPTIONS:
                {
                    SR_Point P = {x,y};
                    if (menu_button("Video Options", &P, 24, (n == 0)) )
                    {
                        PREV_MENU = page;
                        page = VIDEO_OPTIONS;
                        n = 0;
                    }
                    if (menu_button("Back", &P, 24, (n == 1)) )
                    {
                        page = PREV_MENU;
                        n = 0;
                    }
                    break;
                }  
            }   
        }
            
We can think of the menus as functions that execute their own rendering code and logic in a loop, and upon exiting that loop, they output a change to the menu index taking us to the next menu.

After having the straightforward logic of the menu above, we can place it in the code to be executed. In AV-Racer, I isolated the code into its own loop within the game as I didn't need any of the game to be running in the background of menus. This effectively paused the game whenever I called the menu function, and resumed it whenever it exited. The advantage to this was isolating the debugging of both loops, the disadvantage was now I had to think about two loops.

Demonstration of the menu system:

Final thoughts

In the end, we have a functioning menu system without having to define any data structures. Every time we want to add a new submenu, we give it a name in the enum and add an else if (page == NEW_MENU) at the bottom of the if statements. And if we come across a case where we need to implement a new kind of UI object, a special kind of slider or a toggling button, we create a function for it and use it within the loop under its appropriate if menu branch.

There is a lot to gain from this to reduce the effort and time needed to be spent on menu making.
A menu function like this is very easy to debug. Since the flow of the function works the way the CPU goes through the code logic, you can find problems and fix them with ease. Bugs can be easy to spot since they isolate by nature, and since it's a big switch statement, only one menu branch is executed at time, guaranteed.

While the RunMenu() function can get quite long (in AV-Racer it's around 2000 LOC), it is very easy to navigate by searching for the menu integer variable name and jumping to the relevant if branch. Anecdotally, the menu system was by far the easiest chunk of code I worked with while developing this game.

It was very easy to expand on, it scales really well. Since everything was written in functions, there was no data structure organization and re-organization to worry about, so adding new menus and more complicated UI was straightforward. It was also very easy to debug, thanks to its isolated nature for each logical piece, and the fact that everything is in one place. When a UI element breaks, or when a menu doesn't work the way it should you know exactly where to look. To top it all, because we don't use data allocation and predefined content, there is virtually no memory bugs to be originating from this. Most of the time spent on a menu system like this will be in content design, since the code will "just work" because of its simplicity.

Thanks for reading.

-Wassim
◄ Previous article