// 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; } } } }