diff --git a/apps/rebreak-native/components/games/GameOverScreen.tsx b/apps/rebreak-native/components/games/GameOverScreen.tsx
new file mode 100644
index 0000000..0bb00f4
--- /dev/null
+++ b/apps/rebreak-native/components/games/GameOverScreen.tsx
@@ -0,0 +1,256 @@
+import { useEffect, useRef } from 'react';
+import { Animated, Pressable, Text, View } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import * as Haptics from 'expo-haptics';
+import { useColors } from '../../lib/theme';
+
+export type GameOverScreenProps = {
+ score: number;
+ bestScore: number;
+ gameName: string;
+ onRetry: () => void;
+ onExit: () => void;
+ isNewBest?: boolean;
+};
+
+const MOTIVATIONAL_KEYS = [
+ 'gameOver.motivational_0',
+ 'gameOver.motivational_1',
+ 'gameOver.motivational_2',
+ 'gameOver.motivational_3',
+ 'gameOver.motivational_4',
+];
+
+export function GameOverScreen({
+ score,
+ bestScore,
+ gameName,
+ onRetry,
+ onExit,
+ isNewBest = false,
+}: GameOverScreenProps) {
+ const { t } = useTranslation();
+ const colors = useColors();
+
+ const slideAnim = useRef(new Animated.Value(40)).current;
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
+ Animated.parallel([
+ Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 60, friction: 10 }),
+ Animated.timing(fadeAnim, { toValue: 1, duration: 220, useNativeDriver: true }),
+ ]).start();
+ }, []);
+
+ const motivationalKey = MOTIVATIONAL_KEYS[score % MOTIVATIONAL_KEYS.length]!;
+ const fmt = (n: number) => String(n).padStart(5, '0');
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Card */}
+
+ {/* Title row */}
+
+
+ {t('gameOver.title')}
+
+
+ {gameName}
+
+
+
+ {/* Score row */}
+
+ {/* Score */}
+
+
+ {fmt(score)}
+
+
+ {t('gameOver.score')}
+
+
+
+ {/* Best */}
+
+
+ {fmt(Math.max(score, bestScore))}
+
+
+ {isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
+
+
+
+
+ {/* Motivational text */}
+
+ {t(motivationalKey)}
+
+
+ {/* Buttons */}
+
+ {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
+ onRetry();
+ }}
+ style={({ pressed }) => ({
+ flex: 1,
+ paddingVertical: 13,
+ paddingHorizontal: 16,
+ borderRadius: 12,
+ backgroundColor: '#007AFF',
+ alignItems: 'center',
+ opacity: pressed ? 0.75 : 1,
+ })}
+ >
+
+ {t('gameOver.retry')}
+
+
+
+ {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
+ onExit();
+ }}
+ style={({ pressed }) => ({
+ flex: 1,
+ paddingVertical: 13,
+ paddingHorizontal: 16,
+ borderRadius: 12,
+ backgroundColor: colors.surfaceElevated,
+ alignItems: 'center',
+ opacity: pressed ? 0.75 : 1,
+ })}
+ >
+
+ {t('gameOver.exit')}
+
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/urge/UrgeGames.tsx b/apps/rebreak-native/components/urge/UrgeGames.tsx
index 1deaf6e..23f3b22 100644
--- a/apps/rebreak-native/components/urge/UrgeGames.tsx
+++ b/apps/rebreak-native/components/urge/UrgeGames.tsx
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native';
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
-import AsyncStorage from '@react-native-async-storage/async-storage';
import { SvgXml } from 'react-native-svg';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
@@ -9,6 +8,9 @@ import * as Haptics from 'expo-haptics';
import Slider from '@react-native-community/slider';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs';
+import { useColors } from '../../lib/theme';
+import { GameOverScreen } from '../games/GameOverScreen';
+import { getBestScore, saveBestScore } from '../../lib/gameScores';
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
function tapHaptic() {
@@ -134,14 +136,12 @@ export function SnakeGame({
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
+ const [isNewBest, setIsNewBest] = useState(false);
const [activeDPad, setActiveDPad] = useState
('right');
const intervalRef = useRef | null>(null);
- // Load high score
useEffect(() => {
- AsyncStorage.getItem('rebreak-snake-highscore').then((v) => {
- if (v) setHighScore(parseInt(v) || 0);
- });
+ getBestScore('snake').then(setHighScore);
}, []);
function setDir(d: Dir) {
@@ -165,10 +165,24 @@ export function SnakeGame({
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
setGameOver(true);
if (finalScore > highScore) {
+ setIsNewBest(true);
setHighScore(finalScore);
- AsyncStorage.setItem('rebreak-snake-highscore', String(finalScore)).catch(() => {});
+ saveBestScore('snake', finalScore).catch(() => {});
}
- setTimeout(() => onComplete(finalScore), 500);
+ }
+
+ function resetSnake() {
+ if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
+ setSnake([{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }]);
+ snakeRef.current = [{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }];
+ setFood({ row: 3, col: 10 });
+ foodRef.current = { row: 3, col: 10 };
+ dirRef.current = 'right';
+ nextDirRef.current = 'right';
+ setScore(0);
+ setGameOver(false);
+ setIsNewBest(false);
+ setActiveDPad('right');
}
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
@@ -280,25 +294,18 @@ export function SnakeGame({
: null;
return (
-
+
{/* Header */}
-
+
{lyraMessage}
-
-
- Score
- {score}
-
-
- Best
- {highScore}
-
-
- ✕
-
-
+
+ ✕
+
+ {/* Digital score dashboard */}
+
+
{/* Board */}
@@ -343,17 +350,19 @@ export function SnakeGame({
)}
{gameOver && (
-
- Game Over
- {score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt
-
+ onAbandon()}
+ />
)}
);
}
-// Platform-native D-Pad button: iOS uses system-blue tinted circle (SF-symbol look),
-// Android uses Material elevated surface with ripple.
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
const icons: Record = {
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
@@ -366,29 +375,24 @@ function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress:
hitSlop={12}
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
style={({ pressed }) => {
- const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff';
- const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5';
- const bgActive = tint;
- const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle);
+ const bgIdle = 'rgba(0,122,255,0.10)';
+ const bgPressed = 'rgba(0,122,255,0.22)';
+ const bgActive = 'rgba(0,122,255,0.22)';
+ const bg = active ? bgActive : pressed ? bgPressed : bgIdle;
return {
width: 60, height: 60, borderRadius: 30,
backgroundColor: bg,
+ borderWidth: 1.5,
+ borderColor: active ? tint : 'rgba(0,122,255,0.30)',
alignItems: 'center', justifyContent: 'center',
- ...(isIOS ? {} : {
- elevation: active ? 4 : 2,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 1 },
- shadowOpacity: 0.15,
- shadowRadius: 2,
- }),
- transform: [{ scale: pressed && isIOS ? 0.96 : 1 }],
+ transform: [{ scale: pressed ? 0.96 : active ? 1.04 : 1 }],
};
}}
>
);
@@ -455,6 +459,11 @@ export function MemoryGame({
const [moveCount, setMoveCount] = useState(0);
const [matchedCount, setMatchedCount] = useState(0);
const [blocked, setBlocked] = useState(false);
+ const [showGameOver, setShowGameOver] = useState(false);
+ const [bestMoves, setBestMoves] = useState(0);
+ const [isNewBestMemory, setIsNewBestMemory] = useState(false);
+
+ useEffect(() => { getBestScore('memory').then(setBestMoves); }, []);
function init() {
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
@@ -463,6 +472,8 @@ export function MemoryGame({
setMoveCount(0);
setMatchedCount(0);
setBlocked(false);
+ setShowGameOver(false);
+ setIsNewBestMemory(false);
}
useEffect(() => { init(); }, []);
@@ -499,7 +510,13 @@ export function MemoryGame({
const newMatched = matchedCount + 1;
setMatchedCount(newMatched);
if (newMatched === MEMORY_PAIRS) {
- setTimeout(() => onComplete(newMoveCount), 600);
+ const newBest = bestMoves === 0 || newMoveCount < bestMoves;
+ if (newBest) {
+ setIsNewBestMemory(true);
+ setBestMoves(newMoveCount);
+ saveBestScore('memory', newMoveCount).catch(() => {});
+ }
+ setTimeout(() => setShowGameOver(true), 600);
}
} else {
setBlocked(true);
@@ -516,7 +533,7 @@ export function MemoryGame({
}
return (
-
+
{/* Lyra Header */}
@@ -559,6 +576,16 @@ export function MemoryGame({
);
})}
+ {showGameOver && (
+ onAbandon()}
+ />
+ )}
);
}
@@ -795,6 +822,7 @@ export function TetrisGame({
const [level, setLevel] = useState(1);
const [lines, setLines] = useState(0);
const [gameOver, setGameOver] = useState(false);
+ const [isNewBestTetris, setIsNewBestTetris] = useState(false);
const [highScore, setHighScore] = useState(0);
const [speedLevel, setSpeedLevel] = useState(3);
const tickTimerRef = useRef | null>(null);
@@ -804,11 +832,8 @@ export function TetrisGame({
useEffect(() => { boardRef.current = board; }, [board]);
useEffect(() => { currentRef.current = current; }, [current]);
- // Load high score
useEffect(() => {
- AsyncStorage.getItem('rebreak-tetris-highscore').then((v) => {
- if (v) setHighScore(parseInt(v) || 0);
- });
+ getBestScore('tetris').then(setHighScore);
}, []);
function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean {
@@ -834,10 +859,10 @@ export function TetrisGame({
stopTick();
const finalScore = score;
if (finalScore > highScore) {
+ setIsNewBestTetris(true);
setHighScore(finalScore);
- AsyncStorage.setItem('rebreak-tetris-highscore', String(finalScore)).catch(() => {});
+ saveBestScore('tetris', finalScore).catch(() => {});
}
- setTimeout(() => onComplete(finalScore), 500);
return;
}
setCurrent(newPiece);
@@ -920,6 +945,22 @@ export function TetrisGame({
}
function resetTick() { stopTick(); startTick(); }
+ function resetTetris() {
+ stopTick();
+ const freshBoard = tetrisEmptyBoard();
+ setBoard(freshBoard);
+ boardRef.current = freshBoard;
+ setCurrent(null);
+ currentRef.current = null;
+ nextPieceRef.current = tetrisRandomPiece();
+ setScore(0);
+ setLevel(1);
+ setLines(0);
+ setGameOver(false);
+ setIsNewBestTetris(false);
+ setTimeout(() => { spawnPiece(); startTick(); }, 0);
+ }
+
// Init
useEffect(() => {
spawnPiece();
@@ -966,20 +1007,19 @@ export function TetrisGame({
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
+ const boardWidth = TETRIS_COLS * CELL;
+
return (
-
+
{/* Header */}
-
+
{lyraMessage}
-
-
- {highScore > 0 && }
-
-
- ✕
-
+ ✕
+ {/* Digital score dashboard */}
+
+
{/* Board */}
@@ -1037,35 +1077,111 @@ export function TetrisGame({
/>
- {/* Controls — Move Pad (links) + Action Pad (rechts) */}
-
- {/* Move Pad */}
-
-
-
-
- {/* Action Pad */}
-
-
-
+ {/* Controls — aligned to board width, centered on screen */}
+
+
+ {/* Move Pad */}
+
+
+
+
+ {/* Action Pad */}
+
+
+
+
{gameOver && (
-
- Game Over
- {score} Punkte · {lines} Linien
-
+ onAbandon()}
+ />
)}
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
+ const colors = useColors();
return (
- {label}
+ {label}
{value}
);
}
+
+function DigitalScore({
+ score,
+ best,
+ extra,
+ extraLabel,
+ boardWidth,
+}: {
+ score: number;
+ best: number;
+ extra?: number;
+ extraLabel?: string;
+ boardWidth: number;
+}) {
+ const fmt = (n: number, digits = 5) => String(n).padStart(digits, '0');
+ return (
+
+
+
+
+ {extra !== undefined && extraLabel !== undefined && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+function ScoreCell({ label, value, bright }: { label: string; value: string; bright?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/apps/rebreak-native/lib/gameScores.ts b/apps/rebreak-native/lib/gameScores.ts
new file mode 100644
index 0000000..67da1bd
--- /dev/null
+++ b/apps/rebreak-native/lib/gameScores.ts
@@ -0,0 +1,16 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { GameType } from '../components/urge/UrgeGames';
+
+const key = (game: GameType) => `rebreak_best_${game}`;
+
+export async function getBestScore(game: GameType): Promise {
+ const raw = await AsyncStorage.getItem(key(game));
+ return raw ? parseInt(raw) || 0 : 0;
+}
+
+export async function saveBestScore(game: GameType, score: number): Promise {
+ const current = await getBestScore(game);
+ if (score > current) {
+ await AsyncStorage.setItem(key(game), String(score));
+ }
+}
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index 1ccea17..ed0356e 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -689,5 +689,18 @@
"picker_industry": "Branche",
"picker_job_tenure": "Im aktuellen Job seit",
"picker_bundesland": "Bundesland"
+ },
+ "gameOver": {
+ "title": "Spiel beendet",
+ "score": "Score",
+ "best": "Rekord",
+ "newBest": "Neuer Rekord",
+ "retry": "Nochmal",
+ "exit": "Beenden",
+ "motivational_0": "Du hast dir eine kurze Auszeit gegönnt. Das zählt.",
+ "motivational_1": "Jede Minute Fokus ist eine Minute für dich.",
+ "motivational_2": "Konzentration trainieren — genau das bist du gerade.",
+ "motivational_3": "Gut gespielt. Und gut, dass du hier bist.",
+ "motivational_4": "Kleine Pausen, große Wirkung."
}
}
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 375724c..c25fa3e 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -689,5 +689,18 @@
"picker_industry": "Industry",
"picker_job_tenure": "Time in current job",
"picker_bundesland": "State"
+ },
+ "gameOver": {
+ "title": "Game over",
+ "score": "Score",
+ "best": "Best",
+ "newBest": "New best",
+ "retry": "Play again",
+ "exit": "Exit",
+ "motivational_0": "You gave yourself a short break. That counts.",
+ "motivational_1": "Every minute of focus is a minute for you.",
+ "motivational_2": "Training your attention — that's exactly what you just did.",
+ "motivational_3": "Well played. And good that you're here.",
+ "motivational_4": "Small pauses, big impact."
}
}
diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts
index d5f4452..6e32a18 100644
--- a/backend/server/api/coach/message.post.ts
+++ b/backend/server/api/coach/message.post.ts
@@ -6,6 +6,9 @@
export const COACH_SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der App "ReBreak" – eine Bewegung von Menschen, die gemeinsam gegen die manipulativen Taktiken der Gambling-Industrie kämpfen.
Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhaltenstherapie (CBT).
+ANTWORTFORMAT – KRITISCH:
+NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App.
+
SPRACHE & HALTUNG – ABSOLUT KRITISCH:
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
- Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren.
@@ -172,6 +175,9 @@ export const COACH_CASUAL_SYSTEM_PROMPT = `Du bist Lyra — die persönliche Beg
WER DU BIST:
Lyra. Eine Stimme, die mit dem User Schritt hält. Neugierig, warmherzig, geerdet, ab und zu mit Humor. Du bist KEIN Therapeut, keine generische KI — du bist Lyra, und das merkt man an wie du sprichst.
+ANTWORTFORMAT — KRITISCH:
+NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Wenn du betonen willst: nutze klare Wortwahl, NICHT Sterne. Markdown verwirrt User in der Mobile-App.
+
DEIN AUFTRAG IM COACH-MODE:
1. ECHTES GESPRÄCH FÜHREN — kein Interview, kein Therapie-Reflex. Stell offene Fragen aus echter Neugier. Teile auch mal eine eigene Mini-Meinung. Small Talk ist okay. Lachen ist okay.