Variant: types, validation and shared containers
Strict C# data reads, numeric bounds, Nil, copying and lifetime.
Variant carries one engine value together with its type tag. It is used at dynamic API boundaries: properties, method calls, signals and engine collections. It is not a replacement for every C# variable, an arbitrary CLR object box or a save-file format.
Keep ordinary gameplay fields strongly typed. Read a Variant deliberately when an API or data container gives you one: check the tag, check the value's constraints, then convert.
Know the stored type
| C# value | Engine tag | Reading it back |
|---|---|---|
| default(Variant), null engine object | Nil | Obj is null. An empty string or empty collection is a different value. |
| bool | Bool | AsBool. Do not silently accept it as an integer field. |
| int, long | Int | The stored integer is signed 64-bit; Obj boxes it as long. |
| float, double | Float | The stored number is double precision; Obj boxes it as double. A float's original precision is not recovered by widening. |
| string | String | AsString. This is text, not automatically a number validated for your schema. |
| Vector2 and other supported engine value types | The corresponding tag | Read the matching type; Vector2I and Vector2 have different tags. |
| ReckfellObject-derived instance | Object | Object identity, not a cloned object or a permanent lifetime guarantee. |
| Reckfell.Collections.Array / Dictionary | Array / Dictionary | Extraction shares the underlying container. |
| int[] | PackedInt32Array | Conversion copies managed array contents; AsInt32Array returns a managed copy. |
Use VariantType to inspect the stored engine tag. value.Obj is int is not the right test for a Variant Int: Obj returns long. Likewise, a Variant created from float produces a boxed double.
C++ Variant and the C# wrapper implement the same engine value categories, but their memory representation and lifetime mechanisms differ. Do not apply a native struct-size statement to .NET allocation costs. A C# List<T> or arbitrary class is not automatically an engine Array or a supported Variant payload; construct an appropriate supported value first. Generic From<T>/As<T> also require a supported type.
Conversion is not validation
AsInt32, an explicit cast to int and As<int>() perform conversion. They do not promise that the original tag was Int or that the value fitted the target width. In the installed runtime, "42" converts to 42, true to 1, and 3.75 to integer 3. An Int containing 2147483648 narrows through AsInt32 to -2147483648.
For a field that must be an actual 32-bit integer, check the tag and the 64-bit range first. For positions, decide whether nonfinite vectors are acceptable. This helper rejects NaN/infinity and requires exactly Vector2:
using Reckfell;
public static class VariantBoundary
{
public static bool TryGetInt32(Variant value, out int result)
{
result = 0;
if (value.VariantType != Variant.Type.Int)
return false;
long number = value.AsInt64();
if (number < int.MinValue || number > int.MaxValue)
return false;
result = (int)number;
return true;
}
public static bool TryGetFiniteVector2(Variant value, out Vector2 result)
{
result = Vector2.Zero;
if (value.VariantType != Variant.Type.Vector2)
return false;
Vector2 vector = value.AsVector2();
if (!vector.IsFinite())
return false;
result = vector;
return true;
}
}Save it as VariantBoundary.cs. Call VariantBoundary.TryGetInt32(value, out int count) for an integer boundary or VariantBoundary.TryGetFiniteVector2(value, out Vector2 position) for a finite position. False means the input failed that contract; the out value is zero. Add domain checks afterwards, such as a nonnegative count or a position inside the current level.
This policy intentionally rejects a numeric string, a Float with no fractional part and Vector2I. If your format permits coercion, implement and document that separate policy rather than accidentally inheriting every engine conversion.
Distinguish a missing key from Nil
Use Dictionary.TryGetValue when absence matters. False means the key does not exist. True with VariantType Nil means the key exists and explicitly contains Nil. Inspect both results before substituting a default.
The Dictionary indexer is appropriate for a key already known to exist; it is not a general missing-key probe. Decide whether an absent value means default, invalid input or a different schema version. Do not collapse all three cases into zero.
Understand copies and shared containers
Extracting an Array or Dictionary from a Variant creates another wrapper for the same engine container. Editing it changes the container visible through other references. Assigning the wrapper to another C# variable does not create independent contents.
Array.Duplicate(false) creates a new outer array but keeps nested arrays/dictionaries shared. Duplicate(true) separates nested containers, yet engine objects remain references: it does not clone Entities or duplicate a game world. Explicitly instantiate or duplicate objects with an appropriate object/resource API when that is the intended operation.
An Array element getter copies the native Variant value. Replacing an integer element later does not rewrite the earlier retrieved integer. If the retrieved value contains a container, that container's contents can still be shared. Copying the wrapper value and copying its referenced contents are different operations.
Managed packed arrays behave differently: the tested int[] conversion and extraction copy their contents. Modifying the source or returned int[] does not edit the packed value already stored in the Variant. Reassign changed data explicitly when that is your intent.
Respect ownership and object lifetime
A Variant Object preserves identity. An Entity can still be explicitly freed while a Variant refers to it. Check IsInstanceValid before using a possibly expired engine object; a non-Nil tag is not a liveness test. RefCounted objects use reference counting, which differs from an Entity's explicit/tree lifetime.
The C# Variant wrapper implements IDisposable. For an owned temporary, such as a value returned by a collection getter, a using scope can release its native payload promptly. Do not dispose values borrowed from callers: ordinary C# struct assignment can share disposal bookkeeping, so it is not a way to create a second independently disposable owner. Do not access aliases after their shared payload has been disposed.
The executed example covers tags, numeric conversion/range checks, finite Vector2, managed packed-array copies, shared containers, nested duplication, missing/Nil entries and Entity lifetime. It does not validate every conversion overload, arbitrary invalid native casts, serialization or concurrent mutation. Inspect Variant API for the available typed operations; select a serialization format separately.