The standard library
Lesson 8.4
Lua comes with a small set of built-in libraries. This lesson is a tour of what is there, and of what is different inside a Celestial mod.
The libraries at a glance
| Library | What it’s for | Covered in |
|---|---|---|
| basic functions | print, type, tostring, pairs, pcall… |
throughout |
string |
text | Strings, String patterns |
table |
lists | Lists, Working with tables |
math |
numbers | Numbers and maths |
utf8 |
Unicode text | below |
coroutine |
coroutines | Coroutines |
os |
time and dates (and, outside mods, the system) | below |
io |
files and the console | below |
debug |
inspecting running code | below |
package |
how require finds modules |
Modules |
Basic functions you haven’t met yet
select with a negative number counts from the end: select(-1, ...) is the last argument.
next(t, key) returns the key after key in t. pairs uses it internally. next(t) == nil means “the table is empty”.
rawequal, rawget, rawset, rawlen do the plain operation and skip metamethods.
load(text) turns a string of Lua code into a function. It returns nil and a message if the code has a syntax error:
local f = load("return 2 + 3")
print(f()) --> 5
local ok, err = load("return +")
print(ok) --> nil
You can give the loaded code its own environment, so it only sees the names you allow:
local env = { x = 5 }
local g = load("return x * 2", "calc", "t", env)
print(g()) --> 10
The "t" means text only. Never run code that a player typed without a limited environment like this. Usually you don’t need load at all.
collectgarbage("count") gives the memory Lua is using, in kilobytes. Lua frees memory by itself, so you rarely call the collector by hand.
utf8
Lua strings are bytes. The utf8 library works with characters:
print(utf8.len("Café")) --> 4
print(utf8.char(72, 228, 8364)) --> Hä€
print(utf8.codepoint("ä")) --> 228
utf8.codes loops over the characters, giving each one’s byte position and code:
for pos, code in utf8.codes("hé") do
print(pos, code)
end
1 104
2 233
utf8.len returns nil (and the position of the bad byte) when a string isn’t valid UTF-8. utf8.offset(s, n) gives the byte position of the nth character, which you can pass to string.sub to cut by characters:
local name = "Zoë the Great"
print(name:sub(1, utf8.offset(name, 4) - 1)) --> Zoë
os: time and dates
local now = os.time() -- seconds since 1 January 1970
print(type(now)) --> number
local t = os.time({ year = 2026, month = 10, day = 17, hour = 12 })
print(os.date("%Y-%m-%d %H:%M", t)) --> 2026-10-17 12:00
local d = os.date("*t", t) -- a table with the parts
print(d.year, d.month, d.day) --> 2026 10 17
print(os.difftime(t + 90, t)) --> 90.0
os.time()is the current time in seconds. With a table, it converts a date into seconds.os.date(format, time)formats a time:%Yyear,%mmonth,%dday,%H:%M:%Stime. The format"*t"returns a table instead. Without a time it uses now.os.clock()is the CPU time your program has used, in seconds, as a float. Measure how long something takes with two calls:
local start = os.clock()
local sum = 0
for i = 1, 1000000 do sum = sum + i end
print(string.format("took %.3f s", os.clock() - start))
Outside a mod, os also has os.getenv, os.remove, os.rename, os.execute and os.exit, which touch the computer itself.
io: files and the console
In a normal Lua install, io reads and writes files:
-- plain Lua on your computer; this doesn't work in a mod
local file = assert(io.open("scores.txt", "w"))
file:write("Alex 12\n")
file:close()
for line in io.lines("scores.txt") do
print(line)
end
io.write(...) prints without a line break or tabs, and io.read() reads a line typed in the terminal.
debug
debug.traceback() returns the current call stack as text, which is very useful in error handlers (see Error handling). The rest of debug lets code inspect and change local variables and hooks. It is meant for debuggers, not for normal programs.
Inside a Celestial mod
Mods run in a sandbox so they can’t harm the player’s computer. Some of the library is therefore missing or replaced:
| Plain Lua | In a Celestial mod |
|---|---|
print |
works, and writes to the game log |
string, table, math, utf8, coroutine |
all there (no string.dump) |
os.time, os.clock, os.date, os.difftime |
there |
os.execute, os.remove, os.getenv, os.exit |
not available |
io (files, console) |
not available. Use cl.fs: it reads your mod’s own folder and writes only in mods/<your mod>/data/ (Files and the web) |
| settings files | mod.config holds your mod’s config, and cl.config writes it |
require |
only finds files in your mod’s scripts/ folder |
load |
text only, no precompiled chunks; dofile and loadfile are gone |
debug |
only debug.traceback |
math.random |
works as usual |
| logging | prefer cl.log("text"), cl.log.warn(...) and cl.log.error(...), which add the level and your mod’s name |
Everything you have learned about the language works the same. Only the parts that reach outside the game are swapped for the cl API, which part 2 covers, starting with Setting up.
Try it
Write format_duration(seconds) that turns a number of seconds into text like 1h 02m 05s, leaving out the hours when there are none (3m 20s). Then print how many days are left from 17 October 2026 until 25 December 2026, using os.time and os.difftime.
Show a solution
local function format_duration(seconds)
local h = seconds // 3600
local m = seconds % 3600 // 60
local s = seconds % 60
if h > 0 then
return string.format("%dh %02dm %02ds", h, m, s)
end
return string.format("%dm %02ds", m, s)
end
print(format_duration(3725)) --> 1h 02m 05s
print(format_duration(200)) --> 3m 20s
local from = os.time({ year = 2026, month = 10, day = 17, hour = 12 })
local to = os.time({ year = 2026, month = 12, day = 25, hour = 12 })
print(os.difftime(to, from) // 86400) --> 69.0
Using noon (hour = 12) for both dates avoids surprises from daylight-saving changes, which can make a day 23 or 25 hours long.
Recap: the standard library covers text, tables, maths, UTF-8, coroutines and time; in a Celestial mod, files and the system are reached through the cl API instead of io and os.