Hytale Multiserver Architecture Guide
Learn how to build sophisticated Hytale server networks with player referral systems, connection redirects, fallback servers, and custom QUIC proxies. Essential reading for server providers and network administrators.
Quick Navigation
Architecture Overview
Hytale's multiserver architecture allows you to build complex server networks that can:
- Transfer players between servers without disconnecting
- Implement lobby systems and game mode hubs
- Balance load across multiple game servers
- Provide fallback servers when primary servers are full
- Build minigame networks with seamless transitions
- Create proxy layers for DDoS protection
Core Concepts
Player Referral
API to send players to different servers while maintaining their session and authentication state.
Connection Redirect
Handshake-level redirection before player fully connects, useful for maintenance mode or server selection.
Fallback Server
Automatically send disconnected players to alternative servers instead of kicking them.
QUIC Proxy
Custom proxy server built with Netty QUIC to route traffic between players and backend servers.
Example Network Topology
┌─────────────┐
│ Players │
└──────┬──────┘
│
▼
┌─────────────────┐
│ Proxy Server │ ← Single entry point (port 5520)
│ (QUIC Proxy) │
└────────┬────────┘
│
┌────┴────────────┬──────────┐
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────────┐
│ Lobby │ │ Minigame│ │ Survival │
│ Server │ │ Server │ │ Server │
└────────┘ └─────────┘ └──────────┘
│ │ │
└────────┬───────┴─────────────┘
▼
[Player Referral API]Players connect to a single proxy server which distributes them to appropriate backend servers based on your logic.
Player Referral System
The Player Referral API allows you to transfer connected players to different servers seamlessly.
Using PlayerRef.referToServer()
The core API for player transfers:
// Java example from HytaleServer.jar
import com.hytale.server.api.PlayerRef;
import com.hytale.server.api.ServerAddress;
// Transfer player to another server
public void sendPlayerToMinigame(PlayerRef player) {
ServerAddress destination = new ServerAddress(
"minigame.example.com",
5520
);
// This transfers the player while maintaining their session
player.referToServer(destination);
// Player's client will seamlessly connect to new server
// Authentication state is preserved
}Session Continuity
When a player is referred to another server:
- Authentication persists: Player doesn't need to re-authenticate
- Seamless transition: No disconnect screen shown to player
- Transfer token issued: Source server generates token for destination server
- Timeout protection: Transfer must complete within 30 seconds or player disconnects
Practical Use Cases
Lobby → Game Server
Players spawn in a lobby server where they can select game modes. Clicking on a game mode portal transfers them to the appropriate game server.
// Player clicks on "Skywars" portal
player.referToServer(new ServerAddress("skywars1.network.com", 5520));Game End → Lobby
When a minigame ends, automatically send players back to the lobby instead of leaving them in an empty arena.
// Game ended, send all players to lobby
for (PlayerRef player : game.getPlayers()) {
player.referToServer(lobbyAddress);
}Load Balancing
Dynamically distribute players across multiple game servers based on current load.
// Find least populated server ServerAddress leastPopulated = findLeastPopulatedServer(); player.referToServer(leastPopulated);
Cross-Server Teleportation
Build a network where different worlds are hosted on separate servers, with portals that transfer players between them.
// Player enters nether portal player.referToServer(netherServerAddress);
Error Handling
Handle transfer failures gracefully:
try {
player.referToServer(destinationAddress);
logger.info("Transferred player {} to {}",
player.getUsername(), destinationAddress);
} catch (ServerUnavailableException e) {
// Destination server is offline or unreachable
player.sendMessage("§cThat server is currently offline.");
// Optionally try fallback server
player.referToServer(fallbackAddress);
} catch (TransferTimeoutException e) {
// Transfer didn't complete in time
player.sendMessage("§cTransfer timed out. Please try again.");
} catch (PlayerTransferException e) {
// Generic transfer failure
logger.error("Failed to transfer player", e);
player.disconnect("Transfer failed");
}Connection Redirects
Connection redirects occur during the handshake phase, before the player fully connects. This is useful for maintenance mode, version mismatches, or server selection.
Handshake-Level Redirect
Intercept connection during handshake and redirect to another server:
// Plugin/Mod example
@EventHandler
public void onPlayerHandshake(PlayerHandshakeEvent event) {
if (serverMaintenanceMode) {
// Redirect to maintenance lobby
event.redirectTo(new ServerAddress("maintenance.network.com", 5520));
event.setRedirectMessage("§eServer is in maintenance mode.");
}
if (event.getClientVersion() != REQUIRED_VERSION) {
// Redirect outdated clients to update server
event.redirectTo(updateServerAddress);
event.setRedirectMessage("§cPlease update your client.");
}
}Maintenance Mode Implementation
Redirect all players to a maintenance lobby during updates:
Scenario:
You need to restart your main game servers for updates, but you don't want to kick all players offline.
Solution:
- Keep a lightweight "maintenance lobby" server running
- Enable maintenance mode on main servers, which redirects new connections to lobby
- Use player referral to move existing players to lobby
- Update and restart main servers
- Once updated, refer all players back from lobby to main servers
Version Routing
Route players to version-specific servers automatically:
@EventHandler
public void onHandshake(PlayerHandshakeEvent event) {
String clientVersion = event.getClientVersion();
// Route to appropriate server based on version
switch (clientVersion) {
case "1.0.0":
event.redirectTo(new ServerAddress("v1.network.com", 5520));
break;
case "1.1.0":
event.redirectTo(new ServerAddress("v1-1.network.com", 5520));
break;
case "2.0.0":
event.redirectTo(new ServerAddress("v2.network.com", 5520));
break;
default:
event.deny("§cUnsupported client version: " + clientVersion);
}
}Geographic Routing
Redirect players to geographically closer servers for better latency:
@EventHandler
public void onHandshake(PlayerHandshakeEvent event) {
String playerIP = event.getPlayerIP();
String region = geoIPLookup(playerIP); // US, EU, ASIA, etc.
ServerAddress regionalServer = switch(region) {
case "US" -> new ServerAddress("us.network.com", 5520);
case "EU" -> new ServerAddress("eu.network.com", 5520);
case "ASIA" -> new ServerAddress("asia.network.com", 5520);
default -> new ServerAddress("global.network.com", 5520);
};
event.redirectTo(regionalServer);
}Fallback Servers
Fallback servers catch players who would otherwise be disconnected, providing a better user experience during server issues.
Configuring Fallbacks
Set up fallback behavior in server configuration:
// server.properties or config.yaml fallback-server-enabled: true fallback-server-address: lobby.network.com fallback-server-port: 5520 # Priority list of fallback servers fallback-servers: - lobby1.network.com:5520 - lobby2.network.com:5520 - maintenance.network.com:5520
Disconnect Events
Handle disconnect events and route to fallback:
@EventHandler
public void onPlayerDisconnect(PlayerDisconnectEvent event) {
if (event.getReason() == DisconnectReason.SERVER_SHUTDOWN) {
// Server shutting down - send to fallback
ServerAddress fallback = getFallbackServer();
try {
event.getPlayer().referToServer(fallback);
event.setCancelled(true); // Prevent actual disconnect
} catch (Exception e) {
// Fallback failed, allow disconnect
logger.error("Fallback failed for player", e);
}
}
}Smart Fallback Selection
Choose fallback server intelligently based on current conditions:
private ServerAddress getFallbackServer() {
List<ServerAddress> fallbackServers = config.getFallbackServers();
for (ServerAddress server : fallbackServers) {
// Check if server is online
if (!isServerOnline(server)) continue;
// Check if server has capacity
int currentPlayers = getPlayerCount(server);
int maxPlayers = getMaxPlayers(server);
if (currentPlayers >= maxPlayers) continue;
// Check server latency/responsiveness
if (getServerPing(server) > 100) continue;
// This server is suitable
return server;
}
// No fallback available, return first in list
return fallbackServers.get(0);
}Common Fallback Scenarios
Server Restart
When restarting a game server, move all players to lobby instead of disconnecting them.
Server Crash
If a backend server crashes, players automatically get transferred to a stable fallback server.
Server Full
When a server reaches capacity, new players are sent to an alternative server or queue lobby.
Maintenance Window
During scheduled maintenance, all traffic automatically routes to maintenance lobby with status updates.
Building Custom QUIC Proxies
Build custom proxy servers using Netty QUIC to route Hytale traffic. This enables advanced networking features like DDoS protection, load balancing, and protocol inspection.
Why Build a Proxy?
- Single entry point: Players only need one IP address, proxy routes to backends
- DDoS protection: Proxy can absorb attacks and rate limit connections
- Load balancing: Distribute players across multiple backend servers
- Protocol inspection: Log, filter, or modify packets in transit
- Zero-downtime updates: Update backend servers without changing player-facing IP
- Authentication offloading: Proxy handles OAuth2, backend servers trust proxy
Dependencies
Add Netty QUIC to your Maven/Gradle project:
<!-- Maven -->
<dependency>
<groupId>io.netty.incubator</groupId>
<artifactId>netty-incubator-codec-quic</artifactId>
<version>0.0.62.Final</version>
</dependency>
// Gradle
implementation 'io.netty.incubator:netty-incubator-codec-quic:0.0.62.Final'Basic Proxy Implementation
Minimal QUIC proxy for Hytale:
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.incubator.codec.quic.*;
public class HytaleProxy {
private final int listenPort = 5520;
private final String backendHost = "backend.network.com";
private final int backendPort = 5520;
public void start() throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
try {
// Configure QUIC server
QuicSslContext sslContext = QuicSslContextBuilder
.forServer(privateKey, certificateChain)
.applicationProtocols("hytale")
.build();
ChannelHandler codec = new QuicServerCodecBuilder()
.sslContext(sslContext)
.maxIdleTimeout(30_000, TimeUnit.MILLISECONDS)
.initialMaxData(10_000_000)
.initialMaxStreamDataBidirectionalLocal(1_000_000)
.initialMaxStreamDataBidirectionalRemote(1_000_000)
.initialMaxStreamsBidirectional(100)
.tokenHandler(InsecureQuicTokenHandler.INSTANCE)
.handler(new ChannelInitializer<QuicChannel>() {
@Override
protected void initChannel(QuicChannel ch) {
ch.pipeline().addLast(new ProxyHandler());
}
})
.streamHandler(new ChannelInitializer<QuicStreamChannel>() {
@Override
protected void initChannel(QuicStreamChannel ch) {
ch.pipeline().addLast(new StreamProxyHandler());
}
})
.build();
// Bind and start accepting connections
Bootstrap bootstrap = new Bootstrap()
.group(group)
.channel(NioDatagramChannel.class)
.handler(codec);
Channel channel = bootstrap.bind(listenPort).sync().channel();
System.out.println("Hytale Proxy listening on port " + listenPort);
channel.closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
}Packet Forwarding Handler
Forward packets between client and backend server:
public class StreamProxyHandler extends ChannelInboundHandlerAdapter {
private Channel backendChannel;
@Override
public void channelActive(ChannelHandlerContext ctx) {
// Establish connection to backend server
Bootstrap backendBootstrap = new Bootstrap()
.group(ctx.channel().eventLoop())
.channel(NioDatagramChannel.class)
.handler(new BackendConnectionHandler(ctx.channel()));
backendBootstrap.connect(backendHost, backendPort)
.addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
backendChannel = future.channel();
} else {
ctx.close();
}
});
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// Forward client packets to backend
if (backendChannel != null && backendChannel.isActive()) {
backendChannel.writeAndFlush(msg);
} else {
ReferenceCountUtil.release(msg);
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
if (backendChannel != null) {
backendChannel.close();
}
}
}
public class BackendConnectionHandler extends ChannelInboundHandlerAdapter {
private final Channel clientChannel;
public BackendConnectionHandler(Channel clientChannel) {
this.clientChannel = clientChannel;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// Forward backend packets to client
if (clientChannel.isActive()) {
clientChannel.writeAndFlush(msg);
} else {
ReferenceCountUtil.release(msg);
}
}
}Advanced Features
Connection Rate Limiting
private LoadingCache<String, AtomicInteger> connectionCounts =
CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build(new CacheLoader<String, AtomicInteger>() {
public AtomicInteger load(String key) {
return new AtomicInteger(0);
}
});
@Override
public void channelActive(ChannelHandlerContext ctx) {
String clientIP = getClientIP(ctx);
int connections = connectionCounts.get(clientIP).incrementAndGet();
if (connections > 5) {
// Rate limit exceeded
ctx.close();
return;
}
super.channelActive(ctx);
}Packet Inspection & Logging
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf packet = (ByteBuf) msg;
// Log packet metadata
logger.debug("Packet from {}: {} bytes",
ctx.channel().remoteAddress(),
packet.readableBytes());
// Inspect packet contents (be careful with sensitive data!)
if (shouldInspect(packet)) {
byte[] data = new byte[packet.readableBytes()];
packet.getBytes(0, data);
analyzePacket(data);
}
// Forward packet
backendChannel.writeAndFlush(msg);
}Dynamic Backend Selection
@Override
public void channelActive(ChannelHandlerContext ctx) {
// Select backend based on load balancing algorithm
ServerAddress backend = loadBalancer.selectServer();
// Log selection
logger.info("Routing player to {}", backend);
// Connect to selected backend
connectToBackend(ctx, backend);
}Packet Definitions
Hytale's packet structure can be found in the HytaleServer.jar. Common packets for proxy implementation:
- Handshake Packet: Initial connection, contains protocol version and player intent
- Login Start: Player username and authentication token
- Login Success: Server confirms successful authentication
- Disconnect: Connection termination with reason
- Keep Alive: Periodic ping to maintain connection
Decompile HytaleServer.jar with tools like JD-GUI or Jadx to inspect full packet definitions.
Load Balancing Strategies
Distribute players across multiple servers to optimize performance and resource utilization.
Round Robin
Simplest algorithm - rotate through servers sequentially:
public class RoundRobinBalancer {
private List<ServerAddress> servers;
private AtomicInteger currentIndex = new AtomicInteger(0);
public ServerAddress selectServer() {
int index = currentIndex.getAndIncrement() % servers.size();
return servers.get(index);
}
}Pros: Simple, fair distribution. Cons: Doesn't account for server load or capacity.
Least Connections
Send players to server with fewest current connections:
public class LeastConnectionsBalancer {
private Map<ServerAddress, Integer> connectionCounts;
public ServerAddress selectServer() {
return connectionCounts.entrySet().stream()
.min(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(fallbackServer);
}
public void onPlayerConnect(ServerAddress server) {
connectionCounts.merge(server, 1, Integer::sum);
}
public void onPlayerDisconnect(ServerAddress server) {
connectionCounts.merge(server, -1, Integer::sum);
}
}Pros: Balances load based on actual usage. Cons: Requires tracking connection counts.
Weighted Distribution
Assign weights to servers based on their capacity:
public class WeightedBalancer {
private List<WeightedServer> servers;
static class WeightedServer {
ServerAddress address;
int weight; // Higher weight = more traffic
int currentConnections;
}
public ServerAddress selectServer() {
return servers.stream()
.max(Comparator.comparingDouble(s ->
(double) s.weight / (s.currentConnections + 1)
))
.map(s -> s.address)
.orElse(fallbackServer);
}
}Example: High-spec server gets weight 10, low-spec gets weight 3. High-spec receives ~3x more traffic.
Geographic Routing
Route players to nearest server for best latency:
public class GeographicBalancer {
private Map<String, List<ServerAddress>> regionServers;
public ServerAddress selectServer(String playerIP) {
String region = geoIP.lookup(playerIP); // MaxMind GeoIP
List<ServerAddress> regionalServers = regionServers.get(region);
if (regionalServers == null || regionalServers.isEmpty()) {
// Fall back to global servers
regionalServers = regionServers.get("GLOBAL");
}
// Use least connections within region
return selectLeastConnected(regionalServers);
}
}Resource-Based Balancing
Select server based on real-time resource availability:
public class ResourceBalancer {
private Map<ServerAddress, ServerMetrics> metrics;
static class ServerMetrics {
double cpuUsage; // 0.0 - 1.0
double memoryUsage; // 0.0 - 1.0
int playerCount;
long lastUpdate;
}
public ServerAddress selectServer() {
return metrics.entrySet().stream()
.filter(e -> System.currentTimeMillis() - e.getValue().lastUpdate < 30000)
.filter(e -> e.getValue().cpuUsage < 0.8)
.filter(e -> e.getValue().memoryUsage < 0.9)
.min(Comparator.comparingInt(e -> e.getValue().playerCount))
.map(Map.Entry::getKey)
.orElse(fallbackServer);
}
}Requires monitoring infrastructure (e.g., Prometheus) to collect server metrics.
Security Considerations
DDoS Mitigation
- Implement connection rate limiting per IP address
- Use SYN cookies to prevent SYN flood attacks
- Deploy proxy behind DDoS protection service (Cloudflare, etc.)
- Implement IP blacklist/whitelist functionality
- Monitor for unusual traffic patterns
Authentication Security
When building proxies that handle authentication:
⚠️ Important
- Never log or store player authentication tokens
- Use TLS/SSL for all inter-server communication
- Validate tokens on proxy, don't pass raw tokens to backend
- Implement token refresh logic properly
- Use secure random for generating internal session IDs
Backend Server Trust
Secure communication between proxy and backend servers:
- Use internal network/VPN for proxy-to-backend traffic
- Implement shared secret authentication between proxy and backends
- Whitelist proxy IP addresses on backend firewalls
- Use mTLS (mutual TLS) for authentication if possible
Packet Filtering
Inspect and filter malicious packets at proxy level:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf packet = (ByteBuf) msg;
// Check packet size
if (packet.readableBytes() > MAX_PACKET_SIZE) {
logger.warn("Oversized packet from {}", ctx.channel().remoteAddress());
ReferenceCountUtil.release(msg);
ctx.close();
return;
}
// Check for malformed packets
if (!isValidPacket(packet)) {
logger.warn("Invalid packet from {}", ctx.channel().remoteAddress());
ReferenceCountUtil.release(msg);
return;
}
// Forward valid packet
backendChannel.writeAndFlush(msg);
}Monitoring & Observability
Track the health and performance of your multiserver network.
Key Metrics to Monitor
Connection Metrics
- Active connections per server
- Connection attempts per second
- Failed connection rate
- Average connection duration
Transfer Metrics
- Player transfers per minute
- Failed transfer rate
- Average transfer time
- Transfer success rate by destination
Server Health
- Backend server availability
- Server response time
- CPU and memory usage
- Network bandwidth utilization
Error Tracking
- Connection timeouts
- Authentication failures
- Packet forwarding errors
- Backend unavailable events
Prometheus Integration
Export metrics to Prometheus for visualization in Grafana. See our Recommended Plugins guide for the Prometheus exporter plugin.
// Example Prometheus metrics in proxy
private final Counter connectionsTotal = Counter.build()
.name("hytale_proxy_connections_total")
.help("Total connections")
.register();
private final Gauge activeConnections = Gauge.build()
.name("hytale_proxy_active_connections")
.help("Active connections")
.labelNames("backend")
.register();
private final Histogram transferDuration = Histogram.build()
.name("hytale_proxy_transfer_duration_seconds")
.help("Player transfer duration")
.register();Logging Best Practices
- Log levels: Use DEBUG for packet details, INFO for connections, WARN for issues, ERROR for failures
- Structured logging: Use JSON format for easy parsing and analysis
- Log aggregation: Send logs to centralized system (ELK stack, Loki, etc.)
- PII protection: Never log authentication tokens or sensitive player data
- Log rotation: Implement automatic log rotation to prevent disk fill
Health Checks
Implement health check endpoints for monitoring:
// HTTP health check endpoint
@GetMapping("/health")
public HealthResponse getHealth() {
boolean allBackendsHealthy = backendServers.stream()
.allMatch(this::isServerHealthy);
return new HealthResponse(
allBackendsHealthy ? "healthy" : "degraded",
getActiveConnections(),
getBackendStatuses()
);
}
// Liveness check (is proxy running?)
@GetMapping("/health/live")
public String liveness() {
return "ok";
}
// Readiness check (can proxy accept connections?)
@GetMapping("/health/ready")
public String readiness() {
boolean ready = hasHealthyBackends() && !isOverloaded();
if (!ready) {
throw new ServiceUnavailableException();
}
return "ok";
}Frequently Asked Questions
Can I use existing proxy software like BungeeCord or Velocity?
No. BungeeCord and Velocity are designed for Minecraft which uses TCP-based protocol. Hytale uses QUIC (UDP-based), requiring custom proxy implementations with Netty QUIC.
Do backend servers need to be modified to work with a proxy?
It depends on your architecture. For transparent proxying (just packet forwarding), no modifications needed. For advanced features like authentication offloading or shared state, you'll need to implement communication between proxy and backends.
What's the performance overhead of using a proxy?
Minimal if implemented correctly. Expect 1-5ms additional latency for packet forwarding. A well-optimized proxy on modern hardware can handle thousands of concurrent connections without bottleneck.
Can players see when they're transferred between servers?
It depends on the implementation. Using PlayerRef.referToServer() provides a seamless transition with no disconnect screen. However, there will be a brief loading screen as the new world loads.
The transition is similar to changing dimensions in vanilla Hytale.
How do I share player data across servers in a network?
Common approaches:
- Shared database (MySQL, PostgreSQL) for persistent data
- Redis for real-time data and caching
- Message queue (RabbitMQ, Kafka) for events
- Custom API endpoints on each server
What happens if a backend server crashes during player transfer?
The player will experience a connection timeout and be disconnected (or sent to fallback server if configured). Implement health checks before initiating transfers to prevent this.
Is it possible to have cross-server chat?
Yes, but it requires custom implementation. Use a message broker (Redis Pub/Sub, RabbitMQ) or direct server-to-server communication to relay chat messages between servers in your network.
Where can I find example multiserver implementations?
Check the official Hytale documentation and community forums. Large server providers (Nitrado, Apex Hosting) may also publish reference implementations or libraries for network management.
Related Guides
Need Managed Server Network?
Skip the complexity of building and managing multiserver infrastructure. Our enterprise hosting plans include managed proxy, load balancing, and 24/7 monitoring.
View Enterprise Plans →