Is this a good way to structure a project in Roblox?

Hello! I have been practising new ways of structuring my projects and this time I had some practise with using module scripts as much as possible. I am looking for feedback mostly on the way I structured my code and the project and the readability of the whole thing, but any other feedback like optimisations, doesn’t matter how small, is also welcome.

Currently in my game, I have 2 “entities”, SadHog and MadHog. Each animal type has 1 module script each in ServerStorage responsible for Ai and some other functions. There is also a BaseEntity module script, which the 2 other module scripts inherit from using __index.

  • SadHog’s Ai includes them walking around. If a SadHog is attacked, they will defend themself by attacking back.
  • MadHog’s Ai includes them focusing on a random SadHog, attacking them until they’re killed, then focusing on another SadHog.

At the start of the game, 20 SadHogs spawn alongside with 1 MadHog

Here’s the file:
hoghoghoghog.rbxl (69.6 KB)

Here are the 3 module scripts used:

BaseEntity. SadHog and MadHog modules inherit from this

local module = {}

-- both SadHog and AngryHog have __index set to this module, so both should be able to use this function
-- in SadHog, there is a custom :Damage function, meaning this one should be used in only AngryHog
-- though I am still defining a :Damage function if I add more entities and for the sake of practise
function module:Damage(target,damageCount)
	target.Humanoid:TakeDamage(damageCount)
end

return module

SadHog

local module = {}
setmetatable(module,module)-- to use the __index function
module.__index = require(game.ServerStorage.EntityAI.BaseEnemy) -- inherits from BaseEnemy

-- New hogs are created using this function
function module.new(cframe)
	local body = game.ServerStorage.EntityModels.SadHog:Clone()
	body:PivotTo(cframe)
	body.Parent = workspace
	
	coroutine.wrap(module.onready)(body)
	return body
end

function module.onready(body)
	------ WALKING -----
	while true do
		-- to stop walkign when attacked
		if body.Attacking.Value == true then
			break
		end
		
		--finds position around entity
		local xOffset = math.random(-30,30)
		local zOffset = math.random(-30,30)
		local yOffset = body.HumanoidRootPart.Position.Y
		local goal = body.HumanoidRootPart.Position + Vector3.new(xOffset,yOffset,zOffset)
		
		-- moves entity
		body.Humanoid:MoveTo(goal)
		
		-- cooldown
		body.Humanoid.MoveToFinished:Wait()
		wait(math.random(30,100)/10)
		
	end
end

function module:Damage(target,damageCount,enemy)
	-- target is the thing *being* attacked
	target.Humanoid:TakeDamage(damageCount)
	
	if target.Attacking.Value then return end
	target.Attacking.Value = true
	
	-- new ai
	
	-- follow enemy and attck if close
	while wait() do
		
		--if dead
		if enemy.Humanoid.Health <= 0 then
			target.Attacking.Value = false
			module.onready(target) -- i assume that is pretty bad practise. returns hog to standard ai
			return
		end
		
		target.Humanoid:MoveTo(enemy.HumanoidRootPart.Position)
		
		--attacks
		local distance = (target.HumanoidRootPart.Position - enemy.HumanoidRootPart.Position).Magnitude
		if distance <= 10 then
			-- calls the damage function of the module which belongs to the entity attacked
			local entityAi = game.ServerStorage.EntityAI[enemy.EntityName.Value]
			require(entityAi):Damage(enemy,1,target)
		end
	end
end

return module

MadHog

local module = {}
setmetatable(module,module)-- to use the __index function
module.__index = require(game.ServerStorage.EntityAI.BaseEnemy) -- inherits from BaseEnemy

-- New hogs are created using this function
function module.new(cframe)
	local body = game.ServerStorage.EntityModels.MadHog:Clone()
	body:PivotTo(cframe)
	body.Parent = workspace
	
	-- question for devforum, is there an alternative to using coroutines everywhere?
	coroutine.wrap(module.onready)(body)
	return body
end

function module.onready(body)
	local enemy = getRandomSadhog()
	
	-- follow enemy and attck if close
	while wait() do
		--if dead
		if enemy.Humanoid.Health <= 0 then
			enemy = getRandomSadhog() -- gets anotyher hog to attack since current ones dead
		end

		body.Humanoid:MoveTo(enemy.HumanoidRootPart.Position)

		local distance = (body.HumanoidRootPart.Position - enemy.HumanoidRootPart.Position).Magnitude
		if distance <= 10 then
			-- doing this messy way cuz dont know how to coroutine functions with colons
			coroutine.wrap(function()
				-- calls the damage function of the module which belongs to the entity attacked
				local entityAi = game.ServerStorage.EntityAI[enemy.EntityName.Value]
				require(entityAi):Damage(enemy,2,body)
			end)()
		end
	end
end

-- self explanatory
function getRandomSadhog()
	local hogs = {}
	for _,obj in workspace:GetChildren() do
		if obj.Name ~= "SadHog" then continue end
		table.insert(hogs,obj)
	end
	
	return hogs[math.random(1,#hogs)]
end

return module

Thanks in advance!!

6 Likes

I do something similar- i might post later after work today.

I have a BaseMob which defines standard configurations for a data model. It holds things like health, name, uuid, stats, state and a queue for the states. Its a runtime state machine based off of queues (like final fantasy) so the AI can think couple steps ahead (gives player ability to animation cancel etc but in realtime). Then I have methods like pushState, popState (simple queue implementation).

Then from there I extend like in your example SadHog and variants. This is fine.

Ultimately the chunk of the logic will be within the ServerScripts which play and manage the animations. It also defines the states explicitly in an Eum like so Stats.Idle, State.Attack1 and State.Block. This is explained in the code below. This would be like the Animate script but serverside. It calls the data model with .new() constructor and updates the state of the model in there. I do it this way because it allows me to separate the AI logic from the data model. For example data model will contain the info, attacks, skills stats monster has but I can have 2 scripts for my monster. A good example of this is having multiple AnimateAI server scripts for boss difficulty.

Within this server script I have helper methods like playAnimation, cacheAnimation (i do this for performance reasons) , define the tasks and manage the queue. I the spawn 2 task.spawn one for processing the queue and another is for the ai to think/determine what to queue up and building out my action tree from there. The thinking task will determine what state to push to the queue depending on things like if targets acquired, if in range, health state etc. Heres a simple workflow I have in psuedo code. These variables are updated in helper methods.

first id define the possible actions like so:

-- note these are callback functions for the tree to lookup and randomly pick
local shortRangeTasks = {State,basicattack1, State,baiscattack2, State,slashAttack, State,aoespin}
local  longRangeTasks = {State,charge, State,teleport, State,throwSpear}
-- expand 

Then define the action:
local function basicAttack1() 
     -- play basic attack animation
     local animtrack, length = mob:playCachedAnimation(Monster.Skills.BasicAttack1_1)
     animTrack:Play()
     animTrack.stopped:connected()
          -- do your cleanup here
     end
    
     return length
end

local function teleport() 
    -- if you want to get fancy you can also send  an event to players (like when a boss is teleporting turn all player's screens dark
      eventDimPlayerCameras:FireAllClients()
     -- play basic teleport channel animation
      task.wait(1) --since aniamtion might take 1 second to channel
    -- move humnaoid to new position 
     -- play teleport animation end 
     -- i cache my animations so I can also get Length of the animation because animator:loadAnimation() may not guarantee it is fully loaded especially for fast animations less than a second. this custom method returns track to play and the length that I return so loop know how long to wait for after executing
     local animtrack, length = mob:playCachedAnimation(Monster.Skills.Teleport)
     animTrack:Play()
     animTrack.stopped:connected()
          -- do your cleanup here
     end
    
     return length
end

this is how i maintain my queue

maxqueuesize = 5 (only 5 tasks queue up at any given time)
processQueue() {
while()
    while queue length == 0
          task.wait(1)  -- spinlock until we have something to process
    
       taskToProcess = pop from queue
       int timeToWaitToProcess = taskToProcess[]() //execute it note the () because the task we popped is a callback function!
       
       task.wait(timeToWaitToProcess)
}

then in the thinking loop i do the following

function determineActions()
while true ()
       task.wait(1) -- wait one second to prevent burning cycles (idk if this is a thing for robolox)
       if queue length >= MaxQueueSize then return end -- just go to next cycle since queue is full
       target = findTargetWithinRange(50)  -- boss range for agro is 50 (we can drill down from here to add in behavior states and branch off from ranges)
       if targetDistance < 10 
           -- this will randomly pick the task from the list we defined above
           task =  randomNumberGenerarate(shortRangeTasks)
           queue.push(task)
       else if targetDistance < 20
           task =  randomNumberGenerarate(longRangeTasks)
       else
             // no target here, wander, teleport maybe buff or idle
             if no target
                     task = wander or idle2 (I liek to sneak in different idle variant)
             if target 
                    if health >= 50 then
                           task = buff
                    else
                           rask = heal
              if task == nil 
                  task = idle
                  warn("No valid conditions in Tree. Defaulting to idle") -- handle edge case to help debugging
              end
             -- lastly push our task
              queue.push(task)

Then Id play the animation either Birth or Idle upon adding the monster. Then spawn the tasks to run the AI to think

pushQueue(Idle)
task.spawn(processQueue)
task.spawn(determineActions)

Im currently implementing something like this in my game but a little more complex. Its scalable and since everything is server animator:loadAnimation the states are replicated. Humanoid data is replicated automatically as well. The only thing that is not replicated are events like fading player cameras. You dont want to replicate because say the monster can blind a single player via a skill you’d want it to only affect one player rather than all which the teleport example above uses.

Sorry for the word vomit… im on my lunch break :smiley: But I will edit this later (to make it more clear if needed)

3 Likes

Thank you so much!! You’ve introduced many new concepts I wasn’t aware of before and I am kinda excited to go through all of them and learn them lol. I hope you don’t mind if I ask you a couple of questions in the upcoming days.

2 Likes

Just to confirm that I understand, all the data and information are stored in a module script in a similar way to what I have now, however, all the ai and all the code that actually does stuff is in a server script?

if so, there is still a couple of things I am confused about.

  • What is the need for a queue? You said it is so the ai can think a couple of steps ahead but I would expect that would be bad considering the Ai needs to respond to things around it actively. I have tried searching about queues in final fantasy but I couldn’t find anything relevant.
  • From my understanding, the ai is not kept in a module script but in a normal server script, but what if there are multiple entities that share the same Ai? Ideally, I would imagine the ai code would be in a module script which other scripts extend from. Would that be the best way and how would I go about doing that?
  • How would you handle functions such as Damage? If I were to fling a sword at an entity, I would imagine the code responsible for calculating damage and dealing the damage would be within the entity itself rather than the sword for the sake of organisation, and to avoid repetition if more weapons were added. Should I do something similar to what I am already doing?
  • You said that you keep health in the module script. Why do you choose to do it this way rather than use the Health properties of Humanoids?

Side Note: I modified my code to be minimal and focus on the topic of module structure and the AI.

Yes correct. This was my design with the separation of data in mind. You can structure it however you like though. The data model allows me to have some form of structured data that I can have on both client and server for syncing states and network validation. The data model would contain simple primitive data (string for states, or vector3 for target and position etc) so you can have high throughput and scale up on many mobs. In this case since mobs are server owned its more straightforward. This thing becomes more important when you want to interact with the mob (ie. animation cancel their current attack/stagger them, apply status effects etc). Or in my case I have players setup in a similar manner where they have their own data model. So the datamodel is passed to and from the server to sync and validate states.

The system I describe before is a state machine but has a queue (also known as a Queued State Machine QSM). The advantage is that you can delegate heavier computation tasks in a secondary thread. Tasks in roblox (like in many other game engines) cannot run certain calls specific to the engine as those are handled by the main thread’s execution. So to get around this I process the AI’s tasks while creating more in an unblocking manner. The model follows a publisher/subscriber multithreaded method (but tbh to say that would be an oversimplification). And the AI implementation or logic behavior uses a Behavior Tree. This is how you can branch off difference states such as if rangeFromTarget is X then do these tasks or Y do these tasks and Z do these tasks. You split the decision in trees based off of conditions.

It depends on how you design your game. You can decouple and its based on preference. Best practice when designing is to decouple your data from the behavior logic but theres always trade offs and considerations. I have something like this:
ReplicatedStorage:

  • BaseMob (Base data model that youd inherit from)
  • Boar (Inherits from BaseMob, has hp defined, defense, walk speed, attack and animations )
  • Angry Boar (Inherits from Boar but has higher hp and defense. This has same walk speed and attack but maybe a 2 skills with animations to go along with it). Here is my example of how I use my BaseMo (which is a module):
local config = {
	doDebug = false
}
-- BaseMob.lua
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- custom service
local AnimationLoader = require(ServerScriptService:WaitForChild("MainServer"):WaitForChild("AnimationLoader"))
local InstanceManager = require(ServerScriptService.InstanceManager)
local StateEnum = require(ServerScriptService:WaitForChild("MobScripts"):WaitForChild("MobUtils"):WaitForChild("StateEnum"))
local UnitUtils = require(ServerScriptService.UnitUtils)
local PlayerData = require(ReplicatedStorage.DataModel.PlayerData)
-- events
local eventEnemyPlayAnimation = ReplicatedStorage.Events.Enemy.EnemyPlayAnimation
local enemyPlayDeathAnimation = ReplicatedStorage.Events.Enemy.EnmyPlayDeathAnimation
-- remote functions
local rfRequestUUID = ReplicatedStorage.Functions.RequestUUID
-- imported types
type TypeAnimation = PlayerData.TypeAnimation
-- types
type TypeBaseMob = {
	isDead: boolean,
	health: number,
	maxHealth: number,
	damage: number,
	previousState: any?,
	humanoid: Humanoid,
	UUID: string,
	randomSeed: number,
	animator: Animator,
	humanoidRootPart: BasePart,
	state: any,
	name: string,
	fullName: string,
	currentAction: thread?,
	activeAnimations: { [string]: boolean },
	currentAnimationState: string?,
	animationTracks: { [string]: TypeAnimation },
	actionQueue: { any },
	ChangeState: (self: BaseMob, newState: any) -> (),
	PlayAnimation: (self: BaseMob, animationName: string, blendTime: number?, interrupt: boolean?) -> AnimationTrack?,
	StopAllAnimations: (self: BaseMob) -> (),
	PushState: (self: BaseMob, newState: any) -> (),
	RevertState: (self: BaseMob) -> (),
	TakeDamage: (self: BaseMob, amount: number) -> (),
	cancelCurrentAction: (self: BaseMob) -> (),
	loadAnimations: (self: BaseMob, humanoid: Humanoid, animsToLoadTemplate: { [string]: TypeAnimation }) -> { [string]: TypeAnimation },
	disableCollision: (self: BaseMob) -> (),
}

local BaseMob = {}
BaseMob.__index = BaseMob

function BaseMob.new(instance:Instance, humanoid: Humanoid, health: number, damage: number): TypeBaseMob
	local self = setmetatable({}, BaseMob)
	-- param vars check
	if not health then
		warn("Health not defined using 100")
	end
	if not damage then
		warn("Damage not defined using 10")
	end
	---- standard mob vars
	self.isDead = false
	self.health = health or 100
	self.maxHealth = self.health
	self.damage = damage or 10
	-- other vars
	self.previousState = nil
	self.humanoid = humanoid
	self.UUID = InstanceManager.generateAndSetUUID(instance)
	self.randomSeed = UnitUtils.stringToNumber(self.UUID)
	self.animator = humanoid:FindFirstChildOfClass("Animator")
	if not self.animator then
		self.animator = Instance.new("Animator")
		self.animator.Parent = self.humanoid
	end
	self.humanoidRootPart = humanoid.Parent:WaitForChild("HumanoidRootPart")
	self.state = BaseMob:ChangeState(StateEnum.Idle)
	self.name = "Base Mob"
	self.fullName = "Base Mob"..self.UUID
	instance.Name = self.name
	self.humanoid:SetAttribute("UUID", self.UUID)
	self.currentAction = nil 
	---- animation vars
	self.activeAnimations = {}
	self.currentAnimationState = nil
	self.animationTracks = {}
	self.actionQueue = {}
	---- humanoid setup
	humanoid.MaxHealth = health
	humanoid.Health = health

	return self
end

local function deepCopy(originalTable)
	local copy = {}
	for k, v in pairs(originalTable) do
		if type(v) == "table" then
			v = deepCopy(v)
		end
		copy[k] = v
	end
	return copy
end

function BaseMob:cancelCurrentAction()
	if self.currentAction then
		task.cancel(self.currentAction)
		self.currentAction = nil
	end
end

function BaseMob:loadAnimations(humanoid, animsToLoadTemplate: { [string]: TypeAnimation }): { [string]: TypeAnimation }
	--local animations = deepCopy(animsToLoadTemplate)
	-- add template to aniamtion loader if not yet already
	if not AnimationLoader:setExists(self.name, animsToLoadTemplate) then
		AnimationLoader:preloadAnimationsByTemplate(self.name, animsToLoadTemplate)
	end
	
	local animationTracks = {}
	local timestamp
	for _, animData: TypeAnimation in pairs(animsToLoadTemplate) do
		timestamp = tick()
		local animation = AnimationLoader:getAnimationByName(self.name, animData.name)
		if not animation then
			animation = AnimationLoader:loadAnimation(self.name, animData)
			if not animation then
				warn("Something bad happened loading animation ", animData.name)
				continue
			end
		end
		local animationTrack = self.animator:LoadAnimation(animation)
		warn("Loading anim ", animData.name, " waiting for length...")
		task.spawn(function()
			-- unblocking task to wait for animation lengths needed for combat
			while true do
				if animationTrack.Length > 0.01 then
					break
				end
				if tick() - timestamp > 5 then
					warn("Animation ", animData.name, " failed to load for ", self.name)
					break
				end
				task.wait(0.5)
			end
		end)
		-- wait for duration to load otherwise will default to 0
		if animData.length and animData.length == 0 then
			animData.length = animationTrack.Length
		end
		animationTrack.Looped = animData.looped or false
		animationTrack.Priority = animData.priority
		animationTracks[animData.name] = animationTrack
	end
	print(self.name, " finished loading animations ", animationTracks)
	return animationTracks
end

function BaseMob:playAnimation(animationName: string, blendTime: number, interrupt: boolean): AnimationTrack
	blendTime = blendTime or 0
	interrupt = interrupt or false

	if interrupt then
		self:stopAllAnimations()
	end
	-- server initiates the track
	local animationTrack = self.animationTracks[animationName]
	animationTrack:Play(blendTime)
	
	return animationTrack
end

function BaseMob:stopAllAnimations()
	for _, track in pairs(self.animator:GetPlayingAnimationTracks()) do
		track:Stop()
	end
end

function BaseMob:playAnimationv1(state, blendTime: number, interrupt: boolean): AnimationTrack
	interrupt = interrupt or false
	blendTime = blendTime or 0.15
	-- check if animation is already playing if so then do not interrupt 
	-- anims we want to interrupt is hit 
	if not interrupt then
		if self.currentAnimationState == state then
			return
		end
	end

	-- Stop all other animations with a blend out to make transitions smoother
	for animState, track in pairs(self.animationTracks) do
		if animState ~= state then
			if track.IsPlaying then
				if config.doDebug then print("Stopping animation:", animState) end
				track:Stop(blendTime)
			end
			self.activeAnimations[animState] = false
		end
	end

	-- Start the requested animation
	local animData = self.animationTracks[state]
	if animData then
		self.activeAnimations[state] = true
		if not animData.animationTrack then
			return
		end
		animData.animationTrack:Play(blendTime)
		self.currentAnimationState = state
	else
		if config.doDebug then warn("Animation track does not exist for state:", state) end
	end

	return animData.animationTrack
end

function BaseMob:stopAllAnimationsv1(): nil
	for state, track in pairs(self.animationTracks) do
		if track.IsPlaying then
			track:Stop()
		end
		self.activeAnimations[state] = false
	end
end

function BaseMob:ChangeState(newState): nil
	if self.actionQueue and #self.actionQueue > 0 then
		table.remove(self.actionQueue, 1)
	end
	self.previousState = self.state
	self.state = newState
	if config.doDebug then print("State changed to:", self.state) end
end

function BaseMob:PushState(newState)
	table.insert(self.actionQueue, newState)
end

function BaseMob:RevertState(): nil
	self:ChangeState(self.previousState)
end

function BaseMob:TakeDamage(amount): nil
	self.health = self.health - amount
	if config.doDebug then print("Health:", self.health) end
	if self.health <= 0 then
		self:ChangeState(StateEnum.Death)
	else
		self:ChangeState(StateEnum.Hit)
	end
end

function BaseMob:disableCollision()
	-- loop through all humanoid and disable collision and anchor otherwise it would fall through floor
	if not self.humanoid then return end
	self.humanoidRootPart.Anchored = true
	for _, part in pairs(self.humanoid.Parent:GetDescendants()) do
		if part:IsA("Part") or part:IsA("MeshPart") then
			part.CollisionGroup = "Dead"
		end
	end
end

return BaseMob

Then youd define your Boar mob. I have a mob called Stone Golem. And it uses base mob like so. Note the bottom where i have a general and animation setup for all mobs. Then this mob has a, unique mob setup to adjust the hipheight.

local doDebug = true
-- core
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- custom core
local BaseMob = require(ServerScriptService:WaitForChild("MobScripts"):WaitForChild("BaseMob"))
local StoneGolem = require(ReplicatedStorage.Animations.Enemies.StoneGolem)

local StoneGolem = setmetatable({}, BaseMob)
StoneGolem.__index = StoneGolem

-- types
type StoneGolem = BaseMob & {
	new: (instannce: Instance, humanoid: Humanoid, health: number) -> StoneGolem,
}

local DefaultConfigs = {
	health = 10000,
	defense = 100,
	attack = 45,
	magic = 10,
	speed = 25,
}

StoneGolem.Animations = {
	Buff = {
		name = "Buff",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
		stoppedCallbackRef = "",
		stoppedCallback = nil
	},
	DashAttack = {
		name = "DashAttack",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
		stoppedCallbackRef = "",
		stoppedCallback = nil
	},
	Death = {
		name = "Death",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action4,
		stoppedCallbackRef = "",
		stoppedCallback = nil
	},
	Guard = {
		name = "Guard",
		looped = true,
		priority = Enum.AnimationPriority.Action,
	},
	Idle1 = {
		name = "Idle1",
		speed = 1,
		looped = true,
		priority = Enum.AnimationPriority.Idle,
	},
	Idle2 = {
		name = "Idle2",
		animationTrack = nil,
		speed = 1,
		looped = true,
		priority = Enum.AnimationPriority.Idle,
	},
	Hit = {
		name = "Hit",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
	},
	Run = {
		name = "Run",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
		looped = true
	},
	Attack1_1 = {
		name = "Attack1_1",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
	},
	Attack1_2 = {
		name = "Attack1_2",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
	},
	Attack1_3 = {
		name = "Attack1_3",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
	},
	Attack1_4 = {
		name = "Attack1_4",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
	},
	Attack1_5 = {
		name = "Attack1_5",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
	},
	Skill2_Channel = {
		name = "Skill2_Channel",
		animationTrack = nil,
		priority = Enum.AnimationPriority.Action,
	},
}

StoneGolem.InterruptibleStates = {
	[StoneGolem.Buff] = true,
}

function StoneGolem.new(instance, humanoid: Humanoid, health: number): nil
	local self = BaseMob.new(
		instance, 
		humanoid, 
		health or DefaultConfigs.health, 
		DefaultConfigs.defense
	)
	setmetatable(self, StoneGolem)
	-- initialize
	self.name = "Zombie Samurai"
	self.fullName = self.name..self.UUID
	instance.Name = self.name
	-- adorn debug setup
	self.adorneeComponent = "Head_Jnt_089"
	-- animation setup
	self.animationTracks = self:loadAnimations(self.humanoid, StoneGolem.Animations)
	self:playAnimation(StoneGolem.Animations.idle.name, 0, true)
	-- mob unique setup
	humanoid.HipHeight = 2.3
	
	return self
end

return StoneGolem

See code above for baseMob. You would use reference damage and wrap that in an event in your server script that your mob uses:

-- ServerScript CustomStoneGolemAnimate
local doDebug = true
---- AnimateStoneGolem.lua
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
-- custom core
local UnitUtils = require(ServerScriptService:WaitForChild("UnitUtils"))
local V3Directions = require(ReplicatedStorage.SharedLibs.V3Directions)
local SimplePath = require(ServerStorage.SimplePath)
-- events
local CreateHitFxAll = ReplicatedStorage.Events.Fx.CreateHitFxAll

local StoneGolem = require(ServerScriptService:WaitForChild("MobScripts"):WaitForChild("StoneGolem"))
local StoneGolemAnims = require(ReplicatedStorage.Animations.Enemies.ZombieSamurai)
local StoneGolemStateEnum = require(ServerScriptService:WaitForChild("MobScripts"):WaitForChild("StoneGolem"):WaitForChild("StoneGolemStateEnum"))

local instance = script.Parent
local humanoid = instance:WaitForChild("Humanoid")
local mob = StoneGolem.new(instance, humanoid, 50000)
instance.Name = mob.name

-- rest of script in here which includes your QSM and behavior trees
local statesNoTarget = {
	StoneGolemEnum.Idle,
}

local statesCloseRangeActions = {
	StoneGolemEnum.Attack1FullCombo, --uninterruptable
	StoneGolemEnum.Buff,
}

-- your QSM (explained in original reply goes here)

-- Event for damage
function onHealthChanged(health)
	print(mob.name," Health changed from", mob.health, "to", health)
	
	if health <= 0 then
		onDied()
	elseif health < mob.health then
		mob.health = health 
		currentState = StoneGolemStateEnum.Hit
		local animationTrack = mob:playAnimation(StoneGolemAnims.Animations.hit.name, 0)
		
		if currentTaskFunction then
			currentTaskFunction:Disconnect()
			currentTaskFunction = nil
		end

		-- Connect the new animation track's Stopped event
		if animationTrack then
			currentTaskFunction = animationTrack.Stopped:Connect(function()
				currentState = StoneGolemStateEnum.Idle
			end)
		end
	end
end

--events
humanoid.HealthChanged:Connect(onHealthChanged)

This is just one example. Humanoid doesnt hold info like custom states for the queue logic, or stats like defense, attack needed for a combat system im working with. You can get around this with getAttribute and setAttribute but that isnt replicated anyway and when doing events for combat it may be more efficient to have a model of static properties to validate against. It also helps for security and validation making your game hack-proof. For example if i have defense that influences health and combat the template is shared to both client and server. This lets me make runtime calculations with level in consideration. If enemy is level 10 and player is level 12, lookup the stats and compute a new value. The computation is done on client then sent to server. The server does the same and sees if it calculated the same thing the client did. If so its a valid computation otherwise reject. While you can send Humanoid in events you dont need all the info a humanoid has (like walkspeed, isSitting etc) so I send only specific variables to keep my network load low. The trade off is more security with more checks to be made. Since theres a lot of checks happening you want data sent in events to be light weight otherwise you get variable lag when Clients and Server talk to each other.

1 Like

I have tried searching about queues in final fantasy but I couldn’t find anything relevant.

https://www.reddit.com/r/JRPG/comments/ruid3p/im_looking_for_games_with_a_turnqueue_battle/
https://www.reddit.com/r/gamedev/comments/57eu5y/how_would_you_go_about_implementing_the/
https://www.reddit.com/r/FinalFantasy/comments/yrz0tm/what_is_your_favorite_battle_system_in_the_final/

To be fair these are turn-based. So its not real time so its a simpler version. A good example of QSM are MMOs with bosses like World of Warcraft or Final Fantasy XIV Online. But the system I’ve designed has fast combo of skills similar to Genshin Impact, Black Desert Online or Blade and Soul. You need to constantly feed a queue depending on combat conditions and constantly consume from the queue in an asynchronous manner. The complexity is the network and handling client/server desync or hacks.

I cant find specific videos but youtube has a lot of recorded presentations on game design and game theory from GDC.

Edit: Here is a playlist of GDC AI topics. Most vids wont show code but instead talk high level to give you an idea.

1 Like

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