Maps and Dictionaries

23 min read

Key-value lookup types for game code, from a general-purpose hash map and a type-and-ID keyed registry through a Burst-compatible native map to a dictionary that survives Unity’s serializer.

Summary

Four types under Scylla.Core.Structures answer the same basic question (what value is stored under this key) with four different sets of tradeoffs:

  • ScyllaMap<TKey, TValue> is a general-purpose hash map built on open addressing with linear probing over parallel key, value, and slot-state arrays, the default choice for entity lookups, command registries, and any keyed cache in managed game code.
  • ScyllaTypeMap adds a second key dimension on top of the same open-addressing map: it groups values by CLR Type first and by a uint ID second, the shape a module-style registry needs when several instances of the same component type each want their own numbered slot.
  • ScyllaMapDOTS<TKey, TValue> is the Burst-compatible native counterpart, a fixed-capacity struct wrapped around a NativeHashMap for job code that cannot touch the managed heap.
  • SerializableDictionary<TKey, TValue> solves a different problem entirely: it does not implement IScyllaCollection and carries no builder, no synchronized wrapper, and no DOTS variant; its entire reason to exist is surviving Unity’s serializer so a dictionary can be edited directly in the Inspector.

ScyllaMap keeps its keys, values, and per-slot state in three parallel arrays sized to a power of two, which lets the probing code replace the usual modulo with a bitwise mask. Slots move through three states: empty, occupied, and deleted (tombstone). Removing an entry writes a tombstone rather than clearing the slot outright, because a bare gap would break the probe chain for every key that hashed past it; tombstones are folded back into the free pool the next time the table grows or compacts. Growth is automatic by default (a rehash doubles capacity once the map crosses roughly 72% load, or once tombstones alone exceed a quarter of the table), or the map can be built as fixed-capacity, in which case insertion past that limit returns false instead of resizing. A custom IEqualityComparer<TKey> swaps in whenever the default equality is not the one wanted, case-insensitive string keys being the most common example.

ScyllaTypeMap is built from the same ScyllaMap underneath: a top-level ScyllaMap<Type, IBucket> finds the bucket for a generic type argument, and each bucket is itself a ScyllaMap<uint, T> holding that type’s instances. Buckets are created lazily on first insert and torn down automatically once they empty out, so the type table stays compact. ScyllaMapDOTS<TKey, TValue> trades that flexibility for Burst compatibility: capacity is fixed at construction, both key and value must be unmanaged, and the map must be explicitly disposed. Its TryAdd also behaves differently from the managed map: inserting an existing key updates the value and succeeds, an upsert rather than a rejection, matching the re-registration pattern common in DOTS-style code.

SerializableDictionary is the odd one out by design. Unity’s serializer cannot persist a generic Dictionary<TKey, TValue> directly, so this type keeps a live dictionary at runtime and mirrors it into parallel key and value lists immediately before and after serialization via ISerializationCallbackReceiver. It matches the IDictionary<TKey, TValue> surface exactly, so existing dictionary-handling code drops in without changes; the only addition is the ability to show up, and be edited, on a ScriptableObject or MonoBehaviour Inspector. See Collections Overview for how the map family fits alongside the queue, stack, and specialized-structure families that share the IScyllaCollection contract.

Use the map types for:

  • Entity ID, string-key, or other struct-keyed lookups where Dictionary<TKey, TValue> boxing costs matter.
  • Module-style registries where several instances of the same component type each need a numbered slot.
  • Case-insensitive or otherwise custom-equality key lookup via a comparer setter.
  • Burst-compiled jobs that need key-value storage without touching the managed heap.
  • Designer-editable key-value tables exposed on a ScriptableObject or component Inspector.
  • Cross-thread producer/consumer lookups via a synchronized wrapper (managed maps only).

Features

  • Open addressing with linear probing. ScyllaMap avoids the chained-bucket allocation Dictionary<TKey, TValue> pays for; keys, values, and slot states live in three parallel arrays sized to a power of two.
  • No boxing for value-type keys or values. Both ScyllaMap and the buckets inside ScyllaTypeMap store TKey/TValue directly in typed arrays, so struct keys and struct values never box on lookup, insert, or remove.
  • Tombstone-based removal with automatic compaction. Deleted slots become tombstones to preserve probe chains; a rehash reclaims them once they exceed a quarter of the table, or whenever growth is triggered anyway.
  • Automatic growth or fixed capacity. The default map grows (doubling capacity) once load crosses roughly 72%; .FixedCapacity() on the builder (or the constructor flag) disables growth so TryAdd fails instead of resizing.
  • Custom key comparers. .WithComparer(IEqualityComparer<TKey>) on the ScyllaMap builder, and .WithTypeComparer on the ScyllaTypeMap builder, swap in non-default equality, most commonly StringComparer.OrdinalIgnoreCase.
  • Fluent builders on every managed variant. ScyllaMap<TKey, TValue>.CreateBuilder() and ScyllaTypeMap.CreateBuilder() return a Builder with .WithCapacity, .FixedCapacity(), a comparer setter, and .Synchronized(); .Build() returns the shared interface and .BuildConcrete() returns the concrete type, throwing if .Synchronized() was requested.
  • Synchronized wrappers. AsSynchronized(syncRoot) on ScyllaMap and ScyllaTypeMap returns a nested SynchronizedScyllaMap / SynchronizedScyllaTypeMap that locks every operation on a shared SyncRoot.
  • Allocation-free enumeration on the concrete type. ScyllaMap<TKey, TValue>.GetEnumerator() returns a value-type Enumerator yielding KeyValuePair<TKey, TValue>, so a foreach over the concrete map allocates nothing.
  • Type-and-ID registry with lazy, self-cleaning buckets. ScyllaTypeMap maps each CLR Type to a uint-keyed bucket; buckets are created on first insert and removed automatically once they empty, keeping the top-level table compact.
  • Burst-compatible native map. ScyllaMapDOTS<TKey, TValue> wraps a NativeHashMap<TKey, TValue> behind the same Try* surface, with unmanaged key/value constraints and explicit Allocator-based disposal.
  • Upsert semantics on the DOTS map. Unlike the managed map, ScyllaMapDOTS.TryAdd updates the value and succeeds when the key already exists; only a genuinely new key is subject to the fixed-capacity check.
  • Unity-serializable dictionary with Inspector support. SerializableDictionary<TKey, TValue> implements IDictionary<TKey, TValue> plus ISerializationCallbackReceiver, converting to and from parallel key/value lists around Unity’s serialization pass.

The maps

Four types, four different jobs. The table below is the fast way to pick the right one; the recipes that follow show each in use.

TypeKeyed byDistinguishing traitDOTS variant
ScyllaMap<TKey, TValue>Any TKey (custom comparer optional)Open-addressing hash map with linear probing; no boxing for value types; automatic growth or fixed capacityScyllaMapDOTS<TKey, TValue>
ScyllaTypeMapCLR Type plus uint IDTwo-level registry: a ScyllaMap<Type, IBucket> on top of a per-type ScyllaMap<uint, T>; buckets created and torn down automaticallynone
ScyllaMapDOTS<TKey, TValue>Any unmanaged TKey : IEquatable<TKey>Burst-compatible struct over a NativeHashMap; fixed capacity; TryAdd upserts on a duplicate key; TryRemove/TryPeek return an arbitrary entry, not a keyed onen/a (is the DOTS variant)
SerializableDictionary<TKey, TValue>Any Unity-serializable TKeyNot part of IScyllaCollection; mirrors a live Dictionary<TKey, TValue> into parallel lists for Unity’s serializer and Inspector drawersnone

Recipes

These recipes cover every public surface across the four map types. Each one is a self-contained answer to a specific problem, so jump to the one that matches what you are building.

Look up combat entities and console commands by key

Problem. Your combat system looks up a CombatantStats struct by a uint entity ID every frame, and your console needs a command registry keyed by name where “SPAWN” and “spawn” should resolve to the same command. A plain Dictionary<TKey, TValue> boxes struct keys and values on every operation, and it has no way to swap in case-insensitive equality without wrapping every key manually.

Solution. Build the map with an initial capacity sized to the expected entry count, and add .WithComparer when default equality is not the equality wanted.

using Scylla.Core.Structures;

/* Direct construction: pre-sized capacity, default equality for a uint key. */
var combatants = new ScyllaMap<uint, CombatantStats>(capacity: 256);
combatants.TryAdd(entityID: 1001u, new CombatantStats { MaxHP = 100 });
combatants.TryGetValue(1001u, out CombatantStats stats);
combatants.TrySet(1001u, new CombatantStats { MaxHP = 120 });   /* update, does not insert */
combatants.TryRemove(1001u, out CombatantStats removed);

/* Builder with a custom comparer for case-insensitive command lookup. */
var commands = ScyllaMap<string, ConsoleCommand>.CreateBuilder()
    .WithCapacity(128)
    .WithComparer(StringComparer.OrdinalIgnoreCase)
    .Build();

commands.TryAdd("SPAWN", spawnCommand);
commands.TryGetValue("spawn", out ConsoleCommand found);  /* found via case-insensitive match */

Register per-component instances without boxing

Problem. Your module manager keeps more than one instance of some component types (a primary and a shield health pool, several combat sub-systems) and needs a single registry that can hold instances of any type, keyed by a small numeric ID, without boxing struct payloads or hand-rolling a Dictionary<Type, object> for every module.

Solution. ScyllaTypeMap keys each value first by its exact CLR Type, then by a uint ID within that type’s bucket. Buckets are created lazily and torn down automatically once empty.

using Scylla.Core.Structures;

var moduleRegistry = new ScyllaTypeMap(typeCapacity: 32, bucketCapacity: 16);

/* Two HealthModule instances, one CombatModule instance. Each type gets its own bucket. */
moduleRegistry.TryAdd<HealthModule>(id: 0, primaryHealth);
moduleRegistry.TryAdd<HealthModule>(id: 1, shieldHealth);
moduleRegistry.TryAdd<CombatModule>(id: 0, mainCombat);

bool hasHealth   = moduleRegistry.ContainsType<HealthModule>();
int  healthCount = moduleRegistry.CountOf<HealthModule>();      /* 2 */
moduleRegistry.TryGet<HealthModule>(id: 0, out HealthModule found);

/* Upsert: TrySet updates an existing (type, id) or adds it if missing. */
moduleRegistry.TrySet<HealthModule>(id: 0, upgradedHealth);

/* Remove one entry; the bucket survives as long as it still holds entries. */
moduleRegistry.TryRemove<HealthModule>(id: 1, out HealthModule removedShield);

/* Drop every HealthModule at once and remove the now-empty bucket. */
int clearedCount = moduleRegistry.ClearType<HealthModule>();

Edit a spawn-weight table from the Inspector

Problem. A designer needs to tune a table of enemy spawn weights directly on a ScriptableObject, without a hand-rolled parallel array of keys and values or custom OnBeforeSerialize/OnAfterDeserialize logic. The table also needs to be seeded from a Dictionary<string, int> computed at import time.

Solution. SerializableDictionary<TKey, TValue> matches the IDictionary<TKey, TValue> surface, so existing dictionary-handling code keeps working; the only difference is the pair of ISerializationCallbackReceiver hooks that mirror the live dictionary into parallel _keys/_values lists around serialization.

using Scylla.Core.Structures;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "MyGame/SpawnTable")]
public sealed class SpawnTable : ScriptableObject
{
    [SerializeField]
    private SerializableDictionary<string, int> _spawnWeights = new();

    public int GetWeight(string enemyType)
    {
        return _spawnWeights.TryGetValue(enemyType, out int weight) ? weight : 0;
    }
}

/* Seed a table at import time from data computed elsewhere. The
 * IDictionary constructor copies every entry up front. */
var computedWeights = new Dictionary<string, int> { ["Grunt"] = 5, ["Elite"] = 1 };
var seeded = new SerializableDictionary<string, int>(computedWeights);

Feed a key-value lookup into a Burst job

Problem. A crowd simulation or collision-bucketing job runs on worker threads and needs to accumulate per-entity counts (contact counts, hit counts) without touching the managed heap. The managed ScyllaMap<TKey, TValue> cannot be marshalled into Burst-compiled code.

Solution. ScyllaMapDOTS<TKey, TValue> wraps a NativeHashMap behind the same Try* shape, with an explicit Allocator and a fixed capacity decided up front. Both TKey and TValue must be unmanaged, and TKey must implement IEquatable<TKey>.

using Scylla.Core.Structures;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;

[BurstCompile]
public struct ContactCountJob : IJob
{
    public ScyllaMapDOTS<int, int> ContactCounts;
    public int EntityID;

    public void Execute()
    {
        /* TryAdd upserts: an existing key gets its value replaced, a new
         * key is inserted if the map has not reached capacity yet. */
        ContactCounts.TryGetValue(EntityID, out int current);
        ContactCounts.TryAdd(EntityID, current + 1);
    }
}

/* Build with the builder, matching the pattern of the managed collections. */
var contactCounts = ScyllaMapDOTS<int, int>.CreateBuilder()
    .WithAllocator(Allocator.TempJob)
    .WithCapacity(1024)
    .Build();

var job = new ContactCountJob { ContactCounts = contactCounts, EntityID = 42 };
job.Schedule().Complete();

contactCounts.TryGetValue(42, out int finalCount);
contactCounts.Dispose();  /* native memory is not garbage-collected */

Share a registry across a worker thread and the main thread

Problem. A loading thread populates a map (asset path to loaded handle, or similar) while the main thread reads from it during the same frame. Wrapping every call site in a hand-written lock is easy to get wrong, especially for multi-step sequences like “check contains, then add”.

Solution. Both ScyllaMap and ScyllaTypeMap expose a synchronized wrapper that serializes every operation on a shared SyncRoot, obtained either from the builder’s .Synchronized() step or via AsSynchronized() after construction.

using Scylla.Core.Structures;

/* Build directly as a synchronized wrapper. */
var assetHandles = ScyllaMap<string, AssetHandle>.CreateBuilder()
    .WithCapacity(256)
    .Synchronized()
    .Build();   /* returns IScyllaMap<string, AssetHandle>; the concrete type is SynchronizedScyllaMap */

/* Loader thread: safe to call concurrently with the main thread. */
assetHandles.TryAdd("props/crate", crateHandle);

/* Main thread: a multi-step "check then act" sequence needs the lock held
 * for the whole sequence, not just each individual call. */
lock (assetHandles.SyncRoot)
{
    if (!assetHandles.ContainsKey("props/crate"))
    {
        assetHandles.TryAdd("props/crate", FallbackHandle);
    }
}

/* Wrapping an already-built unsynchronized map works the same way. */
var innerMap = new ScyllaMap<string, AssetHandle>(capacity: 256);
var shared = innerMap.AsSynchronized();

Pre-size a map and iterate it without allocating

Problem. The rough final size of a map is known ahead of time (a level’s worth of pickups, a wave’s worth of enemies) but the exact count is not known at construction, and every frame a debug overlay walks the map’s live entries without wanting an enumerator that boxes on the heap.

Solution. EnsureCapacity grows the table once the real size is known, ahead of a burst of insertions. GetEnumerator() on the concrete ScyllaMap<TKey, TValue> type returns a value-type Enumerator, so a foreach over the concrete type (not the IScyllaMap<TKey, TValue> interface) allocates nothing.

using Scylla.Core.Structures;
using System.Collections.Generic;

var pickups = new ScyllaMap<int, PickupData>(capacity: 16);

/* Level load just determined the real count; grow once instead of
 * paying for several incremental rehashes during the insert burst. */
pickups.EnsureCapacity(minCapacity: 512);

for (var i = 0; i < levelPickups.Length; i++)
{
    pickups.TryAdd(levelPickups[i].ID, levelPickups[i]);
}

/* Allocation-free iteration: foreach on the concrete type resolves
 * GetEnumerator() without going through IEnumerable<T>. */
foreach (KeyValuePair<int, PickupData> entry in pickups)
{
    DrawDebugMarker(entry.Key, entry.Value.Position);
}

API Sketch

The public surface across the four map types and their supporting types. See the API reference for full signatures, remarks, and exception contracts on every member.

namespace Scylla.Core.Structures
{
    /* Shared map contract. Note this is narrower than the concrete ScyllaMap
     * surface below; TrySet, the two-argument TryRemove, and EnsureCapacity
     * are members of the concrete type, not of this interface. */
    public interface IScyllaMap<TKey, TValue> : IScyllaCollection
    {
        bool ContainsKey (TKey key);
        bool TryGetValue (TKey key, out TValue value);
        bool TryAdd      (TKey key, TValue value);
        bool TryRemove   (TKey key);
    }

    /* Managed open-addressing hash map. */
    public sealed class ScyllaMap<TKey, TValue> : IScyllaMap<TKey, TValue>
    {
        public ScyllaMap(int capacity = 4, bool fixedCapacity = false,
            IEqualityComparer<TKey> comparer = null);
        public int  Capacity        { get; }
        public bool IsFixedCapacity { get; }
        public bool ContainsKey (TKey key);
        public bool TryGetValue (TKey key, out TValue value);
        public bool TryAdd      (TKey key, TValue value);
        public bool TryRemove   (TKey key);
        public bool TryRemove   (TKey key, out TValue value);
        public bool TrySet      (TKey key, TValue value);
        public void EnsureCapacity(int minCapacity);
        public Enumerator GetEnumerator();  /* value-type; allocation-free foreach */
        public SynchronizedScyllaMap AsSynchronized(object syncRoot = null);
        public static Builder CreateBuilder();

        public struct Enumerator
        {
            public KeyValuePair<TKey, TValue> Current { get; }
            public bool MoveNext();
        }

        public sealed class Builder
        {
            public Builder WithCapacity (int capacity);
            public Builder FixedCapacity(bool value = true);
            public Builder WithComparer (IEqualityComparer<TKey> comparer);
            public Builder Synchronized (object syncRoot = null);
            public IScyllaMap<TKey, TValue> Build();
            public ScyllaMap<TKey, TValue>  BuildConcrete();
        }

        public sealed class SynchronizedScyllaMap : IScyllaMap<TKey, TValue>
        {
            public int  Capacity        { get; }
            public bool IsFixedCapacity { get; }
            public bool TrySet(TKey key, TValue value);
            public bool TryRemove(TKey key, out TValue value);
            public void EnsureCapacity(int minCapacity);
            /* + the shared IScyllaMap<TKey,TValue> members, all lock-guarded. */
        }
    }

    /* Type-and-ID registry. */
    public sealed class ScyllaTypeMap : IScyllaCollection
    {
        public ScyllaTypeMap(int typeCapacity = 4, int bucketCapacity = 4,
            bool fixedCapacity = false, IEqualityComparer<Type> typeComparer = null);
        public int  TypeCount             { get; }
        public int  TypeCapacity          { get; }
        public int  BucketDefaultCapacity { get; }
        public bool IsFixedCapacity       { get; }
        public bool ContainsType<T>();
        public int  CountOf<T>();
        public bool Contains<T>  (uint id);
        public bool TryGet<T>    (uint id, out T value);
        public bool TryAdd<T>    (uint id, T value);
        public bool TrySet<T>    (uint id, T value);
        public bool TryRemove<T> (uint id, out T value);
        public int  ClearType<T> ();
        public SynchronizedScyllaTypeMap AsSynchronized(object syncRoot = null);
        public static Builder CreateBuilder();

        public sealed class Builder
        {
            public Builder WithTypeCapacity  (int typeCapacity);
            public Builder WithBucketCapacity(int bucketCapacity);
            public Builder FixedCapacity     (bool value = true);
            public Builder WithTypeComparer  (IEqualityComparer<Type> typeComparer);
            public Builder Synchronized      (object syncRoot = null);
            public IScyllaCollection Build();
            public ScyllaTypeMap    BuildConcrete();
        }

        public sealed class SynchronizedScyllaTypeMap : IScyllaCollection { /* same shape, lock-guarded */ }
    }

    /* Burst/DOTS-friendly native map. */
    [BurstCompile]
    public struct ScyllaMapEntry<TKey, TValue> where TKey : unmanaged, IEquatable<TKey> where TValue : unmanaged
    {
        public TKey   Key;
        public TValue Value;
        public ScyllaMapEntry(TKey key, TValue value);
    }

    [BurstCompile]
    public struct ScyllaMapDOTS<TKey, TValue> : IScyllaCollection<ScyllaMapEntry<TKey, TValue>>, IDisposable
        where TKey : unmanaged, IEquatable<TKey> where TValue : unmanaged
    {
        public ScyllaMapDOTS(int capacity, Allocator allocator);
        public bool IsCreated { get; }
        public int  Capacity  { get; }
        public bool IsFull    { get; }
        public int  FreeCount { get; }
        public bool ContainsKey (TKey key);
        public bool TryGetValue (TKey key, out TValue value);
        public bool Remove      (TKey key);
        public bool TryAdd      (TKey key, TValue value);                    /* upserts on an existing key */
        public bool TryAdd      (ScyllaMapEntry<TKey, TValue> item);
        public bool TryRemove   (out ScyllaMapEntry<TKey, TValue> item);     /* arbitrary entry, not keyed */
        public bool TryPeek     (out ScyllaMapEntry<TKey, TValue> item);     /* arbitrary entry, not keyed */
        public int  CopyTo      (Span<ScyllaMapEntry<TKey, TValue>> destination);
        public int  CopyTo      (ScyllaMapEntry<TKey, TValue>[] destination, int destinationIndex);
        public void Dispose();
        public static Builder CreateBuilder();

        public sealed class Builder
        {
            public Builder WithAllocator(Allocator allocator);
            public Builder WithCapacity (int capacity);
            public ScyllaMapDOTS<TKey, TValue> Build();
            public ScyllaMapDOTS<TKey, TValue> BuildConcrete();
        }
    }
}

namespace Scylla.Core.Structures
{
    /* Unity-serializable dictionary. Not part of IScyllaCollection; no
     * builder, no synchronized wrapper, no DOTS variant. */
    [Serializable]
    public class SerializableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, ISerializationCallbackReceiver
    {
        public SerializableDictionary();
        public SerializableDictionary(IDictionary<TKey, TValue> source);
        public int  Count      { get; }
        public bool IsReadOnly { get; }
        public ICollection<TKey>   Keys   { get; }
        public ICollection<TValue> Values { get; }
        public TValue this[TKey key] { get; set; }
        public void Add         (TKey key, TValue value);
        public bool ContainsKey (TKey key);
        public bool Remove      (TKey key);
        public bool TryGetValue (TKey key, out TValue value);
        public void Clear();
        public void OnBeforeSerialize();
        public void OnAfterDeserialize();
    }
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Builder reference

ScyllaMap builder

SetterDefaultRequiredEffect
WithCapacity4NoInitial slot count, rounded up to the next power of two. Throws ArgumentOutOfRangeException if less than 1.
FixedCapacityfalseNoDisables growth. Insertions past capacity return false instead of triggering a rehash; only tombstone compaction still runs.
WithComparernull (EqualityComparer<TKey>.Default)NoCustom key equality, for example StringComparer.OrdinalIgnoreCase.
SynchronizedfalseNoWraps the built map in SynchronizedScyllaMap. Build() then returns the wrapper; BuildConcrete() throws InvalidOperationException.

ScyllaTypeMap builder

SetterDefaultRequiredEffect
WithTypeCapacity4NoInitial capacity of the top-level type table. Throws ArgumentOutOfRangeException if less than 1.
WithBucketCapacity4NoInitial capacity used for every newly created per-type bucket. Throws ArgumentOutOfRangeException if less than 1.
FixedCapacityfalseNoDisables growth on both the type table and every bucket.
WithTypeComparernull (EqualityComparer<Type>.Default)NoCustom equality for the Type keys in the top-level table.
SynchronizedfalseNoWraps the built type map in SynchronizedScyllaTypeMap. Build() returns the wrapper; BuildConcrete() throws InvalidOperationException.

ScyllaMapDOTS builder

SetterDefaultRequiredEffect
WithAllocator(none)YesUnity memory allocator for the underlying NativeHashMap. Throws ArgumentException for Allocator.None. Build() and BuildConcrete() both throw InvalidOperationException if this was never called.
WithCapacity4NoFixed maximum entry count, clamped to at least 1 by the constructor. Throws ArgumentOutOfRangeException if negative.

SerializableDictionary

SerializableDictionary<TKey, TValue> has no CreateBuilder(). Construct it directly with the parameterless constructor or the IDictionary<TKey, TValue> copy constructor, both shown in the recipe above.

Best Practices

  • Reach for ScyllaMap first when keys or values are structs. The open-addressing implementation stores TKey/TValue directly in typed arrays, avoiding the per-operation boxing Dictionary<TKey, TValue> pays for with struct type arguments.
  • Pre-size with WithCapacity or EnsureCapacity when the entry count is predictable. Every rehash reallocates all three parallel arrays and re-inserts every live entry; sizing up front, or ahead of a known insertion burst, avoids paying for it more than once.
  • Use TrySet for updates, TryAdd for inserts. TryAdd never overwrites an existing key: it returns false and leaves the map unchanged. Reach for TrySet when the intent is “update if present,” and combine the two only when upsert behavior is genuinely required.
  • Supply a custom comparer instead of pre-processing keys. .WithComparer(StringComparer.OrdinalIgnoreCase), or any other IEqualityComparer<TKey>, is cheaper and less error-prone than manually normalizing every key before it touches the map.
  • Use ScyllaTypeMap for “one bucket per type” registries, not for general key-value storage. Its two-level structure earns its cost when several instances of the same type each need a numbered slot; for a plain single-key lookup, ScyllaMap<TKey, TValue> is the simpler and cheaper choice.
  • Remember ScyllaTypeMap matches on the exact generic type argument. Registering under TryAdd<Concrete> and reading back under TryGet<IInterface> will not find the entry, even when Concrete implements IInterface. Use one consistent type parameter per bucket.
  • Reach for the synchronized wrapper only when a map is genuinely shared across threads. AsSynchronized() and the builder’s .Synchronized() step both add a lock around every operation; on a single-thread hot path that lock is pure overhead with no benefit.
  • Hold SyncRoot for the whole compound operation, not just one call. A synchronized wrapper makes every individual method call atomic, but a “check then add” sequence still needs an external lock (map.SyncRoot) around both calls to avoid a race between them.
  • Use ScyllaMapDOTS only inside Burst-compiled or job-scheduled code. Outside that context it adds the disposal ceremony of a native container with none of the Burst throughput benefit; the managed ScyllaMap<TKey, TValue> is simpler for ordinary game code.
  • Remember the DOTS map upserts on TryAdd. Re-registering an existing key succeeds and replaces the value; the fixed-capacity check only blocks insertion of a genuinely new key. This differs from the managed ScyllaMap, which rejects duplicate keys outright.
  • Dispose every ScyllaMapDOTS instance. The Unity allocator levels have specific lifetimes (Temp one frame, TempJob up to four frames, Persistent until explicitly disposed); forgetting Dispose() leaks native memory. See Collections Overview for the allocator guidance shared across every DOTS-suffixed structure.
  • Use SerializableDictionary only where Unity serialization is the point. It carries list-mirroring overhead on every serialize/deserialize pass that ScyllaMap and Dictionary<TKey, TValue> do not; reach for it only when the dictionary must round-trip through the Inspector or an asset file.
  • Iterate the concrete ScyllaMap<TKey, TValue> type in hot loops, not the IScyllaMap<TKey, TValue> interface. GetEnumerator() on the concrete type is a value-type struct resolved without interface dispatch, so a foreach over the concrete map allocates nothing; enumerating through the interface falls back to a boxed enumerator.

Pitfalls