srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/steam
diff options
context:
space:
mode:
Diffstat (limited to 'crates/steam')
-rw-r--r--crates/steam/Cargo.toml21
-rw-r--r--crates/steam/assets/sprites/pulse.pngbin0 -> 495 bytes
-rw-r--r--crates/steam/assets/sprites/pulse.svg5
-rw-r--r--crates/steam/assets/sprites/racer_marker.pngbin0 -> 371 bytes
-rw-r--r--crates/steam/assets/sprites/racer_marker.svg6
-rw-r--r--crates/steam/assets/sprites/warden_layer.pngbin0 -> 309 bytes
-rw-r--r--crates/steam/assets/sprites/warden_layer.svg14
-rw-r--r--crates/steam/src/lore.rs24
-rw-r--r--crates/steam/src/main.rs55
-rw-r--r--crates/steam/src/multiplayer_plugin.rs179
-rw-r--r--crates/steam/src/net.rs178
-rw-r--r--crates/steam/src/race/breach.rs114
-rw-r--r--crates/steam/src/race/mod.rs16
-rw-r--r--crates/steam/src/race/signal.rs110
-rw-r--r--crates/steam/src/race/terminal.rs81
-rw-r--r--crates/steam/src/racer.rs66
-rw-r--r--crates/steam/src/typing.rs216
17 files changed, 1085 insertions, 0 deletions
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
--- /dev/null
+++ b/crates/steam/assets/sprites/pulse.png
Binary files 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64">
+ <!-- Angular diamond, matching the web icon set's straight-edge language.
+ Reads as a signal pulse travelling along the track. -->
+ <path d="M12 2L22 12L12 22L2 12Z" fill="#ffffff"/>
+</svg>
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
--- /dev/null
+++ b/crates/steam/assets/sprites/racer_marker.png
Binary files 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48">
+ <!-- Shared "this one is you" marker used above the local player's lane
+ in both Signal Run and Breach Protocol. A filled downward chevron,
+ angular like every other icon in the set. -->
+ <path d="M2 4H22L12 16Z" fill="#ffffff"/>
+</svg>
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
--- /dev/null
+++ b/crates/steam/assets/sprites/warden_layer.png
Binary files 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 28" width="280" height="56">
+ <!-- A circuit-board-style defensive layer: a solid bar with angular teeth
+ above and below, echoing the terminal/hacking aesthetic instead of a
+ flat rectangle. -->
+ <rect x="4" y="10" width="132" height="8" fill="#ffffff"/>
+ <rect x="14" y="2" width="14" height="8" fill="#ffffff"/>
+ <rect x="42" y="2" width="14" height="8" fill="#ffffff"/>
+ <rect x="70" y="2" width="14" height="8" fill="#ffffff"/>
+ <rect x="98" y="2" width="14" height="8" fill="#ffffff"/>
+ <rect x="14" y="18" width="14" height="8" fill="#ffffff"/>
+ <rect x="42" y="18" width="14" height="8" fill="#ffffff"/>
+ <rect x="70" y="18" width="14" height="8" fill="#ffffff"/>
+ <rect x="98" y="18" width="14" height="8" fill="#ffffff"/>
+</svg>
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::<RaceVisual>()
+ .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<ButtonInput<KeyCode>>, mut next: ResMut<NextState<RaceVisual>>) {
+ 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<LocalUpdate>);
+
+#[derive(Resource, Default)]
+struct LocalPlayerId(Option<String>);
+
+// 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<NetHandle>,
+ mut local_id: ResMut<LocalPlayerId>,
+ mut typing: ResMut<TypingSession>,
+ mut finish_sent: ResMut<LocalFinishSent>,
+ 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<LocalUpdateSender>,
+ mut finish_sent: ResMut<LocalFinishSent>,
+ typing: Res<TypingSession>,
+ racers: Query<&Racer, With<DemoRace>>,
+) {
+ // 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<PlayerInfo>),
+ 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<mpsc::Receiver<NetEvent>>,
+}
+
+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<String>, name: String) -> (Self, mpsc::Sender<LocalUpdate>) {
+ let (event_tx, event_rx) = mpsc::channel();
+ let (update_tx, update_rx) = mpsc::channel::<LocalUpdate>();
+
+ 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<NetEvent> {
+ self.event_rx.lock().ok()?.try_recv().ok()
+ }
+}
+
+#[derive(serde::Deserialize)]
+struct CreateRoomResponse {
+ room_code: String,
+}
+
+async fn create_room(http_base: &str) -> Result<String, String> {
+ 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::<CreateRoomResponse>().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<S>(write: &mut S, msg: &ClientMessage) -> Result<(), ()>
+where
+ S: futures_util::Sink<WsMessage> + 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<String>,
+ name: String,
+ event_tx: mpsc::Sender<NetEvent>,
+ update_rx: mpsc::Receiver<LocalUpdate>,
+) {
+ 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::<ServerMessage>(&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<AssetServer>,
+ racers: Query<(Entity, &Racer), With<DemoRace>>,
+) {
+ let warden_texture: Handle<Image> = asset_server.load("sprites/warden_layer.png");
+ let marker_texture: Handle<Image> = 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<Entity, With<BreachRoot>>) {
+ 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<AssetServer>,
+ racers: Query<(Entity, &Racer), With<DemoRace>>,
+) {
+ let pulse_texture: Handle<Image> = asset_server.load("sprites/pulse.png");
+ let marker_texture: Handle<Image> = 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<DemoRace>>) {
+ 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<Entity, With<SignalEntity>>) {
+ 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<DemoRace>>) {
+ 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<Entity, With<TerminalDuelRoot>>) {
+ 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<Time>, session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) {
+ if session.is_active() {
+ return;
+ }
+ for mut racer in &mut racers {
+ if !racer.is_local || racer.progress >= 1.0 {
+ continue;
+ }
+ let base_speed = 0.12;
+ racer.progress = (racer.progress + base_speed * time.delta_secs()).min(1.0);
+ racer.wpm = 60.0 + (racer.progress * 40.0);
+ }
+}
diff --git a/crates/steam/src/typing.rs b/crates/steam/src/typing.rs
new file mode 100644
index 0000000..c64a540
--- /dev/null
+++ b/crates/steam/src/typing.rs
@@ -0,0 +1,216 @@
+// Real typing input for the desktop client, replacing the local racer's
+// synthetic progress with actual keystrokes against the passage the server
+// sent for the current race. Reuses typerpunk_core::game::Game - the same
+// correctness/WPM/accuracy engine the wasm/web build runs - instead of
+// reimplementing typing-test logic a third time.
+use crate::racer::{DemoRace, Racer};
+use bevy::input::keyboard::{Key, KeyboardInput};
+use bevy::input::ButtonState;
+use bevy::prelude::*;
+use typerpunk_core::game::Game;
+
+// Bounded the same way the TUI windows its own text rendering - a
+// multiplayer passage could in principle be long, and this keeps the
+// number of spawned per-character entities sane regardless.
+const MAX_VISIBLE_CHARS: usize = 400;
+
+#[derive(Resource, Default)]
+pub struct TypingSession {
+ game: Option<Game>,
+ spans_built: bool,
+}
+
+impl TypingSession {
+ pub fn start(&mut self, text: String) {
+ let mut game = Game::new();
+ game.set_text(text);
+ game.start();
+ self.game = Some(game);
+ self.spans_built = false;
+ }
+
+ pub fn is_active(&self) -> bool {
+ self.game.as_ref().is_some_and(|g| !g.is_finished())
+ }
+
+ /// True once a race's Start message has actually arrived - distinct
+ /// from is_active(), which also goes false again once finished. Used to
+ /// gate outgoing Progress messages: sending them while still sitting in
+ /// the lobby is meaningless (there's no race to report progress on) and
+ /// floods the connection during the server's countdown at the worst
+ /// possible moment (see multiplayer_plugin.rs's send_local_progress).
+ pub fn has_started(&self) -> bool {
+ self.game.is_some()
+ }
+}
+
+#[derive(Component)]
+struct TypingTextRoot;
+
+#[derive(Component)]
+struct TypingCharSpan(usize);
+
+#[derive(Component)]
+struct TypingStatsText;
+
+pub struct TypingPlugin;
+
+impl Plugin for TypingPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::<TypingSession>()
+ .add_systems(Startup, spawn_typing_overlay)
+ .add_systems(
+ Update,
+ (rebuild_spans_on_session_start, handle_keyboard_input, recolor_spans, update_local_racer, update_stats_text).chain(),
+ );
+ }
+}
+
+const CORRECT_COLOR: Color = Color::srgb(0.0, 1.0, 0.62);
+const INCORRECT_COLOR: Color = Color::srgb(1.0, 0.3, 0.3);
+const PENDING_COLOR: Color = Color::srgb(0.5, 0.5, 0.55);
+const CURSOR_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
+
+fn spawn_typing_overlay(mut commands: Commands) {
+ commands.spawn((
+ TypingTextRoot,
+ Text::new(""),
+ TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() },
+ Node {
+ position_type: PositionType::Absolute,
+ bottom: Val::Px(70.0),
+ left: Val::Px(40.0),
+ right: Val::Px(40.0),
+ ..default()
+ },
+ ));
+ commands.spawn((
+ TypingStatsText,
+ Text::new(""),
+ TextColor(CORRECT_COLOR),
+ TextFont { font_size: bevy::text::FontSize::Px(18.0), ..default() },
+ Node {
+ position_type: PositionType::Absolute,
+ bottom: Val::Px(24.0),
+ left: Val::Px(40.0),
+ ..default()
+ },
+ ));
+}
+
+// Spans are spawned once per race (character identities never change for a
+// fixed passage) and only recolored afterward - rebuilding text entities
+// every frame would be wasteful for no visual benefit over just updating
+// TextColor on the ones that already exist.
+fn rebuild_spans_on_session_start(
+ mut commands: Commands,
+ mut session: ResMut<TypingSession>,
+ root: Query<Entity, With<TypingTextRoot>>,
+ old_spans: Query<Entity, With<TypingCharSpan>>,
+) {
+ let has_game = session.game.is_some();
+ if !has_game {
+ if !old_spans.is_empty() {
+ for e in &old_spans {
+ commands.entity(e).despawn();
+ }
+ }
+ return;
+ }
+ if session.spans_built {
+ return;
+ }
+ session.spans_built = true;
+
+ for e in &old_spans {
+ commands.entity(e).despawn();
+ }
+
+ let Ok(root_entity) = root.single() else { return };
+ let text = session.game.as_ref().unwrap().get_text();
+ let children: Vec<Entity> = text
+ .chars()
+ .take(MAX_VISIBLE_CHARS)
+ .enumerate()
+ .map(|(i, c)| {
+ commands
+ .spawn((TypingCharSpan(i), TextSpan(c.to_string()), TextColor(PENDING_COLOR), TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() }))
+ .id()
+ })
+ .collect();
+ commands.entity(root_entity).add_children(&children);
+}
+
+fn handle_keyboard_input(mut events: MessageReader<KeyboardInput>, mut session: ResMut<TypingSession>) {
+ let Some(game) = session.game.as_mut() else {
+ events.clear();
+ return;
+ };
+ if game.is_finished() {
+ events.clear();
+ return;
+ }
+ for ev in events.read() {
+ if ev.state != ButtonState::Pressed {
+ continue;
+ }
+ match &ev.logical_key {
+ Key::Character(s) => {
+ let mut input = game.get_input();
+ input.push_str(s);
+ let _ = game.handle_input(&input);
+ }
+ Key::Space => {
+ let mut input = game.get_input();
+ input.push(' ');
+ let _ = game.handle_input(&input);
+ }
+ Key::Backspace => {
+ let _ = game.handle_backspace(false);
+ }
+ _ => {}
+ }
+ }
+}
+
+fn recolor_spans(session: Res<TypingSession>, mut spans: Query<(&TypingCharSpan, &mut TextColor)>) {
+ let Some(game) = &session.game else { return };
+ let input_len = game.get_input().chars().count();
+ let errors = game.get_error_positions();
+ for (span, mut color) in &mut spans {
+ let i = span.0;
+ color.0 = if i < input_len {
+ if errors.contains(&i) { INCORRECT_COLOR } else { CORRECT_COLOR }
+ } else if i == input_len {
+ CURSOR_COLOR
+ } else {
+ PENDING_COLOR
+ };
+ }
+}
+
+fn update_local_racer(session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) {
+ let Some(game) = &session.game else { return };
+ let input_len = game.get_input().chars().count() as f32;
+ let text_len = (game.get_text().chars().count() as f32).max(1.0);
+ let progress = (input_len / text_len).min(1.0);
+ let wpm = game.get_wpm() as f32;
+ let accuracy = game.get_accuracy() as f32;
+ for mut racer in &mut racers {
+ if racer.is_local {
+ racer.progress = if game.is_finished() { 1.0 } else { progress };
+ racer.wpm = wpm;
+ racer.accuracy = accuracy;
+ }
+ }
+}
+
+fn update_stats_text(session: Res<TypingSession>, mut text_query: Query<&mut Text, With<TypingStatsText>>) {
+ let Ok(mut text) = text_query.single_mut() else { return };
+ let Some(game) = &session.game else {
+ **text = String::new();
+ return;
+ };
+ let status = if game.is_finished() { " - finished" } else { "" };
+ **text = format!("{:.0} wpm {:.0}% acc{status}", game.get_wpm(), game.get_accuracy());
+}