Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save shricodev/8d18bb9c48b0433fbac0abfb3265b009 to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/8d18bb9c48b0433fbac0abfb3265b009 to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interactive Randomized Galton Board</title>
<style>
body {
display: flex;
flex-direction: column; /* Stack controls and canvas vertically */
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #f0f0f0; /* Light background for the page */
font-family: sans-serif;
overflow: hidden; /* Prevent scrollbars */
}
canvas {
border: 1px solid black;
/* Background color is set in Matter.js render options */
margin-top: 10px; /* Add some space below controls */
}
.controls {
display: flex;
align-items: center;
margin-bottom: 10px; /* Space below controls */
background-color: #ddd;
padding: 8px 15px;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.controls label {
margin-right: 10px;
font-weight: bold;
}
.controls input[type="range"] {
width: 150px;
margin-right: 10px;
cursor: pointer;
}
.controls span {
min-width: 30px; /* Ensure space for value */
text-align: right;
font-family: monospace;
font-weight: bold;
background-color: #eee;
padding: 2px 5px;
border-radius: 3px;
}
</style>
</head>
<body>
<!-- UI Controls -->
<div class="controls">
<label for="gravitySlider">Gravity:</label>
<input
type="range"
id="gravitySlider"
min="0"
max="2"
step="0.1"
value="1"
/>
<span id="gravityValue">1.0</span>
</div>
<!-- Simulation Canvas -->
<canvas id="simulationCanvas"></canvas>
<!-- Get Matter.js via CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
// --- Configuration ---
const canvasWidth = 600;
const canvasHeight = 800;
// Ball properties - ranges for randomization
const minBallRadius = 3;
const maxBallRadius = 7;
const minRestitution = 0.3;
const maxRestitution = 0.7;
const minDensity = 0.003;
const maxDensity = 0.009;
const pegRadius = 5;
const pegRows = 16;
const pegSpacingX = 40;
const pegSpacingY = 35;
const binCount = 13;
const binHeight = 120;
const wallThickness = 20;
const dividerThickness = 8;
const ballDropInterval = 40; // *DECREASED* Drop balls faster (milliseconds)
const ballDropXVariance = 15;
const spinnerSpeed = 0.03;
const windTunnelForceMagnitude = 0.0004;
const fanForceMagnitude = 0.004; // Side fan/drag
// --- Colors ---
const colorWall = "#CCCC00"; // Darker Yellow
const colorPeg = "#FF0000"; // Red
const colorSpinner1 = "#FFA500"; // Orange
const colorSpinner2 = "#008080"; // Teal
const colorBall = "#404040"; // Dark Gray
const colorBackground = "#FFFFFF"; // White background
// --- Matter.js Modules ---
const {
Engine,
Render,
Runner,
Bodies,
Composite,
Events,
Body,
Vector,
} = Matter;
// --- Engine Setup ---
const engine = Engine.create();
const world = engine.world;
// Initial gravity set from slider default
engine.world.gravity.y = parseFloat(
document.getElementById("gravitySlider").value,
); // Read initial value
engine.world.gravity.x = fanForceMagnitude;
// --- Renderer Setup ---
const canvas = document.getElementById("simulationCanvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const render = Render.create({
canvas: canvas,
engine: engine,
options: {
width: canvasWidth,
height: canvasHeight,
wireframes: false,
background: colorBackground,
},
});
// --- UI Controls Setup ---
const gravitySlider = document.getElementById("gravitySlider");
const gravityValueSpan = document.getElementById("gravityValue");
// Set initial display value
gravityValueSpan.textContent = parseFloat(gravitySlider.value).toFixed(
1,
);
// Add event listener for slider changes
gravitySlider.addEventListener("input", (event) => {
const newGravity = parseFloat(event.target.value);
engine.world.gravity.y = newGravity; // Update engine gravity
gravityValueSpan.textContent = newGravity.toFixed(1); // Update displayed value
});
// --- World Boundaries ---
const groundY = canvasHeight - wallThickness / 2;
const ground = Bodies.rectangle(
canvasWidth / 2,
groundY,
canvasWidth + wallThickness,
wallThickness,
{
isStatic: true,
label: "ground",
render: { fillStyle: colorWall },
},
);
const leftWall = Bodies.rectangle(
wallThickness / 2,
canvasHeight / 2,
wallThickness,
canvasHeight,
{
isStatic: true,
label: "wall",
render: { fillStyle: colorWall },
},
);
const rightWall = Bodies.rectangle(
canvasWidth - wallThickness / 2,
canvasHeight / 2,
wallThickness,
canvasHeight,
{
isStatic: true,
label: "wall",
render: { fillStyle: colorWall },
},
);
Composite.add(world, [ground, leftWall, rightWall]);
// --- Pegs Setup ---
const pegs = [];
const startY = 100;
const pegsTotalWidth = canvasWidth - 2 * wallThickness;
for (let row = 0; row < pegRows; row++) {
const y = startY + row * pegSpacingY;
const numPegsInRow = Math.floor(pegsTotalWidth / pegSpacingX);
const rowStartX =
(canvasWidth - (numPegsInRow - 1) * pegSpacingX) / 2;
const offsetX = row % 2 === 0 ? 0 : pegSpacingX / 2;
for (let i = 0; i < numPegsInRow; i++) {
const x = rowStartX + i * pegSpacingX + offsetX;
if (
x > wallThickness + pegRadius &&
x < canvasWidth - wallThickness - pegRadius
) {
const peg = Bodies.circle(x, y, pegRadius, {
isStatic: true,
label: "peg",
restitution: 0.5,
friction: 0.1,
render: { fillStyle: colorPeg },
});
pegs.push(peg);
}
}
}
Composite.add(world, pegs);
// --- Dynamic Obstacles: Spinners ---
const spinners = [];
const spinnerPositions = [
{
x: canvasWidth * 0.3,
y: startY + pegSpacingY * 4.5,
color: colorSpinner1,
},
{
x: canvasWidth * 0.7,
y: startY + pegSpacingY * 8.5,
color: colorSpinner2,
},
{
x: canvasWidth * 0.5,
y: startY + pegSpacingY * 12.5,
color: colorSpinner1,
},
];
spinnerPositions.forEach((pos) => {
const spinner = Bodies.rectangle(pos.x, pos.y, 90, 12, {
isStatic: true,
label: "spinner",
angle: Math.random() * Math.PI * 2,
render: { fillStyle: pos.color },
});
spinners.push(spinner);
});
Composite.add(world, spinners);
Events.on(engine, "beforeUpdate", (event) => {
spinners.forEach((spinner) => {
Body.rotate(spinner, spinnerSpeed);
});
});
// --- Dynamic Obstacles: Wind Tunnel Zones ---
const windZones = [
{
x: 0,
y: startY + pegSpacingY * 6,
width: canvasWidth * 0.4,
height: pegSpacingY * 4,
force: Vector.create(windTunnelForceMagnitude, 0),
label: "wind-right",
},
{
x: canvasWidth * 0.6,
y: startY + pegSpacingY * 11,
width: canvasWidth * 0.4,
height: pegSpacingY * 4,
force: Vector.create(-windTunnelForceMagnitude, 0),
label: "wind-left",
},
];
Events.on(engine, "beforeUpdate", (event) => {
const bodies = Composite.allBodies(world);
bodies.forEach((body) => {
if (!body.isStatic && !body.isSensor && body.label === "ball") {
windZones.forEach((zone) => {
if (
body.position.x >= zone.x &&
body.position.x <= zone.x + zone.width &&
body.position.y >= zone.y &&
body.position.y <= zone.y + zone.height
) {
Body.applyForce(body, body.position, zone.force);
}
});
}
});
});
// --- Bins Setup ---
const binDividers = [];
const binBottomY = canvasHeight - wallThickness - binHeight / 2;
const dividerTopY = canvasHeight - wallThickness - binHeight;
const totalDividerWidth = (binCount + 1) * dividerThickness;
const totalBinSpace =
canvasWidth - 2 * wallThickness - totalDividerWidth;
const binWidth = totalBinSpace / binCount;
for (let i = 0; i <= binCount; i++) {
const x =
wallThickness +
i * dividerThickness +
i * binWidth +
dividerThickness / 2;
const divider = Bodies.rectangle(
x,
binBottomY,
dividerThickness,
binHeight,
{
isStatic: true,
label: "divider",
render: { fillStyle: colorWall },
},
);
binDividers.push(divider);
}
Composite.add(world, binDividers);
// --- Ball Drop ---
let ballCreationIntervalId = null; // Store interval ID
const dropBall = () => {
if (
Composite.allBodies(world).filter((b) => b.label === "ball")
.length > 300
) {
// Optional: Limit total balls to prevent extreme lag, adjust limit as needed
// console.log("Ball limit reached, pausing drop momentarily");
return;
}
const x = canvasWidth / 2 + (Math.random() - 0.5) * ballDropXVariance;
const y = 30;
// Randomized properties
const radius =
minBallRadius + Math.random() * (maxBallRadius - minBallRadius);
const restitution =
minRestitution + Math.random() * (maxRestitution - minRestitution);
const density =
minDensity + Math.random() * (maxDensity - minDensity);
const ball = Bodies.circle(x, y, radius, {
label: "ball",
restitution: restitution,
friction: 0.1,
frictionAir: 0.015,
density: density,
render: { fillStyle: colorBall },
});
Composite.add(world, ball);
// Simplified Ball Cleanup (Remove if settled low)
setTimeout(() => {
// Check if the body still exists in the world before accessing properties
if (
world.bodies.includes(ball) &&
ball.position.y > dividerTopY + 20 &&
Vector.magnitude(ball.velocity) < 0.1
) {
Composite.remove(world, ball);
}
}, 25000); // Check after 25 seconds
};
// --- Start Dropping Balls ---
const startDropping = () => {
if (ballCreationIntervalId === null) {
// Prevent multiple intervals
ballCreationIntervalId = setInterval(dropBall, ballDropInterval);
}
};
// Start dropping immediately on load
startDropping();
// --- Run Simulation ---
const runner = Runner.create();
Runner.run(runner, engine);
Render.run(render);
// --- Optional: Stop simulation if page visibility changes ---
// document.addEventListener("visibilitychange", () => {
// if (document.hidden) {
// Runner.stop(runner);
// Render.stop(render);
// if(ballCreationIntervalId !== null) {
// clearInterval(ballCreationIntervalId);
// ballCreationIntervalId = null;
// }
// } else {
// // Resume carefully if needed
// // Runner.start(runner, engine); // Use start, not run
// // Render.run(render); // Already running? Check Matter.js docs
// // startDropping();
// }
// });
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment