Skip to content

Instantly share code, notes, and snippets.

@stigi
Created April 24, 2026 13:40
Show Gist options
  • Select an option

  • Save stigi/e348eef3032dc0e3159eb29090cbc407 to your computer and use it in GitHub Desktop.

Select an option

Save stigi/e348eef3032dc0e3159eb29090cbc407 to your computer and use it in GitHub Desktop.
Mario Kart Tournament Planner
<!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&#10;Luigi&#10;Peach&#10;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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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