PxIgnis: Lua Scripting Engine
for Minecraft Servers

Server-side framework (Fabric) for writing custom game logic, events, interfaces, and mob AI in Lua with async support and hot reloading (/pxignis reload) — no server restarts. Built on PxLuaNova, a custom Lua 5.2 fork.

What You Get

Everything you need to script a server, built in.

🔥

Hot Reload

Edit scripts and run /pxignis reload — no server restarts, no lost connections. Persistent data (mc.data, player.data) is preserved across reloads.

Async Runtime

Coroutine-powered mc.sleep(ticks) and mc.fetch(url) with automatic JSON parsing. Yields the Lua coroutine, resumes on the server thread.

🧠

Mob AI Framework

MobEntity behaviour scripting. Register custom goals with native pathfinding, line-of-sight (canSee), and vanilla AI override. Built-in behaviours: guard, pet, orbiter, wander.

🖼️

Dynamic UI

Per-player scoreboard sidebars, chest GUI menus with click interception (container:onClick), and virtual inventories. Packet-based — never touches the global scoreboard, other players never see it.

🗺️

Spatial Regions

AABB zones with event hooks: enter, leave, move, death, tick. Per-region players/entities queries, throttlable callbacks, chunk-indexed lookups, mc.getRegion for ID-based retrieval.

Real Examples

Real scripts you can try. Click tabs to switch.

spells.lua
-- Right-click blaze rod: raycast → knockback → fire → damage
mc.on("player_use_item", function(player, hand, _, id)
    -- Early return if not the item we care about
    if id ~= "minecraft:blaze_rod" then return end

    -- Raycast from the player's eye position
    local hit = player:raycast(15)
    if not hit then return end

    -- hit.type is "entity" (hit a mob) or "block" (hit terrain)
    if hit.type == "entity" then
        local target = hit.entity

        -- Vector subtraction + normalisation for directional knockback
        local d = target.pos - player.pos
        local dist = math.sqrt(d.x^2 + d.y^2 + d.z^2)
        if dist > 0 then
            target.pos = target.pos + d / dist * 3
        end

        -- Entity methods: fire ticks, damage with source, status effect
        target:setOnFireFor(80)
        target:damage(6, player)
        target:addEffect("minecraft:slowness", 60, 1)
    end
end)
shop.lua
-- Chest GUI shop: require library, build once, open for any player
local chestgui = require "chestgui"

-- Create a 3-row (27-slot) GUI
local gui = chestgui.create(3, "Shop")
local glass = mc.createItem("black_stained_glass_pane", { name = " " })

-- Decorate with glass border — no callback = stuck in place
for col = 1, 9 do
    gui:decorate(1, col, glass)
    gui:decorate(3, col, glass)
end

-- gui:set(row, col, item, callback) — clickable item
-- Returning false cancels the click (prevents item theft)
gui:set(2, 3, mc.createItem("diamond", { name = "Diamond", lore = { "10 coins" } }),
    function(player)
        -- player.data is per-player persistent storage (JSON, survives reloads)
        local coins = player.data.coins or 0
        if coins < 10 then
            player:sendMessage("Not enough coins!")
            return false
        end

        player.data.coins = coins - 10
        player:give(mc.createItem("diamond"))
        player:sendMessage("Bought a diamond! Coins: " .. (coins - 10))
        return false
    end)

register("shop", function(ctx)
    gui:open(ctx.player)
end)
sidebar.lua
-- Live per-player sidebar refreshing every 2s, self-cleaning on disconnect
mc.on("player_join", function(player)
    -- Per-player data persists as JSON across reloads
    local visits = player.data.visits or 0
    visits = visits + 1
    player.data.visits = visits

    if visits == 1 then
        player:give(mc.createItem("compass", 1))
    end

    -- Sidebar is packet-based — never touches the global scoreboard
    player.sidebar = {
        title = "Stats",
        lines = {
            "HP " .. math.floor(player.health),
            "Visits " .. visits,
            "Online " .. mc.onlineCount,
        }
    }

    -- mc.scheduleRepeating(delay, interval, callback) — 20 ticks = 1 second
    local id = mc.scheduleRepeating(40, 40, function()
        local sb = player.sidebar
        if sb then
            sb.lines = {
                "HP " .. math.floor(player.health),
                "Visits " .. visits,
                "Online " .. mc.onlineCount,
            }
        else
            mc.cancelTask(id)  -- sidebar destroyed, stop updating
        end
    end)

    -- Clean up the repeating task when the player disconnects
    mc.on("player_leave", function(p)
        if p.uuid == player.uuid then
            mc.cancelTask(id)
        end
    end)
end)
guard_beast.lua
-- Custom guard behaviour: patrol home, hunt players, return
mc.registerBehaviour("guard_beast", function(self, state)
    -- self: the mob wrapper; state: persists across server ticks per mob
    if not state.home then
        state.home = self.pos  -- remember spawn point on first tick
    end

    -- Throttle: only run every 10 ticks (0.5s) to avoid spam
    state.timer = (state.timer or 0) + 1
    if state.timer % 10 ~= 0 then return end

    -- Search for players within 10 blocks
    local players = self.world:getEntities(self.pos, 10, "minecraft:player")
    if #players > 0 then
        -- Navigate to nearest player, attack when in melee range
        self:navigateTo(players[1])
        if self:distanceTo(players[1]) < 2.5 then
            self:tryAttack(players[1])
        end
    else
        -- No threat — return to home position if drifted away
        local d = self:distanceTo(state.home)
        if d > 2 then
            self:navigateTo(state.home.x, state.home.y, state.home.z)
        end
    end
end)

-- Spawn a zombie and assign the behaviour
local guard = world:spawn("minecraft:zombie", player.pos)
guard.customName = "Guard"
guard.health = 40
guard:setAI("guard_beast")
commands.lua
-- Simple: no arguments, no permission — anyone can use it
register("fly", function(ctx)
    ctx.player.isFlying = not ctx.player.isFlying
    ctx.player:sendMessage("Flight: " .. tostring(ctx.player.isFlying))
end)

-- Complex: choice type (tab-completion), optional target, permission
register("kit <name:choice=warrior,archer,miner> [target:player]", function(ctx, name, target)
    -- target is nil when omitted; fall back to the command sender
    local player = target or ctx.player
    player:clear()

    if name == "warrior" then
        -- mc.createItem(id, {components}) — name, unbreakable, lore, count...
        player:give(mc.createItem("iron_sword", { name = "Warrior Blade", unbreakable = true }))
        player:give(mc.createItem("iron_chestplate"))
    elseif name == "archer" then
        player:give(mc.createItem("bow", { name = "Longbow", unbreakable = true }))
        player:give(mc.createItem("arrow", 64))
    end
end, "px.ignis.admin")  -- only operators or those with the permission node
regions.lua
-- Three zones: lobby (auto-welcome), arena (PvP gate), boss (warning)
local lobby = world:createRegion(vec(0, 64, 0),   vec(100, 128, 100))
local arena = world:createRegion(vec(200, 64, 200), vec(300, 128, 300))
local boss  = world:createRegion(vec(400, 64, 400), vec(500, 96, 500))

-- enter/leave fire once per transition (not every tick)
lobby:on("player_enter", function(p) p:sendMessage("Welcome to the lobby!") end)
lobby:on("player_leave", function(p) p:sendMessage("See you soon.") end)

-- Gate PvP zone by permission; bounce unauthorised to lobby
arena:on("player_enter", function(p)
    if not p:hasPermission("px.ignis.pvp") then
        local a = lobby:getBounds().A
        p:teleport(a.x, a.y, a.z)
        p:sendMessage("PvP access denied.")
    end
end)

-- Throttle: entity_move fires at most every 5 ticks (0.25s)
boss:on("entity_move", function(e, from, to)
    local d = to - from
    if d:length() > 0.5 then  -- moved more than half a block
        e:sendMessage("Boss room is small — slow down!")
    end
end, { throttle = 5 })

-- tick auto-subscribes: no enableTick() needed
lobby:on("tick", function()
    for _, p in ipairs(lobby.players) do
        p:sendActionBar("Online: " .. mc.onlineCount)
    end
end)

-- Find regions at a position: returns {} when empty
mc.on("player_join", function(p)
    local zones = world:getRegionsAt(p.pos)
    p:sendMessage("You are in " .. #zones .. " zone(s).")
end)

-- Resize at runtime — setBounds normalises corners, re-evaluates membership
arena:setBounds(vec(200, 64, 200), vec(350, 128, 300))

Quick Start

1

Place PxIgnis.jar in your Fabric server's mods/ folder.

2

Write scripts in config/pxignis/scripts/. The runtime loads all *.lua files alphabetically.

3

Run /pxignis reload in-game — no restart. Global data (mc.data) and per-player data auto-persist to JSON.