There is an indie game called Braid, a very popular one. The core feature of the game was time rewind mechanic - you could've just stopped time and ran it backwards. I, software developer, was very curious about how to make such a game and tried it myself. Here's what I learned.
A little bit of information for those who missed the game. Braid is an indie-game from Jonathan Blow made in 2008. It became a bestseller, sold more than 55 thousand times during first week after release.
All the GIFs are interactive examples on my webpage, the link will be shown in the end of the article
Start from scratch
Let's simplify - imagine that we make a game about moving a dot in one dimensional space. Using W and S we move the dot along vertical axis and right to it we show a timeline with dot position from time. Also we display all key pressed on the timeline.
Usually moving an object in a game is made with special timer, every tick of which changes object position so slightly, just a few pixels. It happens about 30 times a second and creates an illusion of smooth transition. That's an imperative approach.
const STEP_PER_TICK = 2; // 2 pixels per 1/30 of a second
const dotPosition = DEFAULT_POSITION;
movement.on('tick', () => {
if (keys[UP]) {
dotPosition += STEP_PER_TICK:
}
if (keys[DOWN]) {
dotPosition -= STEP_PER_TICK:
}
});
render.on('tick', () => {
drawCircle(dotPosition, 'black');
});
But sometimes applications utilize another approach - a declarative one. Instead of changing position and updating a variable, we can simply describe how ball position depends on the time - dotPosition(t), where t is current time.
But how? Well, we can use events timeline. For example, if the last event is W keypress, we can simply take dot position at event time and add speed * time difference.
const getDotPosition = (t) => {
const { position, type, time } = getLastEvent(events, t);
const speed = {
RELEASE: 0,
UP: -1,
DOWN: 1,
}[type];
return position + speed * (t - time);
};
render.on('tick', () => {
drawCircle(getDotPosition(now()));
});
Now we don't need to store dot position at all - every time we need to render the dot, we can calculate dot position from current time. Note that now dot position can have decimals since time difference between renders may not be stable and does not equal to 1/30 of a second. Moreover, pressing W and S at the same time does not cancel movement.
Reverse time
Now we can add another term - inner time. The thing is that instead of current time (that we get by calling now()
) we can pass in something else. For example, if we just pass now() / 2
we can slow the time down.
render.on('tick', () => {
const time = now() / 2; // time is twice slower
drawCircle(getDotPosition(time));
});
But that's not exactly what we wanted. We want to control the time§ with keyboard. Not only slow down or speed up, but to rewind. We want the inner time to depend on outer time somewhat like this:
Looks familiar, right? Yes, that exactly the timeline we saw for dot position! Inner time depends outer time the same way, that dot position depends on inner time Just instead of W and S we use Space to control that timeline.
const timeEvents = []; // space press events
const gameEvents = []; // W and S press events
const getInnerTime = (t) => {
const { value, backward, time } = getLastEvent(timeEvents, t);
const change = backward ? -0.8 : 1;
return value + change * (t - time)
};
const getDotPosition = (t) => {
const { position, type, time } = getLastEvent(gameEvents, t);
if (type === RELEASE) {
return position;
}
const change = speed * (t - time);
return position + change * (type === UP ? 1 : -1);
};
render.on('tick', () => {
const innerTime = getInnerTime(now());
const dotPosition = getDotPosition(innerTime);
drawCircle(dotPosition);
});
What we end up having is waterfall of functions. First we take outer time (system time) and calculate inner time (the one you can see in the top of timeline). Only then we take that inner time and calculate dot position. Both times we use events log (W, S and Space presses) to figure out how to calculate value. Isn't that awesome?
Second dimension
Let's go further and add second dimension. Now the dot will move not only along vertical axis, but along horizontal too. Also we'll update the timeline to display position in 2d and use depth for time.
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
const mul = (a, b) => ({ x: a.x * b, y: a.y * b });
const getDotPosition = (t) => {
const { position, directions, time } = getLastEvent(gameEvents, t);
// now position is Point { x, y }
const change = speed * (t - time);
const direction = sum(...directions.map((dir) => ({
return {
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
right: { x: 1, y: 0 },
}[dir];
}));
return add(position, mul(direction, change));
};
render.on('tick', () => {
const innerTime = getInnerTime(now());
const dotPosition = getDotPosition(innerTime);
drawCircle(dotPosition);
});
Add acceleration
Important thing that we still miss is acceleration. A lot of the times platformer's objects actually don't move in a linear fashion, but rather with acceleration. When they fall, for example. Imperatively this would've been done somewhat like that:
let gravity = 10;
let speed = 0;
let position = 100;
// ...
movement.on('tick', () => {
speed += gravity;
position += speed;
});
render.on('tick', () => {
drawObject(position);
});
But we don't have variables for position or speed - we have to define functions to figure out dot position of time. To mind acceleration we should recall school math, specifically uniform acceleration.
const getBallPosition = (t) => {
const event = getLastEvent(gameEvents, t);
if (event.type === 'fall') {
return {
// x doesnt change
x: event.position.x,
// formula from wikipedia
y: event.position.y
+ event.velocity * (t - event.time)
+ .5 * GRAVITY * ((t - event.time) ** 2)
};
}
};
As you can see event how should not only store position, but velocity too. So we need to calculate that for a given time as well. In the end I got something like this:
class GameDot extends Timeline {
getDirections = (innerTime: number) => {
const event = this.get(innerTime); // last event for given time
return event.data.directions;
};
getAcceleration = (innerTime: number) => {
return sum(
{ x: 0, y: 0 },
...this.getDirections(innerTime).map((dir) => ({
up: { x: 0, y: -ACC },
down: { x: 0, y: ACC },
left: { x: -ACC, y: 0 },
right: { x: ACC, y: 0 },
}[dir] || { x: 0, y: 0 })),
);
};
getVelocity = (innerTime: number) => {
const event = this.get(innerTime);
const acceleration = this.getAcceleration(innerTime);
return add(
event.data.velocity,
mul(acceleration, (innerTime - event.time))
);
};
getPosition = (innerTime: number) => {
const event = this.get(innerTime);
const acceleration = this.getAcceleration(innerTime);
return sum(
event.data.position,
mul(event.data.velocity, innerTime - event.time),
mul(acceleration, .5 * ((innerTime - event.time) ** 2))
);
};
};
Finally combine together
Now we have to combine all of that together! Left and right movement will be linear, jump and fall will be uniformely accelerated. Also I will add a special event of touching the ground (platform).
I won't add all of the code here, because I didn't have a goal to make it into real game, but rather to replicate game mechanic. Here's what it looks like in the end:
That's it! Of course tons of stuff is not covered - mobs, death or stairs. But I wanted to talk about rewind specifically. Please let me know if you like this article as I am only considering gamedev articles (that's my first one). Thank you!
Top comments (0)