I started Hollow because I wanted to learn Vulkan and engine architecture by building something large enough that the awkward boundaries would actually matter. A triangle or a small renderer demo can teach the API, but it does not force a renderer, level editor, gameplay simulation, dedicated server and asset pipeline to agree about who owns what.

The target is a large multiplayer survival FPS, with physical inventory, persistent worlds, authoritative servers and community hosting. That is still the target, not the current release state. The repository is active development and the useful result so far is a set of working vertical slices: a raw Vulkan renderer, an integrated docked editor, fixed-tick movement and weapon handling, physical item and magazine state, skeletal character presentation, cooked terrain and vegetation, plus an embedded two-client authority test.

I have deliberately built the difficult boundaries before trying to make a complete game. The current path looks roughly like this:

source assets + level documents
-> editor authoring state
-> validated cooked client/server packages
-> headless fixed-tick Game
-> authoritative snapshots and events
-> interpolated client presentation
-> Vulkan render scene

That shape has been more useful than adding isolated features quickly, because it makes later work travel through the same path as the simple prototype. A local pickup already uses the same revision-checked item transaction that a server can call. A local movement input already goes through encoded protocol bytes, prediction and reconciliation. Terrain used by the client and server comes from separate projections of the same cook rather than both reopening the editor JSON.

The current branch history begins on July 16, 2026 and contains 29 commits through August 2, all authored by me. I used the latest milestone date for this draft because the project has changed substantially across that period and the article describes the current architecture rather than only the first Vulkan bring-up.

Hollow Editor

Keeping the linker useful

One of the earliest choices was to make the build graph enforce the architecture. hollow_core cannot link SDL3, Vulkan or Dear ImGui. hollow_game contains the EnTT registry, Jolt Physics world and gameplay rules, but it also has no presentation dependency. hollow_network deals in owned packet bytes, transport-attributed peers and plain validated values. The graphical client is the composition root where those pieces are finally allowed to meet.

hollow_core       logging, identity, math, input values
hollow_world      cooked package and residency contracts
hollow_assets     CPU source-asset parsing
hollow_network    protocol, sessions and simulated transports
hollow_platform   SDL3 window, input and time
hollow_game       EnTT + private Jolt simulation
hollow_render     Vulkan + VMA + Dear ImGui
apps/*            client, standalone cooked client and server composition

The CMake files make those rules concrete. For example, hollow_game links the headless libraries and keeps asset parsing, Jolt and simdjson private:

add_library(hollow_game STATIC
    cooked_level.cpp
    game.cpp
    inventory_snapshot.cpp
    item_collection.cpp
    item_condition.cpp
    item_definition.cpp
    item_instance.cpp
    item_transaction.cpp
    level_document.cpp
    loot_spawn.cpp
    physics_world.cpp
    runtime_persistence.cpp
    simulation_config.cpp
    weapon_accuracy.cpp
    weapon_definition.cpp
)
target_include_directories(hollow_game PUBLIC ${CMAKE_SOURCE_DIR})
target_link_libraries(hollow_game
    PUBLIC hollow_core hollow_world EnTT::EnTT
    PRIVATE hollow_assets Jolt::Jolt simdjson::simdjson
)

This has caught design mistakes early. If gameplay code suddenly needs an ImGui type or an SDL key code, adding another library to the link line would make the immediate error disappear, but it would also make the same gameplay impossible to run in the dedicated server. The input boundary therefore uses named, renderer-free facts, while gameplay produces typed events which the client can turn into sound, debug lines or UI.

The renderer has a similar public boundary. Renderer::render receives a camera and an already prepared RenderScene. It never receives the EnTT registry, and Vulkan handles do not escape through renderer.hpp. The internal context, swapchain, pipelines, per-frame resources and uploaded asset caches stay behind private forward declarations and unique_ptrs.

One clock for gameplay and another for presentation

The main client loop has two clocks because the monitor and the game simulation solve different problems. Presentation runs whenever a frame can be drawn. Gameplay advances by an immutable SimulationConfig, normally 60 Hz, with structurally supported 30, 60, 64 and 128 Hz presets for server qualification.

The actual accumulator is small, but it owns several important rules:

const double fixedDt = activeGame->simulationConfig().fixedDeltaSeconds();
accumulator += frameDelta;
const auto fixedSimulationStart = Clock::now();
ZoneScopedN("Fixed-step accumulator");
while (accumulator >= fixedDt) {
    if (auto ticked = playSession.tick(input); !ticked) {
        spdlog::critical("network Play tick failed: {}", ticked.error());
        running = false;
        break;
    }
    consumeSimulationEvents(activeGame->events(), now, weaponPresentation,
                            inventoryActionPresentation, weaponCatalog.items());
    ++ticksThisFrame;
    // Look, jump, and button-down edges are transient
    // (Traps #18/#35): the first tick consumes them. Held
    // trigger state intentionally survives for automatic-fire
    // policy on subsequent fixed ticks.
    clearTransientInput(input);
    accumulator -= fixedDt;
}

The 0.25 second clamp prevents a debugger pause from turning into a huge simulation catch-up. Button edges and mouse deltas are consumed by the first fixed tick, while held state remains available for actions such as automatic fire. After the ticks, one alpha value interpolates every presented component between its previous and current simulation states.

I kept that interpolation in a client adapter rather than the renderer. Game stores previous and current transforms, the adapter builds plain render objects, and the renderer only sees the final matrices. That means the simulation does not know about frames per second, the renderer does not know about EnTT, and remote snapshot interpolation can feed the same presentation model.

It also gives networking somewhere sensible to attach. The graphical Play mode currently creates a private predicted game and a separate headless authority. Local input is encoded, copied through INetworkTransport, validated by a server session table, applied to an authoritative actor and acknowledged in a compact snapshot. The client can then restore one actor and replay only its unacknowledged movement commands without repeating the shared physics tick, loot, weapon or item work.

The packet validation is intentionally boring and strict. Before an input becomes a PlayerActorInput, the server checks its transport-owned peer, delivery lane, protocol framing, server-issued session generation, simulation fingerprint, sequence, client tick window and per-peer budget. It retains only the newest accepted command per actor for that server tick.

if (command->sessionGeneration != sessionState.binding.generation) {
    ++m_counters.generationMismatchPackets;
    continue;
}
if (command->simulationConfigFingerprint != m_simulationConfigFingerprint) {
    ++m_counters.fingerprintMismatchPackets;
    continue;
}
if (!sequenceIsNewer(command->sequence, sessionState.acknowledgedInputSequence)) {
    ++m_counters.staleSequencePackets;
    continue;
}
if (command->clientSimulationTick <= sessionState.latestClientSimulationTick) {
    ++m_counters.staleClientTickPackets;
    continue;
}

There is no production socket backend yet. The current simulated topology is useful because it has a virtual clock and seeded latency, jitter, loss, duplication and reordering, so two client streams can be tested without sleeping or depending on the machine’s network timing. It proves the protocol and authority seam. It does not prove authentication, Internet transport security, interest management or disconnect handling.

Vulkan ownership lasts longer than a C++ scope

The renderer uses the Vulkan C API directly, Vulkan Memory Allocator for GPU allocation, HLSL compiled to SPIR-V with DXC, and SPIRV-Reflect to check descriptor and push-constant layouts against what the pipelines expect. The current path has PBR-lite materials, a directional shadow cascade, procedural sky, HDR tonemapping, CPU frustum culling, skinned meshes, terrain patches and instanced vegetation.

The difficult part has usually been lifetime rather than drawing. Replacing a texture, viewport target, terrain allocation or swapchain does not mean the GPU has stopped reading the old one. Destroying it immediately after removing it from a C++ map can leave an older submitted frame holding a dead descriptor or image.

I use one renderer-owned deferred release queue for that boundary. A replacement is published first, the old resource is moved into a callback, and the callback becomes eligible only after the graphics timeline semaphore reaches the submission value which could last reference it.

void enqueue(uint64_t retireAfter, Release release) {
    assert(release);
    m_entries.push_back({.retireAfter = retireAfter, .release = std::move(release)});
}

// Executes every eligible release in insertion order. Values do not have
// to be enqueued in order, which keeps the utility honest for future
// streaming/background upload producers.
std::size_t collect(uint64_t completedValue) {
    std::vector<Entry> pending;
    pending.reserve(m_entries.size());

    std::size_t releasedCount = 0;
    for (Entry& entry : m_entries) {
        if (entry.retireAfter <= completedValue) {
            entry.release();
            ++releasedCount;
        } else {
            pending.push_back(std::move(entry));
        }
    }
    m_entries = std::move(pending);
    return releasedCount;
}

The queue deliberately knows nothing about Vulkan object types. UI textures can retire an ImGui descriptor and image, terrain can retire a storage buffer and descriptor set, and a resized editor viewport can retire several images through the same completion proof. Shutdown waits for the device to become idle and drains the remaining callbacks before their allocator and descriptor pool disappear.

Swapchains need an extra wrinkle because ordinary Vulkan presentation does not give me a present fence. Hollow keeps old swapchain generations until an image from the replacement has been reacquired and a submission waiting on that acquire has completed. That proves both the graphics use and the practical presentation handoff before the old generation is released. This is much more machinery than recreating a swapchain and calling vkDeviceWaitIdle, but it keeps live resize and VSync changes from stalling the whole device.

Physical items instead of UI inventory records

The inventory work forced another useful separation. An ItemDefinition is immutable shared data such as footprint, mass, tags, presentation assets and attachment compatibility. An ItemInstance is one physical copy with a stable ID, revision, quantity, condition and exactly one location.

World(entity and transform)
Cargo(container, grid anchor and rotation)
Equipment(owner and typed slot)
Attachment(parent item and typed socket)
Hands(owner)

Quick slots are references and Vicinity is a spatial query, so neither can duplicate an item. A detachable magazine is also an ordinary item instance and owns its exact ordered cartridges. The weapon instance owns the chamber, cadence deadline and reload state. Dropping the weapon, swapping magazines or saving and restoring the runtime does not create a new abstract ammo count.

The headless ItemTransactionService performs moves, swaps, stacking, magazine insertion, chambering and cartridge transfer on a candidate collection. Requests carry exact item IDs and observed revisions. The service validates actor ownership or fresh world access, source revision, definition compatibility, cargo overlap, attachment rules and nesting before publishing the complete candidate.

This was more work than letting an ImGui drag operation edit a vector, but it gives failure a clean meaning. If a two-item Hands swap cannot stow the displaced item, neither copy moves. If the final loose cartridge is loaded, removing its empty stack and clearing a stale quick-slot reference happen in the same publication. A client can draw a green Combine target, but the fixed tick still reruns the real checks.

Hollow Editor Terrain Sandbox

Cooking one source into different runtime truths

The editor saves versioned source documents under assets/. The standalone client and server are moving toward immutable cooked packages instead of treating that JSON and every source GLB as the shipping world format.

HollowAssetCompiler cook-world loads the world manifest and its cell documents, resolves the recursive asset GUID dependency closure, builds separate shared, client and server packages, encodes them, decodes them again into typed runtime views, then publishes a world.index and strict server profile. The client projection contains presentation meshes, terrain patches and visual vegetation. The server projection contains gameplay entities, terrain heights and separately authored low-cost vegetation collision or concealment boxes. Render triangle bounds never silently become server collision.

Publication uses temporary .cooking candidates and an ordered atomic replacement. Packages are replaced before world.index, then the server profile is replaced last. A changed package cannot become reachable through a new index before its bytes exist, and a profile cannot select a fingerprint which still points at the previous index.

// The order is the fail-closed publication protocol. Replaced package
// bytes remain unreachable until the new index is visible. The profile is
// last, so a newly selected fingerprint never names an unpublished index.
for (Publication& publication : publications) {
    if (auto replaced = replaceFileAtomically(publication.candidatePath, publication.finalPath);
        !replaced) {
        removeCandidates(publications);
        return replaced;
    }
}

The current cooker also partitions client and server regions, compresses eligible sections independently with Zstandard and can reuse a regional package only after rebuilding and verifying the candidate, comparing exact bytes and decoding the existing bytes back to the expected world, grid, region and fingerprint. That is intentionally conservative. The next cache layer can skip more construction using source and recipe fingerprints, but reuse should never mean trusting an old filename.

Multi-object GLB packs exposed a related identity problem. One source file may contain thirty useful trees and rocks, while an editor needs to place one of those logical objects. The current importer inspects the hierarchy without rendering it, produces escaped hierarchy keys with sibling ordinals, and derives a stable child GUID from the source GUID plus the exact key. A .glbpack.json publication records that selection. Reimport validation fails if the node disappears or is rebound, instead of silently choosing whichever mesh now has the same array index.

Hollow Editor Terrain Sandbox

What I check and what is still rough

The project uses Catch2 for renderer-free code, dedicated server smoke tests at 30, 60, 64 and 128 Hz, Vulkan validation during manual runs, RenderDoc for individual frames and Tracy for multi-frame CPU timing. Test names are deliberately behavioral, such as two-item location swaps publish both revisions or roll back the complete candidate, two simulated clients may reuse protocol sequences in independent peer streams, and published GLB pack children survive editor save reopen and parent moves.

The latest documented clean checkpoint on August 2 records a clean MSVC x64 /W4 build and 47,719 assertions across 390 Catch2 cases. While preparing this article I also ran the current CTest preset, which exposes those cases plus five server smoke tests. The worktree is in the middle of the next asset-pack change and 394 of 395 CTest entries passed. The remaining project asset-graph check found two dependency-list mismatches in the edited Test Room and GLB-pack publication records. I would fix and rerun that before calling this exact working tree clean.

Several larger boundaries remain deliberately incomplete. HollowServer.exe is a finite headless network smoke, not a listening Internet service. Remote actors are body-only while inventory and combat commands are still bound to the bootstrap actor. Editor Save writes source, but the planned Build World action is not present, so cooking is still a separate command. The standalone client also resolves some presentation GUIDs through the development asset database, and durable server persistence is still planned.

The renderer is also intentionally conventional. It uses an explicit forward path, CPU culling and bounded instancing, without a render graph, bindless descriptors, GPU-driven culling, mesh shaders or ray tracing. Those would be interesting, but adding them before the current lifetime and content boundaries become a measured limitation would mostly create more systems to debug at once.

The main lesson from Hollow so far has been that the small ownership decisions become the engine. A fixed tick matters because input, persistence and replication all depend on it. Stable item identity matters because Hands, magazines, quick slots and world pickup all touch the same copy. Deferred release matters because a successful C++ replacement still says nothing about GPU completion. Cooking matters because an editor document is not automatically a safe client or server runtime format.

The next useful step is therefore fairly concrete: finish the GLB-pack authoring workflow and repair its dependency graph, put Build World into the editor, then continue the socket and actor-owned command path without bypassing the simulation and publication seams which are already tested. That will make the project look more like a game, but it can still be built out of the same boundaries rather than a second set of shortcuts.