One engine, two information modes: how the role filter makes the deduction game¶
Here is a claim that sounds like marketing and is actually just true: this project ships two different games out of one game engine, and the only thing that decides which game you get is a filter the server runs when it sends state to your screen.
Memory Match is the classic flip-two-cards-and-remember game. Hidden Flips is a deduction game where you are tracking what your opponent saw, not what is on the board. They are the same engine. Nobody forked the code. The same machinery exposes Bluff's public claims ("I played three Kings") while keeping the server-only truth ("actually one King, one 4, one 7") private -- and that is the same engine pattern again, not a third special case.
If you are new to building this way -- new to leaning on an AI to write most of the code, and trying to figure out what you should be deciding versus what you can hand off -- this is a good worked example. The leverage in this design is not in any clever algorithm. It is in one structural decision that everything else falls out of. Finding and protecting decisions like that is most of the job. Let me show you the actual code, because the whole point is that it is small enough to read.
The split everything hangs on¶
A game's state lives in one little dataclass. Here is the entire thing, from the relevant engine module:
@dataclass(frozen=True) class GameState: public: dict[str, Any] = field(default_factory=dict) private: dict[Any, dict[str, Any]] = field(default_factory=dict)
That is it. Two dictionaries. public is the stuff anyone may see. private is keyed by player id -- each player's secret stuff lives under their own key, and there is a reserved "__server__" key for things only the server should ever hold (the actual card layout, for instance).
frozen=True means the state is immutable: every move returns a new GameState rather than editing the old one. That is a deliberate engine rule (record the moves, replay to reconstruct positions), but for this post the part that matters is the public/private line. Drawing that line once, in the smallest possible type, is the decision the rest of the design leans on.
Three views, one rule¶
The state has a public half and a private half. But "what may a TV see?" is a different question from "what may this one player see?" which is different again from "what may a referee see?" So every engine answers all three. From the engine base contract:
class GameEngine(ABC): @abstractmethod def public_view(self, state: GameState) -> dict[str, Any]: ...
def player_view(self, state: GameState, player_id: Any) -> dict[str, Any]:
return {
**self.public_view(state),
"private": {player_id: dict(state.private.get(player_id, {}))},
}
def full_view(self, state: GameState) -> dict[str, Any]:
return {
**self.public_view(state),
"private": {pid: dict(priv) for pid, priv in state.private.items()},
}
Read the default player_view slowly, because it is the entire idea in four lines: take the public view, then attach only this player's slice of the private dict. Not everyone's. Just state.private[player_id]. There is no code path in player_view that can reach into another player's secrets, because it only ever indexes its own player_id. full_view is the referee's view -- it attaches everyone's private slice -- and public_view attaches none.
public_view is abstract: every engine has to write its own, because only the engine knows what counts as "public" in that game. The other two have sensible defaults but an engine can override them when it needs to hide more (a referee, for example, still should not see the undealt deck mid-hand). The key thing: deciding what is hidden lives in the engine, next to the rules, not bolted onto the network layer that has no idea what game is even being played. The companion post Four roles, one game walks through why that placement matters and how the server dispatches these three views to five different client roles.
Memory Match: one config flag, two games¶
Now the payoff. Memory Match's engine has a mode -- and it is a config field, state.public["mode"], set to "public" or "hidden". Not a subclass. Not a fork. The engine's own module docstring says it out loud, in the relevant engine module:
Memory Match -- Public-Flips and Hidden-Flips share one engine. Mode is a config field (state.public["mode"]), not a code split.
When you flip a card, the engine always records the true value in the flipper's private memory log. What differs is what it puts in the public grid. Here is the branch, condensed from _apply_flip_card:
if mode == "public": new_grid[pos_key] = { "status": "flipped_temporary", "match_group_id": match_group_id, "display_value": display_value, # the room sees the card } else: new_grid[pos_key] = {"status": "flipped_temporary"} # only that a card flipped
And then public_view does the matching enforcement on the way out -- in hidden mode it strips any value that might have leaked into the public grid or the tap log, condensed here:
if mode == "hidden": filtered_grid = {} for pos, card in grid.items(): if card.get("status") == "flipped_temporary": filtered_grid[pos] = {"status": "flipped_temporary"} # position only else: filtered_grid[pos] = dict(card) filtered_tap_log = [{"position": e["position"], "player_id": e["player_id"]} for e in tap_log]
In public mode the room sees the dog card light up. In hidden mode the room sees that a square in the flipper's color lit up, but not what was on it -- only the flipper, through their own player_view, learns the value. Same flip action, same stored truth, same scoring, same terminal condition. The only thing that changed is which fields the server attaches to the broadcast.
That flag is the difference between a memory game a five-year-old can play and a deduction game adults choose when the kids are asleep -- where you win by remembering what your opponent turned over and inferring what they are hunting for. The kid-facing side of this engine, and why Hidden Flips is the same code path, gets its own treatment in Memory Match for a 5-year-old who can't read yet.
Bluff: the claim and the truth never travel together¶
The same split carries a much harder game. In Bluff you play cards face-down and claim a rank; you are allowed to lie. So every play has two layers: the public claim and the actual cards. The engine keeps them apart by construction. From the relevant engine module, a play records a claim into public history and the real cards into a server-only pile:
claim_entry = { # public: what you SAID "play_id": play_id, "player_id": action.player_id, "claimed_rank": state.public["next_required_rank"], "claimed_count": len(card_ids), } actual_entry = { # server-only: what you DID "play_id": play_id, "player_id": action.player_id, "card_ids": tuple(card_ids), } pile_actuals = list(new_private[SERVER_KEY]["pile_actuals"]) pile_actuals.append(actual_entry)
claim_entry goes into public["claim_history"]. actual_entry goes into private["__server__"]["pile_actuals"] -- the reserved server-only key. Now look at the two views. public_view exposes the claim history and never touches the actuals:
def public_view(self, state): return { ... "claim_history": tuple(dict(entry) for entry in state.public["claim_history"]), ... }
The only view that ever exposes the real cards is full_view (the referee / post-game review), which explicitly attaches pile_actuals:
def full_view(self, state): view = self.public_view(state) ... view["private"][SERVER_KEY] = { "pile_actuals": tuple(... for entry in state.private[SERVER_KEY]["pile_actuals"]) } return view
There is no player_view path and no public_view path that returns the actuals, because the bluff is the game. The truth living behind the reserved server key, reachable only through full_view, is the same public/private split Memory Match uses -- applied to a game where leaking it would be catastrophic instead of merely spoiling a flip. The design tradeoffs of building a lying game with no chat and no taunt buttons are their own story in A game built on lying, with no chat panel.
The rule that makes it real: filter on every broadcast¶
A pattern that is only usually applied is a leak waiting to happen. So this project makes it a load-bearing architecture rule:
Role-based view filtering runs on every broadcast. Every engine builds
public_view,player_view, andfull_view; private data for other players must never leave the server.
"Every broadcast" is the important half. The server never sends a player the raw GameState. When state changes, the broadcaster computes a per-recipient view first and sends that -- the TV gets public_view, each phone gets its own player_view, the referee gets full_view. (The filter lives in the broadcast layer; per-player tokens decide which role you are in the auth layer.) Because the filter runs on the way out every single time, "what is hidden" is not a property of any one message you have to remember to set -- it is a property of the pipeline. That is what lets a one-line config flag, or a reserved dict key, decide which game you are playing: the filter is guaranteed to honor the split on the way to your screen.
The honest edge: this only works because the server is boss¶
It would be dishonest to sell this as free. It rests on one assumption: the server is authoritative and clients are untrusted. Your phone never tells the server what the game state is; it only sends the action you want to take ("flip the card at row 2, column 3"). The server applies it, computes the new state, filters it per recipient, and broadcasts. State flows one direction.
That is the whole reason hiding works. The value of the card you did not flip is never on your device in hidden mode -- not hidden by CSS, not greyed out in a payload you could open dev-tools and read, simply never sent. A design where the client computes state and the server trusts it could not hide information this way: anything the client needs to render, it would have, and "hidden" would mean "present but please don't look." Against a determined player with a browser console, that is not hidden at all. Server-authoritative is what turns "we filtered the view" into "the secret physically isn't there."
It is also the boundary of the technique. If you ever could not trust the server itself -- player-versus-player stakes where the house might cheat -- you would need cryptographic move commitments, not a view filter. For a LAN board-game night where the server is just the host's machine refereeing fairly, trusting it is exactly right, and the filter is all you need.
What this means if you are learning to build this way¶
If you are new to directing an AI to write your code, the temptation is to spend your attention on the code the AI is generating. Reverse it. The code in this post is short and an AI can produce all of it. The decisions are not in the code; they are upstream of it:
- Find the one split everything hangs on. "Public versus private, drawn once, in the smallest type" is the decision. Get that right and two games, five client roles, and a future catalog of hidden-information games all fall out of it. Get it wrong -- say, by letting each game invent its own ad-hoc hiding -- and every new game re-litigates privacy and every one is a chance to leak. When you brief an AI, spend your words on that structure, not on the loop bodies it can already write.
- Turn the pattern into a rule the moment it works twice. Memory Match and Bluff both lean on the same split. That is the signal to promote it from "a thing we do" to "filter on every broadcast, no exceptions" -- an invariant the AI must honor in every future engine, and one a reviewer (human or machine) can check mechanically. A pattern you merely intend is a pattern that drifts.
- Name your trust boundary out loud. "The server is authoritative, clients are untrusted" is the load-bearing assumption, so it belongs in the brief, not in your head. Half the bugs in multiplayer hidden-information code come from a client quietly being trusted with something it should never have received.
None of those three are coding tasks. They are the part of the work that stays yours when the AI writes the rest. The role filter is a small piece of code that happens to be standing on top of all three -- which is exactly why it can turn one engine into two games.