·····
← ~/devlogs

Devlog 01: the door problem

Doors are where games go to die.

Ah, doors.

There’s a well-known essay in gamedev about exactly this topic. The short version is that the moment you add a door to a game, it reaches into movement, physics, AI, line of sight, networking, and level design all at once, and each of those wants something slightly different from it.

a door is never just a door

A closed door in Project SCRAM has to hold down a few jobs at once. It blocks movement, stops projectiles, and occludes sight.. which matters now that you can’t see the whole level at once. It also has to open for the AI pawns without opening for everything, so an enemy is never walled into a room it’s too dim to leave.

The door object itself is small. It is either open or closed, it latches open once triggered, and the only thing that physically changes is whether its collider is in the world:

private void ApplyState()
{
    var closed = !IsOpen;
    if (Panel is not null)
    {
        Panel.Visible = closed;
    }

    if (Blocker is not null)
    {
        // Collision is present only while the door is closed (a closed door is a physical obstacle).
        Blocker.Disabled = !closed;
    }
}

That closed collider is the same body that stops projectiles.. so “what blocks my shot” and “what blocks my path” are one wall, with no second system to keep in sync.

Opening these doors splits two ways. The AI open doors automatically: each door has a trigger volume that only senses the AI layer, so anything that walks into it is by definition an enemy. The host player’s game opens the door and then conveys that state to every client. On the other hand, there are human players, who open a door with a deliberate key press. This means that the player must commit to opening a door without knowledge of what’s behind it first. For this game at least, a door you have to choose to open is a better door than one that slides automatically for you.

stitching the rooms together

The more difficult part of the door problem was navigation. Levels are not a single authored entity. They are rooms assembled from a pool per seed, so the surface the AI walks on doesn’t exist until the mission is built. Each room ships with its own navmesh baked in the editor, and at runtime the host joins them into a single graph by dropping one bidirectional nav link across each doorway. Only the host navigates. Clients build the same rooms but switch their regions off and never path.

A link ends up being just two points, one a short step into each room, centered on the doorway:

internal static (Vector3 Start, Vector3 End) Endpoints(Transform3D doorMarkerGlobal, float inset)
{
    var center = doorMarkerGlobal.Origin;
    var interior = doorMarkerGlobal.Basis.Z.Normalized();
    return (center + interior * inset, center - interior * inset);
}

Less trivial than it looks.. the door marker faces out of the room along its local axis, so the same vector pushed one way and its negative the other gives a segment that crosses the opening square to the wall, inset just far enough to land on the baked navmesh on each side. Get the axis or the offset wrong and nothing throws an error, but the rooms simply never connect, and the AI quietly treats half the level as off-limits.

I also found that Godot will happily auto-connect two navmesh regions whose edges happen to touch, which sounds helpful right up until two coincident doorways get a free, ungated connection you never asked for and can’t later close. So that’s switched off. Doorways connect through explicit links and nothing else, which has the nice side effect that sealing a door later is just disabling its link.

~/scram/captures/navmesh-stitch.png
Debug navigation view: several assembled greybox rooms, each with its own navmesh region in a different shade, joined by short link segments drawn across each doorway.

the doorway shuffle

While the rooms are stitched now, the AI still can’t walk through them. They’d path up to a doorway, stop, and rotate in place like they’d lost the thread, then give up.

At this point, each room’s navmesh bakes a hair above the floor, the agents stand on the floor, and the check that decides “have I reached this waypoint” was counting that little vertical gap as distance. Out in the open it doesn’t matter, but at a doorway, where the link drops two waypoints close together, the agent kept landing between them, deciding it hadn’t quite reached either, and flip-flopping forever without committing to the crossing. This resulted in AI pawns getting stuck in a comical, never-ending spin believing they had arrived.

Turning on path simplification and loosening the arrival distances fixed it: collapse the redundant waypoints, and stop measuring arrival so tightly that a few centimeters of bake height reads as “not there yet.” While I was in the navigation agent I found another default that needed some tinkering:

// Godot's default cap is 3m, which strands a cross-room path before it reaches the target.
_navigationAgent.PathMaxDistance = 0f;

To keep this from quietly coming back, there’s now a test that assembles a level, drops an AI on one side of a doorway, points it at the room on the other side, and fails if it can’t get there. Before the fix it stalled at the threshold. After, it crosses. It also counts its patience in physics frames rather than seconds, because a loaded CI machine runs the simulation slow and a wall-clock timeout kept failing a test that was actually fine.

what you can see

The last big piece this week was sight, and it shaped the door work more than anything else did. The current vision system decides what you get to see, sorting the level into three states. What’s in your line of sight right now is live. Rooms you’ve already moved through drop to a washed-out “memory”, accurate to the last time you looked but blind to anything that’s moved since. Everywhere else is just black, and an enemy could be one room away without you knowing until the door opens.. at least until spatial audio makes it into the project.

~/scram/captures/vision.webm

My work with doors and the vision state system thankfully wound up in the same week. A door that stopped your body but not your eyes would let you scout a whole room through a wall you can’t walk through, and choosing to open it would stop meaning anything. So sight stops at the same walls projectiles do.

the run

Around the doors, the loop has closed. A contract is the same idea as Devlog 00’s level seed with a few dials on it: the objective, how thick the level is with hostiles, a loot modifier, the reward. The available objectives as of now are to either eliminate a target or locate a payload to carry out, and either way the job isn’t finished until the crew winds up at the extraction zone.

next

Right now I’m working on the combat mechanics, with a dash of juice.

If you want to follow along until then, I post work-in-progress shots on Bluesky. A Steam page for wishlisting is coming.

Thank you for taking the time to read and have a magnificent day.


// end of log · return to feed