Appearance
Stdlib and Config RFCs
Written: 2026-10-14
Learning objectives
- You interpolate and map numbers safely.
- You validate floats before you trust them.
- You pack bits into buffers.
- You resolve modules with aliases and init files.
- You configure projects and read suppression honestly.
Prerequisites
Math lerp
It blends two numbers. You use it for motion, sliders, and transitions.
Original problem
You hand-rolled blends with mixed formulas. Some missed the endpoint exactly. Some jittered near 1. Players noticed stuck doors and shimmering bars.
Change and motivation
The library gained a tuned blend with exact endpoints. You call it with two ends and a t. Values outside zero to one extrapolate. The math lerp RFC picks speed plus exactness, monotonicity, boundedness, and consistency.
Implemented semantics
You write math dot lerp with a, b, and t. A t of 1 returns b exactly. Equal ends return that value for any t. Reversed ends also blend. You still clamp separately with math dot clamp.
Before and after
Before:
luau
--!strict
local function blend(a: number, b: number, t: number): number
return a + (b - a) * t
end
print(blend(0, 100, 0.5))txt
50Your helper works but misses the exactness promise at 1 for some inputs.
After:
luau
--!strict
print(math.lerp(0, 100, 0.5))
print(math.lerp(0, 100, 1))txt
50
100The library call matches at the midpoint and lands exactly on 100. You delete the helper.
Realistic example
You slide a gate open over two seconds.
luau
--!strict
local function gateX(start: number, goal: number, alpha: number): number
return math.lerp(start, goal, alpha)
end
print(gateX(0, 12, 0.25))
print(gateX(0, 12, 1))txt
3
12You pass the endpoints once. Quarter alpha gives 3. Full alpha gives exactly 12, so arrival checks trigger.
Strict implications
All three arguments are numbers. The result is a number. Passing an optional without narrowing still errors. A nil alpha means a bug.
Edge cases
Infinite t or NaN inputs give non-finite results, so you validate network-driven t values first. The blend does not clamp, so overshoot happens past the ends.
Status
The math lerp RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Math map
It moves a value across ranges. You use it for bars, dials, and difficulty curves.
Original problem
Every system rewrote the same five-argument mapping. Copies drifted. Some clamped, some did not. Readers could not tell which at a glance.
Change and motivation
The library gained one map with explicit ends. You pass the value, the input ends, and the output ends. Out-of-range inputs extrapolate. The math map RFC keeps clamping separate.
Implemented semantics
You write math dot map with x, inmin, inmax, outmin, outmax. The formula scales linearly. Zero-width input ranges divide by zero, so you never pass equal input ends.
Before and after
Before:
luau
--!strict
local function scale(x: number): number
return 0 + (x - 0) * (100 - 0) / (10 - 0)
end
print(scale(7))txt
70Your helper hardcodes both ranges. Reuse means editing the body.
After:
luau
--!strict
print(math.map(7, 0, 10, 0, 100))txt
70The library call names both ranges at the use. Each call can differ. The output matches.
Realistic example
You turn health into a bar width.
luau
--!strict
local function barWidth(health: number): number
return math.map(health, 0, 100, 0, 200)
end
print(barWidth(25))
print(barWidth(100))txt
50
200You map the game range onto pixels. Quarter health gives 50. Full health fills the bar.
Strict implications
All five arguments are numbers and the result is a number. You narrow attributes before passing them. The checker catches a swapped string immediately.
Edge cases
Equal input ends divide by zero. Reversed ranges are allowed and behave linearly. Clamp with math dot clamp when the bar must never overflow.
Status
The math map RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Isnan, isinf, isfinite
Three predicates classify floats. You use them at every untrusted numeric boundary.
Original problem
NaN tests used the riddle x ~= x. Finite tests combined equality with absolute value against huge. Infinity needed another manual compare. New readers could not parse the intent. Networked games need these checks constantly.
Change and motivation
The library gained three predicates with plain names. Isnan spots NaN. Isinf spots either infinity. Isfinite accepts only ordinary numbers. The float predicates RFC matches other modern languages.
Implemented semantics
You call each with one number and receive a boolean. Isnan is true only for NaN. Isinf is true only for infinities. Isfinite is true only when neither applies.
Before and after
Before:
luau
--!strict
local speed = 0 / 0
local broken = speed ~= speed
print(broken)txt
trueThe idiom works but reads as a typo. Every reader relearns it.
After:
luau
--!strict
local speed = 0 / 0
print(math.isnan(speed))
print(math.isfinite(42))txt
true
trueThe names state the question. NaN is NaN. Ordinary 42 is finite.
Realistic example
You accept a client-reported jump speed only when finite.
luau
--!strict
local function speedOk(raw: unknown): boolean
if typeof(raw) == "number" then
return math.isfinite(raw)
end
return false
end
print(speedOk(16))
print(speedOk(0 / 0))txt
true
falseYou narrow unknown to number first. You test finiteness. Infinity and NaN both fail the test. The server never integrates a bad speed.
Strict implications
You narrow unknown before calling, since the predicates take numbers. The results are plain booleans, so truthiness tests fit. You keep the checks server side.
Edge cases
Isinf returns false for NaN, so you test NaN separately when the distinction matters. Huge is infinite by definition. Negative infinity counts as infinite too.
Status
The float predicates RFC shows Status: Implemented. Engine reference lists math.isfinite(x: number): boolean alongside math.isnan/math.isinf. You use them today on updated Studio. You still confirm them in Studio per how to read status.
If your Studio or external solver reports math does not contain field isfinite, your Luau is behind: update Studio, update luau-lsp/selene via rokit.toml, restart. For version-proof repos use the pure fallback below (identical semantics, zero version risk), this is what src/shared/NumberUtil.luau ships:
luau
--!strict
local function isFinite(value: number): boolean
return value == value and value ~= math.huge and value ~= -math.huge
endVector lerp
It blends vectors like math lerp blends numbers. You use Vector3:Lerp for paths and effects, per the Vector3 reference. The alpha is not clamped, so clamp it yourself when the blend must stay between the endpoints.
Original problem
Vector blends were hand-rolled per project. Some missed endpoints. Results differed from math lerp behavior. Portable vector code had no shared spelling.
Change and motivation
The vector library gained a lerp that mirrors math lerp, including the exact endpoint promise. Scalar alpha drives the blend. Four-wide vectors work. The vector lerp RFC keeps both lerps consistent.
Implemented semantics
You call vector dot lerp with two vectors and a number. A t of 1 returns the goal exactly. Other t values blend per component. Roblox Vector3 colon Lerp stays separate and makes weaker promises.
Before and after
Before:
luau
--!strict
local a = vector.create(0, 0, 0)
local b = vector.create(10, 0, 0)
local mid = a + (b - a) * 0.5
print(mid.X)txt
5Your formula works but carries no endpoint promise. Each file repeats it.
After:
luau
--!strict
local a = vector.create(0, 0, 0)
local b = vector.create(10, 0, 0)
print(vector.lerp(a, b, 0.5).X)
print(vector.lerp(a, b, 1).X)txt
5
10The library call blends and lands exactly. Midpoint and endpoint both print cleanly.
Realistic example
You move a marker from spawn toward a gate.
luau
--!strict
local function markerAt(alpha: number): vector
local spawn = vector.create(0, 5, 0)
local gate = vector.create(0, 5, 40)
return vector.lerp(spawn, gate, alpha)
end
print(markerAt(0.5).Z)
print(markerAt(1).Z)txt
20
40Half alpha gives 20 on Z. Full alpha gives exactly 40. Arrival logic triggers on the exact value.
Strict implications
Both ends are vectors and alpha is a number. The result is a vector. Mixing vector with Vector3 needs conversion at your boundary, since the types differ.
Edge cases
Non-finite alpha yields non-finite components, so you validate it. The function does not clamp. Fully vector alpha vectors are out of scope; you pass one scalar.
Status
The vector lerp RFC shows Status: Implemented. Do not depend on it yet: the stdlib reference page does not list it, so confirm vector.lerp exists in Studio per how to read status first. Until then prefer the manual blend or Vector3:Lerp() at the Engine boundary.
Buffer bits
They read and write arbitrary bit ranges. You use them for compact packets.
Original problem
Custom widths needed careful shifts and masks. Partial-byte writes and sequential reads across bytes were error prone. Every net module reinvented the loop.
Change and motivation
The buffer library gained readbits and writebits. You name a bit offset and a bit count up to 32. Values are unsigned, little endian from least significant bits. The buffer bits RFC bounds-checks every access.
Implemented semantics
You call buffer dot writebits with buffer, bit offset, bit count, and value. Only the low count bits of the value are stored. You call buffer dot readbits with the same triple and receive an unsigned number. Zero width reads 0 and writes nothing. Out-of-range access errors.
Before and after
Before:
luau
--!strict
local pack = buffer.create(2)
buffer.writeu8(pack, 0, 3)
print(buffer.readu8(pack, 0))txt
3Byte calls handle aligned data. Bit fields need the new pair.
After:
luau
--!strict
local pack = buffer.create(2)
buffer.writebits(pack, 0, 3, 5)
buffer.writebits(pack, 3, 3, 2)
print(buffer.readbits(pack, 0, 3))
print(buffer.readbits(pack, 3, 3))txt
5
2Two 3-bit fields share bytes. Each read returns its own value. The layout stays explicit.
Realistic example
You pack a 2-bit team and a 6-bit level into one byte.
luau
--!strict
local packet = buffer.create(1)
local function encode(team: number, level: number): buffer
buffer.writebits(packet, 0, 2, team)
buffer.writebits(packet, 2, 6, level)
return packet
end
local data = encode(2, 40)
print(buffer.readbits(data, 0, 2))
print(buffer.readbits(data, 2, 6))txt
2
40You write team into bits 0 to 1 and level into bits 2 to 7. Reads mirror the layout. One byte carries both fields.
Strict implications
Offsets, counts, and values are numbers. The calls return number and unit respectively. You keep counts within 0 to 32 or the call errors. Buffers themselves are not nilable after creation, so no guard is needed there.
Edge cases
Signed reads are out of scope, so you apply a bias for signed fields. Bit offsets can exceed 32-bit ranges on huge buffers, so you compute them in numbers carefully. Manual masking can beat the loop when you already proved safety, but you keep the checked call until measurements justify it.
Status
The buffer bits RFC shows Status: Implemented. You use them today. You still confirm them in Studio per how to read status.
Require aliases
They shorten requires to shared libraries. You use them in filesystem projects with a package layout.
Original problem
Requires carried full disk paths. Moving a library meant editing every file. Sharing readable imports across a team was painful. A package manager had no stable hook.
Change and motivation
Projects map short names to paths in configuration. Requires starting with at resolve through that map. Submodules hang off the alias with a slash. The require aliases RFC also reserves the bare at name for the future and treats alias names case insensitively.
Implemented semantics
You define aliases in configuration, then require at plus name. Relative paths still work with dot prefixes. Aliases apply only at the start of the path. Chained aliases resolve iteratively, and cycles fail. One amendment later removed the paths array and tightened resolution.
Alias requires run in toolchain and filesystem contexts. Studio instance requires differ, so you test alias setups in your actual build chain per how to read status.
Before and after
Before:
luau
--!strict
local shop = require("./packages/shop")
print(shop ~= nil)txt
trueThe relative path works but encodes the layout. Moves break every importer.
After:
luau
--!strict
local shop = require("@shop")
print(shop ~= nil)txt
trueThe alias hides the location. Moves edit configuration only. The import line never changes.
Realistic example
You share a formatting library between two services.
luau
--!strict
local format = require("@shared/format")
local coins = require("@shop/prices")
print(format ~= nil)
print(coins ~= nil)Both aliases resolve through configuration. Both services import the same lines. No file counts directories upward.
Strict implications
Required modules keep their own types. You annotate the boundary when the package ships without Luau types. Aliases never change checking; they only change resolution.
Edge cases
Alias names allow letters, digits, dots, dashes, and underscores, but no slashes. The mid-path alias never applies. Case differences resolve identically, so you pick one casing and keep it.
Status
The require aliases RFC shows Status: Implemented. You use it where your toolchain supports it. You still confirm it in your build per how to read status.
Amended require
It makes every require explicit. You use it to end guessing between relatives and aliases.
Original problem
Unprefixed paths were ambiguous. Readers could not tell a relative from an alias. Resolution order debates never settled. Loose file-type preferences hid conflicts.
Change and motivation
Every require now starts with at, dot slash, or dot dot slash. Bare paths error. The old paths array is removed. Conflicting siblings error instead of picking silently. The amended require RFC overrides the matching part of the older aliases page.
Implemented semantics
You prefix aliases with at. You prefix local files with dot slash or dot dot slash. Anything else fails fast. A luau plus lua file collision errors. A file colliding with a same-named init directory errors.
Before and after
Before:
luau
--!strict
local data = require("@shop/data")
print(data ~= nil)txt
trueExplicit prefixes already work. They are now the only form.
After:
luau
--!strict
local data = require("@shop/data")
local localData = require("./data")
print(data ~= nil)
print(localData ~= nil)txt
true
trueBoth lines carry prefixes. The alias goes to configuration. The relative stays local. No bare path remains.
Realistic example
You wire a service to shared code and a sibling helper.
luau
--!strict
local coins = require("@shop/prices")
local helper = require("./helper")
print(coins ~= nil)
print(helper ~= nil)Shared code travels by alias. Nearby code travels by relative path. A reader knows which is which from the first character.
Strict implications
Resolution never changes types. Autocomplete can offer aliases after at and files after dot prefixes. You keep imports static and alphabetical so tools follow them.
Edge cases
Old tutorials show bare paths that now error, so you add prefixes when copying. Removing the paths array breaks configs that relied on it, so you convert each entry into an alias. Sibling collisions need renames, not workarounds.
Status
The amended require RFC shows Status: Implemented. You use it where your toolchain supports it. You still confirm it in your build per how to read status.
Init dot luau
A folder with this file acts as a module. You use it to organize Rojo projects.
Original problem
A package wanted both children and its own exports. Filesystem mapping treated package init as an ordinary nested module. Relative requires inside it resolved one level too deep. Rojo projects and plain Luau projects disagreed.
Change and motivation
Module paths now separate modules from plain directories. A folder with init dot luau is a module. Requires inside it resolve against the folder it represents. A reserved self alias addresses its own children. The init dot luau RFC aligns Rojo with the language.
Implemented semantics
You place init dot luau inside the package folder. The folder path names the module. Inside the init file, dot slash requires resolve to siblings of the folder, while at self requires address the package children. Rojo builds keep working because the rule matches its layout.
Before and after
Before:
luau
--!strict
local prices = require("@shop/prices")
print(prices ~= nil)txt
trueTop-level code imports the package by alias. The package still needs a way to import itself.
After:
luau
--!strict
local prices = require("@self/prices")
print(prices ~= nil)txt
trueInside init dot luau, the self alias names the package children. The line works regardless of where the package folder sits.
Realistic example
You structure a shop package with a root plus two children.
luau
--!strict
local prices = require("@self/prices")
local format = require("@self/format")
print(prices ~= nil)
print(format ~= nil)Each child is addressed through self. Moving the whole folder never edits these lines.
Strict implications
Children keep their exported types. The root re-exports them with annotations. You avoid sibling reaches from init files, since reaching outside the folder signals a layering mistake.
Edge cases
Renaming a file to or from init dot luau changes resolution, so you review its requires on rename. A file and a same-named folder collide by rule, so you keep names distinct. Roblox-side telemetry preceded the rollout, so you test requires after updating Studio.
Status
The init dot luau RFC shows Status: Implemented. You use it where your toolchain supports it. You still confirm it in your build per how to read status.
Config dot luau
It holds project settings in Luau. You use it for modes, lints, and aliases.
Original problem
Settings lived in JSON-like rc files. Embedding contexts without JSON tooling carried extra burden. Authors wanted one language for code and configuration.
Change and motivation
Projects may use a config dot luau file that returns a settings table. It covers language mode, lint toggles, error flags, globals, and aliases. Extraction runs the file in an isolated VM with standard libraries only. The config RFC forbids mixing both config files in one folder.
Implemented semantics
You return a table with a luau section. Absent fields inherit defaults and parent folders. One folder holds either the rc file or the luau file, never both. The file is not a requireable module.
Before and after
Before:
luau
--!strict
local strict = true
print(strict)Real settings lived in JSON-like syntax elsewhere.
After:
luau
--!strict
local config = {
luau = {
languagemode = "strict",
typeerrors = true,
},
}
print(config.luau.languagemode)txt
strictThe real file returns the table instead of printing it. Strict mode and error flags travel together.
Realistic example
You set strict mode, keep lint errors visible, and map a shared alias.
luau
--!strict
local config = {
luau = {
languagemode = "strict",
linterrors = true,
typeerrors = true,
aliases = {
shop = "./packages/shop",
},
},
}
print(config.luau.aliases.shop)txt
./packages/shopMode and flags guard quality. The alias shortens imports. Returning this table from the real file activates it.
Strict implications
The documented config type guides autocomplete. A mistyped field fails early. You keep one config style per folder so tools never guess.
Edge cases
Config code cannot require other scripts. Infinite loops hit extraction timeouts, defaulting around two seconds at runtime. Arbitrary logic complicates automated edits, so you keep config files declarative.
Status
The config RFC shows Status: Implemented. You use it where your toolchain supports it. You still confirm it in your build per how to read status.
Error suppression
It explains any without magic. You use the model to keep boundaries honest.
Original problem
Any behaved as both top and bottom, which switched checking off in both directions. Cascading errors needed ad hoc quieting. Authors could not tell principled suppression from a hole.
Change and motivation
Any stays a top type while failures flowing out of it suppress messages. Unknown stays the top for safe types. An internal error type flows beneath. The error suppression RFC makes subtyping transitive and documents the split.
Implemented semantics
You read any as top with silent exits. You read unknown as top for safe types with enforced narrowing. Property chains through a failed lookup suppress repeats. Intersections with any can unify more precisely than before, so some functions infer tighter types.
Before and after
Before:
luau
--!strict
local function coins(raw: any): number
return raw
end
print(coins(25))txt
25Any lets the return through unchecked. Wrong shapes pass silently.
After:
luau
--!strict
local function coins(raw: unknown): number
if typeof(raw) == "number" then
return raw
end
return 0
end
print(coins(25))txt
25Unknown forces the guard. Numbers pass. Everything else becomes 0. The output matches.
Realistic example
You contain a broken chain without drowning the log.
luau
--!strict
local function zoneOf(payload: unknown): string
if typeof(payload) == "table" then
local zone = (payload :: { zone: string }).zone
return zone
end
return "unknown"
end
print(zoneOf({ zone = "arena" }))
print(zoneOf(42))txt
arena
unknownTables cast to the expected shape after narrowing. Anything else takes the fallback. One error per real problem replaces a cascade.
Strict implications
You choose unknown plus guards for untrusted input. You reserve any for untyped package edges and name the package. Suppression never excuses a missing guard in your own types.
Edge cases
Tighter inference can change inferred types after the model lands, usually for the better. A free variable meeting any can unify where it once stayed open. You recheck boundary annotations when upgrading solvers.
Status
The error suppression RFC shows Status: Implemented. You use its behavior today. You still confirm solver behavior in Studio per how to read status.
Common mistakes
Wrong: you hand-roll blends in every file.
luau
--!strict
print(math.lerp(10, 20, 0.3))The library call is correct. The mistake is the scattered helper it replaces. Fix it by deleting local blends.
Wrong: you map with a hand formula and clamp by accident.
luau
--!strict
local width = math.map(120, 0, 100, 0, 200)
print(width)The call is correct and extrapolates to 240. The mistake is expecting a clamp. Fix it by wrapping with math dot clamp when bounds matter.
Wrong: you trust client numbers without predicates.
luau
--!strict
local function accept(raw: unknown): boolean
if typeof(raw) == "number" then
return math.isfinite(raw)
end
return false
end
print(accept(10))The guard is correct. The mistake is skipping it. Fix it by routing every networked number through isfinite.
Wrong: you write bare requires from old tutorials.
luau
--!strict
local shop = require("@shop")
print(shop ~= nil)The prefixed form is correct. The mistake is the bare shop slash prices shape. Fix it by adding the prefix.
Wrong: you keep both config files in one folder.
luau
--!strict
local config = { luau = { languagemode = "strict" } }
print(config.luau.languagemode)The shape is correct. The mistake is pairing it with a sibling rc file. Fix it by keeping one config per folder.
Exercises
- Ease a value with math lerp at three alphas.
- Map a score range onto a bar range.
- Reject NaN and infinities from a list.
- Blend two vectors and land exactly.
- Pack two bit fields and read them back.
- Convert a bare require into prefixed form.
- Sketch a config table with mode plus one alias.
Solution 1
Run this pure snippet in the Command Bar.
luau
--!strict
print(math.lerp(0, 50, 0))
print(math.lerp(0, 50, 0.5))
print(math.lerp(0, 50, 1))txt
0
25
50You sample start, middle, and end. The end lands exactly. Arrival checks can trust it.
Solution 2
Run this pure snippet in the Command Bar.
luau
--!strict
print(math.map(0, 0, 10, 0, 100))
print(math.map(5, 0, 10, 0, 100))
print(math.map(10, 0, 10, 0, 100))txt
0
50
100You map the ends and the middle. Each output scales linearly. No helper is involved.
Solution 3
Run this pure snippet in the Command Bar.
luau
--!strict
local inputs = { 4, 0 / 0, math.huge, 9 }
local kept = 0
for _, value in inputs do
if math.isfinite(value) then
kept += 1
end
end
print(kept)txt
2You keep only finite entries. NaN and infinity drop.
Solution 4
Run this pure snippet in the Command Bar.
luau
--!strict
local low = vector.create(0, 0, 0)
local high = vector.create(0, 10, 0)
print(vector.lerp(low, high, 0.5).Y)
print(vector.lerp(low, high, 1).Y)txt
5
10You blend on Y. The midpoint halves. The endpoint lands exactly.
Solution 5
Run this pure snippet in the Command Bar.
luau
--!strict
local flags = buffer.create(1)
buffer.writebits(flags, 0, 4, 9)
buffer.writebits(flags, 4, 4, 3)
print(buffer.readbits(flags, 0, 4))
print(buffer.readbits(flags, 4, 4))txt
9
3You split one byte into two nibbles. Each write owns its bits. Each read mirrors its write.
Solution 6
Use this shape in project files where aliases are configured. It does not run in a blank place.
luau
--!strict
local shop = require("@shop")
local nearby = require("./nearby")
print(shop ~= nil)
print(nearby ~= nil)Aliases and relatives both carry prefixes. Bare paths are gone. Copy this shape into new files.
Solution 7
Run this pure snippet in the Command Bar.
luau
--!strict
local config = {
luau = {
languagemode = "strict",
aliases = { shared = "./shared" },
},
}
print(config.luau.languagemode)
print(config.luau.aliases.shared)txt
strict
./sharedMode guards quality. The alias shortens imports. Return this shape from the real file.
Summary
- Lerp and map replace scattered helpers. Clamp stays separate.
- Float predicates guard networked numbers.
- Vector lerp mirrors math lerp. Buffer bits pack fields.
- Aliases shorten imports. Prefixes disambiguate. Init files organize packages.
- Config dot luau holds settings. Suppression keeps any honest.
- Next: Glossary.