Skip to content

Lua safety and limits

Enabled mods can change your world and inventories. They must not get access to your computer (files, programs, or system libraries).

Allowed

Lua modules

require("helpers") first looks for helpers.lua at your pack root, beside modinfo.json, then beside the importing script. Both locations must stay inside your pack. Dotted names select subdirectories: require("Tools.helpers") looks for Tools/helpers.lua in the same order. Existing namespace folders and pack-relative slash paths remain valid; helper names do not need a mod-specific prefix. A missing helper produces an error instead of searching another mod's files.

Each pack has its own cache of successfully imported helper files. Requiring the same file again within that pack returns the same value. Two packs can each have a helpers.lua without sharing that helper's returned table. This private pack cache is separate from Lua's package.loaded. Helpers that import other helpers keep the same pack as their owner and use their own directory as the fallback. Calls to require in later gameplay callbacks also retain the script's pack and directory.

loadfile("Tools/helpers.lua") uses the same search order but treats its argument as a literal filename, preserving .lua and dots in filenames. It returns a fresh compiled chunk each time, rather than a cached helper value. The chunk keeps its owning pack and file directory when you run it.

Return a table from each helper module and keep imports local:

local helpers = require("helpers")

Scripts in your pack share that cached helper table. Use the entity script's own table for state that belongs to one item or creature. Restart or fully reload game content to recreate the Lua state after editing a loaded helper.

The game's Content/Lua/... modules and available standard Lua libraries stay shared, along with the Lua interpreter, globals, and engine helpers such as World. Module ownership does not isolate global variables. Use local variables to avoid changing another script's globals. There is no supported API for importing another installed mod's private modules.

Entity identity and targeting

Entity views, players, creatures, and flora share an Id for the same runtime object. Compare these strings or use them as Lua table keys. Different wrappers around one entity need not compare equal. IDs are local to the running game: do not save them or use them as multiplayer identifiers. TypeId identifies the content definition instead, and HasTag checks an exact authored tag.

Check IsValid when retaining a value across callbacks. Detached, removed, replaced, and previous-world objects are invalid. A retained value stays non-nil after removal. IsDead reports the engine death flag; a living entity can still be present while Dying. Player health and maximum health are read-only.

Inside an equipped-item callback, this selects a living mob while excluding the wearer. The mobs category includes human NPCs, so add a content or tag filter when your mechanic needs a narrower target:

if self.player ~= nil and self.player.IsValid then
    local target = World:findClosestTarget(
        self.player.CurGlobalCenter, 300, true, self.player, "mobs")
    if target ~= nil and target.IsValid then
        self.lastTargetId = target.Id
        self.lastTargetHealth = target.Health
    end
end

The optional trailing arguments are targetKind, typeId, and tag. Kinds are all (default), players, and mobs; players means actual players. Type IDs and tags are case-sensitive; nil leaves a filter unset. Both closest and random searches apply every supplied filter and skip dead/dying targets. For a pack creature, self.creature:FindTarget(300, "mobs") uses filtered targeting with line of sight and excludes the searching creature itself. It can find the same or a different species. Supply typeId, tag, and ignoreEntity after the kind to narrow the result further. The original one-argument form retains the engine's legacy player-priority targeting and same-species exclusion.

Applying and cancelling a status effect

Use the exact registered StatusEffectTypeString for a custom effect (pack.identifier), or a built-in enum name such as JUMP_HEIGHT. Creating a status handle does not attach it:

if self.player ~= nil then
    local effect = World:createStatusEffect("JUMP_HEIGHT", 0.1, 8000, self.player)
    if effect ~= nil then
        self.player:AddStatusEffect(effect)
        self.jumpEffect = effect
    end
end

On unequip, cancel the retained application:

if self.jumpEffect ~= nil then
    self.jumpEffect:Cancel()
    self.jumpEffect = nil
end

Read TypeId, Intensity, Duration (remaining milliseconds), IsActive, and IsExpired from the handle. A fresh unattached effect is inactive and unexpired. IsActive means attached and unexpired; a stronger non-stacking effect can suppress its application while it remains active. An effect removed by another operation can be inactive without being expired. Attach to its original target. When applications merge, the handle tracks the retained effect; cancelling it can remove the shared merged application. An old handle cannot cancel a later replacement. Current-frame modifiers settle on the next status update. Custom effect callbacks can use target:Heal(amount) to heal a Player or EntityView, clamped to maximum health without reviving a dead, dying, or zero-health target. These operations affect the local simulation.

Structure interaction context

Override Structure:use(player) to inspect the interacting Player. It runs in single-player or on the multiplayer host. Client requests pass the requester's host-side Player proxy, selected from the transport sender. Clients do not run use when receiving the resulting native behavior. Normal player interaction supplies a proxy; actorless engine calls pass nil. Older use() overrides can ignore the extra argument.

Multiplayer execution context

World:isHost() is true in offline play and on the session host, false on a remote client, and nil without a world host. player.IsLocalPlayer checks local control; self.item.IsLocallyEquipped checks the local player's displayed held item, including an active displacing quick slot. A displaced hotbar item is inactive. Use these checks in callbacks that can also run for remote representations.

-- Initiate from an equipped item callback on its controlling instance.
if self.item.IsLocallyEquipped then
    self:performActionNetworked(1)
end

The HeldItem helper invokes performAction(index) locally once, then sends it. The host invokes it for the sender's current matching item and relays it to the other clients, excluding the sender. Host-originated actions reach both clients. Stale item IDs are ignored after a weapon switch. Calling the lower-level self.item:PerformActionNetworked(index) only sends; it does not invoke locally. Remote, holstered, and world items cannot originate this action path.

The callback runs on every participating instance. Gate shared world changes with World:isHost() and owner-only input with IsLocallyEquipped. Replicating the callback does not automatically replicate arbitrary Lua fields, custom projectile changes, or every world operation it calls.

Customizing a fired projectile

Item:FireProjectile returns a Projectile handle, or nil if firing is unavailable. In an equipped-item attack callback:

if self.player ~= nil then
    local position = self.player.CurGlobalCenter
    local shot = self.item:FireProjectile(
        "ROUND_9MM", 8, position.X, position.Y, 1, 0)
    if shot ~= nil then
        local charge = math.max(0, math.min(1, self.charge or 0))
        shot.MinDamage = 10 + 30 * charge
        shot.MaxDamage = shot.MinDamage
        shot.HarmsPlayers = false
        shot.HarmsMobs = true
        shot.HarmsOwner = false
    end
end

The example fires right; supply your weapon's direction components for aiming. Initialization has finished when the handle returns, and the projectile has not yet taken its first simulation update. Projectile callbacks can change these properties again, for example to reduce damage after a ricochet. Damage must be finite and nonnegative; negative and non-finite values are ignored. Setting the minimum above the maximum raises the maximum; setting the maximum below the minimum lowers the minimum.

Collision flags govern automatic hits, including sticky attachment. Hitting the owner requires its category flag and HarmsOwner; other native exclusions still apply. HarmsItems covers world items that accept projectile collisions. These flags do not control terrain collisions or explicit DealDamage calls. Firing and property changes affect the local simulation; this API does not automatically replicate a custom shot or its later changes to other peers.

Blocked

Scripts cannot:

  • Read or write files, start programs, or use debugging tools
  • Reach into undocumented game internals
  • Load extra Lua files from arbitrary folders
  • Load extra system libraries
  • Reach around the helpers into hidden game objects
  • Use CLR reflection (GetType) or static members through an object helper
  • Read or change the native metatable of an object helper or error object

Blocked host libraries are removed from the module cache too, so require cannot restore io, os, debug, or CLR interop. Object helpers expose only their documented instance members. Unknown objects and errors remain opaque; they cannot provide a route into hidden game objects.

Combat callbacks use the documented views and proxies. For example, meleeStruckDeposit receives an item helper for the deposit, with no access to its map. Query collections are read-only and use zero-based indexing. Lists expose Count; arrays expose Length. A return type ending in |nil means you must check for nil before using its members. An OwnerToken only carries an owner identity back to the API; it does not expose hidden entity members.

Vector2 positions and GameTime callback timing values remain available for normal gameplay scripts. Vectors returned from properties are value copies; changing a coordinate on that copy does not move the original entity. Where the property is writable, assign the whole vector back to change it. Consult each member's documented units: world positions use pixels, tile coordinates use tile indexes, and durations may use seconds or milliseconds.

Mod Prep Validate flags many of these as errors before you publish. The game also blocks them while the mod is running. There is no “trusted mod” override.

Your saves are still exposed

The sandbox stops a script from using your computer. It does not stop a script from spawning items, dealing damage, or destroying terrain — those commands exist so gameplay mods can work. Only enable mods you trust with your saves.