WENOPAL

Lua for Project Zomboid mods: a practical guide

Client, server and shared; events; how to make things work in multiplayer; and the pitfalls of Project Zomboid's Lua.

Level intermediate · Updated

The .txt scripts define what exists. Lua defines what happens: loot, a new right-click menu, an action with an animation, a light that follows the player, a water cannon on a truck.

Project Zomboid's Lua is Lua 5.1

The game uses Kahlua, a version of Lua 5.1 that runs inside Java. If you learned Lua somewhere else, watch out for these differences:

  • There's no goto and no ::label:: labels. To skip a step inside a for, use an if.
  • There's no // integer division. Use math.floor(a / b).
  • There's no &, |, ~, << or >>.
  • Reserved words (end, function, repeat...) can't be used as bare keys: write t["end"], not t.end.
  • When you call a method that doesn't exist, the error is a cryptic tried to call nil, and it shows up when the save loads, not when the mod loads.

Client, server and shared

text
media/lua/
├── client/   <- only on the player's PC: menus, UI, visual effects
├── server/   <- only on the server: loot, rules, whatever is "in charge"
└── shared/   <- on both: data, utilities and timed actions

In single player your PC is both client and server at the same time, so everything runs. The difference matters in multiplayer, and it's best to think this way from the start: then the mod works in both modes without changes.

One namespace per mod

All mods share the same global Lua. To avoid stepping on anyone else's toes, put everything of yours in a single table:

lua
MiMod = MiMod or {}

function MiMod.saludar(player)
    player:Say("Weno pa'l leseo")
end

("Weno pa'l leseo" is Chilean slang, roughly "always up for some fun".)

Never leave loose global variables like contador = 0 lying around: sooner or later they'll clash with another mod's.

Events: when your code runs

Your code "hooks into" game events:

lua
Events.OnGameStart.Add(function()
    print("[MiMod] la partida empezó")
end)

The full B42 list is in the Lua events reference. Some we use in published mods:

EventWhat we use it for
OnPostDistributionMergeAdding loot (see Loot)
OnTickContinuous stuff, like a light that follows the player. Throttle it: it runs every frame.
OnClientCommandThe server receives requests from players
OnSaveCleaning up temporary things before saving
OnPlayerDeathCleaning up whatever the player had
OnWeaponSwingReacting to a weapon swing (the gravity gun)

To keep OnTick from getting heavy, only do the work every so often:

lua
local ultimo = 0
Events.OnTick.Add(function()
    local ahora = getTimestampMs()
    if ahora - ultimo < 1000 then return end   -- once per second
    ultimo = ahora
    -- heavy work goes here
end)

Multiplayer: the client asks, the server decides

If a player does something that changes the world, the client doesn't apply it directly: it asks the server to do it. The server checks that it makes sense and applies it for everyone. This is how the water cannon in Wanaco works:

lua
-- client/: the player presses the button
sendClientCommand(player, "Wanaco", "toggleCannon", { on = true })
lua
-- server/: the server checks and applies
Events.OnClientCommand.Add(function(module, command, player, args)
    if module ~= "Wanaco" or command ~= "toggleCannon" then return end
    local vehicle = player:getVehicle()
    if not vehicle then return end               -- never trust the client
    -- ... turn on the cannon and notify everyone
end)
  • The first string ("Wanaco") is your mod's channel.
  • The server validates everything again. A modified client could send anything.
  • This also works in single player, so you don't need two versions of the code.

Timed actions

Actions with a progress bar and an animation (eating, building, dancing) are timed actions. You create them by deriving from ISBaseTimedAction:

lua
require "TimedActions/ISBaseTimedAction"

MiMod.BailarAction = ISBaseTimedAction:derive("MiModBailarAction")

function MiMod.BailarAction:isValid()
    return self.character:getVehicle() == nil
end

function MiMod.BailarAction:start()
    self:setActionAnim("MiBaile")
end

API pitfalls we ran into in B42

  • getCell():getVehicles() is now a Set, with no :get(i). To iterate over objects, use getCell():getObjectListForLua().
  • Tile properties are checked with :has(IsoFlagType.water), in lowercase. :Is(...) doesn't exist and gives you tried to call nil.
  • HaloTextHelper.addText(player, texto, color) doesn't exist. Use addGoodText (green), addBadText (red) or addText(player, texto).