Files
2026-05-26 00:11:45 -04:00

4.4 KiB

RPC

Request/response between servers, built on top of MessageBus. Use this when you need an answer; use MessageBus when fire-and-forget is enough.

API

public interface RpcClient {
    <Req, Resp> CompletableFuture<Resp> request(
        String service,
        String method,
        Req payload,
        Class<Resp> responseType
    );

    RpcService registerService(String service);
}

public interface RpcService {
    <Req, Resp> RpcService method(
        String method,
        Class<Req> requestType,
        Class<Resp> responseType,
        Function<Req, Resp> handler
    );
    void close();
}

Calling

record PlayerLookupReq(UUID uuid) {}
record PlayerLookupResp(String username, int kills) {}

CompletableFuture<PlayerLookupResp> future = core.getRpc().request(
    "player-stats",        // service name (matches what the server registered)
    "lookup",              // method name
    new PlayerLookupReq(uuid),
    PlayerLookupResp.class
);

future.whenComplete((resp, err) -> {
    if (err != null) {
        // err can be TimeoutException, RpcException (handler threw), or IllegalStateException (closed)
        return;
    }
    use(resp);
});

The future completes:

  • Normally with the decoded response if the handler returned successfully
  • Exceptionally with TimeoutException if no response arrives within rpc.timeout_ms (default 5000)
  • Exceptionally with RpcClientImpl.RpcException if the handler on the other side threw
  • Exceptionally with IllegalStateException("RpcClient closed") if NetworkCore is shutting down

Don't block on .get() from a Hytale thread - hop to your own executor or use .thenAccept / .whenComplete.

Serving

RpcService stats = core.getRpc().registerService("player-stats");

stats.method("lookup", PlayerLookupReq.class, PlayerLookupResp.class, req -> {
    Player p = lookupInDb(req.uuid());
    return new PlayerLookupResp(p.username(), p.kills());
});

stats.method("topKills", TopKillsReq.class, TopKillsResp.class, this::handleTopKills);

The handler is called on the MessageBus worker pool (not a Hytale world thread). Same caveat as a subscriber - hop to world thread if you touch entities.

Handlers should return reasonably fast (under the RPC timeout, with margin). If a handler is slow or might block, run the work inside the handler and don't block the worker pool indefinitely - the pool is shared with regular MessageBus subscribers.

Throwing from a handler is captured and turned into an error response on the client side. Don't rely on this for control flow.

How any-server-can-respond works

There's no service registry for RPC. The mechanism is:

  1. Client publishes RpcMessages.Request on channel rpc:req:<service> with a unique correlation id and the client's reply channel.
  2. Every server that has called registerService("<service>") is subscribed to that channel. They all receive the request.

If multiple servers register the same service name, all of them will process the request and reply. The client takes the first response that arrives and discards the rest (the future has already completed). For a real load-balanced setup, use distinct service names per instance, or have only one server register each service.

Service naming

Use lowercase kebab-case. Pick names that describe the capability, not the server instance. Examples:

  • player-stats - read player stats from MySQL
  • party-state - the canonical party data store
  • match-allocator - pick a server to host a match on

Don't:

  • player-stats-server-1 (couples the name to a specific instance)
  • lookup (too generic, will collide)

When RPC is the wrong tool

Use MessageBus instead when:

  • You don't need a reply.
  • You're broadcasting to all servers.

Use a database (MySQL) instead when:

  • Multiple readers need consistent data.
  • You need history or transactions.

Use the Hytale event bus (in-process) when:

  • The consumer is on the same server.

RPC is for: "I need server X to do something for me and tell me how it went."

Wire format

For reference. You don't construct these.

Request, published on rpc:req:<service> wrapped in the standard Envelope:

{
    "correlationId": "<uuid>",
    "replyTo": "rpc:resp:<callerServerId>",
    "method": "lookup",
    "body": { ... }
}

Response, published on the replyTo channel:

{
    "correlationId": "<same uuid>",
    "ok": true,
    "error": null,
    "body": { ... }
}