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 -> {
        int gold = value.asInt();      // Returns 0 if null
        int gold = value.asInt(100);   // Returns 100 if null
    });
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();

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.isString()
value.isNumber()
value.isBool()
value.isArray()
value.isObject()

Conversions

Conversion Methods
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

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.