UnifiedData v2
A unified data abstraction layer for Hytale mods
Quick Start
Get up and running with UnifiedData in minutes.
1. Add Dependency
Add UnifiedData as a dependency in your mod's manifest.json:
{
"Dependencies": {
"UnifiedData": "*"
}
}
2. Import the API
Import the Data class in your Java files:
import com.unifieddata.api.Data;
That's it! No initialization required - UnifiedData handles everything automatically when the server starts.
3. Basic Usage
// Reading player gold
Data.from("PLAYER").entity(playerUuid.toString())
.get("currencies.gold")
.thenAccept(value -> {
int gold = value.asInt(0);
player.sendMessage(Message.raw("You have " + gold + " gold"));
});
// Setting player gold
Data.from("PLAYER").entity(playerUuid.toString())
.set("currencies.gold", 500);
// Add 100 gold atomically
Data.from("PLAYER").entity(playerUuid.toString())
.add("currencies.gold", 100);
4. Complete Example
Here's a complete mod example showing how to use UnifiedData:
package com.example.mymod;
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent;
import com.unifieddata.api.Data;
public final class ExampleMod extends JavaPlugin {
public ExampleMod(JavaPluginInit init) {
super(init);
}
@Override
protected void setup() {
// Nothing needed here for UnifiedData
}
@Override
protected void start() {
// Optional: Register a schema if you plan to use queries
Data.schema("PLAYER")
.index("level")
.index("currencies.gold")
.register();
// Register event listeners
getEventRegistry().registerGlobal(PlayerReadyEvent.class, this::onPlayerReady);
}
private void onPlayerReady(PlayerReadyEvent event) {
String uuid = event.getPlayer().getUuid().toString();
// Check if player exists, if not initialize their data
Data.from("PLAYER").entity(uuid).exists("level")
.thenAccept(exists -> {
if (!exists) {
// New player - initialize default data
Data.from("PLAYER").entity(uuid).setAll(java.util.Map.of(
"level", 1,
"currencies.gold", 100,
"currencies.gems", 0
));
} else {
// Returning player - increment login count
Data.from("PLAYER").entity(uuid).increment("stats.loginCount");
}
});
}
@Override
protected void shutdown() {
// Nothing needed - UnifiedData handles cleanup
}
}
Core Concepts
Data Model
UnifiedData uses a simple entity-based data model similar to DynamoDB:
Entity Type
Like a database table (PLAYER, GUILD, CONFIG, etc.)
Entity ID
Primary key - typically a UUID or unique name
Path
Dot-notation path to any field at any depth
Freeform JSON
Each entity is a freeform JSON document. You can store any structure at any nesting depth:
{
"nickname": "Steve",
"level": 42,
"currencies": {
"gold": 1500,
"gems": 25
},
"stats": {
"pvp": {
"kills": 150,
"deaths": 45
}
},
"guilds": ["rebels", "traders"],
"inventory": {
"weapons": {
"sword": {
"damage": 50,
"durability": 100
}
}
}
}
Path Notation
Access any field using dot notation. Arrays use bracket notation:
| Path | Value |
|---|---|
nickname | "Steve" |
level | 42 |
currencies.gold | 1500 |
stats.pvp.kills | 150 |
guilds[0] | "rebels" |
inventory.weapons.sword.damage | 50 |
Storage Structure
With the JSON file backend, each entity is stored as a single JSON file:
mods/unifieddata/data/
├── PLAYER/
│ ├── 550e8400-e29b-41d4-a716-446655440000.json
│ ├── 7c9e6679-7425-40de-944b-e07fc1f90ae7.json
│ └── ...
└── GUILD/
├── rebels.json
└── traders.json
Reading Data
Basic Get
Data.from("PLAYER").entity(uuid).get("currencies.gold")
.thenAccept(value -> {
Integer gold = value.asInt(); // Returns null if missing/invalid
int goldSafe = value.asInt(0); // Returns 0 if missing/invalid
// Check for missing data
if (gold == null) {
// Field doesn't exist or isn't a number
}
});
Data.from("PLAYER").entity(uuid).get("currencies")
.thenAccept(value -> {
JsonObject currencies = value.asObject();
// { "gold": 1500, "gems": 25 }
});
Typed Getters
// String
Data.from("PLAYER").entity(uuid).getString("nickname")
.thenAccept(name -> { /* ... */ });
Data.from("PLAYER").entity(uuid).getString("nickname", "Unknown")
.thenAccept(name -> { /* default if null */ });
// Integer
Data.from("PLAYER").entity(uuid).getInt("level")
.thenAccept(level -> { /* ... */ });
// Long
Data.from("PLAYER").entity(uuid).getLong("stats.totalXp")
.thenAccept(xp -> { /* ... */ });
// Double
Data.from("PLAYER").entity(uuid).getDouble("stats.kdr")
.thenAccept(kdr -> { /* ... */ });
// Boolean
Data.from("PLAYER").entity(uuid).getBool("settings.notifications")
.thenAccept(enabled -> { /* ... */ });
Multiple Values
Data.from("PLAYER").entity(uuid)
.get("currencies.gold", "currencies.gems", "level")
.thenAccept(values -> {
int gold = values.get("currencies.gold").asInt();
int gems = values.get("currencies.gems").asInt();
int level = values.get("level").asInt();
});
Check Existence
Data.from("PLAYER").entity(uuid).exists("nickname")
.thenAccept(exists -> {
if (exists) {
// Field exists and is not null
}
});
Writing Data
Basic Set
// Set a flat field
Data.from("PLAYER").entity(uuid).set("nickname", "Steve");
// Set a nested field (creates intermediate objects automatically)
Data.from("PLAYER").entity(uuid).set("currencies.gold", 500);
// Deep nesting works too
Data.from("PLAYER").entity(uuid).set("inventory.weapons.sword.damage", 50);
// Set an entire object
JsonObject stats = new JsonObject();
stats.addProperty("kills", 0);
stats.addProperty("deaths", 0);
Data.from("PLAYER").entity(uuid).set("stats.pvp", stats);
Set Multiple Values
Data.from("PLAYER").entity(uuid).setAll(Map.of(
"currencies.gold", 1000,
"currencies.gems", 50,
"level", 10
));
Delete Data
// Delete a specific field
Data.from("PLAYER").entity(uuid).delete("tempBonus");
// Delete a nested field
Data.from("PLAYER").entity(uuid).delete("currencies.tempGold");
// Delete entire entity (all data for this player)
Data.from("PLAYER").entity(uuid).deleteEntity();
Atomic Operations
All atomic operations are read-modify-write operations that handle concurrent access safely.
Numeric Operations
// Add to a value (can be negative)
Data.from("PLAYER").entity(uuid).add("currencies.gold", 100)
.thenAccept(newValue -> {
// newValue is the result after adding
});
// Increment by 1
Data.from("PLAYER").entity(uuid).increment("stats.loginCount");
// Decrement by 1
Data.from("PLAYER").entity(uuid).decrement("inventory.potions");
Boolean Toggle
Data.from("PLAYER").entity(uuid).toggle("settings.darkMode")
.thenAccept(newValue -> {
// newValue is the new boolean state
});
Array Operations
// Push to array (creates array if doesn't exist)
Data.from("PLAYER").entity(uuid).push("guilds", "rebels");
// Pull from array (removes first matching element)
Data.from("PLAYER").entity(uuid).pull("guilds", "rebels")
.thenAccept(removed -> {
// true if element was found and removed
});
Custom Update
// Apply custom transformation
Data.from("PLAYER").entity(uuid).update("level", current -> {
int level = current.asInt(1);
return Math.min(level + 1, 100); // Cap at 100
});
Queries
Query across multiple entities using indexed fields.
Basic Query
Data.query("PLAYER")
.where("level").greaterThan(40)
.execute()
.thenAccept(entities -> {
for (Entity entity : entities) {
String id = entity.id();
int level = entity.get("level").asInt();
}
});
Compound Conditions
// AND conditions
Data.query("PLAYER")
.where("level").greaterThan(40)
.and("currencies.gold").greaterThan(1000)
.execute();
// OR conditions
Data.query("PLAYER")
.where("rank").equalTo("admin")
.or("rank").equalTo("moderator")
.execute();
Available Conditions
| Condition | Description |
|---|---|
.equalTo(value) | Exact match |
.notEqualTo(value) | Not equal |
.greaterThan(number) | Greater than |
.greaterThanOrEqual(number) | Greater than or equal |
.lessThan(number) | Less than |
.lessThanOrEqual(number) | Less than or equal |
.contains(value) | Array contains value |
.exists() | Field exists and not null |
.notExists() | Field is null or missing |
Ordering and Limiting
Data.query("PLAYER")
.where("level").greaterThan(0)
.orderBy("level", false) // false = descending
.limit(10)
.execute();
Observables & Bindings
React to data changes and keep local values in sync automatically.
Data Bindings
Bindings keep a local variable synchronized with stored data. Reads are instant (no I/O) because the value is cached locally and updated automatically when the underlying data changes.
// Create binding once (async to load initial value)
DataBinding<Integer> gold = Data.from("PLAYER").entity(uuid)
.bind("currencies.gold", Integer.class, 0)
.join(); // Wait for initial load
// Read anywhere - instant, no I/O, just local memory
int currentGold = gold.get(); // Returns cached value or default (0)
// Check if value exists
Integer maybeGold = gold.getValue(); // Returns null if not set
if (maybeGold != null) {
// Handle existing value
}
// Integer
DataBinding<Integer> level = Data.from("PLAYER").entity(uuid)
.bind("level", Integer.class, 1).join();
// Long
DataBinding<Long> xp = Data.from("PLAYER").entity(uuid)
.bind("stats.totalXp", Long.class, 0L).join();
// Double
DataBinding<Double> kdr = Data.from("PLAYER").entity(uuid)
.bind("stats.kdr", Double.class, 0.0).join();
// Boolean
DataBinding<Boolean> premium = Data.from("PLAYER").entity(uuid)
.bind("premium", Boolean.class, false).join();
// String
DataBinding<String> nickname = Data.from("PLAYER").entity(uuid)
.bind("nickname", String.class, "Player").join();
// Custom extractor for complex types
DataBinding<MyData> custom = Data.from("PLAYER").entity(uuid)
.bind("custom.data", MyData.class, new MyData(),
value -> parseMyData(value))
.join();
Observers
Observers are callbacks that execute whenever data at a specific path changes. Perfect for reactive logic like level-up checks, achievement tracking, or UI updates.
// Full observer with entity ID and path
ObserverRegistration reg = Data.from("PLAYER").entity(uuid)
.observe("stats.xp", (entityId, path, newValue) -> {
int xp = newValue.asInt(0);
if (xp >= requiredXpForNextLevel) {
levelUp(entityId);
}
});
// Simplified observer - just the value
Data.from("PLAYER").entity(uuid).observe("currencies.gold", value -> {
int gold = value.asInt(0);
updateGoldDisplay(gold);
});
// Observe all changes on an entity (wildcard)
Data.from("PLAYER").entity(uuid).observe("*", (entityId, path, value) -> {
log("Player data changed at " + path);
});
// Keep the registration handle
ObserverRegistration reg = Data.from("PLAYER").entity(uuid)
.observe("stats.xp", (id, path, value) -> { ... });
// Later, when you want to stop observing:
reg.unregister();
// Check if still active
if (reg.isActive()) {
// Still receiving notifications
}
Use Cases
Level-Up Detection
Observe XP and trigger level-up when threshold is reached. No polling required.
Achievement Tracking
Multiple observers on the same data - kills, gold, levels all triggering different achievements.
UI Synchronization
Bind values to keep HUD elements up-to-date with instant reads.
Decoupled Logic
Code that grants XP doesn't need to know about leveling - the observer handles it.
How It Works
Unbinding
DataBinding<Integer> gold = Data.from("PLAYER").entity(uuid)
.bind("currencies.gold", Integer.class, 0).join();
// Later, when the player disconnects or you're done:
Data.from("PLAYER").entity(uuid).unbind(gold);
DataBinding Methods
| Method | Returns | Description |
|---|---|---|
get() | T | Current value, or default if null |
getValue() | T (nullable) | Current value, or null if not set |
isPresent() | boolean | True if value is not null |
isInitialized() | boolean | True if initial load completed |
getDefault() | T | The configured default value |
Transactions
Transactions allow atomic operations with preconditions.
Single-Entity Transaction
// Purchase: only succeed if player has enough gold
Data.from("PLAYER").entity(uuid)
.transaction()
.require("currencies.gold").greaterThanOrEqual(100)
.then()
.add("currencies.gold", -100)
.add("inventory.potions", 1)
.commit()
.thenAccept(result -> {
if (result.isSuccess()) {
player.sendMessage(Message.raw("Purchase successful!"));
} else {
player.sendMessage(Message.raw("Not enough gold!"));
}
});
Precondition Types
| Precondition | Description |
|---|---|
.equalTo(value) | Field equals value |
.notEqualTo(value) | Field not equal |
.greaterThan(number) | Field greater than |
.greaterThanOrEqual(number) | Field greater than or equal |
.lessThan(number) | Field less than |
.lessThanOrEqual(number) | Field less than or equal |
.exists() | Field must exist |
.notExists() | Field must not exist |
Transaction Operations
| Operation | Description |
|---|---|
.set("path", value) | Set a value |
.add("path", delta) | Add to numeric value |
.increment("path") | Increment by 1 |
.decrement("path") | Decrement by 1 |
.push("path", value) | Push to array |
.toggle("path") | Toggle boolean |
.update("path", fn) | Custom update function |
Schema & Indexing
Define schemas with indexed fields for fast queries.
Registering a Schema
Data.schema("PLAYER")
.index("level") // Index flat field
.index("rank")
.index("currencies.gold") // Index nested field
.indexArray("guilds") // Index array elements
.register();
Why Index?
Without Index
Query scans all entities
SlowWith Index
Direct lookup by value
Fastcontains() queries.
Storage Modes
Control caching behavior per-operation.
// Default: SMART - reads from cache, writes to both
Data.from("PLAYER").entity(uuid).get("currencies.gold");
// Local smart - uses local RAM cache only (bypasses network cache)
// Best for time-critical operations like combat/movement
Data.from("PLAYER").entity(uuid)
.mode(StorageMode.LOCAL_SMART)
.get("stats.health");
// Local cache only - volatile RAM storage, no persistence
// Best for cooldowns, temp state, tick data
Data.from("PLAYER").entity(uuid)
.mode(StorageMode.LOCAL_CACHE_ONLY)
.set("cooldowns.dash", System.currentTimeMillis() + 5000);
// Force read from storage, bypass cache
Data.from("PLAYER").entity(uuid)
.mode(StorageMode.DIRECT)
.get("currencies.gold");
// Primary cache only (may be network-based)
Data.from("PLAYER").entity(uuid)
.mode(StorageMode.CACHE_ONLY)
.set("session.tempData", data);
// Write to cache immediately, storage async (faster but risky)
Data.from("PLAYER").entity(uuid)
.mode(StorageMode.WRITE_BEHIND)
.set("stats.lastSeen", System.currentTimeMillis());
Mode Summary
| Mode | Read | Write | Use Case |
|---|---|---|---|
SMART |
Cache first, then storage | Both caches + storage | Default, balanced |
LOCAL_SMART |
Local RAM cache first | Local cache + storage | Time-critical, low latency |
LOCAL_CACHE_ONLY |
Local RAM cache only | Local RAM cache only | Volatile data, cooldowns |
DIRECT |
Storage only | Both caches + storage | Fresh data needed |
CACHE_ONLY |
Primary cache only | Primary cache only | Temporary/session data |
WRITE_BEHIND |
Cache first | Both caches + async storage | High-frequency writes |
Configuration
Configuration file: mods/unifieddata/config.json
{
"storageBackend": "json",
"dataDirectory": "data",
"cacheTtlSeconds": 300,
"cacheMaxEntries": 10000,
"cacheEnabled": true,
"ioThreadPoolSize": 4,
"asyncWrites": true,
"debugLogging": false
}
Configuration Options
| Setting | Default | Description |
|---|---|---|
storageBackend |
"json" |
Storage backend: "json" or "dynamodb" (future) |
dataDirectory |
"data" |
Directory for JSON files (relative to mod folder) |
cacheTtlSeconds |
300 |
Cache entry time-to-live in seconds |
cacheMaxEntries |
10000 |
Maximum cache entries |
cacheEnabled |
true |
Enable/disable caching |
ioThreadPoolSize |
4 |
Thread pool size for IO operations |
asyncWrites |
true |
Enable async writes |
debugLogging |
false |
Enable debug logging |
Admin Commands
Commands for testing and debugging (requires OP permission).
Get Value
/udata get <entity_type> <entity_id> <path>
Example: /udata get PLAYER 550e8400-... currencies.gold
Set Value
/udata set <entity_type> <entity_id> <path> <json_value>
Example: /udata set PLAYER 550e8400-... currencies.gold 1000
Delete Entity
/udata delete <entity_type> <entity_id>
Deletes all data for the entity
Storage Stats
/udata stats
Shows: entity types, total entities
Cache Stats
/udata cache
Shows: entries, hits, misses, hit rate
DataValue Reference
DataValue is a wrapper for dynamic JSON values.
Type Checks
value.isNull() // true if null or missing
value.exists() // true if not null
value.isPrimitive() // true if string, number, or boolean
value.isArray() // true if JSON array
value.isObject() // true if JSON object
Conversions
// String
value.asString() // null if missing or not a string
value.asString("default") // "default" if missing/invalid
// Numeric (returns null if missing/invalid, allows null checks)
value.asInt() // Integer, null if missing/invalid
value.asInt(100) // int, 100 if missing/invalid
value.asLong() // Long, null if missing/invalid
value.asLong(0L) // long, 0L if missing/invalid
value.asDouble() // Double, null if missing/invalid
value.asDouble(0.0) // double, 0.0 if missing/invalid
// Boolean
value.asBool() // Boolean, null if missing/invalid
value.asBool(false) // boolean, false if missing/invalid
// Complex types
value.asArray() // JsonArray, null if not array
value.asList() // List, empty if null
value.asObject() // JsonObject, null if not object
asInt(), asLong(), etc.) when you need to detect missing or invalid data.
Use the default-value methods (asInt(0), asLong(0L), etc.) when you want safe fallback behavior.
Best Practices
Define Schemas Early
Register schemas in your mod's start() before using queries.
Use Appropriate Storage Modes
Default SMART is good for most cases; use CACHE_ONLY for session data.
Batch Reads When Possible
Use get(path1, path2, ...) instead of multiple single gets.
Index Query Fields
Only indexed fields can be efficiently queried.
Handle Async Properly
All operations return CompletableFuture; use thenAccept(), thenCompose(), etc.
Use Transactions for Atomic Updates
Especially important for currency/inventory operations.