UnifiedData v2

A unified data abstraction layer for Hytale mods

Freeform JSON Async Swappable Backends Indexed Queries

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 Statement
import com.unifieddata.api.Data;

That's it! No initialization required - UnifiedData handles everything automatically when the server starts.

3. Basic Usage

Reading Player Data
// 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"));
    });
Writing Player Data
// Setting player gold
Data.from("PLAYER").entity(playerUuid.toString())
    .set("currencies.gold", 500);
Atomic Operations
// 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:

ExampleMod.java
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
/
entity_id
/
path.to.field
"PLAYER" "uuid-string" "currencies.gold"

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:

Example Player Entity
{
  "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"
level42
currencies.gold1500
stats.pvp.kills150
guilds[0]"rebels"
inventory.weapons.sword.damage50

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

Get a Single Value
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
        }
    });
Get Nested Object
Data.from("PLAYER").entity(uuid).get("currencies")
    .thenAccept(value -> {
        JsonObject currencies = value.asObject();
        // { "gold": 1500, "gems": 25 }
    });

Typed Getters

Type-Safe 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

Get Multiple Paths at Once
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

Check if Path Exists
Data.from("PLAYER").entity(uuid).exists("nickname")
    .thenAccept(exists -> {
        if (exists) {
            // Field exists and is not null
        }
    });

Writing Data

Basic Set

Set Values
// 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

Batch Set
Data.from("PLAYER").entity(uuid).setAll(Map.of(
    "currencies.gold", 1000,
    "currencies.gems", 50,
    "level", 10
));

Delete Data

Delete Operations
// 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, Increment, Decrement
// 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

Toggle Boolean
Data.from("PLAYER").entity(uuid).toggle("settings.darkMode")
    .thenAccept(newValue -> {
        // newValue is the new boolean state
    });

Array Operations

Push and Pull
// 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

Custom Transformation
// 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.

Important: Fields must be indexed via Schema for efficient queries. See Schema & Indexing.

Basic Query

Simple 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 / OR 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

Order and Limit Results
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.

Creating a Binding
// 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
}
Available Binding Types
// 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.

Observing Data Changes
// 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);
});
Unregistering Observers
// 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

1 set() / add() called
2 Write to cache/storage
3 Notify all observers
4 Update all bindings
Performance Tip: Bindings are ideal for data you read frequently but changes infrequently. After the initial load, reads are pure local memory access - no async, no I/O.

Unbinding

Removing Bindings
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()TCurrent value, or default if null
getValue()T (nullable)Current value, or null if not set
isPresent()booleanTrue if value is not null
isInitialized()booleanTrue if initial load completed
getDefault()TThe configured default value

Transactions

Transactions allow atomic operations with preconditions.

Single-Entity Transaction

Purchase Example
// 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

In Your Mod's start() Method
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

Slow

With Index

Direct lookup by value

Fast
Tip: Index fields that you query frequently. Array indexes support contains() queries.

Storage Modes

Control caching behavior per-operation.

Using Storage Modes
// 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

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

Type Checking Methods
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

Conversion Methods
// 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
Tip: Use the no-arg methods (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

1

Define Schemas Early

Register schemas in your mod's start() before using queries.

2

Use Appropriate Storage Modes

Default SMART is good for most cases; use CACHE_ONLY for session data.

3

Batch Reads When Possible

Use get(path1, path2, ...) instead of multiple single gets.

4

Index Query Fields

Only indexed fields can be efficiently queried.

5

Handle Async Properly

All operations return CompletableFuture; use thenAccept(), thenCompose(), etc.

6

Use Transactions for Atomic Updates

Especially important for currency/inventory operations.