3. Rounds
Now the arena gets a game loop: when enough players have joined, a countdown starts, everyone is teleported to a spawn point, and after a while the round ends. Fights come in the next chapter; here the round simply ends in a draw when time runs out.
Phases
Section titled “Phases”A round goes through four phases:
- waiting: players join, until there are enough of them;
- countdown: a few seconds to get ready;
- playing: the fight;
- ending: the winner celebrates, then everyone returns to the lobby and it is
waitingagain.
The current phase and a timer go into the game state, together with the set of players still in the fight:
local MIN_PLAYERS = 2local COUNTDOWN = 10 -- seconds before a round startslocal ROUND_TIME = 300 -- seconds before a round ends in a drawlocal game = { phase = "waiting", -- waiting -> countdown -> playing -> ending players = {}, -- uuid -> Player: everyone who joined alive = {}, -- uuid -> true: still fighting (playing phase only) timer = 0, -- seconds left in the current phase}A tick every second
Section titled “A tick every second”mc.scheduleRepeating(delay, interval, fn) calls a function every interval ticks (20 ticks = 1 second).
One function that runs once a second and moves the game along is the whole game loop:
-- 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 endend
mc.scheduleRepeating(20, 20, tick)Scheduled tasks are removed on reload, and the file schedules a new one when it loads, so there is always exactly one.
Starting a round
Section titled “Starting a round”local function startRound() game.phase = "playing" game.timer = ROUND_TIME game.alive = {}
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 player.health = player.maxHealth player.food = 20 teleport(player, spawn) end tell("§aFight!")end(i - 1) % #config.spawns + 1 goes through the spawn points one by one and starts over when there are more
players than points: with 3 points, players 1, 2, 3, 4, 5 get points 1, 2, 3, 1, 2.
While a round is running nobody can join:
elseif game.phase == "playing" or game.phase == "ending" then player:sendMessage("§eA round is in progress, try again in a moment.")Ending a round
Section titled “Ending a round”finish(winner) shows the winner (none yet, that is the next chapter) and brings everyone back after a
five-second celebration:
local function finish(winner) game.phase = "ending"
if winner then 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] teleport(player, config.lobby) end game.alive = {} game.phase = "waiting" end)endasync.sleep(ticks) (from local async = require "async" at the top of the file) pauses the function
without pausing the server. It only works inside a coroutine; scheduled callbacks, event handlers and
command handlers all run as one, so it works there. mc.schedule(0, ...) starts the celebration in its
own coroutine, and finish returns right away.
winner.pos + vec(0, 1, 0) is one block above the winner’s feet: vectors support +, - and *.
Try it
Section titled “Try it”- Set
ROUND_TIMEto30while testing. - Join with two players (a second account, or a friend). After ten seconds both are teleported to the spawn points; after 30 more the round ends in a draw and you are back in the lobby.
- Leave during the countdown: it stops.
Dying does nothing special yet: a dead player respawns as usual and is still “alive” in the round. That is fixed in the next chapter.
Checkpoint
Section titled “Checkpoint”config/ignis/arena.lua after chapter 3
-- config/ignis/arena.lua: a last-player-standing PvP arena.local async = require "async"
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) timer = 0, -- seconds left in the current phase}
local function tell(text) for _, player in pairs(game.players) do player:sendMessage(text) endend
-- ── Rounds ─────────────────────────────────────────────────────────────────
local function finish(winner) game.phase = "ending"
if winner then 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] teleport(player, config.lobby) end game.alive = {} game.phase = "waiting" end)end
local function startRound() game.phase = "playing" game.timer = ROUND_TIME game.alive = {}
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 player.health = player.maxHealth player.food = 20 teleport(player, spawn) 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 endend
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 teleport(player, config.lobby) tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")") endend
local function leave(player) local uuid = player.uuid if not game.players[uuid] then return end game.players[uuid] = nil player:sendMessage("§7You left the arena.")end
mc.on("player.leave", function(e) leave(e.player)end)
-- ── 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) )end
buildRegion()
-- ── 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 join", function(ctx) join(ctx.player) end)register("arena leave", function(ctx) leave(ctx.player) end)Exercise: start the round early when the arena is full: if count(game.players) reaches
#config.spawns during the countdown, skip to 3 seconds.