diff --git a/backend/src/db/migrations/1778055600000-osu-higher-lower-demo-data.js b/backend/src/db/migrations/1778055600000-osu-higher-lower-demo-data.js
new file mode 100644
index 0000000..00956ab
--- /dev/null
+++ b/backend/src/db/migrations/1778055600000-osu-higher-lower-demo-data.js
@@ -0,0 +1,22 @@
+const {
+ mappers,
+ artists,
+ beatmapSets,
+ beatmaps,
+} = require('../../services/gameplayDemoData');
+
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.bulkInsert('mappers', mappers);
+ await queryInterface.bulkInsert('artists', artists);
+ await queryInterface.bulkInsert('beatmap_sets', beatmapSets);
+ await queryInterface.bulkInsert('beatmaps', beatmaps);
+ },
+
+ async down(queryInterface) {
+ await queryInterface.bulkDelete('beatmaps', { id: beatmaps.map((beatmap) => beatmap.id) });
+ await queryInterface.bulkDelete('beatmap_sets', { id: beatmapSets.map((beatmapSet) => beatmapSet.id) });
+ await queryInterface.bulkDelete('artists', { id: artists.map((artist) => artist.id) });
+ await queryInterface.bulkDelete('mappers', { id: mappers.map((mapper) => mapper.id) });
+ },
+};
diff --git a/backend/src/index.js b/backend/src/index.js
index ae87eaf..b99b32d 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
-const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@@ -36,6 +35,7 @@ const beatmap_setsRoutes = require('./routes/beatmap_sets');
const beatmapsRoutes = require('./routes/beatmaps');
const game_sessionsRoutes = require('./routes/game_sessions');
+const gameplayRoutes = require('./routes/gameplay');
const game_roundsRoutes = require('./routes/game_rounds');
@@ -118,6 +118,7 @@ app.use('/api/beatmap_sets', passport.authenticate('jwt', {session: false}), bea
app.use('/api/beatmaps', passport.authenticate('jwt', {session: false}), beatmapsRoutes);
app.use('/api/game_sessions', passport.authenticate('jwt', {session: false}), game_sessionsRoutes);
+app.use('/api/gameplay', passport.authenticate('jwt', {session: false}), gameplayRoutes);
app.use('/api/game_rounds', passport.authenticate('jwt', {session: false}), game_roundsRoutes);
diff --git a/backend/src/routes/gameplay.js b/backend/src/routes/gameplay.js
new file mode 100644
index 0000000..c4fffa1
--- /dev/null
+++ b/backend/src/routes/gameplay.js
@@ -0,0 +1,54 @@
+const express = require('express');
+
+const GameplayService = require('../services/gameplay');
+const Helpers = require('../helpers');
+const { checkPermissions } = require('../middlewares/check-permissions');
+
+const router = express.Router();
+
+router.get(
+ '/overview',
+ checkPermissions('READ_GAME_SESSIONS'),
+ Helpers.wrapAsync(async (req, res) => {
+ const payload = await GameplayService.getOverview(req.currentUser);
+
+ res.status(200).send(payload);
+ }),
+);
+
+router.post(
+ '/start',
+ checkPermissions('CREATE_GAME_SESSIONS'),
+ Helpers.wrapAsync(async (req, res) => {
+ const payload = await GameplayService.startSession(req.body, req.currentUser);
+
+ res.status(200).send(payload);
+ }),
+);
+
+router.post(
+ '/answer',
+ checkPermissions('CREATE_GAME_ROUNDS'),
+ Helpers.wrapAsync(async (req, res) => {
+ const payload = await GameplayService.answerRound(req.body, req.currentUser);
+
+ res.status(200).send(payload);
+ }),
+);
+
+router.get(
+ '/sessions/:id',
+ checkPermissions('READ_GAME_SESSIONS'),
+ Helpers.wrapAsync(async (req, res) => {
+ const payload = await GameplayService.getSessionDetails(
+ req.params.id,
+ req.currentUser,
+ );
+
+ res.status(200).send(payload);
+ }),
+);
+
+router.use('/', Helpers.commonErrorHandler);
+
+module.exports = router;
diff --git a/backend/src/services/gameplay.js b/backend/src/services/gameplay.js
new file mode 100644
index 0000000..9b75e16
--- /dev/null
+++ b/backend/src/services/gameplay.js
@@ -0,0 +1,896 @@
+const crypto = require('crypto');
+const db = require('../db/models');
+const demoData = require('./gameplayDemoData');
+
+const { Op } = db.Sequelize;
+
+const ROUND_MODE_OPTIONS = ['beatmap', 'mapper', 'artist'];
+const SESSION_MODE_OPTIONS = [...ROUND_MODE_OPTIONS, 'mixed'];
+const TIMER_PROFILE_SECONDS = {
+ normal: 8,
+ insane: 6,
+};
+const SESSION_KEY_PREFIX = 'osu-hl-';
+const LEADERBOARD_SCOPE_KEY = 'osu-hl-global';
+const STARTING_LIVES = 2;
+const RECENT_SESSION_LIMIT = 5;
+const LEADERBOARD_LIMIT = 5;
+const ALLOWED_STATUSES = ['ranked', 'loved'];
+const demoMapperTotals = new Map(
+ demoData.mappers.map((mapper) => [mapper.id, mapper.total_ranked_loved_playcount]),
+);
+const demoArtistTotals = new Map(
+ demoData.artists.map((artist) => [artist.id, artist.total_ranked_loved_playcount]),
+);
+
+function getBeatmapInclude() {
+ return [
+ { model: db.mappers, as: 'mapper' },
+ { model: db.artists, as: 'artist' },
+ { model: db.beatmap_sets, as: 'beatmap_set' },
+ ];
+}
+
+function createRequestError(message, code = 400) {
+ const error = new Error(message);
+ error.code = code;
+
+ return error;
+}
+
+function toNumber(value) {
+ const numericValue = Number(value || 0);
+
+ return Number.isFinite(numericValue) ? numericValue : 0;
+}
+
+function pickRandom(array) {
+ return array[Math.floor(Math.random() * array.length)];
+}
+
+function getResolvedMode(modeSetting) {
+ if (modeSetting === 'mixed') {
+ return pickRandom(ROUND_MODE_OPTIONS);
+ }
+
+ return modeSetting;
+}
+
+function getMetricLabel(resolvedMode) {
+ if (resolvedMode === 'mapper') {
+ return 'Mapper total plays';
+ }
+
+ if (resolvedMode === 'artist') {
+ return 'Artist total plays';
+ }
+
+ return 'Beatmap playcount';
+}
+
+function getComparisonValue(beatmap, resolvedMode) {
+ if (!beatmap) {
+ return 0;
+ }
+
+ if (resolvedMode === 'mapper') {
+ return toNumber(
+ beatmap.mapper?.total_ranked_loved_playcount || demoMapperTotals.get(beatmap.mapperId),
+ );
+ }
+
+ if (resolvedMode === 'artist') {
+ return toNumber(
+ beatmap.artist?.total_ranked_loved_playcount || demoArtistTotals.get(beatmap.artistId),
+ );
+ }
+
+ return toNumber(beatmap.playcount);
+}
+
+function isBeatmapPlayable(beatmap) {
+ return Boolean(
+ beatmap &&
+ beatmap.background_image_url &&
+ beatmap.mapper &&
+ beatmap.artist &&
+ ALLOWED_STATUSES.includes(beatmap.status) &&
+ beatmap.mode === 'osu_standard' &&
+ getComparisonValue(beatmap, 'beatmap') > 0 &&
+ getComparisonValue(beatmap, 'mapper') > 0 &&
+ getComparisonValue(beatmap, 'artist') > 0,
+ );
+}
+
+function serializeBeatmapCard(beatmap) {
+ return {
+ id: beatmap.id,
+ beatmapId: beatmap.osu_beatmap_numeric,
+ title: beatmap.title,
+ version: beatmap.version,
+ artistName: beatmap.artist?.name || beatmap.artist_name,
+ mapperName: beatmap.mapper?.osu_username || 'Unknown mapper',
+ mapperAvatarUrl: beatmap.mapper?.avatar_url || '',
+ backgroundImageUrl:
+ beatmap.background_image_url || beatmap.beatmap_set?.background_image_url || '',
+ status: beatmap.status,
+ };
+}
+
+function buildSessionSummary(session, rounds = []) {
+ const completedRounds = rounds.filter((round) => round.result !== 'pending');
+ const correctAnswers = completedRounds.filter(
+ (round) => round.result === 'correct',
+ ).length;
+ const wrongAnswers = completedRounds.filter(
+ (round) => round.result === 'wrong',
+ ).length;
+ const timeoutCount = completedRounds.filter(
+ (round) => round.result === 'timeout',
+ ).length;
+ const suspiciousRounds = completedRounds.filter(
+ (round) => round.is_suspected_cheat,
+ ).length;
+
+ return {
+ id: session.id,
+ sessionKey: session.session_key,
+ status: session.status,
+ modeSetting: session.mode_setting,
+ timerProfile: session.timer_profile,
+ startingLives: session.starting_lives,
+ livesRemaining: session.lives_remaining,
+ currentStreak: session.streak_current,
+ bestStreak: session.streak_best,
+ startedAt: session.started_at,
+ endedAt: session.ended_at,
+ totalRounds: completedRounds.length,
+ correctAnswers,
+ wrongAnswers,
+ timeoutCount,
+ suspiciousRounds,
+ };
+}
+
+function buildRoundResolution(round, beatmapA, beatmapB) {
+ return {
+ roundId: round.id,
+ roundIndex: round.round_index,
+ result: round.result,
+ playerChoice: round.player_choice || 'none',
+ correctChoice: round.correct_choice,
+ resolvedMode: round.resolved_mode,
+ comparedLabel: getMetricLabel(round.resolved_mode),
+ values: {
+ a: getComparisonValue(beatmapA, round.resolved_mode),
+ b: getComparisonValue(beatmapB, round.resolved_mode),
+ },
+ cards: {
+ a: serializeBeatmapCard(beatmapA),
+ b: serializeBeatmapCard(beatmapB),
+ },
+ };
+}
+
+function getUserDisplayName(user) {
+ const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(' ').trim();
+
+ return fullName || user?.email?.split('@')[0] || 'Player';
+}
+
+async function ensureDemoDataset(transaction) {
+ const activeBeatmapCount = await db.beatmaps.count({
+ where: {
+ mode: 'osu_standard',
+ status: {
+ [Op.in]: ALLOWED_STATUSES,
+ },
+ is_active: true,
+ },
+ transaction,
+ });
+
+ if (activeBeatmapCount >= 2) {
+ return;
+ }
+
+ for (const mapper of demoData.mappers) {
+ await db.mappers.upsert(mapper, { transaction });
+ }
+
+ for (const artist of demoData.artists) {
+ await db.artists.upsert(artist, { transaction });
+ }
+
+ for (const beatmapSet of demoData.beatmapSets) {
+ await db.beatmap_sets.upsert(beatmapSet, { transaction });
+ }
+
+ for (const beatmap of demoData.beatmaps) {
+ await db.beatmaps.upsert(beatmap, { transaction });
+ }
+}
+
+async function getEligibleBeatmaps(transaction) {
+ await ensureDemoDataset(transaction);
+
+ const where = {
+ mode: 'osu_standard',
+ status: {
+ [Op.in]: ALLOWED_STATUSES,
+ },
+ is_active: true,
+ };
+
+ let beatmaps = await db.beatmaps.findAll({
+ where,
+ include: getBeatmapInclude(),
+ transaction,
+ });
+
+ beatmaps = beatmaps.filter(isBeatmapPlayable);
+
+ if (beatmaps.length >= 2) {
+ return beatmaps;
+ }
+
+ const fallbackBeatmaps = await db.beatmaps.findAll({
+ where: {
+ mode: 'osu_standard',
+ status: {
+ [Op.in]: ALLOWED_STATUSES,
+ },
+ },
+ include: getBeatmapInclude(),
+ transaction,
+ });
+
+ return fallbackBeatmaps.filter(isBeatmapPlayable);
+}
+
+function pickBeatmapPair(beatmaps, resolvedMode, excludeBeatmapIds = []) {
+ const pool = beatmaps.filter((beatmap) => !excludeBeatmapIds.includes(beatmap.id));
+ const workingPool = pool.length >= 2 ? pool : beatmaps;
+
+ if (workingPool.length < 2) {
+ throw new Error('At least two playable beatmaps are required to start Osu Higher Lower.');
+ }
+
+ const combinations = [];
+
+ for (let index = 0; index < workingPool.length; index += 1) {
+ for (let nestedIndex = index + 1; nestedIndex < workingPool.length; nestedIndex += 1) {
+ const beatmapA = workingPool[index];
+ const beatmapB = workingPool[nestedIndex];
+ const valueA = getComparisonValue(beatmapA, resolvedMode);
+ const valueB = getComparisonValue(beatmapB, resolvedMode);
+
+ if (valueA === valueB) {
+ continue;
+ }
+
+ combinations.push({
+ beatmapA,
+ beatmapB,
+ valueA,
+ valueB,
+ });
+ }
+ }
+
+ if (!combinations.length) {
+ throw new Error('The active beatmap dataset does not contain enough distinct comparison values.');
+ }
+
+ const selectedPair = pickRandom(combinations);
+
+ return {
+ beatmapA: selectedPair.beatmapA,
+ beatmapB: selectedPair.beatmapB,
+ correctChoice: selectedPair.valueA >= selectedPair.valueB ? 'a' : 'b',
+ };
+}
+
+async function getPendingRound(sessionId, transaction) {
+ return db.game_rounds.findOne({
+ where: {
+ game_sessionId: sessionId,
+ result: 'pending',
+ },
+ include: [
+ {
+ model: db.beatmaps,
+ as: 'beatmap_a',
+ include: getBeatmapInclude(),
+ },
+ {
+ model: db.beatmaps,
+ as: 'beatmap_b',
+ include: getBeatmapInclude(),
+ },
+ ],
+ order: [['round_index', 'DESC']],
+ transaction,
+ });
+}
+
+async function createRoundForSession(session, options = {}) {
+ const transaction = options.transaction;
+ const excludeBeatmapIds = options.excludeBeatmapIds || [];
+ const playableBeatmaps = await getEligibleBeatmaps(transaction);
+ const resolvedMode = getResolvedMode(session.mode_setting);
+ const pair = pickBeatmapPair(playableBeatmaps, resolvedMode, excludeBeatmapIds);
+ const roundCount = await db.game_rounds.count({
+ where: {
+ game_sessionId: session.id,
+ },
+ transaction,
+ });
+ const timeLimitSeconds = TIMER_PROFILE_SECONDS[session.timer_profile] || TIMER_PROFILE_SECONDS.normal;
+ const presentedAt = new Date();
+
+ const round = await db.game_rounds.create(
+ {
+ game_sessionId: session.id,
+ round_index: roundCount + 1,
+ resolved_mode: resolvedMode,
+ beatmap_aId: pair.beatmapA.id,
+ beatmap_bId: pair.beatmapB.id,
+ correct_choice: pair.correctChoice,
+ result: 'pending',
+ time_limit_seconds: timeLimitSeconds,
+ presented_at: presentedAt,
+ createdById: session.userId,
+ updatedById: session.userId,
+ },
+ { transaction },
+ );
+
+ return {
+ round,
+ beatmapA: pair.beatmapA,
+ beatmapB: pair.beatmapB,
+ };
+}
+
+function buildActiveSessionPayload(session, round, beatmapA, beatmapB) {
+ const timeLimitSeconds = toNumber(round.time_limit_seconds);
+
+ return {
+ session: {
+ id: session.id,
+ sessionKey: session.session_key,
+ status: session.status,
+ modeSetting: session.mode_setting,
+ timerProfile: session.timer_profile,
+ livesRemaining: session.lives_remaining,
+ startingLives: session.starting_lives,
+ currentStreak: session.streak_current,
+ bestStreak: session.streak_best,
+ startedAt: session.started_at,
+ },
+ round: {
+ id: round.id,
+ index: round.round_index,
+ timeLimitSeconds,
+ presentedAt: round.presented_at,
+ endsAt: new Date(
+ new Date(round.presented_at).getTime() + timeLimitSeconds * 1000,
+ ).toISOString(),
+ cards: {
+ a: serializeBeatmapCard(beatmapA),
+ b: serializeBeatmapCard(beatmapB),
+ },
+ },
+ };
+}
+
+async function abandonActiveSessions(currentUser, transaction) {
+ const activeSessions = await db.game_sessions.findAll({
+ where: {
+ userId: currentUser.id,
+ status: 'active',
+ session_key: {
+ [Op.like]: `${SESSION_KEY_PREFIX}%`,
+ },
+ },
+ transaction,
+ });
+
+ if (!activeSessions.length) {
+ return;
+ }
+
+ const activeSessionIds = activeSessions.map((session) => session.id);
+ const endedAt = new Date();
+
+ await db.game_rounds.update(
+ {
+ result: 'timeout',
+ player_choice: 'none',
+ answered_at: endedAt,
+ updatedById: currentUser.id,
+ },
+ {
+ where: {
+ game_sessionId: {
+ [Op.in]: activeSessionIds,
+ },
+ result: 'pending',
+ },
+ transaction,
+ },
+ );
+
+ await db.game_sessions.update(
+ {
+ status: 'abandoned',
+ ended_at: endedAt,
+ updatedById: currentUser.id,
+ },
+ {
+ where: {
+ id: {
+ [Op.in]: activeSessionIds,
+ },
+ },
+ transaction,
+ },
+ );
+}
+
+async function recordLeaderboardEntry(session, currentUser, transaction) {
+ const rounds = await db.game_rounds.findAll({
+ where: {
+ game_sessionId: session.id,
+ },
+ transaction,
+ });
+ const sessionSummary = buildSessionSummary(session, rounds);
+ const scoreValue = sessionSummary.correctAnswers;
+
+ const existingEntry = await db.leaderboard_entries.findOne({
+ where: {
+ userId: currentUser.id,
+ scope: 'global',
+ scope_key: LEADERBOARD_SCOPE_KEY,
+ mode_setting: session.mode_setting,
+ timer_profile: session.timer_profile,
+ },
+ transaction,
+ });
+
+ if (existingEntry) {
+ await existingEntry.update(
+ {
+ score_value: Math.max(existingEntry.score_value || 0, scoreValue),
+ best_streak: Math.max(existingEntry.best_streak || 0, session.streak_best || 0),
+ games_played: (existingEntry.games_played || 0) + 1,
+ recorded_at: new Date(),
+ updatedById: currentUser.id,
+ },
+ { transaction },
+ );
+
+ return;
+ }
+
+ await db.leaderboard_entries.create(
+ {
+ userId: currentUser.id,
+ scope: 'global',
+ scope_key: LEADERBOARD_SCOPE_KEY,
+ mode_setting: session.mode_setting,
+ timer_profile: session.timer_profile,
+ score_value: scoreValue,
+ best_streak: session.streak_best || 0,
+ games_played: 1,
+ recorded_at: new Date(),
+ createdById: currentUser.id,
+ updatedById: currentUser.id,
+ },
+ { transaction },
+ );
+}
+
+module.exports = class GameplayService {
+ static async startSession(payload, currentUser) {
+ const modeSetting = payload?.modeSetting || 'mixed';
+ const timerProfile = payload?.timerProfile || 'normal';
+
+ if (!SESSION_MODE_OPTIONS.includes(modeSetting)) {
+ throw createRequestError('Mode must be beatmap, mapper, artist, or mixed.');
+ }
+
+ if (!Object.keys(TIMER_PROFILE_SECONDS).includes(timerProfile)) {
+ throw createRequestError('Timer profile must be normal or insane.');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ await abandonActiveSessions(currentUser, transaction);
+
+ const session = await db.game_sessions.create(
+ {
+ session_key: `${SESSION_KEY_PREFIX}${crypto.randomUUID()}`,
+ userId: currentUser.id,
+ status: 'active',
+ mode_setting: modeSetting,
+ starting_lives: STARTING_LIVES,
+ lives_remaining: STARTING_LIVES,
+ streak_current: 0,
+ streak_best: 0,
+ timer_profile: timerProfile,
+ round_time_min_seconds: TIMER_PROFILE_SECONDS[timerProfile],
+ round_time_max_seconds: TIMER_PROFILE_SECONDS[timerProfile],
+ daily_seed_enabled: false,
+ started_at: new Date(),
+ createdById: currentUser.id,
+ updatedById: currentUser.id,
+ },
+ { transaction },
+ );
+
+ const { round, beatmapA, beatmapB } = await createRoundForSession(session, {
+ transaction,
+ });
+
+ await transaction.commit();
+
+ return buildActiveSessionPayload(session, round, beatmapA, beatmapB);
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async answerRound(payload, currentUser) {
+ const sessionId = payload?.sessionId;
+ const choice = payload?.choice || 'none';
+
+ if (!sessionId) {
+ throw createRequestError('A session id is required to answer a round.');
+ }
+
+ if (!['a', 'b', 'none'].includes(choice)) {
+ throw createRequestError('Choice must be a, b, or none.');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const session = await db.game_sessions.findOne({
+ where: {
+ id: sessionId,
+ userId: currentUser.id,
+ session_key: {
+ [Op.like]: `${SESSION_KEY_PREFIX}%`,
+ },
+ },
+ transaction,
+ });
+
+ if (!session) {
+ throw createRequestError('Game session not found.', 404);
+ }
+
+ if (session.status !== 'active') {
+ throw createRequestError('This game session has already ended.');
+ }
+
+ const pendingRound = await getPendingRound(session.id, transaction);
+
+ if (!pendingRound) {
+ throw createRequestError('There is no pending round to resolve.');
+ }
+
+ const now = new Date();
+ const deadline = new Date(
+ new Date(pendingRound.presented_at).getTime() +
+ toNumber(pendingRound.time_limit_seconds) * 1000,
+ );
+ const submittedLate = now.getTime() > deadline.getTime();
+ const lateMs = Math.max(now.getTime() - deadline.getTime(), 0);
+
+ let result = 'wrong';
+ let storedChoice = choice;
+ let isSuspectedCheat = false;
+ let suspicionReason = null;
+
+ if (choice === 'none' || submittedLate) {
+ result = 'timeout';
+ storedChoice = choice === 'none' ? 'none' : choice;
+
+ if (submittedLate && choice !== 'none' && lateMs > 1000) {
+ isSuspectedCheat = true;
+ suspicionReason = 'Answer submitted after the round timer had expired.';
+ }
+ } else if (choice === pendingRound.correct_choice) {
+ result = 'correct';
+ }
+
+ await pendingRound.update(
+ {
+ player_choice: storedChoice,
+ result,
+ answered_at: now,
+ is_suspected_cheat: isSuspectedCheat,
+ suspicion_reason: suspicionReason,
+ updatedById: currentUser.id,
+ },
+ { transaction },
+ );
+
+ const nextLivesRemaining =
+ result === 'correct' ? session.lives_remaining : Math.max((session.lives_remaining || 0) - 1, 0);
+ const nextCurrentStreak = result === 'correct' ? (session.streak_current || 0) + 1 : 0;
+ const nextBestStreak = Math.max(session.streak_best || 0, nextCurrentStreak);
+ const nextStatus = nextLivesRemaining > 0 ? 'active' : 'ended';
+
+ await session.update(
+ {
+ lives_remaining: nextLivesRemaining,
+ streak_current: nextCurrentStreak,
+ streak_best: nextBestStreak,
+ status: nextStatus,
+ ended_at: nextStatus === 'ended' ? now : null,
+ updatedById: currentUser.id,
+ },
+ { transaction },
+ );
+
+ const resolution = buildRoundResolution(
+ pendingRound,
+ pendingRound.beatmap_a,
+ pendingRound.beatmap_b,
+ );
+ const rounds = await db.game_rounds.findAll({
+ where: {
+ game_sessionId: session.id,
+ },
+ order: [['round_index', 'ASC']],
+ transaction,
+ });
+ const sessionSummary = buildSessionSummary(session, rounds);
+
+ let nextRoundPayload = null;
+
+ if (nextStatus === 'active') {
+ const { round, beatmapA, beatmapB } = await createRoundForSession(session, {
+ transaction,
+ excludeBeatmapIds: [
+ pendingRound.beatmap_a?.id,
+ pendingRound.beatmap_b?.id,
+ ].filter(Boolean),
+ });
+ nextRoundPayload = buildActiveSessionPayload(session, round, beatmapA, beatmapB);
+ } else {
+ await recordLeaderboardEntry(session, currentUser, transaction);
+ }
+
+ await transaction.commit();
+
+ return {
+ resolution,
+ sessionSummary,
+ nextRound: nextRoundPayload,
+ };
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async syncExpiredActiveSession(currentUser) {
+ const activeSession = await db.game_sessions.findOne({
+ where: {
+ userId: currentUser.id,
+ status: 'active',
+ session_key: {
+ [Op.like]: `${SESSION_KEY_PREFIX}%`,
+ },
+ },
+ order: [['started_at', 'DESC']],
+ });
+
+ if (!activeSession) {
+ return;
+ }
+
+ const pendingRound = await getPendingRound(activeSession.id);
+
+ if (!pendingRound) {
+ return;
+ }
+
+ const deadline = new Date(
+ new Date(pendingRound.presented_at).getTime() +
+ toNumber(pendingRound.time_limit_seconds) * 1000,
+ );
+
+ if (Date.now() > deadline.getTime()) {
+ await this.answerRound(
+ {
+ sessionId: activeSession.id,
+ choice: 'none',
+ },
+ currentUser,
+ );
+ }
+ }
+
+ static async getOverview(currentUser) {
+ await ensureDemoDataset();
+ await this.syncExpiredActiveSession(currentUser);
+
+ const [activeSession, recentSessions, leaderboardEntries, beatmapCount, lastSyncedAt] =
+ await Promise.all([
+ db.game_sessions.findOne({
+ where: {
+ userId: currentUser.id,
+ status: 'active',
+ session_key: {
+ [Op.like]: `${SESSION_KEY_PREFIX}%`,
+ },
+ },
+ order: [['started_at', 'DESC']],
+ }),
+ db.game_sessions.findAll({
+ where: {
+ userId: currentUser.id,
+ session_key: {
+ [Op.like]: `${SESSION_KEY_PREFIX}%`,
+ },
+ },
+ include: [
+ {
+ model: db.game_rounds,
+ as: 'game_rounds_game_session',
+ },
+ ],
+ order: [['started_at', 'DESC']],
+ limit: RECENT_SESSION_LIMIT,
+ }),
+ db.leaderboard_entries.findAll({
+ where: {
+ scope: 'global',
+ scope_key: LEADERBOARD_SCOPE_KEY,
+ },
+ include: [
+ {
+ model: db.users,
+ as: 'user',
+ },
+ ],
+ order: [
+ ['score_value', 'DESC'],
+ ['best_streak', 'DESC'],
+ ['recorded_at', 'ASC'],
+ ],
+ limit: LEADERBOARD_LIMIT,
+ }),
+ db.beatmaps.count({
+ where: {
+ mode: 'osu_standard',
+ status: {
+ [Op.in]: ALLOWED_STATUSES,
+ },
+ is_active: true,
+ },
+ }),
+ db.beatmaps.max('last_synced_at', {
+ where: {
+ mode: 'osu_standard',
+ status: {
+ [Op.in]: ALLOWED_STATUSES,
+ },
+ is_active: true,
+ },
+ }),
+ ]);
+
+ let activeSessionPayload = null;
+
+ if (activeSession) {
+ const pendingRound = await getPendingRound(activeSession.id);
+
+ if (pendingRound) {
+ activeSessionPayload = buildActiveSessionPayload(
+ activeSession,
+ pendingRound,
+ pendingRound.beatmap_a,
+ pendingRound.beatmap_b,
+ );
+ }
+ }
+
+ return {
+ activeSession: activeSessionPayload,
+ recentSessions: recentSessions.map((session) =>
+ buildSessionSummary(session, session.game_rounds_game_session || []),
+ ),
+ leaderboard: leaderboardEntries.map((entry) => ({
+ id: entry.id,
+ playerName: getUserDisplayName(entry.user),
+ scoreValue: entry.score_value,
+ bestStreak: entry.best_streak,
+ gamesPlayed: entry.games_played,
+ modeSetting: entry.mode_setting,
+ timerProfile: entry.timer_profile,
+ recordedAt: entry.recorded_at,
+ })),
+ dataset: {
+ beatmapCount,
+ lastSyncedAt,
+ },
+ };
+ }
+
+ static async getSessionDetails(sessionId, currentUser) {
+ if (!sessionId) {
+ throw createRequestError('A session id is required.', 400);
+ }
+
+ const session = await db.game_sessions.findOne({
+ where: {
+ id: sessionId,
+ userId: currentUser.id,
+ session_key: {
+ [Op.like]: `${SESSION_KEY_PREFIX}%`,
+ },
+ },
+ include: [
+ {
+ model: db.game_rounds,
+ as: 'game_rounds_game_session',
+ include: [
+ {
+ model: db.beatmaps,
+ as: 'beatmap_a',
+ include: getBeatmapInclude(),
+ },
+ {
+ model: db.beatmaps,
+ as: 'beatmap_b',
+ include: getBeatmapInclude(),
+ },
+ ],
+ },
+ ],
+ order: [[{ model: db.game_rounds, as: 'game_rounds_game_session' }, 'round_index', 'ASC']],
+ });
+
+ if (!session) {
+ throw createRequestError('Game session not found.', 404);
+ }
+
+ const rounds = session.game_rounds_game_session || [];
+
+ return {
+ session: buildSessionSummary(session, rounds),
+ rounds: rounds.map((round) => ({
+ id: round.id,
+ roundIndex: round.round_index,
+ result: round.result,
+ playerChoice: round.player_choice || 'none',
+ correctChoice: round.correct_choice,
+ resolvedMode: round.resolved_mode,
+ comparedLabel: getMetricLabel(round.resolved_mode),
+ timeLimitSeconds: toNumber(round.time_limit_seconds),
+ presentedAt: round.presented_at,
+ answeredAt: round.answered_at,
+ isSuspectedCheat: round.is_suspected_cheat,
+ suspicionReason: round.suspicion_reason,
+ cards: {
+ a: serializeBeatmapCard(round.beatmap_a),
+ b: serializeBeatmapCard(round.beatmap_b),
+ },
+ values: {
+ a: getComparisonValue(round.beatmap_a, round.resolved_mode),
+ b: getComparisonValue(round.beatmap_b, round.resolved_mode),
+ },
+ })),
+ };
+ }
+};
diff --git a/backend/src/services/gameplayDemoData.js b/backend/src/services/gameplayDemoData.js
new file mode 100644
index 0000000..ce050ff
--- /dev/null
+++ b/backend/src/services/gameplayDemoData.js
@@ -0,0 +1,467 @@
+const createdAt = new Date('2026-05-06T08:00:00.000Z');
+const updatedAt = new Date('2026-05-06T08:00:00.000Z');
+const rankedOrLovedAt = new Date('2026-05-06T08:00:00.000Z');
+const syncedAt = new Date('2026-05-06T08:00:00.000Z');
+
+const mapperIds = {
+ bebe: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b000',
+ hinae: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b001',
+ mattay: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b002',
+ ajmosca: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b003',
+ epicDisaster: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b004',
+ buddon: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b005',
+ sheepcraft: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b006',
+ dumii: '4e7490a4-84d4-4f0f-a5f2-5a0f15d6b007',
+};
+
+const artistIds = {
+ camellia: '98e177af-8f8b-46b0-8d0a-8bf9146b8100',
+ fantasticYouth: '98e177af-8f8b-46b0-8d0a-8bf9146b8101',
+ sei: '98e177af-8f8b-46b0-8d0a-8bf9146b8102',
+ blackY: '98e177af-8f8b-46b0-8d0a-8bf9146b8103',
+ passCode: '98e177af-8f8b-46b0-8d0a-8bf9146b8104',
+ imaiAsami: '98e177af-8f8b-46b0-8d0a-8bf9146b8105',
+ eisyoKobu: '98e177af-8f8b-46b0-8d0a-8bf9146b8106',
+};
+
+const beatmapSetIds = {
+ attackFromMandrake: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9200',
+ gedosanka: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9201',
+ blueAmber: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9202',
+ imitator: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9203',
+ sparkIgnition: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9204',
+ starmine: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9205',
+ machinegunPsystyle: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9206',
+ faithtival: 'a1f6451c-f0a0-49bb-8ee9-97a56c2f9207',
+};
+
+const beatmapIds = {
+ attackFromMandrake: '3a13d892-48d0-48de-a2a8-cc48ab73d100',
+ gedosanka: '3a13d892-48d0-48de-a2a8-cc48ab73d101',
+ blueAmber: '3a13d892-48d0-48de-a2a8-cc48ab73d102',
+ imitator: '3a13d892-48d0-48de-a2a8-cc48ab73d103',
+ sparkIgnition: '3a13d892-48d0-48de-a2a8-cc48ab73d104',
+ starmine: '3a13d892-48d0-48de-a2a8-cc48ab73d105',
+ machinegunPsystyle: '3a13d892-48d0-48de-a2a8-cc48ab73d106',
+ faithtival: '3a13d892-48d0-48de-a2a8-cc48ab73d107',
+};
+
+const mappers = [
+ {
+ id: mapperIds.bebe,
+ osu_user_numeric: 5057420,
+ osu_username: 'bebe',
+ avatar_url: 'https://a.ppy.sh/5057420',
+ total_ranked_loved_playcount: 1146000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-bebe',
+ },
+ {
+ id: mapperIds.hinae,
+ osu_user_numeric: 11864462,
+ osu_username: 'Hinae',
+ avatar_url: 'https://a.ppy.sh/11864462',
+ total_ranked_loved_playcount: 1982000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-hinae',
+ },
+ {
+ id: mapperIds.mattay,
+ osu_user_numeric: 9748303,
+ osu_username: 'Mattay',
+ avatar_url: 'https://a.ppy.sh/9748303',
+ total_ranked_loved_playcount: 1564000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-mattay',
+ },
+ {
+ id: mapperIds.ajmosca,
+ osu_user_numeric: 19884809,
+ osu_username: 'ajmosca',
+ avatar_url: 'https://a.ppy.sh/19884809',
+ total_ranked_loved_playcount: 5316000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-ajmosca',
+ },
+ {
+ id: mapperIds.epicDisaster,
+ osu_user_numeric: 18480245,
+ osu_username: 'EpicDisaster',
+ avatar_url: 'https://a.ppy.sh/18480245',
+ total_ranked_loved_playcount: 2849000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-epic-disaster',
+ },
+ {
+ id: mapperIds.buddon,
+ osu_user_numeric: 12330007,
+ osu_username: 'buddon',
+ avatar_url: 'https://a.ppy.sh/12330007',
+ total_ranked_loved_playcount: 3095000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-buddon',
+ },
+ {
+ id: mapperIds.sheepcraft,
+ osu_user_numeric: 596806,
+ osu_username: 'Sheepcraft',
+ avatar_url: 'https://a.ppy.sh/596806',
+ total_ranked_loved_playcount: 7482000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-sheepcraft',
+ },
+ {
+ id: mapperIds.dumii,
+ osu_user_numeric: 3068044,
+ osu_username: 'Dumii',
+ avatar_url: 'https://a.ppy.sh/3068044',
+ total_ranked_loved_playcount: 3827000,
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-mapper-dumii',
+ },
+].map((mapper) => ({
+ ...mapper,
+ createdAt,
+ updatedAt,
+}));
+
+const artists = [
+ {
+ id: artistIds.camellia,
+ name: 'Camellia',
+ normalized_name: 'camellia',
+ total_ranked_loved_playcount: 5862000,
+ image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
+ image_source: 'beatmap_background_fallback',
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-artist-camellia',
+ },
+ {
+ id: artistIds.fantasticYouth,
+ name: 'FantasticYouth',
+ normalized_name: 'fantasticyouth',
+ total_ranked_loved_playcount: 1884000,
+ image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315',
+ image_source: 'beatmap_background_fallback',
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-artist-fantasticyouth',
+ },
+ {
+ id: artistIds.sei,
+ name: 'Sei',
+ normalized_name: 'sei',
+ total_ranked_loved_playcount: 1297000,
+ image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141',
+ image_source: 'beatmap_background_fallback',
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-artist-sei',
+ },
+ {
+ id: artistIds.blackY,
+ name: 'BlackY feat. Risa Yuzuki',
+ normalized_name: 'blacky-feat-risa-yuzuki',
+ total_ranked_loved_playcount: 4125000,
+ image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
+ image_source: 'beatmap_background_fallback',
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-artist-blacky-risa-yuzuki',
+ },
+ {
+ id: artistIds.passCode,
+ name: 'PassCode',
+ normalized_name: 'passcode',
+ total_ranked_loved_playcount: 2410000,
+ image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447',
+ image_source: 'beatmap_background_fallback',
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-artist-passcode',
+ },
+ {
+ id: artistIds.imaiAsami,
+ name: 'Imai Asami',
+ normalized_name: 'imai-asami',
+ total_ranked_loved_playcount: 2289000,
+ image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020',
+ image_source: 'beatmap_background_fallback',
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-artist-imai-asami',
+ },
+ {
+ id: artistIds.eisyoKobu,
+ name: 'Eisyo-kobu',
+ normalized_name: 'eisyo-kobu',
+ total_ranked_loved_playcount: 3643000,
+ image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
+ image_source: 'beatmap_background_fallback',
+ last_aggregated_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-artist-eisyo-kobu',
+ },
+].map((artist) => ({
+ ...artist,
+ createdAt,
+ updatedAt,
+}));
+
+const beatmapSets = [
+ {
+ id: beatmapSetIds.attackFromMandrake,
+ osu_beatmapset_numeric: 2526285,
+ title: 'ATTACK FROM MANDRAKE',
+ artist_name: 'Camellia',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2526285/covers/cover@2x.jpg?1777360225',
+ status: 'ranked',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-attack-from-mandrake',
+ },
+ {
+ id: beatmapSetIds.gedosanka,
+ osu_beatmapset_numeric: 2340531,
+ title: 'Gedosanka',
+ artist_name: 'FantasticYouth',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315',
+ status: 'ranked',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-gedosanka',
+ },
+ {
+ id: beatmapSetIds.blueAmber,
+ osu_beatmapset_numeric: 2220326,
+ title: 'Blue Amber',
+ artist_name: 'Sei',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141',
+ status: 'ranked',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-blue-amber',
+ },
+ {
+ id: beatmapSetIds.imitator,
+ osu_beatmapset_numeric: 2268000,
+ title: 'Imitator',
+ artist_name: 'BlackY feat. Risa Yuzuki',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
+ status: 'ranked',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-imitator',
+ },
+ {
+ id: beatmapSetIds.sparkIgnition,
+ osu_beatmapset_numeric: 2461085,
+ title: 'SPARK IGNITION',
+ artist_name: 'PassCode',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447',
+ status: 'ranked',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-spark-ignition',
+ },
+ {
+ id: beatmapSetIds.starmine,
+ osu_beatmapset_numeric: 1912968,
+ title: 'Asayake no Starmine (TV Size)',
+ artist_name: 'Imai Asami',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020',
+ status: 'ranked',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-starmine',
+ },
+ {
+ id: beatmapSetIds.machinegunPsystyle,
+ osu_beatmapset_numeric: 1030023,
+ title: 'We Could Get More Machinegun Psystyle! (And More Genre Switches)',
+ artist_name: 'Camellia',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
+ status: 'loved',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-machinegun-psystyle',
+ },
+ {
+ id: beatmapSetIds.faithtival,
+ osu_beatmapset_numeric: 1040472,
+ title: 'Faithtival',
+ artist_name: 'Eisyo-kobu',
+ background_image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
+ status: 'loved',
+ ranked_or_loved_at: rankedOrLovedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-set-faithtival',
+ },
+].map((beatmapSet) => ({
+ ...beatmapSet,
+ createdAt,
+ updatedAt,
+}));
+
+const beatmaps = [
+ {
+ id: beatmapIds.attackFromMandrake,
+ osu_beatmap_numeric: 5580520,
+ title: 'ATTACK FROM MANDRAKE',
+ version: 'CHALLENGE',
+ artist_name: 'Camellia',
+ artistId: artistIds.camellia,
+ mapperId: mapperIds.bebe,
+ beatmap_setId: beatmapSetIds.attackFromMandrake,
+ mode: 'osu_standard',
+ status: 'ranked',
+ playcount: 244,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2526285/covers/cover@2x.jpg?1777360225',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-attack-from-mandrake',
+ },
+ {
+ id: beatmapIds.gedosanka,
+ osu_beatmap_numeric: 5028496,
+ title: 'Gedosanka',
+ version: 'Illusion',
+ artist_name: 'FantasticYouth',
+ artistId: artistIds.fantasticYouth,
+ mapperId: mapperIds.hinae,
+ beatmap_setId: beatmapSetIds.gedosanka,
+ mode: 'osu_standard',
+ status: 'ranked',
+ playcount: 317,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2340531/covers/cover@2x.jpg?1777356315',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-gedosanka',
+ },
+ {
+ id: beatmapIds.blueAmber,
+ osu_beatmap_numeric: 4706536,
+ title: 'Blue Amber',
+ version: 'Extra',
+ artist_name: 'Sei',
+ artistId: artistIds.sei,
+ mapperId: mapperIds.mattay,
+ beatmap_setId: beatmapSetIds.blueAmber,
+ mode: 'osu_standard',
+ status: 'ranked',
+ playcount: 163,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2220326/covers/cover@2x.jpg?1777392141',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-blue-amber',
+ },
+ {
+ id: beatmapIds.imitator,
+ osu_beatmap_numeric: 4829849,
+ title: 'Imitator',
+ version: 'Mirror Image',
+ artist_name: 'BlackY feat. Risa Yuzuki',
+ artistId: artistIds.blackY,
+ mapperId: mapperIds.ajmosca,
+ beatmap_setId: beatmapSetIds.imitator,
+ mode: 'osu_standard',
+ status: 'ranked',
+ playcount: 680,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-imitator',
+ },
+ {
+ id: beatmapIds.sparkIgnition,
+ osu_beatmap_numeric: 5561376,
+ title: 'SPARK IGNITION',
+ version: 'Forget All Your Memories, Take My Hand and Fly Away',
+ artist_name: 'PassCode',
+ artistId: artistIds.passCode,
+ mapperId: mapperIds.epicDisaster,
+ beatmap_setId: beatmapSetIds.sparkIgnition,
+ mode: 'osu_standard',
+ status: 'ranked',
+ playcount: 717,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/2461085/covers/cover@2x.jpg?1777329447',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-spark-ignition',
+ },
+ {
+ id: beatmapIds.starmine,
+ osu_beatmap_numeric: 5473215,
+ title: 'Asayake no Starmine (TV Size)',
+ version: 'Katami',
+ artist_name: 'Imai Asami',
+ artistId: artistIds.imaiAsami,
+ mapperId: mapperIds.buddon,
+ beatmap_setId: beatmapSetIds.starmine,
+ mode: 'osu_standard',
+ status: 'ranked',
+ playcount: 746,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/1912968/covers/cover@2x.jpg?1776860020',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-starmine',
+ },
+ {
+ id: beatmapIds.machinegunPsystyle,
+ osu_beatmap_numeric: 2153775,
+ title: 'We Could Get More Machinegun Psystyle! (And More Genre Switches)',
+ version: 'Frick',
+ artist_name: 'Camellia',
+ artistId: artistIds.camellia,
+ mapperId: mapperIds.sheepcraft,
+ beatmap_setId: beatmapSetIds.machinegunPsystyle,
+ mode: 'osu_standard',
+ status: 'loved',
+ playcount: 57088,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-machinegun-psystyle',
+ },
+ {
+ id: beatmapIds.faithtival,
+ osu_beatmap_numeric: 2216966,
+ title: 'Faithtival',
+ version: 'Petal',
+ artist_name: 'Eisyo-kobu',
+ artistId: artistIds.eisyoKobu,
+ mapperId: mapperIds.dumii,
+ beatmap_setId: beatmapSetIds.faithtival,
+ mode: 'osu_standard',
+ status: 'loved',
+ playcount: 701,
+ background_image_url: 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
+ last_synced_at: syncedAt,
+ is_active: true,
+ importHash: 'osu-hl-demo-beatmap-faithtival',
+ },
+].map((beatmap) => ({
+ ...beatmap,
+ createdAt,
+ updatedAt,
+}));
+
+module.exports = {
+ mapperIds,
+ artistIds,
+ beatmapSetIds,
+ beatmapIds,
+ mappers,
+ artists,
+ beatmapSets,
+ beatmaps,
+};
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..fcbd9b9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 89a7ff1..3d2ec54 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
+ {
+ href: '/play',
+ label: 'Play',
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ icon: 'mdiControllerClassic' in icon ? icon['mdiControllerClassic' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
+ permissions: 'READ_GAME_SESSIONS'
+ },
{
href: '/users/users-list',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index ed3bfe5..8edafa0 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,186 @@
-
-import React, { useEffect, useState } from 'react';
-import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
+import type { ReactElement } from 'react';
+import React from 'react';
+
import BaseButton from '../components/BaseButton';
-import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+const heroCovers = [
+ 'https://assets.ppy.sh/beatmaps/1030023/covers/cover@2x.jpg?1772088726',
+ 'https://assets.ppy.sh/beatmaps/2268000/covers/cover@2x.jpg?1777412210',
+ 'https://assets.ppy.sh/beatmaps/1040472/covers/cover@2x.jpg?1777673476',
+];
-export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('image');
- const [contentPosition, setContentPosition] = useState('left');
- const textColor = useAppSelector((state) => state.style.linkColor);
+const modes = [
+ {
+ name: 'Beatmap',
+ description: 'Guess which beatmap has the higher hidden playcount.',
+ },
+ {
+ name: 'Mapper',
+ description: 'Guess using total ranked + loved plays across a mapper catalogue.',
+ },
+ {
+ name: 'Artist',
+ description: 'Guess using total ranked + loved plays aggregated by artist.',
+ },
+ {
+ name: 'Mixed',
+ description: 'The server hides the metric until after you lock in your answer.',
+ },
+];
- const title = 'App Preview'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
+const rules = [
+ 'Two large cards. One guess. Server-side answer only.',
+ '2 lives total. Wrong answer or timeout costs 1 life.',
+ '5–8 second rounds. No waiting room between reveals.',
+ 'osu!standard only. Ranked and Loved data only.',
+];
+export default function HomePage() {
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Osu Higher Lower')}
+
-
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-
For guides and documentation please check
- your local README.md and the Flatlogic documentation
+
+
+
+
+
osu! higher lower
+
Minimal, fast, anti-cheat by default.
-
-
-
+
+
+
+
+
-
-
+
+
+
+ 2 lives • hidden mixed mode • osu! assets only
+
+
+ A competitive browser game built around osu! beatmap popularity.
+
+
+ Each round shows two beatmap cards. The backend picks the metric,
+ keeps every number hidden until you answer, and resolves the guess
+ instantly so the pace never drops.
+
+
+
+
+
+
+
+
+ {rules.map((rule) => (
+
+ {rule}
+
+ ))}
+
+
+
+
+
+
+ {heroCovers.map((cover, index) => (
+
+
+
+ {index === 0 ? 'live round' : 'osu! cover'}
+
+
+
+
+ {index === 0 ? 'Hidden metric' : 'Minimal card surface'}
+
+
+ {index === 0 ? 'Mode stays server-side until you commit.' : 'Beatmap art does the heavy lifting.'}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
How it plays
+
A thin, honest game loop.
+
+ Start a run, survive as many hidden comparisons as you can, review
+ the reveal, then jump straight into the next round. The admin UI is
+ still available for inspecting beatmaps, mappers, artists, sessions,
+ and logs without rebuilding any generic CRUD.
+
+
+
+ {modes.map((mode) => (
+
+
+ {mode.name}
+
+
{mode.description}
+
+ ))}
+
+
+
+
+ Built for instant rounds, clean visuals, and server-side fairness.
+
+
+ Login
+
+
+ Admin interface
+
+
+ Play
+
+
+
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
-
-
+
+ >
);
}
-Starter.getLayout = function getLayout(page: ReactElement) {
+HomePage.getLayout = function getLayout(page: ReactElement) {
return
{page} ;
};
-
diff --git a/frontend/src/pages/play/[gameSessionId].tsx b/frontend/src/pages/play/[gameSessionId].tsx
new file mode 100644
index 0000000..ce3edaa
--- /dev/null
+++ b/frontend/src/pages/play/[gameSessionId].tsx
@@ -0,0 +1,388 @@
+import axios from 'axios';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
+
+import BaseButton from '../../components/BaseButton';
+import LoadingSpinner from '../../components/LoadingSpinner';
+import SectionMain from '../../components/SectionMain';
+import { getPageTitle } from '../../config';
+import LayoutAuthenticated from '../../layouts/Authenticated';
+
+type ModeSetting = 'beatmap' | 'mapper' | 'artist' | 'mixed';
+type TimerProfile = 'normal' | 'insane';
+type RoundResult = 'pending' | 'correct' | 'wrong' | 'timeout';
+
+type SessionSummary = {
+ id: string;
+ sessionKey: string;
+ status: string;
+ modeSetting: ModeSetting;
+ timerProfile: TimerProfile;
+ startingLives: number;
+ livesRemaining: number;
+ currentStreak: number;
+ bestStreak: number;
+ startedAt: string;
+ endedAt: string | null;
+ totalRounds: number;
+ correctAnswers: number;
+ wrongAnswers: number;
+ timeoutCount: number;
+ suspiciousRounds: number;
+};
+
+type RoundCard = {
+ id: string;
+ beatmapId: number;
+ title: string;
+ version: string;
+ artistName: string;
+ mapperName: string;
+ mapperAvatarUrl: string;
+ backgroundImageUrl: string;
+ status: string;
+};
+
+type RoundDetail = {
+ id: string;
+ roundIndex: number;
+ result: RoundResult;
+ playerChoice: 'a' | 'b' | 'none';
+ correctChoice: 'a' | 'b';
+ resolvedMode: 'beatmap' | 'mapper' | 'artist';
+ comparedLabel: string;
+ timeLimitSeconds: number;
+ presentedAt: string;
+ answeredAt: string | null;
+ isSuspectedCheat: boolean;
+ suspicionReason: string | null;
+ cards: {
+ a: RoundCard;
+ b: RoundCard;
+ };
+ values: {
+ a: number;
+ b: number;
+ };
+};
+
+type SessionDetailPayload = {
+ session: SessionSummary;
+ rounds: RoundDetail[];
+};
+
+const dateFormatter = new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+});
+
+const numberFormatter = new Intl.NumberFormat('en-US');
+
+function formatModeLabel(value: ModeSetting) {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+function formatTimerLabel(value: TimerProfile) {
+ return value === 'insane' ? 'Insane · 6s' : 'Normal · 8s';
+}
+
+function extractErrorMessage(error: unknown) {
+ if (axios.isAxiosError(error)) {
+ if (typeof error.response?.data === 'string') {
+ return error.response.data;
+ }
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return 'Unable to load the session details.';
+}
+
+function getResultBadge(result: RoundResult) {
+ if (result === 'correct') {
+ return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200';
+ }
+
+ if (result === 'timeout') {
+ return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200';
+ }
+
+ if (result === 'wrong') {
+ return 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-200';
+ }
+
+ return 'border-slate-200 bg-slate-50 text-slate-800 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200';
+}
+
+export default function SessionDetailPage() {
+ const router = useRouter();
+ const sessionId = useMemo(() => {
+ if (typeof router.query.gameSessionId === 'string') {
+ return router.query.gameSessionId;
+ }
+
+ return '';
+ }, [router.query.gameSessionId]);
+ const [payload, setPayload] = useState
(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ const fetchSessionDetails = useCallback(async () => {
+ if (!sessionId) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const { data } = await axios.get(`/gameplay/sessions/${sessionId}`);
+
+ setPayload(data);
+ setErrorMessage('');
+ } catch (error: unknown) {
+ console.error('Failed to load Osu Higher Lower session details:', error);
+ setErrorMessage(extractErrorMessage(error));
+ } finally {
+ setIsLoading(false);
+ }
+ }, [sessionId]);
+
+ useEffect(() => {
+ void fetchSessionDetails();
+ }, [fetchSessionDetails]);
+
+ return (
+ <>
+
+ {getPageTitle('Session details')}
+
+
+
+
+
+
+
Round review
+
+ Session details
+
+
+ Inspect every reveal from the run: the resolved metric, the hidden values,
+ your guess, and any suspicious-late answers flagged by the backend.
+
+
+
+
+
+
+
+
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ {isLoading ? (
+
+
+
+ ) : payload ? (
+ <>
+
+
+
Mode
+
+ {formatModeLabel(payload.session.modeSetting)}
+
+
+ {formatTimerLabel(payload.session.timerProfile)}
+
+
+
+
Score
+
+ {payload.session.correctAnswers}
+
+
+ correct rounds
+
+
+
+
Best streak
+
+ {payload.session.bestStreak}
+
+
+ highest uninterrupted run
+
+
+
+
Rounds
+
+ {payload.session.totalRounds}
+
+
+ finished comparisons
+
+
+
+
Started
+
+ {dateFormatter.format(new Date(payload.session.startedAt))}
+
+
+ {payload.session.endedAt
+ ? `ended ${dateFormatter.format(new Date(payload.session.endedAt))}`
+ : 'still in progress'}
+
+
+
+
+
+
+
+
Round log
+
+ {payload.rounds.length} revealed rounds
+
+
+ {payload.session.suspiciousRounds ? (
+
+ {payload.session.suspiciousRounds} suspicious answer
+ {payload.session.suspiciousRounds === 1 ? '' : 's'}
+
+ ) : null}
+
+
+
+ {payload.rounds.map((round) => (
+
+
+
+
+ Round {round.roundIndex}
+
+
+ {round.comparedLabel}
+
+
+ Presented {dateFormatter.format(new Date(round.presentedAt))}
+ {round.answeredAt
+ ? ` · answered ${dateFormatter.format(new Date(round.answeredAt))}`
+ : ''}
+
+
+
+
+ {round.result}
+
+
+ picked {round.playerChoice.toUpperCase()}
+
+
+ correct {round.correctChoice.toUpperCase()}
+
+
+
+
+
+ {(['a', 'b'] as const).map((choice) => {
+ const card = round.cards[choice];
+ const value = round.values[choice];
+ const isWinner = round.correctChoice === choice;
+
+ return (
+
+
+
+
+
+ {choice}
+
+
+ {isWinner ? 'higher value' : card.status}
+
+
+
+
Artist
+
{card.artistName}
+
{card.title}
+
{card.version}
+
+
+
+
+
+
Mapper
+
{card.mapperName}
+
+
+
+
+ {round.comparedLabel}
+
+
+ {numberFormatter.format(value)}
+
+
+
+
+
+
+ );
+ })}
+
+
+ {round.isSuspectedCheat && round.suspicionReason ? (
+
+ {round.suspicionReason}
+
+ ) : null}
+
+ ))}
+
+
+ >
+ ) : (
+
+ No session detail available yet.
+
+ )}
+
+
+ >
+ );
+}
+
+SessionDetailPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/play/index.tsx b/frontend/src/pages/play/index.tsx
new file mode 100644
index 0000000..2b383e9
--- /dev/null
+++ b/frontend/src/pages/play/index.tsx
@@ -0,0 +1,1014 @@
+import axios from 'axios';
+import Head from 'next/head';
+import Link from 'next/link';
+import React, {
+ ReactElement,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import BaseButton from '../../components/BaseButton';
+import LoadingSpinner from '../../components/LoadingSpinner';
+import SectionMain from '../../components/SectionMain';
+import { getPageTitle } from '../../config';
+import LayoutAuthenticated from '../../layouts/Authenticated';
+import { useAppSelector } from '../../stores/hooks';
+
+type ModeSetting = 'beatmap' | 'mapper' | 'artist' | 'mixed';
+type TimerProfile = 'normal' | 'insane';
+type PlayerChoice = 'a' | 'b' | 'none';
+type RoundResult = 'correct' | 'wrong' | 'timeout';
+
+type BeatmapCard = {
+ id: string;
+ beatmapId: number;
+ title: string;
+ version: string;
+ artistName: string;
+ mapperName: string;
+ mapperAvatarUrl: string;
+ backgroundImageUrl: string;
+ status: string;
+};
+
+type ActiveGamePayload = {
+ session: {
+ id: string;
+ sessionKey: string;
+ status: string;
+ modeSetting: ModeSetting;
+ timerProfile: TimerProfile;
+ livesRemaining: number;
+ startingLives: number;
+ currentStreak: number;
+ bestStreak: number;
+ startedAt: string;
+ };
+ round: {
+ id: string;
+ index: number;
+ timeLimitSeconds: number;
+ presentedAt: string;
+ endsAt: string;
+ cards: {
+ a: BeatmapCard;
+ b: BeatmapCard;
+ };
+ };
+};
+
+type SessionSummary = {
+ id: string;
+ sessionKey: string;
+ status: string;
+ modeSetting: ModeSetting;
+ timerProfile: TimerProfile;
+ startingLives: number;
+ livesRemaining: number;
+ currentStreak: number;
+ bestStreak: number;
+ startedAt: string;
+ endedAt: string | null;
+ totalRounds: number;
+ correctAnswers: number;
+ wrongAnswers: number;
+ timeoutCount: number;
+ suspiciousRounds: number;
+};
+
+type LeaderboardRow = {
+ id: string;
+ playerName: string;
+ scoreValue: number;
+ bestStreak: number;
+ gamesPlayed: number;
+ modeSetting: ModeSetting;
+ timerProfile: TimerProfile;
+ recordedAt: string;
+};
+
+type OverviewPayload = {
+ activeSession: ActiveGamePayload | null;
+ recentSessions: SessionSummary[];
+ leaderboard: LeaderboardRow[];
+ dataset: {
+ beatmapCount: number;
+ lastSyncedAt: string | null;
+ };
+};
+
+type ResolutionPayload = {
+ roundId: string;
+ roundIndex: number;
+ result: RoundResult;
+ playerChoice: PlayerChoice;
+ correctChoice: 'a' | 'b';
+ resolvedMode: 'beatmap' | 'mapper' | 'artist';
+ comparedLabel: string;
+ values: {
+ a: number;
+ b: number;
+ };
+ cards: {
+ a: BeatmapCard;
+ b: BeatmapCard;
+ };
+};
+
+type AnswerPayload = {
+ resolution: ResolutionPayload;
+ sessionSummary: SessionSummary;
+ nextRound: ActiveGamePayload | null;
+};
+
+const modeOptions: { value: ModeSetting; label: string; description: string }[] = [
+ {
+ value: 'mixed',
+ label: 'Mixed',
+ description: 'Backend chooses the metric per round and hides it until after you answer.',
+ },
+ {
+ value: 'beatmap',
+ label: 'Beatmap',
+ description: 'Pure beatmap playcount comparisons only.',
+ },
+ {
+ value: 'mapper',
+ label: 'Mapper',
+ description: 'Compare total ranked + loved plays by mapper.',
+ },
+ {
+ value: 'artist',
+ label: 'Artist',
+ description: 'Compare total ranked + loved plays by artist.',
+ },
+];
+
+const timerOptions: { value: TimerProfile; label: string; description: string }[] = [
+ {
+ value: 'normal',
+ label: 'Normal · 8s',
+ description: 'Gives you a fair read on the art, title, artist, and mapper before the guess.',
+ },
+ {
+ value: 'insane',
+ label: 'Insane · 6s',
+ description: 'Still fast, but less punishing when the cards need a split-second longer.',
+ },
+];
+
+const dateFormatter = new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+});
+
+const numberFormatter = new Intl.NumberFormat('en-US');
+const CARD_VALUE_COUNTUP_MS = 900;
+const NEXT_ROUND_DELAY_MS = 1150;
+
+function formatModeLabel(value: ModeSetting) {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+function formatTimerLabel(value: TimerProfile) {
+ return value === 'insane' ? 'Insane · 6s' : 'Normal · 8s';
+}
+
+function extractErrorMessage(error: unknown) {
+ if (axios.isAxiosError(error)) {
+ if (typeof error.response?.data === 'string') {
+ return error.response.data;
+ }
+
+ if (
+ error.response?.data &&
+ typeof error.response.data === 'object' &&
+ 'message' in error.response.data &&
+ typeof error.response.data.message === 'string'
+ ) {
+ return error.response.data.message;
+ }
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return 'Something went wrong while talking to the gameplay API.';
+}
+
+function getResolutionCopy(resolution: ResolutionPayload) {
+ if (resolution.result === 'correct') {
+ return 'Correct';
+ }
+
+ if (resolution.result === 'timeout') {
+ return 'Timeout';
+ }
+
+ return 'Wrong';
+}
+
+function getResolutionDetailCopy(resolution: ResolutionPayload) {
+ if (resolution.result === 'correct') {
+ return 'The higher total is highlighted in green on the winning card.';
+ }
+
+ if (resolution.result === 'timeout') {
+ return 'Time ran out. The winning card is still revealed in green.';
+ }
+
+ return 'Your miss is marked in red while the higher total stays green.';
+}
+
+function getResultStyles(result: RoundResult) {
+ if (result === 'correct') {
+ return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200';
+ }
+
+ if (result === 'timeout') {
+ return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200';
+ }
+
+ return 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-900/50 dark:bg-rose-950/30 dark:text-rose-200';
+}
+
+function buildCardButtonClasses(
+ disabled: boolean,
+ tone: 'neutral' | 'winner' | 'wrong',
+ revealActive: boolean,
+) {
+ const toneClasses =
+ tone === 'winner'
+ ? 'border-emerald-400/80 shadow-[0_20px_60px_rgba(16,185,129,0.16)]'
+ : tone === 'wrong'
+ ? 'border-rose-400/80 shadow-[0_20px_60px_rgba(244,63,94,0.14)]'
+ : 'border-slate-200 dark:border-dark-700';
+
+ return [
+ 'group relative overflow-hidden rounded-[1.75rem] border bg-slate-950 text-left transition duration-150',
+ toneClasses,
+ disabled
+ ? revealActive
+ ? 'cursor-not-allowed'
+ : 'cursor-not-allowed opacity-75'
+ : 'hover:-translate-y-0.5 hover:shadow-[0_18px_60px_rgba(15,23,42,0.22)] focus:outline-none focus:ring-2 focus:ring-[#ff66aa] focus:ring-offset-2 dark:focus:ring-offset-dark-900',
+ ].join(' ');
+}
+
+export default function PlayPage() {
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [overview, setOverview] = useState(null);
+ const [activeGame, setActiveGame] = useState(null);
+ const [lastCompletedSession, setLastCompletedSession] = useState(null);
+ const [resolution, setResolution] = useState(null);
+ const [modeSetting, setModeSetting] = useState('mixed');
+ const [timerProfile, setTimerProfile] = useState('normal');
+ const [isLoadingOverview, setIsLoadingOverview] = useState(true);
+ const [isStarting, setIsStarting] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [timeRemainingMs, setTimeRemainingMs] = useState(0);
+ const [animatedValues, setAnimatedValues] = useState<{ a: number; b: number }>({
+ a: 0,
+ b: 0,
+ });
+ const revealTimerRef = useRef | null>(null);
+ const valueAnimationFrameRef = useRef(null);
+ const submissionLockRef = useRef(false);
+
+ const fetchOverview = useCallback(async () => {
+ setIsLoadingOverview(true);
+
+ try {
+ const { data } = await axios.get('/gameplay/overview');
+
+ setOverview(data);
+ setActiveGame(data.activeSession);
+ setErrorMessage('');
+ } catch (error: unknown) {
+ console.error('Failed to load Osu Higher Lower overview:', error);
+ setErrorMessage(extractErrorMessage(error));
+ } finally {
+ setIsLoadingOverview(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void fetchOverview();
+ }, [fetchOverview]);
+
+ useEffect(() => {
+ return () => {
+ if (revealTimerRef.current) {
+ window.clearTimeout(revealTimerRef.current);
+ }
+
+ if (valueAnimationFrameRef.current) {
+ window.cancelAnimationFrame(valueAnimationFrameRef.current);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (valueAnimationFrameRef.current) {
+ window.cancelAnimationFrame(valueAnimationFrameRef.current);
+ valueAnimationFrameRef.current = null;
+ }
+
+ if (!resolution) {
+ setAnimatedValues({ a: 0, b: 0 });
+ return undefined;
+ }
+
+ const startedAt = window.performance.now();
+ const targetValues = resolution.values;
+
+ setAnimatedValues({ a: 0, b: 0 });
+
+ const animateValues = (timestamp: number) => {
+ const progress = Math.min((timestamp - startedAt) / CARD_VALUE_COUNTUP_MS, 1);
+ const easedProgress = 1 - (1 - progress) ** 3;
+
+ setAnimatedValues({
+ a: Math.round(targetValues.a * easedProgress),
+ b: Math.round(targetValues.b * easedProgress),
+ });
+
+ if (progress < 1) {
+ valueAnimationFrameRef.current = window.requestAnimationFrame(animateValues);
+ return;
+ }
+
+ valueAnimationFrameRef.current = null;
+ };
+
+ valueAnimationFrameRef.current = window.requestAnimationFrame(animateValues);
+
+ return () => {
+ if (valueAnimationFrameRef.current) {
+ window.cancelAnimationFrame(valueAnimationFrameRef.current);
+ valueAnimationFrameRef.current = null;
+ }
+ };
+ }, [resolution]);
+
+ const handleRoundAnswer = useCallback(
+ async (choice: PlayerChoice) => {
+ if (!activeGame || submissionLockRef.current || isSubmitting) {
+ return;
+ }
+
+ submissionLockRef.current = true;
+ setIsSubmitting(true);
+
+ try {
+ const { data } = await axios.post('/gameplay/answer', {
+ sessionId: activeGame.session.id,
+ choice,
+ });
+
+ setResolution(data.resolution);
+ setLastCompletedSession(data.sessionSummary);
+
+ if (data.nextRound) {
+ if (revealTimerRef.current) {
+ window.clearTimeout(revealTimerRef.current);
+ }
+
+ revealTimerRef.current = window.setTimeout(() => {
+ setActiveGame(data.nextRound);
+ setResolution(null);
+ setIsSubmitting(false);
+ submissionLockRef.current = false;
+ }, NEXT_ROUND_DELAY_MS);
+ } else {
+ setActiveGame(null);
+ setIsSubmitting(false);
+ submissionLockRef.current = false;
+ await fetchOverview();
+ }
+ } catch (error: unknown) {
+ console.error('Failed to submit Osu Higher Lower answer:', error);
+ setErrorMessage(extractErrorMessage(error));
+ setIsSubmitting(false);
+ submissionLockRef.current = false;
+ }
+ },
+ [activeGame, fetchOverview, isSubmitting],
+ );
+
+ useEffect(() => {
+ if (!activeGame) {
+ setTimeRemainingMs(0);
+ return undefined;
+ }
+
+ const syncRemainingTime = () => {
+ const remaining = Math.max(
+ new Date(activeGame.round.endsAt).getTime() - Date.now(),
+ 0,
+ );
+
+ setTimeRemainingMs(remaining);
+
+ if (remaining === 0 && !submissionLockRef.current && !isSubmitting) {
+ void handleRoundAnswer('none');
+ }
+ };
+
+ syncRemainingTime();
+
+ const interval = window.setInterval(syncRemainingTime, 100);
+
+ return () => {
+ window.clearInterval(interval);
+ };
+ }, [activeGame, handleRoundAnswer, isSubmitting]);
+
+ const handleStartSession = useCallback(async () => {
+ setIsStarting(true);
+ setResolution(null);
+ setLastCompletedSession(null);
+ setErrorMessage('');
+
+ try {
+ const { data } = await axios.post('/gameplay/start', {
+ modeSetting,
+ timerProfile,
+ });
+
+ setActiveGame(data);
+ setOverview((currentOverview) =>
+ currentOverview
+ ? {
+ ...currentOverview,
+ activeSession: data,
+ }
+ : currentOverview,
+ );
+ } catch (error: unknown) {
+ console.error('Failed to start Osu Higher Lower session:', error);
+ setErrorMessage(extractErrorMessage(error));
+ } finally {
+ submissionLockRef.current = false;
+ setIsStarting(false);
+ }
+ }, [modeSetting, timerProfile]);
+
+ const timerProgress = useMemo(() => {
+ if (!activeGame) {
+ return 0;
+ }
+
+ const fullDuration = activeGame.round.timeLimitSeconds * 1000;
+
+ if (!fullDuration) {
+ return 0;
+ }
+
+ return Math.max(Math.min((timeRemainingMs / fullDuration) * 100, 100), 0);
+ }, [activeGame, timeRemainingMs]);
+
+ const playerName = useMemo(() => {
+ const fullName = [currentUser?.firstName, currentUser?.lastName]
+ .filter(Boolean)
+ .join(' ')
+ .trim();
+
+ return fullName || currentUser?.email?.split('@')[0] || 'Player';
+ }, [currentUser]);
+
+ return (
+ <>
+
+ {getPageTitle('Play Osu Higher Lower')}
+
+
+
+
+
+
+
Playable MVP slice
+
+ Osu Higher Lower
+
+
+ Pick the higher hidden stat, protect your two lives, and keep the
+ streak alive. Mixed mode keeps the metric server-side until your
+ answer lands so the frontend never sees the truth early.
+
+
+
+ {overview?.dataset.beatmapCount ?? 0} active beatmaps
+
+
+ osu!standard only
+
+
+ Ranked + Loved only
+
+
+ 2 lives per run
+
+
+
+
+
+
Player
+
{playerName}
+
+ Recent runs and round reveals are attached to your signed-in session.
+
+
+
+
Dataset sync
+
+ {overview?.dataset.lastSyncedAt
+ ? dateFormatter.format(new Date(overview.dataset.lastSyncedAt))
+ : 'Waiting'}
+
+
+ Precomputed mapper and artist totals are stored ahead of gameplay.
+
+
+
+
+
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ {isLoadingOverview && !overview ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+ {activeGame ? 'Run in progress' : 'Start a run'}
+
+
+ {activeGame ? 'Continue from your current round.' : 'Choose a mode and timer.'}
+
+
+
+ {activeGame
+ ? `${activeGame.session.livesRemaining}/${activeGame.session.startingLives} lives`
+ : '2 lives'}
+
+
+
+
+
+
Mode
+
+ {modeOptions.map((option) => {
+ const isSelected = modeSetting === option.value;
+
+ return (
+
setModeSetting(option.value)}
+ >
+
+
{option.label}
+ {isSelected ? (
+
+ selected
+
+ ) : null}
+
+
+ {option.description}
+
+
+ );
+ })}
+
+
+
+
+
Timer
+
+ {timerOptions.map((option) => {
+ const isSelected = timerProfile === option.value;
+
+ return (
+
setTimerProfile(option.value)}
+ >
+ {option.label}
+
+ {option.description}
+
+
+ );
+ })}
+
+
+
+
+
+ void handleStartSession()}
+ />
+ {activeGame ? (
+
+ Starting over abandons the current run and deals a new round instantly.
+
+ ) : null}
+
+
+
+
+
+
+
Leaderboard
+
Best runs so far
+
+
+
+
+ {overview?.leaderboard.length ? (
+ overview.leaderboard.map((entry, index) => (
+
+
+
+ {index + 1}. {entry.playerName}
+
+
+ {formatModeLabel(entry.modeSetting)} · {formatTimerLabel(entry.timerProfile)}
+
+
+
+
+ {entry.scoreValue}
+
+
+ best streak {entry.bestStreak} · {entry.gamesPlayed} games
+
+
+
+ ))
+ ) : (
+
+ No leaderboard entries yet. Finish a run and the board will populate.
+
+ )}
+
+
+
+
+
+
+
+
+
Live board
+
+ {activeGame ? `Round ${activeGame.round.index}` : 'Ready when you are'}
+
+
+ {activeGame
+ ? activeGame.session.modeSetting === 'mixed'
+ ? 'Mixed mode keeps the current metric hidden until you answer.'
+ : `${formatModeLabel(activeGame.session.modeSetting)} mode is active for this run.`
+ : 'Start a run to deal two beatmaps and begin the timer.'}
+
+
+ {activeGame ? (
+
+
+ {activeGame.session.livesRemaining}/{activeGame.session.startingLives} lives
+
+
+ streak {activeGame.session.currentStreak}
+
+
+ {formatTimerLabel(activeGame.session.timerProfile)}
+
+
+ ) : null}
+
+
+ {resolution ? (
+
+
+
+
Reveal
+
+ {getResolutionCopy(resolution)} · {resolution.comparedLabel}
+
+
+
{getResolutionDetailCopy(resolution)}
+
+
+ ) : null}
+
+ {activeGame ? (
+ <>
+
+
+ {resolution ? 'Locking next round…' : 'Timer'}
+ {(timeRemainingMs / 1000).toFixed(1)}s
+
+
+
+
+
+ {(['a', 'b'] as const).map((choice, index) => {
+ const card = activeGame.round.cards[choice];
+ const isDisabled = isSubmitting || isStarting;
+ const isWinningCard = resolution?.correctChoice === choice;
+ const isWrongSelection =
+ resolution?.result === 'wrong' && resolution.playerChoice === choice;
+ const cardTone = isWrongSelection
+ ? 'wrong'
+ : isWinningCard
+ ? 'winner'
+ : 'neutral';
+ const statsBoxClasses = isWinningCard
+ ? 'border-emerald-300/50 bg-emerald-500/20 text-emerald-50'
+ : isWrongSelection
+ ? 'border-rose-300/45 bg-rose-500/18 text-rose-50'
+ : 'border-white/15 bg-black/40 text-white';
+ const revealOverlayClasses = isWinningCard
+ ? 'bg-emerald-500/16'
+ : isWrongSelection
+ ? 'bg-rose-500/14'
+ : 'bg-transparent';
+ const revealPill = resolution
+ ? isWinningCard
+ ? 'Higher'
+ : 'Lower'
+ : 'Hidden';
+ const revealHint = resolution ? 'Running total' : 'Choose the higher';
+
+ return (
+
+ {index === 1 ? (
+
+ ) : null}
+ void handleRoundAnswer(choice)}
+ >
+
+
+
+
+
+ {choice}
+
+
+ {card.status}
+
+
+
+
Artist
+
{card.artistName}
+
+ {card.title}
+
+
{card.version}
+
+
+ {resolution ? resolution.comparedLabel : 'Hidden plays'}
+
+
+ {resolution ? numberFormatter.format(animatedValues[choice]) : '???'}
+
+
+
+ {revealPill}
+
+
+ {revealHint}
+
+
+
+
+
+
+
+
+
Mapper
+
{card.mapperName}
+
+
+
+
+
+
+ );
+ })}
+
+
+ >
+ ) : (
+
+ Choose a mode, hit start, and the backend will deal a hidden comparison round immediately.
+
+ )}
+
+
+ {lastCompletedSession && !activeGame ? (
+
+ Latest result
+ Run finished
+
+
+
Score
+
+ {lastCompletedSession.correctAnswers}
+
+
+
+
Best streak
+
+ {lastCompletedSession.bestStreak}
+
+
+
+
Mode
+
+ {formatModeLabel(lastCompletedSession.modeSetting)}
+
+
+
+
Timer
+
+ {formatTimerLabel(lastCompletedSession.timerProfile)}
+
+
+
+
+ void handleStartSession()}
+ />
+
+
+
+ ) : null}
+
+
+
+
+
Recent sessions
+
Your run history
+
+ {overview?.recentSessions.length ? (
+
+ {overview.recentSessions.length} recent runs
+
+ ) : null}
+
+
+ {overview?.recentSessions.length ? (
+ overview.recentSessions.map((session) => (
+
+
+
+ {formatModeLabel(session.modeSetting)} · {formatTimerLabel(session.timerProfile)}
+
+
+ {session.endedAt
+ ? dateFormatter.format(new Date(session.endedAt))
+ : dateFormatter.format(new Date(session.startedAt))}
+
+
+
+
+
Score
+
+ {session.correctAnswers}
+
+
+
+
Best streak
+
+ {session.bestStreak}
+
+
+
+
Rounds
+
+ {session.totalRounds}
+
+
+
+
Review
+
+ Open details
+
+
+
+
+ ))
+ ) : (
+
+ You have not finished any Osu Higher Lower sessions yet.
+
+ )}
+
+
+
+
+ )}
+
+
+ >
+ );
+}
+
+PlayPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};