Skip to content

Instantly share code, notes, and snippets.

@imaman
Last active February 11, 2026 08:14
Show Gist options
  • Select an option

  • Save imaman/cb29075f8f511f4a85b1e5070ddc7c20 to your computer and use it in GitHub Desktop.

Select an option

Save imaman/cb29075f8f511f4a85b1e5070ddc7c20 to your computer and use it in GitHub Desktop.
The Tortoise & The Hare - Recreated in the Browser
<!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 &amp; 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