-
instances
-
assets as config
-
teams
-
phases
-
spectator/disqualification state
-
stats
-
abstract, portable gamerules
-
minigame instance entity
- phase
- config
- instanceRef
-
region/marker entity
- ingame visual editor for region and position markers
-
team entity
-
parties? (group of players that wants to join games together and be in one team)
-
global stats
-
reconnection/leaving logic
Second vertical slice implemented:
- All components registered runtime-only in
Plugin.setup()viaComponents(instances/teams/markers are transient; durable config will move to assets later) PhaseSystem(ECSTickingSystem) drivesWaiting -> Countdown -> Playing -> Ending -> cleanupper world tick and releases players when an instance is removed/minigamecommand tree (admin commands gated tohytale:Admin):create <gametype>— creates an instance with a per-boot numeric id (#1, #2, ...)start [--game=<id>] [--seconds=<secs>]— starts all Waiting instances or onestop— forced stop with the default ending grace period: bare or--game=<id>inside the local world,--asset=<asset>for one asset's instance worlds, and--minigame=<gametype>for every queue world of one minigame (all admin-only; players are sent home by the teardown)status—#<id> gameType=... phase=... players=...per instancejoin [--game=<id>]— joins a specific instance or the lowest-id joinableleaveteam create <name> [--game=<id>],team join <name>,team leave,team list [--game=<id>]— teams are per-instance entities (TeamData+TeamMembers+InMinigame-> instance); players carryTeamRef-> team; names are unique per instance, not globally; instance cleanup releases teams and membershipsregion add <type> [--game=<id>],region list [--game=<id>],region clear [--type=<type>]— in-game marker creation at your positionparty create|invite <name>|accept|leave|list— parties: groups that join together (see below)- disconnect semantics: membership is preserved until the minigame
ends — rosters are uuid-keyed and the instance keeps a preserved-
membership record (role included), so a returning player who runs
/minigame joinis restored into their exact instance, role and team. Voluntary/minigame leavestill releases everything immediately queue <minigame> [<map>]— the player-facing join: enters an open queue for that minigame (team-balanced; the first queuer spawns the map) or spawns one; the queue auto-starts a countdown at the game'sMinPlayersfloor (PhaseSystem), capped byMaxPlayers; queueing elsewhere switches queues;dequeueleaves a pre-start queue (live exits go throughleave);/minigame startremains the admin force-start that ignores floorsdefs list|reload|save [--game=<id>]— durable minigame definitions: JSON in the plugin data directory (definitions/<gameTypeId>.json), loaded at boot;defs savecaptures a live instance's teams and markers (the in-game editor write path) andcreate <gametype>then spawns new instances fully set-up from the definition- definitions also come from asset packs: the framework owns the
Minigame(teams, behaviors, maps) andMinigameMap(instance asset) asset types underServer/MinigamesandServer/MinigameMaps(seegg/ginco/minigame/asset/Assets.kt). A minigame plugin bundles its own asset pack (jar manifestIncludesAssetPack: true) and ships JSON — the framework compiles one definition per minigame (data-dir definitions shadow asset-derived ones per game type id)./minigame create <minigame> [<map>]picks the map: named, or a random playable one when omitted
A party is a group of players that joins a minigame together and lands on the same team. The framework provides the plumbing — state, lifecycle, leader-driven group join — not game rules.
- State is uuid-keyed and runtime-only (like team rosters and preserved membership), so it survives disconnects; a disconnect destroys the entity, not the uuid.
- The leader invites (
party invite <name>, target resolved in the inviter's world) and the leader drives the join:/minigame joinwith a party of more than one joins everyone into the same instance and the same team — all or nothing. Every precondition (everyone present and unattached, a team with room for the whole party) is checked before the first membership write; a full team rejects the whole party rather than splitting it, because splitting is a policy call the framework refuses to make. - The leader leaving disbands the party; joining does not consume it.
Deferred ideas (deliberately not in v1): leader transfer on leader leave, invitation expiry, cross-world invites, party joins into instance-asset worlds (explicitly rejected for now), party-wide leave of a minigame.
A definition may name an instance asset (instanceAsset), the name of a
template world authored with the server's builtin /instance edit new|save.
When it does, the minigame no longer runs in the lobby:
create <gametype>spawns (or reuses) the builtin instance world for that asset —InstancesPlugin.spawnInstance(asset, sourceWorld, transform)at the creating admin's position — and creates the controller and teams in that world's store. The template world folder is copied per spawn, so each game gets its own chunk store; everycreatetherefore yields a fresh world (instance-<asset>-<hash>).join [--asset=<name>] [--team=<name>]moves the player in withteleportPlayerToInstance, which returns a future; the player's entity is rebuilt in the instance world's store.start/stoptake--asset=<name>to target the instance living in another world, andstopinside an instance world ends that instance.- Ending an instance world (explicit
stop, phase end, or the last player leaving) tears the world down withsafeRemoveInstance; players are sent back to the world they joined from (WorldReturnPoint), not the lobby.
Without an instanceAsset the old in-world behaviour is unchanged.
Cross-world membership is restored by the instance's own roster, which is
uuid-keyed and survives the entity rebuild: the instance world runs an
InstanceEntrySystem that, on arrival, applies InMinigame/Role/TeamRef
and writes the player into their team. The lobby store is never read from
another world's thread (ECS refuses foreign-thread store access), so
everything the destination needs travels in PendingInstanceEntry.
The framework DETECTS region boundary crossings and fires
PlayerEnterRegionEvent / PlayerExitRegionEvent (every instance phase;
listeners run on the world thread). It deliberately attaches no meaning to
them — killing players in death zones, scoring goals, capture progress are
consuming-plugin logic written as event listeners:
eventRegistry.register(PlayerEnterRegionEvent::class.java) { e ->
if (e.type != "death") return@register // your own type ids
val phase = entityStore.store.getComponent(e.instance, Phase.componentType)
if (phase?.kind != Phase.Kind.PLAYING) return@register // play-only rule
entityStore.store.replaceComponent(
e.playerEntity, Role.componentType, Role.disqualified("died in a death zone"))
}Region storage is the server's builtin TriggerVolumes module (no bespoke
resource, no framework commands). Builders author markers with the vanilla
point/volume tools — a box-shaped VolumeEntry or a PointEntry tagged
minigame=<type> in the world's TriggerVolumeManager/PointManager is all
a region is — and the builtin's ticking system does the per-tick spatial
crossing detection that the framework used to hand-roll. The programmatic
path for tests/tools is Regions.addRegion(world, type, …). The framework's typed events are a thin
adapter over the builtin TriggerVolumeEvent (system/RegionSystem.kt), so
consumers can also listen to TriggerVolumeEvent directly if they prefer the
raw surface.
Marker type ids are open — region add capture-point-a, region add lasertag-base, anything. Regions.regionsOf(world, type) / Regions.regionAt (world, type, position) are the query surface for plugin code. One reserved
id exists: spawn markers are the kind the framework acts on itself (teleport
targets when an instance starts, read from spawn volumes and Points via
Regions.spawnPointsOf(world)).
Definitions are the durability layer for all of this: markers and teams
placed in-game can be captured with defs save and reload on every boot
(plain JSON in the data directory; the Hytale asset-pack backend may replace
the file I/O later — the schema is already its shape).
Role mechanics too: Roles.spectate/unspectate apply the mechanical
spectator state (Creative + flight + hidden from instance members — 0.6.8
has no spectator game mode), and Roles.disqualify swaps in
Role.disqualified(reason). The framework applies mechanics; WHEN they
apply stays consumer-side. Mechanics are entity state — re-apply after a
preserved-membership restore.
Stats are the same shape: Stats.add/addForPlayer/total/breakdown over
arbitrary consumer-chosen stat ids, scoped to an instance, per-player
counters uuid-keyed (they survive disconnects via preserved membership),
released with the instance. /minigame stats [--game=<id>] prints totals
and per-player breakdowns by name.
Lifetime stats: when an instance's game ends, its counters and per-player
counters merge additively into a per-game-type ledger
(<dataDir>/stats/<gameTypeId>.json) — read it via
LifetimeStats.load(gameTypeId). Atomic writes; merge is additive.
- Integration tests: 72
@HytaleTestmethods run inside a real server via hytest (./gradlew hytaleTest); hytest loads the real plugin from the merged test jar (SubPlugins manifest), so nothing about the framework is stubbed or re-mounted in the harness.
Verified on a live server: plugin loads and registers components/systems/ commands, phase ticking drives the full lifecycle, join/leave guards, targeted and untargeted instance commands, and command response text assertions.
- team assignment (
TeamData/TeamMembers) on join - region marker placement via the builtin TriggerVolumes/Points tools
- disconnect/leave handling beyond
/minigame leave
The framework ships as two artifacts per v* tag:
| Artifact | Made for | Where |
|---|---|---|
Thin jar (gg.ginco:hytale-minigame-framework) |
minigame plugin builds | Maven Central |
Fat plugin jar (hytale-minigame-framework-x.y.z.jar) |
server operators | GitHub Releases |
Each plugin jar gets its own PluginClassLoader — whatever a plugin
bundles is private to it. So minigame plugins must NOT bundle the
framework: two games each carrying a copy would mean two frameworks with
split static state (separate registries, instance ids, stats) and
duplicate /minigame commands. The framework must exist once per
server, installed in mods/ from its GitHub Release jar.
Cross-plugin class access works through the server's plugin bridge
(PluginBridgeClassLoader): when a class isn't in the plugin's own jar or
the server classpath, the bridge searches declared hard dependencies
first, then optional ones, then everything else (see
PluginManager.java in the shared source).
A minigame plugin therefore:
repositories {
mavenCentral()
maven("https://maven.hytale.com/release")
}
dependencies {
implementation("gg.ginco:hytale-minigame-framework:0.4.0") // compile-time
compileOnly("com.hypixel.hytale:Server:0.7.2") // server API
}
hytale {
depends("gg.ginco:MinigameFramework", ">=0.4") // runtime
bundleDependencies = false
}depends(...)makes the server refuse to load the game when the framework plugin isn't installed, and guarantees load order (the framework enables and starts first).- kotlin-stdlib and hytale-codec needed by the game resolve from the framework's loader via the bridge too — no bundling needed for any of it. (Caveat: bridge resolution checks the server classpath before the bridge, so if the server itself ever ships a different kotlin-stdlib version, plugins bind to that one first.)
hytale { subPlugin { ... } }withbundleDependencies = trueremains a legitimate layout for single-game servers (the whole stack in one jar, no operator install step) — but never mix it with a second game plugin doing the same. Verified both layouts on real 0.6.8 servers.
A real two-plugin boot (framework thin-consumer) is documented in
save-the-kweebecs' README.
Integration tests run inside a real, authenticated Hytale server (0.7.2) via the
hytest harness, consumed from Maven
Central (gg.ginco.hytest:<gradle-plugin|runtime|junit5>): settings.gradle.kts
resolves the plugin via mavenCentral(), build.gradle.kts applies
gg.ginco.hytest (plus gg.ginco.hygradle for server provisioning); tests
live in the hytaleTest source set (src/hytaleTest/kotlin).
Run them with:
./gradlew hytaleTest # boots the server, runs every @HytaleTest
./gradlew -PhytestFast hytaleTest # fast tier: exclude the instance-world suite
./gradlew -PhytestLogs hytaleTest # forward server stdout into the build log
./gradlew hytaleTest --tests 'gg.ginco.minigame.MinigameLifecycleTest.createPutsInstanceInWaiting'The first run provisions the authenticated server build into
~/.gradle/caches/hygradle/server/ (non-interactive while the OAuth token cache
in ~/.local/share/hygradle/token.json is valid).
MinigameInstanceWorldTest is the expensive tier: every instance world pays
create + join + settle + stop + teardown, and issue #1's timing analysis
measured ~30–60s per test there — the suite's wall clock. Two tiers off the
same test sources:
- Fast (
-PhytestFast): everything exceptMinigameInstanceWorldTest— definitions, behaviors, lifecycle, teams, parties, spawn, stats, regions, disconnects. CI runs this tier on every main push. - Full (no flag): the complete suite including instance worlds; the CI
nightly schedule and
suite: fulldispatches run it. The instance tier is itself batched — one lifecycle walkthrough world plus a two-world multi-create test — so the full suite keeps the world count (and with it the per-world teardown tax) at 3, not the earlier 6.
The split is a Gradle Test-task name filter (see build.gradle.kts): hytest's
custom JUnit engine ignores engine discovery selectors, but Gradle applies the
patterns as a launcher post-discovery filter, pruning the descriptor tree, so
only the fast subset is ever dispatched to the server.
Notes:
- hytest deploys a single merged
Hytest.jarwhose manifest entrypoint isHytestPlugin, so the framework's ownPluginclass is not loaded during tests;MinigameTestHarness(in thehytaleTestsource set) registers the slice — components,PhaseSystem,/minigame— exactly likePlugindoes. - Commands are executed via
SimulatedPlayer.runCommand(realCommandManagerpath, admin group granted throughHytalePermissionsProvider), and assertions read the ECS store on the world thread (t.runOnWorld); phase transitions are awaited witht.succeedWhen { }, never sleeps.