Skip to content

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.

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 waiting again.

The current phase and a timer go into the game state, together with the set of players still in the fight:

local MIN_PLAYERS = 2
local COUNTDOWN = 10 -- seconds before a round starts
local ROUND_TIME = 300 -- seconds before a round ends in a draw
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
}

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
end
end
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.

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.")

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)
end

async.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 *.

  1. Set ROUND_TIME to 30 while testing.
  2. 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.
  3. 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.

config/ignis/arena.lua after chapter 3
config/ignis/arena.lua
-- config/ignis/arena.lua: a last-player-standing PvP arena.
local async = require "async"
local ADMIN = "arena.admin"
local MIN_PLAYERS = 2
local COUNTDOWN = 10 -- seconds before a round starts
local 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.arena
config.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 > 0
end
local function count(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
-- 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)
end
end
-- ── 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
end
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
teleport(player, config.lobby)
tell("§a" .. player.name .. " §7joined the arena (" .. count(game.players) .. ")")
end
end
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() end
end, { priority = "high" })
mc.on("block.place", function(e)
if isProtected(e.player, e.pos) then e:cancel() end
end, { 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.