// Hovermate Game Events SDK client for Java 8+ (ModTheSpire, Forge, Fabric, plain Java): one file, no dependencies. // Protocol v1: https://docs.hovermate.gg/sdk/protocol/ // MIT License, Copyright (c) 2026 Paliverse Apps LLC. See LICENSE in the SDK. // // Copy this file into your mod. Several mods in one JVM can each bundle it: change the package (or relocate it when // shading) so that their copies do not collide. // // Usage: // HovermateEvents.init("com.example.mygame", "My Game", "MyGame.exe"); // HovermateEvents.hello(Offer.of("kill"), Offer.of("bossKill"), Offer.custom("parry", "Parry")); // HovermateEvents.event("kill", "Double kill", Arrays.asList("headshot"), "Act 2"); // HovermateEvents.custom("parry", "Parry"); // HovermateEvents.shutdown(1000); // when the mod unloads (optional: the thread is a daemon) // // Calls never block the game thread and never throw. Events go over HTTP from a daemon 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. package gg.hovermate.sdk; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.Proxy; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.text.SimpleDateFormat; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.TimeZone; import java.util.function.Consumer; /** Sends game events to Hovermate (protocol v1). All methods are thread-safe. */ public final class HovermateEvents { /** Optional diagnostics (connection state, refused events). Called from the background thread. */ public static volatile Consumer log; /** A type a hello offers: a standard type or a custom type with its toggle label. */ public static final class Offer { final String type, customType, customLabel; private Offer(String type, String customType, String customLabel) { this.type = type; this.customType = customType; this.customLabel = customLabel; } public static Offer of(String standardType) { return new Offer(standardType, null, null); } public static Offer custom(String customType, String customLabel) { return new Offer(null, customType, customLabel); } } private static final int CAPACITY = 256, TIMEOUT_MS = 250; private static final long RELOAD_MS = 5000, HTTP_PAUSE_MS = 5000; private static final Object GATE = new Object(); private static final ArrayDeque QUEUE = new ArrayDeque(); private static String gameJson, gameId; private static Thread worker; private static boolean stopping, busy; // Background-thread state. private static String[] connection; // url, token, inbox private static boolean connectionStale = true, reportedMissing; private static long nextLoad, httpPausedUntil; private HovermateEvents() { } /** * Starts the client. {@code gameId} matches {@code [a-z0-9._-]{3,64}} and stays stable across versions of the mod; * {@code exe} is the game's executable file name (may be null). */ public static void init(String gameId, String gameName, String exe) { try { if (!matches(gameId, 3, 64, true)) { report("game id must match [a-z0-9._-]{3,64}: the client stays off."); return; } String name = clean(gameName, 64); if (name == null) { report("game name is required: the client stays off."); return; } String file = clean(exe, 128); if (file != null) file = new File(file.replace('\\', '/')).getName(); StringBuilder game = new StringBuilder("{\"id\":"); string(game, gameId); game.append(",\"name\":"); string(game, name); if (file != null && !file.isEmpty()) { game.append(",\"exe\":"); string(game, file); } game.append('}'); synchronized (GATE) { HovermateEvents.gameId = gameId; gameJson = game.toString(); stopping = false; if (worker == null) { worker = new Thread(HovermateEvents::run, "Hovermate events"); worker.setDaemon(true); worker.start(); } } } catch (RuntimeException failure) { report("init failed: " + failure); } } /** Announces the types the game can send (optional, recommended). */ public static void hello(Offer... offers) { hello(Arrays.asList(offers), null, null); } /** * Announces the types the game can send, a log file with {@code [HOVERMATE]} lines and mod-folder event files * (both may be null). Hovermate asks the user before it reads a proposed path. */ public static void hello(List offers, String logFile, Collection files) { try { StringBuilder extra = new StringBuilder(); if (offers != null) { extra.append(",\"offers\":["); boolean first = true; for (Offer offer : offers) { if (offer == null) continue; if (offer.type != null) { if (offer.type.isEmpty()) continue; if (!first) extra.append(','); string(extra, offer.type); } else { String label = clean(offer.customLabel, 40); if (!matches(offer.customType, 1, 32, true) || label == null) { report("skipped an invalid custom offer."); continue; } if (!first) extra.append(','); extra.append("{\"customType\":"); string(extra, offer.customType); extra.append(",\"customLabel\":"); string(extra, label); extra.append('}'); } first = false; } extra.append(']'); } String path = clean(logFile, 260); if (path != null) { extra.append(",\"log\":"); string(extra, path); } if (files != null) { List kept = new ArrayList(); for (String file : files) { String value = clean(file, 260); if (value != null && kept.size() < 16) kept.add(value); } if (!kept.isEmpty()) list(extra.append(",\"files\":"), kept); } enqueue("hello", extra.toString()); } catch (RuntimeException failure) { report("hello failed: " + failure); } } /** Sends a standard event type, for example {@code kill}, {@code death} or {@code bossKill}. */ public static void event(String type) { event(type, null, null, null); } /** Sends a standard event type with an optional label (80), tags (8 × [a-z0-9_-]{1,24}) and context (64). */ public static void event(String type, String label, List tags, String context) { send(type, null, null, label, tags, context); } /** Sends a custom event: {@code customType} matches [a-z0-9_.-]{1,32}; {@code customLabel} names its toggle. */ public static void custom(String customType, String customLabel) { custom(customType, customLabel, null, null, null); } /** Sends a custom event with an optional label, tags and context. */ public static void custom(String customType, String customLabel, String label, List tags, String context) { send("custom", customType, customLabel, label, tags, context); } /** Waits until queued events are sent; false when the timeout passed first. */ public static boolean flush(long timeoutMs) { long deadline = System.currentTimeMillis() + Math.max(0, timeoutMs); synchronized (GATE) { try { while ((!QUEUE.isEmpty() || busy) && worker != null) { long left = deadline - System.currentTimeMillis(); if (left <= 0) return false; GATE.wait(left); } } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return false; } return true; } } /** Sends what is queued (up to the timeout) and stops the background thread. */ public static void shutdown(long timeoutMs) { flush(timeoutMs); Thread thread; synchronized (GATE) { stopping = true; QUEUE.clear(); thread = worker; worker = null; GATE.notifyAll(); } if (thread != null && thread != Thread.currentThread()) { try { thread.join(Math.max(1, timeoutMs)); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } } } private static void send(String type, String customType, String customLabel, String label, List tags, String context) { try { if (type == null || type.isEmpty() || type.equals("hello")) { report("event type is required."); return; } StringBuilder extra = new StringBuilder(); if (type.equals("custom")) { String toggle = clean(customLabel, 40); if (!matches(customType, 1, 32, true) || toggle == null) { report("custom events need customType [a-z0-9_.-]{1,32} and customLabel."); return; } extra.append(",\"customType\":"); string(extra, customType); extra.append(",\"customLabel\":"); string(extra, toggle); } String text = clean(label, 80); if (text != null) { extra.append(",\"label\":"); string(extra, text); } if (tags != null) { List kept = new ArrayList(); for (String tag : tags) if (kept.size() < 8 && matches(tag, 1, 24, false) && !kept.contains(tag)) kept.add(tag); if (!kept.isEmpty()) list(extra.append(",\"tags\":"), kept); } String area = clean(context, 64); if (area != null) { extra.append(",\"context\":"); string(extra, area); } enqueue(type, extra.toString()); } catch (RuntimeException failure) { report("event failed: " + failure); } } private static void enqueue(String type, String extra) { SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT); format.setTimeZone(TimeZone.getTimeZone("UTC")); String stamp = format.format(new Date()); synchronized (GATE) { if (gameJson == null || worker == null) return; StringBuilder json = new StringBuilder("{\"v\":1,\"game\":").append(gameJson).append(",\"type\":"); string(json, type); json.append(extra).append(",\"time\":\"").append(stamp).append("\"}"); if (QUEUE.size() >= CAPACITY) QUEUE.pollFirst(); QUEUE.addLast(json.toString()); GATE.notifyAll(); } } private static void run() { while (true) { String json, game; synchronized (GATE) { busy = false; try { while (QUEUE.isEmpty() && !stopping) { GATE.notifyAll(); GATE.wait(); } } catch (InterruptedException interrupted) { return; } if (stopping) { GATE.notifyAll(); return; } json = QUEUE.pollFirst(); game = gameId; busy = true; } try { deliver(json, game); } catch (RuntimeException failure) { report("delivery failed: " + failure); } } } private static void deliver(String json, String game) { String[] current = current(false); if (current == null) return; if (System.currentTimeMillis() < httpPausedUntil) { appendToInbox(current, game, json); return; } byte[] body = json.getBytes(StandardCharsets.UTF_8); int status = 0; String[] reply = new String[1]; for (int attempt = 0; attempt < 2; attempt++) { status = post(current, body, reply); 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. current = current(true); if (current == null) return; continue; } if (status != -1) break; } if (status == -1) { httpPausedUntil = System.currentTimeMillis() + HTTP_PAUSE_MS; connectionStale = true; report("Hovermate's HTTP receiver is unreachable; using the inbox for a few seconds."); appendToInbox(current, game, json); return; } report("Hovermate refused an event: " + status + (reply[0] == null || reply[0].isEmpty() ? "" : " " + reply[0])); } private static String[] current(boolean force) { long now = System.currentTimeMillis(); if (!force && connection != null && !connectionStale) return connection; if (!force && now < nextLoad) return connection; nextLoad = now + RELOAD_MS; 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 String[] load() { try { String location = System.getenv("HOVERMATE_SDK_CONNECTION"); Path path; if (location != null && !location.trim().isEmpty()) path = Paths.get(location.trim().replace("\"", "")); else { String local = System.getenv("LOCALAPPDATA"); if (local == null || local.isEmpty()) return null; path = Paths.get(local, "Hovermate", "Sdk", "connection.json"); } if (!Files.isRegularFile(path)) return null; Map values = parseFlatObject(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); if (values == null || values.get("url") == null || values.get("token") == null || !values.get("url").startsWith("http://")) return null; return new String[] { values.get("url"), values.get("token"), values.get("inbox") }; } catch (Exception failure) { return null; } } /** One POST without a proxy; -1 when Hovermate could not be reached in time. */ private static int post(String[] current, byte[] body, String[] reply) { reply[0] = null; HttpURLConnection request = null; try { request = (HttpURLConnection) new URL(current[0]).openConnection(Proxy.NO_PROXY); request.setConnectTimeout(TIMEOUT_MS); request.setReadTimeout(TIMEOUT_MS); request.setUseCaches(false); request.setInstanceFollowRedirects(false); request.setDoOutput(true); request.setRequestMethod("POST"); request.setFixedLengthStreamingMode(body.length); request.setRequestProperty("Authorization", "Bearer " + current[1]); request.setRequestProperty("Content-Type", "application/json"); OutputStream output = request.getOutputStream(); try { output.write(body); } finally { output.close(); } int status = request.getResponseCode(); InputStream input = status >= 400 ? request.getErrorStream() : request.getInputStream(); if (input != null) { try { ByteArrayOutputStream text = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int read; while (text.size() < 4096 && (read = input.read(buffer)) > 0) text.write(buffer, 0, read); reply[0] = new String(text.toByteArray(), StandardCharsets.UTF_8); } finally { input.close(); } } return status; } catch (Exception failure) { return -1; } finally { if (request != null) request.disconnect(); } } private static void appendToInbox(String[] current, String game, String json) { String inbox = current[2]; if (inbox == null || inbox.isEmpty() || !new File(inbox).isDirectory()) return; try { FileOutputStream file = new FileOutputStream(new File(inbox, game + ".jsonl"), true); try { file.write((json + "\n").getBytes(StandardCharsets.UTF_8)); } finally { file.close(); } } catch (Exception failure) { report("inbox write failed: " + failure); } } private static void report(String message) { Consumer sink = log; if (sink == null) return; try { sink.accept("[Hovermate] " + message); } catch (RuntimeException ignored) { } } /** Plain text: control characters become spaces, trimmed, at most {@code maximum} UTF-16 units; null when blank. */ static String clean(String text, int maximum) { if (text == null) return null; StringBuilder builder = new StringBuilder(text.length()); for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); builder.append(Character.isISOControl(c) ? ' ' : c); } String value = builder.toString().trim(); if (value.length() > maximum) { int cut = Character.isHighSurrogate(value.charAt(maximum - 1)) ? maximum - 1 : maximum; value = value.substring(0, cut).trim(); } return value.isEmpty() ? null : value; } static boolean matches(String text, int minimum, int maximum, boolean dots) { if (text == null || text.length() < minimum || text.length() > maximum) return false; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || (dots && c == '.'))) return false; } return true; } private static void list(StringBuilder out, List values) { out.append('['); for (int i = 0; i < values.size(); i++) { if (i > 0) out.append(','); string(out, values.get(i)); } out.append(']'); } static void string(StringBuilder out, String value) { out.append('"'); for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); switch (c) { case '"': out.append("\\\""); break; case '\\': out.append("\\\\"); break; case '\n': out.append("\\n"); break; case '\r': out.append("\\r"); break; case '\t': out.append("\\t"); break; default: if (c < 0x20 || c == 0x7f || c == '\u2028' || c == '\u2029') out.append(String.format(Locale.ROOT, "\\u%04x", (int) c)); else out.append(c); } } out.append('"'); } /** The string members of a flat JSON object (other values are skipped); null when it is not an object. */ static Map parseFlatObject(String text) { Map result = new HashMap(); int[] at = { 0 }; skip(text, at); if (at[0] >= text.length() || text.charAt(at[0]) != '{') return null; at[0]++; while (true) { skip(text, at); if (at[0] < text.length() && text.charAt(at[0]) == '}') return result; String key = readString(text, at); if (key == null) return null; skip(text, at); if (at[0] >= text.length() || text.charAt(at[0]) != ':') return null; at[0]++; skip(text, at); if (at[0] < text.length() && text.charAt(at[0]) == '"') { String value = readString(text, at); if (value == null) return null; result.put(key, value); } else { int depth = 0; for (; at[0] < text.length(); at[0]++) { char c = text.charAt(at[0]); if (c == '"') { if (readString(text, at) == null) return null; at[0]--; continue; } if (c == '{' || c == '[') depth++; else if ((c == '}' || c == ']') && depth > 0) { if (--depth == 0) { at[0]++; break; } } else if ((c == ',' || c == '}') && depth == 0) break; } } skip(text, at); if (at[0] < text.length() && text.charAt(at[0]) == ',') { at[0]++; continue; } return at[0] < text.length() && text.charAt(at[0]) == '}' ? result : null; } } private static void skip(String text, int[] at) { while (at[0] < text.length() && " \t\r\n\uFEFF".indexOf(text.charAt(at[0])) >= 0) at[0]++; } private static String readString(String text, int[] at) { if (at[0] >= text.length() || text.charAt(at[0]) != '"') return null; StringBuilder value = new StringBuilder(); for (at[0]++; at[0] < text.length(); at[0]++) { char c = text.charAt(at[0]); if (c == '"') { at[0]++; return value.toString(); } if (c != '\\') { value.append(c); continue; } if (++at[0] >= text.length()) return null; char e = text.charAt(at[0]); switch (e) { case 'n': value.append('\n'); break; case 'r': value.append('\r'); break; case 't': value.append('\t'); break; case 'b': value.append('\b'); break; case 'f': value.append('\f'); break; case 'u': if (at[0] + 4 >= text.length()) return null; try { value.append((char) Integer.parseInt(text.substring(at[0] + 1, at[0] + 5), 16)); } catch (NumberFormatException invalid) { return null; } at[0] += 4; break; default: value.append(e); } } return null; } }