"""Hovermate Game Events SDK client for Python 3.7+: one file, standard library only. Protocol v1: https://docs.hovermate.gg/sdk/protocol/ MIT License, Copyright (c) 2026 Paliverse Apps LLC. See LICENSE in the SDK. Usage:: import hovermate_events as hovermate hovermate.init("com.example.mygame", "My Game", "MyGame.exe") hovermate.hello(["kill", "bossKill", hovermate.custom_offer("parry", "Parry")]) hovermate.event("kill", label="Double kill", tags=["headshot"], context="Act 2") hovermate.custom("parry", "Parry") hovermate.shutdown() Calls never block the caller and never raise. 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. """ import collections import datetime import http.client import json import os import re import threading import time import unicodedata import urllib.parse __all__ = ['init', 'hello', 'event', 'custom', 'custom_offer', 'flush', 'shutdown', 'set_log', 'Client'] _CAPACITY = 256 _TIMEOUT = 0.25 _RELOAD_SECONDS = 5.0 _HTTP_PAUSE_SECONDS = 5.0 _GAME_ID = re.compile(r'[a-z0-9._-]{3,64}') _CUSTOM_TYPE = re.compile(r'[a-z0-9_.-]{1,32}') _TAG = re.compile(r'[a-z0-9_-]{1,24}') def custom_offer(custom_type, custom_label): """A custom type for ``hello(offers)``: ``{"customType": ..., "customLabel": ...}``.""" return {'customType': custom_type, 'customLabel': custom_label} def _clean(text, maximum): """Plain text: control characters become spaces, trimmed, at most ``maximum`` UTF-16 units; None when blank.""" if text is None: return None value = ''.join(' ' if unicodedata.category(c) == 'Cc' else c for c in str(text)).strip() units, kept = 0, [] for character in value: units += 2 if ord(character) > 0xFFFF else 1 if units > maximum: break kept.append(character) value = ''.join(kept).rstrip() return value or None def _connection_path(): forced = os.environ.get('HOVERMATE_SDK_CONNECTION', '').strip().strip('"') if forced: return forced local = os.environ.get('LOCALAPPDATA') return os.path.join(local, 'Hovermate', 'Sdk', 'connection.json') if local else None class Client: """One game's connection to Hovermate. The module functions use a shared instance.""" def __init__(self): self._gate = threading.Condition() self._queue = collections.deque() self._game = None self._game_id = None self._thread = None self._stopping = False self._busy = False self._log = None # Background-thread state. self._connection = None self._stale = True self._next_load = 0.0 self._http_paused_until = 0.0 self._reported_missing = False def set_log(self, log): """Optional diagnostics (connection state, refused events): a callable taking one string.""" self._log = log def init(self, game_id, game_name, exe=None): """Starts the client. ``game_id`` matches ``[a-z0-9._-]{3,64}`` and stays stable across mod versions.""" try: if not isinstance(game_id, str) or not _GAME_ID.fullmatch(game_id): self._report('game id must match [a-z0-9._-]{3,64}: the client stays off.') return name = _clean(game_name, 64) if not name: self._report('game name is required: the client stays off.') return game = {'id': game_id, 'name': name} exe = _clean(exe, 128) if exe: game['exe'] = exe.replace('\\', '/').rsplit('/', 1)[-1] with self._gate: self._game, self._game_id, self._stopping = game, game_id, False if self._thread is None: self._thread = threading.Thread(target=self._run, name='Hovermate events', daemon=True) self._thread.start() except Exception as error: # never raise into the game self._report('init failed: %r' % (error,)) def hello(self, offers=None, log=None, files=None): """Announces the types the game can send, a log file with ``[HOVERMATE]`` lines and mod-folder event files. ``offers`` holds standard types and :func:`custom_offer` values.""" try: extra = {} if offers is not None: listed = [] for offer in offers: if isinstance(offer, str): if offer: listed.append(offer) continue custom_type = offer.get('customType') if isinstance(offer, dict) else None label = _clean(offer.get('customLabel'), 40) if isinstance(offer, dict) else None if isinstance(custom_type, str) and _CUSTOM_TYPE.fullmatch(custom_type) and label: listed.append({'customType': custom_type, 'customLabel': label}) else: self._report('skipped an invalid custom offer.') extra['offers'] = listed path = _clean(log, 260) if path: extra['log'] = path kept = [value for value in (_clean(file, 260) for file in files or ()) if value][:16] if kept: extra['files'] = kept self._enqueue('hello', extra) except Exception as error: self._report('hello failed: %r' % (error,)) def event(self, type, label=None, tags=None, context=None): """Sends a standard event type, for example ``kill``, ``death`` or ``bossKill``.""" self._send(type, None, None, label, tags, context) def custom(self, custom_type, custom_label, label=None, tags=None, context=None): """Sends a custom event: ``custom_type`` matches ``[a-z0-9_.-]{1,32}``; ``custom_label`` names its toggle.""" self._send('custom', custom_type, custom_label, label, tags, context) def flush(self, timeout=1.0): """Waits until queued events are sent; False when the timeout passed first.""" deadline = time.monotonic() + max(0.0, timeout) with self._gate: while (self._queue or self._busy) and self._thread is not None: left = deadline - time.monotonic() if left <= 0: return False self._gate.wait(left) return True def shutdown(self, timeout=1.0): """Sends what is queued (up to the timeout) and stops the background thread.""" try: self.flush(timeout) with self._gate: self._stopping = True self._queue.clear() thread, self._thread = self._thread, None self._gate.notify_all() if thread is not None and thread is not threading.current_thread(): thread.join(max(0.0, timeout)) except Exception: pass def _send(self, type, custom_type, custom_label, label, tags, context): try: if not isinstance(type, str) or not type or type == 'hello': self._report('event type is required.') return extra = {} if type == 'custom': toggle = _clean(custom_label, 40) if not isinstance(custom_type, str) or not _CUSTOM_TYPE.fullmatch(custom_type) or not toggle: self._report('custom events need customType [a-z0-9_.-]{1,32} and customLabel.') return extra['customType'], extra['customLabel'] = custom_type, toggle label = _clean(label, 80) if label: extra['label'] = label kept = [] for tag in tags or (): if isinstance(tag, str) and _TAG.fullmatch(tag) and tag not in kept and len(kept) < 8: kept.append(tag) if kept: extra['tags'] = kept context = _clean(context, 64) if context: extra['context'] = context self._enqueue(type, extra) except Exception as error: self._report('event failed: %r' % (error,)) def _enqueue(self, type, extra): stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z' with self._gate: if self._game is None or self._thread is None: return item = {'v': 1, 'game': self._game, 'type': type} item.update(extra) item['time'] = stamp if len(self._queue) >= _CAPACITY: self._queue.popleft() self._queue.append((json.dumps(item, ensure_ascii=False, separators=(',', ':')), self._game_id)) self._gate.notify_all() def _run(self): while True: with self._gate: self._busy = False while not self._queue and not self._stopping: self._gate.notify_all() self._gate.wait() if self._stopping: self._gate.notify_all() return line, game_id = self._queue.popleft() self._busy = True try: self._deliver(line, game_id) except Exception as error: self._report('delivery failed: %r' % (error,)) def _deliver(self, line, game_id): connection = self._current(False) if connection is None: return if time.monotonic() < self._http_paused_until: self._append_to_inbox(connection, game_id, line) return body = line.encode('utf-8') status, reply = 0, '' for attempt in range(2): status, reply = self._post(connection, body) if status == 202: return if attempt: break if status == 401: # The token rotates when the SDK is switched off and on: read the file again, retry once. connection = self._current(True) if connection is None: return continue if status != -1: break if status == -1: self._http_paused_until = time.monotonic() + _HTTP_PAUSE_SECONDS self._stale = True self._report("Hovermate's HTTP receiver is unreachable; using the inbox for a few seconds.") self._append_to_inbox(connection, game_id, line) return self._report('Hovermate refused an event: %d %s' % (status, reply)) def _current(self, force): now = time.monotonic() if not force and self._connection is not None and not self._stale: return self._connection if not force and now < self._next_load: return self._connection self._next_load, self._stale = now + _RELOAD_SECONDS, False self._connection = self._load() if self._connection is None and not self._reported_missing: self._report('connection.json not found: Hovermate is not running or its SDK is off.') self._reported_missing = True if self._connection is not None: self._reported_missing = False return self._connection @staticmethod def _load(): path = _connection_path() try: if not path or not os.path.isfile(path): return None with open(path, encoding='utf-8-sig') as file: values = json.load(file) url = urllib.parse.urlsplit(values['url']) if url.scheme != 'http' or not isinstance(values.get('token'), str): return None return {'host': url.hostname, 'port': url.port or 80, 'path': url.path + ('?' + url.query if url.query else ''), 'token': values['token'], 'inbox': values.get('inbox') if isinstance(values.get('inbox'), str) else None} except (OSError, ValueError, KeyError, TypeError, AttributeError): return None @staticmethod def _post(connection, body): """One POST without a proxy; status -1 when Hovermate could not be reached in time.""" client = http.client.HTTPConnection(connection['host'], connection['port'], timeout=_TIMEOUT) try: client.request('POST', connection['path'], body=body, headers={ 'Authorization': 'Bearer ' + connection['token'], 'Content-Type': 'application/json', 'Connection': 'close'}) response = client.getresponse() return response.status, response.read(4096).decode('utf-8', 'replace') except (OSError, http.client.HTTPException): return -1, '' finally: client.close() def _append_to_inbox(self, connection, game_id, line): inbox = connection.get('inbox') if not inbox or not os.path.isdir(inbox): return try: with open(os.path.join(inbox, game_id + '.jsonl'), 'a', encoding='utf-8', newline='\n') as file: file.write(line + '\n') except OSError as error: self._report('inbox write failed: %s' % (error,)) def _report(self, message): log = self._log if log is None: return try: log('[Hovermate] ' + message) except Exception: pass _shared = Client() init = _shared.init hello = _shared.hello event = _shared.event custom = _shared.custom flush = _shared.flush shutdown = _shared.shutdown set_log = _shared.set_log