·····
← ~/devlogs

Devlog 00: it's all cubes

It's mostly cubes, and mostly harmless.

Welcome to devlog zero for Project SCRAM, a co-op space-heist game. Although my day job is in application security, this game is turning into my evening passion project. I’ve purposely been relatively quiet about the project thus far, since hyping a game before it’s real is a reliable way to never finish one. I can say that it’s real now, and I’m glad to finally show a little of what I’ve been building.

why this game

For a long time “making a game” also meant Unity. Then Godot hit a stride that I couldn’t ignore.. an open-source community shipping solid features at a pace that made the closed-engine grind feel slow. It was an ethos that I wanted to get behind, so for this game I switched. And for this game I decided to stop starting things and actually finish one, all the way to a Steam release.

The spark for Project SCRAM specifically was Heat Signature, a deeply underrated game about boarding procedurally generated ships with whatever equipment you brought along and watching the plan come apart in the best way. Project SCRAM lives in that neighborhood: a co-op space heist for one to six, tacti-lite rather than tactical, with a dash of emergent-station chaos, fast top-down action, and some light RPG progression. You and your crew board a hostile ship, grab what you can, and try to get back to the airlock alive.

where it’s at

Honestly, further along than I’d have guessed before talking about it. The foundations are in and the game runs end to end in-house: movement, combat, the AI, generated levels, the inventory and economy. Visually it’s still a box of cubes, but.. you know, it’ll get there.

Most of the past couple months, though, went into things that are difficult to screenshot. The mechanics I describe below sit on a layer of systems.. serialization, deterministic netcode, the harness that boots the game, and a test suite that’s (not so) quietly becoming one of the larger sections of the codebase. I might write about the test coverage later on as it has already saved me a plethora of headaches along the way.

the thing that made it feel real

Lately I’ve been wiring up mission data, which has really helped sell the loop. But the work that made Project SCRAM feel like an actual game recently was the AI. I spent a while watching a single cube look for me. It found me, I broke line of sight behind a wall, and instead of forgetting I existed it walked to where I’d been standing and swept the room.

Part of why it felt alive is that the AI doesn’t think at one fixed speed. How often an agent currently re-evaluates the world depends on how wound up it is, and that cadence is just a lookup off its state:

public static AiCadenceProjection Project(AiFsmState state) => state switch
{
    AiFsmState.Idle or AiFsmState.Patrol
        => new AiCadenceProjection(AiCadenceBucket.Calm, CalmPeriodMs),
    AiFsmState.Suspicious or AiFsmState.Alert or AiFsmState.Search
        => new AiCadenceProjection(AiCadenceBucket.Hunt, HuntPeriodMs),
    AiFsmState.Engaged or AiFsmState.Withdraw
        => new AiCadenceProjection(AiCadenceBucket.Combat, CombatPeriodMs),
    _ => throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown AiFsmState"),
};

As of now, an enemy AI on a quiet patrol re-thinks about four times a second. One that’s hunting, closer to seven. In a fight, ten. This started as a performance trick to keep a room full of agents from re-scanning every frame, but it turned out to sell the fiction too. An enemy wandering an empty corridor should be a beat slow on the uptake, and the one mid-search up there should not be. The cadence doubles as personality.

There’s a bit of sweeping-the-room logic too. When an agent loses you it walks to your last-known position and then, instead of standing there until the timer runs out, works through a ring of scan points around it. The starting angle of that ring is rotated by the agent’s own id, so two of them searching the same room fan out and cover different ground without ever exchanging a word.

public static void BuildScanOffsetsInto(
    Vector3 focus, float radiusMeters, ulong entitySeed, Span<Vector3> destinations)
{
    var step = Mathf.Tau / destinations.Length;
    var phase = (entitySeed % 8ul) * (Mathf.Tau / 8f);
    for (var i = 0; i < destinations.Length; i++)
    {
        var angle = phase + step * i;
        var offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radiusMeters;
        destinations[i] = focus + offset;
    }
}

That landed after a playtest where three enemies all walked to the same spot and stared at the same wall, which killed the illusion pretty quickly. Phase-rotating the scan by entity seed fixed it for free. They coordinate without communicating, which is exactly how a search should feel.

~/scram/captures/ai-search.png
Top-down greybox scene. Two enemy AI in a search state move toward the player's last known position, vision cones in orange and detection arcs in cyan.

one seed, one ship

The other thing I’m happy with is that an entire level boils down to a number. A contract carries a seed, every player rebuilds the same level from it on their own machine, and the layout never crosses the network. Two people boarding the same level see the same corridors because the math agreed, not because a prebuilt map was shipped over the wire.

Getting there though had a few gotchas. If every system draws from the same random stream they get entangled, so adding one dice roll to the loot logic shoves every wall after it somewhere new, because everything downstream shifted by a draw. The fix ended up being to fan the seed out into separate streams that never touch:

internal static ulong DeriveChannelState(int seed, int channelId)
{
    unchecked
    {
        ulong z = (uint)seed + Gamma * (ulong)(uint)(channelId + 1);
        z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
        z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
        return z ^ (z >> 31);
    }
}

splitmix64 ended up being the right fit here.. pure integer math with published test vectors, so I can prove every peer derives the identical stream. The .NET docs are explicit that System.Random isn’t stable across versions, which is exactly the thing that bites you deep into a project. Layout, loot, and AI spawns each get their own channel off the one seed, so I can mess with loot now without rearranging the architecture.

Good old RNG trap there too.. since it’s a mutable struct, and copying it forks the stream. Two peers that took different copies would silently drift apart, so you consume it by reference. Solid hour hunting this one.

the inventory detour

When I initially designed the inventory system, I went with spatial first.. the kind where every item is a shape you rotate to fit in a grid. It looked.. alright. Then I played with it for an evening and realized I’d started making an inventory-management game by accident. So I pulled the grid back out. Items now cost cells against a budget, a rifle is simply bigger than a medkit, and nobody spends the back half of a firefight playing backpack Tetris.

~/scram/captures/inventory.png
The inventory screen: three columns for loadout, cargo, and stash, with weapons, frag grenades, medkits, and a Scrip balance.

next

My current goal is to post a new devlog every week or two. Partly that’s for whoever’s reading, but mostly it’s for me. I already find it encouraging to look back at where Project SCRAM was even a month ago. Vision and memory mechanics are in-progress now and I am excited to share more on that soon.

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

Thanks for reading. Hope to see you in mission before long.


// end of log · return to feed