Skip to main content

Understanding the Lifecycle

General Game Lifecycle

Iris is designed for games with a core 'game loop' which is the structure that controls what part of the game process happens when. A typical game loop make look very similar to this:

while(not_closed) {
poll_input();
update_game_state();
step_physics();
render_content();
wait(); // for a 60 fps limit
}

Here we start firstly with polling for any input changes, since these affect the game state for that frame. We then update the game state which generally includes the majority of a game engine, since it would control any user updates, world changes, UI updates and others. We may also then choose to step our physics engine, assuming we are using a constant frame rate. Finally we render out everything to our GPU and wait until the appropriate time to start processing the next frame.

Roblox takes most of this away from developers, and instead chooses to rely on an event-driven loop, where we hook onto a part of the engine allowing something else to happen. This makes it more difficult to use Iris, since not every place we want it will run every frame. However, Roblox provides access to RunService events, allowing us to execute code every frame, which is seen below:

while not_closed do
update(UserInputService)
update(ContextActionService)

event(RunService.BindToRenderStepped)
event(RunService.RenderStepped)

render()

event(wait)
event(RunService.Stepped)
update(PhysicsService)

event(RunService.Heartbeat)
update(ReplicationService)

delay() -- for a 60 fps limit
end

This is taken from the Task Scheduler Documentation which goes into more detail about this.

Iris Lifecycle

Iris needs to run every frame, called the cycle, in order to update global variables and to clean any unused widgets. This is equivalent to calling ImGui::EndFrame() for Dear ImGui, which would then process the frame buffers ready for use. This order is important for Iris, which by default uses the RunService.Heartbeat event to process this all on. Therefore, for each frame, any Iris code must run before this event. It is possible to change the event Iris runs on when initialising, but for most cases, RunService.Heartbeat is ideal.

Understanding this is the key to most effectively using Iris. The library provides a handy Iris:Connect() function which will run any code in the function every frame before the cycle. This makes it the most convenient. However, any functions provided here will also run on the initialised event, RunService.Heartbeat here, so will run after physics and animations are calculated. Thankfully, Iris does not constrain you to use only Iris:Connect(). You are able to run Iris code anywhere, in any event, at any time. As long as it is consistent on every frame, and before the cycle event, it will work properly. Therefore, it is very possible to put Iris directly into your core game loops.

Demonstration

Say you have a weapon class which is used by every weapon and then also a weapon handler/serivce/system/controller for handling all weapons on the client. Integrating Iris may look something similar to this:

------------------------------------------------------------------------
--- game.ReplicatedStorage.Modules.Client.Weaopns.WeaponsService.lua
------------------------------------------------------------------------
local WeaponsService = {
maxWeapons = 10,
activeWeapon = nil,
weapons = {}
}

function WeaponsService.init()
end

-- called every frame to update all weapons
function WeaponsService.update(deltaTime: number)
Iris.Window({ "Weapons Service" })

WeaponsService.doSomething()
Iris.CollapsingHeader({ "Global Variables" })
Iris.DragNum({ "Max Weapons", 1, 0 }, { number = Iris.TableState(WeaponsService.maxWeapons) })
Iris.End()

Iris.CollapsingHeader({ "Weapons" })
Iris.Tree({ `Active Weapon: {WeaponsService.activeWeapon.name}` })
WeaponsService.activeWeapon:update()
Iris.End()

Iris.SeparatorText({ "All Weapons" })
for _, weapon: weapon in WeaponsService.weapons do
Iris.Tree({ weapon.name })
weapon:update()
Iris.End()
end
Iris.End()

WeaponsService.doSomethingElse()
Iris.End()
end

function WeaponsService.terminate()
end

return WeaponsService

------------------------------------------------------------------------
--- game.ReplicatedStorage.Modules.Client.Weaopns.Weapon.lua
------------------------------------------------------------------------
local Weapon = {}
Weapon.__index = Weapon

function Weapon.new(...)
end

function Weapon.update(self, deltaTime: number)
Iris.Text({ `ID: {self.id}` })
Iris.Text({ `Bullets: {self.bullets}/{self.capacity}" })
Iris.Checkbox({ "No reload" }, { isChecked = Iris.TableState(self.noreload) })
...
self:updateInputs()
self:updateTransforms()
...
end

function Weapon.destroy(self)
end

Although this is very bare bones code, we are not using any Iris:Connect() methods and instead place our Iris code directly in our update events which we know will run every frame. Another practice this shows is starting a window somewhere and keeping it open through all weapons before closing it and the end of the update. Therefore, we can place lots of different widgets in one window and keep everything organised.

The showcase by @Boogle shows off Iris used exactly like this, but with an actual working system.