Codemojex · C9.2
One model, two transports.
The island’s whole state layer is one Effector model, createGameModel. Full GameProps land in a $props store; derived $view, $leaderboard, $history, $me are the fine-grained subscription surface. The model is transport-agnostic — two thin components plug it onto the two real transports, and GameEdge never learns which one it rode.
Grounding — channel/model.ts · channel/BridgeGame.tsx · channel/PhoenixGame.tsx · liveview_boot/channels.md §3–5.
$props.map(p => p?.view) — the prize pool, the derived prize_usd, the status and timer. A useUnit($view) consumer re-renders when the view slice changes identity, not on every props tick.createGameModel
model.tsThe model mirrors the GameLive contract exactly: the join reply and every game:update pass the isGameProps gate ("view" in value) and land in $props; every other frame becomes a serverEvent, sampled by name into the typed events.{guessRejected, revealed, goldenWin}. Nothing in it knows what transport sits underneath.
// join reply AND "game:update" frames -> $props
sample({ clock: chan.joined, filter: isGameProps, target: propsReceived });
sample({ clock: chan.message,
filter: (m) => m.event === "game:update" && isGameProps(m.payload),
fn: (m) => m.payload, target: propsReceived });
const $view = $props.map((p) => p?.view ?? null);
const $leaderboard = $props.map((p) => p?.leaderboard ?? []);
Derived stores are the contract for the next screen: a prize widget on useUnit($view) touches nothing else; a leaderboard row can go finer with useStoreMap($leaderboard, rows => rows[i]) so one player’s score change re-renders one row. State stays outside React — which is also what lets a hot swap rebuild the tree without losing the game.
Two adapters, one model
BridgeGame · PhoenixGameThe same createGameModel drives the screen on both transports. BridgeGame lifts the LiveView host bridge into ChannelLike via bridgeChannel — the initial data-props ride as a synthetic join reply. PhoenixGame opens one Socket and joins game:<id>. Select a transport.
BridgeGame has no socket of its own — the boot owns it. bridgeChannel(bridge, { joinReply }) resolves ok synchronously with the initial props, seeding $props from getState() ?? initial so a re-bind after a hot swap resumes from the latest props.// BridgeGame.tsx — the LiveView transport model.chan.bind(bridgeChannel(bridge, { joinReply: seed }), GAME_INBOUND); // PhoenixGame.tsx — the real-channel transport const channel = socket.channel(`game:${game}`, {}); model.chan.bind(channel as unknown as ChannelLike, GAME_INBOUND);
References
sources · relatedSources
- Effector — Store and derived
.mapthe$propsstore and its derived slices - effector-react — useUnit / useStoreMapthe fine-grained, per-slice React subscription
- Phoenix — the JavaScript clientthe
Socketand channelPhoenixGamejoins
Related
- C7 — The Live Surface on Phoenixthe RoomChannel and UserSocket the channel transport joins
- C9.1 — The crossinghow props reach
$props - C9 — The Road to GameIslandthe chapter hub