Array and list helpers that grow a pooled buffer in place, search without allocating, and return a sensible default instead of throwing when the collection is null or empty.
Summary
ArrayUtil and CollectionUtil are the everyday helpers for the two collections a game actually spends its time in: the raw T[] behind a pooled buffer, and the List<T> behind almost everything else. They cover growing, clearing, filling, searching, sorting and the small removals that otherwise get rewritten in every system that manages a buffer.
The characteristic worth knowing before anything else is that they do not throw. Forty-three of the forty-six methods guard explicitly against a null or empty collection and return a sensible default: GetFirst on an empty list returns default(T), IndexOf on a null array returns -1, RemoveAtIndex with a bad index returns default and leaves the list alone. That is a deliberate trade. It makes these helpers safe to call from teardown paths, from a system whose dependency has not initialized yet, and from anywhere a NullReferenceException would be noise rather than information.
The two classes differ in shape. ArrayUtil takes arrays as ordinary parameters, and its growth methods take ref T[] because growing an array means replacing it. CollectionUtil is extension methods on List<T> and IList<T>, so it reads as enemies.GetLast() rather than as a static call.
Where these end and the framework’s own collections begin is worth a sentence. These are helpers for the built-in types. When you want a ring buffer, a priority queue, a spatial tree or a pool, those are real data structures with their own pages, starting at Collections Overview.
Use the collection utilities for:
- Growing a pooled array in place as a system’s working set expands, without churning through
List<T>allocations. - Searching an array by predicate in a hot path where LINQ’s allocations are not acceptable.
- Clearing or refilling a reused buffer between frames or between waves.
- Removing an item from a list and getting it back in one call, for a queue-like or stack-like pattern.
- Sorting one collection by the order of another, using an index array rather than zipping the two together.
Features
- Null-tolerant throughout. Almost every method checks for a null or empty collection and returns a default rather than throwing, so these are safe in teardown and early-initialization paths.
- In-place array growth.
EnsureCapacity,GrowBy,ResizeIfNecessaryandTrimExcesstakeref T[]and replace the array, which is how a pooled buffer grows without becoming aList<T>. - Allocation-free searching.
IndexOf,LastIndexOf,Contains,Find,FindIndex,FindLastIndex,ExistsandCountWhereall answer without allocating, unlike their LINQ equivalents. - Ranged clear and fill.
ClearandFilleach have a whole-collection form and a start-plus-count form, so a partially used buffer can be reset without touching the rest. - Four sort overloads. Natural order, an
IComparer<T>, aComparison<T>delegate, or a sub-range by start and count. - Remove and return in one call.
RemoveAtIndex,RemoveFirstandRemoveLasthand back the item they removed, which is what a work queue or an undo stack wants. - Safe first and last access.
GetFirstandGetLastreturndefaulton an empty list; theTryGetFirstandTryGetLastpair tell you which happened. - Index-based sorting.
CreateSortIndicesreturns the source indices in sorted order instead of sorting in place, which is how you reorder several parallel arrays consistently. - List resizing with a fill value.
Resizegrows or shrinks aList<T>to an exact size, padding with a value you choose.
The two classes
They cover the same ground for different collection types, and the split is about how each type is grown rather than about capability.
| Class | Operates on | Call style | Notes |
|---|---|---|---|
ArrayUtil | T[] | Static calls, ArrayUtil.IndexOf(buffer, item) | The array side, and the only one with growth methods, because growing an array means allocating a new one and copying. Those four methods take ref T[] and reassign your variable. Everything else takes the array by value and either reads it or mutates its contents in place. This is the class to reach for when a system owns a raw buffer and does not want List<T> semantics. |
CollectionUtil | List<T>, IList<T> | Extension methods, enemies.GetLast() | The list side. Most methods have both a List<T> and an IList<T> overload, so they work against an interface-typed field as well as a concrete list. List<T> already grows itself, so there is no capacity family here; what it adds instead is the safe accessors, the remove-and-return trio, and CreateSortIndices. |
The ref T[] signature on the growth methods is the part people trip over. EnsureCapacity(ref buffer, 64) may replace buffer with a larger array, so any other variable still pointing at the old one is now stale. Keep a single owner for a growable buffer.
Recipes
Each recipe answers one practical problem and stands alone.
Grow a pooled buffer as a wave gets bigger
Problem. Your spawner keeps a reusable array of active enemies so it is not allocating a fresh List<T> every wave. Wave 12 is bigger than wave 1, and the buffer needs to grow without you writing the copy by hand.
Solution. The growth family takes the array by reference and replaces it, preserving what was already there.
private Enemy[] _activeEnemies = new Enemy[32];
private int _activeCount;
public void SpawnWave(int waveSize)
{
/* Grows only when the current array is too small, so calling it every wave is
cheap. Existing contents are copied across; the tail is default-filled. */
ArrayUtil.EnsureCapacity(ref _activeEnemies, waveSize);
for (var i = 0; i < waveSize; i++)
{
_activeEnemies[i] = SpawnEnemy();
}
_activeCount = waveSize;
}
/* ResizeIfNecessary is the same idea expressed as "make sure this index is writable",
which suits code that discovers the index it needs rather than the count. */
ArrayUtil.ResizeIfNecessary(ref _activeEnemies, spawnIndex);
/* GrowBy adds capacity relative to the current size, for amortized growth. */
ArrayUtil.GrowBy(ref _activeEnemies, 16);
/* And TrimExcess shrinks back down once a wave is over, taking the used count so the
live entries survive. Worth doing after a boss wave, not between every wave. */
ArrayUtil.TrimExcess(ref _activeEnemies, _activeCount);
Reset a reused buffer between waves
Problem. You reuse one array across waves, and stale references from the last wave keep the dead enemies alive as far as the garbage collector is concerned.
Solution. Clear the used portion. The ranged overload avoids touching capacity you never wrote to.
/* Clearing only the used range is cheaper than clearing the whole array, and it is
the part that actually holds stale references. */
ArrayUtil.Clear(_activeEnemies, 0, _activeCount);
_activeCount = 0;
/* Fill is the same shape when the reset value is not the default: a fresh grid of
unrevealed fog tiles, or a cost buffer primed to "unreachable". */
ArrayUtil.Fill(_pathCosts, int.MaxValue);
ArrayUtil.Fill(_fogTiles, FogState.Hidden, 0, _visibleTileCount);
Find a target without allocating
Problem. Your targeting code runs every frame over the active enemy buffer and needs the nearest one below a health threshold. LINQ would read nicely and allocate an enumerator and a closure every call.
Solution. The predicate-based search methods answer the same questions without allocating.
/* Find returns the first match, or default(T) when nothing matches. For a class type
that is null, so check before using it. */
var wounded = ArrayUtil.Find(_activeEnemies, e => e != null && e.HealthFraction < 0.25f);
/* FindIndex when you need the slot rather than the item, which is what an in-place
removal or a parallel-array lookup wants. Returns -1 when nothing matches. */
var index = ArrayUtil.FindIndex(_activeEnemies, e => e != null && e.IsElite);
/* Exists is the cheap "is there any" question; it stops at the first match. */
if (ArrayUtil.Exists(_activeEnemies, e => e != null && e.IsBoss))
{
musicDirector.SwitchToBossTheme();
}
/* CountWhere for a tally, without materializing the matches. */
var eliteCount = ArrayUtil.CountWhere(_activeEnemies, e => e != null && e.IsElite);
/* FindAll does allocate, because it returns a new array. Use it when you genuinely
need the subset, not as a step toward a count or an existence check. */
var elites = ArrayUtil.FindAll(_activeEnemies, e => e != null && e.IsElite);
/* IndexOf, LastIndexOf and Contains cover the same ground for a known value rather
than a predicate. All three return sensibly on a null array. */
var slot = ArrayUtil.IndexOf(_activeEnemies, specificEnemy);
Pull the next job off a work list
Problem. Your AI runs a list of pending requests and needs to take one, process it, and have it gone from the list. Two calls to read then remove is a chance to get the index wrong between them.
Solution. The remove-and-return trio does both in one call.
/* RemoveFirst gives queue semantics: oldest job first. Returns default when the list
is empty, so a null check replaces an emptiness check for reference types. */
var job = _pendingRequests.RemoveFirst();
if (job != null)
{
Process(job);
}
/* RemoveLast gives stack semantics, which is what an undo history or a nested UI
screen stack wants. */
var lastScreen = _screenStack.RemoveLast();
/* RemoveAtIndex when the position came from a search. Returns the removed item and
leaves the list untouched if the index is out of range. */
var removed = _activeEffects.RemoveAtIndex(expiredIndex);
Read the ends of a list that might be empty
Problem. You want the most recent entry in a history list, and the list is empty on the first frame. Guarding every access with a count check clutters the call site.
Solution. GetLast returns default rather than throwing, and the Try pair distinguishes “empty” from “the value really is default”.
/* Safe on an empty list: returns default(T) rather than throwing. For a class type
that is null, which is usually the check you were going to write anyway. */
var lastCheckpoint = _checkpoints.GetLast();
/* The Try form when default is a legitimate value and you need to tell the two apart.
A struct history where 0 is a real reading is the usual case. */
if (_damageHistory.TryGetLast(out var lastHit))
{
ShowDamageNumber(lastHit);
}
/* GetFirst and TryGetFirst mirror them at the other end. */
var oldestQuest = _questLog.GetFirst();
Reorder parallel arrays consistently
Problem. You hold positions, health and team IDs in three parallel arrays, and you need them sorted by distance. Sorting one array leaves the other two mismatched, and zipping them into a temporary object per entry defeats the point of the layout.
Solution. CreateSortIndices returns the source indices in sorted order, so you can apply one ordering to as many arrays as you like.
/* Returns an int[] of source indices in ascending sorted order. The list itself is
not modified, so it is safe to call on data another system is reading. */
var order = _distances.CreateSortIndices();
/* Apply the same ordering to every parallel array. */
for (var i = 0; i < order.Length; i++)
{
var source = order[i];
_sortedPositions[i] = _positions[source];
_sortedHealth[i] = _health[source];
_sortedTeams[i] = _teams[source];
}
/* The comparer overload for a custom order: threat descending rather than distance
ascending, say. */
var byThreat = _targets.CreateSortIndices(new ThreatComparer());
Sort in place with the ordering you actually want
Problem. Your loot table needs sorting by rarity, and the natural order of the type is not the order you want.
Solution. Four overloads cover natural order, a comparer, a delegate, and a sub-range.
/* Natural order, for a type that implements IComparable<T>. */
ArrayUtil.Sort(_dropWeights);
/* A Comparison<T> delegate is the lightest way to express a one-off order. */
ArrayUtil.Sort(_lootTable, (a, b) => b.Rarity.CompareTo(a.Rarity));
/* An IComparer<T> when the same ordering is used in several places and deserves a
name rather than being repeated as a lambda. */
ArrayUtil.Sort(_lootTable, new RarityDescendingComparer());
/* And a sub-range, for a buffer where only the first N slots are live. Sorting the
whole array would drag the unused default entries into the ordering. */
ArrayUtil.Sort(_activeEnemies, 0, _activeCount);
Swap, reverse and resize without the boilerplate
Problem. The small operations. Swapping two entries, flipping a turn order, padding a list out to a fixed size.
Solution. All three exist on both classes, with the same null tolerance as everything else.
/* Swap by index, for a shuffle step or a manual reorder in a UI list. */
_turnOrder.Swap(currentIndex, currentIndex - 1);
ArrayUtil.Swap(_inventorySlots, fromSlot, toSlot);
/* Reverse in place, whole or ranged. Reversing a turn order for a "reverse turn"
card is the obvious case; the ranged form suits reversing one segment of a path. */
_turnOrder.Reverse();
ArrayUtil.Reverse(_pathNodes, startIndex, segmentLength);
/* Resize grows or shrinks a List<T> to an exact size, padding new entries with a
value you choose rather than default. */
_hotbarSlots.Resize(newSlotCount, InventorySlot.Empty);
/* Fill and Copy round out the array side. */
var snapshot = ArrayUtil.Copy(_activeEnemies);
ArrayUtil.CopyTo(_activeEnemies, ref _previousFrameEnemies);
API Sketch
Both classes live in Scylla.Core.Util. ArrayUtil is static calls on T[]; CollectionUtil is extension methods on List<T> and IList<T>.
/* ---------------------------------------------------------------- */
/* ArrayUtil: static, operates on T[] */
/* ---------------------------------------------------------------- */
/* Growth. These take ref and may replace the array. */
void EnsureCapacity<T>(ref T[] array, int minCapacity);
void ResizeIfNecessary<T>(ref T[] array, int requiredIndex);
void GrowBy<T>(ref T[] array, int additionalCapacity);
void TrimExcess<T>(ref T[] array, int usedCount);
/* Contents */
void Clear<T>(T[] array);
void Clear<T>(T[] array, int startIndex, int count);
void Fill<T>(T[] array, T value);
void Fill<T>(T[] array, T value, int startIndex, int count);
T[] Copy<T>(T[] source);
void CopyTo<T>(T[] source, ref T[] destination);
void Swap<T>(T[] array, int indexA, int indexB);
void Reverse<T>(T[] array);
void Reverse<T>(T[] array, int startIndex, int count);
/* Search. All allocation-free except FindAll. */
int IndexOf<T>(T[] array, T item);
int LastIndexOf<T>(T[] array, T item);
bool Contains<T>(T[] array, T item);
T Find<T>(T[] array, Predicate<T> match);
int FindIndex<T>(T[] array, Predicate<T> match);
int FindLastIndex<T>(T[] array, Predicate<T> match);
bool Exists<T>(T[] array, Predicate<T> match);
int CountWhere<T>(T[] array, Predicate<T> match);
T[] FindAll<T>(T[] array, Predicate<T> match); // allocates the result
/* Sort */
void Sort<T>(T[] array);
void Sort<T>(T[] array, IComparer<T> comparer);
void Sort<T>(T[] array, Comparison<T> comparison);
void Sort<T>(T[] array, int startIndex, int count);
/* ---------------------------------------------------------------- */
/* CollectionUtil: extension methods on List<T> and IList<T> */
/* ---------------------------------------------------------------- */
/* Safe ends. Get* return default on empty; TryGet* distinguish empty from default. */
T GetFirst<T>(this List<T> list);
T GetFirst<T>(this IList<T> list);
T GetLast<T>(this List<T> list);
T GetLast<T>(this IList<T> list);
bool TryGetFirst<T>(this List<T> list, out T result);
bool TryGetFirst<T>(this IList<T> list, out T result);
bool TryGetLast<T>(this List<T> list, out T result);
bool TryGetLast<T>(this IList<T> list, out T result);
/* Remove and return */
T RemoveAtIndex<T>(this List<T> list, int index);
T RemoveFirst<T>(this List<T> list);
T RemoveLast<T>(this List<T> list);
/* Contents */
void Swap<T>(this IList<T> list, int indexA, int indexB);
void Reverse<T>(this List<T> list);
void Reverse<T>(this IList<T> list);
void Fill<T>(this IList<T> list, T value);
void Resize<T>(this List<T> list, int newSize, T defaultValue = default);
/* Index-based ordering. Returns source indices; does not modify the list. */
int[] CreateSortIndices<T>(this IList<T> list);
int[] CreateSortIndices<T>(this IList<T> list, IComparer<T> comparer);
See the API reference for full signatures, remarks, and exception contracts on every member.
Best Practices
- Give a growable array exactly one owner. The
ref T[]growth methods replace the array, so a second reference held anywhere else goes stale the moment it grows. - Clear the used range, not the whole array. Clearing capacity you never wrote to is wasted work, and the ranged overload exists precisely so you do not have to.
- Clear arrays of reference types between reuses. A stale slot keeps the object it points at alive. For struct arrays this is a correctness question rather than a memory one.
- Prefer the predicate searches to LINQ in anything per-frame.
Exists,FindIndexandCountWhereanswer the same questions without the enumerator and closure allocations. - Remember
FindAllallocates. It returns a new array, so it is the one search method that does not belong in a hot path. If you only need a count or a yes-or-no, useCountWhereorExists. - Use
TryGetFirstandTryGetLastwhendefaultis a real value. For a class type,GetFirstreturning null is unambiguous. For a struct where zero is legitimate, only theTryform tells you whether the list was empty. - Watch the cost of
RemoveFirston long lists. It shifts every remaining element, which is fine for a handful of pending jobs but wrong for a per-frame drain of thousands. - Reach for
CreateSortIndiceswhen data lives in parallel arrays. It is the one call that lets several arrays share an ordering without packing them into temporary objects. - Cache the index array when sorting repeatedly.
CreateSortIndicesallocates its result, so a per-frame call is a per-frame allocation. - Sort the live range, not the whole buffer. A pooled array’s unused tail is full of defaults, and sorting the whole thing drags them into the ordering.
- Do not treat these as a substitute for the right data structure. They are helpers for
T[]andList<T>. A ring buffer, a priority queue or a spatial tree solves a different problem, and those live under Collections Overview. - Let the null tolerance simplify teardown, not hide bugs. Returning a default from a helper is right when the collection is legitimately absent. It is not a reason to skip the check that would have told you a dependency never initialized.