CONNECT 4 - INTERACTIVE ARCADE GAME SOURCE CODE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Grid Four</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{--cyan:#37e8ff;--orange:#ff8a1f;--dim:#7fb8c4;--bg:#02060c}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);font-family:'Space Grotesk',system-ui,sans-serif;color:#d8f6ff}
canvas{display:block}
.hud{position:fixed;left:0;right:0;bottom:0;pointer-events:none;padding:14px 20px 16px}
.hud>*{pointer-events:auto}
header{text-align:center}
.panel{display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
.plate{font-size:16px;font-weight:500;padding:10px 22px;min-width:240px;text-align:center;
color:#fff;background:rgba(2,6,12,.7);border:1px solid var(--cyan);box-shadow:0 0 10px rgba(55,232,255,.6),inset 0 0 14px rgba(55,232,255,.15);
clip-path:polygon(10px 0,100% 0,calc(100% - 10px) 100%,0 100%)}
.plate.p2{border-color:var(--orange);box-shadow:0 0 10px rgba(255,138,31,.6),inset 0 0 14px rgba(255,138,31,.15)}
.plate.win{background:#fff;color:#02060c}
.btn{font-family:inherit;font-size:14px;font-weight:500;color:var(--cyan);border-radius:4px;background:transparent;cursor:pointer;
border:1px solid rgba(55,232,255,.5);padding:9px 14px;transition:box-shadow .15s,background .15s}
.btn:hover{background:rgba(55,232,255,.08);box-shadow:0 0 12px rgba(55,232,255,.5)}
.btn:focus-visible{outline:1px solid #fff;outline-offset:2px}
.btn[aria-pressed="true"]{background:var(--cyan);color:#02060c;box-shadow:0 0 18px var(--cyan)}
.hint{font-size:13px;color:var(--dim);text-align:center;width:100%;margin:8px 0 0;opacity:.7}
.legend{display:flex;gap:18px;align-items:center;font-size:15px}
.legend span{display:inline-flex;align-items:center;gap:8px}
.legend i{width:14px;height:14px;border-radius:50%;border:3px solid;display:inline-block}
.legend i.b{border-color:var(--cyan);box-shadow:0 0 8px var(--cyan)}
.legend i.o{border-color:var(--orange);box-shadow:0 0 8px var(--orange);border-style:dashed}
</style>
</head>
<body>
<div class="hud">
<div>
<div class="panel">
<button class="btn" id="reset">Derez board</button>
<div class="legend"><span><i class="b"></i>Blue</span><div class="plate" id="status">Blue to move</div><span><i class="o"></i>Orange</span></div>
<button class="btn" id="automaton" aria-pressed="false">MCP plays orange: off</button>
</div>
<p class="hint">Click a column to drop a disc · keys 1–7 · R resets</p>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
const ROWS=6, COLS=7, PITCH=1, HOLE=0.40, DISC_R=0.44, DISC_T=0.14, SLAB_T=0.3, BOARD_Y=1.4;
const NAMES={1:'Blue',2:'Orange'};
const C1=new THREE.Color(0x37e8ff), C2=new THREE.Color(0xff8a1f);
const CORE1=new THREE.Color(0x0a2a38), CORE2=new THREE.Color(0x3a1a06);
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setSize(innerWidth,innerHeight);
renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.outputEncoding=THREE.sRGBEncoding;renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.0;
document.body.appendChild(renderer.domElement);
const scene=new THREE.Scene();
scene.background=new THREE.Color(0x02060c);scene.fog=new THREE.Fog(0x02060c,16,40);
const camera=new THREE.PerspectiveCamera(42,innerWidth/innerHeight,0.1,100);
const LOOK=new THREE.Vector3(0,BOARD_Y+2.6,0);
camera.position.set(0,LOOK.y+2.2,11.6);camera.lookAt(new THREE.Vector3(0,LOOK.y-0.9,0));
const rig=new THREE.Group();scene.add(rig);
scene.add(new THREE.AmbientLight(0x1a3a4a,.6));
const key=new THREE.DirectionalLight(0xbfefff,.5);key.position.set(4,12,8);key.castShadow=true;key.shadow.mapSize.set(2048,2048);
key.shadow.camera.left=-8;key.shadow.camera.right=8;key.shadow.camera.top=10;key.shadow.camera.bottom=-2;scene.add(key);
const l1=new THREE.PointLight(0x37e8ff,1.2,18,2);l1.position.set(0,BOARD_Y+3,4);scene.add(l1);
const l2=new THREE.PointLight(0xff8a1f,1.2,18,2);l2.position.set(0,BOARD_Y+3,-4);scene.add(l2);
const floor=new THREE.Mesh(new THREE.PlaneGeometry(60,60),new THREE.MeshStandardMaterial({color:0x03080f,roughness:.25,metalness:.6}));
floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;scene.add(floor);
const gridA=new THREE.GridHelper(60,60,0x37e8ff,0x0c3a48);gridA.position.y=.01;scene.add(gridA);
const gridB=new THREE.GridHelper(60,12,0x37e8ff,0x37e8ff);gridB.position.y=.015;gridB.material.transparent=true;gridB.material.opacity=.55;scene.add(gridB);
const glass=new THREE.MeshStandardMaterial({color:0x06101a,metalness:.5,roughness:.25});
const lineC=new THREE.LineBasicMaterial({color:0x37e8ff});
function outline(mesh,mat){const e=new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry),mat||lineC);mesh.add(e);}
function plateTexture(){
const s=128,cv=document.createElement('canvas');cv.width=COLS*s;cv.height=ROWS*s;const g=cv.getContext('2d');
g.fillStyle='#050a12';g.fillRect(0,0,cv.width,cv.height);
g.strokeStyle='rgba(55,232,255,.18)';g.lineWidth=2;
for(let r=0;r<=ROWS;r++){g.beginPath();g.moveTo(0,r*s);g.lineTo(cv.width,r*s);g.stroke();}
for(let c=0;c<=COLS;c++){g.beginPath();g.moveTo(c*s,0);g.lineTo(c*s,cv.height);g.stroke();}
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){g.strokeStyle='#37e8ff';g.lineWidth=5;g.beginPath();g.arc(c*s+s/2,r*s+s/2,HOLE*s+3,0,7);g.stroke();}
g.globalCompositeOperation='destination-out';
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){g.beginPath();g.arc(c*s+s/2,r*s+s/2,HOLE*s,0,7);g.fill();}
const t=new THREE.CanvasTexture(cv);t.encoding=THREE.sRGBEncoding;t.anisotropy=8;return t;
}
const tex=plateTexture();
const faceMat=new THREE.MeshStandardMaterial({map:tex,emissiveMap:tex,emissive:0xffffff,emissiveIntensity:.9,transparent:true,alphaTest:.5,metalness:.4,roughness:.3,side:THREE.DoubleSide});
const slab=new THREE.Mesh(new THREE.BoxGeometry(COLS*PITCH,ROWS*PITCH,SLAB_T),[glass,glass,glass,glass,faceMat,faceMat]);
slab.position.set(0,BOARD_Y+ROWS/2,0);slab.castShadow=true;slab.receiveShadow=true;rig.add(slab);outline(slab);
function bar(w,h,d,x,y,z){const b=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),glass);b.position.set(x,y,z);b.castShadow=true;b.receiveShadow=true;rig.add(b);outline(b);return b;}
bar(COLS+.8,.18,SLAB_T+.4,0,BOARD_Y+ROWS+.09,0);
bar(.22,ROWS+.36,SLAB_T+.4,-(COLS/2+.29),BOARD_Y+ROWS/2,0);
bar(.22,ROWS+.36,SLAB_T+.4,(COLS/2+.29),BOARD_Y+ROWS/2,0);
bar(COLS+1.4,.3,1.4,0,BOARD_Y-.15,0);
bar(COLS+2.6,.2,2.6,0,.1,0);
for(const x of[-COLS/2+.3,COLS/2-.3]){const p=new THREE.Mesh(new THREE.BoxGeometry(.16,BOARD_Y-.3,.16),glass);p.position.set(x,(BOARD_Y-.3)/2+.2,0);rig.add(p);outline(p);}
const strip=new THREE.Mesh(new THREE.BoxGeometry(COLS+2.6,.04,.06),new THREE.MeshBasicMaterial({color:0x37e8ff}));
for(const z of[1.3,-1.3]){const s2=strip.clone();s2.position.set(0,.22,z);rig.add(s2);}
const colHits=[];
for(let c=0;c<COLS;c++){const h=new THREE.Mesh(new THREE.BoxGeometry(PITCH,ROWS+1.2,2),new THREE.MeshBasicMaterial({visible:false}));
h.position.set(colX(c),BOARD_Y+ROWS/2+.4,0);h.userData.col=c;rig.add(h);colHits.push(h);}
function colX(c){return (c-(COLS-1)/2)*PITCH;} function rowY(r){return BOARD_Y+(ROWS-1-r)*PITCH+PITCH/2;}
const coreGeo=new THREE.CylinderGeometry(DISC_R*.78,DISC_R*.78,DISC_T,48).rotateX(Math.PI/2);
const ringGeo=new THREE.TorusGeometry(DISC_R-.04,.045,12,64);
const innerGeo=new THREE.TorusGeometry(DISC_R*.42,.025,8,48);
function makeDisc(p,ghostly=false){
const col=p===1?C1:C2;const g=new THREE.Group();
const em=new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:ghostly?.35:.7,metalness:.1,roughness:.5,transparent:ghostly,opacity:ghostly?.5:1});
const coreMat=new THREE.MeshStandardMaterial({color:p===1?CORE1:CORE2,emissive:p===1?CORE1:CORE2,emissiveIntensity:.6,metalness:.6,roughness:.35,transparent:ghostly,opacity:ghostly?.4:1});
const core=new THREE.Mesh(coreGeo,coreMat);core.castShadow=!ghostly;g.add(core);
if(p===1){g.add(new THREE.Mesh(ringGeo,em));g.add(new THREE.Mesh(innerGeo,em));}
else{for(let i=0;i<6;i++){const seg=new THREE.Mesh(new THREE.TorusGeometry(DISC_R-.04,.045,12,16,Math.PI/3*.72),em);seg.rotation.z=i*Math.PI/3;g.add(seg);}
const hex=new THREE.Mesh(new THREE.TorusGeometry(DISC_R*.42,.025,8,6),em);g.add(hex);}
const spoke=new THREE.Mesh(new THREE.BoxGeometry(DISC_R*1.2,.05,DISC_T+.02),em);const spoke2=spoke.clone();spoke2.rotation.z=Math.PI/2;
if(p===1)g.add(spoke,spoke2);else{spoke.rotation.z=Math.PI/4;spoke2.rotation.z=-Math.PI/4;g.add(spoke,spoke2);}
g.userData.em=em;g.userData.p=p;return g;
}
let ghost=makeDisc(1,true);ghost.visible=false;rig.add(ghost);
let grid,player,over,thinking=false,automaton=false,discs=[],tweens=[],winSet=[];
const statusEl=document.getElementById('status'),resetBtn=document.getElementById('reset');
function reset(){grid=Array.from({length:ROWS},()=>Array(COLS).fill(0));player=1;over=false;thinking=false;
discs.forEach(d=>rig.remove(d));discs=[];winSet=[];tweens=[];targetSpin=0;setTurn();}
function setTurn(){statusEl.className='plate'+(player===2?' p2':'');statusEl.textContent=NAMES[player]+' to move';
rig.remove(ghost);ghost=makeDisc(player,true);ghost.visible=false;rig.add(ghost);}
function landing(c){for(let r=ROWS-1;r>=0;r--)if(!grid[r][c])return r;return -1;}
function place(c){const r=landing(c);if(r<0)return;grid[r][c]=player;
const d=makeDisc(player);d.position.set(colX(c),BOARD_Y+ROWS+1.2,0);d.rotation.z=Math.random()*Math.PI;d.userData.r=r;d.userData.c=c;d.userData.p=player;rig.add(d);discs.push(d);
tweens.push({obj:d,from:d.position.y,to:rowY(r),t:0,dur:.35+r*.04});
ghost.visible=false;
const win=findWin(grid,player);
if(win){over=true;winSet=discs.filter(x=>win.some(([wr,wc])=>x.userData.r===wr&&x.userData.c===wc));
statusEl.className='plate win';statusEl.textContent=NAMES[player]+' wins';return;}
if(grid.every(row=>row.every(v=>v))){over=true;statusEl.textContent='Stalemate';return;}
player=3-player;setTurn();if(!automaton)targetSpin+=Math.PI;
if(automaton&&player===2){thinking=true;statusEl.textContent='MCP computing\u2026';setTimeout(()=>{thinking=false;place(bestMove());},600);}
}
function humanMove(c){if(over||thinking)return;if(automaton&&player===2)return;place(c);}
function findWin(g,p){const dirs=[[0,1],[1,0],[1,1],[1,-1]];
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++)if(g[r][c]===p)for(const[dr,dc]of dirs){const line=[[r,c]];
for(let k=1;k<4;k++){const rr=r+dr*k,cc=c+dc*k;if(rr<0||rr>=ROWS||cc<0||cc>=COLS||g[rr][cc]!==p)break;line.push([rr,cc]);}
if(line.length===4)return line;}return null;}
const ORDER=[3,2,4,1,5,0,6];
function land(g,c){for(let r=ROWS-1;r>=0;r--)if(!g[r][c])return r;return -1;}
function bestMove(){let best=-Infinity,choice=3;for(const c of ORDER){const r=land(grid,c);if(r<0)continue;grid[r][c]=2;const s=minimax(grid,4,-Infinity,Infinity,false);grid[r][c]=0;if(s>best){best=s;choice=c;}}return choice;}
function minimax(g,d,a,b,mx){if(findWin(g,2))return 100000+d;if(findWin(g,1))return -100000-d;if(d===0||g[0].every(v=>v))return evaluate(g);
if(mx){let v=-Infinity;for(const c of ORDER){const r=land(g,c);if(r<0)continue;g[r][c]=2;v=Math.max(v,minimax(g,d-1,a,b,false));g[r][c]=0;a=Math.max(a,v);if(a>=b)break;}return v;}
let v=Infinity;for(const c of ORDER){const r=land(g,c);if(r<0)continue;g[r][c]=1;v=Math.min(v,minimax(g,d-1,a,b,true));g[r][c]=0;b=Math.min(b,v);if(a>=b)break;}return v;}
function evaluate(g){let s=0;for(let r=0;r<ROWS;r++)if(g[r][3]===2)s+=3;
const w=a=>{const me=a.filter(v=>v===2).length,op=a.filter(v=>v===1).length,e=4-me-op;if(me===3&&e===1)return 12;if(me===2&&e===2)return 4;if(op===3&&e===1)return -14;if(op===2&&e===2)return -4;return 0;};
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){if(c+3<COLS)s+=w([g[r][c],g[r][c+1],g[r][c+2],g[r][c+3]]);if(r+3<ROWS)s+=w([g[r][c],g[r+1][c],g[r+2][c],g[r+3][c]]);
if(r+3<ROWS&&c+3<COLS)s+=w([g[r][c],g[r+1][c+1],g[r+2][c+2],g[r+3][c+3]]);if(r+3<ROWS&&c-3>=0)s+=w([g[r][c],g[r+1][c-1],g[r+2][c-2],g[r+3][c-3]]);}return s;}
let spin=0,targetSpin=0;
const ray=new THREE.Raycaster(),mouse=new THREE.Vector2();
const el=renderer.domElement;
function setMouse(e){mouse.x=(e.clientX/innerWidth)*2-1;mouse.y=-(e.clientY/innerHeight)*2+1;}
function hitCol(){ray.setFromCamera(mouse,camera);const h=ray.intersectObjects(colHits);return h.length?h[0].object.userData.col:-1;}
function settled(){return Math.abs(targetSpin-spin)<.03;}
el.addEventListener('pointermove',e=>{setMouse(e);
const c=hitCol();if(c>=0&&settled()&&!over&&!thinking&&!(automaton&&player===2)){const r=landing(c);ghost.visible=r>=0;ghost.position.set(colX(c),BOARD_Y+ROWS+.9,0);}else ghost.visible=false;});
el.addEventListener('pointerup',e=>{setMouse(e);const c=hitCol();if(c>=0&&settled())humanMove(c);});
el.addEventListener('pointerleave',()=>{ghost.visible=false;});
document.addEventListener('keydown',e=>{const n=parseInt(e.key,10);if(n>=1&&n<=7&&settled())humanMove(player===1?n-1:7-n);if(e.key==='r'||e.key==='R')resetBtn.click();});
resetBtn.addEventListener('click',reset);
const autoBtn=document.getElementById('automaton');autoBtn.onclick=()=>{automaton=!automaton;autoBtn.setAttribute('aria-pressed',automaton);
autoBtn.textContent='MCP plays orange: '+(automaton?'on':'off');if(automaton)targetSpin=Math.round(targetSpin/(2*Math.PI))*2*Math.PI;
if(automaton&&player===2&&!over&&!thinking){thinking=true;setTimeout(()=>{thinking=false;place(bestMove());},500);}};
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});
const clock=new THREE.Clock();
function frame(){const dt=Math.min(clock.getDelta(),.05),t=clock.elapsedTime;
if(tweens.length===0)spin+=(targetSpin-spin)*Math.min(1,dt*5);rig.rotation.y=spin;
for(const tw of tweens){tw.t=Math.min(1,tw.t+dt/tw.dur);const k=tw.t,e=k<.85?(k/.85)*(k/.85):1-Math.sin((k-.85)/.15*Math.PI)*.05;
tw.obj.position.y=tw.from+(tw.to-tw.from)*e;tw.obj.rotation.z+=dt*5*(1-k);}
tweens=tweens.filter(tw=>tw.t<1);
ghost.rotation.z+=dt*1.5;ghost.position.y=BOARD_Y+ROWS+.9+Math.sin(t*3)*.06;
const pulse=.9+Math.sin(t*6)*.6;
for(const d of winSet){d.rotation.z+=dt*3;d.userData.em.emissiveIntensity=pulse;}
gridB.material.opacity=.4+Math.sin(t*1.2)*.15;
renderer.render(scene,camera);requestAnimationFrame(frame);}
reset();frame();
})();
</script>
</body>
</html>