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.ScyllaTypeMapadds a second key dimension on top of the same open-addressing map: it groups values by CLRTypefirst and by auintID 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 aNativeHashMapfor job code that cannot touch the managed heap.SerializableDictionary<TKey, TValue>solves a different problem entirely: it does not implementIScyllaCollectionand 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
ScriptableObjector component Inspector. - Cross-thread producer/consumer lookups via a synchronized wrapper (managed maps only).
Features
- Open addressing with linear probing.
ScyllaMapavoids the chained-bucket allocationDictionary<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
ScyllaMapand the buckets insideScyllaTypeMapstoreTKey/TValuedirectly 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 soTryAddfails instead of resizing. - Custom key comparers.
.WithComparer(IEqualityComparer<TKey>)on theScyllaMapbuilder, and.WithTypeCompareron theScyllaTypeMapbuilder, swap in non-default equality, most commonlyStringComparer.OrdinalIgnoreCase. - Fluent builders on every managed variant.
ScyllaMap<TKey, TValue>.CreateBuilder()andScyllaTypeMap.CreateBuilder()return aBuilderwith.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)onScyllaMapandScyllaTypeMapreturns a nestedSynchronizedScyllaMap/SynchronizedScyllaTypeMapthat locks every operation on a sharedSyncRoot. - Allocation-free enumeration on the concrete type.
ScyllaMap<TKey, TValue>.GetEnumerator()returns a value-typeEnumeratoryieldingKeyValuePair<TKey, TValue>, so aforeachover the concrete map allocates nothing. - Type-and-ID registry with lazy, self-cleaning buckets.
ScyllaTypeMapmaps each CLRTypeto auint-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 aNativeHashMap<TKey, TValue>behind the sameTry*surface, withunmanagedkey/value constraints and explicitAllocator-based disposal. - Upsert semantics on the DOTS map. Unlike the managed map,
ScyllaMapDOTS.TryAddupdates 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>implementsIDictionary<TKey, TValue>plusISerializationCallbackReceiver, 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.
| Type | Keyed by | Distinguishing trait | DOTS 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 capacity | ScyllaMapDOTS<TKey, TValue> |
ScyllaTypeMap | CLR Type plus uint ID | Two-level registry: a ScyllaMap<Type, IBucket> on top of a per-type ScyllaMap<uint, T>; buckets created and torn down automatically | none |
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 one | n/a (is the DOTS variant) |
SerializableDictionary<TKey, TValue> | Any Unity-serializable TKey | Not part of IScyllaCollection; mirrors a live Dictionary<TKey, TValue> into parallel lists for Unity’s serializer and Inspector drawers | none |
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
| Setter | Default | Required | Effect |
|---|---|---|---|
WithCapacity | 4 | No | Initial slot count, rounded up to the next power of two. Throws ArgumentOutOfRangeException if less than 1. |
FixedCapacity | false | No | Disables growth. Insertions past capacity return false instead of triggering a rehash; only tombstone compaction still runs. |
WithComparer | null (EqualityComparer<TKey>.Default) | No | Custom key equality, for example StringComparer.OrdinalIgnoreCase. |
Synchronized | false | No | Wraps the built map in SynchronizedScyllaMap. Build() then returns the wrapper; BuildConcrete() throws InvalidOperationException. |
ScyllaTypeMap builder
| Setter | Default | Required | Effect |
|---|---|---|---|
WithTypeCapacity | 4 | No | Initial capacity of the top-level type table. Throws ArgumentOutOfRangeException if less than 1. |
WithBucketCapacity | 4 | No | Initial capacity used for every newly created per-type bucket. Throws ArgumentOutOfRangeException if less than 1. |
FixedCapacity | false | No | Disables growth on both the type table and every bucket. |
WithTypeComparer | null (EqualityComparer<Type>.Default) | No | Custom equality for the Type keys in the top-level table. |
Synchronized | false | No | Wraps the built type map in SynchronizedScyllaTypeMap. Build() returns the wrapper; BuildConcrete() throws InvalidOperationException. |
ScyllaMapDOTS builder
| Setter | Default | Required | Effect |
|---|---|---|---|
WithAllocator | (none) | Yes | Unity memory allocator for the underlying NativeHashMap. Throws ArgumentException for Allocator.None. Build() and BuildConcrete() both throw InvalidOperationException if this was never called. |
WithCapacity | 4 | No | Fixed 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
ScyllaMapfirst when keys or values are structs. The open-addressing implementation storesTKey/TValuedirectly in typed arrays, avoiding the per-operation boxingDictionary<TKey, TValue>pays for with struct type arguments. - Pre-size with
WithCapacityorEnsureCapacitywhen 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
TrySetfor updates,TryAddfor inserts.TryAddnever overwrites an existing key: it returnsfalseand leaves the map unchanged. Reach forTrySetwhen 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 otherIEqualityComparer<TKey>, is cheaper and less error-prone than manually normalizing every key before it touches the map. - Use
ScyllaTypeMapfor “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
ScyllaTypeMapmatches on the exact generic type argument. Registering underTryAdd<Concrete>and reading back underTryGet<IInterface>will not find the entry, even whenConcreteimplementsIInterface. 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
SyncRootfor 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 externallock (map.SyncRoot)around both calls to avoid a race between them. - Use
ScyllaMapDOTSonly 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 managedScyllaMap<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 managedScyllaMap, which rejects duplicate keys outright. - Dispose every
ScyllaMapDOTSinstance. The Unity allocator levels have specific lifetimes (Tempone frame,TempJobup to four frames,Persistentuntil explicitly disposed); forgettingDispose()leaks native memory. See Collections Overview for the allocator guidance shared across every DOTS-suffixed structure. - Use
SerializableDictionaryonly where Unity serialization is the point. It carries list-mirroring overhead on every serialize/deserialize pass thatScyllaMapandDictionary<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 theIScyllaMap<TKey, TValue>interface.GetEnumerator()on the concrete type is a value-type struct resolved without interface dispatch, so aforeachover the concrete map allocates nothing; enumerating through the interface falls back to a boxed enumerator.
Pitfalls
Related
- Collections Overview
- Queues, Stacks, and Buffers
- Specialized Structures
- API reference:
Scylla.Core.Structures.IScyllaCollection`1 - API reference:
Scylla.Core.Structures.ScyllaMap`2 - API reference:
Scylla.Core.Structures.IScyllaMap`2 - API reference:
Scylla.Core.Structures.ScyllaMapDOTS`2 - API reference:
Scylla.Core.Structures.ScyllaMapEntry`2 - API reference:
Scylla.Core.Structures.ScyllaTypeMap - API reference:
Scylla.Core.Structures.SerializableDictionary`2