Roblox / Luau

FlagUtil

A lightweight, type-safe boolean state machine for Roblox. React to state changes, run frame-perfect loops, and coordinate async behavior — all without polling.

Overview

FlagUtil wraps a single boolean into a rich reactive object. Instead of scattering if isAlive then checks across your codebase, you declare intent: run this while the flag is Up, stop when it goes Down.

Frame-perfect execution

ExecuteUntil binds to RenderStep — zero polling, zero drift.

🔒

Fully typed

Exported Luau types for Flag, CancelFn, and States.

🧹

Self-cleaning

All connections, threads, and RenderStep bindings cleaned on Destroy.

🎯

Cancellable

Every async operation returns a CancelFn you can call at any time.

Installation

Place FlagUtil.lua (or FlagUtil.luau) inside ReplicatedStorage or wherever your shared modules live. Then require it from any script:

local FlagUtil = require(game.ReplicatedStorage:WaitForChild("FlagUtil"))

FlagUtil has no external dependencies. It only requires RunService from Roblox services.

Quick Start

Create a flag, react to it, and clean up when done.

local FlagUtil = require(game.ReplicatedStorage:WaitForChild("FlagUtil"))

-- Create a flag starting in the Up (true) state
local isAlive = FlagUtil.new(FlagUtil.States.Up)

-- React to every change
isAlive:Changed(function(state: boolean)
    if state then
        print("Player is alive")
    else
        print("Player died")
    end
end)

-- Run something every frame while alive
isAlive:ExecuteUntil(FlagUtil.States.Down, function(dt: number)
    -- update health bar, run animations, etc.
end)

-- Flip the flag
isAlive:Down() -- ExecuteUntil stops, Changed fires

-- Clean up everything
isAlive:Destroy()

API Reference

Each method is documented individually below. Click any entry in the sidebar to jump directly to it.

flag.new (state: boolean) → Flag returns Flag

Creates a new Flag instance with the given initial state. Use FlagUtil.States.Up (true) or FlagUtil.States.Down (false). Asserts if a non-boolean is passed.

ParameterTypeDescription
statebooleanThe initial state of the flag.
local f = FlagUtil.new(FlagUtil.States.Up)   -- starts Up (true)
local f = FlagUtil.new(FlagUtil.States.Down) -- starts Down (false)
flag:Up () → () void

Sets the flag to Up (true) and notifies all listeners. Prints a warning if the flag is already Up.

f:Up()
print(f:Get()) -- true
flag:Down () → () void

Sets the flag to Down (false) and notifies all listeners. Prints a warning if the flag is already Down.

f:Down()
print(f:Get()) -- false
flag:Toggle () → () void

Flips the current state. Calls Down() if currently Up, Up() if currently Down. Triggers all listeners.

local f = FlagUtil.new(FlagUtil.States.Up)
f:Toggle()
print(f:Get()) -- false
f:Toggle()
print(f:Get()) -- true
flag:Get () → boolean returns boolean

Returns the current state as a raw boolean. Use this to read the flag value inline without side effects.

if f:Get() == FlagUtil.States.Up then
    print("flag is Up")
end
flag:Changed (predicate: (boolean) → ()) → CancelFn CancelFn

Registers a callback that fires every time the state changes. The callback receives the new state as a boolean. Returns a CancelFn — call it to unsubscribe at any time.

ParameterTypeDescription
predicate(boolean) → ()Called with the new state value on every change.
local cancel = f:Changed(function(state: boolean)
    print("state is now", state)
end)

f:Down() -- fires callback

cancel() -- unsubscribe
f:Up()   -- callback no longer fires
flag:OnEvent (event: RBXScriptSignal, state: boolean) → () void

Connects an RBXScriptSignal so that when it fires, the flag transitions to state. Uses :Once() internally — auto-disconnects after firing once.

ParameterTypeDescription
eventRBXScriptSignalAny Roblox signal, e.g. humanoid.Died.
statebooleanThe state to transition to when the event fires.
local isAlive = FlagUtil.new(FlagUtil.States.Up)

-- Automatically go Down when the humanoid dies
isAlive:OnEvent(humanoid.Died, FlagUtil.States.Down)
flag:WaitUntil (state: boolean) → () yields

Yields the current coroutine until the flag reaches state. Returns immediately if already at that state. Must be called inside a coroutine or task.spawn.

ParameterTypeDescription
statebooleanThe state to wait for.

Calling this outside a coroutine prints a warning and returns immediately without yielding.

task.spawn(function()
    f:WaitUntil(FlagUtil.States.Down)
    print("flag went Down!")
end)

task.wait(2)
f:Down() -- resumes the coroutine above
flag:ExecuteUntil (state: boolean, predicate: (dt: number, ...any) → (), ...any) → CancelFn? CancelFn?

Binds predicate to RenderStep and calls it every frame until the flag reaches state. Passes delta_time as the first argument, followed by any extra args you provide. Returns a CancelFn to stop early, or nil if the flag is already at state.

ParameterTypeDescription
statebooleanStop executing when the flag reaches this state.
predicate(dt: number, ...any) → ()Called every frame with delta time and any extra args.
...anyExtra arguments forwarded to predicate on every frame.
local cancel = f:ExecuteUntil(FlagUtil.States.Down, function(dt: number)
    character.HumanoidRootPart.CFrame *= CFrame.new(0, dt * 2, 0)
end)

f:Down()   -- stops automatically
cancel()   -- or stop manually at any time
flag:ExecuteAfter (state: boolean, predicate: (dt: number, ...any) → (), ...any) → CancelFn CancelFn

Waits until the flag reaches state, then runs predicate every frame until the flag flips to the opposite state. Equivalent to WaitUntil(state) followed by ExecuteUntil(not state, ...). Always returns a CancelFn.

ParameterTypeDescription
statebooleanWait for this state before starting execution.
predicate(dt: number, ...any) → ()Called every frame once the state is reached.
...anyExtra arguments forwarded to predicate on every frame.

The returned CancelFn works in both phases — before the state is reached it cancels the wait; after, it cancels the RenderStep.

local sprinting = FlagUtil.new(FlagUtil.States.Down)

local cancel = sprinting:ExecuteAfter(FlagUtil.States.Up, function(dt: number)
    stamina -= dt * 10
end)

sprinting:Up()   -- stamina drain begins
sprinting:Down() -- stamina drain stops
cancel()         -- or cancel at any point
flag:ExecuteWhile (state: boolean, predicate: (dt: number, ...any) → (), ...any) → CancelFn CancelFn

Binds predicate to RenderStep and calls it every frame while the flag matches state. Unlike ExecuteUntil, the binding persists indefinitely — when the flag flips away from state the predicate simply stops being called, and when it flips back the predicate resumes automatically. Always returns a CancelFn to unbind entirely.

ParameterTypeDescription
statebooleanThe state during which the predicate is called each frame.
predicate(dt: number, ...any) → ()Called every frame while the flag matches state.
...anyExtra arguments forwarded to predicate on every frame.

The key difference from ExecuteUntil: the RenderStep binding stays alive across multiple Up/Down cycles. Use this when you want a single persistent binding that automatically pauses and resumes, rather than re-registering each time.

local sprinting = FlagUtil.new(FlagUtil.States.Down)

-- Single binding that runs whenever sprinting is Up
local cancel = sprinting:ExecuteWhile(FlagUtil.States.Up, function(dt: number)
    stamina -= dt * 10
end)

sprinting:Up()   -- predicate starts firing
sprinting:Down() -- predicate pauses (binding still alive)
sprinting:Up()   -- predicate resumes automatically

cancel()         -- fully unbinds the RenderStep
flag:Destroy () → () void

Cancels all pending threads, disconnects all RBXScriptConnections, unbinds all RenderStep callbacks, clears internal tables, and removes the metatable. The flag object is unusable after this call.

Always call Destroy() when the owning object is removed. Failing to do so leaks RenderStep bindings and suspended coroutines.

player.CharacterRemoving:Connect(function()
    isAlive:Destroy()
end)

Exported Types

-- A function that cancels an async operation
export type CancelFn = () -> ()

-- The States lookup table shape
export type States = {
    Up: boolean,
    Down: boolean
}

-- Full Flag interface
export type Flag = {
    Get:          (self: Flag) -> boolean,
    Up:           (self: Flag) -> (),
    Down:         (self: Flag) -> (),
    Toggle:       (self: Flag) -> (),

    Changed:      (self: Flag, predicate: (boolean) -> ()) -> CancelFn,
    OnEvent:      (self: Flag, event: RBXScriptSignal, state: boolean) -> (),

    WaitUntil:    (self: Flag, state: boolean) -> (),
    ExecuteUntil: (self: Flag, state: boolean, predicate: (dt: number, ...any) -> (), ...any) -> CancelFn?,
    ExecuteAfter: (self: Flag, state: boolean, predicate: (dt: number, ...any) -> (), ...any) -> CancelFn,
    ExecuteWhile: (self: Flag, state: boolean, predicate: (dt: number, ...any) -> (), ...any) -> CancelFn,

    Destroy:      (self: Flag) -> (),
}

Examples

Flashlight Toggle

Toggle a spotlight on/off with F, running a sway effect only while the flashlight is on.

local FlagUtil = require(game.ReplicatedStorage:WaitForChild("FlagUtil"))
local UIS = game:GetService("UserInputService")

local flashlight = FlagUtil.new(FlagUtil.States.Down)

-- Toggle on F key
UIS.InputBegan:Connect(function(input, processed)
    if processed then return end
    if input.KeyCode == Enum.KeyCode.F then
        flashlight:Toggle()
    end
end)

-- Update spotlight visibility on change
flashlight:Changed(function(on)
    spotlight.Enabled = on
end)

-- Run sway animation only while on
flashlight:ExecuteAfter(FlagUtil.States.Up, function(dt)
    -- update attachment CFrame for sway
end)

Combat State

Lock out actions during an attack animation using WaitUntil.

local attacking = FlagUtil.new(FlagUtil.States.Down)

local function attack()
    if attacking:Get() then return end -- busy
    attacking:Up()
    
    -- Play anim, wait for it to finish
    local track = animator:LoadAnimation(attackAnim)
    track:Play()
    track.Stopped:Wait()
    
    attacking:Down()
end

-- Wait for attack to end before doing something else
task.spawn(function()
    attacking:WaitUntil(FlagUtil.States.Down)
    print("Attack finished, can combo now")
end)

Loading Gate

Block game logic until assets are ready using OnEvent and WaitUntil.

local loaded = FlagUtil.new(FlagUtil.States.Down)

-- Flip Up when ContentProvider finishes
task.spawn(function()
    ContentProvider:PreloadAsync(assetsToLoad)
    loaded:Up()
end)

-- Multiple systems can independently wait
task.spawn(function()
    loaded:WaitUntil(FlagUtil.States.Up)
    initUI()
end)

task.spawn(function()
    loaded:WaitUntil(FlagUtil.States.Up)
    startGameLoop()
end)