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.
STARK uses a two-file storage model. When you open a database named "mydb", two files are created:
| File | Purpose |
|---|---|
mydb.idx | B-tree index — stores keys and pointers to data. Provides fast O(log n) lookups. |
mydb.dat | Data 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.
STARK supports three kinds of keys, each stored and optimized separately:
| Key Type | CLI Command Prefix | Use Case |
|---|---|---|
| Numeric keys (uint32) | addn, getn, deln, existsn | Integer IDs — player IDs, item IDs, save slots |
| String keys | adds, gets, dels, exist_str | Text-based lookups — usernames, config keys |
| Typed records | add, get | Structured data — game entities with fields |
STARK guarantees ACID compliance — the four properties that ensure database reliability even in the event of crashes, power failures, or errors.
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.
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.
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.
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.
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.
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.
| help | Display all available commands and their usage. Shows command syntax and descriptions. |
| exit | Save all pending changes to disk, close the database files, and quit the CLI. |
| stats | Show 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 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.
| 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 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.
| 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"
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.
| Type Keyword | C/C++ Constant | Size | Notes |
|---|---|---|---|
int | TYPE_INT (1) | 4 bytes | Signed 32-bit integer |
string(N) | TYPE_STRING (2) | N+1 bytes | N = max characters. Includes null terminator. Use string(32), string(255), etc. |
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.
| 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
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.
| 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.
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 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.
| begin | Start a new transaction. All subsequent writes (addn, adds, add) are buffered until commit or rollback. |
| commit | Commit 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. |
| rollback | Discard 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/commitblock.
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
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;
}
// Open existing or create new
stark::Database db("mydb");
// Throws stark::Error if
// the database cannot be opened
// 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.
All numeric key operations throw stark::Error on failure. get() returns an empty string if the key is not found.
// 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 — returns true if deleted,
// false if key didn't exist
bool removed = db.remove(1);
// Check existence
if (db.exists(1)) {
// key exists
}
String keys use std::string arguments. Internally, keys are hashed, but you interact with them using the original text.
// 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 by string key
bool ok = db.remove_str(
"username");
// Check existence
if (db.exists_str("theme")) {
// key exists
}
Define schemas with stark::Field objects, then add and retrieve structured records. Field type constants: TYPE_INT (1) and TYPE_STRING (2).
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 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)—nameis the field name,typeisTYPE_INTorTYPE_STRING,sizeis the string buffer size (default 4, ignored for int).
// 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
}
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.
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);
// 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;
// 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
Statsstruct provides:keys(uint64),height(uint32),data_size(uint64 bytes), andpages(uint32 B-tree pages).
The C++ wrapper uses exceptions for error handling. All exceptions inherit from std::runtime_error.
// 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();
}
// 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.
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.
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. |
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. |
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. |
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. |
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. |
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. |
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. |
All C API functions that can fail return a stark_result_t enum. The C++ wrapper converts these to exceptions.
| Code | Value | Meaning |
|---|---|---|
STARK_OK | 0 | Operation succeeded |
STARK_ERROR | -1 | General error — type already exists, invalid state, etc. |
STARK_NOT_FOUND | -2 | Key or type not found |
STARK_FULL | -3 | Database is full (rare — usually indicates disk space) |
STARK_IO_ERROR | -4 | Disk I/O failure — check permissions and disk space |
STARK_INVALID_ARG | -5 | Invalid argument passed — null pointer, bad field definition, etc. |
STARK_CLOSED | -6 | Operation on a closed database handle |
STARK_MEMORY_ERROR | -7 | Out 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.
| Operation | STARK | SQLite | Redis | MongoDB |
|---|---|---|---|---|
| Write 1 record | 0.8 µs | 5 µs | 1 µs | 50 µs |
| Read 1 record | 0.5 µs | 3 µs | 0.8 µs | 30 µs |
| Write 1,000 records | 0.8 ms | 5 ms | 1 ms | 50 ms |
| Read 1,000 records | 0.5 ms | 3 ms | 0.8 ms | 30 ms |
| Database | File Size |
|---|---|
| STARK | ~100 KB |
| SQLite | ~150 KB |
| LevelDB | ~120 KB |
| MongoDB | ~2 MB |