Using the SDK in Unity

The SDK ships a netstandard2.0 assembly, which is what Unity consumes. Nothing here is Unity-specific code — it is the same package, with the packaging and stripping caveats Unity adds.

Status: built for it, not yet run in it

Be clear about what has and has not been demonstrated.

What is verified. The three things that break a .NET library under Unity's IL2CPP backend are runtime code generation, reflection-based serialization, and trimming. The published netstandard2.0 assembly was inspected at the metadata level and references none of System.Reflection.Emit, DynamicMethod, ILGenerator, TypeBuilder, CallSite (the dynamic infrastructure), MakeGenericType, MakeGenericMethod, Activator, or Expression.Compile. JSON is source-generated rather than reflected. The query builder walks expression trees structurally instead of compiling them.

The reflective path that does exist — reading a captured local out of its compiler-generated closure — is exercised by the Native AOT smoke test, which publishes with full trimming and no JIT fallback. That is a stricter regime than Unity's managed stripping.

What is not verified. Nobody has run this inside a Unity project. IL2CPP and Native AOT are the same class of constraint, not the same implementation, so the evidence above is strong rather than conclusive. If something does break under Unity, the closure-reading path in ExpressionTranslator is where to look first, and link.xml is the remedy.

Requirements

API Compatibility Level .NET Standard 2.1 (Player Settings → Other Settings). .NET Framework also works.
Scripting backend Mono or IL2CPP — both fine.
Platforms Everything except WebGL. See WebGL below.

netstandard2.1 is a superset of netstandard2.0, so the shipped assembly is compatible with either profile.

Installing

The easy way

NuGetForUnity resolves the dependency graph for you:

TcgDex.CSharpSdk

That is the whole step. Skip to duplicate assembly errors.

By hand

Unity does not read PackageReference, so without NuGetForUnity you place DLLs in Assets/Plugins/ yourself. The netstandard2.0 closure is 21 assemblies:

Assembly Version
TcgDex.CSharpSdk.dll 0.1.1
Microsoft.Extensions.Http.dll 8.0.1
Microsoft.Extensions.Logging.dll 8.0.1
Microsoft.Extensions.Logging.Abstractions.dll 8.0.3
Microsoft.Extensions.DependencyInjection.dll 8.0.1
Microsoft.Extensions.DependencyInjection.Abstractions.dll 8.0.2
Microsoft.Extensions.Options.dll 8.0.2
Microsoft.Extensions.Primitives.dll 8.0.0
Microsoft.Extensions.Configuration.Abstractions.dll 8.0.0
Microsoft.Bcl.AsyncInterfaces.dll 8.0.0
Microsoft.Bcl.TimeProvider.dll 8.0.1
System.Text.Json.dll 8.0.6
System.Text.Encodings.Web.dll 8.0.0
System.Net.Http.Json.dll 8.0.1
System.Diagnostics.DiagnosticSource.dll 8.0.1
System.ComponentModel.Annotations.dll 5.0.0
System.Buffers.dll 4.5.1
System.Memory.dll 4.5.5
System.Numerics.Vectors.dll 4.4.0
System.Runtime.CompilerServices.Unsafe.dll 6.0.0
System.Threading.Tasks.Extensions.dll 4.5.4

To produce that set without hunting through nuget.org, restore it once from a throwaway project and copy what lands in the package cache:

dotnet new classlib -f netstandard2.0 -o unity-deps
cd unity-deps
dotnet add package TcgDex.CSharpSdk
dotnet build

The lib/netstandard2.0/ folder of each restored package under ~/.nuget/packages/ holds the DLL to copy.

Duplicate assembly errors

The most likely thing to go wrong, and it is a packaging problem rather than a compatibility one.

Seven of those assemblies are polyfills — they backport APIs that netstandard2.1 already has in the box:

System.Buffers.dll
System.Memory.dll
System.Numerics.Vectors.dll
System.Runtime.CompilerServices.Unsafe.dll
System.Threading.Tasks.Extensions.dll
System.ComponentModel.Annotations.dll
Microsoft.Bcl.AsyncInterfaces.dll

On a .NET Standard 2.1 profile Unity supplies these itself, so shipping them too can produce "The type X exists in both …" or a duplicate-assembly load error. If that happens, delete them from Assets/Plugins/ one at a time until the error clears — the rest of the set is still required.

Which of the seven actually collide depends on the Unity version, so this is written as a troubleshooting step rather than a fixed list to remove up front.

IL2CPP and managed stripping

The SDK reads a captured variable out of its closure reflectively, so that .Where(c => c.Hp > minimumHp) works without compiling the expression tree — Expression.Compile() emits IL at runtime and IL2CPP cannot support it.

Managed stripping can remove members that only reflection reaches. If you build with Managed Stripping Level: High and see queries with captured variables producing empty or wrong filters, add Assets/link.xml:

<linker>
  <assembly fullname="TcgDex.CSharpSdk" preserve="all" />
</linker>

That is a blunt instrument — it preserves the whole assembly. A narrower rule is possible, but the assembly is small enough that it is rarely worth the tuning.

Note the failure mode: a stripped closure field surfaces as a wrong query, not an exception. Assert on ToQueryString() in a test rather than trusting a successful build.

Async on Unity's main thread

Every await inside the SDK uses ConfigureAwait(false), which is correct for a library. A common misreading is that this strands your continuation off the main thread. It does not: ConfigureAwait only governs where the awaiting method resumes, so the SDK's setting applies to the SDK's own internals. Your await captures Unity's synchronization context, and your code resumes on the main thread.

private async void Start()
{
    Card? card = await _tcgdex.Cards.GetAsync("swsh3-136", destroyCancellationToken);

    // Main thread. This await captured Unity's synchronization context, which
    // is what decides where *this* method resumes.
    _label.text = card?.Name;
}

Where it does bite is your own helper layers. If you write await SomethingAsync().ConfigureAwait(false) in a helper and then touch a UnityEngine API, that throws — and the SDK is not involved in the mistake.

destroyCancellationToken (Unity 2022.2+) is the right token to pass: it cancels when the object is destroyed, so a scene change does not leave requests running against a dead object.

Lifetime

Create one client for the application, not one per request — a disposed HttpClient leaves connections in TIME_WAIT and exhausts sockets. Unity has no DI container by default, so TcgDexClient.Create() is the entry point:

public sealed class TcgDexService : MonoBehaviour
{
    public static TcgDexService Instance { get; private set; } = null!;

    internal ITcgDexClient Client { get; private set; } = null!;

    private TcgDexClient _owned = null!;

    private void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);

        _owned = TcgDexClient.Create(configureCache: _ => { });
        Client = _owned;
    }

    private void OnDestroy() => _owned.Dispose();
}

configureCache: _ => { } enables response caching with defaults, which matters more in a game than in a service — repeat lookups of the same card cost nothing and the ETag revalidation keeps them correct.

WebGL

System.Net.Http does not work on WebGL. The browser sandbox has no sockets, and Unity's WebGL runtime is single-threaded. This is a platform restriction, not an SDK limitation — no HttpClient-based library works there.

The SDK is usable anyway, because the transport is injectable. It takes an HttpClient, and an HttpClient takes an HttpMessageHandler, so a handler backed by UnityWebRequest makes the whole SDK work on WebGL unchanged:

internal sealed class UnityWebRequestHandler : HttpMessageHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        using UnityWebRequest unityRequest = UnityWebRequest.Get(request.RequestUri);

        // Await the UnityWebRequestAsyncOperation, then translate the result
        // into an HttpResponseMessage — status code, body, and the ETag header
        // if you want the SDK's caching layer to keep working.
        // ...
    }
}

ITcgDexClient client = new TcgDexClient(new HttpClient(new UnityWebRequestHandler()));

Nothing else changes: the SDK spawns no threads, calls no Task.Run, and never blocks on a task, so the single-threaded WebGL model is otherwise fine.

A complete handler is more code than fits here, and none of it is SDK-specific — it is the standard UnityWebRequest-to-HttpClient adapter, and existing implementations can be dropped in.

Reporting a problem

Since this page describes a configuration nobody has run end to end, a bug report from an actual Unity project is genuinely useful. Include the Unity version, scripting backend, API compatibility level, managed stripping level and target platform — those five determine almost everything about what can go wrong here.