↓
iaen.es Devlog Leer en español

Gotic · 4 min read

A game built from mods in Godot 4: switch them on, off, or delete the folder without breaking anything

Gotic is 31 mods you toggle from a menu and remove by deleting their folder. Keeping the game working was easy; keeping the test suite working was not.

#mods#AI agents#testing#godot

A game built from mods in Godot 4: switch them on, off, or delete the folder without breaking anything

In Gotic there is no "main game" plus extras. There's an island, a character who walks, swims and jumps, and an inventory. Everything else is a mod: the villages, the broom, fishing, the farm, day and night, the photo camera. There are 31 today. On the title screen, MODS lists each one with its switch, a description and what it depends on, and you can also change them mid-game.

There's a blunter way to remove a mod: delete its folder. The game still has to start, and the save must not lose what that mod had achieved.

How it's built

Every folder in modulos/ with a modulo.gd is a mod, discovered at startup:

static func available_modules(dir := "res://modulos") -> Array[String]:
	var result: Array[String] = []
	if not DirAccess.dir_exists_absolute(dir):
		return result
	for folder in DirAccess.get_directories_at(dir):
		if ResourceLoader.exists("%s/%s/modulo.gd" % [dir, folder]):
			result.append(folder)
	result.sort()
	return result

It asks ResourceLoader rather than FileAccess because inside the APK scripts are compiled (a .gdc plus a .remap) and FileAccess wouldn't see modulo.gd.

A mod is a node with four functions: setup(world), save_data(), load_data(data) and teardown(). It never touches main.gd; it uses the game's hooks (keys, touch buttons, map markers, USAR (use), bonuses, inventory). Dependencies are plain text, REQUIRES = ["aldea"], never a preload from another folder, so removing one doesn't cause parse errors in the rest.

Mods don't know about each other either: cooking asks every active mod whether it has ingredients(), so fishing and farming feed the kitchen without knowing it exists. When a mod is switched off, its saved data is kept aside, even if its folder is gone: bring it back and its records return.

Island map with its marker legend
With all 31 switched on. Every marker except the chest's belongs to a mod and leaves with it when it's switched off

What went wrong

Freeing the node isn't enough. The broom rides on the player's back, the flower crown sits on their head and the buttons live in the HUD: none of that hangs from the mod's node, so none of it leaves with it. teardown() has to remove all of it before anything is freed.

Modular in the game wasn't modular in the tests. Deleting a mod's folder worked while playing, but tests/run.sh failed: shared tests named specific mods (main.modules["carrera"]) and the race used the broom as "something that flies". The fix:

  • Shared tests walk through whatever mods exist without naming any. One switches each mod on and off and compares a "footprint" of the world (interactions, markers, buttons, tree-free clearings, nodes attached to the player and the HUD): re-enabling must not duplicate anything and disabling must leave no trace. When one of the broom's cleanup steps was removed on purpose, that test caught it.
  • Each mod's own tests live in its folder (pruebas.gd). If a dependency is missing, those tests are skipped with a warning.
  • Dependencies are tested with three fake mods.
  • And it was checked for real, by moving folders out: with the seven mods of that time, 583 checks; with none, 331; without each one, still PRUEBAS OK.

How the agents work

Almost all the code is written by AI agents (Claude) following rules kept in the repository: a plan before starting (PLAN.md: tasks, decisions and what will not be done), one commit per task explaining why, and tests/run.sh ending in PRUEBAS OK before every commit. Every bug gets a new test that must be seen failing with the fix removed. When one of those mutations survives, a test is missing… or there's code that does nothing: removing a line from new_game() broke nothing because it was redundant, so it went.

Folders also split the work. When two Claude sessions ran at once, they shared the temp folder and Godot's user data, and one session's tests trampled the other's test saves. Now each gets its own worktree and XDG_DATA_HOME, they divide the work by mod folder and git add always takes explicit paths.

Still unverified

On a phone (a Samsung S24 Ultra) the title screen runs at 60 FPS, and MODS, "back" and JUGAR (play) responded to taps simulated through adb. But I haven't toggled a single mod on the phone, since that would have changed the real save, and I haven't tried it with actual fingers.

More from Gotic →