HyQuery
A lightweight UDP query protocol plugin for Hytale servers that enables external tools to query server information.
HyQuery uses the same port as your game server by intercepting UDP packets with magic bytes before the QUIC codec processes them, ensuring no additional port configuration is needed.
Features
- Zero Port Configuration - Uses the same port as your game server (typically 5520)
- Privacy Control - Anonymous mode by default, optionally show player lists and plugins
- Custom MOTD - Support for Minecraft color codes in your MOTD
- Binary Protocol - Efficient binary format for fast queries
- OneQuery V2 Support - Challenge-protected
ONEQUERY/HYQUERY2queries - Easy Integration - Simple UDP protocol for developers
Installation
For Server Administrators
-
Download HyQuery
- Download the latest
hyquery-plugin-x.x.x.jarfrom the releases page
- Download the latest
-
Install the Plugin
# Place the jar file in your server's mods directory cp hyquery-plugin-1.0.0.jar /path/to/hytale/server/mods/ -
Start Your Server
- The plugin will automatically create a configuration file at
mods/HyQuery/config.json
- The plugin will automatically create a configuration file at
-
Configure (Optional)
- Edit
mods/HyQuery/config.jsonto customize behavior (see Configuration section)
- Edit
-
Restart the Server
- Changes to the configuration require a server restart
Configuration
Configuration file: mods/HyQuery/config.json
{
"enabled": true,
"showPlayerList": false,
"showPlugins": false,
"useCustomMotd": false,
"customMotd": "§aWelcome to §l§cHytale§r§a! §6Enjoy your stay!",
"v1Enabled": true,
"v2Enabled": true,
"challengeTokenValiditySeconds": 120,
"challengeSecret": "",
"authentication": {
"publicAccess": {
"basic": true,
"players": false
},
"tokens": {
"admin-full-access": {
"basic": true,
"players": true
}
}
}
}
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Enable/disable the query server |
showPlayerList |
boolean | false |
Include online player names and UUIDs in responses |
showPlugins |
boolean | false |
Include installed plugin list in responses |
useCustomMotd |
boolean | false |
Use custom MOTD instead of server config MOTD |
customMotd |
string | "§aWelcome..." |
Custom MOTD with Minecraft color code support |
Security Options
| Option | Type | Default | Description |
|---|---|---|---|
rateLimitEnabled |
boolean | true |
Enable per-IP rate limiting |
rateLimitPerSecond |
int | 10 |
Maximum requests per second per IP |
rateLimitBurst |
int | 20 |
Maximum burst requests allowed |
cacheEnabled |
boolean | true |
Enable response caching |
cacheTtlSeconds |
int | 5 |
Cache time-to-live in seconds |
v1Enabled |
boolean | true |
Enable legacy V1 (HYQUERY\0) request handling |
v2Enabled |
boolean | true |
Enable OneQuery V2 request handling |
challengeTokenValiditySeconds |
int | 120 |
V2 challenge validity window in seconds |
challengeSecret |
string | "" |
Optional V2 challenge secret (blank uses an ephemeral secret each restart) |
V2 Authentication
HyQuery supports OneQuery-compatible endpoint permissions:
authentication.publicAccess.basiccontrols unauthenticated BASIC endpoint accessauthentication.publicAccess.playerscontrols unauthenticated PLAYERS endpoint accessauthentication.tokensmaps auth tokens to per-endpoint permissions
When access is denied, HyQuery responds with FLAG_RESPONSE_AUTH_REQUIRED and server info payload.
Network Mode
HyQuery supports multi-server network configurations with primary and worker roles. This allows server networks to aggregate player counts and information across multiple servers.
Network coordination now supports two modes:
udp(default): existing worker->primary P2P status packets with HMAC verificationredis: workers publish snapshots to Redis, primaries read snapshots for network-wide responses
network.role semantics are unchanged in both modes:
primaryserves network-wide responsesworkerpublishes only (local query responses remain local)
Architecture
┌─────────────┐
│ Primary │ ◄── Query responses include
│ (Hub) │ all network players
└──────▲──────┘
│
┌──────────────┼──────────────┐
│ │ │
┌──────┴──────┐ ┌─────┴─────┐ ┌──────┴──────┐
│ Worker │ │ Worker │ │ Worker │
│ (Game 1) │ │ (Game 2) │ │ (Game 3) │
└─────────────┘ └───────────┘ └─────────────┘
- Primary: Receives status updates from workers, aggregates data for query responses
- Worker: Sends periodic status updates to primary server(s)
Primary Server Configuration
{
"enabled": true,
"showPlayerList": true,
"showPlugins": false,
"useCustomMotd": false,
"customMotd": "",
"rateLimitEnabled": true,
"rateLimitPerSecond": 10,
"rateLimitBurst": 20,
"cacheEnabled": true,
"cacheTtlSeconds": 5,
"network": {
"enabled": true,
"role": "primary",
"coordinator": "udp",
"workerTimeoutSeconds": 30,
"workers": [
{ "id": "game-1", "key": "your-secret-key-here" },
{ "id": "game-2", "key": "your-secret-key-here" },
{ "id": "minigame-*", "key": "shared-minigame-key" }
],
"logStatusUpdates": false
}
}
Primary-specific options:
| Option | Description |
|---|---|
workerTimeoutSeconds |
Seconds before marking a worker as offline |
workers |
List of authorized workers with their IDs and HMAC keys |
Worker IDs support wildcard matching with * (e.g., minigame-* matches minigame-1, minigame-lobby, etc.)
Worker Server Configuration (Single Primary)
{
"enabled": true,
"showPlayerList": true,
"showPlugins": false,
"useCustomMotd": false,
"customMotd": "",
"rateLimitEnabled": true,
"rateLimitPerSecond": 10,
"rateLimitBurst": 20,
"cacheEnabled": true,
"cacheTtlSeconds": 5,
"network": {
"enabled": true,
"role": "worker",
"coordinator": "udp",
"id": "game-1",
"primaryHost": "hub.example.com",
"primaryPort": 5520,
"key": "your-secret-key-here",
"updateIntervalSeconds": 5,
"logStatusUpdates": false
}
}
Worker-specific options:
| Option | Description |
|---|---|
id |
Unique identifier for this worker (must match primary's workers list) |
primaryHost |
Hostname or IP of the primary server |
primaryPort |
Port of the primary server |
key |
Shared HMAC secret (must match primary's key for this worker) |
updateIntervalSeconds |
How often to send status updates |
Hub Clustering
For networks with multiple hub servers (load-balanced or regional), workers can send status updates to all primary servers. This ensures any hub can answer queries with complete network data.
Hub Clustering Architecture
┌─────────────┐ ┌─────────────┐
│ Primary A │ │ Primary B │ ◄── Both hubs have
│ (US Hub) │ │ (EU Hub) │ complete network data
└──────▲──────┘ └──────▲──────┘
│ │
└─────────┬─────────┘
│
┌─────────┼─────────┐
│ │ │
┌──────┴───┐ ┌───┴───┐ ┌───┴──────┐
│ Worker 1 │ │ Wkr 2 │ │ Worker 3 │ ◄── Workers push to
└──────────┘ └───────┘ └──────────┘ ALL primaries
Worker Configuration (Hub Clustering)
Use the primaries array instead of single primaryHost/primaryPort:
{
"enabled": true,
"showPlayerList": true,
"showPlugins": false,
"useCustomMotd": false,
"customMotd": "",
"rateLimitEnabled": true,
"rateLimitPerSecond": 10,
"rateLimitBurst": 20,
"cacheEnabled": true,
"cacheTtlSeconds": 5,
"network": {
"enabled": true,
"role": "worker",
"coordinator": "udp",
"id": "game-1",
"primaries": [
{ "host": "us-hub.example.com", "port": 5520 },
{ "host": "eu-hub.example.com", "port": 5520 }
],
"key": "your-secret-key-here",
"updateIntervalSeconds": 5,
"logStatusUpdates": false
}
}
Notes:
- The
primarieslist takes precedence over legacyprimaryHost/primaryPort - All primaries must have this worker authorized with the same key
- Status updates are sent to all primaries simultaneously
- If some primaries are unreachable, updates continue to the available ones
Network Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
network.enabled |
boolean | false |
Enable network mode |
network.role |
string | "worker" |
Server role: "primary" or "worker" |
network.coordinator |
string | "udp" |
Coordinator mode: "udp" or "redis" |
network.namespace |
string | "global" |
Redis namespace for publish/read scope |
network.includeGlobalNamespace |
boolean | false |
In Redis primary mode, include global namespace when reading |
network.staleAfterSeconds |
int | 10 |
Redis snapshot stale timeout |
network.logStatusUpdates |
boolean | false |
Log status update activity |
Primary-only:
| Option | Type | Default | Description |
|---|---|---|---|
network.workerTimeoutSeconds |
int | 30 |
Seconds before worker marked offline |
network.workers |
array | [] |
Authorized workers [{id, key}, ...] |
Worker-only:
| Option | Type | Default | Description |
|---|---|---|---|
network.id |
string | "server-1" |
This worker's unique identifier |
network.primaryHost |
string | "localhost" |
Legacy: single primary host |
network.primaryPort |
int | 5520 |
Legacy: single primary port |
network.primaries |
array | [] |
Hub clustering: [{host, port}, ...] |
network.key |
string | "change-me" |
Shared HMAC secret |
network.updateIntervalSeconds |
int | 5 |
Update interval in seconds |
Redis-only (network.coordinator = "redis"):
| Option | Type | Default | Description |
|---|---|---|---|
network.redis.host |
string | "localhost" |
Redis host |
network.redis.port |
int | 6379 |
Redis port (single instance v1) |
network.redis.username |
string | "" |
Optional ACL username |
network.redis.password |
string | "" |
Optional ACL password |
network.redis.database |
int | 0 |
Redis database index |
network.redis.tls |
boolean | false |
Use TLS for Redis connection |
network.redis.connectTimeoutMillis |
int | 1000 |
Redis connect timeout |
network.redis.readTimeoutMillis |
int | 1000 |
Redis command read timeout |
network.redis.publishIntervalSeconds |
int | 5 |
Worker publish interval |
network.redis.requireAvailable |
boolean | true |
v1 enforces hard-fail when Redis is unavailable |
Observability:
| Option | Type | Default | Description |
|---|---|---|---|
network.observability.logLevel |
string | "info" |
Network log verbosity: error, warn, info, debug |
network.observability.metricsEnabled |
boolean | true |
Emit periodic network metrics summaries |
network.observability.metricsDetail |
string | "basic" |
Metrics detail: basic or detailed |
Redis Mode Configuration
Primary (network responder):
{
"enabled": true,
"showPlayerList": true,
"showPlugins": false,
"network": {
"enabled": true,
"role": "primary",
"coordinator": "redis",
"namespace": "prod-us",
"includeGlobalNamespace": true,
"staleAfterSeconds": 10,
"redis": {
"host": "redis.example.com",
"port": 6379,
"username": "hyquery",
"password": "REPLACE_ME",
"database": 0,
"tls": true,
"connectTimeoutMillis": 1000,
"readTimeoutMillis": 1000,
"publishIntervalSeconds": 5,
"requireAvailable": true
},
"observability": {
"logLevel": "info",
"metricsEnabled": true,
"metricsDetail": "basic"
}
}
}
Worker (publisher only):
{
"enabled": true,
"network": {
"enabled": true,
"role": "worker",
"coordinator": "redis",
"id": "game-1",
"namespace": "prod-us",
"staleAfterSeconds": 10,
"redis": {
"host": "redis.example.com",
"port": 6379,
"username": "hyquery",
"password": "REPLACE_ME",
"database": 0,
"tls": true,
"connectTimeoutMillis": 1000,
"readTimeoutMillis": 1000,
"publishIntervalSeconds": 5,
"requireAvailable": true
}
}
}
Redis mode operational notes:
- Startup hard-fails if Redis health check fails.
- Runtime Redis failures fail closed (no silent local-only fallback for network-wide responses).
- v1 trust model is Redis ACL/TLS only (no additional worker-signing in Redis mode).
Migration Notes
- Existing configs remain compatible: missing
network.coordinatordefaults toudp. - Existing UDP worker HMAC behavior is unchanged.
network.rolevalues and semantics are unchanged.- To migrate to Redis, set
network.coordinatortoredisand fillnetwork.redissettings.
Minecraft Color Codes
When useCustomMotd is enabled, you can use Minecraft formatting codes in your MOTD.
Note: These color codes currently have no impact on the in-game display. They are preserved in the query response for external tools such as server lists to display formatted MOTDs.
Colors:
§0Black,§1Dark Blue,§2Dark Green,§3Dark Aqua§4Dark Red,§5Dark Purple,§6Gold,§7Gray§8Dark Gray,§9Blue,§aGreen,§bAqua§cRed,§dLight Purple,§eYellow,§fWhite
Formatting:
§lBold,§mStrikethrough,§nUnderline,§oItalic,§rReset
Example:
"customMotd": "§aWelcome to §l§6MyServer§r§a! §bHave fun!"
Developer Integration
Protocol Specification
HyQuery supports both protocol families:
- V1 (legacy):
HYQUERY\0->HYREPLY\0 - V2 (OneQuery):
ONEQUERY/HYQUERY2with challenge-token flow
V2 response magic follows request family:
ONEQUERYrequests receiveONEREPLYHYQUERY2requests receiveHYREPLY2
All V2 non-challenge requests require a valid challenge token tied to the sender IP.
V2 BASIC/PLAYERS endpoints also honor authentication permissions and may return AUTH_REQUIRED.
V1 (Legacy)
HyQuery uses a simple binary protocol over UDP on the game server port (default: 5520).
Request Format
┌──────────┬─────────────┐
│ Magic │ Query Type │
│ 8 bytes │ 1 byte │
└──────────┴─────────────┘
- Magic Bytes:
HYQUERY\0(ASCII, null-terminated) - Query Type:
0x00- Basic query (server info only)0x01- Full query (includes player list and plugins if enabled)
Response Format
All multi-byte integers use little-endian byte order.
Response Header:
┌──────────┬─────────────┐
│ Magic │ Type │
│ 8 bytes │ 1 byte │
└──────────┴─────────────┘
- Magic Bytes:
HYREPLY\0(ASCII, null-terminated) - Type:
0x00(basic) or0x01(full)
Basic Response (Type 0x00):
| Field | Type | Description |
|---|---|---|
| Server Name | String | Length-prefixed UTF-8 string |
| MOTD | String | Length-prefixed UTF-8 string |
| Online Players | uint32 LE | Current player count |
| Max Players | uint32 LE | Maximum player capacity |
| Port | uint32 LE | Server port |
| Version | String | Length-prefixed UTF-8 string |
Full Response (Type 0x01):
Includes all basic fields plus:
| Field | Type | Description |
|---|---|---|
| Player Count | uint32 LE | Number of player entries |
| Player Entries | Array | For each player: |
| - Username | String | Length-prefixed UTF-8 string |
| - UUID | 16 bytes | UUID (MSB 8 bytes + LSB 8 bytes) |
| Plugin Count | uint32 LE | Number of plugin entries |
| Plugin Entries | Array | For each plugin: |
| - Name | String | Length-prefixed UTF-8 string (format: "group:name") |
String Format:
┌──────────┬───────────┐
│ Length │ Data │
│ uint16LE │ UTF-8 │
└──────────┴───────────┘
Example Implementations
Python
import socket
import struct
REQUEST_MAGIC = b"HYQUERY\0"
RESPONSE_MAGIC = b"HYREPLY\0"
TYPE_BASIC = 0x00
TYPE_FULL = 0x01
def query_server(host, port, query_type=TYPE_BASIC):
# Create request
request = REQUEST_MAGIC + bytes([query_type])
# Send query
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5.0)
sock.sendto(request, (host, port))
# Receive response
data, _ = sock.recvfrom(65535)
sock.close()
return parse_response(data)
def parse_response(data):
if not data.startswith(RESPONSE_MAGIC):
raise ValueError("Invalid response magic bytes")
offset = len(RESPONSE_MAGIC)
response_type = data[offset]
offset += 1
# Read strings
def read_string(offset):
length = struct.unpack_from('<H', data, offset)[0]
offset += 2
string = data[offset:offset + length].decode('utf-8')
return string, offset + length
# Parse basic info
result = {}
result['serverName'], offset = read_string(offset)
result['motd'], offset = read_string(offset)
result['onlinePlayers'] = struct.unpack_from('<I', data, offset)[0]
offset += 4
result['maxPlayers'] = struct.unpack_from('<I', data, offset)[0]
offset += 4
result['port'] = struct.unpack_from('<I', data, offset)[0]
offset += 4
result['version'], offset = read_string(offset)
# Parse full response data
if response_type == TYPE_FULL:
player_count = struct.unpack_from('<I', data, offset)[0]
offset += 4
players = []
for _ in range(player_count):
username, offset = read_string(offset)
uuid_bytes = data[offset:offset + 16]
offset += 16
players.append({'username': username, 'uuid': uuid_bytes.hex()})
result['players'] = players
plugin_count = struct.unpack_from('<I', data, offset)[0]
offset += 4
plugins = []
for _ in range(plugin_count):
plugin_name, offset = read_string(offset)
plugins.append(plugin_name)
result['plugins'] = plugins
return result
# Usage
info = query_server('localhost', 5520, TYPE_BASIC)
print(f"Server: {info['serverName']}")
print(f"Players: {info['onlinePlayers']}/{info['maxPlayers']}")
JavaScript (Node.js)
const dgram = require('dgram');
const REQUEST_MAGIC = Buffer.from('HYQUERY\0', 'ascii');
const RESPONSE_MAGIC = Buffer.from('HYREPLY\0', 'ascii');
const TYPE_BASIC = 0x00;
const TYPE_FULL = 0x01;
function queryServer(host, port, queryType = TYPE_BASIC) {
return new Promise((resolve, reject) => {
const request = Buffer.concat([REQUEST_MAGIC, Buffer.from([queryType])]);
const client = dgram.createSocket('udp4');
client.on('message', (msg) => {
client.close();
try {
resolve(parseResponse(msg));
} catch (err) {
reject(err);
}
});
client.on('error', (err) => {
client.close();
reject(err);
});
setTimeout(() => {
client.close();
reject(new Error('Timeout'));
}, 5000);
client.send(request, port, host);
});
}
function parseResponse(data) {
if (!data.subarray(0, 8).equals(RESPONSE_MAGIC)) {
throw new Error('Invalid response magic bytes');
}
let offset = 8;
const responseType = data[offset++];
const readString = () => {
const length = data.readUInt16LE(offset);
offset += 2;
const str = data.subarray(offset, offset + length).toString('utf-8');
offset += length;
return str;
};
const result = {
serverName: readString(),
motd: readString(),
onlinePlayers: data.readUInt32LE(offset),
maxPlayers: data.readUInt32LE(offset + 4),
port: data.readUInt32LE(offset + 8),
};
offset += 12;
result.version = readString();
if (responseType === TYPE_FULL) {
const playerCount = data.readUInt32LE(offset);
offset += 4;
result.players = [];
for (let i = 0; i < playerCount; i++) {
const username = readString();
const uuid = data.subarray(offset, offset + 16);
offset += 16;
result.players.push({ username, uuid: uuid.toString('hex') });
}
const pluginCount = data.readUInt32LE(offset);
offset += 4;
result.plugins = [];
for (let i = 0; i < pluginCount; i++) {
result.plugins.push(readString());
}
}
return result;
}
// Usage
queryServer('localhost', 5520, TYPE_BASIC)
.then(info => {
console.log(`Server: ${info.serverName}`);
console.log(`Players: ${info.onlinePlayers}/${info.maxPlayers}`);
})
.catch(err => console.error('Query failed:', err));
Java
import java.io.IOException;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
public class HyQueryClient {
private static final byte[] REQUEST_MAGIC = "HYQUERY\0".getBytes(StandardCharsets.US_ASCII);
private static final byte[] RESPONSE_MAGIC = "HYREPLY\0".getBytes(StandardCharsets.US_ASCII);
private static final byte TYPE_BASIC = 0x00;
private static final byte TYPE_FULL = 0x01;
public static QueryResponse query(String host, int port, byte queryType) throws IOException {
// Create request
byte[] request = new byte[REQUEST_MAGIC.length + 1];
System.arraycopy(REQUEST_MAGIC, 0, request, 0, REQUEST_MAGIC.length);
request[REQUEST_MAGIC.length] = queryType;
// Send query
DatagramSocket socket = new DatagramSocket();
socket.setSoTimeout(5000);
InetAddress address = InetAddress.getByName(host);
DatagramPacket packet = new DatagramPacket(request, request.length, address, port);
socket.send(packet);
// Receive response
byte[] buffer = new byte[65535];
DatagramPacket response = new DatagramPacket(buffer, buffer.length);
socket.receive(response);
socket.close();
return parseResponse(response.getData(), response.getLength());
}
private static QueryResponse parseResponse(byte[] data, int length) {
ByteBuffer buf = ByteBuffer.wrap(data, 0, length).order(ByteOrder.LITTLE_ENDIAN);
// Check magic bytes
byte[] magic = new byte[RESPONSE_MAGIC.length];
buf.get(magic);
byte responseType = buf.get();
QueryResponse result = new QueryResponse();
result.serverName = readString(buf);
result.motd = readString(buf);
result.onlinePlayers = buf.getInt();
result.maxPlayers = buf.getInt();
result.port = buf.getInt();
result.version = readString(buf);
// Parse full response if needed
if (responseType == TYPE_FULL) {
int playerCount = buf.getInt();
result.players = new Player[playerCount];
for (int i = 0; i < playerCount; i++) {
String username = readString(buf);
byte[] uuid = new byte[16];
buf.get(uuid);
result.players[i] = new Player(username, uuid);
}
int pluginCount = buf.getInt();
result.plugins = new String[pluginCount];
for (int i = 0; i < pluginCount; i++) {
result.plugins[i] = readString(buf);
}
}
return result;
}
private static String readString(ByteBuffer buf) {
int length = buf.getShort() & 0xFFFF;
byte[] bytes = new byte[length];
buf.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
public static void main(String[] args) throws IOException {
QueryResponse info = query("localhost", 5520, TYPE_BASIC);
System.out.println("Server: " + info.serverName);
System.out.println("Players: " + info.onlinePlayers + "/" + info.maxPlayers);
}
}
Building from Source
Prerequisites
- Java 25 or higher
- Maven 3.6+
HytaleServer.jarin the project root
Note:
HytaleServer.jaris a local build dependency and is not committed to this repository. Maven will fail to compile without it.
Build Steps
# Clone the repository
git clone https://github.com/hyvote/hyquery.git
cd hyquery
# Place HytaleServer.jar in the project root
cp /path/to/HytaleServer.jar .
# Build with Maven
mvn clean package
# The compiled jar will be in target/
ls target/hyquery-plugin-*.jar
Troubleshooting
Plugin not loading
- Check that the jar file is in the
mods/directory - Verify server logs for error messages
- Ensure you're running a compatible Hytale server version
No response to queries
- Check that
enabledistruein config.json - Verify the server port (default: 5520)
- Check firewall rules allow UDP traffic
- Review server logs for HyQuery errors
Player list / plugins not showing
- Set
showPlayerListorshowPluginstotruein config - Use query type
0x01(full query) instead of0x00(basic) - Restart the server after config changes
License
MIT License - See LICENSE file for details
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Support
For issues, questions, or feature requests, please open an issue on the GitHub repository.
