Frontend
frontend is the browser client of MCG. It is written in Rust, compiled to WebAssembly (WASM), and renders the user interface with egui/eframe. The crate contains application orchestration, screen implementations, reusable widgets, browser integration, and the WebSocket boundary to the local backend.
The crate is named frontend. It is distinct from the Card Game Description Language compiler crate named front_end.
Module Layout
The source tree separates application-wide orchestration from screens and reusable widgets:
frontend/src/
├─ app.rs # FrontendApp, FrontendInterface, FrontendEvent
├─ app/
│ ├─ state.rs # FrontendState
│ └─ websocket.rs # WebSocketConnection and MessageSender
├─ screens.rs # Screen module declarations and re-exports
├─ screens/ # Concrete screens and screen-local state
├─ widgets/
│ ├─ mod.rs # Widget module declarations
│ ├─ screen.rs # Screen traits, typed IDs, and registry
│ ├─ card.rs # Card traits and implementations
│ ├─ field.rs # Field traits and implementations
│ ├─ camera.rs # Browser camera lifecycle and frame capture
│ ├─ qr_scanner.rs # QR decoding UI
│ ├─ hardcoded_cards.rs # Preconfigured decks
│ └─ theme.rs # Theme constants and DPI helpers
├─ router.rs # Browser URL routing
└─ lib.rs # WASM entry pointArchitecture & API
Application Types
FrontendApp@ frontend/src/app.rs implementseframe::App. It owns the current screen, screen instances, global frontend state, router, WebSocket connection, and inbound event channels.FrontendInterface@ frontend/src/app.rs is the capability boundary passed to screens. Its fields are private. Screens use methods such aschange_screen,send_msg,connect,create_game, andstart_gameinstead of accessing the owningFrontendAppor raw WebSocket.FrontendEvent@ frontend/src/app.rs represents deferred application-level operations. It currently supports typed screen changes, starting the generic drag-and-drop game, and exiting a game.FrontendState@ frontend/src/app/state.rs contains application-wide data: player name, server address, DPI settings, theme selection, and the screen registry.
Screen Traits and Registry
ScreenWidget@ frontend/src/widgets/screen.rs defines the runtime behavior of a screen:uirenders the screen and handles user input.on_messagereceives typedBackend2FrontendMsgvalues dispatched byFrontendApp.on_exitreleases screen-owned resources before navigation.
ScreenDefdefines compile-time metadata and a factory for a screen.ScreenIdwraps Rust'sTypeId. Navigation therefore uses screen types rather than path strings.ScreenRegistrymapsScreenIds and URL paths to metadata and factories. Screen instances are created lazily.impl_screen_def!generates aScreenDefimplementation for screen types that implementDefault.
Card and Field Traits
CardEncoding@ frontend/src/widgets/card.rs maps a card to the encoding required by mental-card-game operations and exposes masking/unmasking behavior.CardConfig@ frontend/src/widgets/card.rs defines visual card rendering, cardinality, encoding width, and natural size.FieldWidget@ frontend/src/widgets/field.rs defines a renderable card container.SimpleCard,DirectoryCardType, andSimpleFieldare the provided implementations.SimpleFieldsupports stacked and horizontal layouts as well as selection and drag-and-drop.hardcoded_cards@ frontend/src/widgets/hardcoded_cards.rs provides preconfigured decks backed by media files served by the backend.
Initialization & Lifecycle
The browser calls the #[wasm_bindgen]-exported start function in frontend/src/lib.rs. It installs image loaders, creates a FrontendApp, and starts the eframe web runner:
#[wasm_bindgen]
pub fn start(canvas: HtmlCanvasElement) -> Result<(), JsValue> {
let init = Box::new(|cc: &eframe::CreationContext| {
install_image_loaders(&cc.egui_ctx);
let app = FrontendApp::new(cc.egui_ctx.clone());
Ok(Box::new(app) as Box<dyn eframe::App>)
});
start_game(canvas, init)
}FrontendApp implements the eframe::App trait. Its update method is part of eframe's application lifecycle and is invoked by the framework whenever a new UI frame must be produced. It therefore serves as the frontend's main loop.
For each FrontendApp::update call, the application:
- processes pending input and messages,
- renders the current UI,
- and applies queued application events.
State Management
Only application-wide data belongs in FrontendState. Screen-specific rendering data, edit buffers, connection feedback, and game projections are owned by their corresponding ScreenWidget.
For example, PokerOnlineScreen owns its Option<PokerStatePublic> and connection manager. QR screens own their scanners and Epoch values. These values are no longer kept in a global client-state monolith.
FrontendInterface::state() and state_mut() currently expose FrontendState; further narrowing this API into purpose-specific accessors is tracked by the Frontend State Decoupling milestone.
Typed Navigation and Screen Events
Screens request navigation by target type:
impl FrontendInterface {
pub fn change_screen<T: ScreenDef + 'static>(&mut self) { ... }
}FrontendInterface converts the target type into a ScreenId and queues a FrontendEvent::ChangeScreen. FrontendApp validates it against the registry, calls on_exit on the old screen, and updates the router with the registered path.
This keeps URL strings in ScreenMetadata and removes route "magic strings" from screen logic.
Networking
Connection Ownership
A single WebSocketConnection @ frontend/src/app/websocket.rs is owned by FrontendApp. Screens never receive the raw web_sys::WebSocket. They interact through FrontendInterface methods or the narrow MessageSender trait.
Connecting installs application-level onopen, onmessage, onerror, and onclose callbacks.
Incoming Messages
The browser callback deserializes text frames into Backend2FrontendMsg and sends them through a standard MPSC channel:
web_sys::WebSocket::message
│
▼
mpsc::Sender<Backend2FrontendMsg>
│
▼
FrontendApp::dispatch_messages
│
▼
active ScreenWidget::on_messageScreens neither register listener closures nor select an "active listener". FrontendApp owns routing and dispatches each queued message to the active screen.
Error and close events use separate MPSC channels and are logged by FrontendApp.
Sending Messages
Screens send typed protocol values through the interface:
impl FrontendInterface {
pub fn send_msg(&mut self, msg: Frontend2BackendMsg) { ... }
}Specialized operations such as create_game are also exposed. The serialized WebSocket payload remains private to WebSocketConnection.
Event-Driven Repainting
The application does not request a repaint unconditionally on every update. Successful incoming WebSocket messages call egui::Context::request_repaint. Time-dependent screens and widgets have to request their own repaint while active; for example, the QR scanner repaints while its camera popup is open and the QR transmitter schedules its next frame.
Drag & Drop
The generic game demo uses egui drag-and-drop while keeping widget rendering separate from game-state mutation.
SimpleFieldinfrontend/src/widgets/field.rspublishes aDNDSelector::Indexpayload and records the local card index.Gameinfrontend/src/screens/game.rstranslates the local index into a logical source or destination (PlayerorStack).- Once both endpoints are present,
GameState::move_cardmutates the fields and the temporary drag/drop state is cleared.
impl ScreenWidget for Game<DirectoryCardType> {
fn ui(&mut self, app_interface: &mut FrontendInterface, ui: &mut egui::Ui, frame: &mut Frame) {
// ...
if let (Some(source), Some(destination)) = (self.drag, self.drop) {
self.game_state.move_card(source, destination);
self.drag = None;
self.drop = None;
}
// ...
}
}Camera and QR Scanning
QR support is split into two reusable widgets:
Camera@ frontend/src/widgets/camera.rs owns theMediaStream, video element, canvas, frame texture, and front/back camera selection. Starting and stopping are safe across asynchronous camera initialization.QrScanner@ frontend/src/widgets/qr_scanner.rs renders the scanner popup, captures every frame for preview, and attempts QR decoding every fifth frame.
QrDecodeTarget selects text or binary output:
use crate::widgets::qr_scanner::{QrDecodeTarget, QrScanner};
#[derive(Default)]
struct MyScreen {
scanner: QrScanner,
text: String,
bytes: Vec<u8>,
}
impl ScreenWidget for MyScreen {
fn ui(&mut self, app_interface: &mut FrontendInterface, ui: &mut egui::Ui, frame: &mut Frame) {
// Text mode closes after the first successful decode.
self.scanner.button_and_popup(
ui,
ui.ctx(),
QrDecodeTarget::String(&mut self.text),
);
// Binary mode remains open so a stream of QR frames can be collected.
self.scanner.button_and_popup(
ui,
ui.ctx(),
QrDecodeTarget::Binary(&mut self.bytes),
);
}
}Extensibility
How to Add a Screen
Create the screen under frontend/src/screens/, implement ScreenWidget, and define its metadata. The macro is the shortest option for default-constructible screens:
use egui::Ui;
use eframe::Frame;
#[derive(Default)]
pub struct MyScreen;
impl ScreenWidget for MyScreen {
fn ui(&mut self, interface: &mut FrontendInterface, ui: &mut Ui, frame: &mut Frame) {
ui.label("Hello from MyScreen!");
if ui.button("Back").clicked() {
interface.change_screen::<crate::screens::MainMenu>();
}
}
}
crate::impl_screen_def!(
MyScreen,
"/myscreen",
"My Screen",
"🌟",
"A custom example screen",
true
);Furthermore, new screens should be exported in frontend/src/screens.rs and have to be registered:
impl ScreenRegistry {
pub fn new() -> Self {
let mut reg = Self { .. };
// Register all screens by calling their ScreenDef implementations
reg.register::<MainMenu>();
reg.register::<MyScreen>();
// ...
reg
}
}Implement ScreenWidget::on_message when the screen wants to consume backend messages and on_exit when it owns resources that must be released during navigation.
How to Add a Card Type
Implement CardEncoding for internal representation and CardConfig for their visual representation:
struct MyCard {
id: usize,
masked: bool,
}
impl CardEncoding for MyCard {
// Implement `t`, `is_masked`, `mask`, and `open`.
}
struct MyCardVisuals;
impl CardConfig for MyCardVisuals {
// Implement `img`, `T`, `w`, and `natural_size`.
}How to Add a Field Layout
SimpleField provides horizontal and stacked layouts. A new layout can be implemented as another FieldWidget:
struct VerticalField {
// cards and layout configuration
}
impl FieldWidget for VerticalField {
fn draw(&self) -> impl egui::Widget {
move |ui: &mut egui::Ui| {
// Draw the field and return its response.
ui.response()
}
}
}