7. Kits and inventories
So far players fight with whatever they carry, and a diamond sword wins every round. Now everyone gets a kit of their choice, and their own items wait in storage until the round is over.
Putting items away
Section titled “Putting items away”A player’s inventory is numbered: slots 0 to 35 are the main inventory and the hotbar, 36 to 39 the armor,
40 the offhand. item:serialise() turns an item into a string with all its enchantments and names, and
mc.deserialise("item", text) turns it back. Strings can be stored, so the items go into player.data:
-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand.local LAST_SLOT = 40
-- Saves the player's items in player.data and empties the inventory.local function stash(player) local saved = {} for slot = 0, LAST_SLOT do local item = player:getItem(slot) if item then saved[tostring(slot)] = item:serialise() end end player.data.arenaStash = saved player:clear()end
local function restore(player) local saved = player.data.arenaStash if not saved then return end player:clear() for slot, json in pairs(saved) do player:setItem(tonumber(slot), mc.deserialise("item", json)) end player.data.arenaStash = nilendThe slot numbers are stored as strings ("0", "38"). Storage is JSON, and a table with gaps in its
numbers comes back from JSON with string keys anyway; converting on both sides keeps it predictable.
Why player.data and not a local table? If the server crashes in the middle of a round, a local table is
gone, and so are the player’s items. player.data is on disk. Chapter 9 uses that to give the items back.
Items go away when a round starts, and come back when a player is knocked out and at the end of the round:
stash(player) giveKit(player) restore(player) restore(player)A kit is plain data: a name, an icon, items with counts, and a chestplate.
local KITS = { { id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", items = { { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, }, chest = "minecraft:iron_chestplate", },The player’s choice is saved in player.data.arenaKit; findKit falls back to the first kit:
local function giveKit(player) local kit = findKit(player.data.arenaKit) for _, entry in ipairs(kit.items) do player:give(entry[1], entry[2]) end player.chest = mc.createItem(kit.chest)endplayer.chest is the chestplate slot; head, legs, feet, mainhand and offhand work the same way.
A chest menu
Section titled “A chest menu”The built-in chestgui library makes a menu out of a chest window. Load it at the top of the file:
local chestgui = require "core:chestgui"One menu serves all players. kitMenu:set(row, column, item, callback) puts a button in it:
local kitMenu = chestgui.create(1, "Choose a kit")for i, kit in ipairs(KITS) do local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) kitMenu:set(1, i * 2 + 1, icon, function(player) player.data.arenaKit = kit.id player:sendMessage("Kit: " .. kit.name) player:playSound("minecraft:ui.button.click") return false end)endreturn false cancels the click, so the item does not end up on the cursor. The buttons go in columns 3, 5
and 7 of the single row.
register("arena kit", function(ctx) kitMenu:open(ctx.player) end)And the scoreboard shows the chosen kit:
"Kit: " .. findKit(player.data.arenaKit).name,Try it
Section titled “Try it”- Put something recognizable in your inventory, e.g. a named item and some armor.
/arena kit, choose the archer, join and wait for the round. Your inventory is replaced by the kit.- After the round your own items are back, in the same slots.
Checkpoint
Section titled “Checkpoint”config/ignis/arena.lua after chapter 7
-- config/ignis/arena.lua: a last-player-standing PvP arena.local async = require "async"local chestgui = require "core:chestgui"
local ADMIN = "arena.admin"local MIN_PLAYERS = 2local COUNTDOWN = 10 -- seconds before a round startslocal ROUND_TIME = 300 -- seconds before a round ends in a draw
-- Corners, spawn points and lobby, kept across restarts by mc.data.mc.data.arena = mc.data.arena or {}local config = mc.data.arenaconfig.spawns = config.spawns or {}
-- Where a player stands, as a plain table that can be saved.local function here(player) local p = player.pos return { x = p.x, y = p.y, z = p.z, world = player.world.name }end
local function teleport(player, point) player:teleport(point.x, point.y, point.z, point.world)end
local region -- Region|nil, created by buildRegion() below
local function isReady() return region ~= nil and config.lobby ~= nil and #config.spawns > 0end
local function count(t) local n = 0 for _ in pairs(t) do n = n + 1 end return nend
-- Game state. It lives only in memory: a reload starts from scratch.local game = { phase = "waiting", -- waiting -> countdown -> playing -> ending players = {}, -- uuid -> Player: everyone who joined alive = {}, -- uuid -> true: still fighting (playing phase only) kills = {}, -- uuid -> kills this round timer = 0, -- seconds left in the current phase}
local function tell(text) for _, player in pairs(game.players) do player:sendMessage(text) endend
-- ── Statistics ─────────────────────────────────────────────────────────────
local function stats(player) if not player.data.arenaStats then player.data.arenaStats = { wins = 0, kills = 0, deaths = 0, games = 0 } end return player.data.arenaStatsend
-- Wins of every player who ever won, for the leaderboard (offline players too).mc.data.arenaWins = mc.data.arenaWins or {}local wins = mc.data.arenaWins
local leaderboard -- Hologram|nil
local function updateLeaderboard() if not leaderboard then return end local rows = {} for _, entry in pairs(wins) do rows[#rows + 1] = entry end table.sort(rows, function(a, b) return a.wins > b.wins end)
local lines = { "§6§lArena: top players" } for i = 1, math.min(5, #rows) do lines[#lines + 1] = string.format("§e%d. §f%s §7- %d", i, rows[i].name, rows[i].wins) end if #rows == 0 then lines[#lines + 1] = "§7no winners yet" end leaderboard.lines = linesend
local function spawnLeaderboard() if leaderboard then leaderboard:destroy() end leaderboard = nil local point = config.leaderboard if not point then return end leaderboard = mc.world(point.world):spawnHologram(vec(point.x, point.y, point.z), "Arena") updateLeaderboard()end
-- ── Inventory ──────────────────────────────────────────────────────────────
-- Player inventory slots: 0-35 main, 36-39 armor, 40 offhand.local LAST_SLOT = 40
-- Saves the player's items in player.data and empties the inventory.local function stash(player) local saved = {} for slot = 0, LAST_SLOT do local item = player:getItem(slot) if item then saved[tostring(slot)] = item:serialise() end end player.data.arenaStash = saved player:clear()end
local function restore(player) local saved = player.data.arenaStash if not saved then return end player:clear() for slot, json in pairs(saved) do player:setItem(tonumber(slot), mc.deserialise("item", json)) end player.data.arenaStash = nilend
-- ── Kits ───────────────────────────────────────────────────────────────────
local KITS = { { id = "warrior", name = "§cWarrior", icon = "minecraft:iron_sword", items = { { "minecraft:iron_sword", 1 }, { "minecraft:shield", 1 }, { "minecraft:cooked_beef", 8 }, }, chest = "minecraft:iron_chestplate", }, { id = "archer", name = "§aArcher", icon = "minecraft:bow", items = { { "minecraft:bow", 1 }, { "minecraft:arrow", 32 }, { "minecraft:stone_sword", 1 }, { "minecraft:cooked_beef", 8 }, }, chest = "minecraft:leather_chestplate", }, { id = "tank", name = "§9Tank", icon = "minecraft:diamond_chestplate", items = { { "minecraft:wooden_sword", 1 }, { "minecraft:golden_apple", 2 }, }, chest = "minecraft:diamond_chestplate", },}
local function findKit(id) for _, kit in ipairs(KITS) do if kit.id == id then return kit end end return KITS[1]end
local function giveKit(player) local kit = findKit(player.data.arenaKit) for _, entry in ipairs(kit.items) do player:give(entry[1], entry[2]) end player.chest = mc.createItem(kit.chest)end
local kitMenu = chestgui.create(1, "Choose a kit")for i, kit in ipairs(KITS) do local icon = mc.createItem(kit.icon, { name = kit.name, lore = { "§7Click to choose" } }) kitMenu:set(1, i * 2 + 1, icon, function(player) player.data.arenaKit = kit.id player:sendMessage("Kit: " .. kit.name) player:playSound("minecraft:ui.button.click") return false end)end
-- ── Scoreboard and boss bar ────────────────────────────────────────────────
local bar = mc.createBossBar("Arena", "yellow", "progress")
local function updateUI() local status if game.phase == "waiting" then status = string.format("Waiting for players: %d/%d", count(game.players), MIN_PLAYERS) bar.progress = math.min(1, count(game.players) / MIN_PLAYERS) elseif game.phase == "countdown" then status = string.format("Starting in %d s", game.timer) bar.progress = game.timer / COUNTDOWN elseif game.phase == "playing" then local minutes, seconds = math.floor(game.timer / 60), game.timer % 60 status = string.format("Alive: %d · %d:%02d", count(game.alive), minutes, seconds) bar.progress = game.timer / ROUND_TIME else status = "Round over" bar.progress = 1 end bar.title = status
for uuid, player in pairs(game.players) do local s = stats(player) player.sidebar = { title = "§6§lArena", lines = { game.alive[uuid] and "§aYou are fighting" or "§7You are watching", "Players: §e" .. count(game.players), "", "Kills this round: §e" .. (game.kills[uuid] or 0), "Wins: §e" .. s.wins, "Kit: " .. findKit(player.data.arenaKit).name, }, } endend
local function hideUI(player) bar:removePlayer(player) player.sidebar = nilend
-- ── Rounds ─────────────────────────────────────────────────────────────────
local function finish(winner) game.phase = "ending"
if winner then stats(winner).wins = stats(winner).wins + 1 wins[winner.uuid] = { name = winner.name, wins = stats(winner).wins } updateLeaderboard() for _, player in pairs(game.players) do player:sendTitle("§6" .. winner.name, "wins the round!") end else tell("§eThe round ended in a draw.") end
-- Celebrate for five seconds, then send everyone back to the lobby. mc.schedule(0, function() for _ = 1, 5 do if winner and game.alive[winner.uuid] then local pos = winner.pos + vec(0, 1, 0) winner.world:particle("minecraft:totem_of_undying", pos, { count = 40, spread = 0.5 }) winner.world:playSound("minecraft:entity.firework_rocket.launch", winner.pos) end async.sleep(20) end for uuid in pairs(game.alive) do local player = game.players[uuid] restore(player) teleport(player, config.lobby) end game.alive = {} game.phase = "waiting" end)end
local function checkWinner() if game.phase ~= "playing" then return end local left = count(game.alive) if left == 1 then finish(game.players[next(game.alive)]) elseif left == 0 then finish(nil) endend
local function eliminate(player, killer) game.alive[player.uuid] = nil stats(player).deaths = stats(player).deaths + 1 restore(player) teleport(player, config.lobby)
if killer and game.alive[killer.uuid] then game.kills[killer.uuid] = (game.kills[killer.uuid] or 0) + 1 stats(killer).kills = stats(killer).kills + 1 tell("§c" .. player.name .. " §7was eliminated by §a" .. killer.name) else tell("§c" .. player.name .. " §7was eliminated") end checkWinner()end
local function startRound() game.phase = "playing" game.timer = ROUND_TIME game.alive = {} game.kills = {}
local i = 0 for uuid, player in pairs(game.players) do i = i + 1 local spawn = config.spawns[(i - 1) % #config.spawns + 1] game.alive[uuid] = true stash(player) giveKit(player) player.health = player.maxHealth player.food = 20 teleport(player, spawn) stats(player).games = stats(player).games + 1 end tell("§aFight!")end
-- Runs once per second and moves the game from phase to phase.local function tick() if game.phase == "waiting" then if count(game.players) >= MIN_PLAYERS then game.phase = "countdown" game.timer = COUNTDOWN end elseif game.phase == "countdown" then if count(game.players) < MIN_PLAYERS then game.phase = "waiting" tell("§eNot enough players, the countdown stopped.") else game.timer = game.timer - 1 if game.timer <= 0 then startRound() elseif game.timer <= 3 then tell("§e" .. game.timer .. "...") end end elseif game.phase == "playing" then game.timer = game.timer - 1 if game.timer <= 0 then finish(nil) end end updateUI()end
mc.scheduleRepeating(20, 20, tick)
-- ── Joining and leaving ────────────────────────────────────────────────────
local function join(player) if not isReady() then player:sendMessage("§cThe arena is not set up yet.") elseif game.players[player.uuid] then player:sendMessage("§eYou have already joined.") elseif game.phase == "playing" or game.phase == "ending" then player:sendMessage("§eA round is in progress, try again in a moment.") else game.players[player.uuid] = player bar:addPlayer(player) teleport(player, config.lobby) tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") updateUI() endend
local function leave(player) local uuid = player.uuid if not game.players[uuid] then return end if game.alive[uuid] then eliminate(player, nil) end game.players[uuid] = nil game.kills[uuid] = nil hideUI(player) player:sendMessage("§7You left the arena.")end
mc.on("player.leave", function(e) leave(e.player)end)
-- ── Fights ─────────────────────────────────────────────────────────────────
-- A fatal hit knocks the player out of the round instead of killing them.mc.on("entity.death", function(e) local player = e.player if not player or not game.alive[player.uuid] then return end e:cancel() player.health = player.maxHealth player.fireTicks = 0 if game.phase == "playing" then eliminate(player, e.attacker and game.players[e.attacker.uuid]) endend, { priority = "high" })
-- No friendly hits before the round starts.mc.on("player.attack", function(e) if game.players[e.player.uuid] and not game.alive[e.player.uuid] then e:cancel() endend)
-- ── The arena region ───────────────────────────────────────────────────────
local function isProtected(player, pos) return region ~= nil and region:contains(pos) and not player:hasPermission(ADMIN)end
mc.on("block.break", function(e) if isProtected(e.player, e.pos) then e:cancel() endend, { priority = "high" })
mc.on("block.place", function(e) if isProtected(e.player, e.pos) then e:cancel() endend, { priority = "high" })
local function buildRegion() if region then region:destroy() end region = nil local a, b = config.pos1, config.pos2 if not (a and b) then return end
-- A region is a box from its min corner up to (but not including) its max -- corner, so +1 makes the max block part of it. region = mc.world(a.world):createRegion( vec(math.min(a.x, b.x), math.min(a.y, b.y), math.min(a.z, b.z)), vec(math.max(a.x, b.x) + 1, math.max(a.y, b.y) + 1, math.max(a.z, b.z) + 1) )
-- Walking (or being knocked) out of the arena counts as losing. region:on("leave", function(e) if e.player and game.phase == "playing" and game.alive[e.player.uuid] then e.player:sendMessage("§cYou left the arena!") eliminate(e.player, nil) end end)
-- Nobody else walks in during a round. region:on("enter", function(e) local player = e.player if player and game.phase == "playing" and not game.alive[player.uuid] and not player:hasPermission(ADMIN) then teleport(player, config.lobby) player:sendMessage("§eA round is in progress.") end end)end
buildRegion()spawnLeaderboard()
-- ── Commands ───────────────────────────────────────────────────────────────
local function corner(ctx, key) local pos = ctx.player.pos config[key] = { x = math.floor(pos.x), y = math.floor(pos.y), z = math.floor(pos.z), world = ctx.player.world.name, } buildRegion() local ready = region and ", the arena region is ready." or "." ctx.player:sendMessage("Corner " .. key .. " set" .. ready)end
register("arena pos1", function(ctx) corner(ctx, "pos1") end, ADMIN)register("arena pos2", function(ctx) corner(ctx, "pos2") end, ADMIN)
register("arena addspawn", function(ctx) local spawns = config.spawns spawns[#spawns + 1] = here(ctx.player) ctx.player:sendMessage("Spawn point #" .. #spawns .. " added.")end, ADMIN)
register("arena clearspawns", function(ctx) config.spawns = {} ctx.player:sendMessage("All spawn points removed.")end, ADMIN)
register("arena setlobby", function(ctx) config.lobby = here(ctx.player) ctx.player:sendMessage("Lobby set.")end, ADMIN)
register("arena setleaderboard", function(ctx) config.leaderboard = here(ctx.player) spawnLeaderboard() ctx.player:sendMessage("Leaderboard placed.")end, ADMIN)
register("arena join", function(ctx) join(ctx.player) end)register("arena leave", function(ctx) leave(ctx.player) end)register("arena kit", function(ctx) kitMenu:open(ctx.player) end)
register("arena stats [<target:player>]", function(ctx, target) local player = target or ctx.player local s = stats(player) ctx.player:sendMessage(string.format( "§6%s§7: %d wins, %d kills, %d deaths in %d games", player.name, s.wins, s.kills, s.deaths, s.games))end)Exercise: give each kit an effect field (e.g. "minecraft:speed" for the archer) and apply it in
giveKit with player:addEffect(kit.effect, 20 * ROUND_TIME).