Both servers are plain Spring Boot applications (ZoneServerApplication.kt / LoginServerApplication.kt, each a @SpringBootApplication with a fun main). There is no custom application container, no Akka, no actor cluster — Spring’s dependency injection wires together plain Kotlin classes, most of them @Component/@Service beans.

Module layout

Gradle multi-module build, declared in settings.gradle:

  bestia-behemoth/
  zone-server/     the game server (this page + most of the /docs/server/* pages)
  login-server/    stateless REST auth — see Authentication
  bnet-messages/   protobuf message contracts, generates both Kotlin and C# classes
  shared/          Role/Authority + EIP-712 DTOs, shared by both servers (not a shared DB)
  worldgen/        standalone world-generation pipeline, invoked by zone-server at boot
  cli-client/      headless Kotlin dev client
  bestia-client/   the Godot client (separate build, not a Gradle subproject)
  

Each server has its own application.yml and its own MariaDB database — see Database below.

Boot sequence

zone-server starts up as an ordered chain of Spring CommandLineRunner/ApplicationRunner beans, each carrying a @Order that fixes its position in the chain. Most live under zone-server/src/main/kotlin/net/bestia/zone/boot/, but a few (like the item script validator below) live next to the domain they validate and simply share the same @Order numbering scheme:

OrderRunnerPurpose
1WorldGenerationBootRunnerGenerate or load the world. First and slowest step — everything else stands on it (entities load at positions in it, mobs spawn onto its terrain), so it fails fast rather than after importing everything else.
100ItemImporterBootRunnerImport item definitions
101MobImporterBootRunnerImport mob definitions
102SkillImporterBootRunnerImport skill definitions
103MasterSkillTreeImporterBootRunnerImport the player skill tree
104StatusEffectImporterBootRunnerImport status effect definitions
110EntityLoaderBootRunnerReload persisted entities into the ECS World
150EquipmentScriptBinderBootRunnerBind equipment scripts to their items
200ContentValidationBootRunnerRun every CatalogValidator (skills, status effects, dialogs, item scripts, occupations, economy, mint chain) — fails boot on a mismatch, before any login or tick
LOWEST_PRECEDENCE - 2ZoneReadyBootRunnerFlip ZoneReadinessService to ready, so logins are accepted only once everything above has finished
LOWEST_PRECEDENCE - 1WorldBootRunnerStart ZoneEngine — the ECS tick loop begins running
LOWEST_PRECEDENCESocketServerBootRunnerBind the Netty socket and start accepting connections
LOWEST_PRECEDENCEDevDataBootstrapRunnerDev-only seed data (@Profile("!test"))

The ordering is deliberate: the socket only opens after the tick loop is running, and ZoneReadyBootRunner gates client logins so nobody connects into a half-loaded world (see ClientMessageHandler.handleAuthenticationSuccess, which checks zoneReadinessService.isReady() before accepting an otherwise-valid login).

login-server has no comparable boot chain — it’s a stateless REST controller layer over a JPA repository, ready as soon as Spring context startup finishes. Its one boot-time actor is DevAccountSeeder, an ApplicationRunner that seeds two dev accounts (admin, user) on every start, needed because the in-memory database resets every restart.

The pieces, at a glance

  • Networking — the Netty pipeline and the Envelope protobuf that every message, in both directions, travels as.
  • Authentication — how login-server issues a JWT and how zone-server independently re-validates it; the two servers never call each other directly.
  • ECS — ZoneEngine drives a single-threaded tick loop over a World of entities/components/systems, then flushes whatever changed to clients over the socket.
  • Everything else (AI, battle, world generation, …) is a set of ECS systems and supporting services layered on top of these three.

Database

Both servers use MariaDB via Spring Data JPA/Hibernate, each with its own database and its own compose.yaml:

ServerDatabaseSchema
login-serverbestia_login (port 3307)Flyway migrations, ddl-auto: validate
zone-serverbestia_zone (port 3306)no migrations, ddl-auto: update

Each server defines its own Account JPA entity independently; they are linked only by the convention that zone-server carries a loginAccountId: Long, never a shared entity class or shared table. Tests run on in-memory H2.

How the zone writes

Nothing on the tick thread talks to the database. A system or handler takes a snapshot of an entity (plain values, no live components) and hands it to EntityWriteBehind, which writes it on the DB executor (AsyncJobExecutor). Every write about one owner uses the same key: a master’s id, or one shared key for the generic entity rows. So the writes for one owner land in the order they were taken, and a delete never overtakes an earlier write.

  • A master is written on every exp gain, on logout (the PersistAndRemove component), and by the periodic save. Selecting a master first waits for its pending writes, so a quick relog reads what the logout wrote.
  • The periodic save (EntityPersistenceSystem) runs on the tick. It saves each Persistent entity once per persistence.interval-ms (90 s by default) and spreads them over that time, a slice every second. An entity whose snapshot equals the last one queued is skipped. A failed write is retried by the next save. A blob row is updated in place, and Hibernate batches the updates.
  • On shutdown, PersistOnShutdown stops the tick, saves what changed, flushes the economy ledger and gives the DB executor up to 30 s to finish.
  • Wild den packs are not saved: a den makes a fresh pack when a player comes near.
  • A drop removes the item in the database first; only then does it leave the live inventory and appear on the ground. A drop can lose an item but never copy one.
  • Static content (items, species, loot tables, commodities) is read into in-memory catalogues once at boot (CatalogueWarmUpBootRunner), so the tick never needs a lookup.
  • TickSqlGuard, a Hibernate statement inspector, reports any SQL that still runs on the tick thread (zone.sql-on-tick: log). It sees lazy loads behind a service call too.

Last updated 05 Oct 2026, 23:42 +0200 . history