jonnify·codemojex /codemojex/road-to-gameisland/one-model-two-transports

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.

The Effector fan-out — one props tick, four derived slices; select a store
game:update $props the full GameProps $view prize_pool · status $leaderboard named, is_me rows $history own guesses $me the PLR id
$view → $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.
§1

createGameModel

model.ts

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

mercury/…/game/src/channel/model.ts source
// 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.

§2

Two adapters, one model

BridgeGame · PhoenixGame

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

The same model, two plugs — select a transport
createGameModel() transport-agnostic · $props + events BridgeGame · bridgeChannel LiveView host bridge → ChannelLike data-props = synthetic join reply PhoenixGame · Socket one Socket, join game:<id> model.chan.bind(channel, GAME_INBOUND)
LiveView bridge → 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.
the two adapters · BridgeGame.tsx · PhoenixGame.tsx source
// 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);
§3

References

sources · related

Sources

Related