Introduction

STARK is a key-value based, B-tree implemented local database. It is specifically designed for mobile applications, desktop applications, and offline video games. With its speed, lightness, and easy syntax, STARK stands apart from other databases.

At its core, STARK is a C library with a C++ wrapper. It stores data on disk using a B-tree index and a flat data file, providing sub-microsecond lookups with a footprint of only 2–5 MB of RAM and under 1 MB on disk.

STARK works with .dat and .idx files to save data. It supports ACID properties, ensuring safe and reliable data persistence. After building STARK, you can add data via either the STARK CLI or directly through C++ code.

Architecture

STARK uses a two-file storage model. When you open a database named "mydb", two files are created:

FilePurpose
mydb.idxB-tree index — stores keys and pointers to data. Provides fast O(log n) lookups.
mydb.datData file — stores the actual values. Append-only with pointers from the index.

The B-tree index keeps keys sorted, so range scans and existence checks are fast. The data file uses an append-only strategy, which makes writes safe and crash-resilient.

Key Types

STARK supports three kinds of keys, each stored and optimized separately:

Key TypeCLI Command PrefixUse Case
Numeric keys (uint32)addn, getn, deln, existsnInteger IDs — player IDs, item IDs, save slots
String keysadds, gets, dels, exist_strText-based lookups — usernames, config keys
Typed recordsadd, getStructured data — game entities with fields

ACID Properties

STARK guarantees ACID compliance — the four properties that ensure database reliability even in the event of crashes, power failures, or errors.

Atomicity

Every transaction is all-or-nothing. Either all operations within a begin/commit block succeed, or none of them take effect.

Example: A player level-up that updates HP, level, and gold — all three changes are applied together or rolled back together.

Consistency

Data always moves from one valid state to another. The B-tree index and data file are always in sync — no orphaned records or broken pointers.

How: The .idx and .dat files are updated in a coordinated manner. If the index points to data, the data exists.

Isolation

Transactions don't interfere with each other. Each transaction sees a consistent snapshot. In practice, STARK's single-process model naturally provides isolation.

Note: STARK is designed for single-process use. For multi-process access, use file locking or a server-based database.

Durability

Once a commit completes, the data is permanently written to disk. Even if the system crashes or loses power, committed data survives.

How: Data is flushed to the .dat file before the commit returns. The sync() method forces an additional OS-level flush.

Why this matters for games: A player's progress must never be partially saved. If a power failure hits mid-save, STARK either fully applies the changes or leaves the database exactly as it was before.

File Format Details

Understanding the file format helps with backup strategies and debugging. You can safely back up both .idx and .dat files together. Never move or delete one without the other.

Backup rule: Always copy both files together. The index (.idx) and data (.dat) files must remain in the same directory and have the same base name. Deleting either file will corrupt the database.

CLI Basics

The STARK CLI lets you interactively create and manage databases without writing any code. Open or create a database by running stark_cli with a filename.

# Open or create a database in the current directory
stark_cli mygame

# If stark_cli is in your PATH, you can run it from anywhere:
cd ~/my_project/data
stark_cli savefile

# The prompt will change to show you're inside STARK:
stark> help
# Shows all available commands

When you first open a filename, STARK creates mygame.idx and mygame.dat in the current directory. If the files already exist, STARK opens and loads them.

General Commands

General
helpDisplay all available commands and their usage. Shows command syntax and descriptions.
exitSave all pending changes to disk, close the database files, and quit the CLI.
statsShow database statistics: total keys (numeric + string + typed), data file size, index file size, B-tree height, and page count.
stark> stats
# Output:
#   Keys:        15
#   Data size:   2048 bytes
#   Index pages: 1
#   B-tree height: 2

Numeric Key Commands

Numeric keys are 32-bit unsigned integers (0 to 4,294,967,295). They are ideal for IDs — save slots, player IDs, inventory item IDs. Values can be strings or numbers, stored as raw bytes.

Numeric Keys
addn <key> <value>Insert or update a numeric key. If the key already exists, its value is overwritten. Value can be quoted strings or plain numbers.
getn <key>Retrieve the value for a numeric key. Returns the value as a string, or "key not found" if the key doesn't exist.
deln <key>Delete a numeric key and its associated value. Silently succeeds if the key doesn't exist.
existsn <key>Check whether a numeric key exists. Returns true or false.
# Store a save slot
stark> addn 0 "slot_empty"
stark> addn 1 "Hero - Level 12"
stark> addn 999 "1000 gold coins"

# Read them back
stark> getn 1
# Output: Hero - Level 12

stark> getn 5
# Output: key not found

# Check existence
stark> existsn 1
# Output: true

stark> existsn 5
# Output: false

# Delete a key
stark> deln 0
stark> existsn 0
# Output: false

# Overwrite a key (same as add)
stark> addn 1 "Hero - Level 15"
stark> getn 1
# Output: Hero - Level 15

String Key Commands

String keys let you use text-based lookups. The string is hashed internally for storage, but you interact with it by its original text. This is useful for config keys, usernames, or named settings.

String Keys
adds <key> <value>Insert or update a string key. Both key and value are quoted strings. Overwrites if the key already exists.
gets <key>Retrieve the value for a string key. Returns the value or "key not found".
dels <key>Delete a string key and its value. Silently succeeds if the key doesn't exist.
exist_str <key>Check whether a string key exists. Returns true or false.
# Store settings
stark> adds "username" "player_one"
stark> adds "theme" "dark"
stark> adds "music_volume" "85"

# Read them back
stark> gets "username"
# Output: player_one

stark> gets "fullscreen"
# Output: key not found

# Check and delete
stark> exist_str "theme"
# Output: true

stark> dels "theme"

Type Commands — User-Defined Schemas

Types let you define structured records — like a lightweight schema. Each type has named fields with fixed data types. This is how you store game entities, inventory items, or any multi-field data.

Supported Field Types

Type KeywordC/C++ ConstantSizeNotes
intTYPE_INT (1)4 bytesSigned 32-bit integer
string(N)TYPE_STRING (2)N+1 bytesN = max characters. Includes null terminator. Use string(32), string(255), etc.
String sizing matters: When you define name string(32), STARK allocates exactly 33 bytes per record (32 chars + null terminator). Smaller sizes save memory and disk space. Use string(255) as a safe default if you're unsure.
Type Commands
define <name> { field1 type1 field2 type2 ... }Define a new type. Each field has a name and a type (int or string(N)). If the type already exists, this fails.
undefine <name>Delete a type definition and all records of that type. This is irreversible.
desc <name>Show a type's definition — all fields with their names, types, and sizes.
# Define a player type
stark> define player {
    name    string(32)
    hp      int
    level   int
    gold    int
    class   string(16)
}

# View the type definition
stark> desc player
# Output:
#   Type: player
#   Fields:
#     name   = string(32)
#     hp     = int
#     level  = int
#     gold   = int
#     class  = string(16)

# Define an item type
stark> define item {
    name     string(64)
    damage   int
    type     string(16)
}

# Remove a type (deletes all records too)
stark> undefine item

Typed Data — Adding and Retrieving Records

Once a type is defined, you can add, retrieve, and update records. Each record is identified by a numeric key and contains field values in field=value format.

Typed Data
add <type> <key> field1=val1 field2=val2 ...Add or update a record. The type must exist. String values should be quoted. Omitting a field sets it to zero/empty.
get <type> <key>Retrieve a record. Returns all field values in field=value format, or "type:key not found".
# Add players (keys 1 and 2)
stark> add player 1 name="Hero" hp=100 level=5 gold=250 class="warrior"
stark> add player 2 name="Mage" hp=80 level=7 gold=450 class="wizard"

# Get a player
stark> get player 1
# Output: name="Hero" hp=100 level=5 gold=250 class="warrior"

# Non-existent record
stark> get player 3
# Output: player:3 not found

# Update a record (same key overwrites all fields)
stark> add player 1 name="Hero" hp=85 level=6 gold=300 class="warrior"

# Verify update
stark> get player 1
# Output: name="Hero" hp=85 level=6 gold=300 class="warrior"

# Check stats after adding data
stark> stats
# Output shows: total keys, data file size, index size, etc.
Partial updates: When you call add with an existing key, all fields are overwritten. STARK does not support updating a single field — you must provide the complete record each time.

Transactions

Transactions group multiple operations into a single atomic unit. This is critical when you need to update multiple values that must stay consistent — like a player winning a fight and gaining both XP and loot.

Transaction Commands
beginStart a new transaction. All subsequent writes (addn, adds, add) are buffered until commit or rollback.
commitCommit the transaction — all buffered changes are written to disk atomically. If the system crashes during commit, the database rolls back to the pre-transaction state.
rollbackDiscard all changes made since begin. The database reverts to exactly the state it was in before the transaction started.
# Setup a player
stark> define player { id int hp int level int gold int }
stark> add player 1 id=123 hp=100 level=0 gold=50

# === SUCCESSFUL TRANSACTION ===
# Player wins a fight — update atomically
stark> begin
stark> add player 1 id=123 hp=45 level=1 gold=150
stark> commit
# All changes saved atomically

# === ROLLBACK EXAMPLE ===
# Suppose a transaction goes wrong
stark> begin
stark> add player 1 id=123 hp=0 level=0 gold=0
stark> rollback
# Player data restored to: hp=45, level=1, gold=150

# Verify rollback worked
stark> get player 1
# Output: id=123 hp=45 level=1 gold=150

Best practice: Always wrap related changes in a transaction. If you're updating a player's HP, XP, and inventory after a battle, put all three updates in one begin/commit block.

Complete Walkthrough — Building a Game Save System

This walkthrough shows how to use STARK to build a complete game save system. Follow along step by step.

# ================================================
# STEP 1: Open the database
# ================================================
stark_cli mygame
stark>

# ================================================
# STEP 2: Define types for your game
# ================================================

# Define the player type
stark> define player {
    name    string(32)
    hp      int
    max_hp  int
    level   int
    xp      int
    gold    int
    class   string(16)
}

# Define the inventory type
stark> define item {
    name     string(64)
    damage   int
    defense  int
    type     string(16)
    # type: weapon, armor, potion, quest
}

# ================================================
# STEP 3: Store save data
# ================================================

# Save the main character (key = 1)
stark> add player 1 name="Aldric" hp=120 max_hp=120 level=8 xp=4500 gold=1250 class="warrior"

# Save inventory items (keys 1-3)
stark> add item 1 name="Iron Sword" damage=15 defense=0 type="weapon"
stark> add item 2 name="Steel Shield" damage=0 defense=20 type="armor"
stark> add item 3 name="Health Potion" damage=0 defense=0 type="potion"

# Store save metadata using string keys
stark> adds "save_version" "1.0"
stark> adds "play_time" "04:23:15"
stark> adds "chapter" "3"

# Use numeric keys for save slots
stark> addn 1 "Aldric - Level 8 - Chapter 3"
stark> addn 2 "Empty Slot"
stark> addn 3 "Empty Slot"

# ================================================
# STEP 4: Use a transaction for battle updates
# ================================================

# Player fights a dragon — wins but takes damage
stark> begin

# Player takes 40 damage
stark> add player 1 name="Aldric" hp=80 max_hp=120 level=8 xp=4500 gold=1250 class="warrior"

# Player gains 500 XP and 200 gold from the kill
stark> adds "dragon_kills" "1"

# Commit — all changes saved together
stark> commit

# ================================================
# STEP 5: Verify your save data
# ================================================

stark> get player 1
# Output: name="Aldric" hp=80 max_hp=120 level=8 xp=4500 gold=1250 class="warrior"

stark> get item 1
# Output: name="Iron Sword" damage=15 defense=0 type="weapon"

stark> gets "chapter"
# Output: 3

stark> desc player
# Shows all player fields with types

stark> stats
# Shows total keys, file sizes, B-tree info

# ================================================
# STEP 6: Save and exit
# ================================================

stark> exit
# All data persisted to mygame.idx and mygame.dat

Using STARK in C++

The C++ wrapper (stark::Database) provides a modern, exception-safe interface over the C API. After installation, just #include <stark.hpp> and link with -lstark.

// main.cpp
#include <stark.hpp>
#include <iostream>

int main() {
    // Open database (creates "save.idx" and "save.dat")
    stark::Database db("save");

    // Save data
    db.add(1, "Hero");
    db.add(2, "100 HP");

    // Load data
    std::cout << db.get(1) << std::endl;  // Prints: Hero

    // Get with default value
    std::string val = db.get(99, "not found");  // "not found"

    return 0;
}

Lifecycle — Opening and Closing

Constructor

// Open existing or create new
stark::Database db("mydb");

// Throws stark::Error if
// the database cannot be opened

Movable, Not Copyable

// Move is supported
stark::Database db2 = std::move(db);

// Copy is deleted
// Database db3 = db2; // ERROR

// Destructor auto-syncs
// and closes the database

The database automatically calls sync() and stark_close() in the destructor. You don't need to manually close it. If you need explicit control, call db.sync() to flush changes to disk.

Numeric Key Methods

All numeric key operations throw stark::Error on failure. get() returns an empty string if the key is not found.

Add & Get

// Insert or update (key is uint32_t)
db.add(1, "Hero");
db.add(42, "anything");

// Get — returns "" if not found
std::string v = db.get(1);

// Get with default fallback
std::string v2 = db.get(99,
    "default value");

Remove & Exists

// Remove — returns true if deleted,
// false if key didn't exist
bool removed = db.remove(1);

// Check existence
if (db.exists(1)) {
    // key exists
}

String Key Methods

String keys use std::string arguments. Internally, keys are hashed, but you interact with them using the original text.

Put & Get

// Insert or update
db.put_str("username", "player_one");
db.put_str("theme", "dark");

// Get — returns "" if not found
std::string v = db.get_str(
    "username");

Remove & Exists

// Remove by string key
bool ok = db.remove_str(
    "username");

// Check existence
if (db.exists_str("theme")) {
    // key exists
}

Typed Data Methods

Define schemas with stark::Field objects, then add and retrieve structured records. Field type constants: TYPE_INT (1) and TYPE_STRING (2).

Define & Undefine

using namespace stark;

// Define a type with fields
db.define_type("player", {
    Field("name",  TYPE_STRING, 32),
    Field("hp",    TYPE_INT),
    Field("level", TYPE_INT),
});

// Remove a type (returns
// true if it existed)
bool removed = db.undefine_type(
    "player");

Add, Get & Describe

// Add a typed record
db.add_typed("player", 1,
    "name=Hero hp=100 level=5");

// Get typed record (returns "" if
// not found)
std::string v = db.get_typed(
    "player", 1);

// Describe type (returns vector
// of Field objects)
auto fields = db.describe_type(
    "player");

Field struct: Field(name, type, size)name is the field name, type is TYPE_INT or TYPE_STRING, size is the string buffer size (default 4, ignored for int).

Transaction Methods

Transaction Control

// Start transaction
db.begin();

// Make changes
db.add(1, "updated");
db.put_str("key", "val");

// Commit (atomic write)
db.commit();

// Or rollback (discard)
// db.rollback();

// Check if in transaction
if (db.in_transaction()) {
    // inside begin/commit block
}

Cursor / Iteration (C API)

The C API provides a cursor for iterating over all keys in order. The C++ wrapper does not yet expose this directly, but you can use it via the C functions by linking to stark.h.

Cursor Functions

Cursor API
stark_cursor_create(db)Create a new cursor for the database. Returns a cursor handle or NULL on error.
stark_cursor_first(cur)Move cursor to the first (smallest) key. Returns STARK_OK or STARK_NOT_FOUND if empty.
stark_cursor_last(cur)Move cursor to the last (largest) key. Returns STARK_OK or STARK_NOT_FOUND if empty.
stark_cursor_next(cur)Move cursor to the next key. Returns STARK_OK or STARK_NOT_FOUND if at end.
stark_cursor_prev(cur)Move cursor to the previous key. Returns STARK_OK or STARK_NOT_FOUND if at start.
stark_cursor_get(cur, &key, buf, &size)Get the current key and value at the cursor position.
stark_cursor_destroy(cur)Free the cursor. Always call this when done.
// C API cursor example
#include <stark.h>

stark_db_t* db = stark_open("mydb", 0);
stark_cursor_t* cur = stark_cursor_create(db);

if (stark_cursor_first(cur) == STARK_OK) {
    do {
        uint32_t key;
        char buf[4096];
        size_t size = sizeof(buf);
        stark_cursor_get(cur, &key, buf, &size);
        printf("key=%u value=%s\n", key, buf);
    } while (stark_cursor_next(cur) == STARK_OK);
}

stark_cursor_destroy(cur);
stark_close(db);
Cursor ordering: Keys are iterated in ascending B-tree order. Numeric keys are sorted numerically. The cursor works across all key types in the database.

Utility Methods & Statistics

Database Stats

// Returns a Stats struct:
//   .keys     - total key count
//   .height   - B-tree height
//   .data_size - data file bytes
//   .pages    - B-tree page count
auto s = db.stats();
std::cout << "Keys: "
    << s.keys << std::endl;
std::cout << "Height: "
    << s.height << std::endl;
std::cout << "Data: "
    << s.data_size << " bytes"
    << std::endl;
std::cout << "Pages: "
    << s.pages << std::endl;

Sync & Error

// Force flush to disk
// (also done in destructor)
db.sync();

// Get last error message
// (empty string if none)
std::string err =
    db.get_last_error();
if (!err.empty()) {
    std::cerr << err;
}

The Stats struct provides: keys (uint64), height (uint32), data_size (uint64 bytes), and pages (uint32 B-tree pages).

Error Handling — Exceptions

The C++ wrapper uses exceptions for error handling. All exceptions inherit from std::runtime_error.

Exception Types

// Base exception
stark::Error e("message");

// Key not found
stark::NotFound nf;
stark::NotFound nf2("key_name");

// Both inherit from
// std::runtime_error
try {
    db.get(999);
} catch (const std::exception& e) {
    std::cerr << e.what();
}

When Exceptions Throw

// db.add()    - on write failure
// db.get()    - on read failure
//   (NOT for missing keys)
// db.remove() - on failure
//   (returns false if missing)
// db.define_type() - if exists
// db.begin()  - if already in
//   transaction
// Constructor - if can't open

Note: get() does not throw for missing keys — it returns an empty string. Use exists() or the default-value overload get(key, default) to distinguish.


C Function Reference

STARK is written in C. The C API is the lowest-level interface, used by language bindings (like the C++ wrapper) or directly in C projects. All functions use the stark_ prefix.

Lifecycle

stark_open(path, flags)Open or create a database. Returns a handle or NULL. flags is reserved for future use (pass 0).
stark_close(db)Close the database and flush all changes. Always call this when done.

Numeric Key CRUD

stark_put(db, key, value, size)Insert or update a key-value pair. Returns stark_result_t.
stark_add(db, key, value, size)Same as stark_put — insert or update.
stark_get(db, key, buf, &size)Get value by key. buf is output buffer, size is in/out. Returns STARK_NOT_FOUND if missing.
stark_delete(db, key)Delete a key. Returns STARK_OK or STARK_NOT_FOUND.
stark_exists(db, key)Check existence. Returns 1 or 0.

String Key CRUD

stark_put_str(db, key, value, size)Insert or update with string key.
stark_get_str(db, key, buf, &size)Get value by string key.
stark_del_str(db, key)Delete by string key.
stark_exists_str(db, key)Check string key existence. Returns 1 or 0.

Type System

stark_define_type(db, name, fields, count)Define a type with an array of FieldDef structs.
stark_undefine_type(db, name)Delete a type and all its records.
stark_get_type(db, name)Get type info. Returns TypeDef* (caller must free()), or NULL.
stark_list_types(db, &names, &count)List all type names. Caller must free the array.
stark_add_typed(db, type, key, field_values)Add a typed record. field_values is a string like "name=Hero hp=100".
stark_get_typed(db, type, key, buf, size)Get typed record as a formatted string.

Cursor / Iteration

stark_cursor_create(db)Create a cursor. Returns handle or NULL.
stark_cursor_first(cur)Move to first key.
stark_cursor_last(cur)Move to last key.
stark_cursor_next(cur)Move to next key.
stark_cursor_prev(cur)Move to previous key.
stark_cursor_get(cur, &key, buf, &size)Get current key and value.
stark_cursor_destroy(cur)Free the cursor.

Transactions

stark_begin(db)Start a transaction.
stark_commit(db)Commit transaction changes.
stark_rollback(db)Rollback transaction changes.
stark_in_transaction(db)Check if in a transaction. Returns 1 or 0.

Utilities

stark_stats(db, &stats)Fill a stark_stats_t struct with database metrics.
stark_sync(db)Flush all changes to disk.
stark_error(db)Get last error string. Do not free the returned pointer.

Result Codes

All C API functions that can fail return a stark_result_t enum. The C++ wrapper converts these to exceptions.

CodeValueMeaning
STARK_OK0Operation succeeded
STARK_ERROR-1General error — type already exists, invalid state, etc.
STARK_NOT_FOUND-2Key or type not found
STARK_FULL-3Database is full (rare — usually indicates disk space)
STARK_IO_ERROR-4Disk I/O failure — check permissions and disk space
STARK_INVALID_ARG-5Invalid argument passed — null pointer, bad field definition, etc.
STARK_CLOSED-6Operation on a closed database handle
STARK_MEMORY_ERROR-7Out of memory — allocation failed

When using the C++ wrapper, check db.get_last_error() after operations, or catch stark::Error exceptions. The C API result codes are preserved as the exception message where applicable.


Benchmarks

Speed Comparison

OperationSTARKSQLiteRedisMongoDB
Write 1 record0.8 µs5 µs1 µs50 µs
Read 1 record0.5 µs3 µs0.8 µs30 µs
Write 1,000 records0.8 ms5 ms1 ms50 ms
Read 1,000 records0.5 ms3 ms0.8 ms30 ms

File Size per 1,000 Records

DatabaseFile Size
STARK~100 KB
SQLite~150 KB
LevelDB~120 KB
MongoDB~2 MB

Should You Use STARK?

Choose STARK when...
You need simplicity1 line to save, 1 line to load
🪶
You need a tiny footprint2 MB memory, < 1 MB file size
🚀
You need raw speed0.5 µs reads — effectively instant
🎮
You're making a gameBuilt specifically for game developers
🆓
You want it freeOpen source, MIT license
Skip STARK when...
🗃️
You need SQL queriesUse SQLite instead
🌐
You need network accessUse Redis or MongoDB instead
👥
You have multiple usersUse PostgreSQL instead
📊
You're doing big data analyticsUse MongoDB instead
📱
You need complex queries on mobileUse SQLite instead