Lua better practices

It pains me to see luanti mods that have some common issues:

  • Un-DRY (Don’t Repeat Yourself, DRY)
  • Never using a global table
  • Lots of tables
  • Unorganized (everything in init.lua)

DRY Top

A good example is when we make a mod that’s organized into multiple .lua files.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
-- Ugly Un-DRY code
dofile(core.get_modpath("my_mod") .. "/something1.lua")
dofile(core.get_modpath("my_mod") .. "/something2.lua")
dofile(core.get_modpath("my_mod") .. "/something3.lua")
dofile(core.get_modpath("my_mod") .. "/something4.lua")

-- Same code, but DRY
local runfile = function(file)
    local mod_path = core.get_modpath("my_mod")
    -- dofile(mod_path .. "/" .. file)
    -- There's also a Lua global called DIR_DELIM (Directory Delimeter, Unix/Linix "/", Windows "\")
    dofile(mod_path .. DIR_DELIM .. file .. ".lua") -- automatically add .lua (dofile only works with Lua)
end

runfile("something1")
runfile("something2")
runfile("something3")
runfile("something4")

By us making it a function runfile, we now can also add it to the mod’s global table, so when we run a file we can keep access to this function too.

Another good example is using Luanti/Minetest’s logging.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
my_mod = {} -- It's highly recommended to add a log function into the mod's global table

my_mod.log = function(msg)
    -- If the msg is a table, we'll have minetest serialize it
    if type(msg) == "table" then
        -- serialize and deserialize make the given into a lua table,
        -- it will return it as a string, so we can then safely log it out
        msg = core.serialize(msg)
        -- i.e. a position {x=1, y=2, z=3}
        -- "return {"x": 1, "y": 2, "z": 3}"
    end
    core.log("action", "[my_mod] " .. tostring(msg))
end

Modspace global table Top

A global table for the mod (typcally the mod’s name) helps publicly provide an API, while also permitting the mod to be assembled in an organized fashion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
my_mod = {
    version = "1.0-dev",
    game = "???",
}
-- I typically prefix underscore for a "private" API (note there is no local, as we want it accessable in the mod's other files)
--                                                   (this also means it is accessable from another mod)
_my_mod = {
    secret = "Ta da!"
}

-- We'll detect what game is installed (in this case we could detect upto 3 different games/gamemodes)
if core.registered_nodes["default:stone"] ~= nil then
    my_mod.game = "minetest_game"
elseif core.registered_nodes["mcl_core:stone"] ~= nil then
    my_mod.game = "voxelibre"
elseif core.registered_nodes["nc_terrain:stone"] ~= nil then
    my_mod.game = "node_core"
end

my_mod.log = function(msg)
    if type(msg) == "table" then
        msg = core.serialize(msg)
    end
    core.log("action", "[my_mod] " .. tostring(msg))
end
my_mod.runfile = function(file)
    local mod_path = core.get_modpath("my_mod")
    dofile(mod_path .. DIR_DELIM .. file .. ".lua")
end

my_mod.log("Version: " .. my_mod.version)
my_mod.log("Game:    " .. my_mod.game)
my_mod.log("         " .. _VERSION) -- Display's what version of Lua we have (5.1 for example)
my_mod.runfile("crafting")

Now in a individual file crafting.lua (for example)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 3 materials that will change based on the game (defaulting to minetest_game)
local iron = "default:steel_ingot"
local gold = "default:gold_ingot"
local diamond = "default:diamond"

-- VoxeLibre support (This would support Voxelibre, Mineclone2, Mineclone5, and Mineclonia)
if my_mod.game == "voxelibre" then
    iron = "mcl_core:iron_ingot"
    gold = "mcl_core:gold_ingot"
    diamond = "mcl_core:diamond"
end

-- (DRY) Now instead of multiple register_craft calls with the different ingredients
core.register_craft({
    type = "shapeless",
    output = diamond,
    recipe = {
        iron,
        gold,
        iron,
        gold
    }
})

Too many tables Top

An issue that has also come up.

1
2
3
4
5
6
7
local def = {
    description = "My Mod Item",
    inventory_image = "my_mod_item.png",
    stack_max = 1,
    groups = {not_in_creative_inventory=1},
}
core.register_craftitem("my_mod:item", def)

Unless you are going to be making multiple items with the same definition (or minor programmatic changes), don’t use a table.

1
2
3
4
5
6
core.register_craftitem("my_mod:item", {
    description = "My Mod Item",
    inventory_image = "my_mod_item.png",
    stack_max = 1,
    groups = {not_in_creative_inventory=1},
})

Unorganized (Everything in init.lua) Top

An issue that has come up a couple of times.

I’ve seen from 400 to even 1200+ lines in the init.lua file, with no other .lua files present.

This is what dofile is for, which is also why my first thing I showed was the runfile function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
my_mod = {
    version = "1.0-dev",
}
_my_mod = {
    game = "???",
    runfile = function(file)
        local mod_path = core.get_modpath("my_mod")
        dofile(mod_path .. DIR_DELIM .. file .. ".lua")
    end,
    log = function(msg)
        if type(msg) == "table" then
            msg = core.serialize(msg)
        end
        core.log("action", "[my_mod] " .. tostring(msg))
    end,
}
if core.registered_nodes["default:stone"] ~= nil then
    _my_mod.game = "minetest_game"
elseif core.registered_nodes["mcl_core:stone"] ~= nil then
    _my_mod.game = "mineclone"
elseif core.registered_nodes["nc_terrain:stone"] ~= nil then
    _my_mod.game = "node_core"
end

_my_mod.runfile("api")
_my_mod.runfile("crafting")
_my_mod.runfile("craftitems")
_my_mod.runfile("nodes")