Second Survey

Jul 2026 – Present

A game I am building on my own · GameMaker (GML) · Godot / GDScript · Python · Linux

My lighting system running in-engine. Lights draw into their own surface that gets multiplied over the finished frame, which lets every room decide how dark it starts out. The shaft is its own kind of light, with dust drifting through it.

Lighting pipeline

The engine already had a lighting controller, but it could not do what I wanted for this area, so I wrote a second one that runs alongside it. Lights are invisible objects that only draw into an off-screen surface, and once the frame is finished that surface gets multiplied over it. Point lights, grate beams, and water glow are all separate kinds of light feeding the same pass. I generated the falloff sprites and the animated water in Python instead of drawing them, since a radial gradient and a texture that scrolls exactly two pixels per loop are really just math.

Movement systems

Jump pads. Every pad in the area runs the same function, so the party hops together and you get control back the moment the leader lands instead of waiting on everyone else.

I kept rewriting the same hop cutscene every time I placed a pad, so I pulled it into one function. A pad now only stores which marker it points at, and the shared routine takes care of the rest: the staggered launches, the landing, and putting the party back in a line. Handing control back early matters more than it sounds like it would. Waiting for the last follower to land made every jump feel sluggish.

scripts/jumppad.gml
function jumppad_trigger_run(_land_id) {
    var _land = marker_getpos("jump_land", _land_id);
    if (is_undefined(_land)) { triggered = false; exit; }

    cutscene_create();
    cutscene_player_canmove(false);
    cutscene_party_follow(false);

    var _names = global.party_names;

    // Everyone launches at (almost) the same time, a few frames apart so
    // the sprites don't perfectly overlap.
    cutscene_func(method({ _names, _land }, function() {
        var _leader = party_get_inst(_names[0]);
        if (instance_exists(_leader))
            actor_move(_leader, [ new actor_movement_jump(_land.x, _land.y, true, 10) ]);

        for (var _i = 1; _i < array_length(_names); _i++) {
            var _follower = party_get_inst(_names[_i]);
            if (!instance_exists(_follower)) continue;
            call_later(_i * 3, time_source_units_frames, /* ... */);
        }
    }));

    // Hand control back the instant the LEADER has landed (its mover is gone).
    cutscene_wait_until(method({ _names }, function() {
        var _leader = party_get_inst(_names[0]);
        with (o_actor_mover) if (character == _leader) return false;
        return true;
    }));
    cutscene_func(function() { cutscene_player_canmove(true); });

    cutscene_play();
}

Wind zones hook into the same system by catching a jump that is already happening. I did not want to special-case wind inside the pad code, so a wind zone just grabs the running tweens and re-aims them. The two mechanics end up combining without either one knowing the other exists:

scripts/wind.gml
/// @desc One-shot redirect of a mid-air jump toward (_tx, _ty). Stops the
///       running x/y tweens and restarts them from the actor's CURRENT
///       position. Extends the jump if needed to leave at least `_curve`
///       frames and gives it a fresh lift arc over that window, so the
///       catch reads as a graceful curve rather than a kink.
function jump_redirect(_mover, _tx, _ty, _curve = 16) {
    if (!instance_exists(_mover)) return;

    with (_mover) {
        if (step < 0 || step >= array_length(seed)) return;
        if (seed[step] != "jump") return;

        if (anim_x != undefined) anime_stop(anim_x);
        if (anim_y != undefined) anime_stop(anim_y);

        // guarantee room for a smooth curve; extend if the catch is late
        var _remain = time[step] - timer;
        if (_remain < _curve) {
            time[step] = timer + _curve;
            _remain = _curve;
        }

        xreq[step] = _tx;
        yreq[step] = _ty;
    }
}

Silhouette shader

A post-processing pass that strips the scene down to outlines. Throwing away the interior detail is the whole point: you can tell something is there well before you can tell what it is.

This is a fragment shader, not redrawn sprites. It sorts every texel into foreground or background by brightness, then looks at all eight neighbours: a texel only draws a line if at least one of them is background. Anything fully surrounded stays black. I was originally going for a fog and static effect, could not get it to work, and ended up liking the plain outlines more. One uniform dims the whole pass during chases, so I did not need a second shader for that:

shaders/sh_soft_fog.fsh
// Anything brighter than this counts as a "shape" against the
// near-black background.
const float FG_THRESHOLD  = 0.07;
const vec3  OUTLINE_COLOR = vec3(0.45);

// 1.0 if the texel is part of a shape, 0.0 if it's background.
float fg(vec2 uv) {
    return step(FG_THRESHOLD, luma(uv));
}

void main() {
    vec2 t = 1.0 / u_resolution;
    float c = fg(v_vTexcoord);

    // 8 neighbour foreground mask. The outline is drawn ONLY where a
    // foreground texel borders the background, i.e. the outer
    // silhouette. Interior texels draw no line, so internal detail
    // stays hidden.
    float ring =
        fg(v_vTexcoord + vec2( t.x, 0.0)) * fg(v_vTexcoord + vec2(-t.x, 0.0)) *
        fg(v_vTexcoord + vec2( 0.0, t.y)) * fg(v_vTexcoord + vec2( 0.0,-t.y)) *
        fg(v_vTexcoord + vec2( t.x, t.y)) * fg(v_vTexcoord + vec2(-t.x, t.y)) *
        fg(v_vTexcoord + vec2( t.x,-t.y)) * fg(v_vTexcoord + vec2(-t.x,-t.y));

    float edge = c * (1.0 - ring);
    float k    = max(1.0 - 0.5 * u_darken, 0.2); // chase darken

    vec3 base = mix(bg, vec3(0.0), c);
    vec3 col  = mix(base, OUTLINE_COLOR, edge) * k;

    gl_FragColor = vec4(col, 1.0);
}

What else I handle on this

  • I maintain my own fork of the engine in GML, including enemy attack patterns, the battle system, and the lighting above.
  • I built the companion website in Godot and GDScript and host it myself on Linux, with a tunnel, a systemd service, and a deploy script.
  • I wrote Python tooling to generate the lighting sprites and animated water instead of drawing them frame by frame.
  • When I notice I am writing the same thing twice, I pull it into a shared system and document it, which is how the jump pads above happened.
  • Everything from version control to the server to packaging builds is mine, since it is just me on this.