Game Dev: A Big Hack

Another one for the graveyard. Why this game didn't work

~7 min read

It happened again. With the best intentions in the world I was going to make a simple game. Alas my large graveyard of games should have been all the warning I needed, but scope creep, grandiose ideas and worldbuilding found their way back in. Still, surely there was something to learn here!?

Player sneaking past guard with terminal open

The game, and where it got out of hand

A 2D puzzle platformer where you use your trusty terminal to enter commands to hack devices on a network and break into physical locations, avoiding guards and defence systems. Inspired by the fantastic Mr. Robot series and the Watch Dogs games.

This started with a small idea; giving the player access to the terminal commands you find in lots of games. I remember spending hours in The Elder Scrolls: Oblivion running commands to build the ultimate character and thought it would be an interesting hook, to give a player access to this kind of developer tooling to solve puzzles.

The scope creep was almost immediate:

  • I built a story around the concept.
  • I had to build a world for the story, complete with anti-consumerism messaging, factions and a government to overthrow.
  • I wrote characters and dialogue to help and hinder your journey through the game.

Rather than getting a playable prototype together, I couldn’t bring myself to show anyone until it ‘made sense’ as a game. That’s clear evidence I hadn’t found the core game loop that’s immediately fun, so I kept adding visuals, mechanics, and at one point, dialogue.

Takeaway 1: FIND THE FUN

Systems thinking

At the heart of the game is a system, consisting of a set of small, composable pieces. A level has a Network. A network contains Devices (doors, lifts, laser emitters, speakers, workstations). Each device is then built from child nodes that answer one question each:

  • Can I find it? A TriangulationStrategy
  • Can I connect to it? A ConnectionStrategy
  • What stops me getting in? A SecurityDispatcher with one or more SecurityProtocols
  • What can I do once I’m in? One or more Commands

For example, here’s what a door looks like in a level once it’s assembled. There’s no door-specific code for any of this (finally an example of an easy ‘Door Problem’). It’s all nodes dropped onto the door in the editor:

Network
└── Door                          (device_name = "EntryDoor", exposure_value = 3)
    ├── DistanceLocateDeviceStrategy   max_distance = 500
    ├── DistanceConnectionStrategy     max_distance = 100
    ├── SecurityDispatcher
    │   └── PasswordSecurityProtocol   password = "..."
    ├── DeviceCommand  "open"  -> open_door
    └── DeviceCommand  "close" -> close_door

The base Device just looks at its children when it starts and wires up whatever it finds:

class_name Device
extends Node2D

signal increase_exposure(exposure_value: int)

@export var device_name: String = "Generic Device"

var commands: Array[Command] = []
var locate_strategy: TriangulationStrategy = null
var connection_strategy: ConnectionStrategy = null


func _ready() -> void:
	for child in get_children():
		if child is Command:
			commands.append(child)

		if child is DeviceCommand:
			child.device_command_execute.connect(_on_device_command_execute)

		if child is ConnectionStrategy:
			connection_strategy = child

		if child is TriangulationStrategy:
			locate_strategy = child
	# ...


func is_connectable(character: Character) -> bool:
	if connection_strategy:
		return connection_strategy.is_connectable(character)
	return true

That means a new device type is just a scene plus a script with its behaviour. All the networking, scanning, connecting and security can be added as strategies to any device.

The systems also fed into each other. Every network keeps an exposure value. Noisy hacking (opening a door with nobody there, failing a password, skimming funds) raises it, and it slowly decays over time:

class_name Network
extends Node

@export var exposure: int = 0
@export var exposure_decay_amount: int = 5
@export var exposure_decay_rate: float = 30.0

@onready var devices: Array[Node] = find_children("*", "Device", false)


func _ready() -> void:
	for device: Device in devices:
		device.increase_exposure.connect(_on_device_increase_exposure)
	# ... start a repeating timer that calls _on_exposure_decay_timeout


func _on_device_increase_exposure(exposure_value: int) -> void:
	exposure += exposure_value


func _on_exposure_decay_timeout() -> void:
	exposure = max(0, exposure - exposure_decay_amount)

Each SecurityProtocol has a threshold. When you try to connect, the device’s dispatcher picks the strongest protocol your current exposure has unlocked:

func get_maximum_security_protocol(exposure: int) -> SecurityProtocol:
	var max_protocol: SecurityProtocol = null
	var max_threshold: int = -1
	for protocol in protocols:
		if protocol.threshold <= exposure and protocol.threshold > max_threshold:
			max_protocol = protocol
			max_threshold = protocol.threshold
	return max_protocol

So a door might let you straight in while you’re quiet, but once you’ve been sloppy it starts asking for an admin password.

Once these pieces existed, when building a level I could take my programmer hat off, and put my level designer hat on. Pick some devices, give them strategies and security, and you have a puzzle. Rinse and repeat until you have a game!

This is where I could move fastest and if I had stopped with these systems and built as many puzzles as I could in say, a week, then showed it to as many people as possible, I probably would have had a better idea if it was fun or not.

Takeaway 2: Build the system, then use it to churn out levels fast and get feedback before adding anything else.

Callable functions: the terminal runs real game code

Terminal commands were written to execute the functions of a device directly, much like you would expose the properties and methods of your game objects in a developer tool.

A DeviceCommand maps what the player types onto a method name on its parent device:

class_name DeviceCommand
extends Command

signal device_command_execute(command_identifier: String, terminal: Terminal, params: Array)

@export var target_function: String = ""


func execute(terminal: Terminal, _network, params: Array) -> void:
	var function_name = target_function if target_function != "" else command_identifier
	device_command_execute.emit(function_name, terminal, params)

And the device dispatches it dynamically. If the method exists, call it:

func _on_device_command_execute(command_name: String, terminal: Terminal, params: Array) -> void:
	if has_method(command_name):
		call(command_name, terminal, params)
	else:
		terminal._write_error("Error '" + command_name + "' not found")

So on the door scene, the command open points at open_door, and the door script just has to implement it:

func open_door(terminal: Terminal, _params) -> void:
	if is_open:
		terminal._type_text(device_name + " is already open.\n")
		return

	is_open = true
	animation_player.play("open")
	terminal._type_text(device_name + " is now open.\n")

	if exposure_value > 0:
		terminal._type_text("[EXPOSURE] opened without operative present", "exposure")
		increase_exposure.emit(exposure_value)

The same pattern gives you goto [floor] and lsfloors on a lift, off / cycle [rate] on the laser emitters, and play_sound / loop on speaker panels, which emit a noise that guards can react to. Each one is a few lines on the device and a node in the editor.

When you connect, the terminal swaps its command list to that device’s commands plus anything flagged globally_scoped, so the prompt changes to (EntryDoor) > and help only shows what the scoped device can actually do.

Help Command Output

This approach wasn’t much of a time-saver. I still had to define the functions and map the commands. However, this was an area where I planned to get meta. Initially, you would only have access to a certain level of command, but eventually I was imagining giving the player access to the methods and properties on the devices regardless of whether there is an explicit command for them, giving the player the ability to change properties that would have real effects in game.

Did I need this to prove the game? Absolutely not.

Takeaway 3: YAGNI (You Aren’t Gonna Need It). Don’t build complexity until you need it, which, to my shame, is something I advocate for regularly, but didn’t apply here!

Wrapping up

So another one goes into my “graveyard” folder, but not empty-handed. The systems I built are the part I’m most proud of. Devices, networks, strategies and security protocols snap together into puzzles without new code, and that’s the bit I’ll take with me into the next project. The mistake was building everything around that system before I knew it was any fun, and then piling on clever extras that weren’t helping me find it.

Next time the focus will be to find the core loop first by building a simple system, using it to churn out a handful of rough levels in short order, and putting them in front of people before a single line of dialogue is written. I’ll attempt to start as low on the complexity scale as I can get away with, only adding to it in the service of making the game more fun, ideally with real play testing as a metric. Sometimes, if it’s not fun, no amount of worldbuilding will save it, and I definitely held on to this one for too long before taking it behind the shed.

Contact

Got a system
to parse?

Architecture, data, tooling or games: if you'd like to talk, get in touch.