Skip to content

Instantly share code, notes, and snippets.

@AlloySecureGroup
Last active July 15, 2026 14:25
Show Gist options
  • Select an option

  • Save AlloySecureGroup/2f756440a32d5326a25431c2c74cd099 to your computer and use it in GitHub Desktop.

Select an option

Save AlloySecureGroup/2f756440a32d5326a25431c2c74cd099 to your computer and use it in GitHub Desktop.
Bret Victor's Inventing on Principle Time Travel Debugger - recreation. Experiment in creators using AI to quicky get feedback.

Time Travel Debugger

A single page web app inspired by Bret Victor's talk "Inventing on Principle".

Talk: https://www.youtube.com/watch?v=PUv66718DII

In the talk, Bret Victor demonstrates a Mario style game in JavaScript with a time travel debugger. He can pause the game, scrub back and forth through time, see trails of the character's past and future, and change values in the code to see how the future changes.

This project recreates that demo in a single HTML file: time-travel-debugger.html. Open it in any browser. There are no dependencies and no build step.

The original prompt

In the talk Inventing on Principle. Bret Victor, demonstrates a Mario themed game in JavaScript with a time travel debugger, that allows him to trace and show trails of the characters past, present and future to experiment with the code. The time travel debugger is dynamic, works in the browser with a slider.

I'd like you to create a page like this that allows me to pause a character and enter debugging mode with trails and alternate futures, and tracking the settings or changes made.

Screenshots from the talk

How it works

The page has three parts: a game canvas on the left, a code panel on the right, and a transport bar with a timeline slider below.

The game

A small character runs and jumps through a level with brick platforms, a water gap, and a star. The character is autonomous. The physics simulation uses a fixed timestep and no randomness, so given the same starting state and the same settings it always produces the same result. This determinism is what makes time travel possible.

Recording the past

While the game plays, every frame of the character's state (position, velocity, direction) is pushed into a history array. The last 60 seconds are kept.

Pausing and scrubbing

Press Pause to freeze the world. The timeline slider unlocks and maps directly onto the history array. Dragging it moves the character to any recorded frame. A gray trail shows the past leading up to that moment.

Computing the future

When paused, the app takes the state at the current frame and runs the same physics function forward for a set number of frames. That produces the orange trail: the character's future. Because the simulation is deterministic and cheap, the future can be recomputed instantly whenever anything changes.

Scrubbable code

The right panel shows the character's physics as code. The blue values (run speed, gravity, jump velocity, star bounce, lookahead) can be dragged left or right with the mouse, or adjusted with arrow keys when focused. Changing a value recomputes the orange future trail immediately, so you can find exactly the value you need, for example the jump strength that reaches the star.

Alternate futures

The "Show alternate futures" checkbox runs the simulation four more times with weaker and stronger jump values and draws them as pale blue trails. This shows a fan of possible outcomes at once. To choose one of these paths, drag the blue jump velocity value in the code until the orange trail matches the blue trail you want, then press Play. The game resumes from the paused frame with your new settings, and any history after that frame is discarded.

Change log

Every committed value change is recorded below the game with the frame number, the old value, and the new value. Reset restores the default settings and is logged too.

Files

  • time-travel-debugger.html - the whole app
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inventing on Principle — Time Travel Debugger</title>
<style>
:root{
--sky-top:#7fb2e5; --sky-bot:#cfe6f7;
--paper:#fbfaf6; --ink:#2a2a30; --ink-dim:#8a8a92;
--kw:#a33ea1; --num:#1a53d6; --comment:#3f8f4f;
--future:#ff8c1a; --past:#9aa4ad; --accent:#1a53d6;
--panel:#1d232b; --panel-ink:#dfe6ee;
font-size:15px;
}
*{box-sizing:border-box; margin:0; padding:0;}
body{
background:#12161c; color:var(--panel-ink);
font-family:"Avenir Next","Segoe UI",system-ui,sans-serif;
min-height:100vh; display:flex; flex-direction:column; align-items:center;
padding:24px 16px 48px;
}
header{max-width:1060px; width:100%; margin-bottom:14px;}
header h1{font-size:1.35rem; font-weight:600; letter-spacing:.01em;}
header p{color:#9fb0c2; font-size:.9rem; margin-top:4px; max-width:70ch;}
header .credit{color:#6d7c8c;}
.stage{
display:grid; grid-template-columns: minmax(340px,560px) minmax(320px,440px);
gap:0; max-width:1060px; width:100%;
border-radius:10px; overflow:hidden;
box-shadow:0 20px 60px rgba(0,0,0,.5);
}
.game-side{background:linear-gradient(var(--sky-top),var(--sky-bot)); position:relative;}
canvas{display:block; width:100%; height:auto;}
.code-side{
background:var(--paper); color:var(--ink);
font-family:"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
font-size:.82rem; line-height:1.75; padding:22px 20px 18px 24px;
border-left:1px solid #d8d5cc;
user-select:none;
}
.code-side .cm{color:var(--comment);}
.code-side .kw{color:var(--kw);}
.code-side .plain{color:var(--ink);}
.num{
color:var(--num); background:#e4ecff; border-radius:3px;
padding:0 3px; cursor:ew-resize; font-weight:600;
border-bottom:2px solid #b9c9f5;
}
.num.active{background:#1a53d6; color:#fff;}
.hint{color:var(--ink-dim); font-size:.72rem; font-family:inherit; margin-top:14px;}
/* transport bar */
.transport{
grid-column:1 / -1; background:var(--panel); padding:14px 18px;
display:flex; align-items:center; gap:14px; flex-wrap:wrap;
}
.transport button{
background:#2e3946; color:#eaf1f8; border:none; border-radius:6px;
padding:8px 16px; font-size:.9rem; cursor:pointer; font-weight:600;
min-width:88px;
}
.transport button:hover{background:#3a4756;}
.transport button:focus-visible{outline:2px solid var(--future); outline-offset:2px;}
.transport input[type=range]{
flex:1; min-width:180px; accent-color:var(--future); height:4px;
}
.transport label{display:flex; align-items:center; gap:7px; font-size:.82rem; color:#b8c5d3; cursor:pointer;}
.transport .frame-readout{font-family:Menlo,Consolas,monospace; font-size:.78rem; color:#8fa0b1; min-width:110px; text-align:right;}
/* change log */
.log{
max-width:1060px; width:100%; margin-top:16px;
background:#181e26; border:1px solid #26303b; border-radius:10px; padding:14px 18px;
}
.log h2{font-size:.85rem; text-transform:uppercase; letter-spacing:.1em; color:#8fa0b1; margin-bottom:8px;}
.log ul{list-style:none; max-height:170px; overflow-y:auto;}
.log li{
font-family:Menlo,Consolas,monospace; font-size:.78rem; color:#c9d5e0;
padding:4px 0; border-bottom:1px dashed #242e39; display:flex; gap:12px;
}
.log li .t{color:#68788a; min-width:82px;}
.log li .delta{color:var(--future);}
.log li.reset .delta{color:#7fd18a;}
.log .empty{color:#5c6b7b; font-style:italic; font-size:.8rem;}
@media (max-width:820px){
.stage{grid-template-columns:1fr;}
.code-side{border-left:none; border-top:1px solid #d8d5cc;}
}
@media (prefers-reduced-motion: reduce){ *{scroll-behavior:auto;} }
</style>
</head>
<body>
<header>
<h1>Time travel debugger &mdash; trails, futures, and scrubbable code</h1>
<p>Press <strong>Pause</strong> to freeze the world. Drag the timeline to move through the past, then <strong>drag the blue numbers in the code</strong> left or right: the orange trail is the character's future, recomputed live. <span class="credit">Homage to Bret Victor's "Inventing on Principle."</span></p>
</header>
<div class="stage">
<div class="game-side">
<canvas id="game" width="560" height="470" aria-label="Platformer game with a character, platforms and a star"></canvas>
</div>
<div class="code-side" id="code">
<div><span class="cm">// GamePlayer &mdash; drag blue values to rewrite physics</span></div>
<div>&nbsp;</div>
<div><span class="kw">this</span><span class="plain">.runSpeed&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= </span><span class="num" data-p="runSpeed" data-min="40" data-max="320" data-step="1" tabindex="0" role="slider" aria-label="run speed"></span><span class="plain">;</span></div>
<div><span class="kw">this</span><span class="plain">.gravity&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;= </span><span class="num" data-p="gravity" data-min="150" data-max="1400" data-step="5" tabindex="0" role="slider" aria-label="gravity"></span><span class="plain">;</span></div>
<div><span class="kw">this</span><span class="plain">.jumpVelocity = -</span><span class="num" data-p="jumpVelocity" data-min="120" data-max="640" data-step="2" tabindex="0" role="slider" aria-label="jump velocity"></span><span class="plain">;</span></div>
<div>&nbsp;</div>
<div><span class="kw">if</span><span class="plain"> (hitInfo.hitStar) {</span></div>
<div><span class="plain">&nbsp;&nbsp;&nbsp;&nbsp;</span><span class="kw">this</span><span class="plain">.yVelocity = -</span><span class="num" data-p="starBounce" data-min="100" data-max="700" data-step="2" tabindex="0" role="slider" aria-label="star bounce velocity"></span><span class="plain">;</span></div>
<div><span class="plain">&nbsp;&nbsp;&nbsp;&nbsp;hitInfo.hitStar.stomp();</span></div>
<div><span class="plain">}</span></div>
<div>&nbsp;</div>
<div><span class="cm">// future lookahead (frames)</span></div>
<div><span class="kw">var</span><span class="plain"> lookahead = </span><span class="num" data-p="lookahead" data-min="60" data-max="600" data-step="10" tabindex="0" role="slider" aria-label="future lookahead frames"></span><span class="plain">;</span></div>
<div class="hint">Drag a value horizontally, or focus it and use arrow keys. While paused, the future updates as you scrub.</div>
</div>
<div class="transport">
<button id="playPause">Pause</button>
<input type="range" id="timeline" min="0" max="0" value="0" step="1" aria-label="timeline scrubber" disabled>
<span class="frame-readout" id="frameReadout">frame 0</span>
<label><input type="checkbox" id="altFutures"> Show alternate futures</label>
<button id="resetBtn">Reset</button>
</div>
</div>
<div class="log">
<h2>Change log</h2>
<ul id="logList"><li class="empty">No changes yet. Pause the game and drag a value in the code.</li></ul>
</div>
<script>
(function(){
"use strict";
var canvas = document.getElementById("game");
var ctx = canvas.getContext("2d");
var DT = 1/60;
var W = canvas.width, H = canvas.height;
// ---------- parameters (the "code") ----------
var DEFAULTS = { runSpeed:150, gravity:620, jumpVelocity:330, starBounce:420, lookahead:300 };
var params = Object.assign({}, DEFAULTS);
// ---------- world ----------
var platforms = [
{x:0, y:400, w:250, h:70}, // left ground
{x:330, y:400, w:230, h:70}, // right ground (gap = water)
{x:355, y:255, w:130, h:22}, // star platform
{x:120, y:315, w:90, h:18} // mid step
];
var star = {x:405, y:222, r:14};
var jumpTriggers = [70, 195, 300, 470]; // x positions where a grounded runner jumps
var SPAWN = {x:20, y:376, vy:0, dir:1, onGround:true, frame:0};
var PW = 18, PH = 24;
function cloneState(s){ return {x:s.x, y:s.y, vy:s.vy, dir:s.dir, onGround:s.onGround, frame:s.frame}; }
// ---------- deterministic simulation step ----------
function step(s, p){
var n = cloneState(s);
var prevX = n.x;
// horizontal
n.x += p.runSpeed * n.dir * DT;
if (n.x < 0){ n.x = 0; n.dir = 1; }
if (n.x + PW > W){ n.x = W - PW; n.dir = -1; }
// wall collisions against platform sides
for (var i=0;i<platforms.length;i++){
var q = platforms[i];
if (n.y + PH > q.y + 4 && n.y < q.y + q.h){
if (n.dir > 0 && prevX + PW <= q.x && n.x + PW > q.x){ n.x = q.x - PW; n.dir = -1; }
else if (n.dir < 0 && prevX >= q.x + q.w && n.x < q.x + q.w){ n.x = q.x + q.w; n.dir = 1; }
}
}
// jump triggers
if (n.onGround){
for (var j=0;j<jumpTriggers.length;j++){
var tx = jumpTriggers[j];
var crossed = (n.dir > 0 && prevX < tx && n.x >= tx) || (n.dir < 0 && prevX > tx && n.x <= tx);
if (crossed){ n.vy = -p.jumpVelocity; n.onGround = false; break; }
}
}
// vertical
n.vy += p.gravity * DT;
var prevY = n.y;
n.y += n.vy * DT;
n.onGround = false;
if (n.vy >= 0){
for (var k=0;k<platforms.length;k++){
var q2 = platforms[k];
if (n.x + PW > q2.x + 2 && n.x < q2.x + q2.w - 2 &&
prevY + PH <= q2.y + 1 && n.y + PH >= q2.y){
n.y = q2.y - PH; n.vy = 0; n.onGround = true; break;
}
}
}
// star bounce
var cx = n.x + PW/2, cy = n.y + PH/2;
var dx = cx - star.x, dy = cy - star.y;
if (dx*dx + dy*dy < (star.r + 14)*(star.r + 14)){
n.vy = -p.starBounce; n.onGround = false;
}
// fell into the water gap
if (n.y > H + 60){ var r = cloneState(SPAWN); r.frame = n.frame + 1; return r; }
n.frame = s.frame + 1;
return n;
}
function simulateFuture(from, p, frames){
var out = [], s = cloneState(from);
for (var i=0;i<frames;i++){ s = step(s, p); out.push(s); }
return out;
}
// ---------- history / playback ----------
var MAX_HISTORY = 60 * 60; // 60 seconds
var history = [cloneState(SPAWN)];
var cursor = 0; // index into history when paused
var playing = true;
var future = [];
var altFutureSets = [];
var timeline = document.getElementById("timeline");
var frameReadout = document.getElementById("frameReadout");
var playBtn = document.getElementById("playPause");
var altToggle = document.getElementById("altFutures");
var logList = document.getElementById("logList");
var logEmpty = true;
function currentState(){ return history[playing ? history.length - 1 : cursor]; }
function recomputeFuture(){
var from = currentState();
future = simulateFuture(from, params, Math.round(params.lookahead));
altFutureSets = [];
if (altToggle.checked){
var factors = [0.55, 0.775, 1.225, 1.45];
for (var i=0;i<factors.length;i++){
var p2 = Object.assign({}, params);
p2.jumpVelocity = params.jumpVelocity * factors[i];
p2.starBounce = params.starBounce * factors[i];
altFutureSets.push(simulateFuture(from, p2, Math.round(params.lookahead)));
}
}
}
function syncTimeline(){
timeline.max = history.length - 1;
timeline.value = playing ? history.length - 1 : cursor;
timeline.disabled = playing;
frameReadout.textContent = "frame " + currentState().frame +
(playing ? "" : " / paused");
}
// ---------- logging ----------
function fmt(p, v){
v = Math.round(v);
if (p === "jumpVelocity" || p === "starBounce") return "-" + v;
return String(v);
}
function addLog(param, oldV, newV, cls){
if (logEmpty){ logList.innerHTML = ""; logEmpty = false; }
var li = document.createElement("li");
if (cls) li.className = cls;
var t = document.createElement("span"); t.className = "t";
t.textContent = "frame " + currentState().frame;
var body = document.createElement("span");
body.innerHTML = param + ": " + fmt(param, oldV) +
' <span class="delta">&rarr; ' + fmt(param, newV) + "</span>";
li.appendChild(t); li.appendChild(body);
logList.insertBefore(li, logList.firstChild);
}
// ---------- scrubbable numbers ----------
var numEls = Array.prototype.slice.call(document.querySelectorAll(".num"));
function refreshNums(){
numEls.forEach(function(el){
var p = el.dataset.p;
el.textContent = Math.round(params[p]);
el.setAttribute("aria-valuenow", Math.round(params[p]));
el.setAttribute("aria-valuemin", el.dataset.min);
el.setAttribute("aria-valuemax", el.dataset.max);
});
}
function commitChange(el, startVal){
var p = el.dataset.p;
if (Math.round(startVal) !== Math.round(params[p])){
addLog("this." + p, startVal, params[p]);
}
}
numEls.forEach(function(el){
var p = el.dataset.p;
var min = +el.dataset.min, max = +el.dataset.max, stepV = +el.dataset.step;
var dragging = false, startX = 0, startVal = 0;
el.addEventListener("pointerdown", function(e){
dragging = true; startX = e.clientX; startVal = params[p];
el.classList.add("active");
el.setPointerCapture(e.pointerId);
e.preventDefault();
});
el.addEventListener("pointermove", function(e){
if (!dragging) return;
var delta = (e.clientX - startX) * ((max - min) / 260);
var v = Math.min(max, Math.max(min, startVal + delta));
v = Math.round(v / stepV) * stepV;
if (v !== params[p]){
params[p] = v; refreshNums(); recomputeFuture();
}
});
el.addEventListener("pointerup", function(e){
if (!dragging) return;
dragging = false; el.classList.remove("active");
commitChange(el, startVal);
});
el.addEventListener("keydown", function(e){
var dir = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 :
e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
if (!dir) return;
e.preventDefault();
var oldV = params[p];
var v = Math.min(max, Math.max(min, params[p] + dir * stepV * (e.shiftKey ? 5 : 1)));
if (v !== oldV){
params[p] = v; refreshNums(); recomputeFuture();
addLog("this." + p, oldV, v);
}
});
});
// ---------- transport ----------
playBtn.addEventListener("click", function(){
playing = !playing;
if (!playing){
cursor = history.length - 1;
recomputeFuture();
playBtn.textContent = "Play";
} else {
// resume from the cursor: truncate any "unlived" history after it
history = history.slice(0, cursor + 1);
playBtn.textContent = "Pause";
}
syncTimeline();
});
timeline.addEventListener("input", function(){
cursor = +timeline.value;
recomputeFuture();
syncTimeline();
});
altToggle.addEventListener("change", function(){ recomputeFuture(); });
document.getElementById("resetBtn").addEventListener("click", function(){
var changed = Object.keys(DEFAULTS).some(function(k){ return params[k] !== DEFAULTS[k]; });
if (changed) addLog("params", NaN, NaN, "reset");
if (changed){
logList.firstChild.lastChild.innerHTML = 'restored defaults <span class="delta">&#8630;</span>';
}
params = Object.assign({}, DEFAULTS);
history = [cloneState(SPAWN)];
cursor = 0; playing = true;
playBtn.textContent = "Pause";
refreshNums(); syncTimeline();
});
// ---------- drawing ----------
function drawPlayerShape(x, y, dir, color, alpha, outline){
ctx.save();
ctx.globalAlpha = alpha;
// body
ctx.fillStyle = color;
ctx.fillRect(x, y+6, PW, PH-6);
// head
ctx.fillStyle = outline ? color : "#f2c89b";
ctx.fillRect(x+2, y, PW-4, 9);
// cap
ctx.fillStyle = color;
ctx.fillRect(x, y-2, PW, 5);
// eye
if (!outline){
ctx.fillStyle = "#222";
ctx.fillRect(dir > 0 ? x+PW-6 : x+3, y+3, 3, 3);
}
ctx.restore();
}
function drawStar(cx, cy, r, glow){
ctx.save();
if (glow){
ctx.fillStyle = "rgba(255,220,80,0.25)";
ctx.beginPath(); ctx.arc(cx, cy, r*2.1, 0, Math.PI*2); ctx.fill();
}
ctx.fillStyle = "#ffd23e";
ctx.strokeStyle = "#d99a00"; ctx.lineWidth = 2;
ctx.beginPath();
for (var i=0;i<10;i++){
var ang = -Math.PI/2 + i * Math.PI/5;
var rad = i % 2 === 0 ? r : r*0.45;
var px = cx + Math.cos(ang)*rad, py = cy + Math.sin(ang)*rad;
if (i===0) ctx.moveTo(px,py); else ctx.lineTo(px,py);
}
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawBricks(q){
ctx.fillStyle = "#c98a4b";
ctx.fillRect(q.x, q.y, q.w, q.h);
// grass lip on thick ground
ctx.fillStyle = "#63b34e";
ctx.fillRect(q.x, q.y, q.w, 6);
ctx.strokeStyle = "rgba(120,70,25,0.55)"; ctx.lineWidth = 1;
for (var yy = q.y + 6; yy < q.y + q.h; yy += 14){
ctx.beginPath(); ctx.moveTo(q.x, yy); ctx.lineTo(q.x + q.w, yy); ctx.stroke();
var off = (Math.floor((yy - q.y) / 14) % 2) * 14;
for (var xx = q.x + off; xx < q.x + q.w; xx += 28){
ctx.beginPath(); ctx.moveTo(xx, yy); ctx.lineTo(xx, Math.min(yy+14, q.y+q.h)); ctx.stroke();
}
}
}
function drawTrail(list, color, stride, headAlpha){
for (var i=0;i<list.length;i+=stride){
var s = list[i];
var t = i / list.length;
drawPlayerShape(s.x, s.y, s.dir, color, headAlpha * (0.12 + 0.5 * (1 - Math.abs(0.5 - t))), true);
}
}
function render(){
// sky
var g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,"#7fb2e5"); g.addColorStop(1,"#d6ecfb");
ctx.fillStyle = g; ctx.fillRect(0,0,W,H);
// clouds
ctx.fillStyle = "rgba(255,255,255,0.85)";
[[110,90,34],[160,80,26],[400,140,30],[445,132,22],[270,60,20]].forEach(function(c){
ctx.beginPath(); ctx.arc(c[0],c[1],c[2],0,Math.PI*2); ctx.fill();
});
// water in the gap
ctx.fillStyle = "#3f7fca";
ctx.fillRect(250, 435, 80, H-435);
ctx.fillStyle = "rgba(255,255,255,0.35)";
ctx.fillRect(250, 435, 80, 4);
platforms.forEach(drawBricks);
drawStar(star.x, star.y, star.r, true);
var now = currentState();
if (!playing){
// past trail (gray)
var past = history.slice(Math.max(0, cursor - 260), cursor);
drawTrail(past, "#5f6b76", 7, 0.9);
// alternate futures (pale blue fan)
altFutureSets.forEach(function(f){
drawTrail(f, "#7f9fe0", 8, 0.55);
});
// primary future (orange)
drawTrail(future, "#ff8c1a", 6, 1.0);
// paused banner
ctx.fillStyle = "rgba(0,0,0,0.55)";
ctx.fillRect(0, 0, W, 26);
ctx.fillStyle = "#ffd9ad";
ctx.font = "600 12px Menlo, Consolas, monospace";
ctx.fillText("PAUSED \u2014 gray = past orange = future" +
(altFutureSets.length ? " blue = alternate jump strengths" : ""), 10, 17);
}
// present character
drawPlayerShape(now.x, now.y, now.dir, "#d63b2f", 1, false);
}
// ---------- main loop ----------
function tick(){
if (playing){
var next = step(history[history.length - 1], params);
history.push(next);
if (history.length > MAX_HISTORY) history.shift();
syncTimeline();
}
render();
requestAnimationFrame(tick);
}
refreshNums();
syncTimeline();
recomputeFuture();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment