Skip to content

Files and the web

Lesson 15.3

Config files are for settings. For everything else your mod wants to keep, like a high-score list, a cache or a log of what happened, use your own files. And with the player’s permission, your mod can ask a website for data. All of it stays inside the sandbox: Permissions explained the rules.

Your folder

Your mod lives in mods/<your mod>/. You may read every file in it, and write only in its data/ folder:

Path Read Write
scripts/main.lua, assets/..., clight.mod.json yes no
data/... yes yes
anything outside your folder (../, C:/...) no no

Paths are written with / and start inside your folder: "data/scores.json" means mods/<your mod>/data/scores.json. During clight run, data/ is the project’s build/run/<your mod>/data/, so your sources stay clean.

Reading and writing text

local ok, err = cl.fs.write("data/hello.txt", "Hello from my mod!")
print(ok, err)                       --> true  nil

print(cl.fs.read("data/hello.txt"))  --> Hello from my mod!

cl.fs.append("data/log.txt", "a player joined\n")
print(cl.fs.exists("data/log.txt"))  --> true

Like Lua’s own io functions, the file functions don’t raise errors. When something goes wrong they return nil and a message:

local text, err = cl.fs.read("data/missing.txt")
print(text, err)  --> nil  not found: data/missing.txt

print(cl.fs.write("scripts/main.lua", "oops"))
--> nil  fs_write: writes go to data/ ("data/scripts/main.lua")

print(cl.fs.read("../other_mod/secret.txt"))
--> nil  path not allowed: ../other_mod/secret.txt

Writes are safe: the game writes a new file next to the old one and then swaps them, so a crash never leaves half a file.

Folders

cl.fs.mkdir("data/players")
for _, name in ipairs(cl.fs.list("data")) do print(name) end
--> hello.txt
--> log.txt
--> players/
local info = cl.fs.stat("data/log.txt")
print(info.kind, info.size)          --> file  16
cl.fs.rename("data/log.txt", "data/old_log.txt")
cl.fs.remove("data/players", true)   -- true: with everything inside

data/ can hold 256 MiB (cl.fs.used() and cl.fs.quota() tell you how much is in use), and files that could run as programs (.exe, .bat, .js, …) can’t be written at all.

Tables as JSON

Tables are easiest to keep as JSON:

local scores = cl.fs.read_json("data/scores.json") or {}

cl.on("player.join", function(e)
  local name = e.player.name
  scores[name] = (scores[name] or 0) + 1
  cl.fs.write_json("data/scores.json", scores)
  e.player:send_message("Visits: " .. scores[name])
end)

cl.json.encode(value) and cl.json.decode(text) do the same with strings. A table with the keys 1, 2, 3, … becomes a JSON list, any other table an object.

Asking a website

A mod may only talk to the websites it lists in its manifest, and only after the player allowed it:

"permissions": ["net.http:api.ruby-tools.example"]

Then:

cl.http.get("https://api.ruby-tools.example/v1/prices", function(res)
  if not res.ok then
    print("no prices:", res.error or res.status)
    return
  end
  local prices = res.json()
  print("a ruby costs", prices.ruby)
end)

The request runs in the background, so the game never waits for the website. Your function is called at the next tick with the answer: res.status (200 means OK, 0 means no answer came), res.ok, res.body (the text), res.json() and res.error (why it failed, like "timeout" or "not allowed: example.com").

To send data, use post (a table is sent as JSON):

cl.http.post("https://api.ruby-tools.example/v1/scores", { player = "Alex", score = 12 }, function(res)
  print("saved:", res.status)
end)

And to download a file straight into data/:

cl.http.request{
  url = "https://api.ruby-tools.example/v1/pack.zip",
  save_to = "data/pack.zip",
  timeout = 30,
  on_done = function(res) print("downloaded:", res.ok) end,
}

Only https:// works (plain http:// only to your own computer, localhost), a request takes at most a minute, and an answer at most 64 MiB. A download is a plain file: nothing the game downloads is ever run.

Asking for permission later

Put a website your mod can live without into optional_permissions, and ask (on the client) when the player first uses the feature:

cl.on("lifecycle.client_setup", function()
  local key = cl.keybind.register{ id = "my_mod:prices", default_key = "key.keyboard.p", category = "key.categories.my_mod" }
  cl.on("tick.client_post", function()
    while key:consume_click() do
      if cl.permission("net.http:api.ruby-tools.example") then
        show_prices()
      else
        cl.request_permission("net.http:api.ruby-tools.example")  -- opens the mod's Permissions screen
      end
    end
  end)
end)

cl.on("mod.permission_changed", function(e)
  if e.granted then show_prices() end
end)

cl.request_permission returns true when it is already allowed, nil while the player decides, and false when the player said no before (they can change it in Mods, then your mod, then Permissions).

Try it

  1. Count how often each player joined and keep the counts in data/visits.json. Print the top three in a /visits command.
  2. Try to write scripts/hacked.lua and data/tool.exe. What does each call return?
Show a solution
local visits = cl.fs.read_json("data/visits.json") or {}

cl.on("player.join", function(e)
  local name = e.player.name
  visits[name] = (visits[name] or 0) + 1
  cl.fs.write_json("data/visits.json", visits)
end)

cl.on("server.command_register", function()
  cl.cmd.register(cl.cmd.literal("visits"):executes(function(ctx)
    local list = {}
    for name, n in pairs(visits) do list[#list + 1] = { name = name, n = n } end
    table.sort(list, function(a, b) return a.n > b.n end)
    for i = 1, math.min(3, #list) do ctx:success(list[i].name .. ": " .. list[i].n) end
    return 1
  end))
end)

print(cl.fs.write("scripts/hacked.lua", "x"))  --> nil  fs_write: writes go to data/ ...
print(cl.fs.write("data/tool.exe", "x"))       --> nil  fs_write: path not allowed: data/tool.exe

Recap: cl.fs reads your folder and writes data/, cl.json turns tables into text and back, and cl.http talks to the websites the player allowed, with the answer at the next tick.