Build a Tag Arena

A four-player game of tag: everyone runs around the arena, one player is “it”, and touching someone passes it on. This tutorial focuses on playing with more than two peers: reacting to net.state changes with net.on, handling players who join mid-game, and cleaning up after players who leave. Do Build Multiplayer Pong and Build a Coin Rush first – the session menu comes from the former and the host-provisioning pattern from the latter, and this tutorial only shows what is new.

The complete code is there to compare against when you are done.

What you will build

  • A host/join menu for up to four players

  • One colored square per player; whoever is “it” gets a red ring

  • Host-refereed tagging, announced through a net.state change listener

  • A taunt button using net.emit

  • Correct behavior when players join mid-game or leave (even the tagged one)

Everything is drawn with fill_rect() and rect() – no sprites needed.

Step 1: Assemble the known parts

Start from the Pong session menu (max_players = 4, title "Tag arena") and Coin Rush’s host-provisioning: the same provision_player with its first-writer idiom, minus the score field, and the same my_player() / update_movement() pair for arrow-key movement clamped to the screen. You will also want an AABB overlap test between two players – Tag defines its own two-player overlaps(a, b) (shown in Tag Arena – complete code), the same rectangle-overlap test explained in Current Limitations.

New locals for this game: tag_cooldown (host only) and taunt_timer, plus two constants – IT_SPEED slightly above SPEED (the chaser’s reward) and TAG_COOLDOWN = 60 frames. In update_movement(), pick the speed by role:

local speed = SPEED
if net.state.it == net.id() then
  speed = IT_SPEED
end

Step 2: One shared fact: who is “it”

The entire game state specific to Tag is a single shared key – net.state.it, the player id of the chaser. The host declares itself “it” at session start (net.state.it = net.id(), right after provisioning itself).

The interesting part is how everyone reacts to it. Instead of sending an event when a tag happens, every peer – host included – listens to the key:

net.on("it", function(path, value)
  if value == net.id() then
    print("You are it! Catch someone!")
  else
    print("Player " .. value .. " is it -- run!")
  end
end)

State your facts, listen for changes. Compared with announcing tags via net.emit, this keeps late joiners correct for free: an event fired before they arrived is gone forever, but the current it is right there in their state snapshot.

While you are in on_connected, handle departures. With more than two players, cleanup is a real concern – and the departed player might be the chaser:

net.on("peer.left", function(playerId)
  net.state.players[playerId] = nil
  if net.state.it == playerId then
    net.state.it = net.id()   -- "it" left: the host takes over
  end
end)

Only the host registers this (it owns the players branch); both roles subscribe to "ended" as usual.

Try it

Make “it” visible before testing: in draw_players(), ring the entry whose tonumber(id) equals net.state.it with a red rect(), and draw a red border around the whole arena when it is you. Then run two or three windows – every square moves, and every screen agrees on who wears the ring.

Step 3: The host referees tags

Who decides a touch happened? If everyone did, two players could disagree by a frame and “it” would flicker. One referee – the host – scans for contact and writes the single shared fact; the cooldown stops the tag from bouncing straight back:

function update_tagging()
  if tag_cooldown > 0 then
    tag_cooldown = tag_cooldown - 1
    return
  end

  local players = net.state.players
  if not players then
    return
  end

  local it = players[net.state.it]
  if not it then
    return
  end

  for id, p in pairs(players) do
    if tonumber(id) ~= net.state.it and overlaps(it, p) then
      net.state.it = tonumber(id)   -- everyone's "it" listener fires
      tag_cooldown = TAG_COOLDOWN
      break
    end
  end
end

Note the familiar details doing their job: string pairs keys converted with tonumber, and nil guards on both the branch and the “it” entry (the chaser might have just disconnected). Call it from update_playing() inside the is_host branch.

Step 4: A taunt, because events still have a place

Not everything belongs in net.state. A taunt is a one-shot moment with no lasting truth – exactly what net.emit is for. On space, with a one-second local cooldown so holding the key does not spam:

if key_pressed(" ") and taunt_timer <= 0 then
  taunt_timer = 60
  net.emit("taunt")
  print("You taunt everyone!")
end

Receivers subscribe to "event:taunt" in on_connected and print who taunted them (the from argument). The local print next to the emit is the usual reminder that senders never receive their own events.

Step 5: Play with three or four

Wire the remaining glue exactly as in the previous tutorials (winner-less this time: the game just runs), then put the multi-peer claims to the test:

  1. Host on one machine, pick public visibility, and have two or three friends find the session in the join dialog’s public list.

  2. Let one player join after the game has been running: they appear instantly with the right positions and the right “it” – the state snapshot doing its job.

  3. Close the tab of the player who is “it”: the host’s peer.left removes their square and takes the “it” role back.

  4. Close the host’s tab instead: everyone else gets the "ended" message and recovers to the menu.

How it all fits together

Every player                       Host only
---------------------------        -----------------------------
writes own players.<id>.x/y        provisions players (join/mid-game)
net.on("it", ...) announces        removes them on peer.left
net.emit("taunt")  ------->        checks overlap with "it"
draws all players,                 writes net.state.it on a tag
ring around the "it"                 (replicates to everyone)

Complete code

Compare your build against the full documented script.

Extending the example

  • Freeze tag – tagged players stop moving until a teammate touches them; store a frozen flag per player, written by the host.

  • Score by survival – host counts the frames each player spends not being it in net.state.players.<id>.score.

  • Obstacles – draw a wall layout with fill_rect() and block movement against it; keep the layout in constants so every client agrees.

  • Round timer – host counts down in net.state.time_left; whoever is “it” at zero loses.