TLDR: I think solutions like the “monad” / functor from the video posted above is pretty good for the UI animation case. For the more complicated situations such as player save data management, those kinds of solutions are not very good in my opinion and there is a different approach that should be used instead: constructing each pipeline as its own unique loop.
I have been noticing this problem myself recently and have come up with a particular way of thinking about it.
Any sequence of steps (I’ll refer to this as a “pipeline”) can be represented either as itself (a plain old sequence of steps) or as a loop. I’ll refer to code in the former representation as “sequential-structure” and in the latter as “loop-structure”.
These two representations are basically equivalent from a computational perspective, it’s just that for us developers writing code there are practical considerations, and established conventions and APIs, which influence which representation we use.
Using the UI animation code as an example, written naively in a sequential-structure it looks like (8 loc):
tweenPopUpOntoScreenAsync()
tweenSecondaryElementAsync()
for _, element in listElements do
fadeInListElementAsync(element)
end
task.wait(3)
tweenAwaySecondaryElementAsync()
tweenPopUpOffScreenAsync()
This is effectively the ideal scenario and we want code that is as close to that as possible, while correctly handling the potential failure cases.
The first thing we could do is turn this into loop-structure. I have done that here. I also made a table to put the “steps” into a more declarative form, but if you really wanted you could hardcode each step into the loop itself (in this case that would be dumb, though it may be a good exercise). Here is the loop-structure (27 loc, so +19 from the sequential-structure):
local steps = {
tweenPopUpOntoScreenAsync;
tweenSecondaryElementAsync;
function(state)
local index = state.listElementsIndex or 1
local element = listElements[index]
if element then
fadeInListElementAsync(element)
state.listElementsIndex = index + 1
return true
else
task.wait(3)
end
end;
tweenAwaySecondaryElementAsync;
tweenPopUpOffScreenAsync;
}
local state = {}
local stepIndex = 1
while true do
local doStepAsync = steps[stepIndex]
if not doStepAsync then
break
elseif not doStepAsync(state) then
stepIndex += 1
end
end
At this stage using the loop-structure is pointless – it’s very verbose and unclear, and we are basically just reinventing the programming language’s own semantics. However it gets a bit more interesting once you add the conditional checks after every yield.
Sequential-structure diff (+11 loc from original sequential-structure):
tweenPopUpOntoScreenAsync()
+if uiElementsArentParentedToNil() then
tweenSecondaryElementAsync()
+ if uiElementsArentParentedToNil() then
for _, element in listElements do
fadeInListElementAsync(element)
+ if not uiElementsArentParentedToNil() then
+ return
+ end
end
task.wait(3)
+ if uiElementsArentParentedToNil() then
tweenAwaySecondaryElementAsync()
+ if uiElementsArentParentedToNil() then
tweenPopUpOffScreenAsync()
+ end
+ end
+ end
+end
Loop-structure diff (+3 loc from original loop-structure):
...
local state = {}
local stepIndex = 1
while true do
local doStepAsync = steps[stepIndex]
if not doStepAsync then
break
elseif not doStepAsync(state) then
stepIndex += 1
end
+ if not uiElementsArentParentedToNil() then
+ break
+ end
end
You can see by looking at the sequential-structure code that it appears to be an unrolled version of the loop-structure code.
What the loop does, fundamentally, is allow us to deduplicate logic that is shared between steps in the pipeline. It can be very good at this – in this case, we got that deduplication almost for free as very few changes needed to be made to the loop-structure representation of the code to add in the new condition.
Once you have put your code into loop-structure representation, you may be able to compress it into a form that can be reused:
local function doStepsUntilNotCondition(condition, steps)
local stepIndex = 1
local state = {}
while true do
local doStepAsync = steps[stepIndex]
if not doStepAsync then
break
-- a step may return a truthy value to indicate it
-- wants to be executed again.
elseif not doStepAsync(state) then
stepIndex += 1
end
if not condition() then
break
end
end
end
The usage code then looks like this (+9 loc from original sequential-structure, which is slightly better than the naive +11):
doStepsUntilNotCondition(uiElementsArentParentedToNil, {
tweenPopUpOntoScreenAsync;
tweenSecondaryElementAsync;
function(state)
local index = state.listElementsIndex or 1
local element = listElements[index]
if element then
fadeInListElementAsync(element)
state.listElementsIndex = index + 1
return true
else
task.wait(3)
end
end;
tweenAwaySecondaryElementAsync;
tweenPopUpOffScreenAsync;
})
The non-atomic loop over list elements complicates things a bit because we need to code its iteration manually. So this loop-structure is not always a winning solution, but for pipelines involving few inner loops it’s pretty good.
This process of “find the loop-structure, deduplicate it, reuse it” is where patterns such as
- “monads” (or “functors” or whatever you want to call the thing shown in the monads video)
- callback / signal chains
- state machines
- recursive functions
come from, and is why they are useful (in my understanding at least).
Another example, the "monad"/functor from the video can be written in loop-structure.
local maybe = "gustave"
local steps = {
database.fetch;
function(user) return user.friends end;
function(friends) return friends.first() end;
function(friend) return friend.gender end;
}
local stepIndex = 1
while maybe ~= nil do
local doStepAsync = steps[stepIndex]
if not doStepAsync then
break
else
maybe = doStepAsync(maybe)
stepIndex += 1
end
end
Then put into reusable form:
local function functor(initial, steps)
local stepIndex = 1
while maybe ~= nil do
local doStepAsync = steps[stepIndex]
if not doStepAsync then
break
else
maybe = doStepAsync(maybe)
stepIndex += 1
end
end
return maybe[1]
end
local gender = functor("gustave", {
database.fetch;
function(user) return user.friends end;
function(friends) return friends.first() end;
function(friend) return friend.gender end;
})
Often these patterns don’t appear to have loop-structure on the surface because they may be implemented and conceptualized as being recursive or “one-thing-at-a-time”. But at the end of the day any recursive function can be written as an iterative (i.e loop-structure) algorithm, and in the real world must be executed on the hardware as such. So this way of thinking gives a unified way to understand how these things work in terms of simple and widely available programming tools.
For the UI animation example, I consider using these patterns derived from reusing the loop-structure to be the most appropriate solution. It leads to the most compact usage code, but only when the pipeline itself is simple and easy to turn into a loop.
Getting reusability will be more difficult with a pipeline where different hierarchies of conditionals are required for every step, or where it is more difficult to obtain knowledge of what the “next step” even is.
So, hopefully the knowledge of where the reusability patterns come from can help you better understand why some work, and enable you to create your own patterns as needed.
As hinted at above, there is an unresolved area of concern: what do we do when the pipeline is complicated? In that case, we probably can’t reuse loop-structures easily. But if it is easier to build the loop-structure (in comparison to other strategies) then we don’t need reusability – we’d already be getting a win without it.
So, what would that look like? First, we need to go a little further with our loop structures.
The above way of putting code into a loop-structure is not the limit of how far we can go – the transformation I did above was very direct, just taking the code in front of me and making it a loop without thinking about it much.
But there remains a fundamental piece of the pipeline which has not been addressed. The Roblox game engine, the Luau language, and any yielding APIs you use are still controlling the behaviour of the pipeline by controlling when our threads get resumed and preventing us from doing things with the pipeline while their own code is in control.
For example, when we call task.wait(3)
, the engine controls how and when our pipeline’s thread gets resumed. (When I say “threads” I am referring to luau’s coroutines/fibers, not to be confused with OS threads or hardware parallelism.)
Because of this external control over thread execution, there is a hidden loop-structure here which is not visible on the surface. Instead this structure is present in the language’s VM, or the game engine’s main loop and signalling code. (If you want to go all the way down, you could also put the OS and physical hardware into the category, but for us those are irrelevant and inaccessible.)
As the pipeline’s control flow and its interaction with the underlying systems (e.g. the game engine, and language VM) get more complicated, the tools provided by these underlying systems (such as threads and signals) start being a hinderance rather than a help. In that scenario, I claim we are better off rejecting the tools and replacing their hidden loop-structures with our own explicit ones.
In Roblox, the furthest we can go with this is using the engine’s main loop as the driver of our loop-structure, with the rest of the pipeline’s computational logic handled ourselves from scratch (as much as we can). With the familiar UI animation example this would look like:
-- the game engine's main loop, which we are hooking into
-- here by connecting to heartbeat and using to drive our
-- loop-structure.
RunService.Heartbeat:Connect(function()
-- for every incomplete UI animation pipeline, we do the
-- following code...
while true do
-- (this while loop exists so that multiple steps can be
-- performed within a single frame if some individual
-- steps do not yield.)
if not uiElementsArentParentedToNil(pipeline) then
-- cancel the pipeline
break
elseif pipeline.thread then
-- async code is currently executing, we need to wait
-- for it to complete.
break
else
local doStepAsync = pipeline.steps[stepIndex]
if not doStepAsync then
-- complete the pipeline
break
else
-- isolate the async work so it doesn't interfere
-- with the frame code
pipeline.thread = coroutine.create(function()
if not doStepAsync(pipeline.state) then
pipeline.stepIndex += 1
end
pipeline.thread = nil
end)
task.spawn(pipeline.thread)
end
end
end
end
I am going to refer to the first loop-structure I showed as the “weak” loop-structure, and this new approach as the “strong” loop-structure because it goes further in trying to encompass the fundamental loop of code execution (relying less on third party things).
I wouldn’t recommend the strong loop-structure approach for the UI animation example, or other simple cases – but for complicated things, I believe this is the most optimal code structure (for humans to work with). The savings I get from deduplicating the pipeline’s logic and not having to worry about how threads get executed recursively etc. vastly outweigh the extra verbosity and potential performance problems.
(Also, performance problems can be dealt with in ways that do not need to compromise the strong loop-structure, such as having dirty flags, or having pieces of state that are expensive to check be tracked separately and queried by the loop-structure in a way that is more efficient.)
One way you can think of this trade-off is that, where n
is the complexity of the pipeline:
- the strong loop-structure code has
O(n)
complexity growth but with a large constant overhead
- the weak loop-structure or sequential-structure code has
O(n^2)
complexity growth with low constant overhead
When n
is large, the strong loop-structure is better because it scales slower and the constant overhead doesn’t matter. But with small n
, weak loop-structure or sequential-structure are preferred because they have less constant overhead.
Interestingly, it seems like when a pipeline becomes sufficiently complicated, the strong loop-structure code starts feeling like it’s more declarative than the equivalent sequential-structure or weak loop-structure code. I think this is because you are declaring what happens in each context rather than declaring the sequence of steps. With a complicated pipeline, its contextual definition is smaller and more legible than its sequential definition.
As an example of a useful strong loop-structure, I have a player save data session management pipeline.
I have never successfully managed to build the equivalent pipeline in a more sequential-structure or weak loop-structure way because there is a lot of conditional logic as well as complications like having thread resumption be done by third-parties. I just can’t hold all that in my head.
The strong loop-structure is the only one that I can really reason about clearly and take into account all the constraints and possible scenarios without much difficulty.
Here is the session management pipeline code
(This code does a lot of stuff so I have only included a vertical slice of it: just the high level session management and the save data header loading code. Functions are also defined in reverse order cause that’s better for reading here.)
local function Heartbeat(HeartbeatNow: number)
for _, PlayerState in PlayerStates do
-- If we don't have SessionState then we do not have an ongoing session
-- and so we are either trying to load data or the player has left the
-- game.
if not SessionState then
-- Since the player is in-game, kick off the load job if the player has
-- been marked as being ready for save data.
if not PlayerState.HasLeft then
PerformHeaderLoad(PlayerState, HeartbeatNow, PlayerState.ReadyForSaveData)
-- Otherwise, we don't have an active session. But we might still have
-- a load job in progress, in which case we need to wait for it to
-- complete.
elseif PlayerState.HeaderLoadState then
PerformHeaderLoad(PlayerState, HeartbeatNow, false)
-- If we reach here then we know for sure we don't have an active
-- session so we can just remove everything immediately.
else
Module.PlayerState_ByUserId[PlayerState.UserId] = nil
-- Inform all pending product purchases that the purchase has failed.
local PPPs = table.clone(PlayerState.PendingProductPurchases)
table.clear(PlayerState.PendingProductPurchases)
for _, PPP in PPPs do
task.spawn(PPP.Thread, false)
end
end
-- If we get here then the player is still in the game and is playing
-- normally.
elseif not PlayerState.HasLeft then
-- We try to load body if it doesn't exist already.
PerformBodyLoad(PlayerState, SessionState, HeartbeatNow)
local WithinGracePeriod = false
local CanSaveHeader = false
if (HeartbeatNow >= SessionState.HeaderAutosaveAt) then
WithinGracePeriod =
(HeartbeatNow - SessionState.HeaderAutosaveAt) <
HeaderMaintainSessionGracePeriod
CanSaveHeader = true
end
-- We try to continue any ongoing body save jobs. We only allow new
-- body save jobs to be started if we are within the grace period
-- (otherwise, that means our metadata save has been delayed a long
-- time in which case we should be prioritizing that as we need to
-- maintain the session lock).
if PlayerState.LoadStatus == BUEShared.LoadStatus.everything_loaded then
PerformBodySave(PlayerState, SessionState, HeartbeatNow, WithinGracePeriod)
-- Prevent header from being saved until after body has finished
-- saving (or we exceed the grace period).
if WithinGracePeriod and SessionState.BodySaveState then
CanSaveHeader = false
end
end
PerformHeaderSave(PlayerState, SessionState, HeartbeatNow,
CanSaveHeader, false)
-- Once the player has left the game, we try to save all changes to their
-- data. If any changes are made while saving we will also try to save
-- those.
else
local CanRelease = true
if PlayerState.LoadStatus == BUEShared.LoadStatus.everything_loaded then
PerformBodySave(PlayerState, SessionState, HeartbeatNow, true)
-- Wait until all data saving jobs have finished before releasing.
if SessionState.BodySaveState then
CanRelease = false
end
end
-- @TODO Perhaps for extra robustness, we should perform a normal
-- session releasing header save as soon as possible, but after
-- that we keep trying to finish body saves. If the body saves
-- eventually succeed, we can then attempt to do a "no-session"
-- header save which will try to save to the header, but only if
-- nobody else has taken a session lock in the meantime.
-- Try to save the header and release the session lock.
PerformHeaderSave(PlayerState, SessionState, HeartbeatNow,
CanRelease, CanRelease)
end
end
end
local function PerformBodySave(
PlayerState: PlayerState,
SessionState: PS_SessionState,
HeartbeatNow: number,
CanStartNewSave: boolean
)
-- [...]
end
local function PerformHeaderSave(
PlayerState: PlayerState,
SessionState: PS_SessionState,
HeartbeatNow: number,
CanBeginNewSave: boolean,
ShouldReleaseSession: boolean
)
-- [...]
end
local function PerformHeaderLoad(
PlayerState: PlayerState,
HeartbeatNow: number,
CanBeginNewLoad: boolean
)
while true do
local HLS = PlayerState.HeaderLoadState :: PS_HeaderLoadState
if not HLS then
if not CanBeginNewLoad then
break
else
local HLS: PS_HeaderLoadState = {
NextAttemptAt = -math.huge;
}
PlayerState.HeaderLoadState = HLS
end
elseif HLS.Thread then
break
elseif not HLS.Result then
if not CanBeginNewLoad then
PlayerState.HeaderLoadState = nil
break
elseif HeartbeatNow < HLS.NextAttemptAt then
break
else
local Thread = coroutine.create(Job_PlayerStateHeader_Load)
HLS.Thread = Thread
task.spawn(Thread, PlayerState, HLS)
end
else
local Result = HLS.Result
HLS.Result = nil
HLS.NextAttemptAt = -math.huge
-- If the load failed then we just retry at some unknown point in the
-- future.
if Result.LoadStatus ~= BUEShared.LoadStatus.body_loading then
HLS.NextAttemptAt = HeartbeatNow + HeaderRetryCooldown
PlayerState.LoadStatus = Result.LoadStatus
-- Otherwise if the load succeeded, we can be happy.
else
local Header = Result.Header
assert(Header)
assert(Header.Session)
local SessionState: PS_SessionState = {
Session = Header.Session;
Header = Header;
HeaderAutosaveAt = HeartbeatNow + HeaderMaintainSessionPeriod;
SavedGuidCount = 0;
ProcessedRemoteChanges = {};
GaveRewards_ByEventId = {};
SavingProductPurchases = {};
}
PlayerState.SessionState = SessionState
local GuidHistory = Header.GuidHistory
local GuidHistoryCount = #GuidHistory
if GuidHistoryCount < 1 then
PlayerState.LoadStatus = BUEShared.LoadStatus.everything_loaded
else
local BLS: SS_BodyLoadState = {
GuidIndex = GuidHistoryCount;
GuidLoadState = {
Guid = GuidHistory[GuidHistoryCount];
LoadedFragments = {};
NextFragmentIndex = 1;
NextFragmentAt = -math.huge;
NextFragmentRetryCount = 0;
}
}
SessionState.BodyLoadState = BLS
PlayerState.LoadStatus = BUEShared.LoadStatus.body_loading
end
ProcessRemoteChanges(PlayerState.DebugIdentifier, PlayerState, SessionState)
PlayerState.HeaderLoadState = nil
break
end
end
end
end
local function Job_PlayerStateHeader_Load(
PlayerState: PlayerState,
HLS: PS_HeaderLoadState
)
local Succeeded = false
local TransformedLoadStatus: number?
local TransformedPSH: PlayerSaveHeader?
local function TransformFunction(
RemotePSH: PlayerSaveHeader?
): (PlayerSaveHeader?, {number}?)
TransformedLoadStatus = nil
TransformedPSH = nil
local NewLoadStatus: number
local NewPSH: PlayerSaveHeader
if not RemotePSH then
NewPSH = BUEShared.TableDeepCopy(TemplatePSH)
NewPSH.Session = SessionCreate()
NewLoadStatus = BUEShared.LoadStatus.body_loading
else
if (RemotePSH.Session) and
(not SessionExpired(RemotePSH.Session))
then
NewLoadStatus = BUEShared.LoadStatus.head_session_is_locked
else
local MigrateSuccess = DoMigration(
PlayerState.DebugIdentifier,
RemotePSH,
"PlayerSaveHeader",
PlayerSaveHeader_VersionMajor,
PlayerSaveHeader_VersionMinor,
BUEMigrators.PSH_Migrators,
BUEMigrators.PSH_Patches)
if not MigrateSuccess then
NewLoadStatus = BUEShared.LoadStatus.head_invalid_version
else
NewPSH = RemotePSH
NewPSH.Session = SessionCreate()
NewLoadStatus = BUEShared.LoadStatus.body_loading
end
end
end
TransformedLoadStatus = NewLoadStatus
TransformedPSH = NewPSH
if (NewLoadStatus == BUEShared.LoadStatus.body_loading) and
(Module.SavingEnabled)
then
-- Note: If saving is disabled then the code making the request cannot
-- rely on the returned Header having particular values set by the
-- request (e.g. the session value, or migrated data).
return NewPSH, {PlayerState.DataStoreUserId}
else
return nil
end
end
local NewPSH: PlayerSaveHeader
local NewLoadStatus: number
local RequestSuccess, RequestMessage = pcall(function()
NewPSH = State.EventsDataStore:UpdateAsync(
string.format(PlayerSaveHeader_Key, PlayerState.DataStoreUserId),
TransformFunction)
end)
-- If request failed then we just do nothing except report the error if
-- it's nontrivial.
if not RequestSuccess then
NewLoadStatus = BUEShared.LoadStatus.head_data_store_request_fail
local ErrorKind = BUEDataStoreAssistant.GetErrorKind(RequestMessage)
if ErrorKind ~= BUEDataStoreAssistant.ErrorKind.backend then
warn(debug.traceback(BUEDataStoreAssistant.FormatError(
ErrorKind, RequestMessage, State.EventsDataStore, "UpdateAsync"
)))
end
elseif not TransformedLoadStatus then
NewLoadStatus = BUEShared.LoadStatus.head_data_store_request_fail
else
NewLoadStatus = TransformedLoadStatus
-- If saving is not enabled (i.e. the UpdateAsync transform function
-- returned nil, cancelling the update) then Header won't have been
-- returned by UpdateAsync, so we need to manually set it here.
if (not Module.SavingEnabled) and
(TransformedLoadStatus == BUEShared.LoadStatus.body_loading)
then
assert(TransformedPSH)
NewPSH = TransformedPSH
end
end
assert(NewLoadStatus <= BUEShared.LoadStatus.body_loading)
HLS.Result = {
Success = RequestSuccess;
Message = RequestMessage;
LoadStatus = NewLoadStatus;
Header = NewPSH;
}
HLS.Thread = nil
end
What about language features or other tools at our disposal? (Threads, recursion, etc.)
Because they often obscure the nature of how code is actually executing and require a lot of programmer effort to understand how they behave in complicated situations, I personally am wary of using language features and patterns. But at the end of the day, they are tools and do provide useful features. And we are often forced to use them when interacting with Roblox’s APIs, so sometimes we can get benefits from the language features for “free”.
Something which is a frequent PITA with loop-structure code is manually handling iteration state for inner loops. Take for example iterating over listElements
in the UI animations pipeline, which requires an extra 4-5 LOC from the original for loop:
local listElementsIndex = 1
while true do
local element = listElements[listElementsIndex]
if not element then
-- continue to the next step or whatever
break
else
fadeInListElementAsync(element)
listElementsIndex += 1
end
if not uiElementsArentParentedToNil() then
-- cancel
break
end
end
One of the nice convenient things about Luau is that threads keep track of this kind of state automatically for us. So the code below is equivalent, our development effort has just been shifted to dealing with thread management rather than loop iteration management:
local iterationThreadReady = true
local iterationThread = coroutine.create(function()
for _, element in listElements do
fadeInListElementAsync(element)
iterationThreadReady = true
coroutine.yield()
end
end)
while true do
if coroutine.status(iterationThread) == "dead" then
-- continue to the next step or whatever
break
elseif iterationThreadReady then
iterationThreadReady = false
task.spawn(iterationThread)
end
if not uiElementsArentParentedToNil() then
-- cancel
break
end
end
This example sucks cause it inflates the line count. But it’s more useful when you have lots of nested loops and control flow which would be difficult to deal with it. It’s another scaling trade-off: in terms of development effort and where n
is the number of inner loops, a thread management solution might be O(1)
albeit with a large constant cost, and the manually tracked loop iteration solution might be O(n)
with smaller constant cost.
Another example is doing recursion manually (writing an iterative loop) vs doing it automatically using function calls. The manual approach is nice - you can compress redundant/duplicate logic and better understand what the code is actually doing (e.g. building a large stack), and how it executes and jumps around your codebase. On the other hand, having to manually maintain a stack or think about your algorithm differently can be annoying, in which case it would be much more expedient to just do some recursive function calls instead.
One of the benefits of this “loop-structure” way of thinking in my opinion is that you are encouraged to pay a little more attention to what benefits language features give you and use them only when you need to. If you have the loop-structure approach in your toolkit, you don’t need to try to shoehorn other loop structures into the idiomatic for loop structure, or monad structure, or whatever else which is provided by a language or library. So I have found it valuable in removing lots of cruft and unnecessary complication due to usage of language features where they weren’t really needed – a plain old while loop could suffice instead.
Anyway, those are my thoughts on this topic. It’s quite a nightmare, haha! I will get off my soapbox now 🚶♂️