Process
Framework code in two tabs. Luau samples match the public repo; roblox-ts samples are typed-stack examples from this site.
DamageSystem.luau
Pre and post hooks, crits, block and parry, NPCs and players, statuses, replication, knockback. Extend with hooks, not one giant function.
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Trove = require(Shared.Modules.Trove)
local Debug = require(Shared.Util.Debug)
local Registry = require(Shared.Core.Registry)
local EntityRegistry = require(Shared.Core.EntityRegistry)
local DamageSystem = { Name = "DamageSystem" }
type DamageRequest = {
Source: Model,
Target: Model,
BaseDamage: number,
DamageType: string?,
StatusEffects: { string }?,
Knockback: Vector3?,
CanCrit: boolean?,
CritMultiplier: number?,
}
type DamageResult = {
FinalDamage: number,
WasCrit: boolean,
WasBlocked: boolean,
WasParried: boolean,
StatusesApplied: { string },
TargetDied: boolean,
}
local _preDamageHooks: { (context: DamageRequest) -> DamageRequest? } = {}
local _postDamageHooks: { (context: DamageRequest, result: DamageResult) -> () } = {}
local _iframes: { [Model]: number } = {}
function DamageSystem:Init()
self._trove = Trove.new()
self:AddPreDamageHook(function(context)
local expiry = _iframes[context.Target]
if expiry and tick() < expiry then return nil end
return context
end)
self:AddPreDamageHook(function(context)
local chars = EntityRegistry:GetByType("Character")
for _, entity in chars do
if entity:GetCharacter() ~= context.Target then continue end
if entity:IsParrying() then
context._parried = true
return context
end
local defense = entity:GetDefenseState()
if defense.IsBlocking then
context._blocked = true
context._blockReduction = defense.BlockDamageReduction
end
break
end
return context
end)
end
function DamageSystem:ProcessDamage(context: DamageRequest): DamageResult
for _, hook in _preDamageHooks do
local result = hook(context)
if result == nil then
return {
FinalDamage = 0,
WasCrit = false,
WasBlocked = false,
WasParried = false,
StatusesApplied = {},
TargetDied = false,
}
end
context = result
end
local damage = context.BaseDamage
local wasCrit = false
if context.CanCrit and math.random() < 0.2 then
damage *= (context.CritMultiplier or 2)
wasCrit = true
end
local wasParried = context._parried == true
local wasBlocked = context._blocked == true
if wasParried then
damage = 0
elseif wasBlocked then
damage *= (1 - (context._blockReduction or 0.5))
end
damage = math.floor(damage)
local targetDied = false
local npcs = EntityRegistry:GetByType("NPC")
local hitNpc = false
for _, npc in npcs do
if npc:GetModel() == context.Target then
npc:TakeDamage(damage, nil)
targetDied = npc:GetHealth() <= 0
hitNpc = true
break
end
end
if not hitNpc then
local humanoid = context.Target:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid:TakeDamage(damage)
targetDied = humanoid.Health <= 0
end
end
local statusesApplied: { string } = {}
if context.StatusEffects then
local statusSystem = Registry:GetService("StatusEffectSystem")
for _, effectId in context.StatusEffects do
if statusSystem:ApplyEffect(context.Target, effectId, context.Source) then
table.insert(statusesApplied, effectId)
end
end
end
local result = {
FinalDamage = damage,
WasCrit = wasCrit,
WasBlocked = wasBlocked,
WasParried = wasParried,
StatusesApplied = statusesApplied,
TargetDied = targetDied,
}
for _, hook in _postDamageHooks do
hook(context, result)
end
Debug.Log("DAMAGE_LOGGING", `{damage} dmg -> {context.Target.Name} crit={wasCrit} block={wasBlocked}`)
local network = Registry:GetService("ServerNetwork")
network:FireAllClients("DamageNumber", {
Position = context.Target:GetPivot().Position,
Amount = damage,
IsCrit = wasCrit,
})
if context.Knockback then
local chars = EntityRegistry:GetByType("Character")
for _, char in chars do
if char:GetCharacter() == context.Target then
char:ApplyKnockback(context.Knockback, context.Knockback.Magnitude)
break
end
end
end
return result
end
function DamageSystem:SetIFrames(target: Model, duration: number)
_iframes[target] = tick() + duration
end
function DamageSystem:AddPreDamageHook(hook: (context: DamageRequest) -> DamageRequest?)
table.insert(_preDamageHooks, hook)
end
function DamageSystem:AddPostDamageHook(hook: (context: DamageRequest, result: DamageResult) -> ())
table.insert(_postDamageHooks, hook)
end
function DamageSystem:Destroy()
self._trove:Clean()
table.clear(_preDamageHooks)
table.clear(_postDamageHooks)
table.clear(_iframes)
end
return DamageSystemAnimationEventController.luau
Timeline events from track time, loop-safe resets, handlers for hitbox, VFX, audio, combos.
--!strict
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Trove = require(Shared.Modules.Trove)
local Signal = require(Shared.Modules.Signal)
local Registry = require(Shared.Core.Registry)
local Debug = require(Shared.Util.Debug)
local AnimationTimelines = require(Shared.Config.AnimationTimelines)
local AnimationEventController = { Name = "AnimationEventController" }
type EventHandler = (owner: Model, eventData: { [string]: any }?, track: AnimationTrack) -> ()
type ActiveTimeline = {
TimelineId: string,
Track: AnimationTrack,
Events: { { Time: number, Event: string, Data: { [string]: any }?, Fired: boolean } },
Heartbeat: RBXScriptConnection?,
StopConn: RBXScriptConnection?,
}
local _handlers: { [string]: EventHandler } = {}
local _active: { [string]: ActiveTimeline } = {}
local function makeKey(owner: Model, timelineId: string): string
return `{tostring(owner)}::{timelineId}`
end
function AnimationEventController:Init()
self._trove = Trove.new()
self.CustomEvent = self._trove:Add(Signal.new())
self:RegisterHandler("HitboxStart", function(owner, data)
local sys = Registry:GetController("ClientHitboxSystem")
if sys then
sys:RequestLocalHitbox({ Owner = owner, Profile = data and data.Profile, Origin = owner:GetPivot() })
end
end)
self:RegisterHandler("HitboxEnd", function(owner)
local sys = Registry:GetController("ClientHitboxSystem")
if sys then
sys:CancelActiveHitbox(owner)
end
end)
self:RegisterHandler("PlayVFX", function(owner, data)
local vfx = Registry:GetController("VFXController")
if vfx and data then
vfx:Play({ VFXId = data.VFXId, Parent = owner, Attachment = data.Attachment, Duration = data.Duration })
end
end)
self:RegisterHandler("StopVFX", function(owner, data)
local vfx = Registry:GetController("VFXController")
if vfx and data then
vfx:StopByTag(data.VFXId, owner)
end
end)
self:RegisterHandler("PlaySFX", function(owner, data)
local sfx = Registry:GetController("SFXController")
if sfx and data then
sfx:Play({ SoundId = data.SoundId, Parent = owner, Volume = data.Volume })
end
end)
self:RegisterHandler("CameraShake", function(_owner, data)
local cam = Registry:GetController("CameraController")
if cam and data then
cam:ShakeCamera(data.Intensity or 0.3, data.Duration or 0.1)
end
end)
self:RegisterHandler("EnableIFrames", function(_owner, data)
local net = Registry:GetController("ClientNetwork")
if net then
net:FireServer("PlayerAction", { action = "EnableIFrames", duration = data and data.Duration or 0.3 })
end
end)
self:RegisterHandler("DisableIFrames", function()
local net = Registry:GetController("ClientNetwork")
if net then
net:FireServer("PlayerAction", { action = "DisableIFrames" })
end
end)
self:RegisterHandler("BranchCombo", function(_owner, data)
local combat = Registry:GetController("CombatController")
if combat and data then
combat:_openComboWindow(data.NextTimeline, data.Window or 0.3)
end
end)
self:RegisterHandler("SpawnProjectile", function(_owner, data)
local net = Registry:GetController("ClientNetwork")
if net and data then
net:FireServer("AbilityUse", { abilityId = data.ProjectileConfig })
end
end)
self:RegisterHandler("ApplyRootMotion", function(owner, data)
if not data then return end
local root = owner:FindFirstChild("HumanoidRootPart") :: BasePart?
if not root then return end
local dir = Vector3.zero
if data.Direction == "Forward" then
dir = root.CFrame.LookVector
elseif data.Direction == "Backward" then
dir = -root.CFrame.LookVector
elseif data.Direction == "Right" then
dir = root.CFrame.RightVector
elseif data.Direction == "Left" then
dir = -root.CFrame.RightVector
end
local dist = data.Distance or 10
local dur = data.Duration or 0.2
root.AssemblyLinearVelocity = dir * (dist / dur)
task.delay(dur, function()
if root and root.Parent then
root.AssemblyLinearVelocity = Vector3.zero
end
end)
end)
self:RegisterHandler("StateTransition", function(_owner, data)
self.CustomEvent:Fire("StateTransition", data)
end)
self:RegisterHandler("Custom", function(_owner, data)
self.CustomEvent:Fire("Custom", data)
end)
end
function AnimationEventController:Start()
end
function AnimationEventController:RegisterHandler(eventTag: string, handler: EventHandler)
_handlers[eventTag] = handler
end
function AnimationEventController:UnregisterHandler(eventTag: string)
_handlers[eventTag] = nil
end
function AnimationEventController:PlayTimeline(timelineId: string, context: any): AnimationTrack
local timeline = AnimationTimelines[timelineId]
assert(timeline, `timeline not found: {timelineId}`)
local owner = context.Owner
local humanoid = context.Humanoid
local overrideData = context.OverrideData
local key = makeKey(owner, timelineId)
if _active[key] then
self:StopTimeline(owner, timelineId)
end
local animController = Registry:GetController("AnimationController")
local track = animController:Play(humanoid, timeline.AnimationId, timeline.Priority, timeline.FadeIn)
if timeline.Looping then
track.Looped = true
end
local events = {}
for _, evt in timeline.Events do
local mergedData = evt.Data
if overrideData and mergedData then
mergedData = table.clone(mergedData)
for k, v in overrideData do
mergedData[k] = v
end
elseif overrideData then
mergedData = overrideData
end
table.insert(events, { Time = evt.Time, Event = evt.Event, Data = mergedData, Fired = false })
end
table.sort(events, function(a, b) return a.Time < b.Time end)
-- time-based dispatch polling track.TimePosition --
local lastPos = 0
local heartbeat = RunService.Heartbeat:Connect(function()
if not track.IsPlaying then return end
local pos = track.TimePosition
for _, evt in events do
if evt.Fired then continue end
if pos >= evt.Time then
evt.Fired = true
local handler = _handlers[evt.Event]
if handler then
Debug.Log("ANIMATION_EVENT_LOGGING", `{timelineId}: {evt.Event} @ {evt.Time}`)
task.spawn(handler, owner, evt.Data, track)
end
end
end
-- reset consumed events on loop wrap --
if track.Looped and pos < lastPos then
for _, evt in events do
evt.Fired = false
end
end
lastPos = pos
end)
local stopConn: RBXScriptConnection
stopConn = track.Stopped:Once(function()
local entry = _active[key]
if not entry then return end
if entry.Heartbeat then
entry.Heartbeat:Disconnect()
end
_active[key] = nil
end)
_active[key] = {
TimelineId = timelineId,
Track = track,
Events = events,
Heartbeat = heartbeat,
StopConn = stopConn,
}
Debug.Log("ANIMATION_EVENT_LOGGING", `playing: {timelineId}`)
return track
end
function AnimationEventController:StopTimeline(owner: Model, timelineId: string)
local key = makeKey(owner, timelineId)
local entry = _active[key]
if not entry then return end
if entry.Heartbeat then
entry.Heartbeat:Disconnect()
end
if entry.StopConn then
entry.StopConn:Disconnect()
end
entry.Track:Stop(0.1)
_active[key] = nil
end
function AnimationEventController:IsTimelinePlaying(owner: Model, timelineId: string): boolean
local key = makeKey(owner, timelineId)
local entry = _active[key]
return entry ~= nil and entry.Track.IsPlaying
end
function AnimationEventController:Destroy()
for key, entry in _active do
if entry.Heartbeat then entry.Heartbeat:Disconnect() end
if entry.StopConn then entry.StopConn:Disconnect() end
entry.Track:Stop(0)
_active[key] = nil
end
table.clear(_handlers)
self._trove:Clean()
end
return AnimationEventControllerHitboxSystem.luau
Profiles, overlap queries, optional cone vs look vector, 64 active cap.
--!strict
local RunService = game:GetService("RunService")
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Trove = require(Shared.Modules.Trove)
local Debug = require(Shared.Util.Debug)
local Constants = require(Shared.Constants)
local HitboxProfiles = require(Shared.Config.HitboxProfiles)
local HitboxSystem = { Name = "HitboxSystem" }
type ActiveHitbox = {
Id: string,
Owner: Model,
Profile: any,
Origin: CFrame,
Filter: { Model }?,
Callback: ({ any }) -> (),
AlreadyHit: { [Model]: boolean },
TimeRemaining: number,
OverlapParams: OverlapParams,
}
local MAX_ACTIVE_HITBOXES = 64
local _active: { [string]: ActiveHitbox } = {}
local _activeCount = 0
function HitboxSystem:Init()
self._trove = Trove.new()
end
function HitboxSystem:Start()
self._trove:Connect(RunService.Heartbeat, function(dt)
self:Update(dt)
end)
end
function HitboxSystem:RequestHitbox(request: any): string
if _activeCount >= MAX_ACTIVE_HITBOXES then
Debug.Warn("HITBOX_VISUALIZATION", "hitbox cap reached, dropping request")
return ""
end
local id = HttpService:GenerateGUID(false)
local profile = request.Profile
if type(profile) == "string" then
profile = HitboxProfiles[profile]
assert(profile, `hitbox profile not found: {request.Profile}`)
end
local params = OverlapParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
local filterList = { request.Owner }
if request.Filter then
for _, model in request.Filter do
table.insert(filterList, model)
end
end
params.FilterDescendantsInstances = filterList
params.MaxParts = profile.MaxTargets * 4
_active[id] = {
Id = id,
Owner = request.Owner,
Profile = profile,
Origin = request.Origin or request.Owner:GetPivot(),
Filter = request.Filter,
Callback = request.Callback,
AlreadyHit = {},
TimeRemaining = profile.Duration,
OverlapParams = params,
}
_activeCount += 1
return id
end
function HitboxSystem:CancelHitbox(hitboxId: string)
if _active[hitboxId] then
_active[hitboxId] = nil
_activeCount -= 1
end
end
function HitboxSystem:Update(dt: number)
for id, hitbox in _active do
hitbox.TimeRemaining -= dt
if hitbox.TimeRemaining <= 0 then
_active[id] = nil
_activeCount -= 1
continue
end
local origin = hitbox.Origin
local profile = hitbox.Profile
local parts: { BasePart }
if profile.Shape == "Sphere" then
parts = workspace:GetPartBoundsInRadius(origin.Position, profile.Size.X / 2, hitbox.OverlapParams)
else
parts = workspace:GetPartBoundsInBox(origin * profile.Offset, profile.Size, hitbox.OverlapParams)
end
local results = {}
local hitCount = 0
for _, part in parts do
if hitCount >= profile.MaxTargets then break end
local model = part:FindFirstAncestorOfClass("Model")
if not model or model == hitbox.Owner then continue end
if hitbox.AlreadyHit[model] then continue end
local humanoid = model:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then continue end
if profile.Directional and profile.DirectionAngle then
local look = hitbox.Owner:GetPivot().LookVector
local toTarget = (model:GetPivot().Position - hitbox.Owner:GetPivot().Position).Unit
local angle = math.deg(math.acos(math.clamp(look:Dot(toTarget), -1, 1)))
if angle > profile.DirectionAngle / 2 then continue end
end
hitbox.AlreadyHit[model] = true
hitCount += 1
table.insert(results, {
Target = model,
Humanoid = humanoid,
HitPart = part,
Distance = (part.Position - origin.Position).Magnitude,
Direction = (part.Position - origin.Position).Unit,
})
end
if #results > 0 then
Debug.Log("HITBOX_VISUALIZATION", `hitbox {id}: {#results} hits`)
local ok, err = pcall(hitbox.Callback, results)
if not ok then
Debug.Warn("HITBOX_VISUALIZATION", `hitbox callback error: {err}`)
end
end
end
end
function HitboxSystem:GetActiveCount(): number
return _activeCount
end
function HitboxSystem:Destroy()
self._trove:Clean()
table.clear(_active)
_activeCount = 0
end
return HitboxSystemStatusEffectSystem.luau
Config-driven stacks, ticks, walk speed from cached base, replicate on change, restore when clear.
--!strict
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Trove = require(Shared.Modules.Trove)
local Debug = require(Shared.Util.Debug)
local Registry = require(Shared.Core.Registry)
local StatusEffects = require(Shared.Config.StatusEffects)
local StatusEffectSystem = { Name = "StatusEffectSystem" }
type ActiveEffect = {
DefinitionId: string,
Source: Model?,
Stacks: number,
TimeRemaining: number,
TickTimer: number,
}
local _activeEffects: { [Model]: { ActiveEffect } } = {}
local _baseWalkSpeed: { [Model]: number } = {}
function StatusEffectSystem:Init()
self._trove = Trove.new()
end
function StatusEffectSystem:Start()
self._trove:Connect(RunService.Heartbeat, function(dt)
self:Update(dt)
end)
end
function StatusEffectSystem:ApplyEffect(target: Model, effectId: string, source: Model?): boolean
local def = StatusEffects[effectId]
if not def then return false end
if not _activeEffects[target] then
_activeEffects[target] = {}
end
-- cache original walk speed on first effect application --
if not _baseWalkSpeed[target] then
local humanoid = target:FindFirstChildOfClass("Humanoid")
if humanoid then
_baseWalkSpeed[target] = humanoid.WalkSpeed
end
end
local effects = _activeEffects[target]
for _, existing in effects do
if existing.DefinitionId ~= effectId then continue end
if def.Stackable and existing.Stacks < def.MaxStacks then
existing.Stacks += 1
end
existing.TimeRemaining = def.Duration
return true
end
table.insert(effects, {
DefinitionId = effectId,
Source = source,
Stacks = 1,
TimeRemaining = def.Duration,
TickTimer = def.TickRate or 0,
})
if def.OnApply then
local ok, err = pcall(def.OnApply, target)
if not ok then
Debug.Warn("DAMAGE_LOGGING", `OnApply error for {effectId}: {err}`)
end
end
local network = Registry:GetService("ServerNetwork")
network:FireAllClients("StatusEffectApply", {
Target = target,
EffectId = effectId,
})
return true
end
function StatusEffectSystem:RemoveEffect(target: Model, effectId: string)
local effects = _activeEffects[target]
if not effects then return end
for i = #effects, 1, -1 do
if effects[i].DefinitionId ~= effectId then continue end
local def = StatusEffects[effectId]
if def and def.OnRemove then
local ok, err = pcall(def.OnRemove, target)
if not ok then
Debug.Warn("DAMAGE_LOGGING", `OnRemove error for {effectId}: {err}`)
end
end
table.remove(effects, i)
local network = Registry:GetService("ServerNetwork")
network:FireAllClients("StatusEffectRemove", {
Target = target,
EffectId = effectId,
})
break
end
if #effects == 0 then
self:_restoreWalkSpeed(target)
_activeEffects[target] = nil
end
end
function StatusEffectSystem:RemoveAllEffects(target: Model)
local effects = _activeEffects[target]
if not effects then return end
for _, effect in effects do
local def = StatusEffects[effect.DefinitionId]
if def and def.OnRemove then
pcall(def.OnRemove, target)
end
end
self:_restoreWalkSpeed(target)
_activeEffects[target] = nil
end
function StatusEffectSystem:HasEffect(target: Model, effectId: string): boolean
local effects = _activeEffects[target]
if not effects then return false end
for _, effect in effects do
if effect.DefinitionId == effectId then return true end
end
return false
end
function StatusEffectSystem:GetStacks(target: Model, effectId: string): number
local effects = _activeEffects[target]
if not effects then return 0 end
for _, effect in effects do
if effect.DefinitionId == effectId then return effect.Stacks end
end
return 0
end
function StatusEffectSystem:Update(dt: number)
for target, effects in _activeEffects do
local humanoid = target:FindFirstChildOfClass("Humanoid")
local speedMod = 1.0
for i = #effects, 1, -1 do
local effect = effects[i]
local def = StatusEffects[effect.DefinitionId]
if not def then
table.remove(effects, i)
continue
end
effect.TimeRemaining -= dt
if effect.TimeRemaining <= 0 then
if def.OnRemove then
pcall(def.OnRemove, target)
end
table.remove(effects, i)
continue
end
if def.TickRate and def.TickRate > 0 then
effect.TickTimer -= dt
if effect.TickTimer <= 0 then
effect.TickTimer = def.TickRate
if def.OnTick then
local ok, err = pcall(def.OnTick, target, effect.Stacks)
if not ok then
Debug.Warn("DAMAGE_LOGGING", `OnTick error for {effect.DefinitionId}: {err}`)
end
elseif def.TickDamage and humanoid then
humanoid:TakeDamage(def.TickDamage * effect.Stacks)
end
end
end
if def.SpeedModifier then
speedMod = math.min(speedMod, def.SpeedModifier)
end
end
if humanoid and humanoid.Health > 0 then
local base = _baseWalkSpeed[target] or 16
humanoid.WalkSpeed = base * speedMod
end
if #effects == 0 then
self:_restoreWalkSpeed(target)
_activeEffects[target] = nil
end
end
end
function StatusEffectSystem:_restoreWalkSpeed(target: Model)
local humanoid = target:FindFirstChildOfClass("Humanoid")
if humanoid and _baseWalkSpeed[target] then
humanoid.WalkSpeed = _baseWalkSpeed[target]
end
_baseWalkSpeed[target] = nil
end
function StatusEffectSystem:Destroy()
for target in _activeEffects do
self:_restoreWalkSpeed(target)
end
self._trove:Clean()
table.clear(_activeEffects)
table.clear(_baseWalkSpeed)
end
return StatusEffectSystemUtilities and NPC glue
StateMachine.luau
Guarded transitions for rounds, modes, UI flows.
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Signal = require(Shared.Modules.Signal)
local StateMachine = {}
StateMachine.__index = StateMachine
export type StateDefinition = {
Name: string,
OnEnter: ((data: any?) -> ())?,
OnExit: (() -> ())?,
OnUpdate: ((dt: number) -> ())?,
CanTransitionTo: { string }?,
}
function StateMachine.new(states: { StateDefinition }, initialState: string)
local self = setmetatable({}, StateMachine)
self._states = {} :: { [string]: StateDefinition }
self._currentState = nil :: StateDefinition?
self._currentStateName = ""
self.StateChanged = Signal.new()
for _, state in states do
self._states[state.Name] = state
end
self:TransitionTo(initialState)
return self
end
function StateMachine:GetState(): string
return self._currentStateName
end
function StateMachine:TransitionTo(stateName: string, data: any?)
local newState = self._states[stateName]
assert(newState, `state not found: {stateName}`)
if self._currentState then
if self._currentState.CanTransitionTo then
assert(
table.find(self._currentState.CanTransitionTo, stateName),
`cannot transition from "{self._currentStateName}" to "{stateName}"`
)
end
if self._currentState.OnExit then
self._currentState.OnExit()
end
end
local oldState = self._currentStateName
self._currentState = newState
self._currentStateName = stateName
if newState.OnEnter then
newState.OnEnter(data)
end
self.StateChanged:Fire(oldState, stateName)
end
function StateMachine:Update(dt: number)
if self._currentState and self._currentState.OnUpdate then
self._currentState.OnUpdate(dt)
end
end
function StateMachine:Destroy()
if self._currentState and self._currentState.OnExit then
self._currentState.OnExit()
end
self.StateChanged:Destroy()
end
return StateMachineActionGate.luau
Priority and cooldowns per action.
--!strict
local ActionGate = {}
ActionGate.__index = ActionGate
function ActionGate.new()
local self = setmetatable({}, ActionGate)
self._active = nil :: { Name: string, Priority: number }?
self._cooldowns = {} :: { [string]: number }
return self
end
function ActionGate:Request(actionName: string, priority: number): boolean
if self:IsOnCooldown(actionName) then return false end
if self._active then
if priority <= self._active.Priority then return false end
self:Release(self._active.Name)
end
self._active = { Name = actionName, Priority = priority }
return true
end
function ActionGate:Release(actionName: string)
if self._active and self._active.Name == actionName then
self._active = nil
end
end
function ActionGate:IsLocked(actionName: string): boolean
return self._active ~= nil and self._active.Name ~= actionName
end
function ActionGate:GetActiveAction(): string?
return if self._active then self._active.Name else nil
end
function ActionGate:SetCooldown(actionName: string, duration: number)
self._cooldowns[actionName] = tick() + duration
end
function ActionGate:IsOnCooldown(actionName: string): boolean
local expires = self._cooldowns[actionName]
if not expires then return false end
if tick() >= expires then
self._cooldowns[actionName] = nil
return false
end
return true
end
return ActionGateServerNPC.luau and ChaseBehavior.luau
NPC from definition; behaviors loaded by name. Chase repaths on an interval into attack range.
--!strict
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local ServerStorage = game:GetService("ServerStorage")
local Shared = ReplicatedStorage.Shared
local Signal = require(Shared.Modules.Signal)
local Server = ServerScriptService.Server
local ServerEntity = require(Server.Entities.ServerEntity)
local ServerNPC = setmetatable({}, { __index = ServerEntity })
ServerNPC.__index = ServerNPC
function ServerNPC.new(definition: any, spawnCFrame: CFrame)
local id = HttpService:GenerateGUID(false)
local self = ServerEntity.new({ Id = id, EntityType = "NPC" })
setmetatable(self, ServerNPC)
self._definition = definition
self._health = definition.Stats.MaxHealth
self._maxHealth = definition.Stats.MaxHealth
self._state = "Idle"
self._target = nil :: Player?
self._lastAttackTime = 0
self._spawnCFrame = spawnCFrame
self._behaviors = {} :: { any }
self._behaviorData = {} :: { [string]: any }
self._model = nil :: Model?
self.StateChanged = self._trove:Add(Signal.new())
self.DamageTaken = self._trove:Add(Signal.new())
self.Died = self._trove:Add(Signal.new())
self.TargetChanged = self._trove:Add(Signal.new())
self:_spawnModel(spawnCFrame)
self:_initBehaviors()
return self
end
function ServerNPC:GetState(): string
return self._state
end
function ServerNPC:SetState(newState: string)
if self._state == newState then return end
local oldState = self._state
self._state = newState
self.StateChanged:Fire(oldState, newState)
end
function ServerNPC:GetHealth(): number
return self._health
end
function ServerNPC:GetMaxHealth(): number
return self._maxHealth
end
function ServerNPC:GetDefinition(): any
return self._definition
end
function ServerNPC:TakeDamage(amount: number, source: Player?)
if not self:IsAlive() then return end
local finalDamage = math.max(0, amount - self._definition.Stats.Defense)
self._health = math.max(0, self._health - finalDamage)
self.DamageTaken:Fire(finalDamage, source)
if self._health <= 0 then
self:_die(source)
end
end
function ServerNPC:GetTarget(): Player?
return self._target
end
function ServerNPC:SetTarget(player: Player?)
if self._target == player then return end
self._target = player
self.TargetChanged:Fire(player)
end
function ServerNPC:CanAttack(): boolean
return tick() - self._lastAttackTime >= self._definition.Stats.AttackCooldown
end
function ServerNPC:RecordAttack()
self._lastAttackTime = tick()
end
function ServerNPC:GetModel(): Model?
return self._model
end
function ServerNPC:_die(killer: Player?)
self:SetState("Dead")
self.Died:Fire(killer)
end
function ServerNPC:_spawnModel(cf: CFrame)
local template = ServerStorage:FindFirstChild(self._definition.Model)
local model: Model
if template and template:IsA("Model") then
model = template:Clone()
else
-- placeholder for models not yet in ServerStorage --
model = Instance.new("Model")
local part = Instance.new("Part")
part.Name = "HumanoidRootPart"
part.Size = Vector3.new(2, 2, 1)
part.Anchored = false
part.Parent = model
model.PrimaryPart = part
local humanoid = Instance.new("Humanoid")
humanoid.MaxHealth = self._maxHealth
humanoid.Health = self._health
humanoid.WalkSpeed = self._definition.Stats.WalkSpeed
humanoid.Parent = model
end
model.Name = self._definition.DisplayName
model:PivotTo(cf)
model.Parent = workspace
self._model = model
self._trove:Add(model)
end
function ServerNPC:_initBehaviors()
local behaviors = self._definition.Behaviors
if not behaviors then return end
local behaviorConfig = self._definition.BehaviorConfig or {}
for _, behaviorName in behaviors do
local ok, behaviorModule = pcall(function()
return require(Server.Systems.Behaviors[behaviorName])
end)
if ok and behaviorModule then
behaviorModule.Init(self, behaviorConfig[behaviorName])
table.insert(self._behaviors, behaviorModule)
end
end
end
function ServerNPC:Destroy()
for _, behavior in self._behaviors do
if behavior.Destroy then
behavior.Destroy(self)
end
end
ServerEntity.Destroy(self)
end
return ServerNPC--!strict
local PathfindingService = game:GetService("PathfindingService")
local ChaseBehavior = { Name = "ChaseBehavior" }
function ChaseBehavior.Init(npc: any, config: { [string]: any }?)
npc._behaviorData.ChaseBehavior = {
pathTimer = 0,
recalcInterval = config and config.RecalculateInterval or 0.3,
}
end
function ChaseBehavior.Update(npc: any, dt: number)
if npc:GetState() ~= "Chasing" then return end
local data = npc._behaviorData.ChaseBehavior
if not data then return end
local target = npc:GetTarget()
if not target or not target.Character then
npc:SetState("Idle")
return
end
local model = npc:GetModel()
if not model then return end
local humanoid = model:FindFirstChildOfClass("Humanoid")
if not humanoid then return end
local def = npc:GetDefinition()
local dist = (target.Character:GetPivot().Position - model:GetPivot().Position).Magnitude
if dist <= def.Stats.AttackRange then
npc:SetState("Attacking")
return
end
data.pathTimer -= dt
if data.pathTimer > 0 then return end
data.pathTimer = data.recalcInterval
local path = PathfindingService:CreatePath({
AgentRadius = 2,
AgentHeight = 5,
AgentCanJump = false,
})
local ok = pcall(function()
path:ComputeAsync(model:GetPivot().Position, target.Character:GetPivot().Position)
end)
if ok and path.Status == Enum.PathStatus.Success then
local waypoints = path:GetWaypoints()
if #waypoints > 1 then
humanoid:MoveTo(waypoints[2].Position)
end
else
humanoid:MoveTo(target.Character:GetPivot().Position)
end
end
function ChaseBehavior.Destroy(npc: any)
npc._behaviorData.ChaseBehavior = nil
end
return ChaseBehaviorStack choice
Luau for speed of iteration. roblox-ts when the codebase is large enough that types matter. Same server rules either way. We pick based on scope and who maintains the repo.
Scoping
Scoping
Define outcomes, users, constraints, and what already exists before writing code.
Short scope up front avoids expensive rework later.
Architecture
Architecture
Rojo project layout: shared, server, client. Clear boundaries and server-owned data.
Structure is how combat and economy stay maintainable as content grows.
Development
Development
Edit on disk, sync with Rojo, test in Studio. Incremental builds and visible progress.
You get playable milestones early, not a single drop at the end.
Launch & Handoff
Launch & Handoff
Performance and edge cases, then docs your team can use without guessing.
Handoff means your team can run and extend the project without me in the loop.
How I ship
Standards
Performance
Measure frame time and memory on Roblox before calling it fast.
Structure
Folders and names match the framework so new work has a clear home.
Logging
Enough logging to debug production issues.
Handoff
Notes on architecture, remotes, and deploy so your team can own it.
Next step
Tell me about the build
Share scope, timeline, and what you need from a collaborator. I will reply with a clear path forward.