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
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
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
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 But I will edit this later (to make it more clear if needed)
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.
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.
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.
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.