Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3abb952
Add the animated Outhere logo as a Rive project
claude Oct 16, 2026
e2b351a
Logo: transparent background and a landed event for haptics
claude Oct 16, 2026
4ed936f
Logo: add opening screen, small and loading versions
claude Oct 16, 2026
85267e0
Add the 1v1 versus card in Rive
claude Oct 16, 2026
5ff4bb9
VS card: bigger runners that fade out where they meet
claude Oct 16, 2026
b76ed2d
VS card: centre each runner on their side and fade more where they meet
claude Oct 16, 2026
3a24d4f
VS card: use the Fighter Pack shadow runners when there is no photo
claude Oct 16, 2026
e3b3207
VS card: lighter fade on the top of their runner
claude Oct 16, 2026
05e23c3
Merge the website branch so the website preview builds here
claude Oct 16, 2026
fee1d1f
Logo splash: OUT larger than HERE; VS card: only soften the top of th…
claude Oct 16, 2026
b8c5572
VS card: WINNER and LOSER hit like the VS, with win and respect shots
claude Oct 16, 2026
05f6bf8
VS card: new female shadow runner
claude Oct 16, 2026
df07ae3
VS card: K.O. for big wins and confetti on every win
claude Oct 16, 2026
4b74cc1
VS card: YOU WIN! and YOU LOSE first, glass K.O. and 3D confetti
claude Oct 16, 2026
7508bc6
Merge main into the Rive branch
claude Oct 16, 2026
a42b69d
Open the VS card with the strike intro
claude Oct 16, 2026
453dbdd
Add the tape, cinematic and Let's Run intros to the VS card
claude Oct 16, 2026
c6a39e2
Soften the VS card's bar glows, colour the leading tape number, drop …
claude Oct 16, 2026
93ce794
Glow the leading tape number along its own digits
claude Oct 16, 2026
42b16ab
Wire the Rive logo and 1v1 card into the phone app
claude Oct 16, 2026
ffa9170
Merge main (Expo SDK 58) into the Rive wiring
claude Oct 17, 2026
67dd472
Phone app: stop the Rive logo closing the app on iPhone
claude Oct 17, 2026
1e71fdf
Phone app: fix the tab bar closing the app on tap
claude Oct 17, 2026
1ba3b36
Phone app: tell the Rive logo it has no view model
claude Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "69115b2d-38e8-4c5c-b0bd-02f1ce15f561"
}
}
}
Expand Down
Binary file added apps/mobile/assets/rive/outhere-logo.riv
Binary file not shown.
Binary file added apps/mobile/assets/rive/outhere-versus.riv
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Binary file added apps/mobile/public/rive.wasm
Binary file not shown.
13 changes: 12 additions & 1 deletion apps/mobile/src/app/battles/[battleId].jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <View style={{ flex: 1, backgroundColor: "#04060b" }} />;
if (reveal === "show") return <VersusReveal battle={battle} onDone={endReveal} />;

return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
Expand Down
137 changes: 127 additions & 10 deletions apps/mobile/src/app/battles/countdown/[battleId].jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,6 +51,117 @@ function Burst({ beat, x, y }) {
}

export default function BattleCountdown() {
return riveAvailable ? <CardCountdown /> : <SplitCountdown />;
}

// 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 (
<View style={{ flex: 1, backgroundColor: "#04060b" }}>
<TopBar onBack={() => router.replace("/outside/arena")} />
</View>
);
}

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 (
<View style={{ flex: 1, backgroundColor: "#04060b" }}>
<VersusCard card={card} onCue={onCue} onReady={() => setStarted(true)} style={StyleSheet.absoluteFill} />
{up && !live ? (
<Animated.View entering={FadeIn.duration(400)} style={[styles.panel, { bottom: height - tilesTop + 14 }]}>
{waiting ? <Text style={styles.panelText}>Sent. The clock starts when {oppName} accepts.</Text> : null}
<GlowButton
title={waiting ? "Back to the Arena" : "See the battle"}
variant="glass"
full
onPress={() => router.replace(waiting ? "/outside/arena" : `/battles/${id}`)}
/>
</Animated.View>
) : null}
</View>
);
}

/** 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();
Expand Down Expand Up @@ -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" },
});
12 changes: 8 additions & 4 deletions apps/mobile/src/components/AppTabBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ function useItems() {
}));
}

/** For <Tabs tabBar={...}>. */
export function TabsTabBar({ state, navigation, hidden }) {
/**
* For <Tabs tabBar={...}>. 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 <TabBar items={items.filter((i) => state.routes.some((r) => r.name === i.key))} current={current} onSelect={onSelect} hidden={hidden} />;
Expand Down
104 changes: 104 additions & 0 deletions apps/mobile/src/components/arena/VersusReveal.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.screen}>
<VersusCard card={card} result={result} onCue={onCue} style={StyleSheet.absoluteFill} />
{result ? <Pressable accessibilityLabel="Carry on to the result" onPress={onDone} style={StyleSheet.absoluteFill} /> : null}
{done ? (
<Animated.View entering={FadeIn.duration(400)} style={[styles.panel, { bottom: height - tilesTop + 14 }]}>
<GlowButton title="Continue" variant="glass" full onPress={onDone} />
</Animated.View>
) : null}
</View>
);
}

const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: "#04060b" },
panel: { position: "absolute", left: 18, right: 18, gap: S.sm },
});
Loading
Loading