SCUA

Manual

Audience gates

An online game keeps the authoritative state on the server and sends a cut-down view to each client. Some fields are safe to send; some must never cross the wire — a client that could see another player's hand, or predict the next bonus, could cheat. The usual way to handle this is a hand-written "remember to strip these fields before sending" function, which is exactly the kind of code that rots: add a field, forget to strip it, leak it.

Audience gates move that decision onto the field itself. You mark a field with the audiences allowed to see it, and project builds the view — including only the fields the audience is allowed. There's no strip-list to keep in sync.

#Audiences and gates

An audience is just an enum — the set of viewers you distinguish:

enum Audience { Server, Client, Spectator }

A gate is a named set of audiences that may see a field:

gate Owner    { Audience.Server, Audience.Client }
gate Internal { Audience.Server }
gate Visible  { Audience.Server, Audience.Client, Audience.Spectator }

A gate lists the audiences it admits. Internal admits only the server; Visible admits everyone.

#Gating a field

Mark a field with gated GateName. A field with no gate is open — always included (open by default):

record Player {
  name:        string,                 -- ungated: everyone sees it
  health:      int    gated Visible,   -- public
  inventory:   { int } gated Owner,    -- only the owning client (and the server)
  bonus_timer: int    gated Internal,  -- server-only secret
}

#Projecting a view

project(value, RecordType, audience) returns a copy of value keeping only the fields that audience is allowed to see. A gated field the audience can't see comes back nil:

let aria: Player = { name = "Aria", health = 80, inventory = [3, 7, 12], bonus_timer = 4213 }

let for_client = project(aria, Player, Audience.Client)
print(`{for_client.name} {for_client.inventory} {for_client.bonus_timer}`)
$ scua project.scua
Aria [3, 7, 12] nil

The client sees its own inventory (it's in the Owner gate) but not bonus_timer (server-only). The audience argument must be an enum value — the compiler rejects anything else.

You send project(state, Player, audience) over the wire — often durable.encode'd — and the secret simply isn't in the bytes. Nothing downstream has to remember to remove it.

#Projection reaches the whole tree

Gating isn't just for top-level fields. A gated field nested inside a sub-record, or inside the elements of a { Record } collection, is stripped too — project recurses the whole structure and deep-copies, so editing a view never touches the original:

record Combat { stance: string, ai_intent: int gated Internal }   -- server-only
record Card   { face: string, value: int gated Owner }            -- a face-down value

record Player {
  name:   string,
  combat: Combat,        -- a nested record with a server-only field
  hand:   { Card },      -- a collection whose elements hide a field
}

Projected for a spectator, combat.ai_intent and every hand[i].value come back nil; projected for the server, they're all present. See the full runnable examples/audience-gates.scua — it projects one authoritative Player for three audiences and prints what each sees.

#What gates do and don't promise

Gates strip the fields you mark, all the way down. They are a clear, in-the-type record of who sees what, and they remove the most common leak — the forgotten field. Two honest limits:

  • Open by default. A field you forget to gate is visible to everyone. The compiler reports a record's gate visibility so you can audit it, but it won't refuse an ungated field. Gate deliberately; for a record where most fields are sensitive, gate every field.
  • No derived-leak protection. Gates strip stored fields. If you compute a public field from a secret one (a score that encodes hidden state), the projection can't know — that's your design to get right.

#See also