Unity SDK

Unity SDK

The Unity SDK is a C# package for Unity 6 that communicates with the Biomata Engine backend over JSON-over-WebSocket. It provides typed clients for agent management, tick control, event streaming, and snapshot save/restore.

Requirements

RequirementMinimum
Unity6000.0 (Unity 6 — primary target: 6.4)
Scripting BackendMono or IL2CPP
API Compatibility Level.NET Standard 2.1
Python backendBiomata Engine 0.5.0

Platform support

PlatformStatus
Editor (Win / macOS / Linux)Primary target
Standalone (Win / macOS / Linux)Supported — Mono or IL2CPP
AndroidSupported — IL2CPP (link.xml guards against stripping)
iOSSupported — IL2CPP
WebGLNot supported — ClientWebSocket is unavailable in browser without a JS shim

Installation

Edit Packages/manifest.json in your Unity project:

json
{
  "dependencies": {
    "com.biomata.sdk": "file:../path/to/biomata-engine/unity_sdk",
    "com.unity.nuget.newtonsoft-json": "3.2.1"
  }
}

Or use Window → Package Manager → + → Add package from disk… and select unity_sdk/package.json.

Newtonsoft JSON

The SDK declares com.unity.nuget.newtonsoft-json as a UPM dependency. Unity pulls it automatically from the default registry.

Quick start

1. Start the backend

bash
pip install -e ".[websocket]"
biomata-ws --config examples/engine_owned/sim.yaml --port 8765

2. Add BiomataManager to a scene

Attach the BiomataManager component to a persistent GameObject in your bootstrap scene. The Inspector exposes Host, Port, UseTls, and connection timeouts. Set Connect On Start to true to connect automatically.

3. Register an agent and tick

csharp
using Biomata.SDK;
using Biomata.SDK.Unity;
using Biomata.SDK.Models;
using System.Collections.Generic;
using UnityEngine;

public class NPCController : MonoBehaviour
{
    private async void Start()
    {
        var biomata = BiomataManager.Instance;

        // Register agent with idle brain (no Ollama needed for testing)
        await biomata.Client.Agents.RegisterAsync(new AgentRegistration
        {
            AgentId    = "npc_guard_01",
            AgentName  = "Guard",
            BrainClass = "src.plugins.builtin.idle_brain.brain.IdleBrain",
        });

        // Send one tick with agent observation
        var observations = new List<AgentObservationData>
        {
            new AgentObservationData("npc_guard_01",
                new Dictionary<string, object>
                {
                    ["location"] = "gatehouse",
                    ["health"]   = 100,
                })
        };

        TickResult result = await biomata.Client.Ticks.TickAsync(observations);

        foreach (var decision in result.Decisions)
        {
            Debug.Log($"{decision.AgentName}: {decision.Action} — {decision.OutcomeText}");
            foreach (var cmd in decision.EngineCommands)
                ApplyCommand(decision.AgentId, cmd);
        }
    }

    private void ApplyCommand(string agentId, Dictionary<string, object> cmd)
    {
        var type = cmd.GetValueOrDefault("type")?.ToString();
        if (type == "navigate")
        {
            // Move the NPC's NavMeshAgent, Rigidbody, etc.
        }
    }
}

Runtime

SimulationClient

Top-level client. Owns the transport and exposes typed sub-clients. Source: unity_sdk/Runtime/Core/SimulationClient.cs.

csharp
var config = new BiomataConfig
{
    Host                      = "localhost",
    Port                      = 8765,
    UseTls                    = false,
    ConnectTimeoutSeconds     = 10f,
    DefaultCallTimeoutSeconds = 30f,
};

var client = new SimulationClient(config);
await client.ConnectAsync(cancellationToken);

// Sub-clients — available after ConnectAsync
client.Health        // HealthClient — liveness probes
client.Agents        // AgentClient — register/remove agents
client.Observations  // ObservationClient — push pre-tick observations
client.Ticks         // TickClient — execute ticks, pause, resume
client.Events        // EventStreamClient — real-time event subscriptions
client.Snapshots     // SnapshotClient — save/restore state

await client.DisconnectAsync();

BiomataManager

Singleton MonoBehaviour that owns the SimulationClient for the game's lifetime. Source: unity_sdk/Runtime/Unity/BiomataManager.cs.

csharp
// Access from any script
var manager = BiomataManager.Instance;

// C# events (fired on main thread)
manager.OnConnectionStateChanged += state => Debug.Log($"Connection: {state}");
manager.OnTickEnd        += ev => Debug.Log($"Tick {ev.Tick} complete");
manager.OnActionCompleted += ev => Debug.Log($"{ev.AgentId}: {ev.Data.GetString("action")}");
manager.OnStreamDisconnected += ex => Debug.LogWarning("Stream disconnected");
manager.OnStreamFailed += ex => Debug.LogError($"Stream failed: {ex.Message}");

// Connect manually (if ConnectOnStart = false)
await manager.ConnectAsync();

// Convenience tick (delegates to Client.Ticks.TickAsync)
TickResult result = await manager.TickAsync(observations, worldMetadata);

// Autonomous mode control
await manager.PauseAsync();
await manager.ResumeAsync();

BiomataConfig

csharp
var config = new BiomataConfig
{
    Host                      = "localhost",
    Port                      = 8765,
    UseTls                    = false,      // wss:// requires a valid server cert
    ConnectTimeoutSeconds     = 10f,
    DefaultCallTimeoutSeconds = 30f,
    Retry = new RetryConfig
    {
        MaxAttempts         = 8,
        InitialDelaySeconds = 0.5f,
        MaxDelaySeconds     = 30f,
        Multiplier          = 2f,
        JitterFraction      = 0.25f,
    }
};

// Derived address
string addr = config.Address; // "ws://localhost:8765"

Transport

WebSocketTransport is the concrete ITransport implementation. It uses System.Net.WebSockets.ClientWebSocket. Source: unity_sdk/Runtime/Transport/WebSocketTransport.cs.

Key behaviors:

  • Reads the hlo frame on connect and stores ServerTickMode, SessionId, ServerVersion
  • Serializes all sends — WebSocket.SendAsync is not concurrent-safe
  • Per-call timeout enforced via CancellationTokenSource.CreateLinkedTokenSource
  • All pending requests are failed with a transport error on disconnect
  • 30-second keep-alive ping interval
csharp
// Read tick mode from transport after connect
var transport = (WebSocketTransport)client._transport;
var tickMode  = transport.ServerTickMode; // "host_driven" or "autonomous"

if (tickMode == "host_driven")
{
    // Call TickAsync — DO NOT call pause/resume
}
else if (tickMode == "autonomous")
{
    // Subscribe to events — DO NOT call TickAsync
    await client.Events.StartAsync(ct);
}

Models

All DTOs live in unity_sdk/Runtime/Models/.

AgentRegistration

csharp
var reg = new AgentRegistration
{
    AgentId     = "npc_guard_01",
    AgentName   = "Guard",
    BrainClass  = "src.plugins.builtin.ollama.brain.OllamaLLMBrain",
    BrainConfig = new Dictionary<string, object>
    {
        ["llm_config"] = new Dictionary<string, object>
        {
            ["model"]       = "qwen2.5:14b",
            ["temperature"] = 0.8,
        },
        ["personality"] = new Dictionary<string, object>
        {
            ["traits"] = new[] { "strategic", "loyal" },
            ["goals"]  = new[] { "protect the keep" },
        }
    },
    MemoryClass  = "",
    MemoryConfig = null,
};

AgentObservationData

csharp
var obs = new AgentObservationData(
    agentId: "npc_guard_01",
    observation: new Dictionary<string, object>
    {
        ["location"]  = "gatehouse",
        ["health"]    = 100,
        ["nearby_agents"] = new[]
        {
            new Dictionary<string, object>
            {
                ["id"]        = "npc_bandit_01",
                ["name"]      = "Bandit",
                ["inventory"] = new Dictionary<string, object>(),
                ["ext"]       = new Dictionary<string, object>(),
            }
        }
    }
);

TickResult

csharp
TickResult result = await client.Ticks.TickAsync(observations, worldMetadata);

result.Tick       // int — engine tick number
result.Decisions  // IReadOnlyList<AgentDecisionResult>
result.Errors     // IReadOnlyList<(string AgentId, string Message)>
result.AllCommands // IReadOnlyList<(string AgentId, Dictionary<string,object> Command)>

// O(1) lookup by agent ID
var decision = result.ForAgent("npc_guard_01");
if (decision != null)
{
    Debug.Log($"{decision.AgentName}: {decision.Action}");
    Debug.Log($"Outcome: {decision.OutcomeText}");
    foreach (var cmd in decision.EngineCommands)
        ApplyCommand(decision.AgentId, cmd);
}

AgentDecisionResult

csharp
decision.AgentId        // string
decision.AgentName      // string
decision.Action         // string — action name chosen by brain
decision.Parameters     // Dictionary<string, object>
decision.OutcomeText    // string — human-readable result
decision.EngineCommands // IReadOnlyList<Dictionary<string, object>>
decision.Error          // string | null — non-null if step threw an exception

Agent Ownership Models

Every agent in Biomata has a lifecycle: it is created, participates in ticks, and is eventually removed. Two systems can own that lifecycle — the backend or Unity. Both models run the same tick pipeline; the only difference is who decides which agents exist and when.

Decision guide

Answer these questions for your project:

QuestionYes → useNo → use
Does the simulation have value without Unity connected?Engine-OwnedHost-Owned
Is the full NPC roster known at ship time?Engine-OwnedHost-Owned
Do different scenes load different agent sets?Host-OwnedEngine-Owned
Do you need hot-swap of brain implementations at runtime?Host-OwnedEither
Will multiple Unity clients observe the same agents?Engine-OwnedEither

Engine-Owned (BindToExisting)

The backend declares agents in sim.yaml before the first tick. Unity connects and binds a visual shell to each pre-existing agent. No registration RPC is sent.

text
sim.yaml defines agents
    ↓
Backend starts ticking (with or without Unity)
    ↓
Unity connects → BiomataAgent.MarkBoundToExisting()
    ↓
Visual shell receives decisions each tick

When to use it: fixed NPC rosters, simulations that run headlessly, spectator clients, or when you want to swap brain implementations without touching Unity.

Inspector setup — set Ownership Mode to BindToExisting on each NPC prefab:

text
[Ownership]
  Mode:     BindToExisting
  ℹ Agent is pre-declared on the backend; no registration RPC is sent.

[Identity]
  Agent ID:  gate_guard_left   ← must match sim.yaml id exactly
  Name:      Aldric

[Debug]
  Auto Bind: ✓
ID must match sim.yaml exactly

The Agent ID in the Unity Inspector must match the id field in sim.yaml character-for-character. A mismatch causes observations to flow to the wrong agent. The Brain group is hidden in this mode — brain config lives in the YAML, not in Unity.

Coordinator script — the connect handler has nothing to register:

csharp
// EngineOwnedManager.cs
private void HandleConnected()
{
    // Nothing. BiomataAgent.Start() calls MarkBoundToExisting() for each agent.
    // No register_agent RPC is ever sent in this mode.
}

See the canonical sample: unity_sdk/samples/EngineOwned/EngineOwnedManager.cs.

Host-Owned (CreateAtRuntime)

The backend starts with no agents. Unity connects, sends an agent.register RPC for each NPC prefab, and sends agent.unregister when the prefab is destroyed.

text
Backend starts with no agents
    ↓
Unity connects → agent.register RPC for each prefab
    ↓
Backend creates agent (imports brain, initializes memory)
    ↓
Agent participates in ticks
    ↓
Prefab destroyed → agent.unregister RPC → backend removes agent

When to use it: procedural spawning, level-load agent sets, player-chosen brain implementations, or when Unity is the canonical record of which NPCs exist.

Inspector setup — set Ownership Mode to CreateAtRuntime. The Brain group becomes visible because Unity owns the brain config:

text
[Ownership]
  Mode:     CreateAtRuntime
  ℹ Unity owns this agent. Registered on connect, unregistered on destroy. Brain Class required.

[Identity]
  Agent ID:  scout_001
  Name:      Scout

[Role]
  Role:         patrol
  Capabilities: patrol

[Brain]
  Brain Class:  src.plugins.builtin.ollama.brain.OllamaLLMBrain
  Brain Config: {"model": "qwen2.5:14b"}

[Debug]
  Auto Register: ✓
Brain Class is required

In CreateAtRuntime mode the Inspector shows a validation warning if Brain Class is empty. A dotted Python path that can't be resolved produces IMPORT_ERROR (-4) at registration time. Brain construction (e.g. loading an LLM model) happens synchronously inside RegisterAsync — large models delay the response.

Coordinator script:

csharp
// HostOwnedManager.cs
private void HandleConnected()
{
    SpawnAgents();
}

private void SpawnAgent(AgentSpawnData data)
{
    var go    = Instantiate(npcPrefab, data.position, Quaternion.identity);
    var agent = go.GetComponent<BiomataAgent>();

    agent.Configure(
        agentId:       data.agentId,
        displayName:   data.displayName,
        autoRegister:  true,
        ownershipMode: AgentOwnershipMode.CreateAtRuntime);
    // BiomataAgent.Start() fires RegisterAsync → backend creates the agent.
    // BiomataAgent.OnDestroy() fires TryRemoveAsync → backend removes the agent.
}

See the canonical sample: unity_sdk/samples/HostOwned/HostOwnedManager.cs.

Reconnect behavior

On a WebSocket reconnect, agents that were registered by CreateAtRuntime remain on the server — the simulation keeps ticking during the disconnect. On reconnect, call RegisterAsync with reconnect: true for each owned agent. The server returns the existing agent state without creating a new one.

csharp
await biomata.Client.Agents.RegisterAsync(new AgentRegistration
{
    AgentId    = "scout_001",
    AgentName  = "Scout",
    BrainClass = "src.plugins.builtin.idle_brain.brain.IdleBrain",
    Reconnect  = true,   // safe to call even if already registered
});

Side-by-side comparison

AspectEngine-Owned (BindToExisting)Host-Owned (CreateAtRuntime)
Agents defined insim.yamlUnity Inspector / code
Registration RPCNoneagent.register on connect
Unregistration RPCNoneagent.unregister on destroy
Backend without UnityRuns normallyNo agents to tick
Agent count at runtimeFixed (restart to change)Dynamic
Brain config lives inYAMLUnity Inspector
Multiple Unity clientsAll bind the same agentsEach client owns its agents
Reconnect behaviorRebind, no side effectsRe-register with reconnect=true
Brain Class field visibleHiddenShown (required)
Canonical exampleexamples/engine_owned/examples/host_owned/
Unity sampleEngineOwned/EngineOwnedManager.csHostOwned/HostOwnedManager.cs

Mixing both models

A scene can contain both BindToExisting and CreateAtRuntime agents simultaneously. YAML-defined agents tick from startup; Unity-spawned agents join when registered and leave when destroyed. The agent.list RPC returns both groups in the same list.

text
sim.yaml defines:          Unity defines:
  gate_guard_left            player_summoned_companion_001
  gate_guard_right           player_summoned_companion_002
  market_merchant            (spawned from inventory, unique per session)
  (static, always there)

Integration

UnitySimulationManager

High-level MonoBehaviour that manages per-NPC bridges and distributes tick results. Source: unity_sdk/Runtime/Integration/Simulation/UnitySimulationManager.cs.

csharp
var manager = GetComponent<UnitySimulationManager>();

// Register your agent bridges
manager.RegisterBridge(guardBridge);
manager.RegisterBridge(archerBridge);

// Call from FixedUpdate or a coroutine
await manager.TickAsync();

UnityAgentBridge

Per-NPC component that provides observations to the tick and receives commands back. Source: unity_sdk/Runtime/Integration/Agents/UnityAgentBridge.cs. Attach one UnityAgentBridge to each NPC GameObject.

ObservationProviders

Components that contribute observation fields. Multiple providers can be attached to the same NPC:

ComponentContributes
TransformObservationProviderposition_x, position_y, position_z, rotation_y
LineOfSightProvidernearest_visible_agent, visible_agents count
NearbyActorsProvidernearby_agents list with IDs and distances

ActionHandlers (Unity side)

Unity-side handlers that execute engine_commands. Attach one per action type:

csharp
public class MoveActionHandler : ActionHandlerBase
{
    [SerializeField] private NavMeshAgent _navMeshAgent;

    protected override string CommandType => "navigate";

    protected override void Execute(Dictionary<string, object> command)
    {
        float x = Convert.ToSingle(command.GetValueOrDefault("x", 0f));
        float z = Convert.ToSingle(command.GetValueOrDefault("z", 0f));
        _navMeshAgent.SetDestination(new Vector3(x, 0f, z));
    }
}

Configure() API

Each integration component exposes a Configure() method for programmatic setup. Call it immediately after AddComponent<T>(), before Start() runs on the next frame. This replaces the older pattern of using reflection (SetField) to write to serialized private fields.

csharp
// UnitySimulationManager
var go  = new GameObject("SimulationManager");
_simMgr = go.AddComponent<UnitySimulationManager>();
_simMgr.Configure(host: "localhost", port: 8765, tickRate: 2f, autoConnect: false);

// UnityAgentBridge (one per NPC)
var bridge = npc.AddComponent<UnityAgentBridge>();
bridge.Configure(agentId: "guard_001", agentName: "Aldric", autoRegister: false);

// MoveActionHandler
var mover = npc.AddComponent<MoveActionHandler>();
mover.Configure(moveSpeed: 3.5f, arrivalThreshold: 0.8f, rotateSpeed: 360f);

// SpeakActionHandler
var speaker = npc.AddComponent<SpeakActionHandler>();
speaker.Configure(logToConsole: true, speechDuration: 4f);

// EventVisualizer
var viz = go.AddComponent<EventVisualizer>();
viz.Configure(showOverlay: true, showSpeechBubbles: true, showInteractionLines: true,
              maxEventLines: 20, toggleKey: KeyCode.F2);

Inspector-assigned values (set via the Unity Editor) remain valid — Configure() is only needed when setting up components at runtime.

Samples

Engine Owned Demo

Import: Package Manager → Biomata Simulation SDK → Samples → Engine Owned → Import

Backend: biomata-ws --config examples/engine_owned/sim.yaml --port 8765

Demonstrates the BindToExisting ownership model with three pre-declared agents (two guards and a merchant). All use IdleBrain — no Ollama required. Run the backend first; the simulation ticks whether or not Unity is connected. This sample shows that HandleConnected() contains no agent-management code — agents bind themselves automatically.

Key learning: swap IdleBrain for OllamaLLMBrain in sim.yaml without touching any Unity code. Source: unity_sdk/samples/EngineOwned/EngineOwnedManager.cs.

Host Owned Demo

Import: Package Manager → Biomata Simulation SDK → Samples → Host Owned → Import

Backend: biomata-ws --config examples/host_owned/sim.yaml --port 8765

Demonstrates the CreateAtRuntime ownership model. The backend sim.yaml has no agents: block — the backend starts empty. Unity connects and spawns NPC prefabs; each calls RegisterAsync automatically. When a prefab is destroyed it calls TryRemoveAsync automatically.

The AgentSpawnData[] array is serializable so designers can add NPCs in the Inspector without code changes. Source: unity_sdk/samples/HostOwned/HostOwnedManager.cs.

Event streaming

csharp
var events = client.Events;

// Register handlers (dispatched on main thread via MainThreadDispatcher)
events.On("tick_end",         ev => Debug.Log($"Tick {ev.Tick} complete"));
events.On("action_completed", ev =>
{
    var action = ev.Data.GetString("action") ?? "?";
    Debug.Log($"{ev.AgentId} performed {action}");
});
events.On("agent_step_error", ev =>
    Debug.LogWarning($"Step error for {ev.AgentId}: {ev.Data.GetString("error")}"));

// Start streaming (subscribe_events + receive loop)
await events.StartAsync(cancellationToken);

// Disconnect handling
events.OnDisconnected += ex => Debug.LogWarning("Stream dropped");
events.OnFailed       += ex => Debug.LogError($"Stream failed: {ex.Message}");

// Stop
await events.StopAsync();

Snapshot and restore

csharp
var snapshots = client.Snapshots;

// Capture current simulation state
SnapshotData snap = await snapshots.CaptureAsync();
Debug.Log($"Snapshot at tick {snap.Tick}, created {snap.CreatedAt}");

// Save to disk (optional — the blob is opaque)
PlayerPrefs.SetString("last_snapshot", snap.DataB64);

// Restore (can be called at any time while connected)
await snapshots.RestoreAsync(snap);
Debug.Log($"Restored to tick {snap.Tick}");
Snapshot format

The snapshot response contains two fields you must preserve: data_b64 (base64-encoded opaque blob) and hmac_sha256 (integrity tag). Pass both verbatim to RestoreAsync — the server verifies the HMAC before deserialising. A modified or replayed snapshot is rejected with error code -5. The blob format may change between engine versions.