• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
07Games
└ Rockfall└ Speak & Math└ Space Invaders└ Apache└ Asteroids└ Molly Roger└ Carrom└ Chess└ Connect 4└ Anagrams
08Voronoi
└ 001└ 002└ 003└ 004└ 005
09Terrain
└ Simple Polygon└ Simple Flyover└ Infinite Flyover 01└ Infinite Flyover 02└ Hexagonal Plane└ Mountain Ridges
10Critter Models 01
└ Wireframe Spider└ Wireframe Fly└ Wireframe Scorpion└ Wireframe Mosquito└ Wireframe Bee└ Wireframe Butterfly└ Wireframe Locust
11Chromatophores
└ 01└ 02└ 03└ 04
12Conformal Maps
└ 01└ 02└ 03└ 04└ 05
02 / 03

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>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.