How To Organize Your Code In a Modular Way (Knit/Matter/OOP Alternative)

Hello. I was responding recently under someone’s thread asking about Knit and if it’s useful and I thought that I’ve been working long enough with my custom framework that I might make a tutorial in case anyone finds it helpful. I was heavily inspired by this thread, but the way I structure my code has evolved a lot to suit my specific needs. If you’re looking for a way to structure your code that takes full advantage of Luau, works well in all workflows, and doesn’t have any excessive APIs or steep learning curves, this is probably for you.

Services

Services are definitely the bulk of where my code goes. These control the different systems of your game, and can be called upon to do actions. I also usually organize them to control a section of data, and that single service is responsible for handling that data. Services are module scripts that can be found in ReplicatedStorage > Services.

ObjectService

To give an example of one of the services in my game, I’m going to introduce you to ObjectService. ObjectService is responsible for handling all of the objects in my game. Objects are strictly typed and have very specific properties (See data section).

Let’s look at this ObjectService.CreateNew function.

function ObjectService.CreateNew(name : string, player : Player?) : Object
    if isServer then
        local data : Object = table.clone(Objects.GetObject(name))
        local model = ReplicatedStorage.Assets.Objects:FindFirstChild(data["assetName"], true)
        data.model = ModelService.CreateOnServer(model)
        if not _Data[player] then 
            _Data[player] = {}
        end
        _Data[player][data.name] = data
        return data
    else
        return ObjectEvents.ExecuteOnServer:InvokeServer("CreateNew", name, LocalPlayer)
    end 
end

This is an actual block of code from one of my games, and essentially I clone an object template and return a new object of type Object. I write most of my functions so that they are usable on the server and the client.

This is accomplished by using a helpful piece of code in each script (or in your init function if applicable that says):

local isServer = game:GetService("RunService"):IsServer()

if isServer then
  ObjectEvents.ExecuteOnServer.OnServerInvoke = function(_player, func, ...)
      return ObjectService[func](...)
  end
end
The self parameter

One thing to note is that not every function takes an object as its first parameter like you might see in an OOP approach. I use detailed names and Luau typechecking to only pass in the information that I need, not the entire object.

Data

The way I organize my data was taken largely from this video. While I didn’t agree with everything in the video, I did like his formatting so I’ll share some of it with you guys now.
I also store my Data in ReplicatedStorage>Data.

ObjectData

As a continuation of the data handled in ObjectService, I want to show you what the Object data looks like, and what kind of properties it might contain.

ObjectData.luau

export type ObjectCategory = "Decoration" | "Amenity" 

export type Object = {
    name: string,
    currentLevel: number,
    maxLevel: number,
    cost: number,
    timeRemaining: number,
    assetName: string,
    model: Model?,
    category : ObjectCategory?
}

Once again, this is stored in a module script. Then, as a child of the script, I have an “Objects” folder where each folder is the title of an object.

drinkmachine.luau

local ObjectData = require(script.Parent.Parent)

type Object = ObjectData.Object

local drinkmachine : Object = {
    name = "Drink Machine",
    currentLevel = 1,
    maxLevel = 3,
    cost = 250,
    cycleTime = 30,
    timeRemaining = 0,
    assetName = "vendingmachine_drinks",
    model = nil,
}

return drinkmachine

This helps me ensure everything is very strictly typed and modularized.

UI

This section is a little bit more optional, but I personally manage all my UI via code and test it using UI-Labs (which I highly recommend checking out by the way).

Basically, I build all my UI and then use Codify to convert it to code. By having this in a module script with related functions I can also keep all panel-specific animations or functionality together and then reference it in my services.

For example, I might have a function called

function Messages.createMessage(message : string) : Frame

and another one called

function Messages.fade(frame : Frame, transparency : number) : ()

Using UI-Labs also helps me figure out how to clean up my GUI easily. I have a Service called UIService where I basically create a “target” gui that destroys itself when I destroy all the frames inside.

function UIService.createScope(object) : ScreenGui
    local gui = Instance.new("ScreenGui")
    gui.Parent = LocalPlayer.PlayerGui
    object.Parent = gui

    -- If no more children, clean up gui :)
    gui.ChildRemoved:Connect(function()
        if #gui:GetChildren() == 0 then
            gui:Destroy()
        end
    end)

    return gui
end

UIService also has some handy helper functions that can be applied across different UI scripts.

Conclusion

This is the way that I modularize my code and keep game functions as separate as possible. Please feel free to criticize or ask any questions. :slight_smile: Hopefully this was helpful to at least one person.

11 Likes