‎tier - layered game framework

tier

layered game framework
v2.0


Download

Module:

Example place, includes my suggested setup:
(I recommend looking through this!)


Structure

tier consists of 3 layers of modules:

  1. systems (uses)
  2. managers (uses and used)
  3. providers (used)

This is to create a clear separation of concerns and modularity. They all follow “rules” about how they interact with other layers.


Rules

The framework does not force you to follow any rules. It’s up to you to ensure your modules follow the rules/structure of the framework.

Generally modules only require down the tree (systems > managers > providers). The only exception is Managers can require other Managers.

  • systems: have no public functionality and should not be required by other modules. Their purpose is to use the Managers and Providers to handle any logic

  • managers: can require other Managers and also require Providers. Their purpose is to manage more specific game functionalities

  • providers: have only public functionality and should not require other modules in the framework. They are foundational modules with modular functionality.


Lifecycle

  1. call tier.prepare

Server does some checks to make sure things are set up correctly.
Client will yield until the server finishes loading (can be changed for client-only games).

  1. Add your modules

Add all your modules to the framework via tier.gather or tier.gatherFromFirstFolders (or individually if choose)

  1. call tier.begin

tier will consecutively move through it’s stages, requiring the respective modules for the stage and their respective startup functions. Each layer’s startup functionality can be found in the “Module Startup” section.

Here are the stages the framework goes through:
image


Module Startup

  • systems
    Do not have any startup functions

  • managers
    The framework will check for a .Start. This will only be called after all the modules dependencies have started. Check out the ‘dependency injection’ for how to use this.

  • providers
    The framework will check for a .Start. This is not exactly important and does not serve much purpose, but it’s available for people who prefer it.


Dependency Injection

  • Only Managers have dependency injection!

Since Managers can require other Managers, they have a dependency injection system. This ensures there’s no race conditions where a Manager tries to use one of its dependencies before it has loaded.

To use dependency injection, add a “Needs” table to your module, and include and modules it depends on. Example:

local CoolClientManager = require(Managers.CoolClientManager)

local Manager = {
	Needs = {CoolClientManager} -- Will make sure dependencies load first
}

Managers also have an optional .Start which is only called after all of its dependencies have been started. Keep in mind dependency injection is optional.


Organization Examples

  • A camera module = fits into a Provider (CameraProvider). Why? The module offers foundational and modular functionality that will be used by other modules. The CameraProvider has no reason to be requiring other modules in the framework.

  • An effect module = fits into a Manager (EffectManager). Why? The module offers public functionality that could possibly be used by other Managers or Systems, but could possibly need to require the CameraProvider for effects.

  • A game loop module = fits into a System (GameLoopSystem). Why? The module handles the core loop of the game. It requires multiple different Managers and Systems to do things at certain times. This module offers no public functionality as its only purpose is to keep things running in a loop.


API

tier.getStage(): (string?, number?) 
-- returns the name of the stage, and the stage index number. Will return nil, nil if the framework has not been prepared yet.
tier.prepare()
-- prepares the framework
tier.gather(From: Instance, Deep: boolean, Add: (ModuleScript) -> (), Filter: ((ModuleScript) -> (boolean?))?)
-- auto gathers modules from a directory (from)
-- "deep" to use GetDescendants instead of GetChildren
-- adds them to the framework using the Add function
-- optional "filter" for filtering modules out of the selection

-- Example: tier.gather(SystemsFolder, false, tier.system, ModuleScriptCallback)
tier.gatherFromFirstFolders(From: Instance, Add: (ModuleScript) -> (), Filter: ((ModuleScript) -> (boolean?))?)
-- auto gathers modules from a directory (from)
-- if a folder is found, it will also gather from that folder (this is not recursive; it only happens for the first layer, hence 'FromFirstFolders')
-- adds them to the framework using the Add function
-- optional "filter" for filtering modules out of the selection
-- this is especially useful for Systems because they have nothing that depends on them and can be easily moved around into folders for organization
tier.provider(ModuleScript: ModuleScript)
-- adds a provider to the framework
tier.manager(ModuleScript: ModuleScript)
-- adds a manager to the framework
tier.system(ModuleScript: ModuleScript)
-- adds a system to the framework
tier.begin()
-- starts the framework

Closing

Let me know your thoughts on my framework! It’s a concept that I’ve been formulating for a while now as I’ve worked with countless other frameworks. It may take a little to get used to, but ultimately synergizes very well with projects of any complexity.

I’m open to any critiques or concerns you may have, just let me know!


My very well made and intricate picture that represents the layers

6 Likes

This reminds me a bit about the architecture I’ve been designing for my game based on MVC (Model, View, Controller), it looks really interesting.

1 Like

I realized the example place was a little messed up. Not entirely sure why but I’m thinking it’s because I didn’t save.

Anyways everything should be fixed now

1 Like

looks good, how i can access managers and providers?

I’m sorry but I dont know what you mean by this. If youre wondering where they are stored, here’s a screenshot of how things are set up in the example place:

image

All the modules already in the framework are just examples

I mean, is there any way i could access a provider or manager via-script/localscript? or from a system script

This framework follows a SSA (single-script-architecture). This means no normal scripts should be interacting with modules in the framework. There should ideally be 1 script per-context that runs the framework (like in the example place).

Inside the framework, the layer that acts most like normal scripts are Systems, since they offer no public functionality. If a system were to require a Manager or a Provider in the framework, it’d look something like this:

(just an example)

With systems, you don’t have to worry about waiting for the lower layers (managers & providers) to load since the framework loads the layers in consecutive order. I suggest reading the “Lifecycle” and “Module Startup” sections of the original post as they have more info on this

1 Like

:skull:

Anyways great module though, but I was wondering how does 1 script per context affect debuggability for performance/bottlenecks?

Because usually you’d use the script activity menu or microprofiler to check what your scripts are doing ( Also if even one of your managers/providers yield it would completely stop the entire framework :skull: )

If I had to guess, the error is probably because you have the new luau type solver beta enabled

Shouldn’t be much of a problem here. If you can’t find your module in MicroProfiler, just use debug.profilebegin and debug.profileend

I don’t really see this being an issue unless you are using while true do loops which I don’t recommend. But if you must, you can just spawn a new thread

Ah yeah I forgot to turn the beta off :skull:

Fair enough, but what about for games with, like, a large amount of modules interacting with eachother though? Idk, it seems like it would be hard to track the root of the problem

Currently only systems are task.spawned in the module:

I mean it’s a pretty easy fix but still yk

Alot of things can yield like :…Async() calls or :WaitForChild() or just regular loops

I’m using this framework in a personal game that so far has a lot of modules and have not had any problems that debug.profilebegin and debug.profilend cant solve

That is how it should be. Since other modules can require Providers, it only makes sense to allow them to all load first even if this means they yield. For managers, this is even more so the case since Managers can require other Managers, it’s important to allow them to yield because of their startup functions. If everything was spawned and ran at the same time, there would be no way to know when any module is safe to start using.

I dont see how this is a problem, sorry. As long as they aren’t yielding infinitely, these two scenarios should not be a problem. If you can personally find a real case where the modules yielding the framework have a problem I’d look into it but I can’t think of any cases myself.

If a Provider needs to do some heavy loading that yields upon loading, Managers should not be able to require that provider until it’s done loading, otherwise things will fall apart with race conditions.

1 Like

Ahh alright, I guess I’m just not used to making games with frameworks, I’ll try using this module in my future games :+1:
Also

Peak

1 Like

Sorry for this pretty stupid question.

I’m currently making a relatively complicated battlegrounds game.

I was wondering if this offers advantages for a semi complicated game over Knit framework.

Regardless of your answer. This is a good framework and I respect the effort put into it.

In my opinion, the biggest advantage is the clear separation of concerns. Having modules organized into certain layers based on their uses/usage can be pretty helpful when dealing with complex games since it makes it more obvious what each module does. Having just a bag of controllers/services gets pretty messy when you have a lot of functionality, making Knit not the best option. This comes from experience mind you, as I’ve used Knit in the past and this is exactly the problem I faced.

WaitForChild(Module) → require Module → use the functions
this functions as a ModuleScript, you’d require this the same way you require a normal ModuleScript
so you can use the functions in the module as long they are stored in the table it returns

Ah okay. I think I’ll keep using knit for the current project I’m working on since it’s not complicated enough to present any hierarchy issues.

But for more complicated projects, I’ll 100% be using this framework.

tier 2.0 update

A bunch of improved functionality and new features!


Added:

  • Added tier.gatherFromFirstFolders
  • Added a ‘commonly mistaken keys’ check. This is to prevent faulty, silent functionality. This can be disabled inside tier if you choose.

Changed:

  • All modules now startup using .Start (previously .Init)
  • Managers now have dependency injection (replacing the .Init & .Start structure)
  • tier is now a Roblox package (if you are not fond of this please let me know and why)

Removed:

  • Removed the ‘tasks’ counter, since it didnt serve much purpose

Changes to test place:

  • All example modules now print to showcase how tier loads
  • Added an example of Manager dependency injection for both contexts
  • Changed both context runners to use .gatherFromFirstFolders for Systems

Closing

Let me know your thoughts! The original post has been updated to include all the new changes.

Tier seems like a really powerful framework, I definitely recommend trying it. I also really appreciate the intricate image that represents the layers, really ties it all together for me.

1 Like