Codemojex · C7.3 · a dive of The Live Surface on Phoenix
Channels and PubSub.
The live half of the surface is one PubSub and a thin channel. A client joins game:<id>; the join subscribes the channel to the matching topic and replies with the game view — never the secret. Classic: every finished attempt fans out as a scored push carrying a name and a score. Golden: state and timer only in-flight, then one fat revealed event at the sealed close. There is no per-room process — a large field of idle rooms costs nothing.
Grounding — codemojex_web/channels/user_socket.ex (connect/3, the SES gate) · room_channel.ex (join/3 + the three pushes) · the :scored broadcast in game.ex · broadcast_revealed/4 in rooms.ex · {Phoenix.PubSub, name: Codemojex.PubSub} in the supervision tree · privacy.stories.md.
connect carries the SES as a body field (session / token) — Codemojex.Session.resolve either assigns :player or refuses the connection. join("game:" <> game) subscribes the channel to the matching PubSub topic and replies {:ok, %{view: Codemojex.game_view(game)}} — the view, never the secret. No per-room process starts.The authenticated socket
user_socket.ex · connect/3The connection is refused before any channel exists. CodemojexWeb.UserSocket declares one channel route — channel "game:*", CodemojexWeb.RoomChannel — and gates connect/3 on the session: Codemojex.Session.resolve(params["session"] || params["token"]) either assigns :player or returns :error. The SES travels as a body field, kept out of a query string so it does not land in proxy logs; revocation is immediate because the session lives in Valkey behind the auth floor (C7.1). Only an authenticated socket carries a non-nil id/1 — "player_socket:" <> plr.
defmodule CodemojexWeb.UserSocket do
use Phoenix.Socket
channel "game:*", CodemojexWeb.RoomChannel
# cm.4: authenticate the socket by the SES connect-param. The SES is a body field
# (`session`, or `token`), kept out of a query string so it does not land in proxy
# logs. A bad / missing / revoked SES → :error (the connection is refused); only an
# authenticated socket carries an assigned :player and a non-nil id/1.
@impl true
def connect(params, socket, _connect_info) do
case Codemojex.Session.resolve(params["session"] || params["token"]) do
{:ok, %{plr: plr}} -> {:ok, assign(socket, :player, plr)}
{:error, _} -> :error
end
end
@impl true
def id(%{assigns: %{player: plr}}) when is_binary(plr), do: "player_socket:" <> plr
def id(_socket), do: nil
end
The auth floor is C7.1: the handshake POST /api/auth/:platform is the sole SES mint; the socket only resolves what the handshake minted. Identity is verified, never trusted.
One topic, no per-room process
room_channel.exjoin/3 is the subscription: joining game:<id> subscribes the channel to the matching PubSub topic and replies {:ok, %{view: Codemojex.game_view(game)}} — the privacy-safe view, never the secret; for a golden game, the public commitment but never its preimage, with the score totals withheld until revealed_ms is set. The inbound surface is one message: "refresh" re-reads the view and the top-20 leaderboard on demand. Everything else arrives as a handle_info from the topic and leaves as a push. The fan-out is Phoenix.PubSub, started as Codemojex.PubSub in the supervision tree right after the Repo; a room with a thousand watchers and a room with none cost the same — zero processes beyond the channels the clients themselves hold open.
@impl true
def join("game:" <> game, _params, socket) do
{:ok, %{view: Codemojex.game_view(game)}, assign(socket, :game, game)}
end
@impl true
def handle_info({:scored, payload}, socket) do
push(socket, "scored", payload) # classic — per finished attempt
{:noreply, socket}
end
@impl true
def handle_info({:revealed, payload}, socket) do
push(socket, "revealed", payload) # golden — the one fat close
{:noreply, socket}
end
@impl true
def handle_info({:golden_win, payload}, socket) do
push(socket, "golden_win", payload) # a Golden Room's live close
{:noreply, socket}
end
@impl true
def handle_in("refresh", _params, socket) do
game = socket.assigns.game
reply = %{
view: Codemojex.game_view(game),
leaderboard: Enum.map(Codemojex.leaderboard(game, 20), fn {p, s} -> %{player: p, score: s} end)
}
{:reply, {:ok, reply}, socket}
end
The acceptance catalog pins the reply’s shape: “there is no :secret key anywhere in the view — only the keyboard snapshot”, and every leaderboard row is a {player, score} pair with no emojis attached (privacy.stories.md).
Classic and golden fan-out
game.ex · rooms.exWhat crosses the wire differs by mode, and the difference is the privacy design. A classic attempt fans out the moment the worker scores it: {:scored, %{game, player, pct, eff}} — a display name, the attempt’s percentage, and the best board total Board.record/3 returned. A golden game broadcasts nothing per guess — the blind mode suppresses the feedback at the source, so no event exists to leak a score — and settles its whole story into one revealed event at the sealed close. A Golden Room tournament adds a third message on the same topic: {:golden_win, %{game: game, diamonds: won}}, the total paid and nothing about any guess.
{:scored, %{game, player, pct, eff}} → push "scored". player is a display name, pct the attempt’s percentage, eff the best board total Board.record/3 returned — a name and a score, never the code, never the guess. A perfect 600 closes the game there and then.# The ONE fat `revealed` event (V-13): the first and only results a blind client
# receives — the now-exposed secret + nonce + commitment, the final board, the
# top-K payouts, and the terminal state. The preimage was sealed until this point.
defp broadcast_revealed(game, settled, board, payouts) do
Phoenix.PubSub.broadcast(
Codemojex.PubSub,
"game:" <> game,
{:revealed,
%{
game: game,
secret: Map.get(settled, :secret),
nonce: Map.get(settled, :nonce),
commitment: Map.get(settled, :commitment),
board: Enum.map(board, fn {p, s} -> %{player: p, score: s} end),
payouts: Enum.map(payouts, fn {p, d} -> %{player: p, diamonds: d} end),
state: Map.get(settled, :status)
}}
)
end
The reveal closes the commitment made at open: with secret and nonce public, any player recomputes SHA-256(secret ‖ nonce) and checks it against the commitment the join view carried all along. In-flight, the events that fan out carry a name and a score, never the code or the guess — privacy.stories.md is the acceptance catalog.
References
sources · relatedSources
- Phoenix — Channelsthe live push and the per-game topics
- Kreps — The Logthe topic as an ordered stream of events about one game
- Wikipedia — Commitment schemethe hiding and binding properties the golden reveal closes out
Related
- C7 — The Live Surface on Phoenixthe chapter hub
- C4 — Scoring and Settlementwhere the scored and revealed events are produced
- EchoMQ — the protocolthe bus the scoring rides before the broadcast
- BCS — the architecture lawthe privacy boundary as a structural law