Event Systems vs. Singletons: Am I Doing It Right?

Hello game developers!

I have a semi-question semi-discussion about Event Systems and Singletons in Unity. I’m currently developing a 2D tile-based colony simulation game. Players will manage a colony of civilians, build their base in the tile grid, manage a farm, etc. It will be similar in genre to games like RimWorld and Dwarf Fortress.

I can’t shake the feeling that I am doing something wrong when it comes to my development style, and that I’m making things difficult for myself. I’ll post the way I’m doing things, and if any of what I am doing is wrong or has a better solution, I’d love to hear it. Any resources would be super helpful!

I currently have five managers that manage different aspects of the game: Saving, the map and tiles, jobs (‘tasks’), backing out of menus, and managing the repository of tiles. Each has a customized script to manage its function.

In the game, the player will be able to place an order to build an object, like a wall or a floor. This means this information needs to be given by the player, then it is processed into a visual on the tilemap, and as information to the AI civilians to build it. A screenshot is attached of the BuildOrderManager script.

9845910--1417053--BuildOrderManager.png

It holds an order map, so that the game can visually display the order on the tilemap. It has a real map, so that when the order is finished being constructed, the real physical tilemap can be updated. It has a togglegroup, so that clicks can only occur when the toggle is enabled (i.e., the button on the UI is pressed and build mode is enabled). It has a tabgroup, a list of buttons to change what tile order to build. It has a JobManager, which feeds the information to the jobs so that the AI knows where and what to build and adds it to a queue to build. Finally, there is a list of tile information on how different tiles should behave (WoodWall, DirtFloor, RustedWall…).

This feels like a singleton workflow and I fear it may lead to hardship later down the line. However, I’m unsure of how to implement an Event System. Would placing each tile be a unique event, and then for each event made it adds it to the job queue which is a listener of the Event System? I have another script that handles backing out of menus, such that pressing Escape will back the user out or deselect something the same as pressing the back button. Would pressing Escape be another event, with a manager listening and handling the queue of what to back out of (such that only the latest entry is backed out of, not all menus)?

I feel like my current direction is not good and is beginning to be difficult to work with. If I wanted a total overhaul, how might an example implementation work? I’ve seen videos on Event Systems (namely GameDevGuide’s video), but I couldn’t really figure out how to use it and what should be considered an event. Is everything an event? And, wouldn’t there still be managers anyway?

I’ve attached additional screenshots of some other managers I have in the game. Is this good workflow? Will this cause headaches later? How are Event Systems implemented?

Thanks you guys!!

Here’s the JobManager script, which manages a couple example jobs (DoConstruct, and DoWander). It needs a survivor, the order manager, the tilemap, etc. Here’s the script:

public class JobManager : MonoBehaviour
{
    public List<BuildOrderTile> BuildOrderTiles;

    public Transform jobTarget;

    public SurvivorAI survivor;

    public BuildOrderManager orderManager;

    public Tilemap map;

    public bool jobsEmpty = false;

    public bool isWorking = false;

    public bool isWandering = false;

    private void Start()
    {
        BuildOrderTiles = new List<BuildOrderTile>();
    }


    public IEnumerator DoConstruct()
    {
        isWorking = true;
        foreach(BuildOrderTile tile in BuildOrderTiles.ToList())
        {   
            //Get the transform of the tile

            jobTarget.transform.position = map.GetCellCenterWorld(tile.thisTilePosition);

            survivor.target = jobTarget;

            yield return new WaitForSeconds(0.5f);

            //While the distance between the survivor and this tile is too high, wait for the survivor to get there. This means DoConstruct will need to be a coroutine.

            while(!survivor.reachedEndOfPath)
            {
                yield return new WaitForSeconds(0.25f);
            }

            //Clear the survivor's target; reached destination
            //survivor.target = null;

            //When the distance is close enough and construction is not finished, begin construction of the tile. While the survivor is nearby, add, idk 1% progress every tenth of a second

            while(survivor.reachedEndOfPath && tile.constructionProgress != 100f)
            {
                tile.constructionProgress += 1f;
                yield return new WaitForSeconds(0.05f);
            }

            //When construction is 100%, send a command to the tile to tell it it's finished. The tile should then turn itself into the tile it is meant to be, which it is associated with

            //Call the build order manager that this tile needs to turn into the associated tile
            orderManager.ConvertTile(tile);

            //Remove this tile from the queue
            BuildOrderTiles.Remove(tile);
        }
        jobsEmpty = true;
        isWorking = false;
    }

    public IEnumerator DoWander()
    {
        isWandering = true;

        GameObject WanderTarget = new(transform.name = "Wander Target");

        WanderTarget.transform.SetParent(this.transform);

        Vector3 randomDeviation = new(Random.Range(-5f, 5f), Random.Range(-5f, 5f), 0);

        WanderTarget.transform.position = survivor.transform.position + randomDeviation;

        survivor.target = WanderTarget.transform;

        while(!survivor.reachedEndOfPath)
        {
            yield return new WaitForSeconds(0.15f);
        }

       
        //Example
        yield return new WaitForSeconds(2f);
        isWandering = false;
        Destroy(WanderTarget);
    }
}

9845910--1417056--SaveGameManager.png
9845910--1417059--BackoutQueueManager.png

Programming patterns are just that, programming patterns. I wouldn’t feel too bad about using a particular one. Despite what some people say, don’t feel bad about using a Singleton if it solves a particular problem. But do keep aware if they start to create issues down the line and be prepared to potentially refactor them.

But you don’t need to use just the one pattern across your project. A healthy project is going to have a whole gamut of different patterns, usually multiple per system.

That said, I think if you’re making a game in this vein then the real solution here is to expressly separate your game state from the visuals. As in have the state of your game be pure data, then the visuals can monitor or hook into this data to update themselves accordingly.

This does solve a number of problems. If you start with a single object at the top of your data structure with everything branching out further, and store this in a component, other components can reference this component and just reach down into the data structure as necessary. This includes components that may modify it during gameplay, and others that keep visuals updated as to the current state.

If you plan ahead and keep all Unity objects out of your game state data, then you’ve already implemented your save game data. You can just serialise your whole game state as-is and deserialise it to restore everything as it were.

Gotcha! As expected, the answer is never black and white, haha. I’ll look into maybe implementing an events system for major events like character deaths or items spawning, something like that. I’ll see what I can work in, and if something doesn’t work keep it as a Singleton.

And, yes I’ve heard something about keeping the data and visuals separate. I think having the visuals as listeners to the data, just so the visuals are simply just representations of the data is smart. I’ll have to think about how to apply that to my game, but I’ll try something along those lines.

Would implementing something like that go along the lines of having a script which listens in to the what the data has to say, and telling the tilemap exactly what to render? Such that, the visuals are only based off of what the data has to say, and so the beams are never crossed? I’ll try something like that… Some kind of manager to link the two together, just by reading what the data has and directing the tilemap to render those tiles.

Thanks for the response!

That’s pretty much akin to what I’m doing in my current project, a 2d tilemap mining game akin to the old flash game Motherload with procedural environments. The proc-gen worlds are just pure data, called the WorldGrid. It gets stored in a WorldGridContainer component. When one is applied to said component (either a new one or one loaded from save data), the WorldGridRender is listening to this, and does a full update of the tilemap visuals. And then it hooks into other events such as when tiles are destroyed or damaged.

Your project is very different of course, more akin to a simulation. Though you can definitely apply these same principles.

This could result in a big overhaul. I would suggest probably trying out the concept in isolation and if you think it will work, build the system in parallel to what you already have before swapping it out.