World Persistence and Data Storage
This document details Pyrite’s SQLite-based world persistence system, including database schema, chunk serialization, compression, async I/O, and world metadata management.
Overview
Pyrite persists world data using SQLite with Write-Ahead Logging (WAL) mode for high-performance, asynchronous writes. This allows the game loop to remain responsive while data is written in background threads.
Key Components:
Database File:
saves/<world_name>/<world_name>.db(SQLite database)Tables:
chunks,player_data,world_metaCompression: zlib (1:5 ratio typical for voxel data)
Threading: Background threads handle I/O; main thread reads/writes safely via locks
Database Schema
1. Chunks Table
Stores terrain voxel data and lightmaps for each chunk.
CREATE TABLE chunks (
x INTEGER,
y INTEGER,
z INTEGER,
voxel_data BLOB, -- Compressed 1D uint8 array
lightmap_data BLOB, -- Compressed 1D uint8 array
generated BOOLEAN, -- Whether terrain was procedurally generated
timestamp INTEGER, -- Unix timestamp of last save
PRIMARY KEY (x, y, z)
)
Row Structure:
x, y, z: Chunk coordinates (e.g., chunk at x=0, y=0, z=0 covers world voxels 0-47 in each axis)
voxel_data: Compressed blob of shape (CHUNK_VOL,) = (110592,) uint8 values
lightmap_data: Compressed blob of shape (CHUNK_VOL,) containing packed sunlight+blocklight (4 bits each)
generated: True if chunk was procedurally generated (not hand-edited)
timestamp: Used for cleanup/optimization later
Chunk Coordinate Mapping:
World voxel (x, y, z) → Chunk coordinate
chunk_x = floor(x / CHUNK_SIZE) # CHUNK_SIZE = 48
chunk_y = floor(y / CHUNK_SIZE)
chunk_z = floor(z / CHUNK_SIZE)
Local voxel index within chunk:
local_index = (x % 48) + (z % 48) * 48 + (y % 48) * 48² = flat_index
2. Player Data Table
Stores player position, inventory, and survival stats.
CREATE TABLE player_data (
id INTEGER PRIMARY KEY,
x REAL, -- Position X
y REAL, -- Position Y
z REAL, -- Position Z
yaw REAL, -- Rotation angle (radians)
pitch REAL, -- Rotation angle (radians)
health REAL, -- 0-20
hunger REAL, -- 0-20
oxygen REAL, -- 0-20
inventory JSON, -- JSON array of voxel IDs
inventory_counts JSON, -- JSON array of stack sizes
hotbar_index INTEGER, -- Selected slot
timestamp INTEGER
)
Storage Format:
{
"x": 128.5,
"y": 64.0,
"z": 256.75,
"yaw": 1.57,
"pitch": 0.0,
"health": 20,
"hunger": 15,
"oxygen": 20,
"inventory": [1, 5, 0, 0, 2, 0, 0, 0, 0, 0],
"inventory_counts": [64, 32, 0, 0, 1, 0, 0, 0, 0, 0]
}
3. World Meta Table
Stores world-level metadata (seed, difficulty, game mode).
CREATE TABLE world_meta (
key TEXT PRIMARY KEY,
value TEXT -- JSON serialized
)
Stored Keys:
seed: Integer seed (or MD5 hash of string seed)
difficulty: String ('PEACEFUL', 'EASY', 'NORMAL', 'HARD')
game_mode: String ('SURVIVAL', 'CREATIVE')
created_time: Unix timestamp of world creation
last_played: Unix timestamp of last save
player_name: Player name/UUID
spawn_x, spawn_y, spawn_z: Spawn location
Serialization and Compression
Chunk Serialization:
import zlib
import numpy as np
def serialize_chunk(voxel_array, lightmap_array):
"""Convert chunk data to compressed binary"""
# voxel_array: (110592,) uint8 array
# lightmap_array: (110592,) uint8 array
# Convert to bytes
voxel_bytes = voxel_array.tobytes() # 110592 bytes
lightmap_bytes = lightmap_array.tobytes()
# Compress with zlib (level 6 default)
voxel_compressed = zlib.compress(voxel_bytes, level=6)
lightmap_compressed = zlib.compress(lightmap_bytes, level=6)
return voxel_compressed, lightmap_compressed
def deserialize_chunk(voxel_compressed, lightmap_compressed):
"""Decompress and convert back to arrays"""
voxel_bytes = zlib.decompress(voxel_compressed)
lightmap_bytes = zlib.decompress(lightmap_compressed)
voxel_array = np.frombuffer(voxel_bytes, dtype=np.uint8).reshape((110592,))
lightmap_array = np.frombuffer(lightmap_bytes, dtype=np.uint8).reshape((110592,))
return voxel_array, lightmap_array
Compression Ratios:
Terrain Type Compressed Size Ratio
Dense terrain ~25 KB 1:4.4 (stone/dirt/grass)
Cave systems ~15 KB 1:7.4 (many air gaps)
Mixed biomes ~35 KB 1:3.2 (varied blocks)
Typical world with 1,440 chunks (30x5x30):
Uncompressed: 1,440 * 110,592 * 2 bytes ≈ 318 MB
Compressed: 1,440 * 25 KB average ≈ 36 MB
Database Operations
Initialize Database (at world creation):
import sqlite3
def create_world_database(world_name, seed):
"""Initialize new world database"""
db_path = f'saves/{world_name}/{world_name}.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Enable WAL mode for performance
cursor.execute('PRAGMA journal_mode=WAL')
cursor.execute('PRAGMA synchronous=NORMAL') # Balance safety/speed
# Create tables
cursor.execute('''
CREATE TABLE chunks (
x INTEGER, y INTEGER, z INTEGER,
voxel_data BLOB, lightmap_data BLOB,
generated BOOLEAN, timestamp INTEGER,
PRIMARY KEY (x, y, z)
)
''')
cursor.execute('''
CREATE TABLE player_data (
id INTEGER PRIMARY KEY,
x REAL, y REAL, z REAL,
yaw REAL, pitch REAL,
health REAL, hunger REAL, oxygen REAL,
inventory TEXT, inventory_counts TEXT,
hotbar_index INTEGER, timestamp INTEGER
)
''')
cursor.execute('''
CREATE TABLE world_meta (
key TEXT PRIMARY KEY,
value TEXT
)
''')
# Store metadata
cursor.execute("INSERT INTO world_meta VALUES (?, ?)", ("seed", seed))
cursor.execute("INSERT INTO world_meta VALUES (?, ?)", ("created_time", int(time.time())))
cursor.execute("INSERT INTO world_meta VALUES (?, ?)", ("game_mode", "SURVIVAL"))
conn.commit()
conn.close()
Save Chunk:
def save_chunk_to_db(conn, chunk_x, chunk_y, chunk_z, voxels, lightmap):
"""Save chunk to database (insert or update)"""
cursor = conn.cursor()
voxel_compressed, lightmap_compressed = serialize_chunk(voxels, lightmap)
timestamp = int(time.time())
# UPSERT pattern (insert or replace)
cursor.execute('''
INSERT OR REPLACE INTO chunks
(x, y, z, voxel_data, lightmap_data, generated, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (chunk_x, chunk_y, chunk_z, voxel_compressed, lightmap_compressed, True, timestamp))
conn.commit()
Load Chunk:
def load_chunk_from_db(conn, chunk_x, chunk_y, chunk_z):
"""Load chunk from database"""
cursor = conn.cursor()
cursor.execute('''
SELECT voxel_data, lightmap_data
FROM chunks
WHERE x=? AND y=? AND z=?
''', (chunk_x, chunk_y, chunk_z))
row = cursor.fetchone()
if row is None:
return None # Chunk not in database
voxel_compressed, lightmap_compressed = row
voxels, lightmap = deserialize_chunk(voxel_compressed, lightmap_compressed)
return voxels, lightmap
Save Player Data:
def save_player_data(conn, player):
"""Save player position, inventory, stats"""
import json
cursor = conn.cursor()
player_data = {
'x': float(player.feet_pos.x),
'y': float(player.feet_pos.y),
'z': float(player.feet_pos.z),
'yaw': float(player.yaw),
'pitch': float(player.pitch),
'health': float(player.health),
'hunger': float(player.hunger),
'oxygen': float(player.oxygen),
'inventory': player.inventory,
'inventory_counts': player.inventory_counts,
'hotbar_index': player.hotbar_index,
}
cursor.execute('''
INSERT OR REPLACE INTO player_data
(id, x, y, z, yaw, pitch, health, hunger, oxygen, inventory, inventory_counts, hotbar_index, timestamp)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
player_data['x'], player_data['y'], player_data['z'],
player_data['yaw'], player_data['pitch'],
player_data['health'], player_data['hunger'], player_data['oxygen'],
json.dumps(player_data['inventory']),
json.dumps(player_data['inventory_counts']),
player_data['hotbar_index'],
int(time.time())
))
conn.commit()
Load Player Data:
def load_player_data(conn):
"""Load player from database"""
import json
cursor = conn.cursor()
cursor.execute('SELECT * FROM player_data WHERE id=1')
row = cursor.fetchone()
if row is None:
# New world: return spawn point
return {
'x': 0, 'y': 64, 'z': 0,
'yaw': 0, 'pitch': 0,
'health': 20, 'hunger': 20, 'oxygen': 20,
'inventory': [0] * 41,
'inventory_counts': [0] * 41,
'hotbar_index': 0
}
# Parse from database
return {
'x': row[1], 'y': row[2], 'z': row[3],
'yaw': row[4], 'pitch': row[5],
'health': row[6], 'hunger': row[7], 'oxygen': row[8],
'inventory': json.loads(row[9]),
'inventory_counts': json.loads(row[10]),
'hotbar_index': row[11]
}
Asynchronous I/O and Threading
Background Save Queue:
from concurrent.futures import ThreadPoolExecutor
from queue import Queue
import threading
class WorldPersistence:
def __init__(self, world_name):
self.db_path = f'saves/{world_name}/{world_name}.db'
self.save_queue = Queue() # (chunk_x, chunk_y, chunk_z, voxels, lightmap)
self.executor = ThreadPoolExecutor(max_workers=2)
self.lock = threading.Lock()
def queue_chunk_save(self, chunk_x, chunk_y, chunk_z, voxels, lightmap):
"""Queue chunk for background save"""
self.save_queue.put((chunk_x, chunk_y, chunk_z, voxels, lightmap))
def process_save_queue(self):
"""Called from background thread"""
while True:
try:
chunk_x, chunk_y, chunk_z, voxels, lightmap = self.save_queue.get(timeout=1)
with threading.Lock(): # Thread-safe DB access
conn = sqlite3.connect(self.db_path)
save_chunk_to_db(conn, chunk_x, chunk_y, chunk_z, voxels, lightmap)
conn.close()
except:
pass # Timeout, continue
def shutdown(self):
"""Flush all queued saves before closing"""
while not self.save_queue.empty():
chunk_data = self.save_queue.get()
# Synchronously save remaining chunks
conn = sqlite3.connect(self.db_path)
save_chunk_to_db(conn, *chunk_data)
conn.close()
Main Thread Integration:
# On world load
persistence = WorldPersistence(world_name)
save_thread = threading.Thread(target=persistence.process_save_queue, daemon=True)
save_thread.start()
# On chunk unload (main thread)
persistence.queue_chunk_save(chunk_x, chunk_y, chunk_z, voxels, lightmap)
# On application quit
persistence.shutdown() # Block until all chunks saved
World Management (File System)
Directory Structure:
saves/
MyWorld/
MyWorld.db # SQLite database
MyWorld.db-shm # Shared memory (WAL)
MyWorld.db-wal # Write-ahead log
AnotherWorld/
AnotherWorld.db
...
List Worlds:
def list_worlds():
"""Find all saved worlds"""
worlds = []
saves_dir = 'saves'
if not os.path.exists(saves_dir):
return worlds
for world_name in os.listdir(saves_dir):
world_path = os.path.join(saves_dir, world_name)
db_file = os.path.join(world_path, f'{world_name}.db')
if os.path.isfile(db_file):
# Load metadata
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
cursor.execute("SELECT value FROM world_meta WHERE key='created_time'")
created = cursor.fetchone()[0] if cursor.fetchone() else 0
cursor.execute("SELECT value FROM world_meta WHERE key='game_mode'")
mode = cursor.fetchone()[0] if cursor.fetchone() else 'SURVIVAL'
conn.close()
worlds.append({
'name': world_name,
'path': db_file,
'created': created,
'mode': mode
})
return worlds
Delete World:
def delete_world(world_name):
"""Delete world and all save files"""
import shutil
world_path = f'saves/{world_name}'
if os.path.exists(world_path):
shutil.rmtree(world_path)
WAL Mode Benefits
SQLite’s Write-Ahead Logging (WAL) mode provides:
Non-blocking writes: Readers don’t block writers (and vice versa)
Batch commits: Multiple chunks written in single transaction
Crash recovery: Partial writes recovered safely
Performance: 2-10x faster than default journaling
Enable WAL:
conn = sqlite3.connect(db_path)
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('PRAGMA synchronous=NORMAL') # Balance safety (FULL=safer, NORMAL=faster)
conn.close()
Replication Checklist
✓ Create SQLite schema (3 tables)
✓ Implement chunk serialization (numpy → bytes)
✓ Add zlib compression
✓ Implement CRUD operations (save/load)
✓ Set up threading and queues
✓ Enable WAL mode
✓ Handle database cleanup on quit
✓ Implement world listing from filesystem