Antares is a game server scaffold built on top of Asteria. It keeps a non-trivial demo domain in place and focuses on the parts that usually become painful in real projects: clustered routing, generated message dispatch, runtime patching policy, configuration publication, config reload, and historical-data compatibility.
Main modules:
gate: client access, gateway routing, topic subscription, and channel-facing handlersplayer: player shard, player actor logic, and player-side config-change repairworld: world shard, wakeup flow, cross-world topics, and broadcast examplesglobal: shared cluster services and singleton-style runtime piecesgm: admin backend and script entrypointsclient-proto: client-facing protobuf contracts and generated client protocol supportserver-proto: internal protobuf RPC contracts and generated server protocol supportcommon: shared runtime, config, routing, patching, and persistence abstractionstools: local topology/config bootstrap helpersstardust: local all-in-one development launcherbattle: Rust stateful battle service prototype with direct client data-plane access
- JDK 21
- MongoDB
- Zookeeper
- Gradle 9
- Start MongoDB and Zookeeper.
- Run
./gradlew :stardust:prepareLocalDevonce when local Zookeeper is empty or stale. - Run
./gradlew :stardust:run. - Optionally start the protocol debug client in
client/.
stardust:run only starts the all-in-one local development cluster. It does not publish config automatically.
If you prefer to launch Stardust from the IDE, run this manually when local runtime config needs refreshing:
./gradlew :stardust:prepareLocalDevThe bootstrap tool writes a default topology into Zookeeper:
player-2333player-2334world-2335global-2336gate-2337gm-2338
It also publishes:
- MongoDB datasource config
- gate netty config on port
6666 - demo world definitions
- demo game config publication
Useful local tasks:
# Prepare local Zookeeper topology/runtime config and game config for Stardust
./gradlew :stardust:prepareLocalDev
# Re-publish changed Excel data to local Zookeeper without rewriting topology
./gradlew :tools:publishLocalGameConfig
# Re-publish with an explicit config revision override
./gradlew :tools:publishLocalGameConfig -PgameConfigVersion=4.3.0
# Reinitialize local topology/runtime config after clearing Zookeeper
./gradlew :tools:initializeLocalRuntimeConfigIf you changed Luban schema or table structure, refresh generated sources first:
./gradlew :config:refreshLubanConfig
./gradlew :tools:publishLocalGameConfigProject version is centralized in gradle.properties as projectVersion. Code package metadata, default game config
publication revision, and Docker image tags all read that value unless explicitly overridden.
Each node can also be launched directly:
gate/src/main/kotlin/com/mikai233/gate/GateNode.ktplayer/src/main/kotlin/com/mikai233/player/PlayerNode.ktworld/src/main/kotlin/com/mikai233/world/WorldNode.ktglobal/src/main/kotlin/com/mikai233/global/GlobalNode.ktgm/src/main/kotlin/com/mikai233/gm/GmNode.kt
- Asteria-based clustered node startup with explicit role and shard registration
- player/world shard routing on top of Pekko cluster sharding
- singleton and shared-service wiring through
common - coordinated shutdown and per-node script support
Message dispatch uses generated registration artifacts:
- Asteria
@AsteriaMessageHandlerfor protobuf/internal message handlers - generated
Generated*NodeDispatchers - generated patchable dispatcher registries such as
PROTOBUF_REGISTRYandINTERNAL_REGISTRY
This keeps node bootstrap code small and makes handler registration explicit, typed, and reviewable without runtime reflection.
Gateway routing is generated from handler annotations plus project-side aggregation:
- handlers use
@AsteriaGatewayRoute - route metadata is generated during build
gateaggregates metadata intoGeneratedGatewayRouting
The generated route layer covers regular static cases. GM commands share one client protobuf message, so Gate resolves the command string to the target actor type before forwarding.
The battle workspace is a Rust prototype for stateful real-time combat. The JVM cluster remains the control plane:
- client sends
BattleStartReqthrough Gate to Player - Player selects a battle endpoint, creates a short-lived token, and returns
BattleStartResp - client connects directly to the battle server for frame traffic
- battle instances register themselves under
/antares/battle/instances - Player nodes watch that path through
BattleDiscoveryModule
This keeps the latency-sensitive battle data plane out of Gate. Static game.battle.endpoints is only a local fallback
for development or environments without discovered battle instances.
Patchable services live in PatchableServiceRegistry, and production hotfixes are installed through the GM patch flow
so patch revision, target selection, status, audit, and rollback stay in one model.
Runtime patch plugins use Asteria's RuntimePatchPlugin and RuntimePatchInstallContext. Each node registers a
module-local GamePatchBindings service that exposes the registries that can be replaced on that node. A player-side
handler patch looks like this:
class FixLoginPatch : RuntimePatchPlugin {
override suspend fun install(context: RuntimePatchInstallContext) {
val bindings = context.runtime.services.get<GamePatchBindings>()
context.messageHandlers.replace(
bindings.playerMessageRegistry,
LoginReq::class,
PatchedLoginHandler(),
)
}
}Service patches use the same patch lifecycle and order:
context.services.replace(
bindings.services,
LoginService::class,
PatchedLoginService(),
)Patch implementations live in src/patch/kotlin and are packaged separately from the node runtime JARs. Use the
module-level patchJars task to build patch artifacts; each generated JAR declares its entrypoint through the
Patch-Class manifest attribute. For example:
./gradlew :player:patchJarsGM publishes patch descriptors and artifacts into the shared config center, and nodes load executable plugins from the same store. The GM HTTP entrypoints are:
curl -X POST http://127.0.0.1:18080/gm/api/patches/publish-and-apply \
-F 'request={
"id":"fix-login",
"roles":["Player"],
"requiredRoles":["Player"]
};type=application/json' \
-F 'file=@player/build/libs/antares_player_LoginServiceHotfixPatch.jar'
curl -X POST http://127.0.0.1:18080/gm/api/patches/fix-login/disableUse roles to select target nodes and requiredRoles, requiredModules, or requiredCapabilities to declare runtime
requirements for the patch artifact.
Internal protobuf RPC shard routing uses generated protobuf entity-id metadata.
The runtime enables both:
- node scripts
- actor scripts
through Asteria's script module. Scripts are kept for diagnostics, operational commands, and short-lived development tasks; long-lived code replacement should use the GM patch path.
Game configuration is loaded through the project's configured publication/fetch path, not from source-tree artifacts. Runtime nodes deserialize config snapshots, build derived query components, and run validation in the same load path.
That path goes through Asteria's ConfigModule, which does three things:
- load raw tables
- build derived snapshot-level query components
- run validators
This matters because it means:
- local validation and runtime loading use the same rules
- a bundle that passes local checks can still fail at runtime if the server code changed, and that failure happens during real snapshot loading rather than much later
Global config query components are built after tables are deserialized and organized by table or domain area. Query components include:
ItemConfigQueries.itemsByTypeMonsterConfigQueries.monstersBySceneIdActivityConfigQueries.activitiesByUnlockLevelDropPoolConfigQueries.dropEntriesByItemId
Query builders live under config/src/main/kotlin/com/mikai233/config/luban/query and are auto-collected through
@AsteriaContribution.
ConfigChangeHandler is not used to build global config caches. Its job is to repair actor-local memory after a config
publication changes.
The flow is:
- config publication changes
GameConfigModuleloads and validates the newConfigSnapshot- derived query components are rebuilt for the snapshot
- a
GameConfigChangedEventis published on the local ActorSystem event stream - player/world actors receive the event through normal internal message handlers
ConfigChangeDispatcher.dispatchIfNewcompares the actor'sActorConfigSyncMemrevision- matching
ConfigChangeHandlertasks are submitted throughConfigChangeExecutor - the executor posts each handler to
actor.execute(...), so repair runs as normal actor mailbox work - handler failures are reported through the dispatcher's failure handler
Actors also catch up when they become active. Player login and world activation read the current ConfigSnapshot from
ConfigService and call dispatchIfNew(actor, snapshot, sync), so handlers remain small, idempotent
handle(actor, snapshot) implementations.
Example: PlayerActivityConfigChangeHandler watches TbActivity, reads the player level from actor memory, and
reconciles PlayerActivityMem from the active table rows.
Player/world config-change handlers are also auto-collected during build through Asteria's
@AsteriaConfigChangeHandler annotation.
Validation is organized by business area. Validators live under:
config/src/main/kotlin/com/mikai233/config/luban/validation
Add validators by table or domain area. Validators are auto-collected through Asteria contribution generation and
passed to ConfigModule as GameConfigValidators.defaultValidators.
Business code should read time through actor-level GameTime, not direct system time. GameTime implements Kotlin
Clock, carries the configured time zone, and exposes helpers such as nowLocal() and today().
The effective time offset is:
- global offset from config center
- plus optional actor-local offset
When the global offset changes, GameTimeReloadModule applies it and runs the node's StartupLikeReloadPlan. Player
and World nodes stop local active actors so they restart through the same path as node startup; sharded actors are not
eagerly pulled up beyond the node's normal wakeup behavior.
Excel is the source of truth for demo game config. The default Excel directory is configured by lubanDataDir in the
root gradle.properties.
- Excel source:
config/luban/Datas - generated Java table/model code:
config/src/generated/luban/java - generated Kotlin Luban metadata/bridge:
config/src/generated/luban/kotlin - generated table accessors:
config/build/generated/ksp/main/kotlin - generated raw
.bytes:config/build/generated/luban/resources/luban - packaged publication artifact:
config/build/generated/luban/bundles/game-config.zip
The generated Java/Kotlin source is checked into the repo. Raw .bytes files are local build outputs and are not part
of the runtime classpath. Runtime nodes consume a single game-config.zip publication artifact from the config center
and unpack it in memory before loading Luban tables. Each bundle includes
META-INF/antares/game-config.properties, which defines the config publication revision.
Common commands:
# Export Luban Java code and raw .bytes
./gradlew :config:exportLubanConfig
# Export Luban Java/.bytes and refresh the generated Kotlin metadata/bridge
./gradlew :config:refreshLubanConfig
# Validate tables and custom business rules
./gradlew :config:validateLubanConfigTables
# Validate that derived game config query components can be constructed
./gradlew :config:validateLubanConfigQueries
# Package a server-consumable config bundle
./gradlew :config:packageLubanConfigBundleCommon configuration:
lubanDataDir=config/luban/Datas
# lubanToolRoot=/path/to/luban/tool/rootUse Gradle properties or environment variables to override local paths: -PlubanDataDir=/path/to/Datas,
LUBAN_DATA_DIR=/path/to/Datas, -PlubanToolRoot=/path/to/luban/tool/root, or
LUBAN_TOOL_ROOT=/path/to/luban/tool/root.
For more detail, see config/luban/README.md.
Internal protobuf RPC messages live under:
server-proto/src/main/proto/rpc
Client-facing protobuf messages live under:
client-proto/src/main/proto/client
The project keeps centralized internal RPC id allocation in:
server-proto/protocol/rpc-protocol.json
That registry is generated from the proto descriptor set and then consumed by Asteria-side protocol generation. Entity-id extraction for shard-routed internal RPC messages comes from protobuf metadata.
The scaffold keeps Mongo-backed entity and memdata flow, including compatibility-oriented patterns for loading historical documents while preserving strict business constructors.
The protocol debug client lives in client/. Update client/lua/proto.lua if your local protocol path differs, then
run the client to send protobuf messages to the gate node.