Script API

Everything a hook script can reach through the roflux global. Together these let a script act like a plugin: read and write files, generate code, build instances, and change the finished tree before it reaches Studio.

New to hook scripts? Start with the script system, which covers what they are and the loading rules. This page is the full reference.

Your editor knows all of this

Every function here is typed in the types.d.luau file RoFlux writes into your project, so you get autocomplete and type checking inside scripts/.

Events

Register a callback once, at the top level of a script. It is then called at the right moment during every build.

FunctionCalled whenGets
roflux.onRead(fn)a source file has been readthe file, return text to replace it
roflux.onTransfer(fn)just before source goes to Studiothe file, return text to replace it
roflux.transpile(ext, fn)a file with that extension is foundthe file, return what it becomes
roflux.onTree(fn)the whole tree is builtthe root node, change it or return a new one
roflux.onCompile(fn)every build finishesbuild info
roflux.onSync(fn)a change is sent to Studiosync info
roflux.onConnect(fn)the plugin connects and starts a sessionthe session
roflux.onEvent(fn)something fires the event in ServerStoragethe session, then what was fired
roflux.onGameEvent(fn)something fires the event in ReplicatedStorage during a playtestthe session, game session, side, then what was fired
roflux.onLog(fn)anything is printed during a playtestthe session, game session, side, message and level
roflux.onGameStart(fn)a playtest startsthe session and game session
roflux.onGameEnd(fn)a playtest stopsthe session and game session
roflux.onAdded(fn)a file appearsthe file
roflux.onRemoved(fn)a file is deletedthe file
roflux.onChanged(fn)a file is savedthe file
roflux.on(name, fn)any of the above, by name
roflux.defer(fn)the next buildnothing

The file table

FieldExample for src/Hud.client.luau
path"src/Hud.client.luau"
name"Hud.client.luau"
stem"Hud"
folder"src"
extension"luau"
sourcethe current source, on source hooks and transpilers
textthe raw file contents, on transpilers
className"LocalScript", on source hooks

Build info from onCompile

{ initial = false, instances = 42, scripts = 17, files = 30, added = 1, updated = 2, removed = 0 }

initial is true for the first build after the server starts or roflux compile runs. The counts say what changed since the last build.

Sync info from onSync

{ added = 1, updated = 0, removed = 0, summary = "+1 ~0 -0", lines = { "+ ReplicatedStorage/Shared/Coins [ModuleScript]" }, clients = 1 }

This only fires while roflux serve is running, and only when a build actually changed something.

Studio events

These let your game and Studio talk to your hook scripts. Use them to copy the console into a file, run a tool from the command bar, collect stats from a playtest, or anything else you can think of. They only work while roflux serve is running and the plugin is connected.

Sessions

Every time the plugin connects, it starts a session with its own ID, a string like "3F2504E0-4F89-11D3-9A0C-0305E82C3301". Every Studio event hands you this ID first, so you can tell connections apart. Disconnecting ends the session.

roflux.onConnect runs as soon as a session starts, with its ID. Connecting again, even from the same Studio, starts a new session and runs it again.

roflux.onConnect(function(session)
	roflux.log("studio connected", session)
	roflux.fireStudio("welcome", roflux.project().name)
end)

By the time it runs, the RoFlux events are already in ServerStorage, so fireStudio works straight away.

Every playtest also gets a game session ID, made fresh each time you press Play. The server and every client in that playtest share it, so you can group everything from one run together.

Two events, one for each direction

While connected, the plugin makes a BindableEvent named RoFlux, and inside it a second BindableEvent named Received. Each one only carries messages one way:

EventYouYour hook scripts
RoFluxcall :Fire(...) on itget it in onEvent or onGameEvent
RoFlux.Receivedconnect to .Event on itsend to it with fireStudio or fireInGame
your code  ── RoFlux:Fire(...) ─────────►  your hook scripts
your code  ◄── RoFlux.Received.Event ────  your hook scripts

So to send something to your scripts, fire RoFlux. To hear from your scripts, listen to RoFlux.Received. RoFlux never fires RoFlux itself, and never listens to Received, so anything that reaches your scripts came from someone firing RoFlux, and a message can never bounce back and forth.

The pair lives in ServerStorage in edit mode, and in ReplicatedStorage during a playtest.

From Studio: onEvent

While connected, the plugin puts the RoFlux event in ServerStorage. Fire it from the command bar, another plugin or any edit time code:

-- in the Studio command bar
game.ServerStorage.RoFlux:Fire("export", workspace.Map:GetPivot(), 42)
-- scripts/tools.luau
roflux.onEvent(function(session, action, pivot, count)
	if action == "export" then
		roflux.fs.write("out/pivot.json", roflux.json.encode(pivot, true))
	end
end)

To get answers back in Studio, connect to Received, and have your scripts call fireStudio:

-- in the Studio command bar
game.ServerStorage.RoFlux.Received.Event:Connect(function(name, ...)
	print("from my scripts", name, ...)
end)

Both events are only there in edit mode. They are never saved into the place, and they are removed as soon as the plugin disconnects.

From a playtest: onGameEvent

When you press Play while connected, the plugin puts the RoFlux event, with its Received child, in ReplicatedStorage. Both server and client code can fire it:

-- any Script or LocalScript
local RoFlux = game.ReplicatedStorage:FindFirstChild("RoFlux")

if RoFlux then
	RoFlux:Fire("coinCollected", player.Name, coin.Position)
end
roflux.onGameEvent(function(session, gameSession, context, name, ...)
	roflux.log(context, name, ...)
end)

context is "server" or "client", depending on which side fired it. Use FindFirstChild as above, so the same code runs fine when RoFlux is not connected and the event is not there. It only exists in playtests in Studio, never in a live game.

To get messages from your scripts during a playtest, listen to RoFlux.Received. See Sending back.

Console: onLog

During a playtest, everything printed to the output on the server and on the client is sent to onLog:

roflux.onLog(function(session, gameSession, context, message, level)
	roflux.fs.append(`logs/{gameSession}.txt`, `[{context}] [{level}] {message}\n`)
end)

level is "output" for print, "warning" for warn, "error" for errors, or "info". This is only for playtests. Edit mode output is not sent.

Playtests: onGameStart and onGameEnd

roflux.onGameStart(function(session, gameSession)
	roflux.log("playtest started", gameSession)
end)

roflux.onGameEnd(function(session, gameSession)
	roflux.log("playtest ended", gameSession)
end)

Each playtest sends one start and one end, from the server side.

What your scripts get

Whatever you fire on RoFlux reaches your scripts as plain data, in the same order, with any nil gaps kept. Numbers, strings, booleans and tables arrive as they are. Roblox types can not exist outside Roblox, so each one arrives as a table of its values, with a type field saying what it was:

FiredArrives as
Vector3, Vector2{ type = "Vector3", X, Y, Z }
CFrameX, Y, Z, Position, LookVector, RightVector, UpVector, Orientation in degrees, and all twelve Components
Color3R, G, B from 0 to 1, and Hex
BrickColorName, Number, Color
UDim, UDim2Scale and Offset, or X and Y holding those
Rect, NumberRangeMin and Max
NumberSequence, ColorSequenceKeypoints, a list of points
An enum like Enum.Material.NeonEnumType, Name, Value
FontFamily, Weight, Style
DateTimeUnixTimestampMillis and Iso
Ray, Region3, TweenInfo, PhysicalPropertiestheir properties
An InstanceClassName, Name, and Path from GetFullName
A function, thread, or anything else{ type = "function", value = "function: 0x..." }

So a CFrame becomes a table you can read, like pivot.Position.Y, but it has no methods. A table that holds itself is cut off with "<cycle>", and NaN or infinity arrive as the text "nan", "inf" or "-inf".

Messages are sent in small batches

The plugin groups messages and sends them a few times a second, so a script sees them a moment later, always in the order they happened. If a playtest prints thousands of lines at once, anything past a thousand waiting messages is dropped rather than slowing the game down.

Sending back: fireStudio and fireInGame

Scripts can talk the other way too. These two functions fire the Received event, and your code listens to it:

FunctionListen with
roflux.fireStudio(...)ServerStorage.RoFlux.Received.Event, in edit mode
roflux.fireInGame(...)ReplicatedStorage.RoFlux.Received.Event, on the server and on every client of the running playtest
-- scripts/greeter.luau
roflux.onGameStart(function(session, gameSession)
	roflux.fireInGame("config", roflux.json("data/tuning.json"))
end)
-- any Script or LocalScript
local RoFlux = game.ReplicatedStorage:FindFirstChild("RoFlux")

if RoFlux then
	RoFlux.Received.Event:Connect(function(name, data)
		print(name, data)
	end)
end

Send basic things: text, numbers, booleans, and tables of those. They arrive in the same order, with any nil gaps kept. Functions can not be sent, and a Roblox type like a Vector3 does not exist on this side, so send its numbers and build it in the game.

Both return true when the message was handed to the server. That only happens while roflux serve is running, so during roflux compile they return false. A message sent while nothing is listening is dropped: fireStudio needs the plugin connected, and fireInGame needs a playtest running. A playtest only gets messages sent after it started, so onGameStart is a good place to send the first one.

Firing Received yourself only reaches your own listeners. It is never sent to your scripts, since only RoFlux goes that way.

While connected, the plugin also keeps two attributes on ServerStorage, RoFluxSession and RoFluxServer. A playtest reads them to know which session it belongs to and where to send things. They are cleared when the plugin disconnects.

Logging

FunctionWhat it does
roflux.log(...)Prints to the server output, tagged hook.
roflux.warn(...)Prints as a warning.
roflux.error(...)Prints as an error. It does not stop the build.
roflux.inspect(value)Turns any value into readable text, with tables laid out and keys sorted.

Tables passed to log, warn and error are printed through inspect for you, so roflux.log(someTable) shows the contents instead of an address. To actually stop a build, call Luau's own error().

Files

All paths are relative to the project folder. Absolute paths and .. are refused, so a script can never touch anything outside the project.

FunctionReturns
roflux.fs.read(path)The contents, or nil if the file is missing. Works for binary files too.
roflux.fs.write(path, text)true if it wrote, false if the file already held exactly that. Makes any missing folders.
roflux.fs.append(path, text)Nothing. Adds to the end of a file, creating it if needed.
roflux.fs.remove(path)true if something was removed. Works on files and empty folders.
roflux.fs.mkdir(path)Nothing. Makes the folder and any parents.
roflux.fs.exists(path)Whether anything is there.
roflux.fs.isFile(path)Whether it is a file.
roflux.fs.isDirectory(path)Whether it is a folder.
roflux.fs.list(path)Names directly inside a folder, sorted.
roflux.fs.walk(path)Every file below a folder, as project paths.
roflux.fs.glob(pattern)Every project file matching a pattern like "data/**/*.json".
roflux.fs.stat(path){ size, modified, isFile, isDirectory }, or nil.
roflux.fs.root()The full path of the project folder.

In a glob, * matches within one folder and ** matches any number of folders. walk and glob skip hidden folders, target and node_modules.

Writing files can cause another build

A file written inside the project is seen by the watcher, which starts a build, which runs your script again. write skips files whose content would not change, so this settles on its own. It only loops forever if you write something different every time, like the current time.

remove will never delete the project folder itself, and it will not delete a folder that still has files in it. The older names roflux.read, roflux.exists, roflux.isDirectory, roflux.list, roflux.walk and roflux.root still work and point at the same functions.

Data formats

FunctionWhat it does
roflux.json(path)Reads and decodes a JSON file. Same as roflux.json.read.
roflux.json.decode(text)Text to a table.
roflux.json.encode(value, pretty?)A table to text, on one line or laid out.
roflux.toml.read(path)Reads and decodes a TOML file.
roflux.toml.decode(text)Text to a table.
roflux.toml.encode(value)A table to text.
roflux.base64.encode(text)Encodes text or binary.
roflux.base64.decode(text)Decodes back to the original.
roflux.hash(text)A SHA-256 hash, as 64 hex characters.

Encoders sort object keys, so the same data always produces the same text. That matters when the output becomes a script, because text that shuffles every build would look like a change every build.

Writing Luau

These turn data into Luau source, which is the heart of most generators.

FunctionExample
roflux.luau.literal(value)Any value as Luau source, so a table becomes a table you can paste into code
roflux.luau.module(value)The same, with return in front, ready to be a ModuleScript
roflux.luau.quote(text)say "hi" becomes "say \"hi\""
roflux.luau.isIdentifier(name)Whether a name can be written without quotes
local levels = {}

for _, file in roflux.fs.glob("data/levels/*.json") do
	levels[roflux.path.stem(file)] = roflux.json(file)
end

local source = roflux.luau.module(levels)

Types for files you transpile

A transpiled file is not Luau, so your editor has nothing to read when you require what it became. roflux.meta fixes that. It writes a small Luau module into a MetaStubs folder and points the sourcemap at it, so you get autocomplete and type checking while you keep working in the original file.

CallWhat it writes
roflux.meta(file, "text")That Luau source, so you write the types yourself.
roflux.meta(file, value)return plus the value, so the types are worked out from the data.

The first argument is the file table a transpiler is given, or a path like "data/shop.csv".

-- scripts/csv.luau
roflux.transpile("csv", function(file)
	local rows = parse(file.text)

	roflux.meta(file, rows)

	return roflux.luau.module(rows)
end)

Now require(script.Parent.Shop) is typed like the table itself, so shop[1].price is checked and autocompletes. To write the types by hand instead, pass source:

roflux.meta(file, [[
export type Row = { name: string, price: number }
return (nil :: any) :: { Row }
]])
If your transpiler already makes Luau

Pass the source you generated straight to roflux.meta. The stub is then the real module, so the types are exactly right with no extra work.

The MetaStubs folder

For transpiled files only

A normal .luau file is already read by your editor. A stub for one would be read instead of the real file, so use roflux.meta for files that are not Luau.

Building instances

roflux.new builds a declaration without writing ["$ClassName"] by hand. It works like Instance.new with the properties filled in.

roflux.new("Frame", {
	Name = "Card",
	Size = roflux.types.fromScale(0.3, 0.2),
	BackgroundColor3 = roflux.types.hex("#12141A"),

	roflux.new("TextLabel", {
		Name = "Title",
		Text = "Play",
		FontFace = roflux.types.font("GothamSSm", "Bold"),
	}),
})

It sorts each field out for you:

Children can be listed by position, as above, as long as each one has a Name. You can also pass them as a third argument, either as a list or as a table keyed by name.

Return the result from a transpiler, or turn it into a tree node with roflux.tree.node.

Value helpers

Small functions that produce the value shapes described on the Value types page. Everything they return is an array, so it works when syncing and in place files.

FunctionGives
roflux.types.hex("#3C91F5")a Color3
roflux.types.rgb(60, 145, 245)a Color3 from 0 to 255 numbers
roflux.types.color(0.2, 0.5, 1)a Color3 from 0 to 1 numbers
roflux.types.hsv(0.6, 0.8, 1)a Color3 from hue, saturation and value
roflux.types.vector3(x, y, z)a Vector3
roflux.types.vector2(x, y)a Vector2
roflux.types.udim(scale, offset)a UDim
roflux.types.udim2(xs, xo, ys, yo)a UDim2
roflux.types.fromScale(x, y)a UDim2, like UDim2.fromScale
roflux.types.fromOffset(x, y)a UDim2, like UDim2.fromOffset
roflux.types.cframe(x, y, z)a CFrame, or pass all twelve numbers
roflux.types.rect(minX, minY, maxX, maxY)a Rect
roflux.types.range(min, max)a NumberRange
roflux.types.numberSequence(...)a number, or a list of points
roflux.types.colorSequence(a, b)a fade from one color to another
roflux.types.font("GothamSSm", "Bold")a FontFace, with the family path filled in
roflux.types.typed(name, value)the explicit $Type form
Colors come back as 0 to 1

hex, rgb and hsv all return numbers between 0 and 1. That way a very dark color like #010101 can never be mistaken for white, which would happen if it came back as [1, 1, 1].

Text

FunctionExample
roflux.text.trim(s)" hi " becomes "hi"
roflux.text.trimStart(s), trimEnd(s)trim one side only
roflux.text.startsWith(s, prefix)true or false
roflux.text.endsWith(s, suffix)true or false
roflux.text.replace(s, find, with)plain text, so "." means a dot
roflux.text.split(s, separator)a list, split on commas by default
roflux.text.lines(s)a list of lines, handling Windows line endings
roflux.text.indent(s, prefix)every line indented, with a tab by default
roflux.text.words(s)"fooBar_baz" becomes { "foo", "Bar", "baz" }
roflux.text.pascal(s)"HelloWorld"
roflux.text.camel(s)"helloWorld"
roflux.text.snake(s)"hello_world"
roflux.text.kebab(s)"hello-world"
roflux.text.title(s)"Hello World"
roflux.text.padStart(s, width, fill)("7", 3, "0") becomes "007"
roflux.text.padEnd(s, width, fill)the same, on the other side

Paths

FunctionExample
roflux.path.join(...)("a", "b/../c", "d.txt") becomes "a/c/d.txt"
roflux.path.normalize(p)tidies slashes, . and ..
roflux.path.dirname(p)"a/b/c.txt" becomes "a/b"
roflux.path.basename(p)"a/b/c.txt" becomes "c.txt"
roflux.path.stem(p)"a/Hud.client.luau" becomes "Hud.client"
roflux.path.extension(p)"a/B.LUAU" becomes "luau"
roflux.path.withExtension(p, ext)("a/b.txt", "md") becomes "a/b.md"

path.stem only removes the last extension. The stem on a file table removes all of them, so it gives "Hud".

The tree

roflux.onTree hands you the finished tree just before RoFlux writes the sourcemap and syncs to Studio. Anything you change is exactly what Studio receives. This is how a script can tag instances, inject generated ones, rename things, or remove what it does not want.

roflux.onTree(function(root)
	roflux.tree.each(root, function(node, path)
		if node.className == "ModuleScript" then
			table.insert(node.tags, "Module")
		end
	end)

	local generated = roflux.tree.ensure(root, "ReplicatedStorage/Generated")
	roflux.tree.add(generated, roflux.tree.node("Built", roflux.new("StringValue", { Value = roflux.version() })))
end)

A node

FieldHolds
namethe instance name
classNamethe class
propertiesa table of properties, in the same JSON shapes as project files
attributesa table of attributes
tagsa list of tags
childrena list of child nodes
ownership"managed", "passthrough" or "reference"
filePathsthe files this node came from

Helpers

FunctionWhat it does
roflux.tree.node(name, declaration)Makes a new node from a class name or any declaration.
roflux.tree.find(root, "A/B/C")Finds a node by path, or nil.
roflux.tree.child(node, name)Finds a direct child by name.
roflux.tree.each(root, fn)Visits every node with its path. Safe to change the tree while visiting.
roflux.tree.add(parent, node)Adds a child, replacing any child with the same name.
roflux.tree.remove(root, "A/B")Removes a node and returns it.
roflux.tree.ensure(root, "A/B")Finds a folder by path, creating any missing ones.
Be careful with ownership

A managed node owns its children, so RoFlux deletes anything inside it that the tree does not list. ensure creates passthrough folders on purpose, so it never deletes what is already in the place. If you set a service to "managed", everything in that service that your project does not declare will be deleted from Studio.

Removing a managed node from the tree removes it from Studio on the next sync, just like deleting its file would.

Project and settings

FunctionReturns
roflux.project(){ name, id, root, manifest }
roflux.config(key?)Your settings from the project file, or one of them.
roflux.env(name)An environment variable, or nil.
roflux.platform()"windows", "linux" or "macos".
roflux.version()The RoFlux version.

Give your scripts settings with a Scripts block in the project file. Anything in it can be read with roflux.config:

{
    "ProjectID": "MyGame",
    "Scripts": {
        "obfuscate": { "enabled": true },
        "banner": "built by RoFlux"
    }
}
local settings = roflux.config("obfuscate") or {}

if settings.enabled then
	-- ...
end

Editing the project file reloads every script, so new settings take effect straight away.

Running programs

local result = roflux.exec("stylua", { "--check", "src" })

if not result.ok then
	roflux.warn(result.stdout)
end

roflux.exec(program, args, options) runs a program and waits for it. It returns { ok, code, stdout, stderr }. Pass { cwd = "some/folder" } as options to run it inside a project folder.

Off unless you turn it on

Hook scripts run the moment someone starts roflux serve. If running programs were always allowed, opening a project you downloaded could run anything on your computer. So exec refuses to work until the project file says so:

"Scripts": { "AllowExec": true }

Only turn this on for projects you trust. A few more things to know:

Cache

A place to keep data between builds. It lives as long as the server does, and survives your scripts being reloaded, so it is the right place to remember expensive work.

FunctionWhat it does
roflux.cache.get(key)The stored value, or nil.
roflux.cache.set(key, value)Stores a value. Setting nil removes it.
roflux.cache.has(key)Whether something is stored.
roflux.cache.delete(key)Removes it, and says whether it was there.
roflux.cache.clear()Removes everything.
roflux.cache.keys()Every key, sorted.

Values must be plain data: text, numbers, booleans and tables of those. Functions cannot be stored. Everything is gone when the server stops.

Luau built ins

Hook scripts also have Luau's own libraries: string, table, math, utf8, bit32, buffer and os. From os you get os.time(), os.date() and os.clock(), which is all you need for timestamps.

All scripts in scripts/ share one Luau runtime and load in name order. A global set in one script can be read by the others.

Modules and require

Split a big script into pieces with require. Paths are relative to the script doing the requiring:

PathLooks in
require("./util")the same folder as this script
require("../util")the folder above that
require("../../util")and so on, one folder up for each ../
-- scripts/lib/strings.luau
local strings = {}

function strings.shout(text)
	return string.upper(text) .. "!"
end

return strings
-- scripts/banner.luau
local strings = require("./lib/strings")

roflux.onTransfer(function(file)
	return "-- " .. strings.shout(file.stem) .. "\n" .. file.source
end)

How a path is found:

Each file runs once. Every later require of it gets back the same value, even from a different script. Files inside scripts/ are loaded as hook scripts too, but a file that was already required is not run a second time. So a helper module in scripts/lib/ is safe.

Modules can live anywhere in the project, not just in scripts/. RoFlux remembers every file a script required, and saving any of them reloads your scripts, just like saving a file in scripts/ does.

Two files that require each other are stopped with an error instead of looping forever. Your editor follows these paths too, so a required module's functions show up in autocomplete.

Examples

Generate a module from data files

roflux.onTree(function(root)
	local levels = {}

	for _, file in roflux.fs.glob("data/levels/*.json") do
		levels[roflux.path.stem(file)] = roflux.json(file)
	end

	local shared = roflux.tree.ensure(root, "ReplicatedStorage/Generated")

	roflux.tree.add(shared, roflux.tree.node("Levels", roflux.new("ModuleScript", {
		Source = roflux.luau.module(levels),
	})))
end)

The data folder does not need to be part of the tree at all. Because it is inside the project, saving any level file rebuilds and updates the module in Studio.

Stamp every script in release builds

local release = roflux.env("RELEASE") ~= nil

roflux.onTransfer(function(file)
	if not release then
		return nil
	end

	return `-- {roflux.project().name} {os.date("%Y-%m-%d")}\n` .. file.source
end)

Skip work that has not changed

roflux.onTransfer(function(file)
	local key = "minified:" .. roflux.hash(file.source)
	local done = roflux.cache.get(key)

	if done then
		return done
	end

	local result = minify(file.source)
	roflux.cache.set(key, result)
	return result
end)

Report every sync to a log file

roflux.onSync(function(info)
	roflux.fs.append("logs/sync.log", os.date("%H:%M:%S ") .. info.summary .. "\n")
end)

Put the log folder somewhere that is not part of your tree. Writing into the project still wakes the watcher, but nothing in the tree changes, so it will not sync again.