Hook, Line and Thinker (4 devlogs)


#1

I’ve been making a fishing puzzle game called Hook, Line and Thinker for more than a year now and I thought I would start chronicling what I’ve done so far, how, why, and what I’m working on next.

This first post will be repeatedly updated to act as a summary and link to the other posts as I write them.

#1: History and Motivations
#2: Gameplay and progression
#3: Tutorial Design
#4: Bootstrapping, menu transitions and level loading

Here’s an overview of the game from August 2017:


#2

#1: History and motivations

History
I started making this game on the PICO-8 for Fishing Jam 2 at the end of October 2016. I vaguely remembered enjoying screenshots of a grid-based fishing roguelike from the first Fishing Jam and liked the idea of making a grid-based fishing puzzle game with a handful of sea creatures that have different effects when reeled in.

It was a 7 day jam but I forgot about it until the second day and had to go to work all week, so in total I spent 4 evenings and 2 full days over the weekend making my entry. You can play the 6 day jam version and read the daily devlog, which does a good job of documenting progress and explaining my thought process as I was designing the game.

ezgif-4-87a046ff7f
I definitely didn’t steal a sprite from Pokémon Red/Blue for the person fishing

The reception to the jam version was good and I really liked my game so I decided to keep working on it. A week or so later a talented audio designer friend of mine (Andrew Dodds) messaged me saying he liked the game and had some audio ideas. We’d enjoyed working together before so he joined the project and we started encouraging each other with feedback.

By the end of 2016 the game was coming along nicely and we were starting to max out some of the limitations of PICO-8, requiring clever solutions to work around them (one of the main ones being the number of audio clips). Around this time I started thinking it would be nice if the game were on mobile as it would be more accessible and began looking at different mobile engines.

Late on January 2nd 2017 I found Defold, then found the Defold GDC Competition 2017. This was a 3 month competition ending in 3 weeks time, where 6 winning games made with Defold would be demoed at GDC in San Francisco (flights and accommodation included). Too good to be true, so I went for it. Since both engines run on Lua I figured I could copy+paste most of the code, but since the architectures are so different I ended up rewriting everything from scratch.

I spent the first week doing tutorials and just generally trying to work out what the hell a Defold was. In week two I ported most of the core gameplay and in week 3 I worked on polish, UI and new levels. It was a pretty hard crunch; I was working around 90 hours a week, spending more time on this fishing game than at my full-time job. We submitted with 45 minutes to spare. I spent a week recovering and anxiously awaiting what I was sure would be a no, only to find out we were one of the winners! After a second and even more intense 3-week-crunch I got on a plane and headed for GDC.

GDC was an incredible experience, but one I struggled to enjoy. Watching and talking to people playing the game taught me a lot about which parts were good and which parts needed work. Reception was pretty positive which was a big motivation boost. I regret not leaving the booth more often to explore, though we were busy almost all the time. In the evenings I was a total mess; fried after 6 weeks of working 90+ hours and now literally thousands of miles outside my comfort zone, I completely failed to engage with the social aspect of GDC. I skipped or bailed early from the various parties and meetups each night, made few connections and spent a disappointing amount of time in our hotel room. Pushing myself so extremely was not a good idea, but at the same time it got us to GDC with a game I was proud to show.

In the months since then I’ve continued working on the game on-and-off, largely depending on my mood and energy after my full-time job. Among many other things this work has included new game mechanics, a lot of level design iteration and/or replacement, localising the game to 12 languages, adding an undo button, creating a series of GameBoy-esque virtual cases and many, many small bits of polish.

At this point I’m happy with almost all the content in the game and most of the remaining work is just polish and the tech required to release a game on the app stores. The plan is to release as a $0.99/£0.99/€0.99 premium game on Android and iOS when it’s ready, which I would really like to be in 2018.

Way back on the sixth day of this project, in the devlog of the original jam version, I wrote “This is already the biggest, best and most complete game I’ve made.” What a long way we’ve come since then.

A more personal account of this history can be played in W.I.P., a game about making the game I’m making.


Motivations
I don’t really care about money. Yes I’d like the game to sell well, not for fame and fortune but because I think people will genuinely enjoy it. More than anything the purpose of this game is educational; I now have at least a rudimentary understanding of and appreciation for pixel art, puzzle design, localization, releasing a game on the app stores (I’m still working on that one) and many other areas of game development. I’ve given this game a lot and it’s given me a lot back. I’m very grateful for that. Ultimately I just want to make the game as good as I know I can make it, in the hopes that a few people out there in the void will appreciate it.

Thanks for reading.

Follow development of the game on Twitter @rhythm_lynx


#3

Thank you so much for sharing your experience of creating Hook, Line & Thinker. It’s a brilliant little game that deserves the attention.


#4

#2: Gameplay and progression

One of my design goals with this game is accessibility, which for gameplay means simplicity and control. Nothing in the game happens without you.

  • There are no timers or time constraints of any kind
  • There is no randomness of any kind
  • There are no real-time elements of any kind. Reaction time is completely irrelevant and you can always take as much time as you want between button presses
  • You only ever have to push one button at a time. Buttons are big, do simple and consistent actions and can comfortably be reached with one hand
  • You can hold down a button to repeat the action with a delay, for example holding down an arrow button will periodically move you one tile in that direction
  • You can undo every action all the way back to the start of the level, with no restrictions whatsoever. There is no punishment for mistakes
  • There are no currencies or microtransactions or any other requirements
  • You never gain any new abilities or controls. You just move the hook and reel it in, from the first level to the last. Additional mechanics are introduced through the sea creatures and how they interact with the other elements of the level
  • Mechanics are consistent. The way each type of creature behaves is the same from the first level to the last
  • The whole level is visible at all times. This means you can plan ahead and you don’t need to remember a complex layout
  • Almost every level is unlocked from the beginning of the game. This means if you get stuck you can just try a different one and come back later. (See the Progression section below)

Movement Mechanics
You start each level with enough fishing line on your spool to make 30 moves before needing to reel in. At the top of the level is a counter for how many Fish, Crab, Squid and Patrols you still need to catch to complete the level. You catch creatures by moving the hook over them then reeling them all the way in. You can’t cross your own line.


There are four directional buttons (left, right, up, down) and one button to begin automatically reeling in. You can reel in even without a creature hooked. If you’re reeling in with nothing hooked you can push a directional button to stop reeling in and move in that direction. Each individual movement step can be undone. All movement and interactions that occur as a result of reeling in can be undone all at once.

Creature Mechanics
fish, jelly, skele
Fish will be killed if you pull them into the stingers of a Jellyfish. Jellyfish and their stingers can’t be caught or moved. Killing a fish leaves behind a Skeleton which you can then pull through stingers to cut them, removing that part of the stingers and all attached parts below. Reeling in a Skeleton doesn’t reduce the Fish counter.

crab
The hook can pass through blocks of Debris but most creatures are too soft to be pulled through. The hard carapace of a Crab can smash the debris, but catching a crab reduces your maximum line length by 5 (pincers and fishing line don’t mix well). Line length is reset to 30 at the start of every level.

squid, ink, sponge
Squid leave a trail of 5 Ink blocks behind them. Most creatures can’t be pulled through ink, but Sea Sponges can absorb one block before becoming saturated. Once saturated a sea sponge can’t be pulled through any more ink. Like debris, ink can be used to intentionally drop creatures.

patrol
Patrols move when you move, including while reeling in. Each ‘tick’ they will either swim forward one tile or turn around if there’s something in front of them. Like you, they can’t cross your fishing line. They can’t be caught when camouflaged in Seaweed and trying to pull one through seaweed will cause you to drop it. Like regular fish, trying to pull a patrol through jellyfish stingers will kill it and leave behind a skeleton. Skeletons can be used to cut seaweed in a similar way to stingers, removing that part of the seaweed and all attached parts above it.

Undo
undo
There is an unlimited undo. Each press of the undo button will undo all actions that occurred as a result of pressing a different button (i.e. there is a 1:1 relation between the number of gameplay button presses to do something and the number of undo button presses to undo it). For example in the GIF above, after hooking the skeleton the reel-in button is pressed which causes the hook and skeleton to move many places and cut the jellyfish stingers and seaweed; all of these are undone together as they correspond to a single input (reel in).

Putting it all together
Here’s the full solution for one of the later levels which includes many of the mechanics:


The first thing to realise is that the catch counter only has one Fish; it doesn’t matter what else you catch. To get the fish you need to clear the Jellies which means you’ll have to sacrifice the Patrol and use it’s Skeleton to cut the stingers. You can catch it straight away from underneath but then you’ll just drop it in the Seaweed, so you need to clear the Debris out of the way and catch it from above. There’s a Crab in the corner but the fish is in the way… Luckily you can use the Ink that the Squid leaves behind to intentionally drop the fish after moving it, giving access to the crab.

Bosses
Levels 10, 20 and 30 are special Boss puzzles with large unique creatures to catch. There are four boss creatures as the final level has two. These levels are generally about clearing a path big enough for you to catch the boss.

Progression
There are 30 levels in the game and 27 of them are unlocked immediately. The boss levels (10, 20 and 30) require you to complete n-1 levels to unlock them, i.e. you need to complete 9 levels to unlock level 10, 19 for 20 and 29 for 30. Note that these can be any levels, including levels after that boss. For example let’s say you complete levels 1-8 but get stuck on 9, you could then complete level 11 (or any other level) to unlock 10 and face the boss. But you can only unlock the final level by completing all 29 preceding ones. This friendly unlock system solves the frustrating issue of getting stuck in a level-based puzzle game and not being able to progress in any way.

In this example the player has completed 2 levels and needs to complete 7 more to unlock level 10 and see the mystery boss marked as a ? on the minimap:

As well as acting as goals and progression markers, the bosses also serve as rewards in their own right. From the main menu you can access the Aquarium, which shows silhouettes of all the creatures and fills in as you encounter and solve puzzles featuring each one. Since the bosses have much larger and more detailed art than regular creatures they look great as trophies in the aquarium.

You can follow development of the game on Twitter @rhythm_lynx


#5

Fantastic write-up, Connor—thanks for sharing! Really looking forward to the mobile release :heart:


#6

I love Pico 8 and I really enjoyed playing this game. Level 17 deserves special mention as it took me forever to finally figure it out, but I loved every second.


#7

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!


#8

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!


#9

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.


#10

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


#11

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


#12

#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.


#13

I wanna play it already :wink:


#14


:heart_eyes:


#15

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?


#16

#4: Bootstrapping, menu transitions and level loading

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

transitions_small

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.

state_controller

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

function init(self)
	load_save_data()
	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")
end

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

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
			begin_settings_state("language")
		else
			msg.post(sender, "disable")
		end
		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
			end
		end
	end
end

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.

transitions

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...
	end
	
	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...
	end
end

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:

example_tiles

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)
	go.delete_all(self.minimap_tiles)
	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
						break
					end
				end
				msg.post(id, "play_animation", { id = hash(anim) })
			end
		end
	end
end

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:

g_SPAWN_TILES = {
	 [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)
	end
	reset_tiles = {}
	
	for i = 1, #g_creatures do
		go.delete(g_creatures[i].id, true)
	end
	g_creatures = {}

	collectgarbage()
end

local function load_level(level_num)
	unload_level()

	-- 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")
	end
	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
					break
				end
			end
			
			if fact == nil then
				prev_creature = nil
			else
				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
					end
				elseif type == "sponge" then
					-- ...etc...
				end

				prev_creature = g_creatures[#g_creatures]
			end
		end
	end
end

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


#17

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!


#18

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


#19

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


#20

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