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 -> {
int gold = value.asInt(); // Returns 0 if null
int gold = value.asInt(100); // Returns 100 if null
});
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();
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.isString()
value.isNumber()
value.isBool()
value.isArray()
value.isObject()
Conversions
value.asString() // null if not string
value.asString("default") // "default" if null
value.asInt() // 0 if not number
value.asInt(100) // 100 if null
value.asLong()
value.asLong(0L)
value.asDouble()
value.asDouble(0.0)
value.asBool() // false if not boolean
value.asBool(true) // true if null
value.asArray() // null if not array
value.asList() // Empty list if null
value.asObject() // null if not object
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.