Permissions – locking down net.state

By default net.state trusts every client: any peer can write any key, and games stay honest by ownership convention. That is fine for a friendly game, but a determined client could just do net.state.winner = net.id() and win. This tutorial turns that convention into a rule the host enforces, using the MULTIPLAYER tab.

We build on the Build a Coin Rush game – have it working first.

The two flags

Every net.state path has two client permissions, set in the MULTIPLAYER tab and enforced by the host at runtime:

  • Clients can write – when off, only the host may write the path. A client’s write is applied optimistically and then rolled back when the host’s rejection arrives, so a net.on change listener sees the value flip and flip back.

  • Clients can read – when off, the host keeps the path private: it is never sent to clients, in the join snapshot or in live updates.

Three things to keep in mind:

  • Allow-by-default. A path you never configure is fully open. You opt into restrictions; you never have to grant permissions just to keep a game working.

  • Inheritance. A path inherits the flags of its nearest configured ancestor, so locking players locks players.3.x too – configure at the granularity you actually own.

  • The host is the authority. There is no separate server to restrict. “Client” always means a joined peer.

Make the winner host-authoritative

In click-race, the client that reaches WIN_SCORE sets the winner itself, inside try_collect:

if me.score >= WIN_SCORE then
  net.state.winner = net.id()      -- a client declaring the result
end

Delete those lines. The result is a global fact, so the host should decide it. Add a host check and call it from the game loop:

function check_winner()
  if net.state.winner then
    return
  end
  for id, p in pairs(net.state.players or {}) do
    if p.score >= WIN_SCORE then
      net.state.winner = tonumber(id)
      return
    end
  end
end

function update_playing()
  update_movement()
  update_collect()

  if is_host then
    update_respawns()
    check_winner()          -- the host is the referee
  end

  if net.state.winner then
    state = "over"
    -- ... (unchanged)
  end
end

Now lock the key. In the MULTIPLAYER tab, add a node for winner and turn Clients can write off (leave Clients can read on – everyone still needs to see who won). Re-run and host: the game plays exactly as before, because only the host writes winner now.

To see the rule bite, temporarily add net.state.winner = net.id() to a client path (say, on a key press). Instead of ending the game it is rejected – the write is applied for a moment and then the host’s rejection snaps the value back, so a net.on("winner", ...) listener sees it flip and flip back.

Note

A client write that batches several keys in one frame is rejected as a whole if any of them is protected, so a forbidden write never lands partially. In practice clients only write their own open keys, so this rarely comes up.

Keeping state server-private

Turning Clients can read off keeps a path on the host only. Use it for anything a client should not be able to inspect – a shuffled deck, an AI’s target, an unrevealed answer:

-- host only
net.state.deck = shuffle(make_deck())    -- with "Clients can read" off on "deck",
                                         -- clients never receive it
net.state.top_card = net.state.deck[1]   -- reveal one card through a readable key

Clients simply never have net.state.deck in their store; reading it returns nil. The host reveals what it wants through separate, readable keys.

What to lock down

A good rule of thumb: the host owns anything global or authoritative – the winner, whose turn it is, spawned pickups – and clients own only their own id-keyed branch (their position, their inputs, their own score). Mark the global keys host-only and you have turned the ownership convention into something the engine guarantees.

Note the distinction: in click-race each player’s score lives inside its own id-keyed branch (net.state.players[net.id()].score) and is written by that client, so it stays open – it is the winner derived from those scores that is global and belongs to the host. Lock the authoritative fact, not the per-player data that feeds it.