#06: Undo


This post was originally written in December 2018. At the time the game was still called Hook, Line and Thinker

There is a single dedicated button in Hook which required a massive code refactoring and ate several months all by itself: undo.

Design evolution

In the original 6-day jam version of the game there was no undo at all. In response to a comment asking about it, I wrote:

No, you have to reel it all the way back in. This was done intentionally to discourage players from brute-forcing the solutions and instead force more thoughtful, slower, confident movement. It is a little punishing at first though, or when you inevitably hit the wrong key by mistake.

5 months after I started the Defold version (7 months after the jam) I added a limited undo which allowed players to correct basic movement mistakes. Each move you made (including hooking a creature) could be individually undone, but once you reeled something in you were committed and the only way to undo that would be to restart the level.

Another 7 months later and I knew it was time for the full, complete undo. All the difficulty should be in understanding how the systems work together and working out how to exploit those to do what you want to do. Once you have a plan, executing it should be easy. The goal was that you could play any level all the way to the end and then as you’re reeling in the final creature hold down the undo button and watch the entire thing play in reverse right back to the very beginning. Undo is free and unlimited in every level.

Gameplay code

As I write this Hook currently consists of around 5500 lines of Lua spread across 31 .script files and 1 .render_script. Two files are primarily responsible: hook.script has 1121 lines, with gameplay_events.script following at 767. These two files are where the game is. (They are closely followed by case.script at 759 lines, which handles everything the previous devlog was about).

Since my game is completely deterministic there were just two simple requirements for full undo: an input counter and an event log. There is (almost) a one-to-one relationship between the number of button presses it takes to do something and the number of undo button presses it takes to undo all the effects of those button presses. This is where the input counter comes in. Whenever a viable move or reel button is pressed we increment the input counter and trigger the relevant gameplay events. Whenever undo is pressed we undo all the events that occurred as a result of the most recent input number and then decrement the input counter. This is how pressing one button like ‘reel in’ can result in dozens and dozens of gameplay events over many frames (e.g. reeling the hook in, pulling a creature along, cutting seaweed, etc) but then one press of ‘undo’ backtracks the whole sequence.

There are currently 18 types of event and each has an add(), an execute() and an undo() function; these and the top level undo() function are what makes up the gameplay_events.script file. The hook.script file is responsible for calling the relevant add() functions or the top level undo() at the right times. Each add() function adds a new table element to the event log which stores all the data necessary both to execute() and undo() the event, then immediately calls execute(). Here’s one of the simpler examples:

function add_grab_creature_event(hook, creature_index)
	local event = {}
	event[1] = EVT_GRAB_CREATURE
	event[2] = g_input_count
	event[3] = creature_index
	table.insert(g_event_log, event)
	execute_grab_creature_event(hook, event)
end

function execute_grab_creature_event(hook, event)
	hook.hooked_index = event[3]
	hook.func_start_reaction(hook, "exclaim")
	msg.post("#sprite", "disable")
	msg.post("main:/audio", "play_sfx", { sound = "grab_fish" })
end

function undo_grab_creature_event(hook, event)
	hook.hooked_index = -1
	hook.func_hide_all_reactions()
	msg.post("#sprite", "enable")
end

The hook.script always passes itself into the add() function along with any data specific to that event. The first element in the event table is always the event type, the second is always the input count. After that each event has its own structure depending on what data it needs, though they all use simple numerical indexing to avoid string overheads. As you can see all the actual data/state changes (setting the hooked_index, playing a reaction animation, disabling the hook sprite) occur in the execute() function, with the undo() function doing the reverse of each operation.

Here’s the top level undo() function called when the button is pressed:

function undo(hook)
	if g_input_count < 1 or #g_event_log < 1 then return end
	while #g_event_log > 0 and g_event_log[#g_event_log][2] == g_input_count do
		local event = g_event_log[#g_event_log]
		if event[1] == EVT_MOVE_HOOK then			undo_move_hook_event(hook, event)
		elseif event[1] == EVT_REEL_HOOK then		undo_reel_hook_event(hook, event)
		elseif event[1] == EVT_GRAB_CREATURE then	undo_grab_creature_event(hook, event)
		elseif event[1] == EVT_REEL_CREATURE then	undo_reel_creature_event(hook, event)
		elseif event[1] == EVT_START_REELING then	undo_start_reeling_event(hook, event)
		elseif event[1] == EVT_FINISH_REELING then	undo_finish_reeling_event(hook, event)
		elseif event[1] == EVT_DROP_CREATURE then	undo_drop_creature_event(hook, event)
		elseif event[1] == EVT_SPAWN_SKELE then		undo_spawn_skele_event(hook, event)
		elseif event[1] == EVT_BREAK_DEBRIS then	undo_break_debris_event(hook, event)
		elseif event[1] == EVT_SQUID_INK then		undo_squid_ink_event(hook, event)
		elseif event[1] == EVT_SPONGE_INK then		undo_sponge_ink_event(hook, event)
		elseif event[1] == EVT_PATROL_MOVE then		undo_patrol_move_event(hook, event)
		elseif event[1] == EVT_PATROL_TURN then		undo_patrol_turn_event(hook, event)
		elseif event[1] == EVT_PATROL_HIDDEN then	undo_patrol_hidden_event(hook, event)
		elseif event[1] == EVT_PATROL_LOCK then		undo_patrol_lock_event(hook, event)
		elseif event[1] == EVT_PATROL_UNLOCK then	undo_patrol_unlock_event(hook, event)
		elseif event[1] == EVT_CUT_STINGER then		undo_cut_stinger_event(hook, event)
		elseif event[1] == EVT_CUT_SEAWEED then		undo_cut_seaweed_event(hook, event)
		end
		table.remove(g_event_log, #g_event_log)
	end
	g_input_count = g_input_count - 1
	msg.post("main:/audio", "play_sfx", { sound = "undo" })
end

All the events which occurred as a result of the most recent input are undone in reverse chronological order (since events are added and executed in chronological order). Then the input counter is decremented.

The engineer in me wants to move this whole system to C and really optimize the data storage, but I almost certainly won’t unless I do some profiling nearer to launch and discover a big problem. The difficult part of this refactor was just working out all the places where relevant data changes occurred, why, and what other data was necessary to allow those changes. The game remained playable throughout though since I just worked my way through each gameplay interaction one by one, refactoring and testing and committing as I went, until I could undo every level all the way back to the beginning and everything was back to where it started.

One exception

I said earlier that there is almost a one-to-one mapping between action button presses and undo button presses. When you reel in with nothing hooked, the input counter is incremented so that you can undo that action and return to where you were. However once you’ve hooked a creature there is nothing you can do other than reel it in, so it stands to reason that whenever you want to undo reeling in a creature you also want to undo hooking it. So when you reel in with something hooked the input counter actually isn’t incremented, meaning that when undo is pressed it will undo everything that occurred as a result of reeling in the creature, as well as the hook movement that caused the creature to be hooked in the first place.

This can be seen in the gif below. When the hook is above the crab and reels in there is nothing hooked, so one action input (reel in) matches one undo input. From there it takes three action inputs (down, down, reel in) to reel the crab in and smash the debris, but only two undo inputs to get back to the spot above it.

Replay

Theoretically the event log is all I need to play back the player’s solution. The log could be parsed to remove pointless inputs such as moving then reeling in without hooking anything, or dropping a fish at A then B then back at A. The log could then be iterated through and executed at a steady rate to show the player the ideal version of their personal solution to the puzzle. With such low resolution and only 16 colors I could even record very long gifs at very small file sizes. This is very much a stretch goal, but it would be pretty cool to let people share their progress.

Follow me on itch or Twitter to see what I work on next

Leave a comment

Log in with itch.io to leave a comment.