Hook, Line and Thinker (7 devlogs)


Thanks! : )


Thanks! The collection proxy concept it’s always a difficult obstacle to climb for beginners!
But your detailed but simple explanation is perfect in this case.
Keep it up!


You’re welcome, I will!


#5: Case anatomy and pixel-perfect rendering

I have just finished the very long and very painful process of making the game handle any aspect ratio and any resolution while remaining pixel-perfect. In the process I added support for landscape layouts and completely redesigned the available case palettes.

Defining ‘pixel-perfect’

[The program I’m using to resize the window is a tool I made called Positive Aspects, there’s a forum thread about it and you can download it for free.]

As you can see in the demo above, the game renders correctly no matter what aspect ratio or resolution it is resized to. Generally in the rest of this post I’m going to refer to the section of the window where all the actual gameplay happens as the ‘game’ and the handheld console surrounding it as the ‘case’. I had several criteria to meet:

  • The game should always be the largest exact multiple of 128x128 that can fit in the window while still leaving enough space for the case
  • On a phone or tablet the game should remain the same size when rotating between portrait and landscape
  • Every element of the case should be scaled equally so that it remains internally consistent
  • The relative proportions of the case should be flexible to allow the game to be as large as possible
  • The size of a case pixel can be different than the size of a game pixel
  • The buttons of the case must always be interactable and should never overlap
  • No case element should ever overlap the game
  • Every game pixel and every case pixel should always be perfectly square
  • No pixel should ever be anything other than one of the 16 colors of the PICO-8 palette

Custom render pipeline

To avoid the complexities of the actual game, I made a separate prototype project with just the case rendering and a 128x128 game screenshot. It took 4 or 5 attempts before I finally got everything working and integrated the prototype into the main project.

I now have a totally custom render pipeline with 3 materials: pre-game, game and post-game. The materials are functionally identical but everything with the pre-game material is obviously rendered first, then game, then post-game. Most of the case is pre-game but the buttons are post-game because if a scenario ever arises where the buttons and game overlap, the buttons should be visible since they’re how you interact with the game. This scenario shouldn’t ever happen other than for resolutions lower than 128 in one or both dimensions, such as this tiny 198x111 window:

To ensure crisp edges each material uses ‘nearest’ min and mag filters and ‘clamp to edge’ wrapping, with border extrusion enabled on the case atlas since sprites with those textures are actually scaled up. All sub-pixel rendering is disabled through the game.project file.

The case elements are sized and positioned to match the window size with (0, 0) at the bottom left corner. No camera manipulation necessary for the case rendering, just simple projection and view matrices and no viewport clipping. The game rendering uses a fixed 128x128 projection matrix and gets the view matrix from a camera moved around the world (as described in devlog #4: Bootstrapping, menu transitions and level loading). The game is placed and sized within the case through manipulating the viewport. The relevant bit of the render script looks like this:

-- setup render state
render.set_blend_func(render.BLEND_SRC_ALPHA, render.BLEND_ONE_MINUS_SRC_ALPHA)
render.clear({[render.BUFFER_COLOR_BIT] = col(g_palettes[g_save_data.palette].background),
	[render.BUFFER_DEPTH_BIT] = 1,
	[render.BUFFER_STENCIL_BIT] = 0}

-- draw pre-game case elements
local case_projection_matrix = vmath.matrix4_orthographic(
	0, g_window_width, -- left, right
	0, g_window_height, -- bottom, top
	-1, 1) --near, far
render.set_viewport(0, 0, g_window_width, g_window_height)

-- draw game
render.set_projection(vmath.matrix4_orthographic(0, GAME_SIZE, 0, GAME_SIZE, -1, 1))
render.set_viewport(g_viewport_left, g_viewport_bottom, g_viewport_size, g_viewport_size)

--draw post-game case elements
render.set_viewport(0, 0, g_window_width, g_window_height)

On startup and whenever a resize is detected (including changing device orientation between landscape and portrait) some calculations are run to work out where the game viewport should now be and what size and position each case element should now have. The script responsible for all this is ~800 lines long, but here are some of the top-level functions that handle resizing:

local function pre_viewport_resize_case()
	-- letterboxing
	g_case_bounds.width = g_window_width
	g_case_bounds.height = g_window_height
	local aspect = g_case_bounds.width / g_case_bounds.height
	g_is_portrait = (aspect < BOUNDARY_ASPECT_RATIO)
	if g_is_portrait == true then
		aspect = math.min(aspect, 1 / MAX_ASPECT_RATIO)
		g_case_bounds.width = g_case_bounds.height * aspect
		aspect = math.max(aspect, 1 / MAX_ASPECT_RATIO)
		g_case_bounds.height = g_case_bounds.width / aspect
		aspect = math.max(aspect, MAX_ASPECT_RATIO)
		g_case_bounds.height = g_case_bounds.width / aspect
		aspect = math.min(aspect, MAX_ASPECT_RATIO)
		g_case_bounds.width = g_case_bounds.height * aspect
	g_case_bounds.width = math.floor(g_case_bounds.width) -- make it integer
	g_case_bounds.height = math.floor(g_case_bounds.height)
	g_case_bounds.width = g_case_bounds.width - (g_case_bounds.width % 2) -- make it even
	g_case_bounds.height = g_case_bounds.height - (g_case_bounds.height % 2)
	g_case_bounds.x = math.floor((g_window_width - g_case_bounds.width) / 2)
	g_case_bounds.y = math.floor((g_window_height - g_case_bounds.height) * 0.3)
	local x_scale = math.floor(g_case_bounds.width / CASE_WIDTH)
	local y_scale = math.floor(g_case_bounds.height / CASE_HEIGHT)
	g_case_bounds.pixel_scale = math.max(1, math.min(x_scale, y_scale))

	for i = 1, #g_case_parts do
		resize_part(g_case_parts[i], (i - 1) / #g_case_parts)

local function calculate_viewport()
	-- work out the largest multiple of GAME_SIZE we can fit in the window while obeying MIN_CASE_BORDER size
	local limiting_dimension = math.min(g_case_bounds.width, g_case_bounds.height)
	local best_size = GAME_SIZE * math.max(1, (math.floor(limiting_dimension / GAME_SIZE)))
	if limiting_dimension - best_size < 2 * MIN_BORDER * g_case_bounds.pixel_scale then
		best_size = math.max(GAME_SIZE, best_size - GAME_SIZE)

	-- don't let the size of the game occupy too much vertical space as we need room for the buttons
	while best_size / g_case_bounds.height > MAX_SCREEN_HEIGHT and best_size > GAME_SIZE do
		best_size = math.max(GAME_SIZE, best_size - GAME_SIZE)

	-- calculate viewport
	g_viewport_size = best_size
	local border = (g_case_bounds.width - g_viewport_size) / 2
	g_viewport_left = g_case_bounds.x + border
	g_viewport_bottom = g_case_bounds.y + (VIEWPORT_CENTRE * g_case_bounds.height) - (g_viewport_size / 2)
	g_viewport_bottom = math.floor(0.5 + g_viewport_bottom)

local function post_viewport_resize_case()
	local surround = nil
	for i = 1, #g_case_parts do
		if g_case_parts[i].name == "screen_surround" then
			surround = g_case_parts[i]
	if surround ~= nil then
		local id, size
		if g_is_portrait == true then
			id = surround.port_id
			size = go.get("port_"..surround.name.."#sprite", "size")
			id = surround.land_id
			size = go.get("land_"..surround.name.."#sprite", "size")
		local pos = go.get_position(id)
		pos.x = g_case_bounds.x + g_case_bounds.width / 2
		pos.y = g_case_bounds.y + g_case_bounds.height * VIEWPORT_CENTRE
		go.set_position(pos, id)
		surround.position = pos

		local scale = go.get_scale(id)
		scale.x = (g_viewport_size + 2 * SURROUND_SIZE * g_case_bounds.pixel_scale) / size.x
		scale.y = (g_viewport_size + 2 * SURROUND_SIZE * g_case_bounds.pixel_scale) / size.y
		go.set_scale(scale, id)

local function resize_case()

To avoid floating point rounding issues and ensure pixel-perfect rendering it was crucial that all x/y positions and scales ultimately end up as integers. The second parameter passed to resize_part is the z-coordinate for each case element: (i - 1) / #g_case_parts. The g_case-parts table is a long list defining the size, position and color of each case element listed from back to front. Setting the z coordinate of each element appropriately in the collection would have been extremely error prone and time consuming; setting it programmatically makes it trivially easy to add and remove case elements as the design was iterated. By hiding elements above a certain z value it also made it fairly easy for me to create this visualisation showing the overall back-to-front render order, with pre-game elements followed by game elements followed by post-game elements:

Case anatomy

The case is made of ~60 pure-white sprites pixelled at a resolution of 160x90 and arranged in landscape and portrait layouts. There is some redundancy here from several iterations of the case design. If the aspect ratio (width ÷ height) is less than 1 then the portrait layout is enabled and resized, otherwise the landscape one is.

The reason all the sprites are pure-white is because players can choose one of 16 color variations, one with a base color for each of the 16 colors in the PICO-8 palette. I had to condense the arrow buttons into a d-pad for the landscape layout in order to keep the game centred.

The result

In total this was around 4 months of work and the game should now render correctly and be fully playable on every device in any orientation, including all future devices with whatever weird resolutions and aspect ratios the device manufacturers throw out next. If that sounds like a long time, bear in mind that I have a full-time job and can only work on Hook when time/energy permits (typically weekends). One month of that time was spent making the Positive Aspects tool; it does more things than I strictly needed it to do but it’ll be useful for every future project, plus completing and releasing a smaller project was a great motivator when returning to The Big One.

Twitter: @rhythm_lynx


Really impressive and inspiring, @connor.halford – once again, thanks for sharing your progress :raised_hands:


Really good job @connor.halford ! How did you solve the issue on windows resize (that brokes the GUI nodes alignment respect the on_input action.x~y)?

I’m experiencing similar issue, in a very simple GUI layout


Hey, thanks. I’m not actually using Defold’s GUI system at all, everything is just sprites in a collection. I mentioned above that it took 4 or 5 attempts to finally get everything working, several of those involved the GUI system but I could never get it to do exactly what I needed. Typically UI assets are high resolution then scaled down, but for me everything is at a tiny resolution* and massively scaled up. I needed complete control and wrestling with automatic layouts just wasn’t worth it.

*Fun fact: I can fit every single texture in the project, including every bit of baked localization text in all 12 languages, into a single 2048x1024 texture with about a fifth still empty.


#6: Undo

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[2] = g_input_count
	event[3] = creature_index
	table.insert(g_event_log, event)
	execute_grab_creature_event(hook, event)

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

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

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)
		table.remove(g_event_log, #g_event_log)
	g_input_count = g_input_count - 1
	msg.post("main:/audio", "play_sfx", { sound = "undo" })

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.


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.

As always you can follow me on Twitter @rhythm_lynx.


#7: Design iteration: player movement

In this game all you do is move and reel back in. Sound simple? Making games is hard.

A couple of caveats for the days per version listed here. It’s a rough estimate based on days where there’s a relevant commit in the log, but some commits represent several days of work, often there are many tweaks done later which are bundled under other commits, I may have been working on other stuff too, and some of these were weekend / full days while others were just evenings after work. Still interesting though.

Version 1: Moving and reeling in (4 days, October 2016)

According to the daily devlog I wrote during the week-long game jam, I had movement working on day 1; collision on day 2; reeling in the hook on day 3; and reeling in creatures on day 5. When you pushed the reel-in button you were committed to reeling all the way in, with no way to stop it or undo your action. This was particularly annoying on puzzles which require you to drop creatures since you had to wait for the hook to reel all the way back in before moving it again.

My thinking at the time was that by making every move matter it would ̶e̶n̶c̶o̶u̶r̶a̶g̶e̶ force players to think through their actions before taking them, rather than using trial-and-error to fudge their way to a solution. I think there’s several issues with that reasoning. For one thing, I don’t explicitly tell the player anything about how to play, instead using level design to prompt players to work things out for themselves through experimentation. Making every move matter is directly opposed to this, causing players’ introduction to the game to be needlessly punishing.

In my opinion, as designer it is not my job to prescribe how players play my game. It is my job to prescribe what they’re trying to do and what tools they have to accomplish their goals (which they may have even set themselves). If someone wants or needs to trial-and-error their way through a puzzle that is a perfectly valid way of playing the game. Do I think that’s the most enjoyable way to play? No. Does the design facilitate such a playstyle anyway? Yes. Encourage it? Early on, yes. People learn through mistakes and using trial-and-error is a great way to make a lot of mistakes fast. Through this experimentation players will (hopefully) start to understand the mechanics of the game and gain the ability to reason about and predict the effects of their actions before making them (consistency is key here). At this point the player transitions from trying to complete the level to trying to solve the puzzle.

Version 2: Limited undo (1 day, May 2017)

After initially being opposed to any undo at all, my design thinking changed over time and eventually landed on unlimited unrestricted undo. This was the first step towards that: you could undo your movements up to grabbing a creature, but once you reeled it in there was no way to cancel it or undo the effects other than to restart the level.

This was better than no undo at all but the restrictions immediately felt frustrating and arbitrary. As a player I felt a little more free and relaxed about my movements, because a misjudged or accidental button press was no longer a loss of progress (prior to this an error meant at best reeling in the whole line to play it out again, at worst having to restart the entire level). Around this time I added full undo to my todo list.

Version 3: Interrupting an empty reel-in (1 day, October 2017)

This was another way for the player to recover from a misclick. I showed the game to a couple of friends and noticed that they sometimes reeled the hook in after getting adjacent to a creature but not actually grabbing it. They would immediately realise their mistake and jab at the movement buttons in a panic, trying to recover their lost progress as the hook wound its way back to the start. Cue frustration and helplessness.

Once I added this I used it constantly as a way to explore levels; approaching something from one side, then reeling in partially and approaching it from another. It increased my options as a player and made stress testing levels for broken solutions easier. My only concern was that it potentially made patrol fish trivial. Patrols move when you move and the intent was that you’d have to outsmart them by creating a trap with your line. They were the most frustrating part of the game because you often knew how and where you wanted to trap them, but when you got there you were out of sync with their movements and couldn’t execute your plan through no real fault of your own, instead having to resort to reeling in and trying again until everything happened to line up. Being able to resume movement after reeling in meant you could now sort of wiggle back and forth on the spot while they moved into place. This issue with patrols stood for well over a year; we’ll come back to it later.

Version 4: Full undo (~8 days, January 2018)

Freedom, at last! As a player I finally have no reservations about trying different approaches and playing with new mechanics to see how they work. It feels liberating.

At this point I thought the movement was done and was confident that unlimited unrestricted undo was a smart decision for this game. That’s handy because it was a lot of work!

Version 5: Auto-stopping the reel-in when you drop something (~2 days, November 2018)

There were two things that motivated me to make this change. A few months prior I was able to do some proper playtesting and saw that absolutely no-one understood that pulling a patrol through seaweed would cause you to drop it. I considered ditching the mechanic but was focused on other tasks for the time being. The second was the realization that most of the time after dropping something the next move you want to make is somewhere in the vicinity.

This felt very strange at first; it changes the rhythm of the game and felt like it was interrupting the flow. It successfully highlights the fact that you drop patrols on seaweed, because it stops you and forces you to realise it (dropping something is the only thing that automatically stops the reel). I had to add a locking mechanic to patrols to prevent the one you dropped from moving until the hook comes to rest, because if it isn’t where you left it then it’s unclear what happened. In some cases the hook was left in a position where the route I wanted to take was blocked by the line so I would have to awkwardly press the reel-in button again to backtrack before moving forward. Sound familiar?

Version 6: Backstepping (1 day, January 2019)

Hopefully the final piece of this designer-facing puzzle is to allow the player to backtrack one step at a time. This had been possible for more than a year through the awkward process of pressing the reel-in button then almost immediately pressing a directional button. I’ve made strong, conscious design decisions throughout the project to avoid any kind of real-time requirements for players and hated that a reaction-based mechanic had essentially been introduced. As a player I was doing this because it allowed me to more easily achieve my goals. It was time to add another tool to the toolbelt!

And that is where movement stands today (February 2019). My concerns about this making patrols easier to catch proved true, but I now consider that a good thing. I want all the difficulty to be in working out what what you need to do, not in executing your plans.

Version 7: ???

Judging by this track record I’m due to change something about the movement around October 2019, then January 2020 (No, I don’t plan to still be working on this game in 2020!). I haven’t played enough of the game with the auto-stop and backstepping to be totally confident about them, but they solve a lot of design issues so I reckon it’s just that I’m not used to it yet. Hopefully it feels good to move now and isn’t frustrating! I think the button visuals help with the feel too, especially that d-pad *pats self on back *.

I hope this look at the design evolution for such a seemingly simple mechanic gives some indication of the interesting, sometimes difficult problems I’m encountering along the way. For those who are eager to play, I hope I’ve garnered a little more of your valuable goodwill and patience. Believe me, I want this game to be finished more than anyone.

Follow me on Twitter @rhythm_lynx


Again, amazing writeup! Thx!


Cheers :slight_smile:
This was a fun one to write because I got to roll back to older versions to record the gifs, funny seeing how different some things were and how others are exactly the same


Agree with Mathias—I really enjoy reading every post. Also, big up for demonstrating the importance of user-/play testing, which can really shatter ones beliefs on game mechanics. Also also, that’s once nice-looking d-pad you’ve got there :slight_smile: