Technische Anforderungen Tournament

MOC - Map of Content

Überblick

Das Breakout Tournament ist das Kernfeature und die größte technische Herausforderung. Dieses Dokument definiert alle Anforderungen für die Implementierung eines weltklasse Tournament-Systems mit Live-Voting, Jury-Scoring, Real-time Leaderboards, und Streaming-Integration.


Tournament Architektur Überblick

┌─────────────────────────────────────────────────────────────┐
│                   BREAKOUT TOURNAMENT                        │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  GROUP STAGE (4 Wochen)                                      │
│  ├─ 10 Groups à 5 Artists                                    │
│  ├─ Round-Robin (jeder gegen jeden)                          │
│  ├─ Public Voting (Community)                                │
│  └─ Qualified: Top 2 aus jedem Group → 20 in Knockout        │
│                                                               │
│  KNOCKOUT STAGE (2 Wochen)                                   │
│  ├─ Semifinals (10 Artists, 5 matches)                       │
│  ├─ Finals (5 Artists, Top Judges, Live Stream)              │
│  ├─ Jury Scoring (60%) + Community Votes (40%)               │
│  └─ Winner: Breakout Artist #1 2025                          │
│                                                               │
│  LIVE STREAM EVENT (1 Tag)                                   │
│  ├─ 12-hour broadcast                                        │
│  ├─ All finals performances                                  │
│  ├─ Real-time voting + leaderboard                           │
│  └─ Prize ceremony                                           │
│                                                               │
└─────────────────────────────────────────────────────────────┘

1. Tournament Management System

1.1 Group Stage Management

Anforderungen:

Components:

// components/tournament/GroupStageManager.jsx
export const GroupStageManager = () => {
  const [groups, setGroups] = useState([]);
  const [selectedGroup, setSelectedGroup] = useState(null);

  useEffect(() => {
    // Fetch all groups and their matches
    const { data: groups } = await supabase
      .from('tournament_groups')
      .select('*, tournament_matches(*)')
      .order('group_letter');

    setGroups(groups);
  }, []);

  return (
    <div className="p-8">
      <h1>Group Stage</h1>
      <div className="grid grid-cols-5 gap-4">
        {groups.map(group => (
          <GroupCard
            key={group.id}
            group={group}
            onClick={() => setSelectedGroup(group)}
          />
        ))}
      </div>

      {selectedGroup && (
        <GroupDetailView group={selectedGroup} />
      )}
    </div>
  );
};

// components/tournament/GroupCard.jsx
const GroupCard = ({ group, onClick }) => {
  return (
    <div
      className="p-4 border rounded cursor-pointer hover:bg-gray-50"
      onClick={onClick}
    >
      <h3 className="font-bold text-lg">Group {group.group_letter}</h3>
      <div className="space-y-1 text-sm mt-2">
        {group.tournament_matches.map(match => (
          <div key={match.id} className="flex justify-between">
            <span>{match.song_a_title}</span>
            <span className="text-gray-500">vs</span>
            <span>{match.song_b_title}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

Database Schema:

-- tournament_groups
CREATE TABLE tournament_groups (
  id UUID PRIMARY KEY,
  tournament_id UUID REFERENCES tournaments(id),
  group_letter VARCHAR(2) NOT NULL, -- A, B, C, etc.
  created_date TIMESTAMP,
  starts_at TIMESTAMP,
  ends_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

-- tournament_matches
CREATE TABLE tournament_matches (
  id UUID PRIMARY KEY,
  group_id UUID REFERENCES tournament_groups(id),
  song_a_id UUID REFERENCES user_songs(id),
  song_b_id UUID REFERENCES user_songs(id),
  song_c_id UUID REFERENCES user_songs(id),
  match_number INT, -- 1, 2, 3, etc. in group
  starts_at TIMESTAMP,
  ends_at TIMESTAMP,
  status VARCHAR('upcoming', 'live', 'completed'),
  created_at TIMESTAMP DEFAULT NOW()
);

-- tournament_match_votes
CREATE TABLE tournament_match_votes (
  id UUID PRIMARY KEY,
  match_id UUID REFERENCES tournament_matches(id),
  voter_id UUID REFERENCES users(id),
  voted_song_id UUID REFERENCES user_songs(id),
  vote_method VARCHAR('public_vote', 'boost'), -- unterschied tracking
  vote_weight DECIMAL(5, 2) DEFAULT 1.0, -- boosts zählen mehr
  created_at TIMESTAMP DEFAULT NOW()
);

1.2 Seeding Algorithm

Problem: Wie werden 50 qualified artists in 10 groups à 5 verteilt?

Solution: Serpentine Seeding

// lib/seedingAlgorithm.js
export const createTournamentGroups = (artists) => {
  // Artists already sorted by qualification score
  const groups = Array(10).fill(null).map(() => []);

  // Serpentine: 1st seed → Group A, 2nd → Group B, ..., 10th → Group J
  // Then 11th → Group J, 12th → Group I, ..., 20th → Group A
  artists.forEach((artist, index) => {
    const groupIndex = index % 10;
    const reverse = Math.floor(index / 10) % 2 === 1;
    const finalGroupIndex = reverse ? 9 - groupIndex : groupIndex;
    groups[finalGroupIndex].push(artist);
  });

  return groups.map((group, idx) => ({
    group_letter: String.fromCharCode(65 + idx), // A, B, C, ...
    artists: group,
    matches: generateRoundRobinMatches(group)
  }));
};

// Generate all pairwise matches
const generateRoundRobinMatches = (artists) => {
  const matches = [];
  for (let i = 0; i < artists.length; i++) {
    for (let j = i + 1; j < artists.length; j++) {
      matches.push({
        song_a: artists[i].latest_song_id,
        song_b: artists[j].latest_song_id,
        scheduled_date: generateScheduleDate(matches.length)
      });
    }
  }
  return matches; // 5 artists = 10 matches per group
};

2. Live Stream Integration

2.1 Streaming Options Analysis

Option A: Embed Twitch/YouTube (RECOMMENDED)

Option B: Own Streaming Infrastructure

Option C: Hybrid (RECOMMENDED for Phase 1)

Recommendation: Hybrid Approach

2.2 Voting During Live Stream

Flow:

  1. Stream shows Artist A performing
  2. Real-time leaderboard displayed on stream (via OBS overlay)
  3. Users voting in Breakout app see their votes update live
  4. Scores refresh every 5 seconds during performance

Components:

// components/tournament/TournamentViewer.jsx
export const TournamentViewer = ({ tournamentId, matchId }) => {
  const [match, setMatch] = useState(null);
  const [scores, setScores] = useState(null);
  const [streamUrl, setStreamUrl] = useState(null);

  useEffect(() => {
    // Fetch current match
    const { data: match } = await supabase
      .from('tournament_matches')
      .select('*, song_a:user_songs!song_a_id(*), song_b:user_songs!song_b_id(*)')
      .eq('id', matchId)
      .single();

    setMatch(match);

    // Subscribe to real-time score updates
    const subscription = supabase
      .from('tournament_match_scores')
      .on('*', payload => {
        setScores(payload.new);
      })
      .eq('match_id', matchId)
      .subscribe();

    // Get Twitch stream URL
    const stream = await getTournamentStreamUrl(tournamentId);
    setStreamUrl(stream);

    return () => subscription.unsubscribe();
  }, [matchId, tournamentId]);

  return (
    <div className="grid grid-cols-3 gap-4 p-8">
      {/* Twitch Stream */}
      <div className="col-span-2">
        <iframe
          src={streamUrl}
          height="600"
          width="100%"
          allow="autoplay"
        />
      </div>

      {/* Voting Sidebar */}
      <div className="bg-gray-50 p-4 rounded">
        <h2>Vote Now</h2>
        <div className="space-y-2">
          {match && (
            <>
              <SongVoteCard song={match.song_a} scores={scores?.song_a_votes} />
              <SongVoteCard song={match.song_b} scores={scores?.song_b_votes} />
            </>
          )}
        </div>

        {/* Real-time Leaderboard */}
        <LiveLeaderboard match={match} />
      </div>
    </div>
  );
};

// components/tournament/SongVoteCard.jsx
const SongVoteCard = ({ song, scores }) => {
  const [isVoting, setIsVoting] = useState(false);

  const handleVote = async () => {
    setIsVoting(true);
    await submitVote(song.id);
    setIsVoting(false);
  };

  return (
    <div className="border rounded p-3 hover:bg-white cursor-pointer">
      <h4 className="font-bold">{song.title}</h4>
      <p className="text-sm text-gray-600">{song.artist_name}</p>
      <div className="mt-2 flex justify-between items-center">
        <span className="text-2xl font-bold text-green-600">{scores || 0}</span>
        <button
          onClick={handleVote}
          disabled={isVoting}
          className="bg-blue-500 text-white px-3 py-1 rounded disabled:bg-gray-300"
        >
          Vote
        </button>
      </div>
    </div>
  );
};

// components/tournament/LiveLeaderboard.jsx
const LiveLeaderboard = ({ match }) => {
  const [leaderboard, setLeaderboard] = useState([]);

  useEffect(() => {
    const subscription = supabase
      .from('tournament_leaderboard_live')
      .on('*', payload => {
        setLeaderboard(payload.new);
      })
      .subscribe();

    return () => subscription.unsubscribe();
  }, []);

  return (
    <div className="mt-6">
      <h3 className="font-bold mb-2">Live Scores</h3>
      <div className="space-y-1 text-sm">
        {leaderboard.map((entry, idx) => (
          <div key={entry.id} className="flex justify-between">
            <span>#{idx + 1} {entry.artist_name}</span>
            <span className="font-bold">{entry.score}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

3. Jury Interface

3.1 Jury Scoring System

Jury Composition (Finals):

Scoring:

Anti-Cheat Measures:

3.2 Jury Panel Component

// components/tournament/JuryPanel.jsx
export const JuryPanel = ({ matchId, juryMemberId }) => {
  const [match, setMatch] = useState(null);
  const [scores, setScores] = useState({
    vocals: null,
    production: null,
    originality: null,
    engagement: null,
    commercialAppeal: null,
    notes: ''
  });
  const [submitted, setSubmitted] = useState(false);

  const criteria = [
    { key: 'vocals', label: 'Vocals', description: 'Quality and technique' },
    { key: 'production', label: 'Production', description: 'Sound quality' },
    { key: 'originality', label: 'Originality', description: 'Unique sound' },
    { key: 'engagement', label: 'Engagement', description: 'Captivating performance' },
    { key: 'commercialAppeal', label: 'Commercial Appeal', description: 'Market potential' }
  ];

  const handleSubmit = async () => {
    // Validate all scores filled
    if (Object.values(scores).some(v => v === null)) {
      alert('Please score all criteria');
      return;
    }

    const averageScore = Object.keys(scores)
      .filter(k => k !== 'notes')
      .reduce((sum, k) => sum + scores[k], 0) / 5;

    await supabase.from('jury_scores').insert({
      match_id: matchId,
      jury_member_id: juryMemberId,
      criteria_scores: scores,
      average_score: averageScore,
      notes: scores.notes,
      submitted_at: new Date()
    });

    setSubmitted(true);
  };

  return (
    <div className="p-8 max-w-2xl">
      <h1>Jury Scoring Panel</h1>

      {submitted ? (
        <div className="bg-green-50 p-4 rounded">
          ✅ Scores submitted! Thank you.
        </div>
      ) : (
        <>
          <div className="space-y-4 my-6">
            {criteria.map(crit => (
              <div key={crit.key}>
                <label className="block font-bold">{crit.label}</label>
                <p className="text-sm text-gray-600">{crit.description}</p>
                <div className="flex gap-2 mt-2">
                  {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(score => (
                    <button
                      key={score}
                      onClick={() =>
                        setScores({ ...scores, [crit.key]: score })
                      }
                      className={`w-10 h-10 rounded ${
                        scores[crit.key] === score
                          ? 'bg-blue-500 text-white'
                          : 'bg-gray-200'
                      }`}
                    >
                      {score}
                    </button>
                  ))}
                </div>
              </div>
            ))}

            <div>
              <label className="block font-bold">Notes (optional)</label>
              <textarea
                value={scores.notes}
                onChange={(e) =>
                  setScores({ ...scores, notes: e.target.value })
                }
                className="w-full p-2 border rounded mt-2"
                rows="4"
              />
            </div>

            <button
              onClick={handleSubmit}
              className="w-full bg-green-500 text-white py-3 rounded font-bold"
            >
              Submit Scores
            </button>
          </div>
        </>
      )}
    </div>
  );
};

Database:

CREATE TABLE jury_scores (
  id UUID PRIMARY KEY,
  match_id UUID REFERENCES tournament_matches(id),
  jury_member_id UUID REFERENCES users(id),
  criteria_scores JSONB, -- {vocals: 8, production: 7, ...}
  average_score DECIMAL(3, 1),
  notes TEXT,
  submitted_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  CONSTRAINT unique_jury_per_match UNIQUE(match_id, jury_member_id)
);

4. Real-time Leaderboard System

4.1 Leaderboard Architecture

Challenge: 50,000+ concurrent votes during live stream = massive traffic

Solution: Materialized Views + Realtime Subscriptions

-- Materialized view (updated every 5 seconds)
CREATE MATERIALIZED VIEW tournament_leaderboard_live AS
SELECT
  a.id as artist_id,
  a.name as artist_name,
  COUNT(v.id) FILTER (WHERE v.vote_method = 'public_vote') as public_votes,
  COUNT(v.id) FILTER (WHERE v.vote_method = 'boost') * 1.5 as weighted_votes, -- boosts weighted
  COUNT(DISTINCT j.id) as jury_votes,
  AVG(j.average_score) as jury_score,
  (
    COUNT(v.id) FILTER (WHERE v.vote_method = 'public_vote') * 0.4 +
    AVG(j.average_score) * 10 * 0.6
  ) as final_score,
  ROW_NUMBER() OVER (ORDER BY ... DESC) as rank
FROM users a
LEFT JOIN user_songs s ON a.id = s.artist_id
LEFT JOIN tournament_match_votes v ON s.id = v.voted_song_id
LEFT JOIN jury_scores j ON v.match_id = j.match_id
GROUP BY a.id, a.name
ORDER BY final_score DESC;

-- Refresh materialized view every 5 seconds via Postgres extension or webhook
CREATE OR REPLACE FUNCTION refresh_leaderboard()
RETURNS void AS $
BEGIN
  REFRESH MATERIALIZED VIEW CONCURRENTLY tournament_leaderboard_live;
END;
$ LANGUAGE plpgsql;

-- Trigger via pg_cron (if available) or external scheduler (Netlify Function every 5s)

4.2 Frontend Real-time Updates

// hooks/useLiveLeaderboard.js
export const useLiveLeaderboard = (tournamentId) => {
  const [leaderboard, setLeaderboard] = useState([]);

  useEffect(() => {
    // Initial fetch
    fetchLeaderboard();

    // Subscribe to changes
    const subscription = supabase
      .from(`tournament:${tournamentId}:leaderboard`)
      .on('*', payload => {
        setLeaderboard(payload.new);
      })
      .subscribe();

    // Also poll every 5 seconds as backup
    const interval = setInterval(fetchLeaderboard, 5000);

    return () => {
      subscription.unsubscribe();
      clearInterval(interval);
    };
  }, [tournamentId]);

  const fetchLeaderboard = async () => {
    const { data } = await supabase
      .from('tournament_leaderboard_live')
      .select('*')
      .order('rank')
      .limit(50);
    setLeaderboard(data);
  };

  return leaderboard;
};

// components/tournament/LiveLeaderboard.jsx
export const LiveLeaderboard = ({ tournamentId }) => {
  const leaderboard = useLiveLeaderboard(tournamentId);

  return (
    <div className="p-4">
      <h2 className="font-bold text-xl mb-4">Live Leaderboard</h2>
      <div className="space-y-1">
        {leaderboard.map((entry) => (
          <div
            key={entry.artist_id}
            className="flex justify-between p-2 bg-gray-50 rounded"
          >
            <div>
              <span className="font-bold text-lg mr-3">#{entry.rank}</span>
              <span>{entry.artist_name}</span>
            </div>
            <div className="text-right">
              <div className="text-sm text-gray-600">Score</div>
              <div className="font-bold text-lg">{entry.final_score.toFixed(1)}</div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};

5. Qualification Tracking

5.1 Artist Qualification Criteria

Requirements:

Qualification Path:

  1. Artist submits song
  2. Accumulates votes over 4 weeks (Group Stage)
  3. Top 2 from each of 10 groups (20 artists) → Knockout Stage
  4. Knockout Top 5 → Finals
  5. Winner + Runner-up automatically get Breakout label deal

5.2 Qualification UI

// components/tournament/QualificationTracker.jsx
export const QualificationTracker = ({ artistId }) => {
  const [qualificationStatus, setQualificationStatus] = useState(null);

  useEffect(() => {
    const subscription = supabase
      .from('artist_qualification_status')
      .on('*', payload => {
        setQualificationStatus(payload.new);
      })
      .eq('artist_id', artistId)
      .subscribe();

    return () => subscription.unsubscribe();
  }, [artistId]);

  if (!qualificationStatus) return null;

  const { position_in_group, group, is_qualified, votes_count, votes_needed } = qualificationStatus;

  return (
    <div className="bg-blue-50 p-4 rounded">
      <h3 className="font-bold">Tournament Qualification</h3>

      {is_qualified ? (
        <div className="mt-2 text-green-600 font-bold">
          ✅ Qualified! You're in the tournament!
        </div>
      ) : (
        <>
          <div className="mt-2">
            <p className="text-sm text-gray-600">
              Group {group} - Position: #{position_in_group}
            </p>
            <p className="text-sm text-gray-600 mt-1">
              Votes: {votes_count} / {votes_needed}
            </p>
            <div className="w-full bg-gray-200 rounded-full h-2 mt-2">
              <div
                className="bg-blue-500 h-2 rounded-full"
                style={{
                  width: `${Math.min((votes_count / votes_needed) * 100, 100)}%`
                }}
              />
            </div>
          </div>
          <p className="text-xs text-gray-600 mt-2">
            Top 2 from each group advance to Knockout Stage
          </p>
        </>
      )}
    </div>
  );
};

// Global Progress Tracker
export const TournamentProgressTracker = () => {
  const [qualified, setQualified] = useState(0);

  useEffect(() => {
    const subscription = supabase
      .from('artist_qualification_status')
      .on('*', payload => {
        // Re-count qualified artists
        fetchQualifiedCount();
      })
      .subscribe();

    return () => subscription.unsubscribe();
  }, []);

  const fetchQualifiedCount = async () => {
    const { count } = await supabase
      .from('artist_qualification_status')
      .select('*', { count: 'exact' })
      .eq('is_qualified', true);
    setQualified(count);
  };

  return (
    <div className="text-center p-4">
      <h2 className="text-2xl font-bold">
        🎵 {qualified} / 50 Artists Qualified
      </h2>
      <div className="w-full bg-gray-200 rounded-full mt-2 h-3">
        <div
          className="bg-green-500 h-3 rounded-full transition-all"
          style={{ width: `${(qualified / 50) * 100}%` }}
        />
      </div>
    </div>
  );
};

Database:

CREATE TABLE artist_qualification_status (
  id UUID PRIMARY KEY,
  artist_id UUID REFERENCES users(id) UNIQUE,
  tournament_id UUID REFERENCES tournaments(id),
  group_id UUID REFERENCES tournament_groups(id),
  position_in_group INT,
  votes_count INT DEFAULT 0,
  votes_needed INT DEFAULT 100, -- configurable
  is_qualified BOOLEAN DEFAULT FALSE,
  qualified_date TIMESTAMP,
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Trigger to auto-qualify when votes >= votes_needed
CREATE OR REPLACE FUNCTION check_qualification()
RETURNS TRIGGER AS $
BEGIN
  UPDATE artist_qualification_status
  SET is_qualified = true, qualified_date = NOW()
  WHERE artist_id = NEW.artist_id AND votes_count >= votes_needed;
  RETURN NEW;
END;
$ LANGUAGE plpgsql;

CREATE TRIGGER after_vote_check_qualification
AFTER INSERT ON tournament_match_votes
FOR EACH ROW
EXECUTE FUNCTION check_qualification();

6. Infrastructure & Performance Requirements

6.1 Load Testing & Scaling

Expected Load (Worst Case - Live Finals):

Supabase Capacity:

Netlify Functions:

6.2 Database Optimization

-- Indexes for fast vote counting
CREATE INDEX idx_tournament_votes_song ON tournament_match_votes(voted_song_id, created_at);
CREATE INDEX idx_tournament_votes_match ON tournament_match_votes(match_id);
CREATE INDEX idx_jury_scores_match ON jury_scores(match_id);

-- Partitioning (for very large scale)
CREATE TABLE tournament_match_votes_partition
PARTITION BY RANGE (created_at);

-- Caching layer (optional)
-- Use Redis or Supabase caching to reduce DB hits for leaderboard

6.3 CDN & Media Delivery

Problem: Song audio files can be large (5-10 MB each)

Solution:

// Setup CloudFront in front of Supabase Storage
const audioUrl = `https://cdn.breakout.music/${songId}.mp3`;

// Or use Supabase CDN directly (built-in)
const supabaseAudioUrl = supabase.storage
  .from('song-audio')
  .getPublicUrl(songId).data.publicUrl;

7. Voting & Boost Integration

7.1 Vote Types During Tournament

Type 1: Public Votes (Free)

Type 2: Boost Votes (Paid)

Type 3: Jury Votes

7.2 Vote Submission Logic

// lib/voting.js
export const submitVote = async (userId, songId, voteType = 'public') => {
  // 1. Check rate limit
  const recentVotes = await checkRecentVotes(userId, songId);
  if (recentVotes >= 1) {
    throw new Error('Already voted for this song');
  }

  // 2. If boost, verify payment
  if (voteType === 'boost') {
    const payment = await verifyPaymentProcessed(userId);
    if (!payment) throw new Error('Payment not confirmed');
  }

  // 3. Insert vote
  const { data } = await supabase
    .from('tournament_match_votes')
    .insert({
      match_id: getCurrentMatchId(),
      voter_id: userId,
      voted_song_id: songId,
      vote_method: voteType,
      vote_weight: voteType === 'boost' ? 1.5 : 1.0,
      created_at: new Date()
    });

  // 4. Realtime broadcast (triggers materialized view refresh)
  await broadcastVoteUpdate(songId);

  return data;
};

8. Technical Implementation Timeline

Phase Component Effort Timeline
1. Foundation Group Stage Manager, Bracket Viz 3 days Week 1
2. Voting Public Vote System, Real-time Updates 3 days Week 2
3. Jury Jury Interface, Score Aggregation 2 days Week 2
4. Leaderboard Real-time Leaderboard, Materialized Views 2 days Week 3
5. Streaming Twitch Integration, Voting Sidebar 2 days Week 3
6. Qualification Tracker, Auto-Qualification Logic 2 days Week 4
7. Load Testing Stress testing, optimization 3 days Week 4-5
8. Failover Backup systems, error recovery 2 days Week 5

Total: 10-15 days (depends on developer experience with Supabase/Realtime)


9. Infrastructure Checklist


10. Critical Risk Mitigations

Risk 1: Database goes down during live event

Risk 2: Voting service crashes from load

Risk 3: Real-time updates lag/don't deliver

Risk 4: Network issues during stream

Risk 5: Jury judge loses connection


11. Success Metrics

Metric Target How to Track
Votes Processed During Finals 100K+ Analytics
Leaderboard Update Latency < 2 seconds Monitoring
System Uptime 99.9% Sentry, PagerDuty
Concurrent Users Supported 50K+ Load testing
Audience Engagement (votes/viewers) > 5% Analytics
Jury Score Submission Rate 100% Database audit
Artwork views/downloads 10K+ Analytics

MOC - Map of Content | Tournament-Ablauf | Aktueller Stand der App | Fehlende Komponenten | Qualifikation