The setup
Boxless games run as a shared display plus everyone's phone. The authority is a Lambda behind a WebSocket API: clients send actions, the handler mutates a state document in DynamoDB, and the new state fans out to every connection in the session. It's a clean architecture with one obvious hazard: "fans out to every connection" is doing a lot of work in that sentence.
Seventeen of our games have a backend. The other games in the catalog are single-player solitaires that never leave the browser, so there's nothing to leak. Of those seventeen, nine were shipping information to clients that weren't entitled to see it.
The finding that made this worth writing about
We already had a getPublicState function in every handler. That was the
point of it: take the full state, strip what players shouldn't see, broadcast the rest. It
is the obvious design and it is the one everyone reaches for.
It was not enough, and the reason generalizes. Alongside the state
snapshot, every broadcast carries an action payload, a state.action field
describing what just happened, so clients can animate it. That field goes out verbatim. It
never passes through getPublicState. Nobody decided that; it's just what happens
when you add a second channel to a system whose redaction lives on the first one.
So Gin Rummy had a properly redacted public state and was still broadcasting unredacted
hands inside its action payloads. And because the action is part of the persisted document, it
was writing them to DynamoDB in plaintext too. Our guessing game did the same thing with the
round's secret target angle: the public-state path withheld it correctly, and then the
GAME_STARTED and ROUND_STARTED actions embedded the whole freshly
created round object, target included.
The general rule: an audit of "what does each client actually see" has to cover
every channel that leaves the server, not just the one named public.
State snapshot, action payloads, error messages, whatever your framework attaches for
free.
We wrote the audit brief ourselves, and we still missed the action channel on the first pass. It took a second look at the persisted documents to find it.
The fix we rejected: encrypt the hidden state
This came up early and it's the first idea most people have, so it's worth killing carefully. The proposal: keep broadcasting everything, but encrypt the parts each client isn't allowed to read.
It doesn't work, for a reason that has nothing to do with the cipher. The client has to decrypt in order to render. That means the key ships inside the public game bundle on S3 and CloudFront, where anyone can read it, and even if you obfuscate the key beyond recognition, a breakpoint on the line after the decrypt call hands the attacker plaintext. You have not built a boundary; you have built a speed bump, at the cost of a key distribution problem and a pile of code that looks like security.
The cryptographically sound version is a commitment scheme: the server commits to a shuffle up front and reveals cards with proofs, so players can verify afterward that nothing was fabricated. That's real, and it's the right tool for poker where money is at stake. But note what it defends against: a dishonest server. We own the server. It runs our code in our AWS account. And the scheme still requires every piece of server-driven-reveal plumbing that the boring fix requires, on top of the crypto.
The boring fix is the whole fix: send each client only what it may see, and validate every move server-side. The second half matters as much as the first. Hiding state without validating moves doesn't remove the exploit, it just relocates it. A client that can't see your tableau but can still post an arbitrary "I moved these cards" action is exactly as broken. We shipped move validation for double solitaire in a separate pass for that reason.
The seam
The enabling change was in our SDK: a game handler can now supply
getPublicStateFor(state, viewer) alongside listPlayerIds(state),
and the platform resolves the right view per connection when it fans out. Go Fish is the
reference implementation, and it's about as small as this gets:
function getPublicStateFor(state, viewer) {
// Player view — spectator view PLUS the viewer's own hand.
const base = getPublicState(state);
return {
...base,
state: { ...base.state, myHand: state.hands[viewer.playerId] ?? [] },
};
}
The default getPublicState stays as the view for the shared display,
spectators, and any connection that isn't a seated player. Opponents' hands become
handCounts; a draw pile becomes a count instead of an ordered list.
One deliberate bit of API design: the handler factory throws at construction if you supply one of the two functions without the other. You cannot half-adopt the per-recipient path and end up with a personalized view that silently falls back to the broadcast one for players it can't enumerate. A partial adoption of a security seam is worse than none, because it looks done.
Redaction is replacement, not deletion
A detail we got wrong first: our initial pass concealed face-down cards by stripping their suit and rank. The client's card builder promptly dropped those cards on the floor and the visible stack counts went wrong: a player could see that the opponent's stock had eleven cards while the server thought it had twenty-four.
So a concealed card is now emitted as a fixed placeholder identity that resolves normally on the client and renders as a card back. Fixed is the operative word: it's the same placeholder for every concealed card, every player, every position, because any variation would itself be a channel.
const CONCEALED_CARD_PLACEHOLDER = { suit: 'C', rank: '2' };
function redactCard(card) {
if (card.faceUp) return card;
return { ...CONCEALED_CARD_PLACEHOLDER, faceUp: false };
}
The opposite case shows up in the guessing game, where the secret target is omitted from the payload rather than zeroed. A sentinel value would be worse than nothing there, because the client renders a target marker whenever a target angle is present, and a zeroed field would draw a marker at a real dial position. Which shape is correct depends entirely on what the renderer does with a missing field, so you have to look.
The leak we kept
One game in the catalog broadcasts full hands to every client, with a comment in the backend saying so. We left it alone.
It's designed for local multiplayer: everyone is in the same room, and each client filters the broadcast down to its own seat. The failure mode is a friend leaning over and peeking, which the game already survives, because that's what playing a board game with friends has always been. The complexity of a per-recipient view buys nothing there.
We're saying that out loud because "we fixed everything" would be the more flattering sentence and the less true one. Hidden-information disclosure is only a bug when the game's threat model has an adversary in it. Which brings up the cleanest framing we found in the whole sweep: chess, checkers, and a handful of others needed no changes at all. They're perfect-information games. Every legal fact is on the board by design, so there's nothing a leak could reveal. The audit surface is exactly the set of games where a player's knowledge is supposed to differ from the table's.
How to run this on your own game
1. List the channels, not the functions. Enumerate everything that leaves the server toward a client: state snapshots, action or event payloads, errors, the initial join response. Redaction has to be on all of them or it's on none of them.
2. Read the persisted document. Ours revealed the leak our code review missed. If a secret is sitting in plaintext in your database's state blob, ask how it got there. The answer is usually a channel you forgot.
3. Redact per recipient, at the fan-out. A single "public" view is a design that can only ever be as permissive as its most-privileged reader.
4. Then validate the moves. Concealment without server-side validation just changes which exploit works.
None of the nine leaks was exotic. Every one was a place where "the server has the state and sends it to the players" quietly became "the server sends all the state to all the players," and nothing in the type system, the tests, or the gameplay ever objected. Six of those games got their first backend tests as part of this work, which is its own small confession.
Boxless is our no-download party games platform: phones as controllers, one shared screen, nothing to install. It's live at boxless.games.