echo

During the 2025 GMTK game jam I made a small prototype in Godot to learn more about the engine. The theme of the jam was "loop", and I got stuck thinking about time loops and how you could use recorded player state to solve puzzles with your past, like a temporal single player co-op game.

The game is playable in the browser or on Windows, available at https://grousejst.itch.io/echo.

The jam took place between 30th of July and 3rd of August 2025, during which I wrote Echo over the course of about 2 days, using mostly primitives and a handful of assets from Kenney's Asset packs, https://kenney.nl/assets. It's a really small prototype, but I had fun making it, and it was honest work!

Levels

The prototype contains 6 small levels, with ideas for many more filling my notebooks. Below I outline the first 3 levels that introduce the rules of the looping, culminating in a hopefully satisfying realisation as the player realises how the ghost interacts with the world.

The past will set you free

The first level introduces the player to the concept of looping and solving the puzzle using the ghost of the previous iteration. As such, the level is a simple locked door that is opened with a pressure switch. Step off the switch, the door closes again. The only solution is to to stand still on the switch for a while, loop the iteration, and wait for the ghost to stand on the switch and let you through.

Planning ahead

The second level is a small step forward in the same vein as level 1, the only difference is introducing a measure of timing and having to plan ahead by standing still on the first switch long enough to let future you walk through, and then moving over to the 2nd switch.

Ghost continuum

In level 3, I am extending the concepts from the previous levels to introduce the idea of ghosts breaking continuity, from the perspective of the present. In short, that ghost does not interact with physics, and can walk through walls or doors, if they were not there during the ghost's version of the world.

In practice, this is merely a side effect of recording the state of the player and position and replaying it. If I were to instead record input and rely on deterministic simulations, achieving the same result would require a more complex solution.

Record & replay state

Recording the player state simply means pushing the player position and rotation onto an ever-growing buffer, rate limited to 30Hz. For the jam, I didn't have time to think too much about how long I wanted to the recorded state window to be, and what that would mean for the design of the game.

I chose 30Hz recording frequency primarily to have a fixed framerate in the recording that would be consistent, but as a high enough framerate I didn't need practically consider things like interpolating through corners, as such errors would be too small to make a difference for the purpose of the jam.

Memory is not really a concern when recording so little state; the naive approach shown below and used in the jam results in about 720 bytes per second for storing the state, or 43.2 KiB per minute. With modern hardware and such a small game, recording just a single entity's state, you'd be able to record for literally days (59 MiB per day) before this started making a dent in your RAM usage.

func record_state(position : Vector3, rotation : Vector3) -> void: if record_start_time < 0: return if recorded_state.size() > 0 && (time-recorded_state[recorded_state.size()-1].end_time_msec) < record_rate_ms: return if recorded_state.size() > 0: recorded_state.back().end_time_msec = time var state : RecordedState = RecordedState.new() state.position = position state.rotation = rotation recorded_state.append(state)

Replaying the previously recorded state linearly interpolates between the most current state and the next to achieve smooth playback, no matter the frequency of the recorded state.

func replay_state(): var delta_msec = Time.get_ticks_usec() - replay_start_time while replay_i < replay_state.size() && delta_msec >= replay_state[replay_i].end_time_msec: replay_i += 1 if replay_i < replay_state.size(): var pos = replay_state[replay_i].position var rot = replay_state[replay_i].rotation var pos_n = pos var rot_n = rot var alpha = 0 if replay_i+1 < replay_state.size(): pos_n = replay_state[replay_i + 1].position rot_n = replay_state[replay_i + 1].rotation var quotient = replay_state[replay_i + 1].end_time_msec - replay_state[replay_i].end_time_msec if quotient > 0: alpha = (delta_msec - replay_state[replay_i].end_time_msec) / float(quotient) pos = pos.lerp(pos_n, alpha) rot = rot.slerp(rot_n, alpha) position = pos rotation = rot

The End

Echo was the first somewhat real thing I'd done in Godot, and it was the first game jam I'd really attempted, although in the end I missed the submission deadline by a few minutes and so it was never officially submitted. Jokes on them, because I got everything I wanted out of the jam and then some.

I had a lot of fun making it, and it was fun learning Godot. The resulting prototype is very small and barely scratches the surface of being a game, but it is, technically, a game. I have enough left-over ideas and areas I want to explore that I'm pretty sure I'll return to it at some point and see where the design space takes me. Who knows, maybe one day it'll become a real game.