← All posts
03 Aug 202610 min read

Cthulhu Survivor: a Vampire Survivors-like built as a DOTS/ECS showcase

  • Unity
  • DOTS
  • ECS
  • Architecture
  • Editor Tooling
Cthulhu Survivor: a Vampire Survivors-like built as a DOTS/ECS showcase

Cthulhu Survivor is a Vampire Survivors-like set in the Call of Cthulhu universe: a top-down horde-survival loop where you out-walk a swarm, weapons auto-fire on their own, and every level-up hands you a choice between three cards. The genre is deliberately familiar — the point of the project isn't the genre, it's what's running underneath it.

Autotargeting revolver and a shotgun firing a three-way spread, early prototype footage

This is a DOTS/ECS demonstration first and a game second. Thousands of enemies need to move, collide, and die without turning the frame budget into soup, which means the simulation layer — enemies, projectiles, damage, spatial queries — lives entirely in Unity Entities with Burst-compiled jobs, while everything a player actually clicks on (menus, popups, the level-up screen) stays plain C# behind VContainer DI. Getting that seam right, and keeping it right as content got added, is most of what this post is about.

The shape of the stack

The project is split into assemblies along the same line most of the interesting bugs turned out to live on:

Cthulhu.Core        — pure C# rules (stats, weapon leveling, damage math), zero Unity
Cthulhu.Simulation  — ECS: components, systems, Burst jobs
Cthulhu.App         — MonoBehaviour/VContainer layer: controllers, services, DI scopes
Cthulhu.UI          — popups, views, no knowledge of App or gameplay types

Cthulhu.Core doesn't reference UnityEngine at all — weapon leveling, stat modifiers, damage application are all testable NUnit-style without a running Editor. Cthulhu.Simulation is where Burst lives. Cthulhu.App is the only place those two worlds are allowed to touch, through a handful of explicit bridge points — a PlayerProxy singleton component the ECS side reads, and managed event queues the other direction.

That last part needed its own trick, because ECS jobs can't just call into managed code.

The bridge: managed event queues out of Burst jobs

Enemy death needs to spawn an XP gem. Getting hit needs to show a floating damage number. Both of those are MonoBehaviour/GameObject concerns — pooled prefabs, DOTween animations — and both originate inside [BurstCompile] jobs that can't touch managed state directly.

The fix is a static queue that Burst can write to, because the write happens through a NativeQueue<T>.ParallelWriter inside the job, and the drain happens in a plain (non-Burst) tail of the system's OnUpdate, after the job handle completes:

public void OnUpdate(ref SystemState state)
{
    var damagePopups = new NativeQueue<DamagePopupEvent>(Allocator.TempJob);

    state.Dependency = new ProjectileEnemyCollisionJob
    {
        // ...
        DamagePopups = damagePopups.AsParallelWriter(),
    }.Schedule(_aliveProjectilesQuery, state.Dependency);

    state.Dependency.Complete();

    while (damagePopups.TryDequeue(out var popupEvent))
        DamagePopupEvents.Raise(popupEvent.Position, popupEvent.Damage);

    damagePopups.Dispose();
}

DamagePopupEvents is a plain static class holding a managed Queue<T> — the ECS side calls Raise(), a DamagePopupService on the App side calls TryDequeue() on its own tick and spawns the popup from a pool. Neither side references the other's assembly. It's the same shape Unity's own EntityCommandBuffer uses for structural changes, just applied to "tell the MonoBehaviour world something happened" instead of "create an entity."

Autotargeting: why a spatial grid instead of a naive scan

Every weapon needs to answer one question every time it's about to fire: where's the nearest enemy? With a swarm in the thousands, checking distance to every living enemy every time a weapon wants to shoot is the kind of thing that's fine at 50 enemies and a measurable frame-time line item at 3000.

The fix is a spatial hash grid rebuilt fresh every frame — not incrementally maintained, just thrown away and recomputed, because with enemies moving every frame the bookkeeping to track cell transitions costs more than just redoing the bucket pass:

[BurstCompile]
public struct BuildGridJob : IJobFor
{
    [ReadOnly] public NativeArray<LocalTransform> Transforms;
    public float CellSize;
    public NativeParallelMultiHashMap<int2, int>.ParallelWriter Grid;

    public void Execute(int index)
    {
        var cell = GridMath.ToCell(Transforms[index].Position.xy, CellSize);
        Grid.Add(cell, index);
    }
}

Finding the nearest enemy to a point then means checking the point's own cell, then expanding outward ring by ring — 3×3, 5×5, and so on — stopping as soon as the closest candidate found so far is provably closer than anything a wider ring could contain:

for (var ring = 0; ring <= MaxRingRadius; ring++)
{
    SearchRing(playerCell, ring, ref bestIndex, ref bestDistanceSquared);

    if (bestIndex >= 0)
    {
        var found = ring * CellSize;
        if (found * found >= bestDistanceSquared) break;
    }
}

The result gets written once, per frame, into a NearestEnemyTarget singleton component — every weapon controller reads that instead of running its own search, so the expensive part happens exactly once no matter how many weapons the player is carrying. This is also the piece that makes a fully-automatic playstyle possible later: aiming isn't "read the mouse," it's "read what the grid already decided," so a control scheme with no manual aim at all is a config choice, not a rewrite.

Weapons: interface, then base class, then concrete type — every single layer

The rule that shaped the weapon system more than anything else: no MonoBehaviour per weapon type. Weapons are picked up mid-run through the level-up screen, not placed in the scene ahead of time, so a weapon has to be something a service can construct and destroy on demand — which rules out "drag a component onto the player" as an approach entirely.

What it turned into is a strict contract chain, repeated for every weapon and every view, even when the concrete type has nothing to add:

IWeaponController                          // contract WeaponService talks to
└── IRevolverController : IWeaponController        // empty marker — still a real contract
    └── RevolverController : WeaponControllerBase, IRevolverController

WeaponViewBase                             // MonoBehaviour base
└── RevolverView : WeaponViewBase, IRevolverView

WeaponDefinition (abstract ScriptableObject)
└── ProjectileWeaponDefinition (abstract) : WeaponDefinition
    └── RevolverDefinition : ProjectileWeaponDefinition
        └── CreateController(context) => new RevolverController(context, this)

IRevolverController has no members. It's not dead code — it's the seam where a Revolver-specific method gets added later without touching every caller that only knows about IWeaponController. The convention across the project is: interface first, then a base class if shared logic exists, then the concrete type — never skip a layer because "it's simple for now."

A single WeaponService owns every equipped weapon's controller, ticks them all every frame, and hands out a WeaponContext — player reference, EntityManager, pool service, view container — to whichever WeaponDefinition gets picked at level-up. The definition is both the authored data (damage, cooldown, projectile count) and the factory (CreateController), which means adding a new weapon is "write a WeaponDefinition subclass," not "also go update a switch statement somewhere that maps weapon IDs to controller types."

That contract chain is also exactly why the different weapon shapes — projectile, zone, cone — don't collapse into one class with a pile of if (type == X) branches:

ProjectileWeaponDefinition   — bullet count, speed, pierce, optional homing turn rate
ZoneWeaponDefinition         — radius, tick interval, Self (aura) or Target (AoE on the nearest enemy)
ConeWeaponDefinition         — angle, range, windup delay before the swing lands

A shotgun and a revolver are both ProjectileWeaponDefinition — same fields, same controller shape, different numbers. A melee swing and a ranged cone-attack are both ConeWeaponDefinition — the difference is range and windup, not a separate system.

Multi-shot: reusing the fan math instead of writing it twice

Adding a projectile-count stat (a shotgun firing three pellets in a spread instead of one straight shot) turned into a small standalone utility rather than logic embedded in one controller, specifically so every future projectile weapon gets it for free:

public static float2[] ComputeDirections(float2 baseDirection, int count, float spreadAngleDegrees)
{
    if (count <= 1) return new[] { baseDirection };

    var directions = new float2[count];
    var spreadRadians = math.radians(spreadAngleDegrees);
    var startAngle = -spreadRadians * (count - 1) * 0.5f;
    var baseAngle = math.atan2(baseDirection.y, baseDirection.x);

    for (var i = 0; i < count; i++)
    {
        var angle = baseAngle + startAngle + spreadRadians * i;
        directions[i] = new float2(math.cos(angle), math.sin(angle));
    }

    return directions;
}

Wiring that in surfaced a real bug in the collision job, worth naming because it's an easy one to miss: a projectile with pierce > 1 was hitting the same enemy multiple times across consecutive frames, because "distance ≤ combined radius" stays true for several frames while a slow projectile passes through a large enemy's collider — not just the one frame contact actually starts. Pierce was draining against one target instead of hitting several. The fix is a small DynamicBuffer<HitEnemyEntry> living on the projectile entity — a per-projectile "already hit this one" list, checked before applying damage:

if (DeadLookup.IsComponentEnabled(enemyEntity)) continue;
if (HasAlreadyHit(hitEnemies, enemyEntity)) continue;
// ... distance check, apply damage ...
hitEnemies.Add(new HitEnemyEntry { Enemy = enemyEntity });

Cheap, and it's the same "state lives on the entity that needs to remember it" instinct ECS pushes you toward generally.

The Editor tool: generating a full weapon from one window

Once the contract chain above stabilized, writing a new weapon meant creating five files by hand every time — two interfaces, a controller, a view, and a ScriptableObject definition — copy-pasted from the last weapon and renamed. That's exactly the kind of repetitive, mechanical work worth automating once the pattern is proven, so it became a custom EditorWindow: pick a name, pick a base (Projectile / Zone / Cone), pick a slot (character-exclusive vs. general loot pool), hit Generate.

The interesting part isn't the string templating — it's that the tool has to solve a chicken-and-egg problem Unity doesn't give you a clean API for: you can't create a ScriptableObject asset of a type that doesn't exist yet, and the type doesn't exist yet because the script was just written to disk and hasn't compiled. The fix is to split "generate" into two halves across a domain reload:

public static void Request(string fullTypeName, string folder, string assetName)
{
    SessionState.SetString(TypeNameKey, fullTypeName);
    SessionState.SetString(FolderKey, folder);
    SessionState.SetString(AssetNameKey, assetName);
}

[DidReloadScripts]
private static void OnScriptsReloaded()
{
    var typeName = SessionState.GetString(TypeNameKey, string.Empty);
    if (string.IsNullOrEmpty(typeName)) return;
    // ... clear the pending request ...

    var type = FindType(typeName);
    var instance = ScriptableObject.CreateInstance(type);
    AssetDatabase.CreateAsset(instance, assetPath);
}

SessionState survives the reload that recompiling triggers; a plain field wouldn't. Click "Generate," the window writes the five scripts and calls AssetDatabase.Refresh(), Unity recompiles, and [DidReloadScripts] fires once the new type genuinely exists — at which point the tool finds it by name through reflection and creates the asset. One button, no manual "wait for compile, then right-click Create" step in between.

What's still open

The three weapon shapes (projectile, zone, cone) all have working ECS systems and at least one concrete weapon each, but content — actual balance numbers, most of the roster's passives, a boss archetype — is still thin. The autotargeting system is built for a fully-automatic playstyle specifically because that's an explicit future mode, not just the current default; a manual-aim option is the next targeting-related task once it's needed. No stress test past a few hundred enemies yet, either — the spatial grid is designed for thousands, but "designed for" and "measured at" are different claims, and the honest one to make right now is the first.