Part 1
Commits a72332d
A ghost of your last three seconds
Echoes is a side-scrolling puzzle platformer in LÖVE. This post covers the first build: one mechanic, five levels, and the data structures the mechanic needed.

The echo
You play a small amber lantern-spirit. Press X and a cyan ghost of you appears where you were three seconds ago and does what you did. When the replay runs out it holds still for 4 seconds, then fades. The ghost is solid on top, so you can stand on it, and it carries you while it moves. It also holds down pressure plates, and a held plate keeps its door open. You get 2 at a time.
That turned out to be enough to build puzzles from. A plate you would have to stand on yourself can be held by your past self. A ledge too high for one jump takes two, because the ghost of the first jump is a step.

Writing the data down first
A replay is only worth trusting if the same inputs always put you in the same places. Loose state breaks that, so the data came before the logic: what a level is, what a tile is, and what a recording is.
Levels are ASCII maps. # is stone, ^ is spikes, = is a one-way ledge, and lowercase a to d are the plates for doors A to D. Editing a level means editing text.
Every tile kind is a row of flags in one registry, so collision, rendering and lighting don’t each grow their own if kind == 'spike' then ... elseif ... chain:
M.TILES = {
solid = { solid = true, static = true },
oneway = { oneway = true, static = true },
spike = { hurts = true, static = true },
torch = { light = 64 },
goal = { light = 78, goal = true },
plate = { plate = true },
door = { solid = true, openable = true },
}
Collision asks TILES[c.kind].solid and the light pass asks for light. When three new tiles came along in part 3, each one was mostly a new row here.
The recorder is a ring buffer of 180 frames. The simulation steps at a fixed 1/60 s, so three seconds is always exactly 180 frames. Each step overwrites the oldest slot and reuses its table:
function Echo.record(rec, x, y, facing, state)
rec.head = rec.head % Echo.FRAMES + 1
local f = rec.buf[rec.head]
if not f then
f = {}
rec.buf[rec.head] = f
end
f.x, f.y, f.facing, f.state = x, y, facing, state
rec.count = math.min(rec.count + 1, Echo.FRAMES)
end
A cast copies the buffer out, oldest frame first, so nothing you do afterwards can change a ghost that’s already walking.
The player and the scenes are state machines. The player is ground, air, dead or cleared. Doors are never toggled. Every step, each door looks at whether anything is standing on its plate. A toggled flag can miss an event and drift. A door recomputed from the world every step can’t.

No image files
The first build has no PNGs and no sound files. Everything is love.graphics primitives on a 480x270 canvas scaled up 2x. Behind the level are three parallax layers: ruined arches, hanging chains and vines, and drifting dust. Stone has a bevel and moss on top. The flame leans with your velocity and squashes and stretches. A lightmap canvas darkens the scene and cuts light out around the player, each echo and each torch. Sounds come from love.sound.newSoundData, filled one sample at a time.


Act one
The first five levels each add one idea:
- Wake: movement, gaps and spikes.
- First Echo: a plate and a door.
- Stairway: an echo of your jump becomes a step.
- Two Minds: two plates, two doors, two echoes.
- Ascent: a tall climb that uses all of it.

Keeping every level beatable
It’s easy to break a platformer without noticing. Change the jump height a little and a ledge three levels away goes out of reach.
So each level ships with a scripted solution, made of segments like “hold right until x is 202, then jump”. The game can play every solution to the goal with no window open, because the simulation doesn’t know about rendering or the keyboard. The same run also checks the physics directly: jump height, coyote time, spikes, plates, doors, and being carried by an echo. A tuning change that cuts a level off shows up as a failed solution, not as a stuck player.