Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 33 — Project: A Simple Minigame

What You’ll Build

This is the big one. Up to now every chapter taught a tool: scoreboards count, storage remembers, /execute reshapes who and where a command runs, /schedule makes things happen later, /title and /tellraw talk to players. In this chapter you compose the ones you already have into a single working game.

The game is King of the Hill: players gather, someone starts a round, a “3… 2… 1… GO!” countdown plays, and then everyone races to stand inside a marked hill region. While you’re on the hill you earn a point every tick; the player with the most points when the round timer runs out wins. The winner is announced, scores reset, and the game returns to waiting for the next round.

By the end you’ll have a complete, self-contained project pack (a brand-new pack named hill_pack with its own namespace hill) that you can drop into any world and play. Along the way you’ll see the single most important idea in data pack design: a game is a state machine. It is always in exactly one phase, it does work according to that phase, and it moves to the next phase when something happens. You met that pattern in Chapter 27 with a tiny three-state demo; here it grows into a real game.

Figure (to be captured). a flat arena with a square of gold blocks (the hill) in the centre, a sidebar scoreboard on the right showing two players’ scores, and an action-bar line reading “Time left: 18”

A note on packs. Everything since Chapter 9 has gone into your mypack pack. This chapter is different: a finished game deserves its own pack, so it can be shared, enabled, and disabled on its own. We’ll build hill_pack from scratch, with its own pack.mcmeta and, importantly, its own minecraft:load and minecraft:tick tags. We are not touching mypack’s canonical load and tick tags. Two packs can each add their own entries to minecraft:load/minecraft:tick; the game runs them all. Keeping the game’s wiring inside the game’s own pack is what makes it a clean, portable project pack.

The architecture: four phases as a state machine

Before writing a single command, let’s design the game on paper. A minigame is a handful of modes, and the game is always in exactly one of them. Ours has four phases:

  • init — runs once, when the pack loads. It builds the scoreboards and parks the game in waiting. (This is the only phase that isn’t a “mode you sit in”; it sets everything up and immediately hands off to waiting.)
  • waiting — the lobby. Players join, nothing is being scored, and the game waits for someone to press start.
  • running — a round is live. Each tick, the game checks who’s on the hill, awards points, updates the on-screen timer, and watches for the round to end.
  • cleanup — the round just ended. Announce the winner, reset the scores and tags, and drop back to waiting for the next round.

The arrows between them, the transitions, are the whole game:

init ──▶ waiting ──(start)──▶ countdown ──▶ running ──(timer ends)──▶ cleanup ──▶ waiting ──▶ …

(We’ll give the 3-2-1 countdown its own short-lived phase, countdown, so that the per-tick logic knows not to score anyone while the numbers are still counting down.)

Here is the key design decision, and it’s exactly the one Chapter 12 drilled into you: **the current phase is game STATE, so it lives in command storage, not in a scoreboard. A scoreboard holds numbers you compare and show players (each player’s points, the round timer). The phase is a named mode, and storage is where named, structured state belongs. Chapter 12’s mnemonic was “scoreboards count; storage remembers.” The board counts the points; storage remembers which phase we’re in. We’ll keep the phase as a string at hill:game, in a compound called Game:

Game: { phase: "waiting" }

Every tick, one function reads that phase and dispatches to the right logic. That dispatcher is the heart of the whole pack, and it has a name: the tick router.

Building the project pack skeleton

Let’s lay the pack out. A pack needs its marker file, pack.mcmeta (Chapter 9). Create the folder hill_pack and give it:

hill_pack/pack.mcmeta

{
  "pack": {
    "description": "King of the Hill minigame",
    "min_format": 107,
    "max_format": 107
  }
}

That’s the same marker shape and the same format number (107) you used for mypack in Chapter 9: a pack is a pack. Next, the game needs to set itself up the moment the pack loads, and it needs one function to run every tick. Those are the two function tags from Chapters 9 and 4, but this time they belong to hill_pack, not mypack.

hill_pack/data/minecraft/tags/function/load.json

{
  "values": [
    "hill:init"
  ]
}

hill_pack/data/minecraft/tags/function/tick.json

{
  "values": [
    "hill:tick"
  ]
}

Modern Minecraft. It might look odd to have a second data/minecraft/tags/function/load.json when mypack already has one. That’s completely fine, and it’s why data packs are built the way they are. The minecraft:load function tag is shared: every enabled pack contributes its own values entries, and the game merges them. hill_pack adds hill:init; mypack still has its own entries; nobody overwrites anybody. (Remember from Chapter 14 that a tag file with no "replace": true extends rather than replaces.) Keeping hill’s wiring inside hill_pack is exactly what makes the game a tidy, shareable unit.

Everything else lives under hill_pack/data/hill/function/. From here on, file paths are written relative to the pack, and every function is named hill:<name>.

Phase 1 — init: setting up the game

The init function runs once on load. It creates the two scoreboards the game needs, shows the points board on the sidebar, and parks the phase in waiting.

hill_pack/data/hill/function/init.mcfunction

# Runs once on load (wired into minecraft:load via hill:init).
# Two objectives: one for player points, one to hold the round timer.
scoreboard objectives add hill_points dummy
scoreboard objectives add hill_timer dummy

# Show the points objective on the right-hand sidebar (Chapter 11).
scoreboard objectives setdisplay sidebar hill_points

# Put the game into its starting phase. Storage holds the state (Chapter 12).
data modify storage hill:game Game.phase set value "waiting"

# Announce that the game is loaded and ready.
tellraw @a {"text":"[King of the Hill] Loaded. Run hill:join to play.","color":"gold"}

Both objectives use the dummy criterion, the plain “I’ll set this myself with commands” kind from Chapter 11, perfect for points and a timer that we control. setdisplay sidebar hill_points puts the points board on the sidebar, the panel on the right edge of the screen (Chapter 11’s display slots). And data modify storage hill:game Game.phase set value "waiting" writes the phase string into storage with the exact /data modify ... set value form you learned in Chapter 12.

Phase 2 — waiting: joining the game

In the lobby, players opt in by running a join function. Joining does three things: it tags the player so the rest of the game can find “who’s playing” (Chapter 13’s runtime entity tags), it zeroes their score for a clean start, and it tells them they’re in.

hill_pack/data/hill/function/join.mcfunction

# A player joins the game. Mark them with a runtime tag (Chapter 13).
tag @s add hill_player

# Start their score at 0 (Chapter 11).
scoreboard players set @s hill_points 0

# Confirm to just this player.
tellraw @s {"text":"You joined King of the Hill! Wait for the round to start.","color":"green"}

tag @s add hill_player is the /tag command from Chapter 13: it sticks the label hill_player on whoever ran the function. Later, @a[tag=hill_player] selects exactly the players who have joined: that’s how the game tells contestants apart from spectators. To play, a player runs /function hill:join (typed in chat with its slash, since they’re calling it by hand).

Try It! Right now players join by typing a command. In Chapter 19 you learned the hidden-advancement-as-detector trick: an advancement with no display that fires a reward function and re-arms itself. You could make “step on the emerald block by the lobby” run hill:join automatically. The game logic below doesn’t change at all; only how hill:join gets called does.

Phase 3 setup — start and the countdown

Starting a round is a transition: it should only work when we’re actually waiting, and it moves the game forward. We guard it with the Chapter 27 trick (an if data test on a compound filter) so that mashing start during a live round does nothing.

hill_pack/data/hill/function/start.mcfunction

# Only start if we are in the waiting phase. The {phase:"waiting"} compound
# filter (Chapter 12/27) makes this line run ONLY when that's the current phase.
execute if data storage hill:game Game{phase:"waiting"} run data modify storage hill:game Game.phase set value "countdown"

# Seed the countdown number (3, 2, 1) on a fake player (Chapter 11).
execute if data storage hill:game Game{phase:"waiting"} run scoreboard players set #count hill_timer 3

# Kick off the self-rescheduling countdown one second from now (Chapter 26).
execute if data storage hill:game Game{phase:"waiting"} run schedule function hill:countdown_tick 20t replace

# Tell everyone a round is starting.
execute if data storage hill:game Game{phase:"waiting"} run tellraw @a {"text":"A round is starting!","color":"yellow"}

Every line is guarded by the same if data storage hill:game Game{phase:"waiting"} so the whole start sequence only fires from the waiting phase. The #count score holder is a fake player (the # prefix hides it from the sidebar, Chapter 11) used to carry the countdown number. We store the number on the hill_timer objective for now since it’s a temporary counter; the real round timer reuses the same objective later. The last new piece is schedule function hill:countdown_tick 20t replace, the Chapter 26 pattern: run a function in 20t (one second; 20 ticks per second from Chapter 7), with replace so starting twice can’t stack two countdowns.

Now the countdown itself. It announces the current number with a big /title, counts down, and either re-schedules itself or, when it reaches zero, flips the phase to running and starts the round timer.

hill_pack/data/hill/function/countdown_tick.mcfunction

# Show the current count big on screen for everyone (Chapter 5 title).
execute if score #count hill_timer matches 1.. run title @a title {"text":"","extra":[{"score":{"name":"#count","objective":"hill_timer"}}]}

# When the count hits 0, show GO! instead of a number.
execute if score #count hill_timer matches 0 run title @a title {"text":"GO!","color":"green"}

# Count down by one.
scoreboard players remove #count hill_timer 1

# If there are still numbers to show, re-schedule for one more second.
execute if score #count hill_timer matches 0.. run schedule function hill:countdown_tick 20t replace

# When the count drops below 0, the countdown is over: BEGIN THE ROUND.
execute if score #count hill_timer matches ..-1 run function hill:begin_round

Read it the same way you read the Chapter 26 countdown: show the number (matches 1.. means “1 or more”), special-case zero as “GO!”, decrement, and re-schedule while matches 0.. (“0 or more”) still holds. The one new line is the last: once the count goes below zero (matches ..-1, meaning “−1 or less”), we call hill:begin_round instead of re-scheduling, which switches the game into its running phase.

hill_pack/data/hill/function/begin_round.mcfunction

# Transition countdown -> running.
data modify storage hill:game Game.phase set value "running"

# Set the round length: 600 ticks = 30 seconds (20 ticks/second, Chapter 7).
scoreboard players set #round hill_timer 600

# Fresh scores for everyone who joined.
function hill:reset_scores

hill:begin_round writes the new phase, sets the round timer to 600 (thirty seconds at 20 ticks a second), held on the fake player #round, and calls a helper to zero every contestant’s points so the round starts fair:

hill_pack/data/hill/function/reset_scores.mcfunction

# Zero the points of every joined player (Chapter 3 selector + Chapter 11).
scoreboard players set @a[tag=hill_player] hill_points 0

@a[tag=hill_player] is the Chapter 3/12 selector: all players carrying the hill_player tag. One command sets every contestant’s score to 0.

Phase 3 — running: the tick router and the round logic

Now the engine. Remember the plan: one function runs every tick (it’s the only entry in our tick.json), and its job is to look at the phase and dispatch. That’s the tick router:

hill_pack/data/hill/function/tick.mcfunction

# THE TICK ROUTER. Runs every tick (wired into minecraft:tick via hill:tick).
# Read the phase from storage and run the matching phase's logic. Only ONE of
# these lines fires per tick, because the game is in exactly one phase.
execute if data storage hill:game Game{phase:"running"} run function hill:run_tick

That’s the whole router for now: a single line, because waiting, countdown, and cleanup don’t need per-tick work (waiting just sits there; the countdown drives itself with /schedule; cleanup runs once and exits). The if data storage hill:game Game{phase:"running"} test means hill:run_tick only runs while a round is live. If you later add per-tick lobby effects, you’d add one more guarded line. The router scales by adding lines, never by getting tangled.

Here’s the per-tick round logic. It does three jobs every tick: award points to whoever is on the hill, update the on-screen timer, and count the round timer down and end the round when it hits zero.

hill_pack/data/hill/function/run_tick.mcfunction

# Runs every tick WHILE the phase is "running" (called by the router).

# 1) AREA DETECTION + SCORING.
#    For each joined player standing inside the hill region, add a point.
#    The hill is a box centred on (0, -60, 0): dx/dy/dz give its size (Chapter 3).
#    +1 each is the per-tick reward; the player who camps the hill longest wins.
execute as @a[tag=hill_player] at @s if entity @s[x=-3,y=-61,z=-3,dx=6,dy=3,dz=6] run scoreboard players add @s hill_points 1

# 2) TIMER UI.
#    Show the round timer on the action bar for everyone (Chapter 5).
title @a actionbar {"text":"Time left: ","extra":[{"score":{"name":"#round","objective":"hill_timer"}}]}

# 3) COUNT THE ROUND TIMER DOWN.
scoreboard players remove #round hill_timer 1

# 4) END THE ROUND when the timer reaches 0: go to cleanup.
execute if score #round hill_timer matches ..0 run function hill:cleanup

There’s a lot of Part I–VI in those five lines, so let’s name each move:

  • Area detection. execute as @a[tag=hill_player] at @s runs the rest as each contestant, standing where they stand (Chapter 4’s as/at). Then if entity @s[x=-3,y=-61,z=-3,dx=6,dy=3,dz=6] is the Chapter 3 volume selector: it asks “is this same player (@s) inside the box whose corner is (-3, -61, -3) and which extends 6 blocks along x, 3 up, and 6 along z?” In plain terms, that’s a 6×6 square three blocks tall: the hill. Only players who pass that test reach the run scoreboard players add @s hill_points 1, so only players on the hill gain a point this tick. (Adjust the numbers to wherever your gold-block hill actually is.)
  • Timer UI. title @a actionbar {...} prints to the action bar, the line just above the hotbar (Chapter 5). The {"score":{"name":"#round","objective":"hill_timer"}} text component prints the live value of the #round timer, the same “score in text” trick from Chapter 11. So everyone sees “Time left: 600”, “Time left: 599”, …, ticking down in real time.
  • Count down. scoreboard players remove #round hill_timer 1 knocks one off the round timer each tick (Chapter 11).
  • End the round. if score #round hill_timer matches ..0 (“0 or below”) fires the moment the timer runs out, calling hill:cleanup to wrap up. Because the router only runs run_tick while the phase is running, and cleanup immediately changes the phase, the round ends exactly once.

Under the Hood (skippable). Notice we never wrote a per-player timer or a for each player loop for the timer: there’s only one round timer, on the fake player #round, shared by everyone. But points are per-player, so they live on the real players via @a[tag=hill_player]. That split is the Chapter 12 decision guide in miniature: one shared number (the clock) versus a number that belongs to each entity (their score). Picking the right holder for each value is most of what makes a game’s data clean.

Score arithmetic: scoreboard players operation

Our scoring uses plain scoreboard players add @s hill_points 1, adding a fixed 1 each tick, which is all King of the Hill needs. But back in Chapter 11 we deferred the other way to change a score: scoreboard players operation, which does math between two scores. This is the chapter that promised to deliver it, so here’s the full tool:

scoreboard players operation <targets> <targetObjective> <operation> <source> <sourceObjective>

It applies an arithmetic operation that alters the targets’ scores in the target objective, using the sources’ scores in the source objective as input. Here is every operator:

  • =assignment: set the target’s score to the source’s score.
  • +=addition: add the source’s score to the target’s.
  • -=subtraction: subtract the source’s score from the target’s.
  • *=multiplication: set the target to the product of the two.
  • /=floor division: divide the target by the source, rounded down to an integer.
  • %=modulus: divide, and keep the positive remainder.
  • ><swap: swap the target’s and source’s scores.
  • <choose minimum: set the target to the source only if the source is smaller.
  • >choose maximum: set the target to the source only if the source is larger.

One rule is worth remembering: in all cases except ><, the source’s score remains unchanged, and if the target or source isn’t tracked by the specified objective, it is set to 0. So all but the swap leave the source alone, and an unset score is treated as 0.

Where would a King of the Hill game use this? Here’s a natural example: a double-points power-up. Suppose you keep a per-player multiplier in an objective hill_mult (1 normally, 2 while a power-up is active). Instead of add ... 1, you could award the player their multiplier each tick:

hill_pack/data/hill/function/score_with_mult.mcfunction

# Award each on-hill player their current multiplier, using operation +=.
# "+= @s hill_mult" adds the player's own multiplier score onto their points.
execute as @a[tag=hill_player] at @s if entity @s[x=-3,y=-61,z=-3,dx=6,dy=3,dz=6] run scoreboard players operation @s hill_points += @s hill_mult

That single operation @s hill_points += @s hill_mult reads “add this player’s hill_mult score onto their hill_points score”: so a player with a 2× multiplier gains 2 per tick, a normal player gains 1 (their multiplier), and a player whose multiplier was never set gains 0 (unset counts as 0). This is optional polish (the base game uses the simple add 1), but operation is the tool the moment you want score math instead of fixed increments.

Try It! Use > (choose maximum) to track a best-ever score across rounds: keep an objective hill_best, and at cleanup run scoreboard players operation @s hill_best > @s hill_points for each player. It copies this round’s points into hill_best only if they beat the old best: a high-score table for free.

Phase 4 — cleanup: declaring a winner and resetting

When the timer hits zero, hill:cleanup runs once. It needs to find the player with the most points, announce them, and reset the game to waiting. Finding “the highest score” is a perfect use of a selector sort from Chapter 3: @a[tag=hill_player] with a descending sort on the hill_points score, limited to one player, is the leader.

hill_pack/data/hill/function/cleanup.mcfunction

# Runs once when the round ends.

# Move the phase out of "running" immediately so run_tick stops firing.
data modify storage hill:game Game.phase set value "cleanup"

# Announce the winner: the joined player with the highest hill_points.
# The selector below picks exactly one (limit=1, highest first); we show
# their name and score (Chapter 5/10).
execute as @a[tag=hill_player,scores={hill_points=1..},limit=1,sort=descending] run tellraw @a [{"text":"Winner: "},{"selector":"@s"},{"text":" with "},{"score":{"name":"@s","objective":"hill_points"}},{"text":" points!","color":"gold"}]

# Big title for everyone.
title @a title {"text":"Round over!","color":"gold"}

# Clear the runtime tag so players must re-join for the next round, and
# return the game to the waiting phase.
tag @a remove hill_player
data modify storage hill:game Game.phase set value "waiting"
tellraw @a {"text":"Run hill:join to play again.","color":"yellow"}

The winner line is the densest one, so unpack it:

  • @a[tag=hill_player,scores={hill_points=1..},limit=1,sort=descending] selects joined players who scored at least 1 (scores={hill_points=1..}, the Chapter 11 score selector), sorts them highest-first (sort=descending), and keeps just the top one (limit=1). That’s the winner.
  • The /tellraw message is a list of text components (Chapter 5): a label, the winner’s name via {"selector":"@s"}, their score via {"score":{"name":"@s","objective":"hill_points"}}, and a gold “points!” tail. Because the line runs as the winner, @s inside the components means the winner.

After the announcement we tag @a remove hill_player to clear everyone’s join tag (a fresh hill:join is needed for the next round) and data modify storage hill:game Game.phase set value "waiting" to park the machine back in the lobby. The state machine has come full circle: waiting → countdown → running → cleanup → waiting, ready to go again.

Figure (to be captured). chat reading “Winner: Steve with 214 points!” in gold, with a “Round over!” title across the screen

What Went Wrong? “Nobody is ever the winner.” The winner selector requires scores={hill_points=1..}, at least one point. If no contestant ever stood on the hill, nobody qualifies and the tellraw simply doesn’t fire (no winner to announce). That’s correct behaviour, not a bug. But if you expected a winner and got none, check that your hill box (x/y/dx/dy/dz) actually lines up with where the gold blocks are.

Wiring it together

The pack is built. Here’s the complete file list for hill_pack, so you can confirm nothing’s missing:

hill_pack/pack.mcmeta
hill_pack/data/minecraft/tags/function/load.json      (values: hill:init)
hill_pack/data/minecraft/tags/function/tick.json      (values: hill:tick)
hill_pack/data/hill/function/init.mcfunction
hill_pack/data/hill/function/join.mcfunction
hill_pack/data/hill/function/start.mcfunction
hill_pack/data/hill/function/countdown_tick.mcfunction
hill_pack/data/hill/function/begin_round.mcfunction
hill_pack/data/hill/function/reset_scores.mcfunction
hill_pack/data/hill/function/tick.mcfunction
hill_pack/data/hill/function/run_tick.mcfunction
hill_pack/data/hill/function/cleanup.mcfunction

To play:

  1. Build a flat arena and place a 6×6 square of gold blocks centred near (0, -60, 0) — that’s the hill. (Use whatever coordinates you like; just match the run_tick box to them.)
  2. Enable hill_pack and /reload. You’ll see the gold “[King of the Hill] Loaded” message, and the sidebar appears. init has set the phase to waiting.
  3. Each player runs /function hill:join.
  4. Someone runs /function hill:start. The 3-2-1 countdown plays for everyone, then “GO!”.
  5. Race to the hill. Stand inside it to rack up points; watch the action-bar timer tick down.
  6. When the timer hits zero, the winner is announced, the game resets, and you can hill:join and hill:start again.

Notice what made this manageable: every phase is a separate, small function, and one router decides which runs. You never wrote a giant tangle of nested ifs. Adding a feature means adding a function and, maybe, one guarded line in the router. That’s the payoff of designing the game as a state machine before writing commands.

Practice

  1. Lobby countdown to auto-start. Right now a round only starts when someone runs hill:start. Add a per-tick lobby check: in the router, add a line that runs a new hill:waiting_tick while Game{phase:"waiting"}. Have waiting_tick count joined players with execute store result score #players hill_timer if entity @a[tag=hill_player] (the Chapter 27 “count entities into a score” pattern) and, once there are 2 or more, call hill:start automatically.

  2. Sudden-death overtime. In cleanup, before declaring a winner, check whether the top two scores are tied. If they are, instead of ending, set the phase back to running and the round timer to a short 200t. (Hint: copy the two top scores into fake players with scoreboard players operation, then compare with if score.)

  3. A win-target instead of a clock. Change the game so the first player to reach 300 points wins immediately, regardless of the timer. Add a line to run_tick: execute as @a[tag=hill_player,scores={hill_points=300..}] run function hill:cleanup. (Make sure cleanup still works when called mid-round — it already moves the phase out of running, so the router stops run_tick cleanly.)

  4. A second hill. Add a second gold square somewhere else and award points for standing on either. Remember from Chapter 27 that “OR” in commands is two separate chains: add a second execute as @a[tag=hill_player] at @s if entity @s[...second box...] run scoreboard players add @s hill_points 1 line. A player on either hill scores.

What Can Go Wrong

What Went Wrong? “The game scores people during the countdown / lobby.” This means run_tick is running when it shouldn’t. The fix is the router: scoring must only happen while Game{phase:"running"}. Confirm tick.json points at hill:tick (the router), not straight at hill:run_tick, and that the router’s line is guarded by if data storage hill:game Game{phase:"running"}. If you wire run_tick directly into the tick tag, it runs in every phase, and you’ll score people in the lobby.

What Went Wrong? “The phase never changes / the game is stuck.” A state machine only advances if something writes the next phase. Trace the transition: does start actually set "countdown"? Does begin_round set "running"? Does cleanup set "waiting"? A common slip is guarding a transition with the wrong phase name: Game{phase:"waiting"} versus a typo like Game{phase:"wating"}. The compound filter is an exact string match, so one wrong letter means the line silently never fires. Read the phase back any time with the Chapter 12 command, typed in chat: /data get storage hill:game Game.phase.

What Went Wrong? “Two countdowns are running at once.” You started a round twice and the schedules stacked. Two defences are already in place: start is guarded so it only fires from waiting (once we’re in countdown, a second start does nothing), and the schedule function hill:countdown_tick 20t replace uses replace (Chapter 26), so even a duplicate schedule overwrites rather than piles up. If you ever change replace to append, expect overlapping countdowns: that’s exactly what append is for, and exactly what you don’t want here. To kill a stuck countdown by hand: /schedule clear hill:countdown_tick (full namespaced ID required, Chapter 26).