From a726d9f5fb56e1fd7983c5ac806d408ad78daa86 Mon Sep 17 00:00:00 2001
From: srdusr <99972264+srdusr@users.noreply.github.com>
Date: Thu, 11 Sep 2025 21:53:00 +0200
Subject: Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
still filling, or opens one. Players never see a room code; joining by
code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
empty lobby. They only ever join quick-match rooms, never a room opened
by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
two bots are never near each other's pace, and they stall to correct
mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
which is what reset clients mid-countdown.
Typing languages
- 16 languages for the generated-word modes, each with its own
high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
difficulty tier instead of falling back to English words.
Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
accumulated byte-length deltas while total_correct_characters compared a
char index against that byte count. Equal on ASCII, so it went unnoticed;
a correctly typed Spanish passage scored 6%. The old byte slicing would
also have panicked if an index landed inside a multi-byte character.
Rewritten char-based, with regression tests.
Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
by necessity, since the typing input is a single-line field.
Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
column is a whole number of characters wide so wrapping cannot leave a
permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
carried over from the typing screen, and a graph with a separate error
axis, axis titles including seconds, and smoothed lines.
---
crates/steam/Cargo.toml | 21 +++
crates/steam/assets/sprites/pulse.png | Bin 0 -> 495 bytes
crates/steam/assets/sprites/pulse.svg | 5 +
crates/steam/assets/sprites/racer_marker.png | Bin 0 -> 371 bytes
crates/steam/assets/sprites/racer_marker.svg | 6 +
crates/steam/assets/sprites/warden_layer.png | Bin 0 -> 309 bytes
crates/steam/assets/sprites/warden_layer.svg | 14 ++
crates/steam/src/lore.rs | 24 +++
crates/steam/src/main.rs | 55 +++++++
crates/steam/src/multiplayer_plugin.rs | 179 ++++++++++++++++++++++
crates/steam/src/net.rs | 178 ++++++++++++++++++++++
crates/steam/src/race/breach.rs | 114 ++++++++++++++
crates/steam/src/race/mod.rs | 16 ++
crates/steam/src/race/signal.rs | 110 ++++++++++++++
crates/steam/src/race/terminal.rs | 81 ++++++++++
crates/steam/src/racer.rs | 66 ++++++++
crates/steam/src/typing.rs | 216 +++++++++++++++++++++++++++
17 files changed, 1085 insertions(+)
create mode 100644 crates/steam/Cargo.toml
create mode 100644 crates/steam/assets/sprites/pulse.png
create mode 100644 crates/steam/assets/sprites/pulse.svg
create mode 100644 crates/steam/assets/sprites/racer_marker.png
create mode 100644 crates/steam/assets/sprites/racer_marker.svg
create mode 100644 crates/steam/assets/sprites/warden_layer.png
create mode 100644 crates/steam/assets/sprites/warden_layer.svg
create mode 100644 crates/steam/src/lore.rs
create mode 100644 crates/steam/src/main.rs
create mode 100644 crates/steam/src/multiplayer_plugin.rs
create mode 100644 crates/steam/src/net.rs
create mode 100644 crates/steam/src/race/breach.rs
create mode 100644 crates/steam/src/race/mod.rs
create mode 100644 crates/steam/src/race/signal.rs
create mode 100644 crates/steam/src/race/terminal.rs
create mode 100644 crates/steam/src/racer.rs
create mode 100644 crates/steam/src/typing.rs
(limited to 'crates/steam')
diff --git a/crates/steam/Cargo.toml b/crates/steam/Cargo.toml
new file mode 100644
index 0000000..b6c4851
--- /dev/null
+++ b/crates/steam/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "typerpunk-steam"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+
+[[bin]]
+name = "typerpunk-desktop"
+path = "src/main.rs"
+
+[dependencies]
+typerpunk-core = { path = "../core", default-features = false, features = ["serde", "multiplayer"] }
+bevy = { workspace = true }
+serde.workspace = true
+serde_json.workspace = true
+rand.workspace = true
+tokio = { workspace = true, features = ["full"] }
+tokio-tungstenite.workspace = true
+futures-util.workspace = true
+reqwest.workspace = true
diff --git a/crates/steam/assets/sprites/pulse.png b/crates/steam/assets/sprites/pulse.png
new file mode 100644
index 0000000..a70d94d
Binary files /dev/null and b/crates/steam/assets/sprites/pulse.png differ
diff --git a/crates/steam/assets/sprites/pulse.svg b/crates/steam/assets/sprites/pulse.svg
new file mode 100644
index 0000000..de5b98e
--- /dev/null
+++ b/crates/steam/assets/sprites/pulse.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/steam/assets/sprites/racer_marker.png b/crates/steam/assets/sprites/racer_marker.png
new file mode 100644
index 0000000..e7afbc3
Binary files /dev/null and b/crates/steam/assets/sprites/racer_marker.png differ
diff --git a/crates/steam/assets/sprites/racer_marker.svg b/crates/steam/assets/sprites/racer_marker.svg
new file mode 100644
index 0000000..60e973c
--- /dev/null
+++ b/crates/steam/assets/sprites/racer_marker.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/steam/assets/sprites/warden_layer.png b/crates/steam/assets/sprites/warden_layer.png
new file mode 100644
index 0000000..0b4f3f2
Binary files /dev/null and b/crates/steam/assets/sprites/warden_layer.png differ
diff --git a/crates/steam/assets/sprites/warden_layer.svg b/crates/steam/assets/sprites/warden_layer.svg
new file mode 100644
index 0000000..2fdf9a4
--- /dev/null
+++ b/crates/steam/assets/sprites/warden_layer.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/steam/src/lore.rs b/crates/steam/src/lore.rs
new file mode 100644
index 0000000..193587c
--- /dev/null
+++ b/crates/steam/src/lore.rs
@@ -0,0 +1,24 @@
+// In-universe naming for TyperPunk's multiplayer race modes.
+
+pub const APP_NAME: &str = "TyperPunk";
+
+/// A player or bot in a race. "Runner" is our own term for a race participant.
+pub const PARTICIPANT_LABEL: &str = "Runner";
+
+pub mod modes {
+ /// Terminal Duel: two runners' text execution shown side by side, like
+ /// racing shells. Cheapest of the three to build well; on-brand.
+ pub const TERMINAL_DUEL: &str = "Terminal Duel";
+
+ /// Breach Protocol: each runner chips through a stack of defensive
+ /// layers ("Wardens") with correct keystrokes; first to clear them wins.
+ pub const BREACH_PROTOCOL: &str = "Breach Protocol";
+
+ /// Signal Run: runners are pulses of light racing along a circuit trace
+ /// toward a destination node. The animated, particle-heavy visual.
+ pub const SIGNAL_RUN: &str = "Signal Run";
+}
+
+/// A single defensive layer in Breach Protocol. Renamed from the generic
+/// "firewall layer" to keep the game's own vocabulary distinct.
+pub const WARDEN_LABEL: &str = "Warden";
diff --git a/crates/steam/src/main.rs b/crates/steam/src/main.rs
new file mode 100644
index 0000000..3fafb61
--- /dev/null
+++ b/crates/steam/src/main.rs
@@ -0,0 +1,55 @@
+use bevy::prelude::*;
+
+mod lore;
+mod multiplayer_plugin;
+mod net;
+mod race;
+mod racer;
+mod typing;
+
+use multiplayer_plugin::MultiplayerPlugin;
+use race::{
+ breach::BreachProtocolPlugin, signal::SignalRunPlugin, terminal::TerminalDuelPlugin, RaceVisual,
+};
+use racer::DemoProgressPlugin;
+use typing::TypingPlugin;
+
+fn main() {
+ App::new()
+ .add_plugins(DefaultPlugins.set(WindowPlugin {
+ primary_window: Some(Window {
+ title: lore::APP_NAME.to_string(),
+ resolution: (1280, 720).into(),
+ ..default()
+ }),
+ ..default()
+ }))
+ .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.03)))
+ .init_state::()
+ .add_plugins(DemoProgressPlugin)
+ .add_plugins(MultiplayerPlugin)
+ .add_plugins(TypingPlugin)
+ .add_plugins(TerminalDuelPlugin)
+ .add_plugins(BreachProtocolPlugin)
+ .add_plugins(SignalRunPlugin)
+ .add_systems(Startup, spawn_camera)
+ .add_systems(Update, cycle_visual_on_key)
+ .run();
+}
+
+fn spawn_camera(mut commands: Commands) {
+ commands.spawn(Camera2d);
+}
+
+/// Demo-only: press 1/2/3 to switch which race visual is on screen, so all
+/// three can be compared side by side before committing to one for real
+/// multiplayer. Remove once there's a real mode-select flow.
+fn cycle_visual_on_key(keys: Res>, mut next: ResMut>) {
+ if keys.just_pressed(KeyCode::Digit1) {
+ next.set(RaceVisual::TerminalDuel);
+ } else if keys.just_pressed(KeyCode::Digit2) {
+ next.set(RaceVisual::BreachProtocol);
+ } else if keys.just_pressed(KeyCode::Digit3) {
+ next.set(RaceVisual::SignalRun);
+ }
+}
diff --git a/crates/steam/src/multiplayer_plugin.rs b/crates/steam/src/multiplayer_plugin.rs
new file mode 100644
index 0000000..05c1760
--- /dev/null
+++ b/crates/steam/src/multiplayer_plugin.rs
@@ -0,0 +1,179 @@
+// Bridges the background WebSocket worker in net.rs into Bevy's ECS: applies
+// incoming NetEvents to Racer entities every frame, and forwards the local
+// racer's (synthetic, see net.rs's module comment) progress out over the
+// same connection.
+use crate::net::{LocalUpdate, MultiplayerNet, NetEvent};
+use crate::racer::{DemoRace, Racer};
+use crate::typing::TypingSession;
+use bevy::prelude::*;
+use std::sync::mpsc::Sender;
+
+fn server_http_base() -> String {
+ std::env::var("TYPERPUNK_SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string())
+}
+
+fn server_ws_base() -> String {
+ // Same host as the HTTP API, just a different scheme - ws:// unless the
+ // HTTP base was already secured, in which case wss://.
+ let http = server_http_base();
+ if let Some(rest) = http.strip_prefix("https://") {
+ format!("wss://{rest}")
+ } else if let Some(rest) = http.strip_prefix("http://") {
+ format!("ws://{rest}")
+ } else {
+ format!("ws://{http}")
+ }
+}
+
+#[derive(Resource)]
+struct NetHandle(MultiplayerNet);
+
+#[derive(Resource)]
+struct LocalUpdateSender(Sender);
+
+#[derive(Resource, Default)]
+struct LocalPlayerId(Option);
+
+// Without this, send_local_progress would re-send Finish every single frame
+// once the local racer reaches 1.0 progress, flooding the connection
+// forever instead of announcing it once.
+#[derive(Resource, Default)]
+struct LocalFinishSent(bool);
+
+/// Tags a Racer entity as backed by a specific server-assigned player id, so
+/// incoming PlayerProgress/PlayerFinished events know which entity to
+/// update, and PlayerList knows which remote players already have one.
+#[derive(Component)]
+struct RemotePlayer(String);
+
+pub struct MultiplayerPlugin;
+
+impl Plugin for MultiplayerPlugin {
+ fn build(&self, app: &mut App) {
+ app.insert_resource(LocalPlayerId::default())
+ .insert_resource(LocalFinishSent::default())
+ .add_systems(Startup, connect)
+ .add_systems(Update, (poll_events, send_local_progress));
+ }
+}
+
+fn connect(mut commands: Commands) {
+ // A room code passed in means "join this existing room" (e.g. the code
+ // a web client printed) - otherwise a fresh room is created and its
+ // code logged so a second client can join it to actually see a live
+ // race between two real connections.
+ let room_code = std::env::var("TYPERPUNK_ROOM_CODE").ok();
+ let name = std::env::var("TYPERPUNK_PLAYER_NAME").unwrap_or_else(|_| "Desktop".to_string());
+ let (net, update_tx) = MultiplayerNet::spawn(server_http_base(), server_ws_base(), room_code, name);
+ commands.insert_resource(NetHandle(net));
+ commands.insert_resource(LocalUpdateSender(update_tx));
+}
+
+fn poll_events(
+ mut commands: Commands,
+ net: Res,
+ mut local_id: ResMut,
+ mut typing: ResMut,
+ mut finish_sent: ResMut,
+ mut racers: Query<(&mut Racer, &RemotePlayer)>,
+ existing: Query<(Entity, &RemotePlayer)>,
+) {
+ while let Some(event) = net.0.try_recv() {
+ match event {
+ NetEvent::RoomCreated { code } => {
+ info!("multiplayer room created - join it from the web app with code: {code}");
+ }
+ NetEvent::Joined { player_id } => {
+ info!("joined multiplayer room as player {player_id}");
+ local_id.0 = Some(player_id);
+ }
+ NetEvent::PlayerList(players) => {
+ for player in &players {
+ if local_id.0.as_deref() == Some(player.id.as_str()) {
+ continue; // that's us - our own Racer entity already exists
+ }
+ let already_spawned = existing.iter().any(|(_, r)| r.0 == player.id);
+ if !already_spawned {
+ commands.spawn((
+ Racer { name: player.name.clone(), is_local: false, progress: 0.0, wpm: 0.0, accuracy: 100.0, mistakes: 0 },
+ RemotePlayer(player.id.clone()),
+ DemoRace,
+ ));
+ }
+ }
+ // A player who left the room stops appearing in PlayerList --
+ // their racer entity is stale and would otherwise sit frozen
+ // on screen forever.
+ let current_ids: Vec<&str> = players.iter().map(|p| p.id.as_str()).collect();
+ for (entity, remote) in &existing {
+ if !current_ids.contains(&remote.0.as_str()) {
+ commands.entity(entity).despawn();
+ }
+ }
+ }
+ NetEvent::Countdown(seconds) => {
+ info!("race starting in {seconds}s");
+ }
+ NetEvent::Start(text) => {
+ info!("race started");
+ typing.start(text);
+ // A fresh race needs its own Finish announcement - without
+ // resetting this, a second race in the same process would
+ // never send one, since the guard would still remember the
+ // first race's finish.
+ finish_sent.0 = false;
+ }
+ NetEvent::PlayerProgress { player_id, percent, wpm } => {
+ for (mut racer, remote) in &mut racers {
+ if remote.0 == player_id {
+ racer.progress = (percent / 100.0).clamp(0.0, 1.0);
+ racer.wpm = wpm;
+ }
+ }
+ }
+ NetEvent::PlayerFinished { player_id, wpm, .. } => {
+ for (mut racer, remote) in &mut racers {
+ if remote.0 == player_id {
+ racer.progress = 1.0;
+ racer.wpm = wpm;
+ }
+ }
+ }
+ NetEvent::RoomClosed(reason) => {
+ warn!("multiplayer room closed: {reason}");
+ }
+ NetEvent::Error(message) => {
+ warn!("multiplayer error: {message}");
+ }
+ }
+ }
+}
+
+fn send_local_progress(
+ sender: Res,
+ mut finish_sent: ResMut,
+ typing: Res,
+ racers: Query<&Racer, With>,
+) {
+ // Nothing to report before a race has actually started - sending
+ // Progress{0%, 0wpm} on a loop while sitting in the lobby is meaningless
+ // and, worse, floods the connection right as the server's Ready handler
+ // is mid-countdown holding the room lock (see multiplayer.rs's Ready
+ // handler comment on why that hold is intentional).
+ if !typing.has_started() {
+ return;
+ }
+ for racer in &racers {
+ if !racer.is_local {
+ continue;
+ }
+ if racer.progress >= 1.0 {
+ if !finish_sent.0 {
+ let _ = sender.0.send(LocalUpdate::Finish { wpm: racer.wpm, accuracy: racer.accuracy, time: 0.0 });
+ finish_sent.0 = true;
+ }
+ } else {
+ let _ = sender.0.send(LocalUpdate::Progress { percent: racer.progress * 100.0, wpm: racer.wpm });
+ }
+ }
+}
diff --git a/crates/steam/src/net.rs b/crates/steam/src/net.rs
new file mode 100644
index 0000000..4d213b4
--- /dev/null
+++ b/crates/steam/src/net.rs
@@ -0,0 +1,178 @@
+// Real multiplayer networking for the desktop client, replacing racer.rs's
+// synthetic two-racer demo driver. Bevy's render loop is synchronous, so (as
+// with the TUI's own net.rs) a dedicated OS thread owns a tokio runtime and
+// the actual WebSocket connection, bridging to the ECS over plain
+// std::sync::mpsc channels polled once per frame.
+//
+// The local racer's progress is still produced by a synthetic driver (see
+// racer::drive_demo_progress) rather than real typing input - this crate
+// has no typing UI yet, that's a separate, much larger feature. What's real
+// here is the network path itself: the local racer's synthetic progress is
+// sent to typerpunk-server over the same protocol the web client uses, and
+// every *other* racer on screen is driven entirely by what the server
+// actually broadcasts, so two real clients (this one and a browser, or two
+// copies of this one) genuinely race against each other's live state.
+use futures_util::{SinkExt, StreamExt};
+use std::sync::mpsc;
+use tokio_tungstenite::tungstenite::Message as WsMessage;
+use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage};
+
+#[derive(Debug, Clone)]
+pub enum NetEvent {
+ RoomCreated { code: String },
+ Joined { player_id: String },
+ PlayerList(Vec),
+ Countdown(u32),
+ Start(String),
+ PlayerProgress { player_id: String, percent: f32, wpm: f32 },
+ PlayerFinished { player_id: String, wpm: f32, accuracy: f32, time: f32, place: u32 },
+ RoomClosed(String),
+ Error(String),
+}
+
+#[derive(Debug, Clone)]
+pub enum LocalUpdate {
+ Progress { percent: f32, wpm: f32 },
+ Finish { wpm: f32, accuracy: f32, time: f32 },
+}
+
+// std::sync::mpsc::Receiver is Send but not Sync, and Bevy's Resource trait
+// requires Sync - a single-slot Mutex around it costs nothing here since
+// poll_events is the only reader, called from a single system.
+pub struct MultiplayerNet {
+ event_rx: std::sync::Mutex>,
+}
+
+impl MultiplayerNet {
+ /// `room_code: None` creates a fresh room and reports its code back via
+ /// `NetEvent::RoomCreated` (printed to the terminal by main.rs so a
+ /// tester can join it from the web app); `Some(code)` joins an existing
+ /// one instead.
+ pub fn spawn(http_base: String, ws_base: String, room_code: Option, name: String) -> (Self, mpsc::Sender) {
+ let (event_tx, event_rx) = mpsc::channel();
+ let (update_tx, update_rx) = mpsc::channel::();
+
+ std::thread::spawn(move || {
+ let rt = tokio::runtime::Runtime::new().expect("failed to start steam network runtime");
+ rt.block_on(run(http_base, ws_base, room_code, name, event_tx, update_rx));
+ });
+
+ (Self { event_rx: std::sync::Mutex::new(event_rx) }, update_tx)
+ }
+
+ pub fn try_recv(&self) -> Option {
+ self.event_rx.lock().ok()?.try_recv().ok()
+ }
+}
+
+#[derive(serde::Deserialize)]
+struct CreateRoomResponse {
+ room_code: String,
+}
+
+async fn create_room(http_base: &str) -> Result {
+ let client = reqwest::Client::new();
+ let url = format!("{http_base}/api/multiplayer/rooms");
+ let resp = client.post(&url).send().await.map_err(|e| format!("could not reach server: {e}"))?;
+ if !resp.status().is_success() {
+ return Err(format!("room creation failed ({})", resp.status()));
+ }
+ resp.json::().await.map(|b| b.room_code).map_err(|e| format!("bad response: {e}"))
+}
+
+fn to_event(msg: ServerMessage) -> NetEvent {
+ match msg {
+ ServerMessage::Joined { player_id } => NetEvent::Joined { player_id },
+ ServerMessage::PlayerList { players } => NetEvent::PlayerList(players),
+ ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds),
+ ServerMessage::Start { text } => NetEvent::Start(text),
+ ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm },
+ ServerMessage::PlayerFinished { player_id, wpm, accuracy, time, place } => {
+ NetEvent::PlayerFinished { player_id, wpm, accuracy, time, place }
+ }
+ ServerMessage::RoomClosed { reason } => NetEvent::RoomClosed(reason),
+ ServerMessage::Error { message } => NetEvent::Error(message),
+ }
+}
+
+async fn send_msg(write: &mut S, msg: &ClientMessage) -> Result<(), ()>
+where
+ S: futures_util::Sink + Unpin,
+{
+ let text = serde_json::to_string(msg).map_err(|_| ())?;
+ write.send(WsMessage::Text(text)).await.map_err(|_| ())
+}
+
+async fn run(
+ http_base: String,
+ ws_base: String,
+ room_code: Option,
+ name: String,
+ event_tx: mpsc::Sender,
+ update_rx: mpsc::Receiver,
+) {
+ let code = match room_code {
+ Some(code) => code,
+ None => match create_room(&http_base).await {
+ Ok(code) => {
+ let _ = event_tx.send(NetEvent::RoomCreated { code: code.clone() });
+ code
+ }
+ Err(err) => {
+ let _ = event_tx.send(NetEvent::Error(err));
+ return;
+ }
+ },
+ };
+
+ let url = format!("{ws_base}/ws/multiplayer/{code}");
+ let (ws_stream, _) = match tokio_tungstenite::connect_async(&url).await {
+ Ok(pair) => pair,
+ Err(err) => {
+ let _ = event_tx.send(NetEvent::Error(format!("connect failed: {err}")));
+ return;
+ }
+ };
+ let (mut write, mut read) = ws_stream.split();
+
+ let join = ClientMessage::Join { name, device_type: "desktop".to_string() };
+ if send_msg(&mut write, &join).await.is_err() {
+ let _ = event_tx.send(NetEvent::Error("failed to send join".to_string()));
+ return;
+ }
+ let _ = send_msg(&mut write, &ClientMessage::Ready).await;
+
+ loop {
+ tokio::select! {
+ incoming = read.next() => {
+ match incoming {
+ Some(Ok(WsMessage::Text(text))) => {
+ if let Ok(msg) = serde_json::from_str::(&text) {
+ if event_tx.send(to_event(msg)).is_err() { return; }
+ }
+ }
+ Some(Ok(WsMessage::Close(_))) | None => {
+ let _ = event_tx.send(NetEvent::RoomClosed("connection closed".to_string()));
+ return;
+ }
+ Some(Err(err)) => {
+ let _ = event_tx.send(NetEvent::Error(format!("connection error: {err}")));
+ return;
+ }
+ _ => {}
+ }
+ }
+ // std::sync::mpsc has no async recv - polled on a short interval
+ // instead of a third select branch reading it directly.
+ _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => {
+ while let Ok(update) = update_rx.try_recv() {
+ let client_msg = match update {
+ LocalUpdate::Progress { percent, wpm } => ClientMessage::Progress { percent, wpm },
+ LocalUpdate::Finish { wpm, accuracy, time } => ClientMessage::Finish { wpm, accuracy, time },
+ };
+ if send_msg(&mut write, &client_msg).await.is_err() { return; }
+ }
+ }
+ }
+ }
+}
diff --git a/crates/steam/src/race/breach.rs b/crates/steam/src/race/breach.rs
new file mode 100644
index 0000000..9151774
--- /dev/null
+++ b/crates/steam/src/race/breach.rs
@@ -0,0 +1,114 @@
+use super::RaceVisual;
+use crate::lore;
+use crate::racer::{DemoRace, Racer};
+use bevy::prelude::*;
+
+/// "Breach Protocol" - each runner chips through a vertical stack of
+/// defensive layers ("Wardens") as they type correctly; first to clear
+/// their whole stack wins. Doubles as the visual for a hacking-themed mode.
+pub struct BreachProtocolPlugin;
+
+const LAYER_COUNT: usize = 8;
+const LAYER_SIZE: Vec2 = Vec2::new(140.0, 28.0);
+const LAYER_GAP: f32 = 6.0;
+const LANE_GAP: f32 = 240.0;
+
+impl Plugin for BreachProtocolPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(OnEnter(RaceVisual::BreachProtocol), spawn_lanes)
+ .add_systems(OnExit(RaceVisual::BreachProtocol), despawn_lanes)
+ .add_systems(
+ Update,
+ update_layers.run_if(in_state(RaceVisual::BreachProtocol)),
+ );
+ }
+}
+
+#[derive(Component)]
+struct BreachRoot;
+
+#[derive(Component)]
+struct WardenLayer {
+ racer: Entity,
+ index: usize,
+}
+
+fn spawn_lanes(
+ mut commands: Commands,
+ asset_server: Res,
+ racers: Query<(Entity, &Racer), With>,
+) {
+ let warden_texture: Handle = asset_server.load("sprites/warden_layer.png");
+ let marker_texture: Handle = asset_server.load("sprites/racer_marker.png");
+ let lanes: Vec<_> = racers.iter().collect();
+ let start_x = -(LANE_GAP * (lanes.len().max(1) as f32 - 1.0)) / 2.0;
+
+ for (lane_index, (entity, racer)) in lanes.iter().enumerate() {
+ let x = start_x + lane_index as f32 * LANE_GAP;
+ let stack_top = -200.0 + LAYER_COUNT as f32 * (LAYER_SIZE.y + LAYER_GAP);
+
+ commands.spawn((
+ BreachRoot,
+ Text2d::new(format!("{} - {}", racer.name, lore::WARDEN_LABEL)),
+ TextFont {
+ font_size: bevy::text::FontSize::Px(18.0),
+ ..default()
+ },
+ TextColor(Color::srgb(0.6, 0.6, 0.65)),
+ Transform::from_xyz(x, stack_top + 20.0, 0.0),
+ ));
+
+ // Shared racer marker (see racer.rs's is_local) sits above the
+ // local player's own stack, same asset Signal Run uses for the
+ // same purpose.
+ if racer.is_local {
+ commands.spawn((
+ BreachRoot,
+ Sprite {
+ image: marker_texture.clone(),
+ color: Color::srgb(0.0, 1.0, 0.62),
+ custom_size: Some(Vec2::splat(20.0)),
+ ..default()
+ },
+ Transform::from_xyz(x, stack_top + 44.0, 0.0),
+ ));
+ }
+
+ for i in 0..LAYER_COUNT {
+ let y = -200.0 + i as f32 * (LAYER_SIZE.y + LAYER_GAP);
+ commands.spawn((
+ BreachRoot,
+ WardenLayer {
+ racer: *entity,
+ index: i,
+ },
+ Sprite {
+ image: warden_texture.clone(),
+ color: Color::srgb(0.15, 0.15, 0.18),
+ custom_size: Some(LAYER_SIZE),
+ ..default()
+ },
+ Transform::from_xyz(x, y, 0.0),
+ ));
+ }
+ }
+}
+
+fn update_layers(racers: Query<&Racer>, mut layers: Query<(&WardenLayer, &mut Sprite)>) {
+ for (layer, mut sprite) in &mut layers {
+ if let Ok(racer) = racers.get(layer.racer) {
+ let cleared = (racer.progress * LAYER_COUNT as f32).floor() as usize;
+ sprite.color = if layer.index < cleared {
+ Color::srgb(0.0, 1.0, 0.62)
+ } else {
+ Color::srgb(0.15, 0.15, 0.18)
+ };
+ }
+ }
+}
+
+fn despawn_lanes(mut commands: Commands, root: Query>) {
+ for entity in &root {
+ commands.entity(entity).despawn();
+ }
+}
diff --git a/crates/steam/src/race/mod.rs b/crates/steam/src/race/mod.rs
new file mode 100644
index 0000000..15ad5f3
--- /dev/null
+++ b/crates/steam/src/race/mod.rs
@@ -0,0 +1,16 @@
+use bevy::prelude::*;
+
+pub mod breach;
+pub mod signal;
+pub mod terminal;
+
+/// Which race visualization is currently on screen. Switchable at runtime
+/// (keys 1/2/3 in the demo) so all three can be compared side by side
+/// during development instead of committing to one before they're built.
+#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
+pub enum RaceVisual {
+ #[default]
+ TerminalDuel,
+ BreachProtocol,
+ SignalRun,
+}
diff --git a/crates/steam/src/race/signal.rs b/crates/steam/src/race/signal.rs
new file mode 100644
index 0000000..1046fc8
--- /dev/null
+++ b/crates/steam/src/race/signal.rs
@@ -0,0 +1,110 @@
+use super::RaceVisual;
+use crate::racer::{DemoRace, Racer};
+use bevy::prelude::*;
+
+/// "Signal Run" - runners are pulses of light traveling along a circuit
+/// trace toward a destination node.
+pub struct SignalRunPlugin;
+
+const TRACK_START_X: f32 = -500.0;
+const TRACK_END_X: f32 = 500.0;
+const TRACK_Y: f32 = 0.0;
+const LANE_SPACING: f32 = 60.0;
+
+impl Plugin for SignalRunPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(OnEnter(RaceVisual::SignalRun), spawn_pulses)
+ .add_systems(OnExit(RaceVisual::SignalRun), despawn_pulses)
+ .add_systems(
+ Update,
+ (draw_track, move_pulses).run_if(in_state(RaceVisual::SignalRun)),
+ );
+ }
+}
+
+#[derive(Component)]
+struct SignalEntity;
+
+#[derive(Component)]
+struct Pulse {
+ racer: Entity,
+}
+
+fn lane_y(lane: usize) -> f32 {
+ TRACK_Y - lane as f32 * LANE_SPACING
+}
+
+fn spawn_pulses(
+ mut commands: Commands,
+ asset_server: Res,
+ racers: Query<(Entity, &Racer), With>,
+) {
+ let pulse_texture: Handle = asset_server.load("sprites/pulse.png");
+ let marker_texture: Handle = asset_server.load("sprites/racer_marker.png");
+
+ for (lane, (entity, racer)) in racers.iter().enumerate() {
+ let color = if racer.is_local {
+ Color::srgb(0.0, 1.0, 0.62)
+ } else {
+ Color::srgb(1.0, 0.3, 0.5)
+ };
+ commands.spawn((
+ SignalEntity,
+ Pulse { racer: entity },
+ Sprite {
+ image: pulse_texture.clone(),
+ color,
+ custom_size: Some(Vec2::splat(22.0)),
+ ..default()
+ },
+ Transform::from_xyz(TRACK_START_X, lane_y(lane), 1.0),
+ ));
+
+ // Shared racer marker (see racer.rs's is_local) sits above the
+ // local player's lane, on-brand with the same asset the Breach
+ // Protocol visual uses for the same purpose.
+ if racer.is_local {
+ commands.spawn((
+ SignalEntity,
+ // Tagged with Pulse too, purely so move_pulses (below) keeps
+ // its x position tracking the same racer's progress - its
+ // spawned y offset above the lane is left untouched since
+ // that system only ever writes .x.
+ Pulse { racer: entity },
+ Sprite {
+ image: marker_texture.clone(),
+ color: Color::srgb(0.0, 1.0, 0.62),
+ custom_size: Some(Vec2::splat(20.0)),
+ ..default()
+ },
+ Transform::from_xyz(TRACK_START_X, lane_y(lane) + 26.0, 1.0),
+ ));
+ }
+ }
+}
+
+fn move_pulses(racers: Query<&Racer>, mut pulses: Query<(&Pulse, &mut Transform)>) {
+ for (pulse, mut transform) in &mut pulses {
+ if let Ok(racer) = racers.get(pulse.racer) {
+ transform.translation.x =
+ TRACK_START_X + (TRACK_END_X - TRACK_START_X) * racer.progress;
+ }
+ }
+}
+
+fn draw_track(mut gizmos: Gizmos, racers: Query<&Racer, With>) {
+ for lane in 0..racers.iter().len() {
+ let y = lane_y(lane);
+ gizmos.line_2d(
+ Vec2::new(TRACK_START_X, y),
+ Vec2::new(TRACK_END_X, y),
+ Color::srgba(0.0, 1.0, 0.62, 0.25),
+ );
+ }
+}
+
+fn despawn_pulses(mut commands: Commands, pulses: Query>) {
+ for entity in &pulses {
+ commands.entity(entity).despawn();
+ }
+}
diff --git a/crates/steam/src/race/terminal.rs b/crates/steam/src/race/terminal.rs
new file mode 100644
index 0000000..d558e42
--- /dev/null
+++ b/crates/steam/src/race/terminal.rs
@@ -0,0 +1,81 @@
+use super::RaceVisual;
+use crate::racer::{DemoRace, Racer};
+use bevy::prelude::*;
+
+/// "Terminal Duel" - each runner's line renders like a shell command
+/// executing in real time. Cheapest of the three visuals: no sprites, no
+/// physics, just text - and it reads as authentically on-brand for a
+/// typing game about hacking rather than racing.
+pub struct TerminalDuelPlugin;
+
+impl Plugin for TerminalDuelPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(OnEnter(RaceVisual::TerminalDuel), spawn_ui)
+ .add_systems(OnExit(RaceVisual::TerminalDuel), despawn_ui)
+ .add_systems(
+ Update,
+ update_lines.run_if(in_state(RaceVisual::TerminalDuel)),
+ );
+ }
+}
+
+#[derive(Component)]
+struct TerminalDuelRoot;
+
+#[derive(Component)]
+struct TerminalLine(Entity);
+
+fn spawn_ui(mut commands: Commands, racers: Query<(Entity, &Racer), With>) {
+ commands
+ .spawn((
+ TerminalDuelRoot,
+ Node {
+ width: Val::Percent(100.0),
+ height: Val::Percent(100.0),
+ flex_direction: FlexDirection::Column,
+ justify_content: JustifyContent::Center,
+ align_items: AlignItems::Center,
+ row_gap: Val::Px(24.0),
+ ..default()
+ },
+ ))
+ .with_children(|parent| {
+ for (entity, racer) in &racers {
+ parent.spawn((
+ TerminalLine(entity),
+ Text::new(line_for(racer)),
+ TextFont {
+ font_size: bevy::text::FontSize::Px(26.0),
+ ..default()
+ },
+ TextColor(Color::srgb(0.0, 1.0, 0.62)),
+ ));
+ }
+ });
+}
+
+fn update_lines(racers: Query<&Racer>, mut lines: Query<(&TerminalLine, &mut Text)>) {
+ for (link, mut text) in &mut lines {
+ if let Ok(racer) = racers.get(link.0) {
+ *text = Text::new(line_for(racer));
+ }
+ }
+}
+
+fn line_for(racer: &Racer) -> String {
+ let width = 32;
+ let filled = ((racer.progress * width as f32).round() as usize).min(width);
+ let bar: String = "#".repeat(filled) + &".".repeat(width - filled);
+ format!(
+ "{:<8} [{bar}] {:>3.0}% {:>3.0}wpm",
+ racer.name,
+ racer.progress * 100.0,
+ racer.wpm
+ )
+}
+
+fn despawn_ui(mut commands: Commands, root: Query>) {
+ for entity in &root {
+ commands.entity(entity).despawn();
+ }
+}
diff --git a/crates/steam/src/racer.rs b/crates/steam/src/racer.rs
new file mode 100644
index 0000000..790833e
--- /dev/null
+++ b/crates/steam/src/racer.rs
@@ -0,0 +1,66 @@
+use crate::typing::TypingSession;
+use bevy::prelude::*;
+
+/// A participant in a race visual. Decoupled from networking on purpose --
+/// the three visualizations below only ever read this component, so they
+/// work identically whether progress comes from a local typing session, a
+/// bot, or the real multiplayer connection.
+#[derive(Component, Debug, Clone)]
+pub struct Racer {
+ pub name: String,
+ pub is_local: bool,
+ /// 0.0 (start) to 1.0 (finished).
+ pub progress: f32,
+ pub wpm: f32,
+ pub accuracy: f32,
+ pub mistakes: u32,
+}
+
+/// Marks the set of racer entities that belong to the current demo race, so
+/// visualization plugins can query just their own participants.
+#[derive(Component)]
+pub struct DemoRace;
+
+pub struct DemoProgressPlugin;
+
+impl Plugin for DemoProgressPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(Startup, spawn_demo_racers)
+ .add_systems(Update, drive_demo_progress);
+ }
+}
+
+fn spawn_demo_racers(mut commands: Commands) {
+ // Every other racer on screen comes from multiplayer_plugin.rs, spawned
+ // and updated from the actual server. This one is "You" - driven for
+ // real by typing.rs once a race is underway (see update_local_racer),
+ // and by drive_demo_progress's idle animation before that.
+ commands.spawn((
+ Racer {
+ name: "You".into(),
+ is_local: true,
+ progress: 0.0,
+ wpm: 0.0,
+ accuracy: 100.0,
+ mistakes: 0,
+ },
+ DemoRace,
+ ));
+}
+
+// Idle animation only, for the brief window before a real race has started
+// (typing.rs's TypingSession isn't active yet) - once real keystrokes are
+// driving the local racer, this backs off so it doesn't fight that.
+fn drive_demo_progress(time: Res