Build custom Minecraft
server gameplay in Lua.

Events, commands, GUIs, regions, mob AI, async tasks and hot reload — all without restarting your server.

Fabric 1.21.x Active Development Open Source

What You Can Build

Server owners script the gameplay. Players see the result.

RPG Classes
Custom Bosses
Minigames
Quest Systems
Shops & Economies
Server Hubs
MMO Abilities
Custom AI Companions

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.

Real Examples

Real scripts you can try. Click tabs to switch.

spells.lua
--# nova syntax
    -- Right-click blaze rod: raycast → knockback → fire → damage
mc.on("player_use_item") \{ player, hand, _, id -> -- Kotlin-like custom syntax
    -- 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
}
shop.lua
--# nova syntax
-- 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" } })) \{ 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
}

register("shop") \{ ctx ->
    gui:open(ctx.player)
}
sidebar.lua
--# nova syntax
-- 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

-- Doesn't alter server scoreboards
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
id = mc.scheduleRepeating(40, 40) \{
    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
}

-- Clean up the repeating task when the player disconnects
mc.on("player_leave") \{ p ->
    if p.uuid == player.uuid then
        mc.cancelTask(id)
    end
}
guard_beast.lua
--# nova syntax
-- Custom guard behaviour: patrol home, hunt players, return
mc.registerBehaviour("guard_beast") \{ 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
}

-- 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
--# nova syntax
-- Simple: no arguments, no permission — anyone can use it
register("fly") \{ ctx ->
    ctx.player.isFlying = not ctx.player.isFlying
    ctx.player:sendMessage("Flight: " .. tostring(ctx.player.isFlying))
}

-- Complex: choice type (tab-completion), optional target, permission
register("kit <name:choice=warrior,archer,miner> [target:player]", \{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
}, "px.ignis.admin")  -- only those with the permission
regions.lua
--# nova syntax
-- 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") \{ p -> p:sendMessage("Welcome to the lobby!") }
lobby:on("player_leave") \{ p -> p:sendMessage("See you soon.") }

-- Gate PvP zone by permission; bounce unauthorised to lobby
arena:on("player_enter") \{ 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
}

-- Throttle: entity_move fires at most every 5 ticks (0.25s)
boss:on("entity_move", \{ 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
}, { throttle = 5 })

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

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

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

Why Script Instead of Writing a Mod?

Server owners iterate faster, players see the result live.

Reload Scripts Instantly

Edit a file, run /pxignis reload. Changes are live in milliseconds — no server restart, no reconnecting players.

🚫

No Recompilation

Skip the Java toolchain. No Gradle, no Loom, no 5-minute build cycles. Just a text editor and a save.

🛡️

Safer Iteration

Lua errors are isolated per-script. One bad handler doesn't take down the whole server — and rollback is just git checkout.

🤝

Server Owners Customize Gameplay

Hand the scripts folder to a community manager. They can tune shops, classes, and events without ever touching Java.

📦

No IDE Required

VS Code, Notepad++, vim — anything that saves .lua works. The runtime reads files directly from disk.

🔌

Full Fabric Power

Lua wraps the entire Fabric API surface. You get events, networking, world data — without writing a single Java class.

See It In-Game

Demos and GIFs coming soon — drop a file at public/showcase/*.gif and it appears here.

Open shop GUI · Buy item · Coins decrease Demo · GIF
Chest GUI shop, per-player coins, click interception
Edit file · Run /pxignis reload · Feature updates instantly Demo · GIF
Hot reload workflow with zero downtime
Region enter · Custom welcome · Scoreboard sidebar Demo · GIF
Region events and live per-player sidebar