Skip to content

Instantly share code, notes, and snippets.

@44100hertz
Created July 23, 2025 22:09
Show Gist options
  • Save 44100hertz/3c3f95e140991d3db8986da08f905ac6 to your computer and use it in GitHub Desktop.
Save 44100hertz/3c3f95e140991d3db8986da08f905ac6 to your computer and use it in GitHub Desktop.
FSG in vanilla JS
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<style>
body {
margin: 0;
}
#sand {
height: calc(100vmin);
border: none;
display: block;
margin: 0 auto;
}
</style>
<canvas id="sand" width=400 height=400></canvas>
<script>
const sand = document.getElementById('sand');
const ctx = sand.getContext('2d');
// helpers
const sandSize = sand.width * sand.height;
const FLAG = {
shiftl: 0x400,
shiftr: 0x800,
falling: 0x1000,
solid: 0x2000,
granular: 0x4000,
liquid: 0x8000,
}
const PROP = {
solid: FLAG.solid,
falling: FLAG.solid | FLAG.falling,
granular: FLAG.solid | FLAG.falling | FLAG.granular,
liquid: FLAG.liquid | FLAG.solid | FLAG.granular | FLAG.falling,
}
const P = {
empty: 0,
wall: PROP.solid,
rock: PROP.falling,
sand: PROP.granular,
water: PROP.liquid,
};
const colors = {
[P.empty]: [0,0,0],
[P.sand]: [1,1,0.5],
[P.rock]: [1,0.5,0.5],
[P.wall]: [0.5,0.5,0.5],
[P.water]: [0.0,0.5,1.0],
}
const particles = {
particles: new Array(sandSize).fill(P.empty),
drawLine (p, x1, y1, x2, y2) {
if (x2 - x1 == 0) {
// vertical line
if (y1 > y2) [y1,y2] = [y2,y1];
for (let y=y1; y<=y2; ++y) {
this.set(x1, y, p);
this.set(x1+1, y, p);
}
} else {
// diagonal / horizontal line
let dist = Math.max(Math.abs(x2-x1), Math.abs(y2-y1));
for (let t = 0; t < dist; ++t) {
let tt = t / dist;
let x = tt * (x2 - x1) + x1;
let y = tt * (y2 - y1) + y1;
this.set(x, y, p, true)
this.set(x+1, y, p, true)
this.set(x, y+1, p, true)
this.set(x+1, y+1, p, true)
}
}
},
toIndex (x,y) { return Math.round(x) + Math.round(y) * sand.width },
get(x, y) {
if (x < 0 || x > sand.width || y < 0 || y > sand.width) return P.wall;
return this.particles[this.toIndex(x, y)];
},
set(x, y, p, withinLine) {
if (x < 0 || x > sand.width || y < 0 || y > sand.width) return;
this.particles[this.toIndex(x, y)] = p;
},
draw() {
this.particles.forEach((p, i) => {
let [r,g,b] = colors[clearMove(p)];
pixels.data[i * 4 + 0] = r * 255;
pixels.data[i * 4 + 1] = g * 255;
pixels.data[i * 4 + 2] = b * 255;
pixels.data[i * 4 + 3] = 255;
});
ctx.putImageData(pixels, 0, 0);
}
}
const pixels = ctx.createImageData(sand.width, sand.height);
let mouseX = null;
let mouseY = null;
let activeP = P.sand;
const clearMove = (p) => p & ~(FLAG.shiftr | FLAG.shiftl);
const setMove = (p, dir) => clearMove(p) | (
dir == 0 ? 0 :
dir == 1 ? FLAG.shiftr :
FLAG.shiftl);
function update() {
if (mouseX) {
particles.set(mouseX, mouseY, activeP);
}
for (let y = sand.height - 1; y >= 0; --y) {
let [start, end, iterDirection] = Math.random() < 0.5 ?
[0, sand.width, 1] : [sand.width, -1, -1];
// randomly iterate either left or right
for (let x = start; x != end; x += iterDirection) {
const p = particles.get(x, y);
const tryMove = (ox, oy) => {
const dest = particles.get(x+ox, y+oy);
if (!(FLAG.solid & dest)) {
particles.set(x, y, P.empty);
particles.set(x+ox, y+oy, setMove(p, ox));
return true;
}
}
// straight down -- for general falling case
if (p & FLAG.falling) {
if (tryMove(0, 1)) {
continue;
}
}
const moveDirection =
(p & FLAG.shiftr) ? 1 :
(p & FLAG.shiftl) ? -1 :
-iterDirection;
// diagonal move -- for grains
if (p & FLAG.granular) {
if (tryMove(moveDirection, 1)) {
continue;
}
}
// sideways move -- for liquids
if (p & FLAG.liquid) {
if (tryMove(moveDirection, 0)) {
continue;
}
}
// cannot move -- return to neutrality
if (moveDirection == -iterDirection)
particles.set(x, y, clearMove(p));
}
}
particles.draw();
}
setInterval(update, (1000 / 60));
let internalPos = (ev) => {
const {width, height} = sand.getBoundingClientRect()
return [
Math.floor(ev.offsetX / width * sand.width),
Math.floor(ev.offsetY / height * sand.height)
];
}
addEventListener("keydown", (ev) => {
const keys = Object.keys(P);
const idx = keys.findIndex((k) => P[k] == activeP);
let activeK = keys[idx+1] ? keys[idx+1] : keys[0];
console.log(activeK);
activeP = P[activeK];
})
sand.addEventListener("mousedown", (ev) => {
const [x,y] = internalPos(ev);
particles.set(x, y, activeP);
[mouseX, mouseY] = internalPos(ev);
})
sand.addEventListener("mousemove", (ev) => {
if (mouseX != null) {
let [x,y] = internalPos(ev);
particles.drawLine(activeP, mouseX, mouseY, x, y);
[mouseX, mouseY] = [x, y];
}
})
addEventListener("mouseup", () => {
mouseX = null;
mouseY = null;
})
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment