diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 0595f87..cd9ac2e 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -88,6 +88,9 @@
"extra": {
"router": {
"origin": false
+ },
+ "eas": {
+ "projectId": "69115b2d-38e8-4c5c-b0bd-02f1ce15f561"
}
}
}
diff --git a/apps/mobile/assets/rive/outhere-logo.riv b/apps/mobile/assets/rive/outhere-logo.riv
new file mode 100644
index 0000000..e45d97a
Binary files /dev/null and b/apps/mobile/assets/rive/outhere-logo.riv differ
diff --git a/apps/mobile/assets/rive/outhere-versus.riv b/apps/mobile/assets/rive/outhere-versus.riv
new file mode 100644
index 0000000..21ece7a
Binary files /dev/null and b/apps/mobile/assets/rive/outhere-versus.riv differ
diff --git a/apps/mobile/assets/rive/silhouette-female-blue.jpg b/apps/mobile/assets/rive/silhouette-female-blue.jpg
new file mode 100644
index 0000000..0114320
Binary files /dev/null and b/apps/mobile/assets/rive/silhouette-female-blue.jpg differ
diff --git a/apps/mobile/assets/rive/silhouette-female-red.jpg b/apps/mobile/assets/rive/silhouette-female-red.jpg
new file mode 100644
index 0000000..0c73dc7
Binary files /dev/null and b/apps/mobile/assets/rive/silhouette-female-red.jpg differ
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
index 8a5e93a..4534dda 100755
--- a/apps/mobile/metro.config.js
+++ b/apps/mobile/metro.config.js
@@ -4,6 +4,9 @@ const path = require('node:path');
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
+// Rive files (assets/rive) are bundled like images.
+config.resolver.assetExts.push('riv');
+
const WEB_ALIASES = {
'expo-secure-store': path.resolve(__dirname, './polyfills/web/secureStore.web.ts'),
'react-native-webview': path.resolve(__dirname, './polyfills/web/webview.web.tsx'),
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index ee13f9c..c143739 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -25,6 +25,8 @@
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native-picker/picker": "2.11.4",
"@react-three/fiber": "9.8.1",
+ "@rive-app/canvas": "2.43.1",
+ "@rive-app/react-native": "0.5.5",
"@shopify/react-native-skia": "2.14.0",
"@supabase/supabase-js": "^2.117.3",
"@tanstack/react-query": "5.72.2",
@@ -42,6 +44,7 @@
"expo-clipboard": "~58.0.3",
"expo-constants": "~58.0.12",
"expo-contacts": "~58.0.7",
+ "expo-dev-client": "~58.0.14",
"expo-device": "~58.0.5",
"expo-document-picker": "~58.0.4",
"expo-file-system": "~58.0.9",
@@ -81,6 +84,7 @@
"react-native-gesture-handler": "3.3.0",
"react-native-graph": "1.1.0",
"react-native-maps": "1.29.11",
+ "react-native-nitro-modules": "0.36.5",
"react-native-qrcode-svg": "6.3.21",
"react-native-reanimated": "4.7.1",
"react-native-reanimated-carousel": "4.0.2",
diff --git a/apps/mobile/public/rive.wasm b/apps/mobile/public/rive.wasm
new file mode 100755
index 0000000..bfe037a
Binary files /dev/null and b/apps/mobile/public/rive.wasm differ
diff --git a/apps/mobile/src/app/battles/[battleId].jsx b/apps/mobile/src/app/battles/[battleId].jsx
index aebe2fe..31217a9 100644
--- a/apps/mobile/src/app/battles/[battleId].jsx
+++ b/apps/mobile/src/app/battles/[battleId].jsx
@@ -22,6 +22,7 @@ import { EmoteBurst } from "@/components/arena/RaceScreens";
import ClassicBattle from "@/components/arena/ClassicBattle";
import { RaceScreen, RevealScreen, SealedScreen } from "@/components/arena/RaceScreens";
import Confetti from "@/components/arena/Confetti";
+import VersusReveal, { useFirstReveal } from "@/components/arena/VersusReveal";
import { durationLabel, formatScore, metricUnit, playerName, useNow } from "@/components/arena/shared";
import { BATTLE_EMOTES, BATTLE_LINES, emoteLabel } from "@/data/seedOutside";
import { PHOTOS } from "@/constants/photos";
@@ -328,11 +329,21 @@ function Result({ battle }) {
Share.share({ message: `${won ? "Beat" : "Battled"} ${oppName} on Outhere: ${formatScore(metric, mine)} to ${formatScore(metric, theirs)}${metric === "steps" ? " steps" : ""}.` }).catch(() => {});
const objectSize = Math.min(width * 0.7, 280);
const packPic = won ? battle.me.player?.fighter?.win : draw ? null : battle.me.player?.fighter?.respect;
+ // The first time, the result lands on the 1v1 card (VersusReveal) and
+ // the card's own cues play it in the hand.
+ const [reveal, endReveal] = useFirstReveal(battle);
+ const buzzed = useRef(false);
// The result lands in the hand once: rising taps for a win, a soft drop for a loss.
useEffect(() => {
+ if (reveal === "checking" || buzzed.current) return;
+ buzzed.current = true;
+ if (reveal === "show") return;
if (won) haptic.win();
else if (!draw) haptic.lose();
- }, [won, draw]);
+ }, [won, draw, reveal]);
+
+ if (reveal === "checking") return ;
+ if (reveal === "show") return ;
return (
diff --git a/apps/mobile/src/app/battles/countdown/[battleId].jsx b/apps/mobile/src/app/battles/countdown/[battleId].jsx
index db00ade..9eee26b 100644
--- a/apps/mobile/src/app/battles/countdown/[battleId].jsx
+++ b/apps/mobile/src/app/battles/countdown/[battleId].jsx
@@ -1,17 +1,21 @@
-// The versus screen before a 1v1, like a fighting game (the prototype's
-// .vs9): both fighters slide in on either side of a slanted, glowing seam,
-// the head to head sits up top, and VS turns into the count. Each beat
-// flashes white and fires a ring from the middle while the number slams in
-// (SpotlightNumber). When the battle is live it hands over to the battle
-// screen; while it waits on the other side to accept, it says so.
-
-import React, { useEffect, useState } from "react";
+// The versus screen before a 1v1, like a fighting game. Where the build can
+// play Rive it is the 1v1 card (rive/versus): the intro picked for the
+// battle (seam strike, tale of the tape, cinematic or Let's Run), then the
+// card holds a moment and the battle opens. In Expo Go it is the prototype's
+// .vs9 in React Native: both fighters slide in on either side of a slanted,
+// glowing seam, the head to head sits up top, and VS turns into the count.
+// Each beat flashes white and fires a ring from the middle while the number
+// slams in (SpotlightNumber). Either way, while it waits on the other side
+// to accept, it says so.
+
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { StyleSheet, Text, View, useWindowDimensions } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BlurView } from "expo-blur";
-import Animated, { Easing, FadeInLeft, FadeInRight, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated";
-import { useBattle, useHeadToHead, useProfile } from "@/data/hooks";
+import Animated, { Easing, FadeIn, FadeInLeft, FadeInRight, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated";
+import { useBattle, useHeadToHead, usePlayer, useProfile } from "@/data/hooks";
+import { INTRO_MS, VersusCard, pickIntro, riveAvailable, versusCard } from "@/components/rive";
import { fighterPhoto } from "@/utils/fighter";
import { Avatar, C, F, GlowButton, Grain, R, S, Skeleton, SpotlightNumber, VersusSplit } from "@/components/ui";
import { TopBar } from "@/components/arena/OutsideKit";
@@ -47,6 +51,117 @@ function Burst({ beat, x, y }) {
}
export default function BattleCountdown() {
+ return riveAvailable ? : ;
+}
+
+// The card is 390 x 844 and covers the screen; its stake tiles start 736 down.
+const CARD = { w: 390, h: 844, tiles: 736 };
+// How long the card holds once it is up before the battle opens. The tape
+// holds longer so its numbers can be read.
+const HOLD_MS = { tape: 2600, cinematic: 1400, letsRun: 1400, strike: 1400 };
+
+/** The 1v1 card. */
+function CardCountdown() {
+ const router = useRouter();
+ const { width, height } = useWindowDimensions();
+ const { battleId } = useLocalSearchParams();
+ const id = String(battleId || "");
+ const { data: battle, refetch } = useBattle(id);
+ const me = useProfile().data;
+ const opp = battle?.opponent?.player;
+ const h2hQuery = useHeadToHead(opp?.id);
+ const h2h = h2hQuery.data;
+
+ // The intro is picked once the battle and the head to head are in (or a
+ // moment has passed without the head to head), and then it stays.
+ const [waited, setWaited] = useState(false);
+ useEffect(() => {
+ const t = setTimeout(() => setWaited(true), 1500);
+ return () => clearTimeout(t);
+ }, []);
+ const picked = useRef(null);
+ if (!picked.current && battle && me && (h2hQuery.status !== "pending" || !opp?.id || waited)) picked.current = pickIntro(battle, h2h);
+ const intro = picked.current;
+
+ const needStats = intro === "tape" || intro === "cinematic";
+ const youStats = usePlayer(needStats ? me?.id : undefined).data;
+ const themStats = usePlayer(needStats ? opp?.id : undefined).data;
+ const card = useMemo(
+ () => (intro ? versusCard({ battle, me, opp, h2h, stats: { you: youStats, them: themStats }, intro }) : null),
+ [intro, battle, me, opp, h2h, youStats, themStats],
+ );
+
+ const live = battle?.status === "active";
+ const waiting = battle?.status === "pending";
+
+ // Up once the intro has played; if the card never says it started, carry on anyway.
+ const [started, setStarted] = useState(false);
+ const [up, setUp] = useState(false);
+ useEffect(() => {
+ if (!intro) return undefined;
+ const t = setTimeout(() => setStarted(true), 2500);
+ return () => clearTimeout(t);
+ }, [intro]);
+ useEffect(() => {
+ if (!started) return undefined;
+ const t = setTimeout(() => setUp(true), INTRO_MS[intro] || INTRO_MS.strike);
+ return () => clearTimeout(t);
+ }, [started, intro]);
+
+ // Live: hold, then GO and the battle opens.
+ useEffect(() => {
+ if (!live || !up) return undefined;
+ const t = setTimeout(() => {
+ haptic.go();
+ router.replace(`/battles/${id}`);
+ }, HOLD_MS[intro] || HOLD_MS.strike);
+ return () => clearTimeout(t);
+ }, [live, up, intro, id, router]);
+
+ // Waiting on them: check now and then whether they've said yes.
+ useEffect(() => {
+ if (!waiting) return undefined;
+ const t = setInterval(() => refetch(), 5000);
+ return () => clearInterval(t);
+ }, [waiting, refetch]);
+
+ const onCue = (name) => {
+ if (name === "slam" || name === "letsRun") haptic.beat();
+ else if (name === "cutIn") haptic.select();
+ else if (name === "addPack") router.push("/pack");
+ };
+
+ if (!card) {
+ return (
+
+ router.replace("/outside/arena")} />
+
+ );
+ }
+
+ const oppName = (opp?.name || "Rival").split(" ")[0];
+ const scale = Math.max(width / CARD.w, height / CARD.h);
+ const tilesTop = (height - CARD.h * scale) / 2 + CARD.tiles * scale;
+ return (
+
+ setStarted(true)} style={StyleSheet.absoluteFill} />
+ {up && !live ? (
+
+ {waiting ? Sent. The clock starts when {oppName} accepts. : null}
+ router.replace(waiting ? "/outside/arena" : `/battles/${id}`)}
+ />
+
+ ) : null}
+
+ );
+}
+
+/** The prototype's .vs9 in React Native, for builds without Rive (Expo Go). */
+function SplitCountdown() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
@@ -233,4 +348,6 @@ const styles = StyleSheet.create({
stakeValue: { color: "#fff", fontFamily: F.display, fontSize: 22, lineHeight: 24, marginBottom: 2 },
stakeLabel: { color: "rgba(255,255,255,0.7)", fontFamily: F.body, fontSize: 10.5, textAlign: "center" },
wait: { color: C.textSoft, fontFamily: F.body, fontSize: 13, textAlign: "center", marginVertical: S.sm },
+ panel: { position: "absolute", left: 18, right: 18, gap: S.sm, padding: S.md, borderRadius: 22, backgroundColor: "rgba(4,6,11,0.72)" },
+ panelText: { color: "#fff", fontFamily: F.bodySemi, fontSize: 14, textAlign: "center" },
});
diff --git a/apps/mobile/src/components/AppTabBar.jsx b/apps/mobile/src/components/AppTabBar.jsx
index 7aae609..80e7ceb 100644
--- a/apps/mobile/src/components/AppTabBar.jsx
+++ b/apps/mobile/src/components/AppTabBar.jsx
@@ -31,17 +31,21 @@ function useItems() {
}));
}
-/** For . */
-export function TabsTabBar({ state, navigation, hidden }) {
+/**
+ * For . Since expo-router 58 the tab bar gets `emitter`
+ * and `navigateToTab` rather than `navigation`; tapping a tab called the old
+ * navigation.emit and closed the app.
+ */
+export function TabsTabBar({ state, emitter, navigateToTab, hidden }) {
const items = useItems();
const current = state.routes[state.index]?.name;
const onSelect = (key) => {
const route = state.routes.find((r) => r.name === key);
if (!route) return;
- const event = navigation.emit({ type: "tabPress", target: route.key, canPreventDefault: true });
+ const event = emitter.emit({ type: "tabPress", target: route.key, canPreventDefault: true });
if (key !== current && !event.defaultPrevented) {
hapticSelection();
- navigation.navigate(route.name);
+ navigateToTab(route.key);
}
};
return state.routes.some((r) => r.name === i.key))} current={current} onSelect={onSelect} hidden={hidden} />;
diff --git a/apps/mobile/src/components/arena/VersusReveal.jsx b/apps/mobile/src/components/arena/VersusReveal.jsx
new file mode 100644
index 0000000..c43fa87
--- /dev/null
+++ b/apps/mobile/src/components/arena/VersusReveal.jsx
@@ -0,0 +1,104 @@
+// The first time you open a finished 1v1, the result lands on the 1v1 card
+// (rive/versus) before the result screen: the card strikes in, VS bursts,
+// YOU WIN! or YOU LOSE (with K.O. and the glass crack when the winner had at
+// least double), then WINNER and LOSER on each side's result shot. A tap
+// once the result is playing, or Continue at the end, opens the result
+// screen. Only where the build can play Rive, and only once per battle on
+// this phone, for battles that ended in the last week.
+
+import React, { useEffect, useMemo, useState } from "react";
+import { Pressable, StyleSheet, View, useWindowDimensions } from "react-native";
+import Animated, { FadeIn } from "react-native-reanimated";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { VersusCard, riveAvailable, versusCard, versusResult } from "@/components/rive";
+import { GlowButton, S } from "@/components/ui";
+import { haptic } from "@/services/haptics";
+
+const SEEN_KEY = "outhere.vsReveal.v1";
+const WEEK_MS = 7 * 86_400_000;
+// The card is 390 x 844 and covers the screen; its stake tiles start 736 down.
+const CARD = { w: 390, h: 844, tiles: 736 };
+
+/**
+ * "checking", then "show" the first time this battle's result is opened
+ * (and marks it seen) or "no". The second value ends the reveal.
+ */
+export function useFirstReveal(battle) {
+ const eligible = riveAvailable && !!versusResult(battle) && Date.now() - (battle?.completedAt || battle?.endAt || 0) < WEEK_MS;
+ const [state, setState] = useState(eligible ? "checking" : "no");
+ const id = battle?.id;
+ useEffect(() => {
+ if (!eligible) return undefined;
+ let alive = true;
+ AsyncStorage.getItem(SEEN_KEY)
+ .then((raw) => {
+ const seen = JSON.parse(raw || "[]");
+ if (!alive) return;
+ if (seen.includes(id)) return setState("no");
+ setState("show");
+ return AsyncStorage.setItem(SEEN_KEY, JSON.stringify([id, ...seen].slice(0, 200)));
+ })
+ .catch(() => alive && setState("no"));
+ return () => {
+ alive = false;
+ };
+ }, [eligible, id]);
+ return [state, () => setState("no")];
+}
+
+export default function VersusReveal({ battle, onDone }) {
+ const { width, height } = useWindowDimensions();
+ const card = useMemo(() => versusCard({ battle, me: battle.me.player, opp: battle.opponent.player, intro: "strike" }), [battle]);
+ const outcome = useMemo(() => versusResult(battle), [battle]);
+ const [slammed, setSlammed] = useState(false);
+ const [result, setResult] = useState(null);
+ const [done, setDone] = useState(false);
+
+ // VS slams, a beat, then the result. Timers stand in if a cue never comes.
+ useEffect(() => {
+ const t = setTimeout(() => setSlammed(true), 2600);
+ return () => clearTimeout(t);
+ }, []);
+ useEffect(() => {
+ if (!slammed) return undefined;
+ const t = setTimeout(() => setResult(outcome), 900);
+ return () => clearTimeout(t);
+ }, [slammed, outcome]);
+ useEffect(() => {
+ if (!result) return undefined;
+ const t = setTimeout(() => setDone(true), result.ko ? 7000 : 6000);
+ return () => clearTimeout(t);
+ }, [result]);
+
+ const onCue = (name) => {
+ if (name === "slam") {
+ haptic.beat();
+ setSlammed(true);
+ } else if (name === "youWin") haptic.win();
+ else if (name === "youLose") haptic.lose();
+ else if (name === "knockout" || name === "shatter") haptic.beat();
+ else if (name === "prize") {
+ haptic.tap();
+ setTimeout(() => setDone(true), 900);
+ }
+ };
+
+ const scale = Math.max(width / CARD.w, height / CARD.h);
+ const tilesTop = (height - CARD.h * scale) / 2 + CARD.tiles * scale;
+ return (
+
+
+ {result ? : null}
+ {done ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: "#04060b" },
+ panel: { position: "absolute", left: 18, right: 18, gap: S.sm },
+});
diff --git a/apps/mobile/src/components/rive/RiveLogo.jsx b/apps/mobile/src/components/rive/RiveLogo.jsx
new file mode 100644
index 0000000..58aa3ab
--- /dev/null
+++ b/apps/mobile/src/components/rive/RiveLogo.jsx
@@ -0,0 +1,50 @@
+// The animated mark (rive/logo) on a phone. `size` is the mark's own box, as
+// for Logo; the artboard has a tenth of the size spare on every side for the
+// glow, so the player overhangs the box by that much. Logo (96 px and up)
+// lands the dot with a ping and a glow that then breathes; Small (under
+// that) plays the same landing without them. onLanded fires as the dot lands.
+// Only render it when riveAvailable is true (runtime.js).
+
+import React, { useEffect, useRef } from "react";
+import { View } from "react-native";
+import { listenForEvents, riveNative as R } from "./runtime";
+import { RIVE_FILES } from "./files";
+
+const LANDED_MS = 1100;
+
+export default function RiveLogo({ size = 150, artboard = size >= 96 ? "Logo" : "Small", onLanded, style }) {
+ const { riveFile } = R.useRiveFile(RIVE_FILES.logo);
+ const { riveViewRef, setHybridRef } = R.useRive();
+ const landed = useRef(onLanded);
+ landed.current = onLanded;
+
+ useEffect(() => {
+ if (!riveViewRef) return undefined;
+ const stop = listenForEvents(riveViewRef, (event) => {
+ if (event.name === "landed") landed.current?.();
+ });
+ if (stop) return stop;
+ // No events on this player: the dot lands about 1.1 s into the intro.
+ const timer = setTimeout(() => landed.current?.(), LANDED_MS);
+ return () => clearTimeout(timer);
+ }, [riveViewRef]);
+
+ const spare = size * 0.1;
+ return (
+
+ {riveFile ? (
+ console.warn("Logo:", error.message)}
+ style={{ position: "absolute", left: -spare, top: -spare, width: size + spare * 2, height: size + spare * 2 }}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/components/rive/RiveLogo.web.jsx b/apps/mobile/src/components/rive/RiveLogo.web.jsx
new file mode 100644
index 0000000..1c4c15a
--- /dev/null
+++ b/apps/mobile/src/components/rive/RiveLogo.web.jsx
@@ -0,0 +1,38 @@
+// The animated mark on the web, through the canvas runtime. Same props as
+// RiveLogo.jsx.
+
+import React, { useEffect, useRef } from "react";
+import { View } from "react-native";
+import { Asset } from "expo-asset";
+import { Alignment, EventType, Fit, Layout, Rive } from "@rive-app/canvas";
+import "./runtime";
+import { RIVE_FILES } from "./files";
+
+export default function RiveLogo({ size = 150, artboard = size >= 96 ? "Logo" : "Small", onLanded, style }) {
+ const canvas = useRef(null);
+ const landed = useRef(onLanded);
+ landed.current = onLanded;
+
+ useEffect(() => {
+ const rive = new Rive({
+ src: Asset.fromModule(RIVE_FILES.logo).uri,
+ canvas: canvas.current,
+ artboard,
+ stateMachines: artboard,
+ autoplay: true,
+ layout: new Layout({ fit: Fit.Contain, alignment: Alignment.Center }),
+ onLoad: () => rive.resizeDrawingSurfaceToCanvas(),
+ });
+ rive.on(EventType.RiveEvent, (event) => {
+ if (event.data?.name === "landed") landed.current?.();
+ });
+ return () => rive.cleanup();
+ }, [artboard]);
+
+ const spare = size * 0.1;
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/rive/VersusCard.jsx b/apps/mobile/src/components/rive/VersusCard.jsx
new file mode 100644
index 0000000..a463069
--- /dev/null
+++ b/apps/mobile/src/components/rive/VersusCard.jsx
@@ -0,0 +1,108 @@
+// The 1v1 card (rive/versus) on a phone, through @rive-app/react-native.
+// Only render it when riveAvailable is true (runtime.js); Expo Go can't.
+//
+// card versusCard(...) from versusData.js: intro, strings, numbers,
+// booleans and the two photos. The intro is read once, as the card
+// first plays; everything else follows the prop.
+// result versusResult(...) or null. When it arrives the card loads the
+// result shots and plays the win or the loss, once.
+// onCue called with each event the file fires (slam, youWin, prize,
+// addPack, ...; the list is in rive/versus/README.md).
+// onReady called once the card is on screen and playing.
+
+import React, { useEffect, useRef } from "react";
+import { StyleSheet, View } from "react-native";
+import { listenForEvents, riveNative as R } from "./runtime";
+import { RIVE_FILES } from "./files";
+import { imageUrl } from "./images";
+
+const IMAGE_PROPS = ["youPhoto", "themPhoto"];
+
+function bindValues(vmi, card, withIntro) {
+ if (!vmi || !card) return;
+ if (withIntro) vmi.enumProperty("intro")?.set(card.intro);
+ for (const [key, value] of Object.entries(card.strings || {})) vmi.stringProperty(key)?.set(String(value ?? ""));
+ for (const [key, value] of Object.entries(card.numbers || {})) vmi.numberProperty(key)?.set(Number(value) || 0);
+ for (const [key, value] of Object.entries(card.booleans || {})) vmi.booleanProperty(key)?.set(!!value);
+}
+
+async function bindImage(vmi, key, source) {
+ const url = await imageUrl(source);
+ if (!url) return;
+ const image = await R.RiveImages.loadFromURLAsync(url);
+ vmi.imageProperty(key)?.set(image);
+}
+
+export default function VersusCard({ card, result, onCue, onReady, style }) {
+ const { riveFile } = R.useRiveFile(RIVE_FILES.versus);
+ const first = useRef(card);
+ // The intro and the first values are set before the card binds, so the
+ // state machine opens on the right intro.
+ const { instance } = R.useViewModelInstance(riveFile, { viewModelName: "Versus", onInit: (vmi) => bindValues(vmi, first.current, true) });
+ const { riveViewRef, setHybridRef } = R.useRive();
+ const cue = useRef(onCue);
+ cue.current = onCue;
+ const ready = useRef(onReady);
+ ready.current = onReady;
+
+ useEffect(() => {
+ bindValues(instance, card, false);
+ }, [instance, card]);
+
+ const youPhoto = card?.photos?.youPhoto;
+ const themPhoto = card?.photos?.themPhoto;
+ useEffect(() => {
+ if (!instance) return;
+ [youPhoto, themPhoto].forEach((source, i) => {
+ if (source != null) bindImage(instance, IMAGE_PROPS[i], source).catch(() => {});
+ });
+ }, [instance, youPhoto, themPhoto]);
+
+ // Where the player has no events the screens' own timers stand in for the cues.
+ useEffect(() => {
+ if (!riveViewRef) return undefined;
+ const stop = listenForEvents(riveViewRef, (event) => cue.current?.(event.name));
+ ready.current?.();
+ return stop ?? undefined;
+ }, [riveViewRef]);
+
+ // The result plays once: the shots load first so they're there under the flash.
+ const played = useRef(false);
+ useEffect(() => {
+ if (!instance || !riveViewRef || !result || played.current) return;
+ played.current = true;
+ (async () => {
+ instance.booleanProperty("ko")?.set(!!result.ko);
+ if (result.prize) instance.stringProperty("prize")?.set(result.prize);
+ await Promise.all([
+ bindImage(instance, "youResult", result.youResult ?? youPhoto).catch(() => {}),
+ bindImage(instance, "themResult", result.themResult ?? themPhoto).catch(() => {}),
+ ]);
+ instance.triggerProperty(result.outcome === "win" ? "win" : "lose")?.trigger();
+ riveViewRef.playIfNeeded();
+ })();
+ }, [instance, riveViewRef, result, youPhoto, themPhoto]);
+
+ return (
+
+ {riveFile && instance ? (
+ console.warn("VS card:", error.message)}
+ style={StyleSheet.absoluteFill}
+ />
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: { flex: 1, backgroundColor: "#04060b" },
+});
diff --git a/apps/mobile/src/components/rive/VersusCard.web.jsx b/apps/mobile/src/components/rive/VersusCard.web.jsx
new file mode 100644
index 0000000..d98746c
--- /dev/null
+++ b/apps/mobile/src/components/rive/VersusCard.web.jsx
@@ -0,0 +1,113 @@
+// The 1v1 card on the web, through the canvas runtime (@rive-app/canvas).
+// Same props as VersusCard.jsx.
+
+import React, { useEffect, useRef, useState } from "react";
+import { StyleSheet, View } from "react-native";
+import { Asset } from "expo-asset";
+import { Alignment, EventType, Fit, Layout, Rive, decodeImage } from "@rive-app/canvas";
+import "./runtime";
+import { RIVE_FILES } from "./files";
+import { imageUrl } from "./images";
+
+const IMAGE_PROPS = ["youPhoto", "themPhoto"];
+
+function bindValues(vmi, card, withIntro) {
+ if (!vmi || !card) return;
+ const set = (prop, value) => {
+ if (prop) prop.value = value;
+ };
+ if (withIntro) set(vmi.enum("intro"), card.intro);
+ for (const [key, value] of Object.entries(card.strings || {})) set(vmi.string(key), String(value ?? ""));
+ for (const [key, value] of Object.entries(card.numbers || {})) set(vmi.number(key), Number(value) || 0);
+ for (const [key, value] of Object.entries(card.booleans || {})) set(vmi.boolean(key), !!value);
+}
+
+async function bindImage(vmi, key, source) {
+ const url = await imageUrl(source);
+ if (!url) return;
+ const response = await fetch(url);
+ const image = await decodeImage(new Uint8Array(await response.arrayBuffer()));
+ const prop = vmi.image(key);
+ if (prop) prop.value = image;
+ image.unref?.();
+}
+
+export default function VersusCard({ card, result, onCue, onReady, style }) {
+ const canvas = useRef(null);
+ const first = useRef(card);
+ const [vmi, setVmi] = useState(null);
+ const cue = useRef(onCue);
+ cue.current = onCue;
+ const ready = useRef(onReady);
+ ready.current = onReady;
+
+ useEffect(() => {
+ const rive = new Rive({
+ src: Asset.fromModule(RIVE_FILES.versus).uri,
+ canvas: canvas.current,
+ artboard: "Versus",
+ stateMachines: "Versus",
+ autoplay: false,
+ autoBind: true,
+ layout: new Layout({ fit: Fit.Cover, alignment: Alignment.Center }),
+ onLoad: () => {
+ rive.resizeDrawingSurfaceToCanvas();
+ const instance = rive.viewModelInstance;
+ bindValues(instance, first.current, true);
+ // replay sends the state machine to the intro just set, whether or
+ // not it has already taken its first step.
+ instance?.trigger("replay")?.trigger();
+ rive.play();
+ setVmi(instance);
+ ready.current?.();
+ },
+ });
+ rive.on(EventType.RiveEvent, (event) => cue.current?.(event.data?.name));
+ const resize = () => rive.resizeDrawingSurfaceToCanvas();
+ window.addEventListener("resize", resize);
+ return () => {
+ window.removeEventListener("resize", resize);
+ rive.cleanup();
+ };
+ }, []);
+
+ useEffect(() => {
+ bindValues(vmi, card, false);
+ }, [vmi, card]);
+
+ const youPhoto = card?.photos?.youPhoto;
+ const themPhoto = card?.photos?.themPhoto;
+ useEffect(() => {
+ if (!vmi) return;
+ [youPhoto, themPhoto].forEach((source, i) => {
+ if (source != null) bindImage(vmi, IMAGE_PROPS[i], source).catch(() => {});
+ });
+ }, [vmi, youPhoto, themPhoto]);
+
+ const played = useRef(false);
+ useEffect(() => {
+ if (!vmi || !result || played.current) return;
+ played.current = true;
+ (async () => {
+ const ko = vmi.boolean("ko");
+ if (ko) ko.value = !!result.ko;
+ const prize = vmi.string("prize");
+ if (prize && result.prize) prize.value = result.prize;
+ await Promise.all([
+ bindImage(vmi, "youResult", result.youResult ?? youPhoto).catch(() => {}),
+ bindImage(vmi, "themResult", result.themResult ?? themPhoto).catch(() => {}),
+ ]);
+ vmi.trigger(result.outcome === "win" ? "win" : "lose")?.trigger();
+ })();
+ }, [vmi, result, youPhoto, themPhoto]);
+
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: { flex: 1, backgroundColor: "#04060b" },
+});
diff --git a/apps/mobile/src/components/rive/__tests__/runtime.test.js b/apps/mobile/src/components/rive/__tests__/runtime.test.js
new file mode 100644
index 0000000..eb45cf0
--- /dev/null
+++ b/apps/mobile/src/components/rive/__tests__/runtime.test.js
@@ -0,0 +1,30 @@
+import { listenForEvents } from "../runtime";
+
+describe("listenForEvents", () => {
+ it("listens and stops where the player has events", () => {
+ const view = { onEventListener: jest.fn(), removeEventListeners: jest.fn() };
+ const onEvent = () => {};
+ const stop = listenForEvents(view, onEvent);
+ expect(view.onEventListener).toHaveBeenCalledWith(onEvent);
+ stop();
+ expect(view.removeEventListeners).toHaveBeenCalled();
+ });
+
+ it("returns null instead of throwing where the player has no events", () => {
+ const fail = () => {
+ throw new Error("Events are not supported by the new iOS runtime");
+ };
+ const view = { onEventListener: fail, removeEventListeners: fail };
+ expect(listenForEvents(view, () => {})).toBeNull();
+ });
+
+ it("stops quietly when the view has already gone", () => {
+ const view = {
+ onEventListener: () => {},
+ removeEventListeners: () => {
+ throw new Error("gone");
+ },
+ };
+ expect(() => listenForEvents(view, () => {})()).not.toThrow();
+ });
+});
diff --git a/apps/mobile/src/components/rive/__tests__/versusData.test.js b/apps/mobile/src/components/rive/__tests__/versusData.test.js
new file mode 100644
index 0000000..e3dd774
--- /dev/null
+++ b/apps/mobile/src/components/rive/__tests__/versusData.test.js
@@ -0,0 +1,123 @@
+// The 1v1 card's data: which intro a battle gets, what the card binds, the
+// tape rows and the result from your side.
+
+// fighter.js takes imageSource from the UI kit, whose animation libraries
+// Jest doesn't load; this is the same function.
+jest.mock("@/components/ui", () => ({ imageSource: (src) => (src == null ? null : typeof src === "string" ? { uri: src } : src) }));
+
+const { BIG_STAKE, cardPhoto, clockFor, pickIntro, stepsShort, tapeRows, versusCard, versusResult } = require("../versusData");
+const { SHADOW_RUNNERS } = require("../assets");
+
+// Thursday 15 October 2026, 18:45 local: the fourth day of the week.
+const NOW = new Date(2026, 9, 15, 18, 45).getTime();
+
+const me = { id: "me", name: "Alex Mensah", area: "Peckham", runner: "man", fighter: null };
+const kemi = { id: "p_kemi", name: "Kemi Ade", area: "Brixton", runner: "woman", fighter: null };
+const battle = (extra = {}) => ({
+ id: "b1",
+ mode: "most_steps",
+ metric: "steps",
+ modeName: "Most steps",
+ durationHours: 24,
+ stakeOut: 50,
+ status: "active",
+ me: { player: me, value: null },
+ opponent: { player: kemi, value: null },
+ ...extra,
+});
+
+describe("pickIntro", () => {
+ it("strikes for an everyday battle, including the usual 50 stake", () => {
+ expect(pickIntro(battle(), { played: 0, mine: 0, theirs: 0 })).toBe("strike");
+ expect(pickIntro(battle(), undefined)).toBe("strike");
+ });
+ it("runs the tape for a rematch, someone you've met or a big stake", () => {
+ expect(pickIntro(battle({ rematchOf: "b0" }), null)).toBe("tape");
+ expect(pickIntro(battle(), { played: 1, mine: 1, theirs: 0 })).toBe("tape");
+ expect(pickIntro(battle({ stakeOut: BIG_STAKE }), null)).toBe("tape");
+ });
+ it("goes cinematic for a grudge match, a derby or the top of a leaderboard", () => {
+ expect(pickIntro(battle(), { played: 4, mine: 2, theirs: 2 })).toBe("cinematic");
+ expect(pickIntro(battle(), { played: 4, mine: 3, theirs: 1 })).toBe("tape");
+ expect(pickIntro(battle({ isDerby: true }), null)).toBe("cinematic");
+ expect(pickIntro(battle({ isTopOfLeaderboard: true }), null)).toBe("cinematic");
+ });
+ it("lets run for an event battle, above everything else", () => {
+ expect(pickIntro(battle({ eventId: "e1", rematchOf: "b0" }), { played: 5, mine: 2, theirs: 3 })).toBe("letsRun");
+ });
+});
+
+describe("the numbers", () => {
+ it("shortens counts the way the tape shows them", () => {
+ expect(stepsShort(11_400)).toBe("11.4k");
+ expect(stepsShort(9000)).toBe("9k");
+ expect(stepsShort(950)).toBe("950");
+ expect(stepsShort(1_250_000)).toBe("1.3m");
+ });
+ it("writes the clock tile in hours, then days", () => {
+ expect(clockFor(24)).toBe("24h");
+ expect(clockFor(72)).toBe("3d");
+ expect(clockFor(undefined)).toBe("24h");
+ });
+ it("makes each bar a share of the bigger number", () => {
+ const rows = tapeRows({
+ you: { weekSteps: 40_000, todaySteps: 9000, streak: 6, xp: 1200 },
+ them: { weekSteps: 32_000, todaySteps: 12_000, streak: 0, xp: 0 },
+ h2h: { mine: 0, theirs: 0 },
+ now: NOW,
+ });
+ expect(rows.map((r) => r.label)).toEqual(["STEPS A DAY", "TODAY", "DAY STREAK", "XP", "HEAD TO HEAD"]);
+ expect(rows[0]).toEqual({ label: "STEPS A DAY", you: "10k", them: "8k", youBar: 1, themBar: 0.8 });
+ expect(rows[1].youBar).toBe(0.75);
+ expect(rows[1].themBar).toBe(1);
+ expect(rows[4]).toMatchObject({ you: "0", them: "0", youBar: 0, themBar: 0 });
+ });
+});
+
+describe("versusCard", () => {
+ it("binds names, places, the rule and the tiles", () => {
+ const card = versusCard({ battle: battle({ rematchOf: "b0" }), me, opp: kemi, h2h: null, now: NOW });
+ expect(card.intro).toBe("tape");
+ expect(card.strings).toMatchObject({ youName: "Alex", youCity: "Peckham", themName: "Kemi", themCity: "Brixton", title: "1V1 · REMATCH", rule: "24h · most steps", stake: "50", prize: "100", clock: "24h" });
+ expect(card.strings.tape1Label).toBe("STEPS A DAY");
+ expect(card.numbers.tape5YouBar).toBe(0);
+ expect(card.booleans.youHasPack).toBe(false);
+ });
+ it("leaves the tape alone for the other intros", () => {
+ const card = versusCard({ battle: battle({ isSurprise: true }), me, opp: kemi, h2h: null, now: NOW });
+ expect(card.intro).toBe("strike");
+ expect(card.strings.title).toBe("1V1 · MYSTERY");
+ expect(card.strings.tape1Label).toBeUndefined();
+ expect(card.numbers).toEqual({});
+ });
+ it("uses the photo, else the female shadow runner, else the card's own male one", () => {
+ expect(cardPhoto(me, "you")).toBeNull();
+ expect(cardPhoto(kemi, "them")).toBe(SHADOW_RUNNERS.womanThem);
+ expect(cardPhoto({ runner: "either", runnerFigure: "woman" }, "you")).toBe(SHADOW_RUNNERS.womanYou);
+ expect(cardPhoto({ ...kemi, avatar: "https://x.test/k.jpg" }, "them")).toEqual({ uri: "https://x.test/k.jpg" });
+ expect(cardPhoto({ ...kemi, fighter: { vs: "https://x.test/vs.png" } }, "them")).toEqual({ uri: "https://x.test/vs.png" });
+ });
+});
+
+describe("versusResult", () => {
+ const done = (extra) => battle({ status: "complete", ...extra });
+ it("has no result before the end, or for a draw", () => {
+ expect(versusResult(battle())).toBeNull();
+ expect(versusResult(done({ result: "draw" }))).toBeNull();
+ });
+ it("is a knockout when the winner had at least double", () => {
+ const won = done({ result: "won", me: { player: me, value: 20_000 }, opponent: { player: kemi, value: 9000 }, prize: { out: 115 } });
+ expect(versusResult(won)).toMatchObject({ outcome: "win", ko: true, prize: "115" });
+ const close = done({ result: "lost", me: { player: me, value: 9000 }, opponent: { player: kemi, value: 12_000 } });
+ expect(versusResult(close)).toMatchObject({ outcome: "lose", ko: false, prize: null });
+ });
+ it("never knocks out in a timed race", () => {
+ expect(versusResult(done({ mode: "live_race", result: "won", me: { player: me, value: 1500 }, opponent: { player: kemi, value: 3100 } })).ko).toBe(false);
+ });
+ it("shows the winner's win pose and the loser's respect pose, else their card photo", () => {
+ const packed = { ...me, fighter: { vs: "https://x.test/vs.png", win: "https://x.test/win.png", respect: "https://x.test/respect.png" } };
+ const r = versusResult(done({ result: "lost", me: { player: packed, value: 1 }, opponent: { player: kemi, value: 2 } }));
+ expect(r.youResult).toEqual({ uri: "https://x.test/respect.png" });
+ expect(r.themResult).toBe(SHADOW_RUNNERS.womanThem);
+ });
+});
diff --git a/apps/mobile/src/components/rive/assets.js b/apps/mobile/src/components/rive/assets.js
new file mode 100644
index 0000000..874863b
--- /dev/null
+++ b/apps/mobile/src/components/rive/assets.js
@@ -0,0 +1,8 @@
+// The shadow runners the 1v1 card swaps in for a runner with no photo. The
+// card embeds the male ones (blue for you, red for them), so only the
+// female ones ship with the app. They come from rive/versus/images.
+
+export const SHADOW_RUNNERS = {
+ womanYou: require("../../../assets/rive/silhouette-female-blue.jpg"),
+ womanThem: require("../../../assets/rive/silhouette-female-red.jpg"),
+};
diff --git a/apps/mobile/src/components/rive/files.js b/apps/mobile/src/components/rive/files.js
new file mode 100644
index 0000000..79a6d0d
--- /dev/null
+++ b/apps/mobile/src/components/rive/files.js
@@ -0,0 +1,7 @@
+// The Rive files. Their sources live in rive/ at the repo root (build.py
+// there writes them); copy each export here after a rebuild.
+
+export const RIVE_FILES = {
+ versus: require("../../../assets/rive/outhere-versus.riv"),
+ logo: require("../../../assets/rive/outhere-logo.riv"),
+};
diff --git a/apps/mobile/src/components/rive/images.js b/apps/mobile/src/components/rive/images.js
new file mode 100644
index 0000000..1c1002d
--- /dev/null
+++ b/apps/mobile/src/components/rive/images.js
@@ -0,0 +1,18 @@
+// Turns an image source as the app keeps it (a require() number, { uri } or
+// a plain URL) into a URL a Rive player can load. Bundled images go through
+// expo-asset: on a phone it copies them to a local file, on the web it gives
+// the exported file's hashed URL.
+
+import { Platform } from "react-native";
+import { Asset } from "expo-asset";
+
+export async function imageUrl(source) {
+ if (source == null) return null;
+ if (typeof source === "string") return source;
+ if (typeof source === "object" && source.uri) return source.uri;
+ if (typeof source !== "number") return null;
+ const asset = Asset.fromModule(source);
+ if (Platform.OS === "web") return asset.uri;
+ await asset.downloadAsync();
+ return asset.localUri || asset.uri;
+}
diff --git a/apps/mobile/src/components/rive/index.js b/apps/mobile/src/components/rive/index.js
new file mode 100644
index 0000000..e2a84f9
--- /dev/null
+++ b/apps/mobile/src/components/rive/index.js
@@ -0,0 +1,8 @@
+// Rive in the app: the animated logo and the 1v1 card. riveAvailable says
+// whether this build can play them (not in Expo Go); when it can't, screens
+// draw their React Native versions.
+
+export { riveAvailable } from "./runtime";
+export { default as RiveLogo } from "./RiveLogo";
+export { default as VersusCard } from "./VersusCard";
+export * from "./versusData";
diff --git a/apps/mobile/src/components/rive/runtime.js b/apps/mobile/src/components/rive/runtime.js
new file mode 100644
index 0000000..a167291
--- /dev/null
+++ b/apps/mobile/src/components/rive/runtime.js
@@ -0,0 +1,43 @@
+// Whether this build can play Rive, and the player when it can.
+//
+// Rive is native code (@rive-app/react-native on Nitro), so it plays in our
+// own builds (the EAS development build and the store builds) but not in
+// Expo Go, which only carries Expo's own native modules. There the import
+// throws, `rive` stays null and every Rive screen draws its older React
+// Native version instead. The web has its own player in runtime.web.js.
+
+import Constants, { ExecutionEnvironment } from "expo-constants";
+
+let rive = null;
+if (Constants.executionEnvironment !== ExecutionEnvironment.StoreClient) {
+ try {
+ rive = require("@rive-app/react-native");
+ } catch {
+ rive = null;
+ }
+}
+
+export const riveNative = rive;
+export const riveAvailable = !!rive;
+
+/**
+ * Listens for the file's events (landed, slam, addPack, ...) and returns the
+ * function that stops listening, or null where the player has no events.
+ * The newer phone runtime, the default in @rive-app/react-native 0.5, throws
+ * "Events are not supported" from onEventListener; left uncaught that closed
+ * the app as soon as the logo came on screen. Callers stand in with timers.
+ */
+export function listenForEvents(view, onEvent) {
+ try {
+ view.onEventListener(onEvent);
+ } catch {
+ return null;
+ }
+ return () => {
+ try {
+ view.removeEventListeners();
+ } catch {
+ // The view has gone already.
+ }
+ };
+}
diff --git a/apps/mobile/src/components/rive/runtime.web.js b/apps/mobile/src/components/rive/runtime.web.js
new file mode 100644
index 0000000..3c64a12
--- /dev/null
+++ b/apps/mobile/src/components/rive/runtime.web.js
@@ -0,0 +1,11 @@
+// On the web Rive is the canvas runtime (@rive-app/canvas). Its WebAssembly
+// file ships in public/ (copied to the site root by the export), so the
+// loader looks there rather than on a CDN.
+
+import { RuntimeLoader } from "@rive-app/canvas";
+
+const base = process.env.EXPO_BASE_URL || "";
+RuntimeLoader.setWasmUrl(`${base}/rive.wasm`);
+
+export const riveNative = null;
+export const riveAvailable = true;
diff --git a/apps/mobile/src/components/rive/versusData.js b/apps/mobile/src/components/rive/versusData.js
new file mode 100644
index 0000000..65ad2b3
--- /dev/null
+++ b/apps/mobile/src/components/rive/versusData.js
@@ -0,0 +1,177 @@
+// What the 1v1 card (rive/versus) shows for a battle: which intro opens it,
+// the words and numbers it binds, each side's photo and, once the battle is
+// over, the result. Plain functions over the data layer's battle and player
+// views, so both players (native and web) and the tests share them.
+
+import { imageSource } from "@/components/ui/Cover";
+import { distanceLabel } from "@/constants/battleModes";
+import { startOfWeek } from "@/data/time";
+import { fighterPhoto, hasPack } from "@/utils/fighter";
+import { formatNumber } from "@/utils/format";
+import { SHADOW_RUNNERS } from "./assets";
+
+// How long each intro runs before the card is up (rive/versus/README.md).
+export const INTRO_MS = { strike: 1700, tape: 3200, cinematic: 3900, letsRun: 4100 };
+
+// A stake at or over this is a big one and gets the tale of the tape. The
+// create screen starts every challenge at 50, so a lower line would put
+// nearly every battle on the tape and the strike would hardly show.
+export const BIG_STAKE = 100;
+
+const DAY_MS = 86_400_000;
+
+const firstName = (p, fallback) => (p?.name || fallback).split(" ")[0];
+const placeOf = (p) => p?.area || p?.city || "";
+
+/** 11400 as "11.4k", 950 as "950". */
+export const stepsShort = (n) => {
+ const v = Math.max(0, Math.round(Number(n) || 0));
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
+ if (v >= 1000) return `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k`;
+ return String(v);
+};
+
+/** A grudge match: three or more meetings and never more than one win apart. */
+export const isGrudge = (h2h) => (h2h?.played || 0) >= 3 && Math.abs((h2h.mine || 0) - (h2h.theirs || 0)) <= 1;
+
+/**
+ * Which intro opens the card. Let's Run for event battles, the cinematic for
+ * the big ones (a club derby, the top of a leaderboard, a grudge match), the
+ * tale of the tape for a rematch, someone you've met before or a big stake,
+ * and the seam strike for everything else.
+ */
+export function pickIntro(battle, h2h) {
+ if (!battle) return "strike";
+ if (battle.eventId || battle.isEvent) return "letsRun";
+ if (battle.isDerby || battle.isTopOfLeaderboard || isGrudge(h2h)) return "cinematic";
+ if (battle.rematchOf || (h2h?.played || 0) > 0 || (battle.stakeOut || 0) >= BIG_STAKE) return "tape";
+ return "strike";
+}
+
+/** The top bar's rule: "24h · most steps", "Fastest 5 km · 5 km". */
+export function ruleFor(battle) {
+ if (battle.mode === "most_steps") {
+ const goal = battle.metric === "distance" ? "km" : battle.metric === "minutes" ? "minutes" : "steps";
+ return `${clockFor(battle.durationHours)} · most ${goal}`;
+ }
+ return `${battle.modeName || "Battle"}${battle.distanceM ? ` · ${distanceLabel(battle.distanceM)}` : ""}`;
+}
+
+/** The clock tile: "24h", "3d". */
+export const clockFor = (hours) => {
+ const h = Number(hours) || 24;
+ return h >= 48 ? `${Math.round(h / 24)}d` : `${h}h`;
+};
+
+/** Steps a day this week so far, from a player view with weekSteps. */
+export function stepsADay(player, now = Date.now()) {
+ if (player?.weekSteps == null) return null;
+ const days = Math.max(1, Math.ceil((now - startOfWeek(new Date(now)).getTime()) / DAY_MS));
+ return Math.round(player.weekSteps / days);
+}
+
+const bars = (a, b) => {
+ const top = Math.max(a, b);
+ return top > 0 ? [a / top, b / top] : [0, 0];
+};
+
+/**
+ * The tale of the tape's five rows from the two player views (usePlayer:
+ * weekSteps, todaySteps, streak, xp) and the head to head. Each row has the
+ * label, both numbers as shown and each side's bar as a share of the bigger.
+ */
+export function tapeRows({ you, them, h2h, now = Date.now() }) {
+ const rows = [
+ ["STEPS A DAY", stepsADay(you, now) || 0, stepsADay(them, now) || 0, stepsShort],
+ ["TODAY", you?.todaySteps || 0, them?.todaySteps || 0, stepsShort],
+ ["DAY STREAK", you?.streak || 0, them?.streak || 0, (n) => String(n)],
+ ["XP", you?.xp || 0, them?.xp || 0, stepsShort],
+ ["HEAD TO HEAD", h2h?.mine || 0, h2h?.theirs || 0, (n) => String(n)],
+ ];
+ return rows.map(([label, a, b, show]) => {
+ const [youBar, themBar] = bars(a, b);
+ return { label, you: show(a), them: show(b), youBar, themBar };
+ });
+}
+
+const lineFor = (player, stats, now) => {
+ const place = placeOf(player).toUpperCase();
+ const perDay = stepsADay(stats, now);
+ return [place, perDay ? `${stepsShort(perDay).toUpperCase()} STEPS A DAY` : null].filter(Boolean).join(" · ");
+};
+
+/**
+ * The card's photo for one side: their Fighter Pack VS shot, else their own
+ * photo, else the shadow runner for the figure they chose. The male shadow
+ * runners are already in the file, so for those this returns null and the
+ * card keeps its own.
+ */
+export function cardPhoto(player, side) {
+ const { source, isSilhouette } = fighterPhoto(player, side === "you" ? "vs" : "vsMirror");
+ if (!isSilhouette) return source;
+ const figure = player?.runner === "either" ? player?.runnerFigure : player?.runner;
+ if (figure !== "woman") return null;
+ return side === "you" ? SHADOW_RUNNERS.womanYou : SHADOW_RUNNERS.womanThem;
+}
+
+/**
+ * Everything the card binds before it plays. stats is { you, them } from
+ * usePlayer, only needed for the tape and the cinematic lines.
+ */
+export function versusCard({ battle, me, opp, h2h, stats = {}, intro = pickIntro(battle, h2h), now = Date.now() }) {
+ const stake = battle.stakeOut || 0;
+ const strings = {
+ youName: firstName(me, "You"),
+ youCity: placeOf(me),
+ themName: firstName(opp, "Rival"),
+ themCity: placeOf(opp),
+ title: `1V1${battle.rematchOf ? " · REMATCH" : battle.isSurprise ? " · MYSTERY" : ""}`,
+ rule: ruleFor(battle),
+ stake: formatNumber(stake),
+ prize: formatNumber(stake * 2 || 15),
+ clock: clockFor(battle.durationHours),
+ youLine: lineFor(me, stats.you, now),
+ themLine: lineFor(opp, stats.them, now),
+ };
+ const numbers = {};
+ if (intro === "tape") {
+ tapeRows({ you: stats.you, them: stats.them, h2h, now }).forEach((row, i) => {
+ const n = i + 1;
+ strings[`tape${n}Label`] = row.label;
+ strings[`tape${n}You`] = row.you;
+ strings[`tape${n}Them`] = row.them;
+ numbers[`tape${n}YouBar`] = row.youBar;
+ numbers[`tape${n}ThemBar`] = row.themBar;
+ });
+ }
+ return {
+ intro,
+ strings,
+ numbers,
+ booleans: { youHasPack: hasPack(me) },
+ photos: { youPhoto: cardPhoto(me, "you"), themPhoto: cardPhoto(opp, "them") },
+ };
+}
+
+/**
+ * The result, from your side, once a battle is over: "win" or "lose" (a draw
+ * has none), whether it's a knockout (in a most-steps battle the winner has
+ * at least double the loser's count), each side's result shot (the winner's
+ * win pose, the loser's respect pose, else their card photo) and the prize.
+ */
+export function versusResult(battle) {
+ if (battle?.status !== "complete" || (battle.result !== "won" && battle.result !== "lost")) return null;
+ const won = battle.result === "won";
+ const mine = Number(battle.me?.value) || 0;
+ const theirs = Number(battle.opponent?.value) || 0;
+ const [top, bottom] = won ? [mine, theirs] : [theirs, mine];
+ const ko = battle.mode === "most_steps" && top > 0 && top >= bottom * 2;
+ const shot = (player, side, pose) => imageSource(player?.fighter?.[pose]) || cardPhoto(player, side);
+ return {
+ outcome: won ? "win" : "lose",
+ ko,
+ youResult: shot(battle.me?.player, "you", won ? "win" : "respect"),
+ themResult: shot(battle.opponent?.player, "them", won ? "respect" : "win"),
+ prize: won && battle.prize?.out ? formatNumber(battle.prize.out) : null,
+ };
+}
diff --git a/apps/mobile/src/components/ui/Logo.jsx b/apps/mobile/src/components/ui/Logo.jsx
index 910ed39..c51c435 100644
--- a/apps/mobile/src/components/ui/Logo.jsx
+++ b/apps/mobile/src/components/ui/Logo.jsx
@@ -2,12 +2,18 @@
// out now dot sitting in the gap, the same as a photo ring with its dot.
// Weight A (Edem's pick): ring 12, dot 9, space 5, round ends, on a 100 box.
// With `animate`, the ring draws itself in and then the dot springs into place.
+// Where the build can play Rive (not Expo Go), an animated mark in the house
+// colours is the Rive one (rive/logo): the same landing with a ping, a glow
+// that breathes and a light tap in the hand as the dot lands.
import React, { useEffect } from "react";
import { Text, View } from "react-native";
import Svg, { Circle, Path } from "react-native-svg";
import Animated, { Easing, useAnimatedProps, useReducedMotion, useSharedValue, withDelay, withSpring, withTiming } from "react-native-reanimated";
import { C, F } from "./tokens";
+import RiveLogo from "../rive/RiveLogo";
+import { riveAvailable } from "../rive/runtime";
+import { haptic } from "@/services/haptics";
const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -42,6 +48,15 @@ export function Logo({ size = 40, ink = C.text, dotInk = C.out, w = 12, dot = 9,
const ringProps = useAnimatedProps(() => ({ strokeDashoffset: length * (1 - drawn.value) }));
const dotProps = useAnimatedProps(() => ({ r: dot * pop.value }));
+ const house = ink === C.text && dotInk === C.out && w === 12 && dot === 9 && space === 5;
+ if (play && house && riveAvailable) {
+ return (
+
+ = 96 ? haptic.tap : undefined} />
+
+ );
+ }
+
return (