A photon bouncing between two mirrors is a clock. Put that clock on a moving train and the photon has to travel diagonally, which is a longer path at the same speed. So the clock ticks slower. That is the whole argument.
EXP-008
Move the cursor over the canvas — it is part of the simulation.
Drag your finger across the canvas — it is part of the simulation.
What to look for
The view slider. At 1 (the default) it is the SAME clock seen from two frames: its own on top, yours below. The bounces are the SAME EVENTS, so both dots rise and fall exactly together, verified at 0.000000 px of drift. What differs is the path: H on top, gamma-H below. The animation runs on YOUR clock, so the bottom dot always moves at c no matter what beta does, while the top one slows to c/gamma: what you see slowing down is the rocket clock running slow. Move the slider and both panels respond. That is why your clock reads more: t/tau comes out as gamma, and that number is MEASURED off the drawing, not computed from the formula. At 0 the question changes: two separate clocks in one frame, and there both photons do move at the same on-screen speed, with the travelling one bouncing less often. Both views are correct and cannot coexist: bouncing together, moving at the same on-screen speed, and covering different distances are three things you can only ever have two of.
The maths
γ = 1/√(1 − β²)
The Lorentz factor, with β = v/c. At β = 0.6 it is 1.25; at 0.99 it is 7.1. It blows up at β = 1, which is why nothing with mass gets there.
Δt = γ·Δτ
Coordinate time against proper time. τ is what the moving clock reads; t is what you read. The counters in the corner drift apart by exactly this factor.
L = L₀/γ
Length contraction, the same factor upside down. The moving clock is drawn shorter for that reason, not for perspective.
How it is built
Two clocks are drawn. The one at rest bounces its photon straight up and down; the moving one is carried sideways while its photon bounces, and its position is recorded frame by frame so the diagonal path draws itself. The only relativistic line in the whole thing is that the moving clock advances its phase by dt/gamma. The contracted ruler underneath is drawn separately, because contraction acts along the motion while the mirror separation is perpendicular to it and does not change.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
This is the only place dilation is applied. It is not a drawing trick: the photon travels a longer diagonal at the same speed c, so each bounce takes gamma times longer measured from here. The tick counters diverge by exactly that ratio.
The photon position is recorded frame by frame in this reference frame and drawn as a trail. Nobody draws the diagonal by hand: it appears because the clock moves sideways while the photon bounces, and the longer path is right there to see.
Length contraction only applies along the direction of motion, so the mirror separation is untouched and a ruler underneath carries that effect instead. Mixing the two into one drawing is the usual way this diagram gets it wrong.
Why it matters
Einstein got here in 1905 from two assumptions: physics works the same in every inertial frame, and light has the same speed in all of them. Everything else, including E=mc², is bookkeeping from there. GPS satellites correct for this daily or positions would drift kilometres.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · relativity
{ id: 'relativity',
name: L('Special relativity', 'Relatividad especial'),
note: L('A light clock, seen from two frames. Same bounces, different elapsed time.',
'Un reloj de luz visto desde dos marcos. Mismos rebotes, distinto tiempo transcurrido.'),
params: [
{ key: 'vista', label: L('View: 0 same frame · 1 two frames', 'Vista: 0 un marco · 1 dos marcos'),
min: 0, max: 1, step: 1, def: 1 },
{ key: 'beta', label: L('Speed β = v/c', 'Velocidad β = v/c'), min: 0, max: 0.98, step: 0.005, def: 0.6 },
{ key: 'rate', label: L('Clock rate', 'Ritmo del reloj'), min: 0.3, max: 2.5, step: 0.05, def: 1 },
{ key: 'trail', label: L('Path memory', 'Memoria del camino'), min: 0, max: 900, step: 20, def: 420 }
],
make() {
// ── Dos diagramas distintos, y la diferencia entre ellos ES la lección.
//
// No se puede tener las tres cosas a la vez:
// (1) las dos bolitas rebotan en el mismo instante
// (2) las dos van a la misma rapidez en pantalla
// (3) la de la derecha recorre más camino (cierto si β > 0)
// Dos cualesquiera excluyen la tercera.
//
// Vista 1 (por defecto) = 1+3: el MISMO reloj visto desde dos marcos.
// Los rebotes son los MISMOS EVENTOS, así que ocurren juntos. Lo que
// cambia es cuánto tiempo marcó cada reloj entre esos dos eventos: el
// panel derecho recorre γ veces más camino a la misma c, o sea que en
// tu marco pasó γ veces más tiempo. Eso es la dilatación, derivada.
// Las rapideces EN PANTALLA no se comparan entre paneles porque cada
// panel avanza sobre su propio eje de tiempo.
//
// Vista 0 = 2+3: dos relojes en UN marco. Ahí sí ambos fotones van a c
// en pantalla, y por eso el que viaja rebota menos seguido.
let ph = 0, lab = 0, tau = 0, ticksM = 0, ticksR = 0, path = [], px0 = 0;
let xL = 0, yL = 0, prevPm = 1, upL = -1, legs = 0;
// Camino que lleva recorrido cada fotón, acumulado del dibujo mismo.
// Los relojes se calculan de acá (t = s / c) en vez de multiplicar por γ:
// así el número es una MEDICIÓN de lo que se está viendo y delata
// cualquier error de geometría, en vez de repetir la fórmula.
let sIzq = 0, sDer = 0, ultIzq = null, ultDer = null;
const tri = u => Math.abs(((u % 2) + 2) % 2 - 1); // diente de sierra 1→0→1
return {
reset() { ph = 0; lab = 0; tau = 0; ticksM = ticksR = 0; path = []; px0 = 0;
xL = 0; yL = 0; prevPm = 1; upL = -1; legs = 0;
sIzq = 0; sDer = 0; ultIzq = null; ultDer = null; },
step(ctx, w, h, t, acc, P, M) {
const beta = M.in ? Math.min(0.98, Math.max(0, M.x / w)) : P.beta;
const g = 1 / Math.sqrt(1 - beta * beta);
const dt = 0.016 * P.rate;
const dos = (P.vista | 0) === 1;
ctx.font = '11px monospace';
if (dos) {
// ══ DOS MARCOS ══════════════════════════════════════════════
// Un solo avance de fase manda los dos paneles: por construcción
// los rebotes son simultáneos, que es justo lo que se quiere ver.
// La animación corre sobre TU reloj. Por eso un tramo tarda γ
// veces más: la bolita de abajo queda fija en c pase lo que pase
// con β, y la de arriba se frena a c/γ. Antes el eje era el de los
// eventos y el panel de arriba no reaccionaba a la velocidad.
const prev = ph;
ph += (dt * 1.2) / g;
if (Math.floor(ph) !== Math.floor(prev)) legs++;
const p = tri(ph);
// Paneles APILADOS: así el reloj de abajo tiene el ancho entero
// para viajar y su reinicio ocurre fuera de pantalla. Lado a lado
// tenía que dar la vuelta dentro del panel y teletransportaba en
// cada ciclo, que es justo el defecto que esto venía a arreglar.
const H = h * 0.28;
const topA = h * 0.08, botA = topA + H; // marco del reloj
const topB = h * 0.46, botB = topB + H; // tu marco
const cV = 1.2 * H;
const mirror = (x, y0, y1, wide, a) => {
ctx.strokeStyle = `rgba(${acc},${a})`; ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(x - wide / 2, y0); ctx.lineTo(x + wide / 2, y0);
ctx.moveTo(x - wide / 2, y1); ctx.lineTo(x + wide / 2, y1);
ctx.stroke();
};
const wide = Math.min(w * 0.09, 84);
// ── Arriba: en el marco del propio reloj ──
const ax = w * 0.5, ay = topA + H * p;
mirror(ax, topA, botA, wide, 0.5);
ctx.strokeStyle = `rgba(${acc},0.18)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ax, topA); ctx.lineTo(ax, botA); ctx.stroke();
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.beginPath(); ctx.arc(ax, ay, 4.2, 0, TAU); ctx.fill();
// ── Abajo: el mismo reloj, moviéndose. Avanza de continuo y
// reaparece fuera del cuadro, nunca salta a la vista.
const pad = w * 0.10, span = w + 2 * pad;
if (!px0) px0 = pad + w * 0.30;
px0 += beta * cV * dt; // βγH por tramo, con el eje nuevo
let reinicio = false;
if (px0 > span) { px0 -= span; path.length = 0; reinicio = true; }
const bx = -pad + px0, by = topB + H * p;
path.push([bx, by]);
const keep = P.trail | 0;
if (keep === 0) path.length = 0;
else if (path.length > keep) path.splice(0, path.length - keep);
ctx.lineWidth = 1.1;
for (let k = 1; k < path.length; k++) {
if (Math.abs(path[k][0] - path[k - 1][0]) > w * 0.5) continue;
ctx.strokeStyle = `rgba(${acc},${((k / path.length) * 0.5).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(path[k - 1][0], path[k - 1][1]);
ctx.lineTo(path[k][0], path[k][1]);
ctx.stroke();
}
mirror(bx, topB, botB, wide / g, 0.9); // contracción de longitudes
ctx.strokeStyle = `rgba(${acc},0.18)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(bx, topB); ctx.lineTo(bx, botB); ctx.stroke();
ctx.fillStyle = `rgba(${acc},1)`;
ctx.beginPath(); ctx.arc(bx, by, 4.2, 0, TAU); ctx.fill();
// Guía: las dos van a la misma altura relativa, siempre
ctx.strokeStyle = `rgba(${acc},0.12)`; ctx.lineWidth = 1;
ctx.setLineDash([1, 5]);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(ax, ay + (topB - topA)); ctx.stroke();
ctx.setLineDash([]);
// Camino recorrido por cada fotón, tomado del dibujo
if (ultIzq) { const d = Math.hypot(ax - ultIzq[0], ay - ultIzq[1]); if (d < H) sIzq += d; }
if (ultDer && !reinicio) { const d = Math.hypot(bx - ultDer[0], by - ultDer[1]); if (d < H) sDer += d; }
ultIzq = [ax, ay]; ultDer = [bx, by];
ctx.fillStyle = `rgba(${acc},0.5)`;
ctx.fillText('en el marco del reloj', 14, topA - 10);
ctx.fillText('en tu marco', 14, topB - 10);
const propio = (sIzq / cV).toFixed(2);
const tuyo = (sDer / cV).toFixed(2);
const medido = sIzq > 1 ? (sDer / sIzq) : 1;
ctx.fillStyle = `rgba(${acc},0.9)`;
ctx.fillText('reloj del cohete τ = ' + propio + ' s tu reloj t = ' + tuyo + ' s', 12, h - 44);
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('mismos ' + legs + ' rebotes · arriba se ve lenta porque ese reloj corre lento en tu tiempo', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.fillText('β = ' + beta.toFixed(3) + ' γ = ' + g.toFixed(3) +
' t/τ medido = ' + medido.toFixed(3), 12, h - 12);
return;
}
// ══ UN MARCO ═══════════════════════════════════════════════════
const topY = h * 0.20, botY = h * 0.62, H = botY - topY;
const restX = w * 0.13;
const prevR = lab;
lab += dt;
const pr = tri(lab * 1.2);
if (Math.floor(lab * 1.2) !== Math.floor(prevR * 1.2)) ticksR++;
const prevT = tau;
tau += dt / g;
const pm = tri(tau * 1.2);
if (Math.floor(tau * 1.2) !== Math.floor(prevT * 1.2)) ticksM++;
const cLuz = 1.2 * H;
const pad = w * 0.10;
const span = w + 2 * pad;
if (!px0) px0 = pad + w * 0.32;
px0 = px0 + beta * cLuz * dt;
if (px0 > span) { px0 -= span; path.length = 0; }
const cxm = -pad + px0;
const phY = topY + H * pm;
path.push([cxm, phY]);
const keep = P.trail | 0;
if (keep === 0) path.length = 0;
else if (path.length > keep) path.splice(0, path.length - keep);
ctx.lineWidth = 1;
for (let i = 1; i < path.length; i++) {
if (Math.abs(path[i][0] - path[i - 1][0]) > w * 0.5) continue;
ctx.strokeStyle = `rgba(${acc},${((i / path.length) * 0.55).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(path[i - 1][0], path[i - 1][1]);
ctx.lineTo(path[i][0], path[i][1]);
ctx.stroke();
}
const mirror2 = (x, alpha, wide) => {
ctx.strokeStyle = `rgba(${acc},${alpha})`; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(x - wide / 2, topY); ctx.lineTo(x + wide / 2, topY);
ctx.moveTo(x - wide / 2, botY); ctx.lineTo(x + wide / 2, botY);
ctx.stroke();
};
mirror2(restX, 0.3, w * 0.07);
ctx.strokeStyle = `rgba(${acc},0.18)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(restX, topY); ctx.lineTo(restX, botY); ctx.stroke();
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.beginPath(); ctx.arc(restX, topY + H * pr, 3.2, 0, TAU); ctx.fill();
mirror2(cxm, 0.9, (w * 0.07) / g);
ctx.fillStyle = `rgba(${acc},1)`;
ctx.beginPath(); ctx.arc(cxm, phY, 3.8, 0, TAU); ctx.fill();
const rulerY = h * 0.79, L0 = w * 0.16;
ctx.strokeStyle = `rgba(${acc},0.25)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(restX - L0 / 2, rulerY); ctx.lineTo(restX + L0 / 2, rulerY); ctx.stroke();
ctx.strokeStyle = `rgba(${acc},0.85)`; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cxm - L0 / (2 * g), rulerY); ctx.lineTo(cxm + L0 / (2 * g), rulerY); ctx.stroke();
ctx.fillStyle = `rgba(${acc},0.9)`;
ctx.fillText('β = ' + beta.toFixed(3) + ' γ = ' + g.toFixed(3), 12, h - 44);
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('rest ' + ticksR + ' ticks', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.fillText('moving ' + ticksM + ' ticks ratio ' +
(ticksR ? (ticksM / ticksR).toFixed(3) : '—') + ' 1/γ = ' + (1 / g).toFixed(3), 12, h - 12);
}
};
}
},