Ports & portability
The two handles you use in every game, the native escape hatch for everything else, and (later) what makes a game cross-engine.
Inside a lifecycle method you act on the game through two handles, PlayerHandle
and GameWorld. They cover the orchestration every game needs. For anything
engine-specific, there is one explicit escape hatch. That is the whole page for
most games.
Building for one engine? You can stop after the escape hatch below. The rest of this page (capabilities, value-type purity, the core/adapter split) is only relevant the day you ship the same game on a second engine. Until then, ignore it. A single-engine game is one module, full stop.
The two handles you use
PlayerHandle is a player with no engine type attached. GameWorld is
the instance’s arena. You get both off the context in every verb.
public void onStart(MatchContext ctx) {
// PlayerHandle: a player, no engine type attached.
ctx.players().forEach(p -> {
p.teleport(ctx.spawn(p)); // ctx.spawn reads a marker
p.sendMessage("Good luck!");
});
// GameWorld: arena geometry, read by marker type.
ctx.world().markers("Chest").forEach(this::fillChest);
} PlayerHandle | Does |
|---|---|
uuid() / name() | identity |
teleport(location) | move the player |
sendMessage(text) | chat line |
notify(title, subtitle) | on-screen title |
setSpectator(bool) | toggle spectator |
inventory() | give / clear / set items, by item id |
GameWorld | Does |
|---|---|
markers(type) | read arena points by marker type ("Player_Spawnpoint", "Chest", …) |
spawnProp(spec) | place a prop declaratively |
resetArena() | restore the arena between rounds |
players() | players currently in the world |
Markers are how a game stays map-agnostic: the genre reads arena.spawns from
the manifest, and your code reads markers("Chest").
Neither hard-codes a coordinate, so the same game runs on any map an artist
builds.
The context also hands you a Scheduler (delayed and repeating tasks, already
thread-correct), a Hud (the scoreboard), a StatsSink
(player data) and, in the party genre, an Economy (coins).
You rarely reach for these directly; the genre drives them.
The native escape hatch
The handles are deliberately narrow, they cover orchestration. Native engine
APIs are wide: particles, sounds, custom cameras, bespoke mechanics live in
that long tail. No portable surface should try to cover it. When you need it,
reach the native API explicitly with .as(...) or .native(...):
// Portable path: works on any engine, no native types.
ctx.eliminate(p);
// Native flourish, Hytale only. The .as(...) is the exact line
// where portability stops, and grep finds every one instantly.
PlayerRef ref = p.as(PlayerRef.class);
World world = ctx.world().native(World.class);
world.execute(() -> // hop to the world thread
SoundUtil.playSoundEvent2dToPlayer(ref, Sounds.ELIMINATED, SoundCategory.SFX)); This is real Hytale: world.execute(...), PlayerRef, SoundUtil are exactly
what a hand-written Hytale game calls. The escape hatch is a feature, not a leak.
That .as(PlayerRef.class) line is where your game stopped being portable, and
it is greppable, so the cost is always visible rather than hidden.
A Hytale-only game uses the hatch freely. If a game is mostly native calls, that is the honest signal it is a mechanics-heavy game (think custom collision or physics) better written straight against the engine than dressed up as portable.
Mechanics live in your own engine code. A round that needs, say, fall detection writes a normal Hytale ECS system for it (Byte Crashers ships a
MinigameFallDetectionSystemthat disqualifies a player when theirydrops below a threshold). The SDK does not try to abstract that. It gives you the game shape and the player/world handles; the engine mechanics stay yours.
Advanced: going cross-engine
Everything below earns its keep only when you ship one game on two engines (Hytale and Minecraft). A single-engine game never needs it.
Value types stay pure
So the same game logic compiles against either adapter, the handles never pass an engine type:
Inventoryworks on abstract item ids (give(itemId, count)), never a nativeItemStack. The adapter resolves an id to a real item at grant time.PropSpecis a declarative “what to place” (template id + location + rotation), not an engine prefab handle.Location/Vec3are plain records, no JOML, no BukkitLocation.
Capabilities
Some engine features don’t exist everywhere. Rather than branch on the engine, a game declares the capability it needs, and the SDK offers it only where the adapter satisfies it (or degrades gracefully):
| Capability | Example |
|---|---|
CameraCapability | cinematic server cameras (Hytale) |
NpcCapability | spawn/steer NPCs (Hytale) |
ConcurrentGames | many instances in one process, in parallel |
ConcurrentGames is why “how many games fit on one server” isn’t your problem:
Hytale (per-world threads) and Folia (regions) run many instances in one process;
single-thread servers don’t, and the network scales those out as more servers
instead. Your game code never asks.
One engine, one module
Portability is opt-in, something the handles buy you, not a tax paid up front. For one engine you write one module against the adapter (plus the escape hatch) and never touch a core/adapter split. You extract a shared, engine-agnostic core from working code the day a second engine becomes real, never before. A solo dev shipping one game should never juggle three modules.
Next
- Build your first game puts the two handles to work.
- Match games and Party games show them in each genre’s verbs.