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.
- 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
- Easy Integration - Simple UDP protocol for developers
-
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 file: mods/HyQuery/config.json
{
"enabled": true,
"showPlayerList": false,
"showPlugins": false,
"useCustomMotd": false,
"customMotd": "§aWelcome to §l§cHytale§r§a! §6Enjoy your stay!"
}| 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 |
| 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 |
HyQuery supports multi-server network configurations with primary and worker roles. This allows server networks to aggregate player counts and information across multiple servers.
┌─────────────┐
│ 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)
{
"enabled": true,
"showPlayerList": true,
"showPlugins": false,
"useCustomMotd": false,
"customMotd": "",
"rateLimitEnabled": true,
"rateLimitPerSecond": 10,
"rateLimitBurst": 20,
"cacheEnabled": true,
"cacheTtlSeconds": 5,
"network": {
"enabled": true,
"role": "primary",
"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.)
{
"enabled": true,
"showPlayerList": true,
"showPlugins": false,
"useCustomMotd": false,
"customMotd": "",
"rateLimitEnabled": true,
"rateLimitPerSecond": 10,
"rateLimitBurst": 20,
"cacheEnabled": true,
"cacheTtlSeconds": 5,
"network": {
"enabled": true,
"role": "worker",
"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 |
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.
┌─────────────┐ ┌─────────────┐
│ 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
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",
"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
| Option | Type | Default | Description |
|---|---|---|---|
network.enabled |
boolean | false |
Enable network mode |
network.role |
string | "worker" |
Server role: "primary" or "worker" |
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 |
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!"HyQuery uses a simple binary protocol over UDP on the game server port (default: 5520).
┌──────────┬─────────────┐
│ 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)
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 │
└──────────┴───────────┘
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']}")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));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);
}
}- Java 25 or higher
- Maven 3.6+
- HytaleServer.jar (place in project root)
# 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- 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
- Check that
enabledistruein config.json - Verify the server port (default: 5520)
- Check firewall rules allow UDP traffic
- Review server logs for HyQuery errors
- Set
showPlayerListorshowPluginstotruein config - Use query type
0x01(full query) instead of0x00(basic) - Restart the server after config changes
MIT License - See LICENSE file for details
Contributions are welcome! Please feel free to submit a Pull Request.
For issues, questions, or feature requests, please open an issue on the GitHub repository.