Lua better practices

It pains me to see luanti/minetest 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
    minetest.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 see 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")