import {api,card,escape,toast} from './common.js';
export async function mario(root){
root.innerHTML=card('Level 1 to Bowser’s castle.',`
Start at Level 1 and clear the 15 fixed Mario levels in order, then face Bowser in Level 16. Learners share an action-value table. When a round finishes with a win, the class advances to the next level. Choose Auto to advance after a clear, or Manual to use the Next level button. You can switch without resetting your learner.
Auto is on. Levels advance after a clear.
Level 1 of 16 · Fixed campaign
Reach the flag to advance. The levels always appear in the same order.
FRESH POLICY · READY AT THE START
Your learners start with an empty action-value table. Try a test before learning, train a few rounds, then test again.
1round
32active agents
0finishes this run
0experiences
0Q-value updates
0situations stored
A tick advances the original game engine once. Speed changes how many game ticks we run per real second. The physics and learning rule stay the same. At high speeds the display samples frames, while all engine ticks still run. Ghost Marios are independent attempts; they do not collide with one another.
How far is the class getting?
Dark green: mean progress. Gold: best progress. A test uses no random exploration and makes no learning updates.
The leader’s action values
These are learned estimates of future reward, not probabilities. The agent sees nearby tiles, gaps, enemies, velocity, and whether it can jump.
Saving keeps the learned table for this classroom session.
Rewards, source & what is actually learning
Real tabular Q-learning updates one shared table after four game ticks. New forward distance earns points, coins earn extra points, falling or enemy collisions lose points, and reaching the flag earns a bonus. Random actions create new experience. There is no scripted jump controller, imitation teacher, pretrained policy, or search planner.
Jump school and Gap school are short teaching courses we made for the downloaded engine. Levels 1–15 are the framework’s fixed original level maps. Level 16 is our custom castle: Bowser patrols, jumps, and breathes fire. Stomp him three times or jump past him and touch the axe to drop his bridge, then reach the castle exit. The campaign never chooses a random world. Finishing a teaching course does not establish mastery of the full game.
Local, noncommercial educational research. Mario and the original graphical assets belong to Nintendo; the framework’s attribution notice is included with the downloaded source.
`,'REAL GAME ENGINE · ONLINE Q-LEARNING');
const $=s=>root.querySelector(s),canvas=$('#mario-canvas'),ctx=canvas.getContext('2d');let dirty=false,data,frame,tiles,width,alive=true,running=false,busy=false,human=false,humanView=false,generation=0,timer,raf,animationDone;const maps=new Map();
root.querySelectorAll('button').forEach(b=>b.disabled=true);
const assets=await Promise.all(['mapsheet','smallmariosheet','enemysheet','mariosheet','firemariosheet','itemsheet'].map(name=>new Promise(resolve=>{const image=new Image();image.onload=()=>resolve(image);image.onerror=()=>resolve(null);image.src='/static/mario-assets/'+name+'.png'})));
if(!root.isConnected)return()=>{};
const keys=new Set();
function controls(){const auto=data?.auto_advance!==false,pending=humanView?Boolean(frame?.next_level):Boolean(data?.awaiting_next_level);$('#mario-progression').disabled=busy||running;$('#mario-next').hidden=auto||data?.course!=='campaign';$('#mario-next').disabled=busy||running||dirty||!pending;$('#mario-progression-note').textContent=auto?'Auto is on. Levels advance after a clear.':'Manual is on. Clear a level, then press Next level.';for(const id of ['step','burst','test','human','reset','save','load','course','population'])$('#mario-'+id).disabled=busy||running;for(const id of ['step','burst','test','human'])$('#mario-'+id).disabled=busy||running||dirty;$('#mario-start').disabled=(busy&&!running)||dirty||(!auto&&!humanView&&pending);$('#mario-start').textContent=running?'Pause learning':'Start / resume learning'}
function draw(){
if(!frame)return;if(frame.map)maps.set(frame.level_number,frame.map);const currentMap=maps.get(frame.level_number);if(currentMap){tiles=currentMap.tiles;width=currentMap.width;}if(!tiles)return;const exitX=currentMap?.exit_x??data.exit_x,boss=frame.boss;$('#mario-level').textContent=`${frame.level_number?'Level '+frame.level_number+' of 16':frame.level_name}${boss?' · Bowser’s castle':''}${data.course==='campaign'?' · Fixed campaign':''}`;$('#mario-objective').textContent=boss?(boss.hp>0?'Bowser: '+boss.hp+'/3 health · Stomp him three times, or reach the axe beyond him. Then reach the castle exit.':'Bowser defeated! Reach the castle exit.'):'Reach the flag'+(data.course==='campaign'?(data.auto_advance===false?' to unlock the Next level button.':' to advance to the next level.'):' to finish this level.');const desiredHeight=canvas.clientWidth<600?800:420;if(canvas.height!==desiredHeight)canvas.height=desiredHeight;const lead=frame.runners?.[frame.leader||0]||frame.runner;const scale=canvas.height/256,view=1000/scale,cam=Math.max(0,Math.min(Math.max(0,width-view),lead.x-view*.35));ctx.setTransform(1,0,0,1,0,0);ctx.fillStyle=boss?'#242738':'#86bfd6';ctx.fillRect(0,0,1000,canvas.height);ctx.imageSmoothingEnabled=false;ctx.save();ctx.scale(scale,scale);ctx.translate(-cam,0);
ctx.fillStyle=boss?'#343545':'#cbe0d4';for(let x=0;x[x+','+y,t]));tiles.forEach((row,y)=>row.forEach((value,x)=>{const t=changed.get(x+','+y)??value;if(!t||x*16cam+view)return;if(assets[0])ctx.drawImage(assets[0],t%8*16,Math.floor(t/8)*16,16,16,x*16,y*16,16,16);else{ctx.fillStyle='#a77848';ctx.fillRect(x*16,y*16,16,16)}}));
if(boss){
ctx.fillStyle='#f36d30';for(let x=22*16;x<98*16;x+=16){if(!tiles[14]?.[Math.floor(x/16)]||x>=74*16)ctx.fillRect(x,242+Math.sin((frame.tick||0)/5+x)*2,16,14)}
ctx.fillStyle='#101522';ctx.fillRect(exitX-12,177,28,47);ctx.strokeStyle='#d5aa61';ctx.strokeRect(exitX-12,177,28,47);
if(!boss.axe_activated){ctx.fillStyle='#b48a50';ctx.fillRect(boss.axe_x,193,4,31);ctx.fillStyle='#e5e5cf';ctx.fillRect(boss.axe_x-8,189,18,12);}
if(boss.y<290){const x=boss.x,y=boss.y;ctx.fillStyle='#b4d35b';ctx.fillRect(x-18,y-30,32,26);ctx.fillStyle='#426840';ctx.fillRect(x-8,y-40,25,29);ctx.fillStyle='#f4d693';for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(x-8+i*8,y-38);ctx.lineTo(x-4+i*8,y-47);ctx.lineTo(x+i*8,y-38);ctx.fill()}ctx.fillStyle='#dfad55';ctx.fillRect(x-25,y-31,19,18);ctx.fillRect(x-17,y-6,12,7);ctx.fillRect(x+7,y-6,12,7);ctx.fillStyle='#f0eee0';ctx.fillRect(x-25,y-30,9,7);ctx.fillStyle='#a52e31';ctx.fillRect(x-24,y-29,4,4);ctx.fillStyle='#d25b3b';ctx.fillRect(x-24,y-38,12,6);}
for(const [x,y] of boss.fireballs){ctx.fillStyle='#f08b35';ctx.beginPath();ctx.ellipse(x,y,9,5,0,0,Math.PI*2);ctx.fill();ctx.fillStyle='#ffe39b';ctx.fillRect(x-4,y-2,5,4)}
}else{ctx.fillStyle='#edf5df';ctx.fillRect(exitX,48,2,176);ctx.fillStyle='#568368';ctx.beginPath();ctx.moveTo(exitX+2,50);ctx.lineTo(exitX+30,60);ctx.lineTo(exitX+2,72);ctx.fill();}
for(const [x,y,type] of frame.enemies||[]){if(assets[2])ctx.drawImage(assets[2],0,({2:2,3:2,4:0,5:0,6:1,7:1,8:3,9:3,10:5,11:6}[type]??2)*32,16,32,x-8,y-32,16,32);else{ctx.fillStyle='#8c503c';ctx.fillRect(x-7,y-12,14,12)}}
for(const [x,y,type] of frame.items||[]){if(type===16){ctx.fillStyle='#ffb34d';ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill()}else if(assets[5])ctx.drawImage(assets[5],(type===13?1:type===15?3:0)*16,0,16,16,x-8,y-16,16,16)}
const runners=frame.runners||[frame.runner];runners.forEach((r,i)=>{if(r.y>290)return;ctx.globalAlpha=r.done&&r.reason!=='win'?.18:r===lead||r.reason==='win'?1:.25;const size=r.large?32:16,sprite=r.large?assets[r.fire?4:3]:assets[1];if(sprite)ctx.drawImage(sprite,(r.grounded?Math.floor((frame.tick||0)/5)%3+1:4)*size,0,size,size,r.x-size/2,r.y-size,size,size);else{ctx.fillStyle='#bf4738';ctx.fillRect(r.x-6,r.y-16,12,16)}if(r===lead){ctx.globalAlpha=1;ctx.strokeStyle='#fcf4bd';ctx.lineWidth=1.3;ctx.strokeRect(r.x-10,r.y-19,20,22)}});ctx.globalAlpha=1;ctx.restore();
const hudScale=1000/Math.max(250,canvas.clientWidth);ctx.fillStyle='#204b47dd';ctx.fillRect(14,14,Math.min(972,350*hudScale),34*hudScale);ctx.fillStyle='#f7f4da';ctx.font=(12*hudScale)+'px monospace';ctx.fillText(`L${frame.level_number||'—'} · ${human?'YOU':frame.runners?'ROUND '+frame.round:'POLICY TEST'} · ${(lead.progress*100).toFixed(0)}%${boss?' · BOWSER '+boss.hp+'/3':''}`,26*hudScale,36*hudScale);
}
function chart(){const c=$('#mario-chart').getContext('2d'),h=data.history||[];c.clearRect(0,0,720,180);c.fillStyle='#eef3e6';c.fillRect(0,0,720,180);c.strokeStyle='#d5dfcf';for(let y=20;y<170;y+=35){c.beginPath();c.moveTo(32,y);c.lineTo(710,y);c.stroke()}for(const [key,color] of [['best_progress','#ca9b4d'],['mean_progress','#397e69']]){c.strokeStyle=color;c.lineWidth=2;c.beginPath();h.forEach((r,i)=>{const x=34+i/Math.max(1,h.length-1)*670,y=160-r[key]*140;i?c.lineTo(x,y):c.moveTo(x,y)});c.stroke()}c.fillStyle='#6e8266';c.font='12px sans-serif';c.fillText('100%',0,18);c.fillText('0%',8,165)}
function update(s,map=false){data={...data,...s};$('#mario-progression').value=data.auto_advance===false?'manual':'auto';if(map){dirty=false;tiles=s.tiles;width=s.width;$('#mario-course').value=s.course==='original'?'level-1':s.course;$('#mario-population').value=s.population;}if(s.tiles)maps.set(s.level_number,{tiles:s.tiles,width:s.width,exit_x:s.exit_x});frame=s.frame;for(const k of ['round','active','wins','experiences','updates','states'])$('#mario-'+k).textContent=(s[k]??data[k]??0).toLocaleString();$('#mario-values').innerHTML=(s.q_values||[]).map((v,i)=>`
${escape(['Run right','Run + jump','Move left','Left + jump','Wait','Jump in place'][i])}${v.toFixed(2)}
`).join('');chart();draw()}
const onResize=()=>draw();window.addEventListener('resize',onResize);
function stop(){running=false;human=false;clearTimeout(timer);generation++;cancelAnimationFrame(raf);animationDone?.();keys.clear();controls()}
function animate(frames,duration,seen){cancelAnimationFrame(raf);const start=performance.now();return new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;clearTimeout(fallback);cancelAnimationFrame(raf);if(alive&&seen===generation){for(const f of frames)if(f.map)maps.set(f.level_number,f.map);frame=frames.at(-1);draw()}animationDone=null;resolve()};animationDone=finish;const fallback=setTimeout(finish,duration+50);function tick(now){if(!alive||seen!==generation){finish();return}for(const f of frames)if(f.map)maps.set(f.level_number,f.map);frame=frames[Math.min(frames.length-1,Math.floor((now-start)/duration*frames.length))];draw();if(now-start>=duration)finish();else raf=requestAnimationFrame(tick)}raf=requestAnimationFrame(tick)})}
async function train(){if(!alive||!running||busy)return;busy=true;controls();const seen=generation;try{const speed=Number($('#mario-speed').value),s=await api('mario/train',{ticks:Math.max(1,Math.round(speed/5))});if(!alive||seen!==generation)return;update(s);$('#mario-mode').textContent=`${s.population} INDEPENDENT AGENTS · SHARED LEARNING`;$('#mario-message').textContent=`Round ${s.round}: ${s.active} still playing. ${s.wins} training finishes so far. Random exploration is ${(s.exploration*100).toFixed(0)}%.`;await animate(s.frames,200,seen);if(s.awaiting_next_level&&s.auto_advance===false){stop();$('#mario-mode').textContent='LEVEL CLEARED · READY FOR NEXT LEVEL';$('#mario-message').textContent='Level cleared. Press Next level when you are ready.';}if(s.campaign_complete){stop();$('#mario-mode').textContent='CAMPAIGN COMPLETE · BOWSER DEFEATED';$('#mario-message').textContent='All 16 levels cleared! Start a new learner to play the campaign again.'}}catch(e){if(alive){stop();$('#mario-message').textContent=e.message}}finally{busy=false;if(alive){controls();if(running)timer=setTimeout(train,0)}}}
async function once(fn){stop();busy=true;controls();try{await fn()}catch(e){if(alive)$('#mario-message').textContent=e.message}finally{busy=false;if(alive)controls()}}
for(const id of ['course','population'])$('#mario-'+id).onchange=()=>{dirty=Number($('#mario-population').value)!==data.population||$('#mario-course').value!==(data.course==='original'?'level-1':data.course);if(dirty)$('#mario-message').textContent='Settings changed. Choose New learner from settings to apply them with an empty Q table.';controls()};
$('#mario-start').onclick=()=>{if(running){stop();$('#mario-message').textContent='Paused. Test the learned policy or change the reward and exploration controls.'}else{stop();humanView=false;running=true;train()}};
$('#mario-step').onclick=()=>once(async()=>{humanView=false;const s=await api('mario/train',{ticks:1});update(s);if(s.awaiting_next_level&&s.auto_advance===false){$('#mario-message').textContent='Level cleared. Press Next level when you are ready.';return;}if(s.campaign_complete){$('#mario-message').textContent='Campaign complete! All 16 levels cleared.';return;}$('#mario-mode').textContent='SINGLE GAME TICK · SHARED LEARNING';$('#mario-message').textContent=`Advanced one game tick for ${s.active} active agents. The shared table updates after each four-tick action, or when an attempt ends.`});
$('#mario-reset').onclick=()=>once(async()=>{humanView=false;const n=Number($('#mario-population').value);if(!Number.isInteger(n)||n<1||n>128)throw Error('Choose between 1 and 128 agents.');update(await api('mario/reset',{population:n,course:$('#mario-course').value,seed:7}),true);$('#mario-message').textContent='New learner: empty Q table, every Mario at the starting line.';$('#mario-mode').textContent='FRESH POLICY · ALL AT THE START'});
$('#mario-burst').onclick=()=>once(async()=>{humanView=false;const target=data.round+20;let s=data;$('#mario-mode').textContent='ACCELERATED LEARNING';while(alive&&s.roundonce(async()=>{humanView=false;const s=await api('mario/evaluate',{});if(!alive)return;const r=s.evaluations[0];$('#mario-mode').textContent='POLICY TEST · NO EXPLORATION · NO UPDATES';$('#mario-message').textContent=`Test: ${(r.progress*100).toFixed(0)}% of the level · ${r.win?'level cleared':r.reason}. Tested after ${s.training_experiences.toLocaleString()} training experiences.`;await animate(r.frames,Math.min(7000,Math.max(1400,r.frames.length*40)),generation)});
const onDown=e=>{if(!human||!['ArrowLeft','ArrowRight',' ','ArrowUp'].includes(e.key))return;e.preventDefault();keys.add(e.key)},onUp=e=>keys.delete(e.key);canvas.addEventListener('keydown',onDown);canvas.addEventListener('keyup',onUp);canvas.addEventListener('blur',()=>keys.clear());
async function play(seen=generation){if(!alive||!human||seen!==generation)return;try{const jump=keys.has(' ')||keys.has('ArrowUp'),left=keys.has('ArrowLeft'),right=keys.has('ArrowRight'),a=left?(jump?3:2):right?(jump?1:0):jump?5:4;const nextFrame=await api('mario/human',{action:a});if(!alive||!human||seen!==generation)return;frame=nextFrame;draw();if(frame.runner.done){
if(frame.next_level){keys.clear();if(data.auto_advance===false){human=false;$('#mario-mode').textContent='LEVEL CLEARED · READY FOR NEXT LEVEL';$('#mario-message').textContent=`Level ${frame.level_number} cleared! Press Next level when you are ready.`;controls();return;}$('#mario-message').textContent=`Level ${frame.level_number} cleared! Next: Level ${frame.next_level}${frame.next_level===16?' · Bowser’s castle':''}.`;timer=setTimeout(()=>play(seen),900);return;}
human=false;$('#mario-human').textContent=frame.runner.reason==='win'?'Play yourself again':`Retry Level ${frame.level_number||'practice'}`;$('#mario-message').textContent=frame.campaign_complete?'You defeated Bowser and cleared all 16 levels!':frame.runner.reason==='win'?'Level cleared!':'Attempt ended: '+frame.runner.reason+'. Retry this level to continue.';if(frame.campaign_complete)$('#mario-mode').textContent='CAMPAIGN COMPLETE · BOWSER DEFEATED';return;}timer=setTimeout(()=>play(seen),120)}catch(e){stop();if(alive)$('#mario-message').textContent=e.message}}
$('#mario-human').onclick=()=>once(async()=>{humanView=true;frame=await api('mario/human/reset',{});if(!alive)return;human=true;$('#mario-human').textContent='Play yourself';draw();canvas.focus();$('#mario-mode').textContent='YOUR TURN · ARROWS + SPACE';$('#mario-message').textContent='Click the game, use left/right arrows to move and Space or up to jump. Your run does not train the learner. Clear the level to continue the campaign; a failed attempt retries the same level.';play()});
$('#mario-progression').onchange=()=>{const resumeHuman=human;once(async()=>{const s=await api('mario/progression',{auto_advance:$('#mario-progression').value==='auto'});if(!alive)return;data={...data,...s};$('#mario-progression').value=s.auto_advance?'auto':'manual';draw();$('#mario-message').textContent=s.auto_advance?'Auto-advance is on.':'Manual progression is on. Clear a level, then press Next level.';if(humanView&&((resumeHuman&&!frame.runner.done)||(s.auto_advance&&frame.next_level))){human=true;canvas.focus();play();}})};
$('#mario-next').onclick=()=>once(async()=>{if(humanView){frame=await api('mario/human/next',{});if(!alive)return;human=true;draw();canvas.focus();$('#mario-mode').textContent='YOUR TURN · ARROWS + SPACE';$('#mario-message').textContent=`Level ${frame.level_number} · Your turn.`;play();}else{update(await api('mario/next',{}));$('#mario-mode').textContent='NEXT LEVEL · READY AT THE START';$('#mario-message').textContent='Next level loaded. Start learning when you are ready.';}});
$('#mario-exploration').oninput=e=>$('#mario-exploration-value').textContent=Math.round(e.target.value*100)+'%';$('#mario-penalty').oninput=e=>$('#mario-penalty-value').textContent=e.target.value;
async function config(){try{await api('mario/config',{exploration:Number($('#mario-exploration').value),death_penalty:Number($('#mario-penalty').value)})}catch(e){toast(e.message,true)}}$('#mario-exploration').onchange=config;$('#mario-penalty').onchange=config;
$('#mario-save').onclick=()=>once(async()=>{const r=await api('mario/save',{});$('#mario-save-note').textContent=`Saved after ${r.experiences.toLocaleString()} experiences.`});$('#mario-load').onclick=()=>once(async()=>{humanView=false;update(await api('mario/load',{}),true);$('#mario-mode').textContent='SAVED LEARNER · NEW ROUND AT THE START';$('#mario-message').textContent='Saved action values loaded. A new round starts at the beginning.'});
data=await api('mario');if(!root.isConnected)return()=>{alive=false};$('#mario-exploration').value=data.exploration;$('#mario-exploration-value').textContent=Math.round(data.exploration*100)+'%';$('#mario-penalty').value=data.death_penalty;$('#mario-penalty-value').textContent=data.death_penalty;update(data,true);controls();
return()=>{alive=false;stop();window.removeEventListener('resize',onResize);canvas.removeEventListener('keydown',onDown);canvas.removeEventListener('keyup',onUp)};
}