Hook, Line and Thinker (11 devlogs)

Hi @connor.halford
Thanks for posting this article. It’s inspiring to see your process and thoughts. The game looks awesome! I really love the art style and gameplay mechanics. Simple, colorful, beautiful, and thought provoking! Keep up the good work!


Thanks for playing and thanks for the feedback! I guess you’re talking about the web build on the community portal? That’s something like 6 or 8 months old, a lot has changed since then including a complete redesign of about half the levels. I should probably update that build!


Thank you very much! I will write more articles (and y’know, finish and release the game) when I have time. Really appreciate the encouragement, so much of this project is just me on my own in my flat so having other people interested in the game helps a lot with motivation.


you should! This would be a good reason for us to spread the news abt your game wider :wink:


Fantastic! I’ve been eagerly awaiting a release since first playing the competition entry, which immediately hooked me (sorry!).


#3: Tutorial Design

Teaching through play
I don’t explain anything to the player, including the controls. All actions are done by pressing one of the 7 buttons visible on the screen at all times. You never need to press more than one button at a time and when a button won’t do anything it gets disabled (greyed out).

This is a puzzle game so I want to encourage the player to get into the mindset of working things out for themselves. The first few levels have been iterated a lot to set up scenarios that force the player to discover how a gameplay mechanic works.

A puzzle is something you initially don’t know how to solve, so every level is arguably a tutorial on how to solve itself. As players progress they get a deeper understanding of mechanics and how they relate to each other, even learning some patterns of layout or movement they can use as tools in multiple levels. There are a number of reasons that motivate level design and one of them is to teach the player something specific, or rather encourage them to work it out for themselves. Here are a few of those levels.

Movement and win condition

In the first level players discover how to move the hook around (push the arrow buttons) and reel it back in (push the circle button). They’ll probably also realise that they can’t cross their own line or move through purple blocks, and might notice that they have a limited line length (shown by the reel in the top right). Once they move into a fish they’ll find that they can’t move again and must reel it in. If they’re really observant they’ll also notice the counters along the top that count down as they catch the fish. The curious ones might also try pressing the undo button (backwards pointer) or pause menu button (cross).

Crabs and debris

In level two the player is confronted with brown blocks they haven’t seen before. They can’t do anything other than try and move through them, which they can. Well there’s a fish right behind the blocks and they solved the previous level by catching fish, so they’ll probably try to reel it in, only to discover they can’t pull fish through brown blocks and will drop them instead. A lot of players will try pulling the fish through the other brown block, just in case. When that fails too there’s nothing left to do but approach the crab, which is when players find that they can reel crabs in too and that crabs smash brown blocks. The observant ones will notice that doing so reduced their line length by 5.

Squid, ink and how to get out of an unsolvable state

Level 3 introduces the squid and ink mechanics and is the first puzzle where the player can get it into an unsolvable state. After catching the first squid they see a trail of 5 ink tiles, which it turns out they can move through but can’t pull creatures through. The level is now unsolvable, so players will eventually try hitting the pause button and finding the restart menu item, or try the undo button and go back to a solvable state. Some players pre-emptively solve this by getting the rear squid first without knowing what will happen. I might iterate some more on this one, but then again the interaction with ink is simple and easily found later.

Jellyfish and skeletons

Fish, I remember those! Here players will find they can’t move through or catch jellyfish, but they can move through their stingers. Trying to pull one of the fish through will kill it, blocking the path with a skeleton. There’s nothing left to do but try and catch that, only to discover that skeletons cut stingers. Observant players will realise that they don’t necessarily have to catch every creature in a level, the counters at the top describe the win condition.

Sea sponges

Not all mechanics are introduced immediately. After the fish, crab, squid and jellyfish are introduced there are several levels using combinations of those mechanics to reinforce understanding and let the player start solving ‘real’ non-tutorial puzzles. Some smaller mechanics / patterns are taught in those. This is an example of a later level that introduces the sea sponge, which can absorb a single ink tile. It also features the pattern of two squid in a 3x3 cave which is used a few times with different squid and entrance positions.

Follow me on Twitter @rhythm_lynx


I wanna play it already :wink:




Hi Connor,

Question on how you are handling your levels… Is each level its own collection, or do you have separate collections for each level?

#4: Bootstrapping, menu transitions and level loading

There are 6 main screens in Hook: the title screen, level select, gameplay, aquarium, settings and about.


Each of these is a separate .collection file in Defold with an additional main.collection acting as the bootstrapper that loads and enables/disables all the others. For localization purposes there are different settings screen and title screen collections for each language (settings_english.collection, title_french.collection etc.) but that can wait for a post about localization. For the purposes of this post there are 7 collections: main plus one for each screen.

First, a disclaimer
I don’t know what I’m doing. This is my first project in Defold and a lot of very important project and code structure decisions were made under extreme time constraints during the first few weeks of the project (see devlog #1). It’s very likely I’m doing some really weird stupid stuff which isn’t necessarily a good idea you should copy. So long as it works I really don’t care. Also my game and especially my assets are tiny so I can get away with basically whatever I want without having to worry about performance.

So, what happens on startup?
The bootstrap main collection in the project settings is my aptly named main.collection. This contains a game object called state_controller which has a collection proxy for every other collection and the also aptly named state_controller.script.


The relevant bits from state_controller.script look something like this:

function init(self)
	lang_suffix = language_suffix(g_save_data.language)
	g_current_state = g_STATE_UNINITIALIZED
	self.num_proxies_loading = 6
	self.startup_load = true
	self.load_pause = 0
	msg.post("#proxy_title"..lang_suffix, "load")
	msg.post("#proxy_level_select", "load")
	msg.post("#proxy_game", "load")
	msg.post("#proxy_aquarium", "load")
	msg.post("#proxy_about", "load")
	msg.post("#proxy_settings"..lang_suffix, "load")

function update(self, dt)
	if self.load_pause > 0 then
		self.load_pause = self.load_pause - dt
		if self.load_pause <= 0 then

function on_message(self, message_id, message, sender)
	if message_id == hash("proxy_loaded") then
		msg.post(sender, "enable")
		if g_current_state == g_STATE_SETTINGS and string.find(""..sender, "settings") then
			msg.post(sender, "disable")
		if self.startup_load == true then
			self.num_proxies_loading = self.num_proxies_loading - 1
			if self.num_proxies_loading <= 0 then
				self.startup_load = false
				self.load_pause = 0.01

Since you can change your language in the settings menu the current language is stored in the save data. The load_save_data() function will either load an existing save file or populate a new save file with the current system language.

The key thing is that the other six proxies all begin loading right at startup and are never unloaded. My game and assets are so small that memory is not even remotely an issue, so the only loading ‘hitch’ should be right here on startup and then every menu transition at runtime should be seamless. In reality these loads are near instantaneous anyway. Making incredibly low resolution pixel art games has its advantages.

As each collection finishes loading we enable then immediately disable it, just to flush all the game object state properly. Since each version of the settings screen in each language is a different collection, changing the language triggers some proxy loading and we don’t want to disable the settings menu if we’re in there currently. Once everything has loaded there is an additional brief pause to let everything settle before kicking off the intro/startup cloud pan cinematic and transitioning to the title screen.

Changing the current state/menu
The gameplay and title screen rest at the ocean surface while the other screens reside underwater. This gives a natural, simple and visually pleasing menu transition of simply panning the camera up or down, but does restrict which screens you can move between. For example you cannot go directly from settings to about (you have to go up to the title first), or directly from the game to the title (you must go down to level select). A side benefit here is that we can safely make some assumptions, for example we know that if we’re transitioning to the level select screen we must be coming from the surface. Some systems such as the camera pan don’t need to know or care whether we’re coming from gameplay or the title screen, it’s just surface to underwater.


Incidentally yes, the underwater parts of the game really are just happening several hundred pixels below the surface. The change_state function of state_controller.script manages acquiring and releasing input focus, enabling the collection we’re transitioning to, setting up the collection if necessary (through an “entering” message or similar) and triggering the camera pan itself. Once the pan completes we disable the collection that we just left.

function change_state(new_state, level_num)
	local to_disable = nil
	if g_current_state == g_STATE_LEVEL_SELECT then
		to_disable = "main:/state_controller#proxy_level_select"
		msg.post("level_select:/controller", "release_input_focus")
	elseif g_current_state == g_STATE_GAME then
		-- ...etc...
	g_current_state = new_state
	if g_current_state == g_STATE_LEVEL_SELECT then
		msg.post("main:/state_controller#proxy_level_select", "enable")
		msg.post("level_select:/controller", "acquire_input_focus")
		msg.post("level_select:/controller", "entering")
		start_cam_move(CAM_Y_SURFACE, CAM_Y_UNDERWATER, to_disable)
	elseif g_current_state == g_STATE_GAME then
		-- ...etc...

Level previews on the level select screen
Each level is just a plain tilemap. There’s a level.go game object with every tilemap attached and a factory for each type of game object a level can consist of (fish, seaweed etc). This level.go object is used both in the gameplay collection and the level select screen collection.

Whenever you change the currently selected level the generate_minimap function of level_select.script updates the preview by spawning a set of sprites to represent the level tilemap. It was a lot of fun trying to represent things in 8x8 sprites, then fun again to try and boil those down to 3x3. Here are a few:


At some point I’ll update the following code to have a fixed array of sprites that are reused and enabled/disabled as needed, but since a lot of tiles are empty space it was quicker to just throw them all away and only spawn in the ones that are needed. Major time constraints therefore inefficient code and magic numbers blah blah here’s the code:

local function generate_minimap(self)
	self.minimap_tiles = {}
	local tile, tx, ty, id = nil, 0, 0, nil
	local pos, anim = vmath.vector3(0, 0, 0), ""
	for x = 1, 16 do
		for y = -11, 0 do
			tile = tilemap.get_tile("tilemap_parent"..get_tilemap_name(self.cursor), "layer1", x, y)
			pos.x = 71 + x * 3
			pos.y = Y_OFFSET - 5 + y * 3
			if tile ~= 0 then
				id = factory.create("#factory_minimap", pos)
				table.insert(self.minimap_tiles, id)
				anim = "coral"
				for i = 1, #g_SPAWN_TILES do
					if tile == g_SPAWN_TILES[i].tile then
						anim = g_SPAWN_TILES[i].type
				msg.post(id, "play_animation", { id = hash(anim) })

So, what happens on level load?
When you actually load a level the tilemap is iterated across and whenever a tile that should spawn a gameobject is encountered (e.g. a fish) that gameobject is spawned from a factory and the tile in the tilemap is set to 0 (blank). This is because the tilemap is referred to by gameplay logic for collision checks with the walls. When the level is unloaded any edited tiles in the tilemap are reset to their original values. Here’s a very simplified version of the level loading and unloading functions:

	 [1] = { tile = 27, type = "crab" },
	 [2] = { tile = 25, type = "fish" },
	-- ...etc...

local function unload_level()
	tilemap_name = get_tilemap_name(g_level_num)
	for i = 1, #reset_tiles do
		tilemap.set_tile(tilemap_name, "layer1", reset_tiles[i].x, reset_tiles[i].y, reset_tiles[i].original)
	reset_tiles = {}
	for i = 1, #g_creatures do
		go.delete(g_creatures[i].id, true)
	g_creatures = {}


local function load_level(level_num)

	-- Only enable the tilemap for the current level
	local tilemap_name = nil
	for i = 1, g_NUM_TILEMAPS do
		tilemap_name = get_tilemap_name(i)
		msg.post(tilemap_name, "disable")
	tilemap_name = get_tilemap_name(level_num)
	msg.post(tilemap_name, "enable")
	-- Loop through the tilemap and spawn creatures
	local tile, fact, type, anim, frame, new_id
	local prev_creature = nil
	for x = 1, 16 do -- left to right
		prev_creature = nil
		for y = -11, 0 do -- bottom to top
			tile = tilemap.get_tile(tilemap_name, "layer1", x, y)
			fact = nil
			type = nil
			for spawn = 1, #g_SPAWN_TILES do
				if tile == g_SPAWN_TILES[spawn].tile then
					type = g_SPAWN_TILES[spawn].type
					fact = "#factory_"..type
					frame = tile - g_SPAWN_TILES[spawn].tile
					anim = type..frame
			if fact == nil then
				prev_creature = nil
				new_id = factory.create(fact, vmath.vector3(8 * (x - 1), 8 * y, -go.get_position().z))
				msg.post(new_id, "set_parent", { parent_id = go.get_id(), keep_world_transform = 0 })
				msg.post(new_id, "play_animation", { id = hash(anim) })
				table.insert(g_creatures, { id = new_id, type = type, frame = frame, removed = false })
				tilemap.set_tile(tilemap_name, "layer1", x, y, 0)
				table.insert(reset_tiles, { x = x, y = y, original = tile })
				if type == "squid" then
					add_ink(g_creatures[#g_creatures], 5)
				elseif type == "sting" then
					g_creatures[#g_creatures].marked_for_removal = false
					if prev_creature ~= nil and prev_creature.type == "sting" then
						prev_creature.above = g_creatures[#g_creatures]
						g_creatures[#g_creatures].below = prev_creature
				elseif type == "sponge" then
					-- ...etc...

				prev_creature = g_creatures[#g_creatures]

By now you may have realized that some creatures such as jellyfish are actually composites. The root of the jellyfish is one creature, then each tile of stingers is a separate independent ‘creature’ as this makes it easy to have jellies with any number of stingers. However there is some behaviour that groups them together, for example cutting a stinger will also remove all the stingers attached below it. This is why the level is parsed vertically instead of horizontally and why I track the previously spawned creature; jellyfish stingers act as a doubly-linked list to make cutting them easier.

As always you can follow development of the game on Twitter @rhythm_lynx


Hi @melliott.meeka, great question! I’ve been meaning to write a more technical post here for a while so I hope #4: Bootstrapping, menu transitions and level loading answers your question!


Thanks @connor.halford !!! This is fantastic! We really appreciate these blogs. They’ve been super helpful and inspiring.


You’re welcome : ) nice to know people read and appreciate them


Awesome write up and great idea! Thanks for sharing!
Keep it up!


Thanks! : )

1 Like

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!

1 Like

#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

1 Like