๐ฎ Controllers
Controllers in Shard are client-side modules responsible for handling:
- UI behavior
- Input handling
- Camera and viewport logic
- Local effects and sounds
- Client-specific gameplay interactions
Shard uses single file architecture ideology therefore controllers are to be made inside the src/shared/Controllers
folder/file.
Controllers are automatically loaded and parented to the client from the src/shared/Controllers
file using Shard's module lifecycle system.
The controller bootstrapping process will wait for the server to load before loading the client. This is done to ensure that all server-side dependencies are available before the client starts.
Each controller module can implement the following lifecycle methods:
Method | Purpose |
---|---|
new() | Optional constructor (inject dependencies if needed) |
initialize() | Called after construction, used to set up connections or state |
start() | Called after all modules are initialized |
Controllers are registered into
GetModule
automatically, so if you need to useGetModule
, use it in thestart()
method since all modules are guaranteed to be initialized at that point.
Controller Bootstrapper Raw Code
while workspace:GetAttribute("ServerLoaded") ~= true do
print("Waiting for server to load...")
task.wait()
end
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Framework = require(ReplicatedStorage.Configs.Framework)
local ReplicaClient = require(ReplicatedStorage.Libraries.Replica.ReplicaClient)
local GetModule = require(ReplicatedStorage.Packages.GetModule)
local Promise = require(Packages:WaitForChild("Promise"))
local start = tick()
GetModule.Debug = Framework.GET_MODULE_DEBUG
local totalModules = #script:GetChildren()
local loaded = 0
local initialized = {}
local function loadModule(module)
if Framework.DEV_MODE and RunService:IsStudio() then
task.spawn(function()
local success, err = pcall(function()
local required = require(module)
if required.new then
required = required.new({}) -- You can pass any arguments you want to the constructor here as DI
end
if required.initialize then
required:initialize()
end
initialized[module.Name] = required
GetModule:Add(required, module.Name)
end)
if not success then
warn(`๐ซ {err}`)
end
loaded += 1
end)
else
local required = require(module)
if required.new then
required = required:new({}) -- You can pass any arguments you want to the constructor here as DI
end
if required.initialize then
required:initialize()
end
initialized[module.Name] = required
GetModule:Add(required, module.Name)
end
end
Promise.new(function(resolve, reject)
for _, module in script:GetChildren() do
loadModule(module)
end
if Framework.DEV_MODE and RunService:IsStudio() then
while loaded < totalModules do
task.wait()
end
end
for name, module in initialized do
if module.start then
module:start()
end
print(`๐ Loaded {name}`)
end
resolve()
end)
:andThen(function()
print(`โ
Client Loaded Successfully in {tick() - start}s`)
ReplicaClient.RequestData()
end)
:catch(function(err)
warn(err)
end)
๐งช Basic Controller Exampleโ
With Object-Oriented Styleโ
local MyController = {}
MyController.__index = MyController
function MyController.new()
local self = setmetatable({}, MyController)
return self
end
function MyController:initialize()
-- This is called after the module is required
print("Initialized MyController")
end
function MyController:start()
-- Any functionality that needs to be run after all modules are loaded
print("Started MyController")
end
return MyController
With Stateless or Singleton Styleโ
local MyController = {}
function MyController:initialize()
print("Initialized MyController")
end
function MyController:start()
print("Started MyController")
end
return MyController
๐ฌ Advanced Controller Exampleโ
In more complex games, a single controller may need to manage many moving parts. A great way to stay organized is by breaking down responsibilities into components, such as separate modules for each UI page or feature. This promotes reusability and separation of concerns.
In this example, we'll create a UIController that loads and manages multiple UI page components. Each page is a standalone module, following the same lifecycle and supporting dependency injection (DI) โ such as passing down PlayerGui or shared services.
We'll also introduce type annotations, folder-based module resolution, and inter-controller communication via Dispatcher, showcasing best practices when building scalable systems in Shard.
UIControllerโ
local Players = game:GetService("Players")
local Player = Players.LocalPlayer
local PlayerGui = Player:WaitForChild("PlayerGui")
local UIController = {}
UIController.__index = UIController
type self = {
new: () -> UIController,
initialize: () -> (),
start: () -> ()
}
export type UIController = typeof(setmetatable({} :: self, UIController))
function UIController.new(): UIController
local self = setmetatable({} :: self, UIController)
return self
end
function UIController:initialize()
local pages = {}
for _, page in script.Pages:GetChildren() do
local pageModule = require(page)
-- We will pass the PlayerGui to each page so we don't have re-define it in each page.
-- This is called Dependency Injection (DI)
pages[page.Name] = pageModule.new(PlayerGui)
end
self.pages = pages
end
function UIController:start()
-- Here we can safely assume all pages have been initialized.
self.pages.Shop:open() -- Open the shop page
end
return UIController
Shop Pageโ
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Dispatcher = require(ReplicatedStorage.Packages.Dispatcher)
local Shop = {}
Shop.__index = Shop
type self = {
new: () -> Shop,
initialize: () -> (),
open: () -> (),
close: () -> ()
}
export type Shop = typeof(setmetatable({} :: self, Shop))
function Shop.new(): Shop
local self = setmetatable({} :: self, Shop)
return self
end
function Shop:initialize(PlayerGui)
self.Frame = PlayerGui:WaitForChild("ShopFrame")
self.CloseButton = self.Frame:WaitForChild("CloseButton")
self.Frame.Visible = false
-- Close the shop when the close button is clicked
self.CloseButton.MouseButton1Click:Connect(function()
self:close()
end)
-- Listen for events from other controllers
Dispatcher:Listen("OpenShop", function()
self:open()
end)
Dispatcher:Listen("CloseShop", function()
self:close()
end)
end
function Shop:open()
self.Frame.Visible = true
end
function Shop:close()
self.Frame.Visible = false
end
return Shop
๐ก Using ReplicaClient in a Controllerโ
Shard integrates Replica to sync player data to the client in real-time.
Replica is a powerful data synchronization library that allows you to manage and sync data between the server and client efficiently.
Normally when you want to notify the client of a data change, you would use RemoteEvents or RemoteFunctions. However, with Replica, you can simply use the ReplicaClient
to listen for changes.
By default, Shard will create a PlayerData
replica for each player. So whenever a change is made to a PlayerProfile, it will automatically sync to all clients.
You can define your Replica functions anywhere in your controller, the controller bootstrapper will automatically call RequestData()
after all controllers are initialized and started.
You can listen to data changes or access the current state like this:
local ReplicaClient = require(ReplicatedStorage.Libraries.Replica.ReplicaClient)
Replica.OnNew("PlayerData", function(replica)
-- Since it replicates to all clients, we need to check if this is the local player
local isLocal = replica.Tags.Player == Players.LocalPlayer
if not isLocal then
return
end
print(`Replica received client-side! Data:`, replica.Data)
replica:OnSet({"Cash"}, function(new_value, old_value)
print(`Cash has changed from {old_value} to {new_value}`)
end)
end)
You can listen to nested changes as well:
{ "Stats", "Health" }
Next: Services โ