This is the first devlog for the Forgotten Paths rebuild. If you missed the announcement, we're turning the GMTK 2025 jam entry into a full game, and we're starting from scratch to do it right. The full context is in the announcement post.
The New Structure
The biggest lesson from the jam version was that clean, extensible code makes everything easier. The jam gave us 96 hours so shortcuts were taken. This time we don't have that excuse, and we're building the backend right from the start.
At the core of the new version is the BattleEngine.
It's the main backend manager that drives everything.
It handles the game loop, processes tiles and entities each tick, tracks the player's command budget, and determines win/lose conditions.
The tick order is always tiles → enemies → player, and every tick returns an array of GameEvents instead of directly mutating the scene.
This means the UI and logic are fully decoupled - the engine doesn't care how anything gets displayed, it just processes state and emits events.
This also lets us run the engine headless for the Simulator, which I'll get to later.
This type of decision somewhat goes against the grain of how Godot projects are often structured, with a lot of logic embedded in scenes and nodes. In this case, we're using Godot more as a rendering and input layer, while the core game logic lives in pure GDScript classes that don't depend on the scene tree at all. There are definitely pros and cons to this approach (might make a post about it later), but it works good for what we're doing so far, and it also enables us to use a simulator to easily check our levels. One of the problems with the jam is that there was so many ways to solve and do levels, it made it hard to build and make good puzzles.
The grid itself is managed by the GridManager, which uses two dictionaries instead of 2D arrays.
One mapping positions to tiles, one mapping positions to entities.
Dictionary-based grids are just more flexible for this kind of puzzle game.
Sparse maps, irregular shapes, and fast lookups all work naturally.
The tile grid is set once when a map loads and never changes.
The entity grid is the live, mutable state.
Maps are defined by MapData resources - plain Godot .tres files that describe grid dimensions, tile spawns, entity spawns, the player's starting position, and constraints like the command budget and max loop length.
The GridManager loads these at the start of each level to populate its dictionaries.
Here's the core tick loop:
# tick() is the core loop. call this once per game step.
# tick order: tiles -> enemies -> player
func tick() -> Array[GameEvent]:
if is_terminal:
Clogger.warn("ticking after a terminal event")
return []
var events: Array[GameEvent] = []
events.append_array(_process_tick_tiles())
if _check_terminal(events): return events
events.append_array(_process_entities())
if _check_terminal(events): return events
var cmd: Command = player.current_command()
events.append_array(_process_player())
if _check_terminal(events): return events
# deduct the cost of the command that just ran
if remaining_budget >= 0 and cmd != null:
remaining_budget -= cmd.cost
if remaining_budget <= 0:
remaining_budget = 0
events.append(GameOverEvent.new(GameOverEvent.Reason.BUDGET_EXHAUSTED))
return events
The Command Loop
The core mechanic of Forgotten Paths is the command loop. The player doesn't directly control their character tick by tick. Instead, they set up a sequence of commands that the character then executes repeatedly in a cycle. Getting that loop right is the puzzle.
In the new codebase this is modeled with a proper Command base class that each command type extends.
Right now we have MoveCommand, RotateCommand, and WaitCommand to mimic the behavior of the original jam version.
Each one has an execute() method that takes an entity and the grid and returns a list of GameEvents.
This pattern makes adding new commands straightforward - you create a new class, implement execute(), and it plugs right in without touching anything else.
We have a long list of commands we want to add, and this structure means we can do that without breaking existing behavior.
MoveCommand supports all four directions and is aware of the player's current rotation, so "move up" might actually move the player right if they've been rotated 90 degrees clockwise.
RotateCommand changes that rotation state.
WaitCommand is a simple no-op, which will be useful for timing-based puzzles later.
Each command also has a cost field that gets deducted from the map's budget when it runs, making some actions more expensive than others.
One other intentional design choice: Command.execute() takes an Entity, not specifically a Player.
This means we can reuse the same command system for enemy behavior.
If we want an enemy that moves in a pattern, we give it a command loop and let it run.
The snippet below shows how MoveCommand uses this - rotation is applied only when the entity is a Player, while enemies just move in a fixed direction.
# Adding a new command is as simple as extending Command and implementing execute().
# Nothing else in the engine needs to change.
extends Command
class_name MoveCommand
var direction: Consts.Direction
func execute(entity: Entity, grid: GridManager) -> Array[GameEvent]:
var target: Vector2i
if entity is Player:
# rotation is applied here, so the loop's directions are relative to
# the player's current facing - not the world grid.
var actual_dir = RotationHelper.rotate_direction(direction, entity.current_rotation)
target = GridHelper.get_movement_in_direction(entity.position, actual_dir)
else:
target = GridHelper.get_movement_in_direction(entity.position, direction)
return grid.move_entity(entity, target)
# factory methods keep loop construction readable at the call site
static func up() -> MoveCommand: return MoveCommand.new(Consts.Direction.UP)
static func down() -> MoveCommand: return MoveCommand.new(Consts.Direction.DOWN)
static func left() -> MoveCommand: return MoveCommand.new(Consts.Direction.LEFT)
static func right() -> MoveCommand: return MoveCommand.new(Consts.Direction.RIGHT)
Tiles & Entities
Both tiles and entities follow the same philosophy: a base class that defines the interface, and subclasses that override specific behavior.
The BaseTile class has two overridable methods - is_passable() and on_entered().
Most tiles are passable and do nothing when stepped on, but some override those to create walls, traps, portals, and so on.
Currently we have BlockedTile (the rocks and walls from the jam), PortalTile (teleports the entity to its paired portal on entry), and TickTile (a base class for tiles that need per-tick behavior, like traps).
Most tiles don't need that, but having the base class lets us support more complex tile behavior without bloating the BaseTile interface.
On the entity side, BaseEnemy extends Entity and adds the concept of being alive and an on_entered() callback for when the player walks into it.
The first concrete enemy type is StaticEnemy, which just stands there and dies when the player bumps into it - same as the jam.
But now the system is ready to support enemies that move, have patterns, and react to rotations.
We'll get there.
The Simulator
This is probably my favorite part of the new codebase.
The Simulator wraps the BattleEngine and lets us test command loops programmatically.
You give it a map and a loop, it runs the engine until there's a win, loss, or timeout, and returns a SimResult describing what happened.
The really useful function is simulate_all().
It generates every possible command loop up to the map's max_loop_length using base-N enumeration over the command palette, runs each one, and collects every winning loop.
This lets us verify a map is solvable and find all solutions.
If a map has 50 winning loops when we intended 2 or 3, the puzzle is too loose and needs more constraints.
If it has zero, we broke something.
It's essentially a built-in puzzle validator. It means we can design levels with far more confidence than we had during the jam, where we were mostly just playtesting by hand. It's going to be indispensable as the puzzle complexity grows.
# simulate_all() brute-forces every possible loop up to max_loop_length.
# returns only the winning SimResults so we can audit solution counts per map.
func simulate_all(max_ticks: int = 100) -> Array[SimResult]:
var max_length: int = _engine.grid._loaded_map.max_loop_length
var palette: Array[Command] = [
MoveCommand.up(), MoveCommand.down(),
MoveCommand.left(), MoveCommand.right(),
RotateCommand.r90cc(), RotateCommand.r270cc(), RotateCommand.r180(),
]
var wins: Array[SimResult] = []
for length in range(1, max_length + 1):
var combo_count: int = int(pow(palette.size(), length))
for i in range(combo_count):
# base-N enumeration to get the loop for this combo index
var loop: Array[Command] = _index_to_loop(i, palette, length)
var result: SimResult = simulate(loop, max_ticks)
result.loop = loop
if result.outcome == SimResult.Outcome.WIN:
wins.append(result)
return wins
What's Next
The backend is in a solid place.
The BattleEngine ticks correctly, the Simulator validates maps, entities and tiles are extensible, and the event system keeps everything decoupled.
What doesn't exist yet is anything visual - there's no renderer, no UI, no actual Godot scene to run it in.
That's the next big chunk of work.
Beyond that, we have a long list of things to build: more enemy types, more tile types, a proper level editor, a UI for building the command loop, and eventually a full campaign. There's a lot to do, but for the first time it actually feels like the foundation is solid enough to build on top of.
I'll keep posting these as progress gets made. If you played the jam version and have thoughts, feel free to reach out on our Discord.
Rat Haven Studios