// Hovermate Game Events SDK client for C#: one file, no dependencies.
// Targets netstandard2.0 / .NET Framework 4.7.2 and newer (Unity, BepInEx, MelonLoader) with C# 7.3.
// Protocol v1: https://docs.hovermate.gg/sdk/protocol/
// MIT License, Copyright (c) 2026 Paliverse Apps LLC. See LICENSE in the SDK.
//
// Usage:
// HovermateEvents.Init("com.example.mygame", "My Game", "MyGame.exe");
// HovermateEvents.Hello(new HovermateOffer[] { "kill", "bossKill", HovermateOffer.Custom("parry", "Parry") });
// HovermateEvents.Event("kill", label: "Double kill", context: "Act 2");
// HovermateEvents.Custom("parry", "Parry");
// HovermateEvents.Shutdown(); // when the mod unloads
//
// Calls never block the game and never throw. Events go over HTTP from a background thread (250 ms timeouts, a
// bounded queue that drops the oldest event, one retry). When HTTP is unreachable they are appended to the inbox.
// When Hovermate is not installed or its SDK is off, the client stays silent.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
namespace Hovermate.Sdk
{
/// A type a hello offers: a standard type (a string converts implicitly) or a custom type.
public struct HovermateOffer
{
internal readonly string Type, CustomType, CustomLabel;
private HovermateOffer(string type, string customType, string customLabel)
{
Type = type;
CustomType = customType;
CustomLabel = customLabel;
}
public static HovermateOffer Standard(string type) { return new HovermateOffer(type, null, null); }
public static HovermateOffer Custom(string customType, string customLabel) { return new HovermateOffer(null, customType, customLabel); }
public static implicit operator HovermateOffer(string type) { return Standard(type); }
}
/// Sends game events to Hovermate (protocol v1). All members are thread-safe.
public static class HovermateEvents
{
/// Optional diagnostics (connection state, refused events). Called from the background thread.
public static Action Log;
private static readonly Client Shared = new Client();
/// Starts the client. matches [a-z0-9._-]{3,64} and stays stable across
/// versions of the mod; is the game's executable file name (optional).
public static void Init(string gameId, string gameName, string exe = null)
{
try { Shared.Init(gameId, gameName, exe); }
catch (Exception failure) { Report("init failed: " + failure.Message); }
}
/// Announces the types the game can send (optional, recommended), a log file to read
/// [HOVERMATE] lines from and mod-folder event files. Hovermate asks the user before it reads a path.
public static void Hello(IEnumerable offers, string log = null, IEnumerable files = null)
{
try { Shared.Hello(offers, log, files); }
catch (Exception failure) { Report("hello failed: " + failure.Message); }
}
/// Sends a standard event type, for example kill, death or bossKill.
public static void Event(string type, string label = null, IEnumerable tags = null, string context = null)
{
try { Shared.Send(type, null, null, label, tags, context); }
catch (Exception failure) { Report("event failed: " + failure.Message); }
}
/// Sends a custom event. matches [a-z0-9_.-]{1,32};
/// (at most 40 characters) names its highlight toggle.
public static void Custom(string customType, string customLabel, string label = null, IEnumerable tags = null, string context = null)
{
try { Shared.Send("custom", customType, customLabel, label, tags, context); }
catch (Exception failure) { Report("custom event failed: " + failure.Message); }
}
/// Waits until queued events are sent; false when the timeout passed first.
public static bool Flush(int timeoutMilliseconds = 1000)
{
try { return Shared.Flush(timeoutMilliseconds); }
catch (Exception) { return false; }
}
/// Sends what is queued (up to the timeout) and stops the background thread.
public static void Shutdown(int timeoutMilliseconds = 1000)
{
try { Shared.Shutdown(timeoutMilliseconds); }
catch (Exception) { }
}
internal static void Report(string message)
{
var log = Log;
if (log == null) return;
try { log("[Hovermate] " + message); }
catch (Exception) { }
}
private sealed class Connection
{
internal string Host, Path, Token, Inbox;
internal int Port;
}
private sealed class Client
{
private const int Capacity = 256, TimeoutMilliseconds = 250;
private static readonly TimeSpan ReloadInterval = TimeSpan.FromSeconds(5), HttpPause = TimeSpan.FromSeconds(5);
private static readonly Encoding Utf8 = new UTF8Encoding(false);
private readonly object _gate = new object();
private readonly Queue _queue = new Queue();
private string _gameJson;
private string _gameId;
private Thread _thread;
private bool _stopping, _busy;
// Background-thread state.
private Connection _connection;
private bool _connectionStale = true;
private DateTime _nextLoad = DateTime.MinValue, _httpPausedUntil = DateTime.MinValue;
private bool _reportedMissing;
internal void Init(string gameId, string gameName, string exe)
{
if (!Matches(gameId, 3, 64, true)) { Report("game id must match [a-z0-9._-]{3,64}: the client stays off."); return; }
var name = Clean(gameName, 64);
if (name == null) { Report("game name is required: the client stays off."); return; }
exe = Clean(exe, 128);
if (exe != null) exe = System.IO.Path.GetFileName(exe);
var game = new StringBuilder("{\"id\":");
Json.String(game, gameId);
game.Append(",\"name\":");
Json.String(game, name);
if (!string.IsNullOrEmpty(exe)) { game.Append(",\"exe\":"); Json.String(game, exe); }
game.Append('}');
lock (_gate)
{
_gameId = gameId;
_gameJson = game.ToString();
_stopping = false;
if (_thread == null)
{
_thread = new Thread(Run) { IsBackground = true, Name = "Hovermate events" };
_thread.Start();
}
}
}
internal void Hello(IEnumerable offers, string log, IEnumerable files)
{
var extra = new StringBuilder();
if (offers != null)
{
extra.Append(",\"offers\":[");
var first = true;
foreach (var offer in offers)
{
if (offer.Type != null)
{
if (offer.Type.Length == 0) continue;
if (!first) extra.Append(',');
Json.String(extra, offer.Type);
}
else
{
var label = Clean(offer.CustomLabel, 40);
if (!Matches(offer.CustomType, 1, 32, false) || label == null) { Report("skipped an invalid custom offer."); continue; }
if (!first) extra.Append(',');
extra.Append("{\"customType\":");
Json.String(extra, offer.CustomType);
extra.Append(",\"customLabel\":");
Json.String(extra, label);
extra.Append('}');
}
first = false;
}
extra.Append(']');
}
var logPath = Clean(log, 260);
if (logPath != null) { extra.Append(",\"log\":"); Json.String(extra, logPath); }
if (files != null)
{
var list = new List();
foreach (var file in files)
{
var path = Clean(file, 260);
if (path != null && list.Count < 16) list.Add(path);
}
if (list.Count > 0)
{
extra.Append(",\"files\":[");
for (var index = 0; index < list.Count; index++)
{
if (index > 0) extra.Append(',');
Json.String(extra, list[index]);
}
extra.Append(']');
}
}
Enqueue("hello", extra.ToString());
}
internal void Send(string type, string customType, string customLabel, string label, IEnumerable tags, string context)
{
if (string.IsNullOrEmpty(type) || type == "hello") { Report("event type is required."); return; }
var extra = new StringBuilder();
if (type == "custom")
{
var toggle = Clean(customLabel, 40);
if (!Matches(customType, 1, 32, false) || toggle == null) { Report("custom events need customType [a-z0-9_.-]{1,32} and customLabel."); return; }
extra.Append(",\"customType\":");
Json.String(extra, customType);
extra.Append(",\"customLabel\":");
Json.String(extra, toggle);
}
label = Clean(label, 80);
if (label != null) { extra.Append(",\"label\":"); Json.String(extra, label); }
if (tags != null)
{
var kept = new List();
foreach (var tag in tags)
if (kept.Count < 8 && Matches(tag, 1, 24, false) && tag.IndexOf('.') < 0 && !kept.Contains(tag)) kept.Add(tag);
if (kept.Count > 0)
{
extra.Append(",\"tags\":[");
for (var index = 0; index < kept.Count; index++)
{
if (index > 0) extra.Append(',');
Json.String(extra, kept[index]);
}
extra.Append(']');
}
}
context = Clean(context, 64);
if (context != null) { extra.Append(",\"context\":"); Json.String(extra, context); }
Enqueue(type, extra.ToString());
}
private void Enqueue(string type, string extra)
{
lock (_gate)
{
if (_gameJson == null || _thread == null) return;
var json = new StringBuilder("{\"v\":1,\"game\":").Append(_gameJson).Append(",\"type\":");
Json.String(json, type);
json.Append(extra).Append(",\"time\":\"")
.Append(DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture)).Append("\"}");
if (_queue.Count >= Capacity) _queue.Dequeue();
_queue.Enqueue(json.ToString());
Monitor.PulseAll(_gate);
}
}
internal bool Flush(int timeoutMilliseconds)
{
var deadline = DateTime.UtcNow.AddMilliseconds(Math.Max(0, timeoutMilliseconds));
lock (_gate)
{
while ((_queue.Count > 0 || _busy) && _thread != null)
{
var left = deadline - DateTime.UtcNow;
if (left <= TimeSpan.Zero) return false;
Monitor.Wait(_gate, left);
}
return true;
}
}
internal void Shutdown(int timeoutMilliseconds)
{
Flush(timeoutMilliseconds);
Thread thread;
lock (_gate)
{
_stopping = true;
_queue.Clear();
thread = _thread;
_thread = null;
Monitor.PulseAll(_gate);
}
if (thread != null && thread != Thread.CurrentThread) thread.Join(Math.Max(0, timeoutMilliseconds));
}
private void Run()
{
while (true)
{
string json, gameId;
lock (_gate)
{
_busy = false;
while (_queue.Count == 0 && !_stopping)
{
Monitor.PulseAll(_gate);
Monitor.Wait(_gate);
}
if (_stopping) { Monitor.PulseAll(_gate); return; }
json = _queue.Dequeue();
gameId = _gameId;
_busy = true;
}
try { Deliver(json, gameId); }
catch (Exception failure) { Report("delivery failed: " + failure.Message); }
}
}
private void Deliver(string json, string gameId)
{
var connection = CurrentConnection(false);
if (connection == null) return;
if (DateTime.UtcNow < _httpPausedUntil) { AppendToInbox(connection, gameId, json); return; }
var body = Utf8.GetBytes(json);
var status = 0;
string reply = null;
for (var attempt = 0; attempt < 2; attempt++)
{
try { status = Post(connection, body, out reply); }
catch (Exception) { status = -1; }
if (status == 202) return;
if (attempt > 0) break;
if (status == 401)
{
// The token rotates when the SDK is switched off and on: read the file again, retry once.
connection = CurrentConnection(true);
if (connection == null) return;
continue;
}
if (status != -1) break;
}
if (status == -1)
{
_httpPausedUntil = DateTime.UtcNow + HttpPause;
_connectionStale = true;
Report("Hovermate's HTTP receiver is unreachable; using the inbox for a few seconds.");
AppendToInbox(connection, gameId, json);
return;
}
Report("Hovermate refused an event: " + status + (string.IsNullOrEmpty(reply) ? "" : " " + reply));
}
private Connection CurrentConnection(bool force)
{
var now = DateTime.UtcNow;
if (!force && _connection != null && !_connectionStale) return _connection;
if (!force && now < _nextLoad) return _connection;
_nextLoad = now + ReloadInterval;
_connectionStale = false;
_connection = Load();
if (_connection == null && !_reportedMissing) { Report("connection.json not found: Hovermate is not running or its SDK is off."); _reportedMissing = true; }
if (_connection != null) _reportedMissing = false;
return _connection;
}
private static Connection Load()
{
var path = Environment.GetEnvironmentVariable("HOVERMATE_SDK_CONNECTION");
if (string.IsNullOrEmpty(path) || path.Trim().Length == 0)
{
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrEmpty(local)) return null;
path = System.IO.Path.Combine(System.IO.Path.Combine(System.IO.Path.Combine(local, "Hovermate"), "Sdk"), "connection.json");
}
path = path.Trim().Trim('"');
string text;
try
{
if (!File.Exists(path)) return null;
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
using (var reader = new StreamReader(stream, Utf8, true)) text = reader.ReadToEnd();
}
catch (Exception) { return null; }
var values = Json.ParseFlatObject(text);
string url, token, inbox;
if (values == null || !values.TryGetValue("url", out url) || !values.TryGetValue("token", out token)) return null;
values.TryGetValue("inbox", out inbox);
Uri address;
if (!Uri.TryCreate(url, UriKind.Absolute, out address) || address.Scheme != "http") return null;
return new Connection { Host = address.Host, Port = address.Port, Path = address.PathAndQuery, Token = token, Inbox = inbox };
}
/// One HTTP/1.1 POST on a plain socket: no proxy, no Expect: 100-continue, no Origin.
private static int Post(Connection connection, byte[] body, out string reply)
{
reply = null;
using (var client = new TcpClient(AddressFamily.InterNetwork))
{
IPAddress address;
var pending = IPAddress.TryParse(connection.Host, out address)
? client.BeginConnect(address, connection.Port, null, null)
: client.BeginConnect(connection.Host, connection.Port, null, null);
if (!pending.AsyncWaitHandle.WaitOne(TimeoutMilliseconds)) return -1;
client.EndConnect(pending);
client.NoDelay = true;
client.SendTimeout = TimeoutMilliseconds;
client.ReceiveTimeout = TimeoutMilliseconds;
var stream = client.GetStream();
var head = Encoding.ASCII.GetBytes("POST " + connection.Path + " HTTP/1.1\r\nHost: " + connection.Host + ":" + connection.Port.ToString(CultureInfo.InvariantCulture) +
"\r\nAuthorization: Bearer " + connection.Token + "\r\nContent-Type: application/json\r\nContent-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) +
"\r\nConnection: close\r\n\r\n");
stream.Write(head, 0, head.Length);
stream.Write(body, 0, body.Length);
var buffer = new byte[4096];
var received = new MemoryStream();
var deadline = DateTime.UtcNow.AddMilliseconds(TimeoutMilliseconds);
while (received.Length < 16 * 1024 && DateTime.UtcNow < deadline)
{
int read;
try { read = stream.Read(buffer, 0, buffer.Length); }
catch (IOException) { break; }
if (read <= 0) break;
received.Write(buffer, 0, read);
}
var text = Encoding.UTF8.GetString(received.ToArray());
var space = text.IndexOf(' ');
int status;
if (!text.StartsWith("HTTP/1.", StringComparison.Ordinal) || space < 0 || text.Length < space + 4 ||
!int.TryParse(text.Substring(space + 1, 3), NumberStyles.None, CultureInfo.InvariantCulture, out status)) return -1;
var end = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
if (end >= 0) reply = text.Substring(end + 4);
return status;
}
}
private void AppendToInbox(Connection connection, string gameId, string json)
{
if (string.IsNullOrEmpty(connection.Inbox) || !Directory.Exists(connection.Inbox)) return;
try
{
var bytes = Utf8.GetBytes(json + "\n");
using (var file = new FileStream(System.IO.Path.Combine(connection.Inbox, gameId + ".jsonl"), FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete))
file.Write(bytes, 0, bytes.Length);
}
catch (Exception failure) { Report("inbox write failed: " + failure.Message); }
}
/// Plain text: control characters become spaces, trimmed, at most UTF-16
/// units without splitting a surrogate pair; null when empty.
private static string Clean(string text, int maximum)
{
if (text == null) return null;
var builder = new StringBuilder(text.Length);
foreach (var character in text) builder.Append(char.IsControl(character) ? ' ' : character);
var clean = builder.ToString().Trim();
if (clean.Length > maximum)
{
var cut = char.IsHighSurrogate(clean[maximum - 1]) ? maximum - 1 : maximum;
clean = clean.Substring(0, cut).TrimEnd();
}
return clean.Length == 0 ? null : clean;
}
/// Lower-case letters, digits, _, - and (when or for custom
/// types) ..
private static bool Matches(string text, int minimum, int maximum, bool dots)
{
if (text == null || text.Length < minimum || text.Length > maximum) return false;
foreach (var character in text)
if (!((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '_' || character == '-' || character == '.'))
return false;
return true;
}
}
/// The little JSON this client needs: string escaping and a flat object of strings.
private static class Json
{
internal static void String(StringBuilder builder, string value)
{
builder.Append('"');
foreach (var character in value)
{
switch (character)
{
case '"': builder.Append("\\\""); break;
case '\\': builder.Append("\\\\"); break;
case '\n': builder.Append("\\n"); break;
case '\r': builder.Append("\\r"); break;
case '\t': builder.Append("\\t"); break;
default:
if (character < 0x20 || character == 0x7f || character == '\u2028' || character == '\u2029')
builder.Append("\\u").Append(((int)character).ToString("x4", CultureInfo.InvariantCulture));
else builder.Append(character);
break;
}
}
builder.Append('"');
}
/// The string members of a JSON object (other values are skipped); null when it is not an object.
internal static Dictionary ParseFlatObject(string text)
{
var result = new Dictionary(StringComparer.Ordinal);
var index = 0;
Skip(text, ref index);
if (index >= text.Length || text[index] != '{') return null;
index++;
while (true)
{
Skip(text, ref index);
if (index < text.Length && text[index] == '}') return result;
var key = ReadString(text, ref index);
if (key == null) return null;
Skip(text, ref index);
if (index >= text.Length || text[index] != ':') return null;
index++;
Skip(text, ref index);
if (index < text.Length && text[index] == '"')
{
var value = ReadString(text, ref index);
if (value == null) return null;
result[key] = value;
}
else if (!SkipValue(text, ref index)) return null;
Skip(text, ref index);
if (index < text.Length && text[index] == ',') { index++; continue; }
if (index < text.Length && text[index] == '}') return result;
return null;
}
}
private static void Skip(string text, ref int index)
{
while (index < text.Length && (text[index] == ' ' || text[index] == '\t' || text[index] == '\r' || text[index] == '\n' || text[index] == '\uFEFF')) index++;
}
private static string ReadString(string text, ref int index)
{
if (index >= text.Length || text[index] != '"') return null;
var builder = new StringBuilder();
for (index++; index < text.Length; index++)
{
var character = text[index];
if (character == '"') { index++; return builder.ToString(); }
if (character != '\\') { builder.Append(character); continue; }
if (++index >= text.Length) return null;
switch (text[index])
{
case 'n': builder.Append('\n'); break;
case 'r': builder.Append('\r'); break;
case 't': builder.Append('\t'); break;
case 'b': builder.Append('\b'); break;
case 'f': builder.Append('\f'); break;
case 'u':
int code;
if (index + 4 >= text.Length || !int.TryParse(text.Substring(index + 1, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out code)) return null;
builder.Append((char)code);
index += 4;
break;
default: builder.Append(text[index]); break;
}
}
return null;
}
/// Skips a number, literal, object or array (nested values are not needed).
private static bool SkipValue(string text, ref int index)
{
var depth = 0;
for (; index < text.Length; index++)
{
var character = text[index];
if (character == '"') { if (ReadString(text, ref index) == null) return false; index--; continue; }
if (character == '{' || character == '[') depth++;
else if (character == '}' || character == ']')
{
if (depth == 0) return true;
depth--;
if (depth == 0) { index++; return true; }
}
else if (character == ',' && depth == 0) return true;
}
return depth == 0;
}
}
}
}