Which client-server module structure should be better?

Hi, I’m developing a game, but I’m constantly wondering about the structure of my code. I’m writing all the code in module scripts, but the problem is how to structure it on the server and the client. For example, I want to create notification system. To do this, I’ll write two module scripts: a controller on the client and a handler on the server:

-- On the client
-- ReplicatedStorage/Controllers/NotificationController
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local NotificationController = {}
NotificationController.__index = NotificationController

export type NotificationController = {
	ShowNotification: (self: NotificationController, title: string) -> (),
}

local RE = ReplicatedStorage:WaitForChild("NotificationRE") :: RemoteEvent

-- private type
type NotificationControllerImpl = {
	shownNotifications: any,
} & NotificationController

-- Just a contructor
function NotificationController.new(): NotificationController
	local self: NotificationControllerImpl =
		setmetatable({}, NotificationController) :: any

	RE.OnClientEvent:Connect(function(...) self:ShowNotification(...) end)

	return self
end

-- Method to show the notification
function NotificationController.ShowNotification(
	self: NotificationControllerImpl,
	title: string
)
	-- create notification
end

return NotificationController.new()
-- On the server
-- ServerScriptService/Handlers/NotificationHandler
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local NotificationHandler = {}
NotificationHandler.__index = NotificationHandler

export type NotificationHandler = {
	SendNotification: (
		self: NotificationHandler,
		player: Player,
		title: string
	) -> (),
}

local RE = Instance.new("RemoteEvent")
RE.Name = "NotificationRE"
RE.Parent = ReplicatedStorage

function NotificationHandler.new(): NotificationHandler
	local self: NotificationHandler =
		setmetatable({}, NotificationHandler) :: any

	return self
end

-- Method to send an event to controller and then show the notification
function NotificationHandler.SendNotification(
	self: NotificationHandler,
	player: Player,
	title: string
)
	RE:FireClient(player, title)
end

return NotificationHandler.new()

(These are example classes)

This will allow me to require the controller from the client script or the handler from the server script to display a notification to the player. But I can wrote this differently, through making a single NotificationService module:

-- ReplicatedStorage/Services/NotificationService
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local NotificationService = {}
NotificationService.__index = NotificationService

export type NotificationService = {
	ShowNotification: (self: NotificationService, title: string) -> (),
	SendNotificationToPlayer: (
		self: NotificationService,
		player: Player,
		title: string
	) -> (),
}

local RE
if RunService:IsServer() then
	RE = Instance.new("RemoteEvent")
	RE.Parent = ReplicatedStorage
	RE.Name = "NotificationRE"
else
	RE = ReplicatedStorage:WaitForChild("NotificationRE") :: RemoteEvent
end

function NotificationService.new(): NotificationService
	local self: NotificationService =
		setmetatable({}, NotificationService) :: any

	if RunService:IsClient() then
		RE.OnClientEvent:Connect(function(...) self:ShowNotification(...) end)
	end

	return self
end

function NotificationService.SendNotification(
	self: NotificationService,
	player: Player,
	title: string
)
	assert(RunService:IsServer(), "This method can only be called from a server")

	RE:FireClient(player, title)
end

function NotificationService.ShowNotification(self: NotificationService, title: string)
    assert(RunService:IsClient(), "This method can only be called from a client")

    -- create notification
end

return NotificationService.new()

Which option is better to use? Not only for this example, but in general

Generally keeping code that does one thing in one place is a good practice.
For example, you might want to extract “NotificationRE” hardcoded value and store it in the beginning of the file to use it in the rest of the file.

Also seems you do not need one NotificationService.new method and use one interface for both cases because you do know the place you need it, it’s either local or server script. Also you know what are you going to do: either asking to show notifications or sending them; so it’s ok to call NotificationService.newClient or NotificationService.newServer and later use separate interfaces.

These are singleton modules, and that’s how I wrote the scripts (classes everywhere :slight_smile: ). You don’t need to call the new() method in these classes, because it’s already called when the module has required and simply returns the class.

But using newClient and newServer?

-- The end of module script
if RunService:IsClient() then
    return NotificationService.newClient()
else
    return NotificationService.newServer()
end

Like this?
But I think there is a better way to do it

it’s more like this:

--!strict

local module = {}

export type Server = {
	send_notification : (self : Server, text : string) -> ()
}

local function send_notification(self : Server, text : string) : ()
	print("send_notification", text)
end

export type Client = {
	show_notification : (self : Client, text : string) -> ()
}

local function show_notification(self : Client, text : string) : ()
	print("show_notification", text)
end

local ServerInstance : Server = {
	send_notification = send_notification;
}

function module.getServer() : Server
	return ServerInstance
end

-- returns new client
function module.newClient() : Client
	local client_instance : Client = {
		show_notification = show_notification;
	}
	return client_instance
end

local ClientInstance : Client = {
	show_notification = show_notification;
}

-- returns singleton
function module.getClient() : Client
	return ClientInstance
end

return module

I understood. But what about other situations? Is it even a good practise for making separate controllers (for client) and handlers (for server)?

And what for classes? Like in my game I have VehicleController, its registering a vehicle on a client (when its spawned) and constructs a ClientVehicle class, but for server I have VehicleHandler, which construct a ServerVehicle class. This also applies to classes in the vehicles like ClientWheel/ServerWheel, InteriourController/InteriourHandler etc

Well, as I understood, you can’t have one implementation that does both and have same interface.
You’ve tried in OP, but as you can see that implementation are literally two different classes, each method is split by if server then else end.

As for classes, interfaces and encapsulation it’s up to you and whatever works for you.
I’d like to keep things --!strict and with Roblox’s auto-completion helping me.

Yea, I always writing in strict mode, I think i will continue using Controller/Handler structure, because it’s very flexible and easy to understand, but a bit more time to write. Thank you for your responce)

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.