jonnify·codemojex /codemojex/the-live-surface/channels-and-pubsub

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.

The live loop — select a leg
client Telegram UserSocket connect · SES RoomChannel game:<id> PubSub topic game:<id> connect → join → subscribe ScoreWorker classic ← push "scored" · per attempt sealed close golden ← push "revealed" · one fat event
join → 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.
§1

The authenticated socket

user_socket.ex · connect/3

The 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.

echo/…/codemojex_web/channels/user_socket.ex — the SES-gated connect source
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.

§2

One topic, no per-room process

room_channel.ex

join/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.

echo/…/codemojex_web/channels/room_channel.ex — join, the three pushes, refresh source
@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).

§3

Classic and golden fan-out

game.ex · rooms.ex

What 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.

What crosses the wire — select a mode
classic scored scored scored scored settled each: {player, pct, eff} — a name and a score golden state + timer only — no per-guess events revealed secret · board · payouts
classic → per finished attempt: {: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.
echo/…/codemojex/rooms.ex — the one fat revealed event source
# 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.

§4

References

sources · related

Sources

Related