Skip to content

C# and .NET ​

Verified

tools/verify_csharp.sh builds the shipped wrapper against the real Godot .NET SDK and runs it against the addon: C# starts a conversation with parameters, receives a blocking event, branches from inside the handler, and reads the player lock back. Checked on Godot 4.7.2 .NET with .NET SDK 8.0.425.

What does not work ​

C# cannot reference a GDScript class_name as a type. There is no NPCTalkKit type in C#; you hold the node as Node.

Calls go through Call(), properties through Get() / Set(), and signals through Connect() with string names. Mistakes surface at run time, not at compile time.

Playing a conversation ​

cs
// Docs: /guide/csharp
//
// The addon is GDScript. It loads and runs in a Godot .NET project, but C#
// cannot reference a GDScript class_name as a type: you hold the node as Node
// and go through Call(), Get()/Set() and Connect(). Mistakes surface at run
// time, not at compile time.
//
// Compiled by tools/verify_csharp.sh against the real Godot .NET SDK, so a
// signature that stops existing fails there rather than in your game.

using Godot;

public partial class Blacksmith : Node2D
{
    private Node _talk;

    public override void _Ready()
    {
        _talk = GetNode("NPCTalkKit");
        ConnectTalk();
    }

    // #region play
    public void Greet(string playerName)
    {
        var args = new Godot.Collections.Dictionary { { "player_name", playerName } };
        _talk.Call("play", "greeting", args);
    }
    // #endregion

    // #region signals
    private void ConnectTalk()
    {
        _talk.Connect("event_blocked",
            Callable.From((StringName id, Variant payload) => OnBlocked(id, payload)));
        _talk.Connect("request_player_lock",
            Callable.From((bool locked) => SetPlayerLocked(locked)));
    }

    private void OnBlocked(StringName eventId, Variant payload)
    {
        if (eventId == "offer_job")
            _talk.Call("play", PlayerAccepts() ? "accepted" : "declined");
    }
    // #endregion

    // #region wrapper
    // A thin wrapper puts the rest of your code back in typed C#. The surface
    // is small because v0.4 authors content in the editor, not in code.
    private bool IsTalking() => _talk.Call("is_running").AsBool();

    private bool IsBlocked() => _talk.Call("is_blocked").AsBool();

    private void Resume() => _talk.Call("resume");
    // #endregion

    private bool PlayerAccepts() => true;

    private void SetPlayerLocked(bool locked) { }
}

Signals ​

cs
// Docs: /guide/csharp
//
// The addon is GDScript. It loads and runs in a Godot .NET project, but C#
// cannot reference a GDScript class_name as a type: you hold the node as Node
// and go through Call(), Get()/Set() and Connect(). Mistakes surface at run
// time, not at compile time.
//
// Compiled by tools/verify_csharp.sh against the real Godot .NET SDK, so a
// signature that stops existing fails there rather than in your game.

using Godot;

public partial class Blacksmith : Node2D
{
    private Node _talk;

    public override void _Ready()
    {
        _talk = GetNode("NPCTalkKit");
        ConnectTalk();
    }

    // #region play
    public void Greet(string playerName)
    {
        var args = new Godot.Collections.Dictionary { { "player_name", playerName } };
        _talk.Call("play", "greeting", args);
    }
    // #endregion

    // #region signals
    private void ConnectTalk()
    {
        _talk.Connect("event_blocked",
            Callable.From((StringName id, Variant payload) => OnBlocked(id, payload)));
        _talk.Connect("request_player_lock",
            Callable.From((bool locked) => SetPlayerLocked(locked)));
    }

    private void OnBlocked(StringName eventId, Variant payload)
    {
        if (eventId == "offer_job")
            _talk.Call("play", PlayerAccepts() ? "accepted" : "declined");
    }
    // #endregion

    // #region wrapper
    // A thin wrapper puts the rest of your code back in typed C#. The surface
    // is small because v0.4 authors content in the editor, not in code.
    private bool IsTalking() => _talk.Call("is_running").AsBool();

    private bool IsBlocked() => _talk.Call("is_blocked").AsBool();

    private void Resume() => _talk.Call("resume");
    // #endregion

    private bool PlayerAccepts() => true;

    private void SetPlayerLocked(bool locked) { }
}

A wrapper ships with the addon ​

addons/npc_talkkit/csharp/TalkKit.cs is a typed face over the calls above:

csharp
_talk = TalkKit.Of(this);
_talk.EventBlocked += OnBlocked;
_talk.Play("greeting", TalkKit.Params("player_name", "Alex"));

Run the check yourself with:

sh
tools/verify_csharp.sh /path/to/Godot_mono.app/Contents/MacOS/Godot

It builds the wrapper in an otherwise empty project and drives the addon with it, so a signature that stops existing fails there rather than in your game.

A .cs file in a standard Godot build has no importer and is ignored. That was verified by importing this project on 4.7.2 standard with the file present: no errors, no warnings.

Writing your own instead ​

cs
// Docs: /guide/csharp
//
// The addon is GDScript. It loads and runs in a Godot .NET project, but C#
// cannot reference a GDScript class_name as a type: you hold the node as Node
// and go through Call(), Get()/Set() and Connect(). Mistakes surface at run
// time, not at compile time.
//
// Compiled by tools/verify_csharp.sh against the real Godot .NET SDK, so a
// signature that stops existing fails there rather than in your game.

using Godot;

public partial class Blacksmith : Node2D
{
    private Node _talk;

    public override void _Ready()
    {
        _talk = GetNode("NPCTalkKit");
        ConnectTalk();
    }

    // #region play
    public void Greet(string playerName)
    {
        var args = new Godot.Collections.Dictionary { { "player_name", playerName } };
        _talk.Call("play", "greeting", args);
    }
    // #endregion

    // #region signals
    private void ConnectTalk()
    {
        _talk.Connect("event_blocked",
            Callable.From((StringName id, Variant payload) => OnBlocked(id, payload)));
        _talk.Connect("request_player_lock",
            Callable.From((bool locked) => SetPlayerLocked(locked)));
    }

    private void OnBlocked(StringName eventId, Variant payload)
    {
        if (eventId == "offer_job")
            _talk.Call("play", PlayerAccepts() ? "accepted" : "declined");
    }
    // #endregion

    // #region wrapper
    // A thin wrapper puts the rest of your code back in typed C#. The surface
    // is small because v0.4 authors content in the editor, not in code.
    private bool IsTalking() => _talk.Call("is_running").AsBool();

    private bool IsBlocked() => _talk.Call("is_blocked").AsBool();

    private void Resume() => _talk.Call("resume");
    // #endregion

    private bool PlayerAccepts() => true;

    private void SetPlayerLocked(bool locked) { }
}

The surface C# has to touch is small, because v0.4 authors content in the editor rather than in code. For most games it is play() and two signals.

What is unaffected ​

Everything authored in the TalkKit workspace — conversations, styles, anchors, templates — works identically. None of it goes through C#.

GDScript-first. No telemetry, no network requests, no AI service dependency in the shipped addon.