Escape game · room 4 · 16+
The Bleeding Cellar
60 minutes to get out of Doctor Vorn's cellar. 2 to 6 players, every evening until 1 am.
Book a slotLiquid blood on a canvas: drops fall, merge and fill a pool at the bottom; your cursor or finger carries a glob that pulls them in.
Updated
This effect is free — the Distortion category contains 31 effects total, including 5 free. Effect.Labs has 840 vanilla effects. Explore the category →
Escape game · room 4 · 16+
60 minutes to get out of Doctor Vorn's cellar. 2 to 6 players, every evening until 1 am.
Book a slotVELMARO
SANGUEVRY
Saturday 31 October · 9 pm to 6 am · 6 films
9 pmThe Salt Marsh House
11 pmThe Drowned Hold
1 amThe Twelfth Bell
The .goo-scene spans the full width at a 16/10 ratio. Its canvas draws solid discs (drops, pool, glob), then CSS applies filter: blur(7px) contrast(16): the blur makes shapes bleed into one another, the very strong contrast redraws a sharp edge. Two nearby drops then weld together like liquid. That contrast also pushes each colour channel towards 0 or 255: the default #c81e2d red shows as pure red.
Every 0.18 s, a drop with a 9 to 18 px radius is born at a random spot along the top and falls, speeding up. When it touches the pool it disappears and raises it; the pool drains slowly at the same time. So it grows quickly, then levels off: measured on an 800 px wide scene, about 14% of the height after 3 s, 20% at 6 s, 23 to 25% from 10 s on (cap: half). Its surface ripples continuously.
The pointer, mouse or finger, carries a large glob with a 34 px radius that follows with a slight lag. It pulls in drops less than 150 px away, harder the closer they are; when it moves fast, it flings new droplets in its direction; they fall back into the pool, which then rises higher (measured: close to half the height after 10 s of continuous finger movement). The scene holds at most 130 drops. Speeds are computed from elapsed time (capped at 50 ms): same pace at 60, 120 or 144 Hz.
The canvas follows the scene's size (ResizeObserver) at the display density, capped at 2. The loop stops when the scene leaves the screen or the tab is hidden. Each [data-goo] runs its own simulation: two copies on a page are independent (measured in Chromium 151, Firefox 153 and WebKit 26.5).
prefers-reduced-motion: reduce, no drop falls by itself any more and the pool stays still as long as nobody touches the scene; the scene opens on a pool and nine still drops. The gesture stays active: when you move the pointer or finger over it, the glob appears, pulls and flings drops, which fall back into the pool and raise it (measured: from 22% to 43% of the height after 10 s of finger movement); everything stops 1.5 s after your last move.touch-action: none on the scene lets the finger drive the glob instead of scrolling the page (measured: 12 moves received, no cancellation, the glob under the finger). The page scrolls normally outside the scene; on mobile, leave some margin around it if the scene is large.aria-hidden="true"; the scene is silent. If the blood carries meaning (a “bleeding” 404 page), say so in the nearby text.data-color) or announce the theme.Canvas 2D, CSS blur() + contrast() filter, aspect-ratio, Pointer Events and 6.6 KB of JavaScript. Minimum version set by aspect-ratio according to MDN data (Chrome 88, Firefox 89, Safari 15). Measured in Chromium 151, Firefox 153 and WebKit 26.5; finger input measured in Chromium in touch mode.
Without aspect-ratio, the scene has no height: give it a fixed one (height: 320px). Without JavaScript, the scene stays a dark rectangle.
Copy the three blocks into your page. No dependencies.
<div class="fx-0803-root">
<div class="goo-scene" data-goo data-color="#c81e2d">
<canvas class="goo-canvas" aria-hidden="true"></canvas>
<div class="goo-hint" aria-hidden="true">Sweep the blood…</div>
</div>
</div>
.fx-0803-root,
.fx-0803-root *,
.fx-0803-root *::before,
.fx-0803-root *::after {
box-sizing: border-box
}
.fx-0803-root {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 200px;
position: relative;
background: rgb(10, 10, 15);
color: rgb(255, 255, 255);
font-family: sans-serif;
}
.goo-scene {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 14px;
overflow: hidden;
isolation: isolate;
background: #0b0406;
cursor: crosshair;
touch-action: none;
}
.goo-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
filter: blur(7px) contrast(16);
}
.goo-hint {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
font-size: 12px;
color: #ff5a5a;
opacity: .85;
white-space: nowrap;
pointer-events: none;
}
(function() {
/* ============================================================
Scoped JS (IIFE) — Cursed Blood Goo (fx-0803)
Blood metaballs on each [data-goo]
============================================================ */
(function() {
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var MAX = 130;
document.querySelectorAll('.fx-0803-root [data-goo]').forEach(function(scene) {
var canvas = scene.querySelector('.goo-canvas');
var ctx = canvas.getContext('2d');
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var W = 0, H = 0, drops = [], pool = 0, t = 0, drip = 0;
var mx = -999, my = -999, on = false, bx = -999, by = -999, pmx = -999, pmy = -999;
var visible = true, raf = 0, last = 0, awake = 0;
function blob(x, y, r) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.fill();
}
function render() {
if (!W || !H) return;
ctx.fillStyle = '#0b0406';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = scene.dataset.color || '#c81e2d';
// pool (rippling surface)
var surf = H - pool;
ctx.beginPath();
ctx.moveTo(0, H + 2);
ctx.lineTo(0, surf);
for (var x = 0; x <= W + 18 * dpr; x += 18 * dpr) ctx.lineTo(x, surf + Math.sin(x * 0.012 + t * 3) * 6 * dpr);
ctx.lineTo(W, H + 2);
ctx.closePath();
ctx.fill();
// drops
for (var i = 0; i < drops.length; i++) blob(drops[i].x, drops[i].y, drops[i].r);
// pointer glob
if (on) blob(bx, by, 34 * dpr);
}
function resize() {
var w = Math.round(scene.clientWidth * dpr);
var h = Math.round(scene.clientHeight * dpr);
if (!w || !h || (w === W && h === H)) return;
if (H) pool = pool * h / H;
else if (reduce.matches) {
// reduced motion: pool and drops in place from the start, nothing moves without the pointer
pool = h * 0.22;
for (var i = 0; i < 9; i++) drops.push({
x: (0.12 + Math.random() * 0.76) * w,
y: (0.2 + Math.random() * 0.45) * h,
vx: 0,
vy: 0,
r: (12 + Math.random() * 14) * dpr
});
}
W = canvas.width = w;
H = canvas.height = h;
render();
}
// f = frame duration in 60 fps frames (capped at 50 ms): same speed at 60, 120 or 144 Hz
function step(f) {
t += f / 60;
// dripping from the top (one drop every 11/60 s), off under reduced motion
if (!reduce.matches) {
drip += f;
while (drip >= 11) {
drip -= 11;
if (drops.length < MAX) drops.push({
x: Math.random() * W,
y: -12 * dpr,
vx: (Math.random() - 0.5) * 0.5,
vy: 1 + Math.random() * 1.4,
r: (9 + Math.random() * 9) * dpr
});
}
}
var floorY = H - pool, damp = Math.pow(0.99, f);
for (var i = drops.length - 1; i >= 0; i--) {
var d = drops[i];
d.vy += 0.28 * dpr * f;
d.vx *= damp;
if (on) {
var ax = mx - d.x,
ay = my - d.y,
dist = Math.hypot(ax, ay);
if (dist < 150 * dpr && dist > 1) {
var k = (1 - dist / (150 * dpr)) * 0.7 * f;
d.vx += ax / dist * k;
d.vy += ay / dist * k;
}
}
d.x += d.vx * f;
d.y += d.vy * f;
if (d.y + d.r * 0.4 >= floorY) {
// each drop feeds the pool (share of the height, whatever the width)
pool = Math.min(H * 0.5, pool + H * 0.012 * d.r * d.r / (169 * dpr * dpr));
drops.splice(i, 1);
continue;
}
if (d.x < -40 * dpr || d.x > W + 40 * dpr) drops.splice(i, 1);
}
// the pool drains slowly: it rises to an equilibrium (≈ 20 % of the height)
pool -= pool * 0.3 * f / 60;
if (on) {
var e = 1 - Math.pow(0.7, f);
bx += (mx - bx) * e;
by += (my - by) * e;
var sp = Math.hypot(mx - pmx, my - pmy) / f;
if (sp > 13 * dpr && drops.length < MAX) drops.push({
x: bx + (Math.random() - 0.5) * 22 * dpr,
y: by + (Math.random() - 0.5) * 22 * dpr,
vx: (mx - pmx) / f * 0.07,
vy: (my - pmy) / f * 0.07 + 1,
r: (6 + Math.random() * 6) * dpr
});
}
pmx = mx;
pmy = my;
}
function frame(now) {
raf = 0;
var f = last ? Math.min(now - last, 50) * 0.06 : 1;
last = now;
step(f);
render();
sync();
}
// loop paused off screen / hidden tab; under reduced motion it only runs for 1.5 s after a gesture
function sync() {
var run = visible && !document.hidden && W > 0 && (!reduce.matches || performance.now() < awake);
if (run && !raf) raf = requestAnimationFrame(frame);
if (!run) {
if (raf) cancelAnimationFrame(raf);
raf = 0;
last = 0;
}
}
function point(e) {
var r = scene.getBoundingClientRect();
mx = (e.clientX - r.left) * dpr;
my = (e.clientY - r.top) * dpr;
if (!on) {
bx = pmx = mx;
by = pmy = my;
}
on = true;
awake = performance.now() + 1500;
sync();
}
function leave() {
on = false;
mx = my = -999;
}
scene.addEventListener('pointerdown', point);
scene.addEventListener('pointermove', point);
scene.addEventListener('pointerleave', leave);
scene.addEventListener('pointercancel', leave);
resize();
if ('ResizeObserver' in window) new ResizeObserver(function() {
resize();
sync();
}).observe(scene);
else window.addEventListener('resize', resize);
if ('IntersectionObserver' in window) {
new IntersectionObserver(function(entries) {
visible = entries[0].isIntersecting;
sync();
}).observe(scene);
}
if ('MutationObserver' in window) new MutationObserver(render).observe(scene, {
attributes: true,
attributeFilter: ['data-color']
});
document.addEventListener('visibilitychange', sync);
if (reduce.addEventListener) reduce.addEventListener('change', sync);
sync();
});
})();
})();
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
data-color (on .goo-scene) |
#c81e2d (shown as pure red) |
Liquid colour, read on every frame: it can be changed live. The filter pushes each channel to 0 or 255: prefer pure colours (#00ff00, #ff00ff, #00ffff, #ffff00). |
Shape (CSS — aspect-ratio of .goo-scene) |
16 / 10 |
Replace with aspect-ratio: auto; height: 100% to fill a container, or a fixed height for a band. |
Drop merging (CSS — filter of .goo-canvas) |
blur(7px) contrast(16) |
More blur (10–12 px) = softer drops that weld from further away; less (4 px) = sharper, separate drops. |
Drip rate (JS — drip >= 11) |
One drop every 11/60 s | 6 for a dense rain, 20 for a slow drip. The more drops, the higher the pool rises. |
Pool height (JS — H * 0.012 and pool * 0.3) |
≈ 25% of the height | The first number is each drop's contribution, the second the draining speed. Double the contribution for a pool twice as high (cap: half the scene). |
Attraction (JS — 150 * dpr and 0.7) |
150 px radius, 0.7 strength | Reach of the glob and strength of the pull. Set the strength to 0 for a glob that only flings. |
Hint (HTML — .goo-hint) |
“Sweep the blood…” | Replace the text or delete the tag. |
The contrast(16) that welds the drops multiplies each channel's distance from the middle by 16: anything above half goes to 255, the rest to 0. An orange #ff8000 comes out roughly orange, but a brown or a pastel turns red, yellow or white. Pick a pure colour, or lower the contrast (drop edges then get blurry).
Yes, outside the scene. On the scene itself the finger drives the glob (touch-action: none): that is deliberate, otherwise the browser takes over the gesture to scroll and the glob does not follow. For a full-screen scene on mobile, leave a margin or reduce its height.
It drains all the time and only holds up thanks to the falling drops: raise the 11 in drip >= 11 (fewer drops) for a lower pool, or set a very large value to let it empty. Under reduced motion it only changes while you move over the scene. For a higher permanent pool, increase the H * 0.012 contribution.