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
- Records and gradual types — the record types gates attach to.
- Enums — the substrate an
Audienceis built from. - Save, load, and migrate — encoding a projected view for the wire.