Created
April 24, 2026 13:40
-
-
Save stigi/e348eef3032dc0e3159eb29090cbc407 to your computer and use it in GitHub Desktop.
Mario Kart Tournament Planner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>🏁 Mario Kart Turnier</title> | |
| <style> | |
| :root{--bg:#0b1437;--card:#15205a;--ink:#fff;--accent:#ffcf2a;--red:#e60012;--green:#2bb673;--muted:#8b97d4} | |
| *{box-sizing:border-box} | |
| body{margin:0;font-family:system-ui,sans-serif;background:linear-gradient(180deg,#0b1437,#1c2d8a);color:var(--ink);min-height:100vh} | |
| header{padding:24px;text-align:center;background:linear-gradient(90deg,var(--red),var(--accent),var(--green));color:#111;font-weight:900;letter-spacing:1px} | |
| header h1{margin:0;font-size:28px;text-shadow:2px 2px 0 #fff} | |
| main{max-width:1000px;margin:0 auto;padding:20px;display:grid;gap:20px} | |
| .card{background:var(--card);border-radius:14px;padding:20px;box-shadow:0 6px 20px rgba(0,0,0,.3)} | |
| h2{margin:0 0 12px;font-size:20px;border-bottom:2px solid var(--accent);padding-bottom:6px} | |
| input,select,button,textarea{font:inherit} | |
| input,textarea,select{background:#0b1437;color:#fff;border:1px solid #3a4ba0;border-radius:8px;padding:8px 10px;width:100%} | |
| textarea{resize:vertical;min-height:110px;font-family:ui-monospace,monospace} | |
| button{cursor:pointer;background:var(--accent);color:#111;border:none;border-radius:8px;padding:10px 16px;font-weight:700;transition:transform .1s} | |
| button:hover{transform:translateY(-1px)} | |
| button.secondary{background:#3a4ba0;color:#fff} | |
| button.danger{background:var(--red);color:#fff} | |
| .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap} | |
| .row>*{flex:1} | |
| .row>button{flex:0 0 auto} | |
| .players{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px} | |
| .chip{background:#0b1437;border:1px solid #3a4ba0;border-radius:20px;padding:4px 12px;display:flex;gap:6px;align-items:center} | |
| .chip button{background:transparent;color:#ff8;padding:0 4px;font-size:16px} | |
| table{width:100%;border-collapse:collapse} | |
| th,td{padding:8px 10px;text-align:left;border-bottom:1px solid #3a4ba0} | |
| th{color:var(--accent);font-size:13px;text-transform:uppercase} | |
| .race{background:#0b1437;border:1px solid #3a4ba0;border-radius:10px;padding:12px;margin-bottom:10px} | |
| .race h3{margin:0 0 8px;color:var(--accent);display:flex;justify-content:space-between;align-items:center} | |
| .race .slot{display:grid;grid-template-columns:30px 1fr 80px;align-items:center;gap:8px;padding:4px 0} | |
| .race .slot .pos{color:var(--muted);text-align:center;font-weight:700} | |
| .race .slot select{padding:6px} | |
| .leader tr:first-child td{color:var(--accent);font-weight:900;font-size:18px} | |
| .gold{background:linear-gradient(90deg,#ffcf2a33,transparent)} | |
| .controls{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px} | |
| .muted{color:var(--muted);font-size:13px} | |
| .winner{font-size:32px;text-align:center;padding:30px;background:linear-gradient(90deg,var(--accent),var(--red));color:#111;border-radius:14px;font-weight:900} | |
| .lang{position:absolute;top:10px;right:14px;display:flex;gap:4px} | |
| .lang button{background:rgba(0,0,0,.25);color:#fff;border:1px solid rgba(255,255,255,.4);padding:4px 10px;font-size:12px;font-weight:700;border-radius:6px} | |
| .lang button.active{background:#fff;color:#111} | |
| header{position:relative} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1 id="title"></h1> | |
| <div class="lang"> | |
| <button data-lang="de">DE</button> | |
| <button data-lang="en">EN</button> | |
| </div> | |
| </header> | |
| <main> | |
| <section class="card" id="setup"> | |
| <h2 data-i18n="players"></h2> | |
| <div class="row"> | |
| <input id="nameInput" data-i18n-ph="enterName" /> | |
| <button id="addBtn" data-i18n="add"></button> | |
| </div> | |
| <div class="muted" style="margin-top:6px" data-i18n="pasteList"></div> | |
| <textarea id="bulk" placeholder="Mario Luigi Peach Bowser"></textarea> | |
| <div class="controls"> | |
| <button class="secondary" id="bulkBtn" data-i18n="importList"></button> | |
| <button class="danger" id="clearBtn" data-i18n="clearAll"></button> | |
| </div> | |
| <div class="players" id="players"></div> | |
| </section> | |
| <section class="card" id="configCard" style="display:none"> | |
| <h2 data-i18n="setup"></h2> | |
| <div class="row"> | |
| <label><span data-i18n="racersPer"></span> | |
| <select id="raceSize"> | |
| <option value="2">2</option> | |
| <option value="3">3</option> | |
| <option value="4" selected>4</option> | |
| </select> | |
| </label> | |
| <label><span data-i18n="scoring"></span> | |
| <select id="scoring"> | |
| <option value="gp" selected data-i18n="scoringGp"></option> | |
| <option value="simple" data-i18n="scoringSimple"></option> | |
| </select> | |
| </label> | |
| <button id="generateBtn" data-i18n="generate"></button> | |
| </div> | |
| <p class="muted" id="schedInfo"></p> | |
| </section> | |
| <section class="card" id="racesCard" style="display:none"> | |
| <h2 data-i18n="racesHeader"></h2> | |
| <div id="races"></div> | |
| </section> | |
| <section class="card" id="boardCard" style="display:none"> | |
| <h2 data-i18n="leaderboard"></h2> | |
| <table class="leader"><thead><tr><th>#</th><th data-i18n="player"></th><th data-i18n="points"></th><th data-i18n="races"></th><th data-i18n="wins"></th></tr></thead><tbody id="board"></tbody></table> | |
| <div id="winnerBox"></div> | |
| </section> | |
| </main> | |
| <script> | |
| const I18N={ | |
| de:{ | |
| title:'🏎️ MARIO KART TURNIERPLANER 🏁', | |
| players:'1. Spieler', enterName:'Spielername eingeben', add:'Hinzufügen', | |
| pasteList:'Oder Liste einfügen (einen pro Zeile):', importList:'Liste importieren', clearAll:'Alles löschen', | |
| setup:'2. Turnier-Einstellungen', racersPer:'Fahrer pro Rennen', scoring:'Punktesystem', | |
| scoringGp:'Grand Prix (15/12/10/9…)', scoringSimple:'Einfach (N, N-1, … 1)', | |
| generate:'🏁 Turnierplan erstellen', | |
| racesHeader:'3. Rennen — Platzierungen eintragen', | |
| leaderboard:'🏆 Rangliste', player:'Spieler', points:'Punkte', races:'Rennen', wins:'Siege', | |
| confirmClear:'Wirklich alles löschen?', | |
| schedInfo:(n,sz)=>`${n} Spieler, ${sz} pro Rennen. Klicke auf Erstellen, um einen Plan zu generieren, bei dem jedes Paar mindestens einmal gemeinsam fährt.`, | |
| raceLabel:i=>`Rennen ${i}`, complete:'✅ fertig', finishOpt:'-- Platz --', | |
| wins_msg:name=>`👑 ${name} GEWINNT! 🏆`, | |
| progress:(c,t)=>`${c}/${t} Rennen abgeschlossen. Beende alle, um den Champion zu küren!`, | |
| tbHeader:'⚡ Entscheidungsrennen — Punktgleichstand!', | |
| tbDesc:(names,pts)=>`${names} sind mit ${pts} Punkten gleichauf. Fahrt ein Stechen, um den Champion zu küren!`, | |
| tbWins:name=>`👑 ${name} GEWINNT DAS STECHEN! 🏆`, | |
| }, | |
| en:{ | |
| title:'🏎️ MARIO KART TOURNAMENT PLANNER 🏁', | |
| players:'1. Players', enterName:'Enter player name', add:'Add', | |
| pasteList:'Or paste a list (one per line):', importList:'Import list', clearAll:'Clear all', | |
| setup:'2. Tournament setup', racersPer:'Racers per race', scoring:'Scoring', | |
| scoringGp:'Grand Prix (15/12/10/9…)', scoringSimple:'Simple (N, N-1, … 1)', | |
| generate:'🏁 Generate bracket', | |
| racesHeader:'3. Races — enter finishing positions', | |
| leaderboard:'🏆 Leaderboard', player:'Player', points:'Points', races:'Races', wins:'Wins', | |
| confirmClear:'Clear everything?', | |
| schedInfo:(n,sz)=>`${n} players, ${sz} per race. Click generate to build a schedule where every pair races together at least once.`, | |
| raceLabel:i=>`Race ${i}`, complete:'✅ complete', finishOpt:'-- finish --', | |
| wins_msg:name=>`👑 ${name} WINS! 🏆`, | |
| progress:(c,t)=>`${c}/${t} races completed. Finish them all to crown the champion!`, | |
| tbHeader:'⚡ Tiebreaker — points tied!', | |
| tbDesc:(names,pts)=>`${names} are tied at ${pts} points. Race a tiebreaker to crown the champion!`, | |
| tbWins:name=>`👑 ${name} WINS THE TIEBREAKER! 🏆`, | |
| } | |
| }; | |
| let LANG=localStorage.getItem('mk-lang')||'de'; | |
| const t=()=>I18N[LANG]; | |
| function applyI18n(){ | |
| const d=t(); | |
| document.documentElement.lang=LANG; | |
| document.title=d.title.replace(/[\ud83c-\udfff\ud83d-\udfff\ud83e-\udfff][\udc00-\udfff]/g,'').trim(); | |
| $('title').textContent=d.title; | |
| document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=d[el.dataset.i18n]||''}); | |
| document.querySelectorAll('[data-i18n-ph]').forEach(el=>{el.placeholder=d[el.dataset.i18nPh]||''}); | |
| document.querySelectorAll('.lang button').forEach(b=>b.classList.toggle('active',b.dataset.lang===LANG)); | |
| } | |
| const GP_POINTS=[15,12,10,9,8,7,6,5,4,3,2,1]; | |
| const state=JSON.parse(localStorage.getItem('mk')||'null')||{players:[],raceSize:4,scoring:'gp',schedule:null,results:{}}; | |
| const $=id=>document.getElementById(id); | |
| function save(){localStorage.setItem('mk',JSON.stringify(state))} | |
| function addPlayer(n){n=n.trim();if(!n)return;if(state.players.includes(n))return;state.players.push(n);save();render()} | |
| function removePlayer(n){state.players=state.players.filter(p=>p!==n);state.schedule=null;state.results={};save();render()} | |
| $('addBtn').onclick=()=>{addPlayer($('nameInput').value);$('nameInput').value=''}; | |
| $('nameInput').addEventListener('keydown',e=>{if(e.key==='Enter')$('addBtn').click()}); | |
| $('bulkBtn').onclick=()=>{$('bulk').value.split(/\n+/).forEach(addPlayer);$('bulk').value=''}; | |
| $('clearBtn').onclick=()=>{if(confirm(t().confirmClear)){localStorage.removeItem('mk');location.reload()}}; | |
| document.querySelectorAll('.lang button').forEach(b=>{b.onclick=()=>{LANG=b.dataset.lang;localStorage.setItem('mk-lang',LANG);applyI18n();render()}}); | |
| $('raceSize').onchange=e=>{state.raceSize=+e.target.value;state.schedule=null;state.results={};save();render()}; | |
| $('scoring').onchange=e=>{state.scoring=e.target.value;save();render()}; | |
| $('generateBtn').onclick=()=>{state.schedule=buildSchedule(state.players,state.raceSize);state.results={};save();render()}; | |
| // Greedy schedule: keep adding races until every pair has co-raced at least once. | |
| function buildSchedule(players,size){ | |
| const n=players.length; | |
| if(n<2) return []; | |
| if(n<=size) return [players.slice()]; | |
| const idx=players.map((_,i)=>i); | |
| const pairKey=(a,b)=>a<b?a+'-'+b:b+'-'+a; | |
| const needed=new Set(); | |
| for(let i=0;i<n;i++)for(let j=i+1;j<n;j++)needed.add(pairKey(i,j)); | |
| const appearances=new Array(n).fill(0); | |
| const races=[]; | |
| // Generate candidate races by trying combinations that cover most uncovered pairs. | |
| while(needed.size>0){ | |
| // pick race minimizing appearances and maximizing new pair coverage | |
| let best=null,bestScore=-1; | |
| // To keep it tractable, generate candidates seeded by least-appearing players. | |
| const order=idx.slice().sort((a,b)=>appearances[a]-appearances[b]); | |
| // Try combinations of size from top order (limit search) | |
| const pool=order.slice(0,Math.min(n,size+6)); | |
| const combos=kCombos(pool,size); | |
| for(const c of combos){ | |
| let nw=0; | |
| for(let i=0;i<c.length;i++)for(let j=i+1;j<c.length;j++)if(needed.has(pairKey(c[i],c[j])))nw++; | |
| const appSum=c.reduce((s,p)=>s+appearances[p],0); | |
| const score=nw*1000-appSum; | |
| if(score>bestScore){bestScore=score;best=c} | |
| } | |
| if(!best||bestScore<=0){ | |
| // fallback: just pick least-appearing players | |
| best=order.slice(0,size); | |
| } | |
| for(let i=0;i<best.length;i++){ | |
| appearances[best[i]]++; | |
| for(let j=i+1;j<best.length;j++)needed.delete(pairKey(best[i],best[j])); | |
| } | |
| races.push(best.map(i=>players[i])); | |
| if(races.length>500)break; // safety | |
| } | |
| return races; | |
| } | |
| function kCombos(arr,k){ | |
| const res=[]; | |
| const rec=(start,pick)=>{if(pick.length===k){res.push(pick.slice());return} | |
| for(let i=start;i<arr.length;i++){pick.push(arr[i]);rec(i+1,pick);pick.pop()}}; | |
| rec(0,[]); | |
| return res; | |
| } | |
| function pointsFor(pos,size){ | |
| if(state.scoring==='gp') return GP_POINTS[pos]||0; | |
| return Math.max(size-pos,0); | |
| } | |
| function render(){ | |
| // Players chips | |
| $('players').innerHTML=state.players.map(p=>`<span class="chip">${escape(p)} <button onclick="removePlayer('${escape(p).replace(/'/g,"\\'")}')">×</button></span>`).join(''); | |
| $('configCard').style.display=state.players.length>=2?'block':'none'; | |
| $('raceSize').value=state.raceSize; | |
| $('scoring').value=state.scoring; | |
| if(state.players.length>=2){ | |
| const sz=Math.min(state.raceSize,state.players.length); | |
| $('schedInfo').textContent=t().schedInfo(state.players.length,sz); | |
| } | |
| // Races | |
| if(state.schedule){ | |
| $('racesCard').style.display='block'; | |
| $('boardCard').style.display='block'; | |
| renderRaces(); | |
| renderBoard(); | |
| } else { | |
| $('racesCard').style.display='none'; | |
| $('boardCard').style.display='none'; | |
| } | |
| } | |
| function escape(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c])} | |
| function renderRaces(){ | |
| const c=$('races');c.innerHTML=''; | |
| state.schedule.forEach((race,i)=>{ | |
| const div=document.createElement('div');div.className='race'; | |
| const results=state.results[i]||{}; | |
| const done=race.every(p=>results[p]!=null); | |
| div.innerHTML=`<h3>${t().raceLabel(i+1)} <span class="muted">${done?t().complete:''}</span></h3>`; | |
| race.forEach(player=>{ | |
| const row=document.createElement('div');row.className='slot'; | |
| const sel=document.createElement('select'); | |
| sel.innerHTML=`<option value="">${t().finishOpt}</option>`+race.map((_,idx)=>`<option value="${idx}">${ordinal(idx+1)}</option>`).join(''); | |
| if(results[player]!=null)sel.value=results[player]; | |
| sel.onchange=e=>{const v=e.target.value;state.results[i]=state.results[i]||{};if(v==='')delete state.results[i][player];else state.results[i][player]=+v;save();render()}; | |
| row.innerHTML=`<div class="pos">🏎️</div><div><b>${escape(player)}</b></div>`; | |
| row.appendChild(sel); | |
| div.appendChild(row); | |
| }); | |
| c.appendChild(div); | |
| }); | |
| } | |
| function ordinal(n){return n+'.'} | |
| function renderTiebreaker(tied){ | |
| const names=tied.map(t=>t.player); | |
| const tb=state.tiebreaker||{players:names,result:{}}; | |
| // reset if the tied set changed | |
| if(JSON.stringify(tb.players)!==JSON.stringify(names)){tb.players=names;tb.result={}} | |
| state.tiebreaker=tb;save(); | |
| const done=names.every(p=>tb.result[p]!=null); | |
| let html=`<div class="race" style="margin-top:18px;border:2px solid var(--accent)"> | |
| <h3>${t().tbHeader}</h3> | |
| <p class="muted" style="margin:0 0 10px">${t().tbDesc(names.map(escape).join(', '),tied[0].pts)}</p>`; | |
| names.forEach(p=>{ | |
| const selOpts=`<option value="">${t().finishOpt}</option>`+names.map((_,idx)=>`<option value="${idx}" ${tb.result[p]==idx?'selected':''}>${ordinal(idx+1)}</option>`).join(''); | |
| html+=`<div class="slot"><div class="pos">🏎️</div><div><b>${escape(p)}</b></div><select data-tb="${escape(p)}">${selOpts}</select></div>`; | |
| }); | |
| html+=`</div>`; | |
| if(done){ | |
| const winner=names.find(p=>tb.result[p]===0); | |
| html+=`<div class="winner" style="margin-top:14px">${t().tbWins(escape(winner))}</div>`; | |
| } | |
| $('winnerBox').innerHTML=html; | |
| $('winnerBox').querySelectorAll('select[data-tb]').forEach(sel=>{ | |
| sel.onchange=e=>{ | |
| const p=sel.getAttribute('data-tb'); | |
| const v=e.target.value; | |
| // clear anyone else who had the same position (one position per player) | |
| if(v!==''){Object.keys(tb.result).forEach(k=>{if(tb.result[k]==+v && k!==p)delete tb.result[k]})} | |
| if(v==='')delete tb.result[p];else tb.result[p]=+v; | |
| save();render(); | |
| }; | |
| }); | |
| } | |
| function renderBoard(){ | |
| const stats={}; | |
| state.players.forEach(p=>stats[p]={player:p,pts:0,races:0,wins:0}); | |
| state.schedule.forEach((race,i)=>{ | |
| const r=state.results[i]||{}; | |
| race.forEach(p=>{ | |
| if(r[p]!=null){ | |
| stats[p].races++; | |
| stats[p].pts+=pointsFor(r[p],race.length); | |
| if(r[p]===0)stats[p].wins++; | |
| } | |
| }); | |
| }); | |
| const arr=Object.values(stats).sort((a,b)=>b.pts-a.pts||b.wins-a.wins||a.player.localeCompare(b.player)); | |
| $('board').innerHTML=arr.map((s,i)=>`<tr class="${i===0?'gold':''}"><td>${i+1}</td><td>${escape(s.player)}</td><td>${s.pts}</td><td>${s.races}</td><td>${s.wins}</td></tr>`).join(''); | |
| const totalRaces=state.schedule.length; | |
| const completed=state.schedule.filter((_,i)=>{const r=state.results[i]||{};return state.schedule[i].every(p=>r[p]!=null)}).length; | |
| if(completed===totalRaces && totalRaces>0){ | |
| // Check for tie at the top | |
| const topPts=arr[0].pts; | |
| const tied=arr.filter(s=>s.pts===topPts); | |
| if(tied.length>1){ | |
| renderTiebreaker(tied); | |
| } else { | |
| $('winnerBox').innerHTML=`<div class="winner">${t().wins_msg(escape(arr[0].player))}</div>`; | |
| } | |
| } else { | |
| $('winnerBox').innerHTML=`<p class="muted" style="margin-top:14px">${t().progress(completed,totalRaces)}</p>`; | |
| } | |
| } | |
| window.removePlayer=removePlayer; | |
| applyI18n(); | |
| render(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment