How does Braid work?

Many people played the famous indie game Braid and many were impressed by the mechanics of going back in time. For me, as a programmer, this was especially interesting, I decided to try to repeat this mechanic and here is what I learned.


First, a little background for those who missed this wonderful game. Braid is an indie project by American Jonathan Blow, released in 2008 and became a hit, purchased more than 55 thousand times during the first week after release. The main feature of the game is the Rewind mechanic, which allows you to turn back time and “rewind the game” back, and then try to complete the level again.

Game trailer – https://www.youtube.com/watch?v=uqtSKkyJgFM

All examples (GIF) are interactive in the original version of the article (link will be at the end)

Let's start with something simple

First, let's try to simplify the task for ourselves – let's imagine that our entire game comes down to controlling a point in one-dimensional space. Using W and S, we will move the point along a vertical line, and to the right of it we will depict a graph of its position versus time. The icons on the graph will mark the moments in time when we pressed a button or canceled the action of an already pressed button.

Usually, moving an object in simple games is done with a special timer, each tick of which slightly changes the position of the object by a few pixels. If this happens 30 times per second, then it seems to us that the object is smoothly moving in the right direction. This is an imperative approach.

const STEP_PER_TICK = 2; // 2 пикселя в 1/30 секунды
const ballPosition = DEFAULT_POSITION;

movement.on('tick', () => {
    if (keys[UP]) {
        ballPosition += STEP_PER_TICK:
    }

    if (keys[DOWN]) {
        ballPosition -= STEP_PER_TICK:
    }
});

render.on('tick', () => {
    drawCircle(ballPosition, 'black');
});

But sometimes applications use a different approach – declarative. Instead of defining how exactly the ball's position will change every 1/30 of a second, we can describe how the ball's position will depend on the current time – create a function ball_position
const { position, type, time } = getLastEvent(events, t);

if (type === RELEASE) {
return position;
}

const change = speed * (t – time);

return position + change * (type === UP ? 1 : -1);
};

render.on(‘tick’, () => {
drawCircle(getBallPosition(now()));
});

Now we don't need to store the ball position at all – it will be recalculated every time a frame is rendered. Note that the ball position now sometimes has a fractional part – we can't be sure that the rendering will happen at 1/30 of a second. Also, pressing both buttons (W and S) now sets the ball in motion – unlike the previous example, where the buttons canceled each other out.

Let's turn back time

Now we can add another concept – internal time. The thing is that we can't change what the now() function returns to us, but we don't necessarily have to pass its result to the function that finds the ball's position. If we replace `now()` with `now() / 2`, then time in the game will go twice as slow.

But that's not all – we want to control time with the keyboard. And not just slow it down or speed it up, but also reverse it. We want to make the internal time depend on the external time and on the keyboard presses in approximately the following way:

Sounds familiar, doesn't it? Oh yeah, it's almost exactly the same graph as the one we plot for the ball's position. Internal time depends on external time in the same way that the position of the ball depends on internal time. It's just that instead of the W and S buttons, the game responds to pressing the space bar.

const timeEvents = []; // сюда мы складываем события нажатий на SPACE
const gameEvents = []; // сюда мы складываем события нажатий на W и S

const getInnerTime = 
    const { value, backward, time } = getLastEvent(timeEvents, t);
    const change = backward ? -0.8 : 1;
                    
    return value + change * (t - time)
};

const getBallPosition = 
    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 ballPosition = getBallPosition(innerTime);

    drawCircle(ballPosition);
});
          

This results in a cascade of functions – first, from the external time (system time), we find the internal time of the game (what is indicated at the top of the timeline), and only then, based on the internal time of the game, we find the position of the ball. At both the first and second stages, we use the event log (presses on W, S, or Space) to understand how exactly the time or position of the ball has changed since the last event.

Let's add a second dimension

Let's go a little further and complicate the task – add a second dimension. Now our ball will move not only up or down, but also left / right. At the same time, we will update the timeline, and use depth to display the time of the event.

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 getBallPosition = 
    const { position, directions, time } = getLastEvent(gameEvents, t);

    // теперь position это 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 ballPosition = getBallPosition(innerTime);

    drawCircle(ballPosition);
});

Let's speed up

An important element that our game lacks is acceleration. The thing is that in platformer games, characters and objects do not move uniformly. Almost always, they move with some acceleration, for example, when they fall. Usually (in the imperative approach), this is done something like this:

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 the current speed or even the current position – we just have a function that returns the position of the ball for a certain time. To add acceleration to this function, we need to remember some school math, namely uniformly accelerated motion.

const getBallPosition = 
    const event = getLastEvent(gameEvents, t);

    // предположим какое-то событие начинает падение

    if (event.type === 'fall') { 
        return {
            // x не меняется
            x: event.position.x, 
            // та самая формула из википедии
            y: event.position.y
                + event.velocity * (t - event.time)
                + .5 * GRAVITY * ((t - event.time) ** 2)
        };
    }
};

As you can see, in addition to position, event now also needs to store velocity – which means we need a function that will calculate the velocity for a given in-game time. In the end, I got something like this:

class Gameline extends RawTimeline {
    getDirections = (innerTime: number) => {
        const event = this.get(innerTime);

        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))
        );
    };
};

Let's build a platformer

The only thing left to do is to assemble a platformer from all this! The movement to the left and right will be uniform, and the fall will be uniformly accelerated. At the same time, we will add a platform and a special event indicating the character touching the platform.

I didn't have a goal to turn this into a full-fledged game, so I only made a demo minimum. I'll show you the result right away:

That's all. Of course, there are some very complex moments beyond the article – monsters, character death, climbing ladders, but I wanted to talk specifically about the rewind mechanics. If you liked this article – be sure to subscribe and comment.

Interactive original article on the author's blog

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *