Build custom Minecraft
server gameplay in Lua.
Events, commands, GUIs, regions, mob AI, async tasks and hot reload — all without restarting your server.
What You Can Build
Server owners script the gameplay. Players see the result.
Quick Start
Place PxIgnis.jar in your Fabric server's mods/ folder.
Write scripts in config/pxignis/scripts/. The runtime loads all *.lua files alphabetically.
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.
--# 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
}--# 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)
}--# 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
}--# 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")--# 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--# 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.