Last active
February 11, 2026 08:14
-
-
Save imaman/cb29075f8f511f4a85b1e5070ddc7c20 to your computer and use it in GitHub Desktop.
The Tortoise & The Hare - Recreated in the Browser
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.0"> | |
| <title>The Tortoise & The Hare - Recreated</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 20px; | |
| background-color: #000; | |
| color: #0f0; | |
| font-family: 'Courier New', monospace; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 100vh; | |
| } | |
| h1 { | |
| color: #0f0; | |
| font-size: 24px; | |
| text-align: center; | |
| margin: 0; | |
| padding: 20px; | |
| text-transform: uppercase; | |
| display: none; | |
| } | |
| #container { | |
| background-color: #000; | |
| padding: 0; | |
| } | |
| #prompt { | |
| font-size: 16px; | |
| text-align: left; | |
| background-color: #4169E1; | |
| color: #87CEEB; | |
| padding: 10px 10px 10px 20px; | |
| margin: 0; | |
| font-weight: bold; | |
| letter-spacing: 2px; | |
| } | |
| .cursor { | |
| display: inline-block; | |
| width: 12px; | |
| height: 16px; | |
| background-color: #87CEEB; | |
| margin-left: 0; | |
| } | |
| canvas { | |
| display: block; | |
| background-color: #000; | |
| image-rendering: pixelated; | |
| image-rendering: crisp-edges; | |
| } | |
| #info { | |
| margin-top: 20px; | |
| font-size: 14px; | |
| text-align: center; | |
| color: #666; | |
| display: none; | |
| } | |
| button { | |
| margin-top: 20px; | |
| padding: 10px 30px; | |
| background-color: #0a0; | |
| color: #000; | |
| border: 2px solid #0f0; | |
| font-family: 'Courier New', monospace; | |
| font-size: 16px; | |
| cursor: pointer; | |
| text-transform: uppercase; | |
| } | |
| #status { | |
| margin-top: 10px; | |
| font-size: 14px; | |
| text-align: center; | |
| color: #0f0; | |
| min-height: 20px; | |
| } | |
| #progress { | |
| margin-top: 10px; | |
| font-size: 16px; | |
| text-align: center; | |
| color: #0f0; | |
| font-family: 'Courier New', monospace; | |
| } | |
| .progress-bar { | |
| display: inline-block; | |
| margin: 0 20px; | |
| } | |
| .tortoise-progress { | |
| color: #b97d2d; | |
| } | |
| .hare-progress { | |
| color: #b97d2d; | |
| } | |
| button:hover { | |
| background-color: #0f0; | |
| } | |
| button:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| #credit { | |
| margin-top: 24px; | |
| font-size: 12px; | |
| text-align: center; | |
| color: #555; | |
| line-height: 1.7; | |
| max-width: 640px; | |
| } | |
| #credit a { | |
| color: #777; | |
| text-decoration: none; | |
| } | |
| #credit a:hover { | |
| color: #0f0; | |
| text-decoration: underline; | |
| } | |
| #credit .book-title { | |
| color: #777; | |
| font-style: italic; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"> | |
| <h1>* The Tortoise & The Hare *</h1> | |
| <canvas id="raceCanvas" width="640" height="420"></canvas> | |
| <div id="info">Hare (left) creates random mess | Tortoise (right) fills solidly</div> | |
| <div style="text-align: center;"> | |
| <button id="raceBtn" onclick="startRace()">Start Race</button> | |
| <button id="gifBtn" onclick="generateGif()" disabled>Record Video</button> | |
| </div> | |
| <div id="status"></div> | |
| <div id="credit"> | |
| Inspired by a beloved program (<a href="https://gist.github.com/imaman/8fcde0272ed13c7f0fc78a1092bc21fe" target="_blank">gist</a>) from <a href="https://en.wikipedia.org/wiki/Atari_Games_%26_Recreations" target="_blank" class="book-title">ATARI® Games and Recreations</a> (1982) | |
| <br> | |
| by <a href="https://en.wikipedia.org/wiki/Herbert_R._Kohl" target="_blank">Herb Kohl</a>, | |
| <a href="https://lifeboat.com/ex/bios.ted.m.kahn" target="_blank">Ted Kahn</a>, | |
| Len Lindsay & Pat Cleland | |
| · Reston Publishing Co. | |
| </div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('raceCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const raceBtn = document.getElementById('raceBtn'); | |
| const recordBtn = document.getElementById('gifBtn'); | |
| const statusDiv = document.getElementById('status'); | |
| // Atari GRAPHICS 7 is 160x80 pixels, we'll scale up | |
| const ATARI_WIDTH = 160; | |
| const ATARI_HEIGHT = 80; | |
| const SCALE = 4; // Scale factor for visibility | |
| const PROMPT_HEIGHT = 50; // Height for the prompt area (increased for padding) | |
| const MARGIN_HEIGHT = 50; // Bottom margin beneath prompt area | |
| canvas.width = ATARI_WIDTH * SCALE; | |
| canvas.height = (ATARI_HEIGHT * SCALE) + PROMPT_HEIGHT + MARGIN_HEIGHT; | |
| let x = 0; | |
| let y = 0; | |
| let isRacing = false; | |
| let animationFrame; | |
| let isRecording = false; | |
| let frameCount = 0; | |
| let winner = null; | |
| let finalTortoisePercent = null; | |
| let finalHarePercent = null; | |
| // Atari GRAPHICS 7 COLOR 1 - tan/brown color | |
| const ATARI_COLOR = '#b97d2d'; | |
| function drawPrompt(tortoisePercent, harePercent) { | |
| const promptY = ATARI_HEIGHT * SCALE; | |
| // Draw blue background for prompt area | |
| ctx.fillStyle = '#4169E1'; | |
| ctx.fillRect(0, promptY, canvas.width, PROMPT_HEIGHT); | |
| // Draw prompt text | |
| ctx.fillStyle = '#87CEEB'; | |
| ctx.font = 'bold 16px "Courier New"'; | |
| ctx.fillText('WHO DO YOU THINK WILL FINISH FIRST - HARE OR TORTOISE?', 20, promptY + 18); | |
| // Draw progress bars if provided | |
| if (tortoisePercent !== undefined && harePercent !== undefined) { | |
| ctx.font = '14px "Courier New"'; | |
| // Create progress bars using block character █ | |
| const maxBarLength = 20; // characters | |
| const hareBlocks = Math.floor((parseFloat(harePercent) / 100) * maxBarLength); | |
| const tortoiseBlocks = Math.floor((parseFloat(tortoisePercent) / 100) * maxBarLength); | |
| const hareBar = '█'.repeat(hareBlocks) + ' '.repeat(maxBarLength - hareBlocks); | |
| const tortoiseBar = '█'.repeat(tortoiseBlocks) + ' '.repeat(maxBarLength - tortoiseBlocks); | |
| // Draw hare progress bar | |
| ctx.fillText(`H [${hareBar}]`, 20, promptY + 35); | |
| ctx.fillText(`${harePercent}%`, 20 + 240, promptY + 35); | |
| // Draw tortoise progress bar | |
| ctx.fillText(`T [${tortoiseBar}]`, 340, promptY + 35); | |
| ctx.fillText(`${tortoisePercent}%`, 340 + 240, promptY + 35); | |
| } | |
| } | |
| function drawWinner(winner) { | |
| const centerX = canvas.width / 2; | |
| const centerY = (ATARI_HEIGHT * SCALE) / 2; | |
| // Draw semi-transparent background | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; | |
| ctx.fillRect(centerX - 200, centerY - 40, 400, 80); | |
| // Draw winner text | |
| ctx.fillStyle = '#b97d2d'; | |
| ctx.font = 'bold 48px "Courier New"'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(`${winner.toUpperCase()} WON!`, centerX, centerY + 15); | |
| ctx.textAlign = 'left'; // Reset alignment | |
| } | |
| function drawPercentages(tortoisePercent, harePercent) { | |
| // This function now just redraws the entire prompt area with percentages | |
| drawPrompt(tortoisePercent, harePercent); | |
| } | |
| function plotPixel(px, py) { | |
| ctx.fillStyle = ATARI_COLOR; | |
| ctx.fillRect(px * SCALE, py * SCALE, SCALE, SCALE); | |
| } | |
| function drawLine(x1, y1, x2, y2) { | |
| ctx.strokeStyle = ATARI_COLOR; | |
| ctx.lineWidth = SCALE; | |
| ctx.beginPath(); | |
| ctx.moveTo(x1 * SCALE + SCALE/2, y1 * SCALE + SCALE/2); | |
| ctx.lineTo(x2 * SCALE + SCALE/2, y2 * SCALE + SCALE/2); | |
| ctx.stroke(); | |
| } | |
| function random() { | |
| return Math.random(); | |
| } | |
| function countFilledPixels(x, y, width, height) { | |
| const imageData = ctx.getImageData(x, y, width, height); | |
| const data = imageData.data; | |
| let filledCount = 0; | |
| // Data is [R, G, B, A, R, G, B, A, ...] | |
| // Check every pixel for non-black color | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| const a = data[i + 3]; | |
| // If not black (0,0,0) and not transparent | |
| if ((r !== 0 || g !== 0 || b !== 0) && a > 0) { | |
| filledCount++; | |
| } | |
| } | |
| return filledCount; | |
| } | |
| function updateProgress() { | |
| const raceAreaHeight = ATARI_HEIGHT * SCALE; | |
| const halfWidth = (ATARI_WIDTH / 2) * SCALE; | |
| const totalPixels = halfWidth * raceAreaHeight; | |
| let harePercent, tortoisePercent; | |
| // Use final stored percentages if race is over, otherwise calculate | |
| if (finalHarePercent !== null && finalTortoisePercent !== null) { | |
| harePercent = finalHarePercent; | |
| tortoisePercent = finalTortoisePercent; | |
| } else { | |
| // Count hare pixels (LEFT half now - 0-79) | |
| const harePixels = countFilledPixels(0, 0, halfWidth, raceAreaHeight); | |
| harePercent = (harePixels / totalPixels * 100).toFixed(1); | |
| // Count tortoise pixels (RIGHT half now - 80-159) | |
| const tortoisePixels = countFilledPixels(halfWidth, 0, halfWidth, raceAreaHeight); | |
| tortoisePercent = (tortoisePixels / totalPixels * 100).toFixed(1); | |
| } | |
| // Draw percentages on canvas | |
| drawPercentages(tortoisePercent, harePercent); | |
| // Redraw winner message if race is finished | |
| if (winner) { | |
| drawWinner(winner); | |
| } | |
| } | |
| function startRace() { | |
| if (isRacing) return; | |
| // Clear canvas | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Draw prompt area | |
| drawPrompt(0, 0); | |
| isRacing = true; | |
| raceBtn.disabled = true; | |
| recordBtn.disabled = true; | |
| x = 0; | |
| y = 0; | |
| frameCount = 0; | |
| winner = null; | |
| finalHarePercent = null; | |
| finalTortoisePercent = null; | |
| animate(); | |
| } | |
| function animate() { | |
| // Continue animating briefly after race ends if recording | |
| if (!isRacing && !isRecording) return; | |
| // Only do race logic if still racing | |
| if (isRacing) { | |
| // Execute multiple iterations per frame for speed | |
| for (let i = 0; i < 12; i++) { | |
| if (x > 79) { | |
| // Race complete | |
| isRacing = false; | |
| raceBtn.disabled = false; | |
| if (!isRecording) { | |
| recordBtn.disabled = false | |
| } | |
| // Calculate final percentages BEFORE drawing anything | |
| const raceAreaHeight = ATARI_HEIGHT * SCALE; | |
| const halfWidth = (ATARI_WIDTH / 2) * SCALE; | |
| const totalPixels = halfWidth * raceAreaHeight; | |
| const harePixels = countFilledPixels(0, 0, halfWidth, raceAreaHeight); | |
| const tortoisePixels = countFilledPixels(halfWidth, 0, halfWidth, raceAreaHeight); | |
| finalHarePercent = (harePixels / totalPixels * 100).toFixed(1); | |
| finalTortoisePercent = (tortoisePixels / totalPixels * 100).toFixed(1); | |
| // Determine winner | |
| winner = tortoisePixels > harePixels ? 'Tortoise' : 'Hare'; | |
| // Now draw progress with stored percentages (this will draw winner too) | |
| updateProgress(); | |
| break; // Exit the for loop | |
| } | |
| // Line 50: PLOT X,Y - This is the TORTOISE | |
| // Tortoise is now on the RIGHT side (80-159) | |
| plotPixel(x + 80, y); | |
| // Lines 60-70: The HARE jumps randomly | |
| // Hare is now on the LEFT side (0-79) | |
| // Line 60: PLOT (RND(1)*79)+80, RND(1)*79 | |
| const hareX1 = Math.floor(random() * 79); | |
| const hareY1 = Math.floor(random() * 79); | |
| plotPixel(hareX1, hareY1); | |
| // Line 70: DRAWTO (RND(1)*79)+80, RND(1)*79 | |
| const hareX2 = Math.floor(random() * 79); | |
| const hareY2 = Math.floor(random() * 79); | |
| drawLine(hareX1, hareY1, hareX2, hareY2); | |
| // Line 80: NEXT Y | |
| y++; | |
| if (y > 79) { | |
| y = 0; | |
| // Line 90: NEXT X | |
| x++; | |
| } | |
| } | |
| // Update progress every few frames for performance (only during race) | |
| if (frameCount % 5 === 0) { | |
| updateProgress(); | |
| } | |
| } else if (winner) { | |
| // Race ended, just keep redrawing winner message | |
| updateProgress(); | |
| } | |
| frameCount++; | |
| animationFrame = requestAnimationFrame(animate); | |
| } | |
| function generateGif() { | |
| if (isRacing) return; | |
| statusDiv.textContent = 'Recording...'; | |
| recordBtn.disabled = true; | |
| raceBtn.disabled = true; | |
| isRecording = true; | |
| // Use MediaRecorder API for proper video capture | |
| const stream = canvas.captureStream(30); // 30 FPS | |
| const mediaRecorder = new MediaRecorder(stream, { | |
| mimeType: 'video/webm;codecs=vp9' | |
| }); | |
| const chunks = []; | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) { | |
| chunks.push(e.data); | |
| } | |
| }; | |
| mediaRecorder.onstop = () => { | |
| const blob = new Blob(chunks, { type: 'video/webm' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'tortoise-hare.webm'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| recordBtn.disabled = false; | |
| raceBtn.disabled = false; | |
| isRecording = false; | |
| statusDiv.textContent = 'Video downloaded!'; | |
| }; | |
| mediaRecorder.start(); | |
| // Start the race | |
| startRace(); | |
| // Stop recording 0.5s after race finishes | |
| const checkComplete = setInterval(() => { | |
| if (!isRacing && isRecording) { | |
| setTimeout(() => { | |
| mediaRecorder.stop(); | |
| statusDiv.textContent = 'Rendering video...'; | |
| }, 500); // Wait 0.5s after race ends | |
| clearInterval(checkComplete); | |
| } | |
| }, 100); | |
| } | |
| // Initial setup | |
| ctx.fillStyle = '#000'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| drawPrompt(0, 0); | |
| // Enable GIF button | |
| recordBtn.disabled = false; | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment