chore: integrate multiplayer code (wip)

This commit is contained in:
Henri Bourcereau 2026-04-22 17:42:05 +02:00
parent 2838d59f30
commit 4f5e21becb
66 changed files with 6423 additions and 18 deletions

View file

@ -0,0 +1,17 @@
[package]
name = "backbone-lib"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", features = ["use-std"] }
bytes = "1.11"
ewebsock = "0.8"
protocol = { path = "../../server/protocol" }
futures = "0.3"
web-time = "1.1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
gloo-timers = { version = "0.3", features = ["futures"] }

View file

@ -0,0 +1,84 @@
//! Background task for the client (non-host) side of a session.
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use crate::platform::sleep_ms;
use crate::protocol::{parse_client_update, send_disconnect, send_rpc};
use crate::session::{BackendMsg, SessionEvent};
use crate::traits::SerializationCap;
pub(crate) async fn client_loop<A, D, VS>(
mut ws_sender: WsSender,
ws_receiver: WsReceiver,
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
event_tx: UnboundedSender<SessionEvent<D, VS>>,
) where
A: SerializationCap,
D: SerializationCap,
VS: SerializationCap,
{
loop {
// 1. Drain outbound actions.
loop {
match action_rx.try_next() {
Ok(Some(BackendMsg::Action(action))) => {
send_rpc(&mut ws_sender, &action);
}
Ok(Some(BackendMsg::Disconnect)) => {
send_disconnect(&mut ws_sender, false);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
Ok(None) => {
send_disconnect(&mut ws_sender, false);
return;
}
Err(_) => break,
}
}
// 2. Drain inbound state updates.
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
match parse_client_update::<VS, D>(data) {
Ok(updates) => {
for u in updates {
event_tx
.unbounded_send(SessionEvent::Update(u))
.ok();
}
}
Err(e) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
}
}
Some(WsEvent::Closed) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(
"Connection closed".to_string(),
)))
.ok();
return;
}
Some(WsEvent::Error(e)) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
Some(_) => continue,
None => break,
}
}
sleep_ms(2).await;
}
}

View file

@ -0,0 +1,211 @@
//! Background task for the host (game server) side of a session.
use std::collections::HashSet;
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use web_time::{Duration, Instant};
use crate::platform::sleep_ms;
use crate::protocol::{
ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state,
send_kick, send_reset,
};
use crate::session::{BackendMsg, SessionEvent};
use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
struct Timer {
id: u16,
fire_at: Instant,
}
pub(crate) async fn host_loop<A, D, VS, Backend>(
mut ws_sender: WsSender,
ws_receiver: WsReceiver,
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
event_tx: UnboundedSender<SessionEvent<D, VS>>,
rule_variation: u16,
host_state: Option<Vec<u8>>,
) where
A: SerializationCap,
D: SerializationCap + Clone,
VS: SerializationCap + Clone,
Backend: BackEndArchitecture<A, D, VS>,
{
let mut backend = host_state
.as_deref()
.and_then(|b| Backend::from_bytes(rule_variation, b))
.unwrap_or_else(|| Backend::new(rule_variation));
backend.player_arrival(0);
// Push initial state to UI immediately.
let initial = backend.get_view_state().clone();
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial)))
.ok();
let mut timers: Vec<Timer> = Vec::new();
let mut cancelled_timers: HashSet<u16> = HashSet::new();
let mut remote_player_count: u16 = 0;
loop {
let mut client_joined = false;
// 1. Drain local actions / detect session drop or disconnect request.
loop {
match action_rx.try_next() {
Ok(Some(BackendMsg::Action(action))) => {
backend.inform_rpc(0, action);
}
Ok(Some(BackendMsg::Disconnect)) => {
send_disconnect(&mut ws_sender, true);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
Ok(None) => {
// All senders dropped — session was dropped without calling disconnect().
send_disconnect(&mut ws_sender, true);
return;
}
Err(_) => break, // Channel empty; nothing pending.
}
}
// 2. Drain WebSocket events from the relay.
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
match parse_server_command::<A>(data) {
ToServerCommand::ClientJoin(id) => {
backend.player_arrival(id);
remote_player_count += 1;
client_joined = true;
}
ToServerCommand::ClientLeft(id) => {
backend.player_departure(id);
remote_player_count = remote_player_count.saturating_sub(1);
}
ToServerCommand::Rpc(id, payload) => {
backend.inform_rpc(id, payload);
}
ToServerCommand::Error(e) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
}
}
Some(WsEvent::Closed) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(
"Connection closed".to_string(),
)))
.ok();
return;
}
Some(WsEvent::Error(e)) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
Some(_) => continue, // Ignore Opened / text messages.
None => break, // No more events this iteration.
}
}
// 3. Fire elapsed timers.
let now = Instant::now();
let mut fired = Vec::new();
timers.retain(|t| {
if t.fire_at <= now {
fired.push(t.id);
false
} else {
true
}
});
for id in fired {
if !cancelled_timers.remove(&id) {
backend.timer_triggered(id);
}
}
// 4. Drain and process backend commands.
let commands = backend.drain_commands();
if commands.is_empty() && !client_joined {
sleep_ms(2).await;
continue;
}
let mut delta_batch: Vec<D> = Vec::new();
let mut reset = false;
for cmd in commands {
match cmd {
BackendCommand::TerminateRoom => {
send_disconnect(&mut ws_sender, true);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
BackendCommand::SetTimer { timer_id, duration } => {
// Cancel any existing timer with the same id, then re-arm.
timers.retain(|t| t.id != timer_id);
cancelled_timers.remove(&timer_id);
timers.push(Timer {
id: timer_id,
fire_at: Instant::now() + Duration::from_secs_f32(duration),
});
}
BackendCommand::CancelTimer { timer_id } => {
cancelled_timers.insert(timer_id);
}
BackendCommand::KickPlayer { player } => {
if remote_player_count > 0 {
send_kick(&mut ws_sender, player);
}
}
BackendCommand::ResetViewState => {
reset = true;
}
BackendCommand::Delta(d) => {
delta_batch.push(d);
}
}
}
if reset {
// Reset supersedes all pending deltas: send fresh full state.
let state = backend.get_view_state().clone();
if remote_player_count > 0 {
send_reset(&mut ws_sender, &state);
}
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state)))
.ok();
} else {
// Broadcast deltas, then notify local UI.
if remote_player_count > 0 && !delta_batch.is_empty() {
send_delta(&mut ws_sender, &delta_batch);
}
for d in delta_batch {
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d)))
.ok();
}
}
// Send full state to clients that joined this iteration.
if client_joined {
send_full_state(&mut ws_sender, backend.get_view_state());
}
sleep_ms(2).await;
}
}

View file

@ -0,0 +1,10 @@
pub mod session;
pub mod traits;
mod client;
mod host;
mod platform;
mod protocol;
pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};

View file

@ -0,0 +1,48 @@
use std::future::Future;
/// Spawns a background task.
/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required)
/// - Native: spawns an OS thread running `futures::executor::block_on`
#[cfg(target_arch = "wasm32")]
pub fn spawn_task<F>(fut: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(fut);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn_task<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
std::thread::spawn(move || {
futures::executor::block_on(fut);
});
}
/// Yields for approximately `ms` milliseconds.
/// - WASM: non-blocking yield via browser timer
/// - Native: blocks the current thread (safe on a dedicated background thread)
#[cfg(target_arch = "wasm32")]
pub async fn sleep_ms(ms: u32) {
gloo_timers::future::TimeoutFuture::new(ms).await;
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn sleep_ms(ms: u32) {
std::thread::sleep(std::time::Duration::from_millis(ms as u64));
}
/// Platform-agnostic bound for types that can be moved into a background task.
/// - WASM: only requires `'static` (single-threaded, no Send needed)
/// - Native: requires `Send + 'static`
#[cfg(target_arch = "wasm32")]
pub trait TaskBound: 'static {}
#[cfg(target_arch = "wasm32")]
impl<T: 'static> TaskBound for T {}
#[cfg(not(target_arch = "wasm32"))]
pub trait TaskBound: Send + 'static {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send + 'static> TaskBound for T {}

View file

@ -0,0 +1,159 @@
//! Wire protocol encoding/decoding helpers.
//!
//! Translates between raw WebSocket binary frames and typed Rust values using
//! postcard serialization and the message-type constants from the `protocol` crate.
use crate::traits::{SerializationCap, ViewStateUpdate};
use bytes::{Buf, BufMut, Bytes, BytesMut};
use ewebsock::{WsMessage, WsSender};
use postcard::{from_bytes, take_from_bytes, to_stdvec};
use protocol::{
CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE,
FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS,
SERVER_ERROR, SERVER_RPC,
};
// ---------------------------------------------------------------------------
// Inbound command types (relay → host)
// ---------------------------------------------------------------------------
pub enum ToServerCommand<A> {
ClientJoin(u16),
ClientLeft(u16),
Rpc(u16, A),
Error(String),
}
// ---------------------------------------------------------------------------
// Send helpers
// ---------------------------------------------------------------------------
fn send_binary(sender: &mut WsSender, data: &[u8]) {
sender.send(WsMessage::Binary(data.to_vec()));
}
pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> {
let bytes = to_stdvec(req).map_err(|e| e.to_string())?;
send_binary(sender, &bytes);
Ok(())
}
pub fn send_rpc<A: SerializationCap>(sender: &mut WsSender, action: &A) {
let raw = to_stdvec(action).expect("Failed to serialize RPC");
let mut buf = BytesMut::with_capacity(1 + raw.len());
buf.put_u8(SERVER_RPC);
buf.put_slice(&raw);
send_binary(sender, &buf);
}
pub fn send_delta<D: SerializationCap>(sender: &mut WsSender, deltas: &[D]) {
let serialized: Vec<u8> = deltas
.iter()
.flat_map(|d| to_stdvec(d).expect("Failed to serialize delta"))
.collect();
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(DELTA_UPDATE);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_full_state<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
let serialized = to_stdvec(state).expect("Failed to serialize full state");
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(FULL_UPDATE);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_reset<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
let serialized = to_stdvec(state).expect("Failed to serialize reset state");
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(RESET);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_kick(sender: &mut WsSender, player_id: u16) {
let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE);
buf.put_u8(CLIENT_GETS_KICKED);
buf.put_u16(player_id);
send_binary(sender, &buf);
}
pub fn send_disconnect(sender: &mut WsSender, as_host: bool) {
let msg = if as_host {
SERVER_DISCONNECTS
} else {
CLIENT_DISCONNECTS_SELF
};
send_binary(sender, &[msg]);
}
// ---------------------------------------------------------------------------
// Receive / parse helpers
// ---------------------------------------------------------------------------
/// Parses the relay's handshake response.
///
/// Returns `(player_id, rule_variation, reconnect_token)`.
pub fn parse_handshake_response(data: Vec<u8>) -> Result<(u16, u16, u64), String> {
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
HAND_SHAKE_RESPONSE => {
let player_id = bytes.get_u16();
let rule_variation = bytes.get_u16();
let token = bytes.get_u64();
Ok((player_id, rule_variation, token))
}
other => Err(format!("Unexpected handshake message id: {other}")),
}
}
pub fn parse_server_command<A: SerializationCap>(data: Vec<u8>) -> ToServerCommand<A> {
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()),
NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()),
CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()),
SERVER_RPC => {
let client_id = bytes.get_u16();
let payload: A =
from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload");
ToServerCommand::Rpc(client_id, payload)
}
other => ToServerCommand::Error(format!("Unknown server message id: {other}")),
}
}
pub fn parse_client_update<VS, D>(
data: Vec<u8>,
) -> Result<Vec<ViewStateUpdate<VS, D>>, String>
where
VS: SerializationCap,
D: SerializationCap,
{
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
DELTA_UPDATE => {
let mut result = Vec::new();
let mut remaining: &[u8] = &bytes;
while !remaining.is_empty() {
let (delta, rest): (D, &[u8]) =
take_from_bytes(remaining).map_err(|e| e.to_string())?;
remaining = rest;
result.push(ViewStateUpdate::Incremental(delta));
}
Ok(result)
}
FULL_UPDATE | RESET => {
let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?;
Ok(vec![ViewStateUpdate::Full(state)])
}
other => Err(format!("Unknown client message id: {other}")),
}
}

View file

@ -0,0 +1,266 @@
//! The public-facing session API.
//!
//! # Usage
//!
//! ```ignore
//! // Connect (async, returns after handshake completes)
//! let mut session: GameSession<MyAction, MyDelta, MyState> =
//! GameSession::connect::<MyBackend>(RoomConfig {
//! relay_url: "ws://localhost:8080/ws".to_string(),
//! game_id: "my-game".to_string(),
//! room_id: "room-42".to_string(),
//! rule_variation: 0,
//! role: RoomRole::Create,
//! reconnect_token: None,
//! })
//! .await?;
//!
//! // In a loop (e.g. Dioxus coroutine with futures::select!):
//! loop {
//! futures::select! {
//! cmd = ui_rx.next().fuse() => session.send_action(cmd),
//! event = session.next_event().fuse() => match event {
//! Some(SessionEvent::Update(u)) => view_state.apply(u),
//! Some(SessionEvent::Disconnected(reason)) | None => break,
//! }
//! }
//! }
//! ```
use ewebsock::{WsEvent, WsMessage};
use futures::StreamExt;
use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};
use protocol::JoinRequest;
use crate::client::client_loop;
use crate::host::host_loop;
use crate::platform::{TaskBound, sleep_ms, spawn_task};
use crate::protocol::{parse_handshake_response, send_join_request};
use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate};
// ---------------------------------------------------------------------------
// Public configuration types
// ---------------------------------------------------------------------------
/// Whether to create a new room (host) or join an existing one (client).
pub enum RoomRole {
Create,
Join,
}
/// Configuration required to connect to a game session.
pub struct RoomConfig {
/// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`).
pub relay_url: String,
/// Game identifier registered on the relay (e.g. `"tic-tac-toe"`).
pub game_id: String,
/// Room identifier shared between host and clients.
pub room_id: String,
/// Game mode/variant. Only used when `role` is `Create`.
pub rule_variation: u16,
pub role: RoomRole,
/// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh.
/// The value is the token returned by a previous successful handshake.
pub reconnect_token: Option<u64>,
/// Serialized backend state for host reconnect.
///
/// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in
/// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host
/// reconnects so the game can resume from the last known state.
/// Ignored for non-host reconnects and normal connections.
pub host_state: Option<Vec<u8>>,
}
/// Error returned by [`GameSession::connect`].
#[derive(Debug)]
pub enum ConnectError {
WebSocket(String),
Handshake(String),
}
impl std::fmt::Display for ConnectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"),
ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"),
}
}
}
// ---------------------------------------------------------------------------
// Internal message type (UI → background task)
// ---------------------------------------------------------------------------
pub(crate) enum BackendMsg<A> {
Action(A),
Disconnect,
}
// ---------------------------------------------------------------------------
// Session event (background task → UI)
// ---------------------------------------------------------------------------
/// Events emitted by the session to the UI.
pub enum SessionEvent<Delta, ViewState> {
/// A state update arrived from the host backend.
Update(ViewStateUpdate<ViewState, Delta>),
/// The session ended. `None` = clean disconnect, `Some(reason)` = error.
Disconnected(Option<String>),
}
// ---------------------------------------------------------------------------
// GameSession
// ---------------------------------------------------------------------------
/// A connected game session.
///
/// Created by [`GameSession::connect`]. Holds channels to the background task
/// that owns the WebSocket connection and (on host) the game backend.
pub struct GameSession<Action, Delta, ViewState> {
/// The player ID assigned by the relay server. Always `0` for the host.
pub player_id: u16,
/// The game mode/variant selected by the host.
pub rule_variation: u16,
/// `true` if this client is hosting the game (runs the backend).
pub is_host: bool,
/// Token to persist in localStorage for reconnect on page refresh.
/// Only meaningful for non-host players (player_id > 0).
pub reconnect_token: u64,
action_tx: UnboundedSender<BackendMsg<Action>>,
event_rx: UnboundedReceiver<SessionEvent<Delta, ViewState>>,
}
impl<A, D, VS> GameSession<A, D, VS>
where
A: SerializationCap + TaskBound,
D: SerializationCap + Clone + TaskBound,
VS: SerializationCap + Clone + TaskBound,
{
/// Connects to the relay server and performs the handshake.
///
/// Returns after the relay confirms the player ID and rule variation.
/// Spawns a background task that drives the WebSocket connection for the
/// lifetime of the session.
///
/// # Errors
/// Returns `Err` if the WebSocket cannot be opened or the handshake fails.
pub async fn connect<Backend>(config: RoomConfig) -> Result<Self, ConnectError>
where
Backend: BackEndArchitecture<A, D, VS> + TaskBound,
{
let create_room = matches!(config.role, RoomRole::Create);
// 1. Open WebSocket.
let (mut ws_sender, ws_receiver) =
ewebsock::connect(&config.relay_url, ewebsock::Options::default())
.map_err(|e| ConnectError::WebSocket(e.to_string()))?;
// 2. Wait for the Opened event (WASM WebSocket is async).
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Opened) => break,
Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)),
Some(WsEvent::Closed) => {
return Err(ConnectError::WebSocket("Connection closed".to_string()));
}
Some(_) => continue,
None => sleep_ms(1).await,
}
}
// 3. Send the join request.
let req = JoinRequest {
game_id: config.game_id,
room_id: config.room_id,
rule_variation: config.rule_variation,
create_room,
reconnect_token: config.reconnect_token,
};
send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?;
// 4. Wait for the handshake response.
let (player_id, rule_variation, reconnect_token) = loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
break parse_handshake_response(data).map_err(ConnectError::Handshake)?;
}
Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)),
Some(WsEvent::Closed) => {
// The relay may have sent a binary error frame just before
// closing. ewebsock can deliver Closed before that frame,
// so drain one more message to catch it.
if let Some(WsEvent::Message(WsMessage::Binary(data))) =
ws_receiver.try_recv()
{
break parse_handshake_response(data)
.map_err(ConnectError::Handshake)?;
}
return Err(ConnectError::Handshake(
"Connection closed during handshake".to_string(),
));
}
Some(_) => continue,
None => sleep_ms(1).await,
}
};
// The relay assigns player_id == 0 exclusively to the host.
let is_host = player_id == 0;
// 5. Set up channels between the UI and the background task.
let (action_tx, action_rx) = mpsc::unbounded::<BackendMsg<A>>();
let (event_tx, event_rx) = mpsc::unbounded::<SessionEvent<D, VS>>();
// 6. Spawn the background event loop.
if is_host {
spawn_task(host_loop::<A, D, VS, Backend>(
ws_sender,
ws_receiver,
action_rx,
event_tx,
rule_variation,
config.host_state,
));
} else {
spawn_task(client_loop::<A, D, VS>(
ws_sender,
ws_receiver,
action_rx,
event_tx,
));
}
Ok(GameSession {
player_id,
rule_variation,
is_host,
reconnect_token,
action_tx,
event_rx,
})
}
/// Sends a game action to the backend (fire-and-forget).
pub fn send_action(&self, action: A) {
self.action_tx
.unbounded_send(BackendMsg::Action(action))
.ok();
}
/// Awaits the next session event.
///
/// Returns `None` if the background task has exited (i.e. the session is
/// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))`
/// before the channel closes.
pub async fn next_event(&mut self) -> Option<SessionEvent<D, VS>> {
self.event_rx.next().await
}
/// Signals the background task to send a graceful disconnect message and
/// shut down. Consumes the session.
pub fn disconnect(self) {
self.action_tx
.unbounded_send(BackendMsg::Disconnect)
.ok();
}
}

View file

@ -0,0 +1,97 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
/// Marker trait for types that can be serialized with postcard.
pub trait SerializationCap: Serialize + DeserializeOwned {}
impl<T> SerializationCap for T where T: Serialize + DeserializeOwned {}
/// State updates delivered to the frontend for rendering.
///
/// - [`Full`](Self::Full): Immediately set all visual state, no animation.
/// - [`Incremental`](Self::Incremental): Apply with animation/transition.
pub enum ViewStateUpdate<ViewState, DeltaInformation> {
/// Complete game state snapshot. Received on join or after a reset.
Full(ViewState),
/// Incremental state change for animated transitions.
Incremental(DeltaInformation),
}
/// Commands emitted by the game backend to control the session.
pub enum BackendCommand<DeltaInformation>
where
DeltaInformation: SerializationCap,
{
/// Incremental state change to be broadcast to all frontends.
Delta(DeltaInformation),
/// Signals a complete reset: discard queued deltas, broadcast fresh full state.
ResetViewState,
/// Forcibly removes a player from the session.
KickPlayer { player: u16 },
/// Schedules a callback after `duration` seconds. Overwrites any existing
/// timer with the same `timer_id`.
SetTimer { timer_id: u16, duration: f32 },
/// Cancels a previously scheduled timer. No-op if already fired or not set.
CancelTimer { timer_id: u16 },
/// Shuts down the entire room and disconnects all players.
TerminateRoom,
}
/// The contract for game-specific server logic.
///
/// Implement this on the host side. The session calls these methods in response
/// to network events and drives `drain_commands` to collect outbound messages.
///
/// # Type Parameters
/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`)
/// * `DeltaInformation` — Incremental state changes for animations
/// * `ViewState` — Complete game snapshot for syncing new clients
pub trait BackEndArchitecture<ServerRpcPayload, DeltaInformation, ViewState>
where
ServerRpcPayload: SerializationCap,
DeltaInformation: SerializationCap,
ViewState: SerializationCap + Clone,
{
/// Creates a new game instance. `rule_variation` selects the game mode.
fn new(rule_variation: u16) -> Self;
/// Attempt to restore a previously running game from serialized bytes.
///
/// Called when the host reconnects after a page refresh. The bytes are the
/// game-specific snapshot produced by the app layer (via `serde_json` or
/// similar) and stored in localStorage.
///
/// Return `None` if restoration is not supported or the bytes are invalid —
/// the caller falls back to `new(rule_variation)`.
fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option<Self>
where
Self: Sized,
{
None
}
/// Called when a player connects. Player will receive a full state snapshot
/// automatically after this returns.
fn player_arrival(&mut self, player: u16);
/// Called when a player disconnects.
fn player_departure(&mut self, player: u16);
/// Called when a player sends a game action.
fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload);
/// Called when a previously scheduled timer fires.
fn timer_triggered(&mut self, timer_id: u16);
/// Returns the complete current game state.
fn get_view_state(&self) -> &ViewState;
/// Collects and clears all pending commands since the last drain.
///
/// Implement with `std::mem::take(&mut self.command_list)`.
fn drain_commands(&mut self) -> Vec<BackendCommand<DeltaInformation>>;
}