Help with Singleton Not Correctly Referencing

I apologize for the trivial question. I cannot reference the same singleton data from another script. Is my setup correct?

Here is my setup:

  • I have a module MobStatSingleton located in ServerScriptService.MobSystem
  • I have 2 server scripts located in ServerScriptService which reference the module script in bullet 1
    - server script named MobSpawner in ServerScriptService.MobSystem
    - server script named WeaponSystemListener in ServerScriptService.WeaponSystem

The issue:
The mobspawner initializes the singleton with data through :RegisterMobs. But when that same singleton is reference throught another script :TakeDamage() rather than referencing the same self.mobStats table it is empty as if though it initialized a new instance.

--MobSpawner.lua
local MobStatManager = require(ServerScriptService.MobSystem.MobStatManager )
--[[ this script spanws mobs, configures then and adds then to workspace. 
this is the simplified version ]]
local mob = mobTemplate:Clone()
MobStatManager:registerMob(mob, {attack = 10, health = 200, maxHealth = 200})
mob.Parent = workspace
--WeaopnListener.llua
--[[ Event listener that invoked when player hits non-humanoid mobs which is why this exists and not using humanoid. Note: this is the simplified version ]]
local MobStatManager = require(ServerScriptService.MobSystem.MobStatManager )
local function processMeleeAttack(player:Player, humanoid: Humanoid | ModuleScript)
     -- some code block here
     MobStatManager:takeDamage(victim, damage)
end

---- Events
MeleeAttackRequest  = Instance.new("RemoteEvent")
MeleeAttackRequest.Name = "MeleeAttackRequest"
MeleeAttackRequest.Parent = ReplicatedStorage.WeaponsSystem
MeleeAttackRequest.OnServerEvent:Connect(processMeleeAttack)

Here is the Single module MobStatManager. I created 2 variations in my attempt. Is the singleton setup correctly? In either case I run into the same problem where I cannot reference data within the singleton instance.

MobStatManager (Attempt 1 with typing):

--!nonstrict
--ServerScriptService.MobSystem.MobStatManager.lua 
--[[
	Stores all mob instances and stats
]]

local MobStatManager = {}
MobStatManager.__index = MobStatManager

-- Types -----------------------------------------

-- Define the type for the params table
type MobParams = {
	maxHealth: number?,
	health: number?,
	attack: number?
}

-- Define the type for the mobStats table
type MobStats = {
	[Instance]: {
		maxHealth: number,
		health: number,
		attack: number
	}
}

export type TypeMobStatManager = {
	_mobStats: MobStats,
	registerMob: (self: TypeMobStatManager, mob: Instance, params: MobParams?) -> (),
	takeDamage: (self: TypeMobStatManager, mob: Instance, damage: number) -> boolean
}

-- Default mob stats
local DEFAULT_MOB_STATS = {
	maxHealth = 100,
	health = 100,     
	attack = 10
} :: MobParams

-- Attributes for syncing with the client
local ATTRIBUTES = {
	HEALTH = "Health",
	MAX_HEALTH = "MaxHealth"
}

-- Singleton instance
local instance: TypeMobStatManager? = nil

-- Private constructor
local function new(): TypeMobStatManager
	local self = setmetatable({}, MobStatManager)
	self._mobStats = {}
	
	return self
end

-- Public method to get the singleton instance
function MobStatManager.getInstance(): TypeMobStatManager
	if not instance then
		instance = new()
	end
	
	return instance
end

--[[
    Registers a new mob with default or provided attributes.

    @param mob: Instance - The mob instance to register.
    @param params: table (optional) - Contains mob attributes:
        - maxHealth: number (default = 100)
        - health: number (default = 100)
        - attack: number (default = 10)
]]
function MobStatManager:registerMob(mob: Instance, params: MobParams?) : ()
	if not mob then return end

	-- Ensure params is always a valid table
	params = params or {} :: MobParams

	-- Populate _mobStats with provided or default values
	self._mobStats[mob] = {
		maxHealth = params.maxHealth or DEFAULT_MOB_STATS.maxHealth,
		health = params.health or DEFAULT_MOB_STATS.health,
		attack = params.attack or DEFAULT_MOB_STATS.attack
	}

	-- Sync data to client using attributes
	mob:SetAttribute(ATTRIBUTES.MAX_HEALTH, self._mobStats[mob].maxHealth)
	mob:SetAttribute(ATTRIBUTES.HEALTH, self._mobStats[mob].health)
end

--[[
    Applies damage to a mob and updates its health.

    @param mob: Instance - The mob instance to damage.
    @param damage: number - The amount of damage to apply.
    @return boolean - True if damage was applied, false otherwise.
]]
function MobStatManager:takeDamage(mob: Instance, damage: number) : boolean
	if not mob or not damage then return false end

	if not self._mobStats[mob] then 
		error("Error: E-012 Mob not registered on hit")
		return false
	end

	-- Apply damage
	local health = self._mobStats[mob].health
	local newHealthValue = math.max(health - damage, 0)
	self._mobStats[mob].health = newHealthValue

	-- Update health attribute (for client UI)
	mob:SetAttribute(ATTRIBUTES.HEALTH, newHealthValue)

	-- Handle mob death
	if newHealthValue <= 0 then
		self._mobStats[mob] = nil -- Remove from `_mobStats`
		-- TODO: Implement mob despawning logic
		-- mob:Destroy()
	end

	return true
end

return MobStatManager

MobStatManager (Attempt 2 without typing):

local module = {
	
	_mobStats = {}
}

local DEFAULT_MOB_STATS = {
	maxHealth = 100,
	health = 100,     
	attack = 10
}

local STAT_ATTRIBUTES = {
	HEALTH = "Health",
	MAX_HEALTH = "MaxHealth"
}

function module:registerMob(mob: Instance, params: MobParams?) : ()
	if not mob then return end

	-- Ensure params is always a valid table
	params = params or {} :: MobParams

	-- Populate _mobStats with provided or default values
	self._mobStats[mob] = {
		maxHealth = params.maxHealth or DEFAULT_MOB_STATS.maxHealth,
		health = params.health or DEFAULT_MOB_STATS.health,
		attack = params.attack or DEFAULT_MOB_STATS.attack
	}

	-- Sync data to client using attributes
	mob:SetAttribute(STAT_ATTRIBUTES.MAX_HEALTH, self._mobStats[mob].maxHealth)
	mob:SetAttribute(STAT_ATTRIBUTES.HEALTH, self._mobStats[mob].health)
end

function module:takeDamage(mob: Instance, damage: number) : boolean
	if not mob or not damage then return false end

	if not self._mobStats[mob] then 
		error("Error: E-012 Mob not registered on hit")
		return false
	end

	-- Apply damage
	local health = self._mobStats[mob].health
	local newHealthValue = math.max(health - damage, 0)
	self._mobStats[mob].health = newHealthValue

	-- Update health attribute (for client UI)
	mob:SetAttribute(STAT_ATTRIBUTES.HEALTH, newHealthValue)

	-- Handle mob death
	if newHealthValue <= 0 then
		self._mobStats[mob] = nil -- Remove from `_mobStats`
		-- TODO: Implement mob despawning logic
		-- mob:Destroy()
	end

	return true
end

return module
3 Likes

in the first attempt module try to return MobStatManager.getInstance()
and to rewrite it better make the _mobStats table a local table and transform every private property
of the singleton class to a local variable

When I make singletons I store them in the returned table. That way when the module is required it returns the singleton. For example:

manager = {}

function manager:Start() -- Do something like this instead of something like manager.new
end

function manager:Update()
    -- some code
end

return manager -- The return value *is* this singleton, instead of returning something that creates a singleton.

For singletons written this way, you don’t need to use metatables. The metatables in OOP are used for having multiple objects reference the same table to get common functions/methods, constants, and properties, but for a singleton you only have one object, so you don’t need to do this.

I would also make sure that the problem isn’t just the code using the default values because of one of the ternary expressions:

	if not mob then return end

	-- Ensure params is always a valid table
	params = params or {} :: MobParams

	-- Populate _mobStats with provided or default values
	self._mobStats[mob] = {
		maxHealth = params.maxHealth or DEFAULT_MOB_STATS.maxHealth,
		health = params.health or DEFAULT_MOB_STATS.health,
		attack = params.attack or DEFAULT_MOB_STATS.attack
	}

I created a new test file with just the Singleton and changed it to return MobStatManager.getInstance() at the end. In my small test file this works.

But when attempting it in my project it doesnt. Is there some sort of race condition/execution I should be aware of?

I double checked and the script does not execute or override the table where it would clear. I tried your suggestion and received same results unfortunately

1 Like

if it’s meant to be a code shared everywhere across the scripts, you can do this.

local module = {}
module.__index = module

function module.new()
	
	local self = {}
	self._mobStats = {}
	
	return setmetatable(self, module)
end

return module.new()

I tested your code and it’s able to share state across scripts. You can see that in this place file:

DevForumSingletonExample.rbxl (56.6 KB)

Have you tried printing out ._mobStats in the other script? Your module seems to work as intended as far as sharing the table.

I figured it out. The code in my first attempt was correct (except I had to return .GetInstance(). But thats the other reason which I didnt realize…

  1. Script 1 (Spawner) is running in LUAU Parallel using Actors. I assume the main thread could run but it seems like because they run in a different VM the context of global values is not shared between scripts. This means Singletons break and wont work.

So rather then calling :registerMob in my main parallel thread I simply add a listener whenever the Spawner added a mob. From there I am able to configure. Heres the complete code:

Then the last thing I added was a new server script to listen on mobs being added to a folder in workspace (ie Workspace.Enemies) to then configure from there:

-- ServerScriptService.MobSystem.MobStatListener.lua
-[[ This script listens on mobs being added by parallel chunking spawner which then configures their stat data objects]]
local ServerScriptService = game.ServerScriptService
local CollectionService = game.CollectionService
local MobStatManager = require(ServerScriptService.MobSystem.MobStatManager)


local ENEMIES_FOLDER = workspace.Enemies
local isReady = Signal.new("MobStatManagerListener")
-- if mob is added to folder then configure, if deletes then remove

ENEMIES_FOLDER.ChildAdded:Connect(function(child: Instance)
    MobStatManager:registerMob(child, { maxHealth = 200, health = 200, attack = 20 })
end)

Heres the fixed singleton (only thing changed was the last line including MobStatManager.getInstance())

--!nonstrict
--ServerScriptService.MobSystem.MobStatManager.lua 
--[[
	Stores all mob instances and stats
]]

local MobStatManager = {}
MobStatManager.__index = MobStatManager

-- Types -----------------------------------------

-- Define the type for the params table
type MobParams = {
	maxHealth: number?,
	health: number?,
	attack: number?
}

-- Define the type for the mobStats table
type MobStats = {
	[Instance]: {
		maxHealth: number,
		health: number,
		attack: number
	}
}

export type TypeMobStatManager = {
	_mobStats: MobStats,
	registerMob: (self: TypeMobStatManager, mob: Instance, params: MobParams?) -> (),
	takeDamage: (self: TypeMobStatManager, mob: Instance, damage: number) -> boolean
}

-- Default mob stats
local DEFAULT_MOB_STATS = {
	maxHealth = 100,
	health = 100,     
	attack = 10
} :: MobParams

-- Attributes for syncing with the client
local ATTRIBUTES = {
	HEALTH = "Health",
	MAX_HEALTH = "MaxHealth"
}

-- Singleton instance
local instance: TypeMobStatManager? = nil

-- Private constructor
local function new(): TypeMobStatManager
	local self = setmetatable({}, MobStatManager)
	self._mobStats = {}
	
	return self
end

-- Public method to get the singleton instance
function MobStatManager.getInstance(): TypeMobStatManager
	if not instance then
		instance = new()
	end
	
	return instance
end

--[[
    Registers a new mob with default or provided attributes.

    @param mob: Instance - The mob instance to register.
    @param params: table (optional) - Contains mob attributes:
        - maxHealth: number (default = 100)
        - health: number (default = 100)
        - attack: number (default = 10)
]]
function MobStatManager:registerMob(mob: Instance, params: MobParams?) : ()
	if not mob then return end

	-- Ensure params is always a valid table
	params = params or {} :: MobParams

	-- Populate _mobStats with provided or default values
	self._mobStats[mob] = {
		maxHealth = params.maxHealth or DEFAULT_MOB_STATS.maxHealth,
		health = params.health or DEFAULT_MOB_STATS.health,
		attack = params.attack or DEFAULT_MOB_STATS.attack
	}

	-- Sync data to client using attributes
	mob:SetAttribute(ATTRIBUTES.MAX_HEALTH, self._mobStats[mob].maxHealth)
	mob:SetAttribute(ATTRIBUTES.HEALTH, self._mobStats[mob].health)
end

--[[
    Applies damage to a mob and updates its health.

    @param mob: Instance - The mob instance to damage.
    @param damage: number - The amount of damage to apply.
    @return boolean - True if damage was applied, false otherwise.
]]
function MobStatManager:takeDamage(mob: Instance, damage: number) : boolean
	if not mob or not damage then return false end

	if not self._mobStats[mob] then 
		error("Error: E-012 Mob not registered on hit")
		return false
	end

	-- Apply damage
	local health = self._mobStats[mob].health
	local newHealthValue = math.max(health - damage, 0)
	self._mobStats[mob].health = newHealthValue

	-- Update health attribute (for client UI)
	mob:SetAttribute(ATTRIBUTES.HEALTH, newHealthValue)

	-- Handle mob death
	if newHealthValue <= 0 then
		self._mobStats[mob] = nil -- Remove from `_mobStats`
		-- TODO: Implement mob despawning logic
		-- mob:Destroy()
	end

	return true
end

return MobStatManager.getInstance()

My initial assumption was a race condition. So I went down the rabbit hole and assumed race conditions were happening. So I implemented a mutex lock with the singleton. I needed either way as Im managing many mobs. So as a kind thank you heres how to implement a mutex lock hashmap using a Signals library and a singleton!

--!nonstrict
--ServerScriptService.MobSystem.MobStatManager.lua 
--[[
	Stores all mob instances and stats
]]
local Signal = require(script.GoodSignal) 

local MobStatManager = {}
MobStatManager.__index = MobStatManager

-- Define the type for the params table
type MobParams = {
	maxHealth: number?,
	health: number?,
	attack: number?
}

-- Define the type for the mobStats table
type MobStats = {
	[Instance]: {
		maxHealth: number,
		health: number,
		attack: number
	}
}

export type TypeMobStatManager = {
	mobStats: MobStats,
	registerMob: (self: TypeMobStatManager, mob: Instance, params: MobParams?) -> (),
	isDead: (self: TypeMobStatManager, mob: Instance) -> boolean,
	takeDamage: (self: TypeMobStatManager, mob: Instance, damage: number) -> boolean
}

-- Default mob stats
local DEFAULT_MOB_STATS = {
	maxHealth = 100,
	health = 100,     
	attack = 10
} :: MobParams

-- Attributes for syncing with the client
local ATTRIBUTES = {
	HEALTH = "Health",
	MAX_HEALTH = "MaxHealth"
}

local STATES = {
	ISDEAD = "IsDead",
}

local _instance: TypeMobStatManager = nil
local _mobStats = {} -- Stores mob stats
local _mutexTable = {} -- Stores lock signals per mob

--[[ Private function: Acquires a lock for a mob 
	Using counter/semaphore to avoid deadlocks in the edge case the same mob is locked at the same time
	
	@PARAMS mob - key to the mutex table reference
]]
function MobStatManager._acquireLock(mob: Instance)
	if not mob then return end

	-- Initialize the lock if it doesn't exist
	if not _mutexTable[mob] then
		_mutexTable[mob] = {
			signal = Signal.new(),
			count = 0
		}
	end

	local mutex = _mutexTable[mob]

	-- Wait until the lock is free
	while mutex.count > 0 do
		mutex.signal:Wait() -- Wait for the signal
		task.wait() -- Yield to prevent CPU burning
		print("waiting wooo")
	end

	-- Acquire the lock
	mutex.count += 1
end

--[[ Private function: 
	Releases a lock for a mob and unblocks any other tasks waiting to acquirelock

	@PARAM mob - key to the mutex table reference
]]
function MobStatManager._releaseLock(mob: Instance)
	if not mob then return end

	local mutex = _mutexTable[mob]
	if not mutex then return end

	-- Release the lock
	mutex.count -= 1

	-- Handle edge case: count should never be negative
	if mutex.count < 0 then
		warn("Lock count is negative. Possible bug in locking mechanism.")
		mutex.count = 0
	end

	-- Notify waiting threads
	mutex.signal:Fire()
end

function MobStatManager.new()
	if _instance == nil then
		_instance = setmetatable({}, MobStatManager)
		_instance._mobStats = {}
	end
	
	return _instance
end

function MobStatManager:isDead(mob: Instance): boolean
	if not mob or not mob:IsA("Instance") then
		warn("Invalid mob provided to registerMob")
		return
	end


	local isDead = false
	if _mobStats[mob] then
		isDead = _mobStats[mob].isDead
	end
	
	return isDead
end

--[[
    Registers a new mob with default or provided attributes.

    @param mob: Instance - The mob instance to register.
    @param params: table (optional) - Contains mob attributes:
        - maxHealth: number (default = 100)
        - health: number (default = 100)
        - attack: number (default = 10)
]]
function MobStatManager:registerMob(mob: Instance, params: MobParams?) : ()
	if not mob or not mob:IsA("Instance") then
		warn("Invalid mob provided to registerMob")
		return
	end
	
	MobStatManager._acquireLock(mob)

	-- Ensure params is always a valid table
	params = params or {} :: MobParams

	-- Populate _mobStats with provided or default values
	_mobStats[mob] = {
		isDead = false,
		maxHealth = params.maxHealth or DEFAULT_MOB_STATS.maxHealth,
		health = params.health or DEFAULT_MOB_STATS.health,
		attack = params.attack or DEFAULT_MOB_STATS.attack
	}

	-- Sync data to client using attributes
	mob:SetAttribute(ATTRIBUTES.MAX_HEALTH, _mobStats[mob].maxHealth)
	mob:SetAttribute(ATTRIBUTES.HEALTH, _mobStats[mob].health)
	
	-- states
	mob:SetAttribute(STATES.ISDEAD, _mobStats[mob].isDead)
	
	MobStatManager._releaseLock(mob)
end

--[[ 
    Unregisters a mob from the mob table and clears any attributes associated to the model
    
    @PARAM mob - mob to unregister
]]
function MobStatManager:unRegisterMob(mob: Instance)
	if not mob or not mob:IsA("Instance") then
		warn("Invalid mob provided to registerMob")
		return
	end

	MobStatManager._acquireLock(mob)
	_mobStats[mob] = nil

	-- Remove attributes
	mob:SetAttribute(ATTRIBUTES.MAX_HEALTH, nil) 
	mob:SetAttribute(ATTRIBUTES.HEALTH, nil)    

	MobStatManager._releaseLock(mob)
end


--[[
    Applies damage to a mob and updates its health.

    @param mob: Instance - The mob instance to damage.
    @param damage: number - The amount of damage to apply.
    @return boolean - True if damage was applied, false otherwise.
]]
function MobStatManager:takeDamage(mob: Instance, damage: number) : boolean
	if not mob or not damage then return false end
	
	MobStatManager._acquireLock(mob)
	
	if not _mobStats[mob] then 
		_releaseLock(mob)
		error("Error: E-012 Mob not registered on hit")
	end

	-- Apply damage
	local health = _mobStats[mob].health
	health -= damage
	local newHealthValue = math.max(health, 0)
	_mobStats[mob].health = newHealthValue

	-- Update health attribute (for client UI)
	mob:SetAttribute(ATTRIBUTES.HEALTH, newHealthValue)

	-- Handle mob death
	if newHealthValue <= 0 then
		_mobStats[mob].isDead = true
		mob:SetAttribute(STATES.ISDEAD, _mobStats[mob].isDead)
	end
	
	MobStatManager._releaseLock(mob)

	return true
end

-- Return the singleton instance
return MobStatManager

OOP based singletons are generally a bad idea and you’re just bloating your codebase. Just use local variables and export a table with methods

Yes. Generally you want to avoid overhead and I absolutely agree in the general case. But with the mutex mechanisms this is an exception. Its a trade off. Regular modules put everything in global namespace. So now your fields and methods which are meant to be private are exposed. I opted for classes here to clearly define the code isolation. In this case locking/releasing mutex or accessing the inner mob or mutex table should never be accessed outside the module- but without OOP you can and this is bad practice.

Heres the refactored non OOP code which works fine but (see below for explanation):

--!nonstrict
-- ServerScriptService.MobSystem.MobStatManager.lua
--[[ 
    Stores all mob instances and stats
]]

local Signal = require(script.GoodSignal)

-- Default mob stats
local DEFAULT_MOB_STATS = {
	maxHealth = 100,
	health = 100,
	attack = 10
}

-- Attributes for syncing with the client
local ATTRIBUTES = {
	HEALTH = "Health",
	MAX_HEALTH = "MaxHealth"
}

-- 
local MobStatManager = {
	_mobStats = {},  -- Stores mob stats
	_mutexTable = {} -- Stores lock signals per mob
}

--[[ 
    Registers a new mob with default or provided attributes.
    
    @PARAM mob - mob to register
    @PARAM params - Table of params to override default values
]]
function MobStatManager.registerMob(mob: Instance, params: { maxHealth: number?, health: number?, attack: number? }?)
	if not mob or not mob:IsA("Instance") then
		warn("Invalid mob provided to registerMob")
		return
	end

	MobStatManager._acquireLock(mob)

	-- Use provided params or defaults
	params = params or {}

	MobStatManager._mobStats[mob] = {
		maxHealth = params.maxHealth or DEFAULT_MOB_STATS.maxHealth,
		health = params.health or DEFAULT_MOB_STATS.health,
		attack = params.attack or DEFAULT_MOB_STATS.attack
	}

	-- Change attributes which are replicated back to the client
	if not mob:GetAttribute(ATTRIBUTES.MAX_HEALTH) then
		mob:SetAttribute(ATTRIBUTES.MAX_HEALTH, MobStatManager._mobStats[mob].maxHealth)
	end
	if not mob:GetAttribute(ATTRIBUTES.HEALTH) then
		mob:SetAttribute(ATTRIBUTES.HEALTH, MobStatManager._mobStats[mob].health)
	end

	MobStatManager._releaseLock(mob)
end

--[[ 
    Unregisters a mob from the mob table and clears any attributes associated to the model
    
    @PARAM mob - mob to unregister
]]
function MobStatManager.unRegisterMob(mob: Instance)
	if not mob or not mob:IsA("Instance") then
		warn("Invalid mob provided to registerMob")
		return
	end

	MobStatManager._acquireLock(mob)
	MobStatManager._mobStats[mob] = nil

	-- Remove attributes
	mob:SetAttribute(ATTRIBUTES.MAX_HEALTH, nil) 
	mob:SetAttribute(ATTRIBUTES.HEALTH, nil)    

	MobStatManager._releaseLock(mob)
end

--[[ 
    Applies damage to a mob and updates its health.
    Returns `true` if damage was applied, `false` otherwise.
    
    @PARAM mob - the mob taking damage
    @PARAM damage - the amount of damage to apply
]]
function MobStatManager.takeDamage(mob: Instance, damage: number): boolean
	if not mob or type(damage) ~= "number" then return false end

	MobStatManager._acquireLock(mob)

	local mobData = MobStatManager._mobStats[mob]
	if not mobData then
		MobStatManager._releaseLock(mob)
		error("An Error occurred: Error MM814") -- mob hit not registered
	end

	-- Apply damage
	mobData.health = math.max(mobData.health - damage, 0)
	mob:SetAttribute(ATTRIBUTES.HEALTH, mobData.health)

	print("Mob HP:", mobData.health .. "/" .. mobData.maxHealth)

	-- Handle death
	if mobData.health <= 0 then
		MobStatManager._mobStats[mob] = nil
		print("Mob died:", mob)
	end

	MobStatManager._releaseLock(mob)

	return true
end

--[[ Private function: Acquires a lock for a mob 
	Using counter/semaphore to avoid deadlocks in the edge case the same mob is locked at the same time
	
	@PARAMS mob - key to the mutex table reference
]]
function MobStatManager._acquireLock(mob: Instance)
	if not mob then return end
	if not MobStatManager._mutexTable[mob] then
		MobStatManager._mutexTable[mob] = {
			signal = Signal.new(),
			count = 0
		}
	else
		MobStatManager._mutexTable[mob].count += 1
		MobStatManager._mutexTable[mob].signal:Wait() 
	end
end

--[[ Private function: 
	Releases a lock for a mob and unblocks any other tasks waiting to acquirelock

	@PARAM mob - key to the mutex table reference
]]
function MobStatManager._releaseLock(mob: Instance)
	if not mob then return end
	if MobStatManager._mutexTable[mob] then
		MobStatManager._mutexTable[mob].count -= 1
		if MobStatManager._mutexTable[mob].count == 0 then
			MobStatManager._mutexTable[mob].signal:Fire()
			MobStatManager._mutexTable[mob] = nil
		else
			MobStatManager._mutexTable[mob].signal:Fire()
		end
	end
end

return MobStatManager

In here user can modify the table outside the module (when they really should not without acquiring locks/release locks etc)

-- ServerScriptService.AnotherScript.lua
--[[ Very bad as this can introduce deadlocks where you accidentally overwrite the
 table without acquiring the lock while another thread is modifying. Also blocks 
other potential tasks waiting on a mutex lock that is never acquired then released.]]
MobStatManager = require(ServerScriptService.MobSystem.MobStatManager)
MobStatManager._mobStats[mob] = someMob:Clone()

Similarly they should never be able to call the locking mechanisms directly

-- Similar issue to above
-- ServerScriptService.SomeOtherScript.lua
MobStatManager = require(ServerScriptService.MobSystem.MobStatManager)
MobStatManager._acquireLock(someMob)

But yes OOP Singleton has overhead and should be generally avoided. This is an exception to the norm.

Why not just make the internal functions local? This is a singleton so it’s not exactly bound to an object instance

Ive tried and hopeless failed to get it to work. Im still new to lua. If you could point out how i can edit this code that’d be awesome.

Sorry for the late response; here:

local MobManager = {}

local function AcquireLock(mob: Instance)
    -- ...code
end

local function ReleaseLock(mob: Instance)
    -- ...code
end

function MobManager.PublicFunction()
    -- ...code
end

return MobManager

You can do the same thing with internal fields (turn them into local variables)

1 Like

No worries. I overcomplicated it. Follow the standard structure without using oop made implementation easier.