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