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.

src/server/Systems/DamageSystem.luau
--!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 DamageSystem

AnimationEventController.luau

Timeline events from track time, loop-safe resets, handlers for hitbox, VFX, audio, combos.

src/client/Controllers/AnimationEventController.luau
--!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 AnimationEventController

HitboxSystem.luau

Profiles, overlap queries, optional cone vs look vector, 64 active cap.

src/server/Systems/HitboxSystem.luau
--!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 HitboxSystem

StatusEffectSystem.luau

Config-driven stacks, ticks, walk speed from cached base, replicate on change, restore when clear.

src/server/Systems/StatusEffectSystem.luau
--!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 StatusEffectSystem

Utilities and NPC glue

StateMachine.luau

Guarded transitions for rounds, modes, UI flows.

src/shared/Core/StateMachine.luau
--!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 StateMachine

ActionGate.luau

Priority and cooldowns per action.

src/shared/Core/ActionGate.luau
--!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 ActionGate

ServerNPC.luau and ChaseBehavior.luau

NPC from definition; behaviors loaded by name. Chase repaths on an interval into attack range.

src/server/Entities/ServerNPC.luau
--!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
src/server/Systems/Behaviors/ChaseBehavior.luau
--!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 ChaseBehavior

Stack 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.

01

Scoping

Define outcomes, users, constraints, and what already exists before writing code.

Core loop or product function
Audience and expectations
Budget, timeline, platform
Greenfield vs existing work

Short scope up front avoids expensive rework later.

02

Architecture

Rojo project layout: shared, server, client. Clear boundaries and server-owned data.

What runs where (shared / server / client)
Network contracts at the boundary
Load and save paths for profiles and economy
Luau or roblox-ts depending on team and scale

Structure is how combat and economy stay maintainable as content grows.

03

Development

Edit on disk, sync with Rojo, test in Studio. Incremental builds and visible progress.

Branches and small PRs
Tests on critical paths where it matters
Demos from a real place file
Modules stay in the tree you agreed on

You get playable milestones early, not a single drop at the end.

04

Launch & Handoff

Performance and edge cases, then docs your team can use without guessing.

Profiling: memory, frame time
Stress and edge testing
Deploy steps and rollback
Architecture notes and remote maps

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.