Skip to main content

Database Schema

Hyperscape uses PostgreSQL in production and SQLite for local development, with Drizzle ORM for type-safe database access.
Database schema is defined in packages/server/src/database/schema.ts.

Overview

TablePurposeRepository
configSystem-wide key-value configuration-
usersUser accounts (Privy/Farcaster auth)UserRepository
charactersPlayer character dataCharacterRepository
inventoryCharacter inventory itemsInventoryRepository
equipmentEquipped itemsEquipmentRepository
bank_storageBank storage (480 slots)BankRepository
bank_tabsCustom bank tab configurationBankRepository
quest_progressPlayer quest state trackingQuestRepository
quest_audit_logQuest action audit trailQuestRepository
world_chunksPersistent world modificationsWorldRepository
player_sessionsLogin/logout trackingSessionRepository
npc_killsKill statisticsStatsRepository
death_locksDeath state for reconnectsPlayerDeathSystem
quest_progressPlayer quest progressionQuestRepository
quest_audit_logQuest action audit trailQuestRepository
activity_logPlayer activity loggingActivityLogRepository
tradesTrade historyTradingSystem
user_bansModeration bansAdminSystem

Core Tables

Users

// From schema.ts
export const users = pgTable('users', {
  id: text('id').primaryKey(),                    // UUID
  privyUserId: text('privy_user_id').unique(),    // Privy auth ID
  farcasterFid: text('farcaster_fid'),            // Farcaster ID
  role: text('role').default('player'),           // player, admin, moderator
  createdAt: bigint('created_at', { mode: 'number' }).notNull(),
  updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
});

// Indexes for fast lookup
export const usersIndexes = {
  privyUserIdIdx: index('privy_user_id_idx').on(users.privyUserId),
  farcasterFidIdx: index('farcaster_fid_idx').on(users.farcasterFid),
};

Characters

export const characters = pgTable('characters', {
  id: text('id').primaryKey(),                    // UUID
  accountId: text('account_id').notNull()         // References users.id
    .references(() => users.id),
  name: text('name').notNull(),                   // Character name

  // Position (stored as floats for sub-tile precision)
  positionX: real('position_x').notNull().default(0),
  positionY: real('position_y').notNull().default(0),
  positionZ: real('position_z').notNull().default(0),

  // Combat skill levels
  attackLevel: integer('attackLevel').default(1),
  strengthLevel: integer('strengthLevel').default(1),
  defenseLevel: integer('defenseLevel').default(1),
  constitutionLevel: integer('constitutionLevel').default(10),
  rangedLevel: integer('rangedLevel').default(1),
  
  // Prayer skill (added in PR #563)
  prayerLevel: integer('prayerLevel').default(1),
  
  // Gathering skill levels
  woodcuttingLevel: integer('woodcuttingLevel').default(1),
  miningLevel: integer('miningLevel').default(1),
  fishingLevel: integer('fishingLevel').default(1),
  
  // Artisan skill levels
  firemakingLevel: integer('firemakingLevel').default(1),
  cookingLevel: integer('cookingLevel').default(1),
  smithingLevel: integer('smithingLevel').default(1),
  agilityLevel: integer('agilityLevel').default(1),

  // Combat skill XP
  attackXp: integer('attackXp').default(0),
  strengthXp: integer('strengthXp').default(0),
  defenseXp: integer('defenseXp').default(0),
  constitutionXp: integer('constitutionXp').default(1154),
  rangedXp: integer('rangedXp').default(0),
  
  // Prayer skill XP (added in PR #563)
  prayerXp: integer('prayerXp').default(0),
  
  // Gathering skill XP
  woodcuttingXp: integer('woodcuttingXp').default(0),
  miningXp: integer('miningXp').default(0),
  fishingXp: integer('fishingXp').default(0),
  
  // Artisan skill XP
  firemakingXp: integer('firemakingXp').default(0),
  cookingXp: integer('cookingXp').default(0),
  smithingXp: integer('smithingXp').default(0),
  agilityXp: integer('agilityXp').default(0),

  // Prayer
  prayerPoints: integer('prayerPoints').default(1),
  prayerMaxPoints: integer('prayerMaxPoints').default(1),
  activePrayers: text('activePrayers').default('[]'),

  // Quest progression
  questPoints: integer('questPoints').default(0),

  // Combat state
  health: integer('health').default(100),
  maxHealth: integer('maxHealth').default(100),
  combatState: text('combat_state').default('idle'), // idle, fighting, dead
  lastCombatTick: bigint('last_combat_tick', { mode: 'number' }),

  // Quest progression
  questPoints: integer('questPoints').default(0).notNull(),

  // Timestamps
  lastLogin: bigint('last_login', { mode: 'number' }),
  createdAt: bigint('created_at', { mode: 'number' }).notNull(),
  updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
});
Skills are stored as individual columns (e.g., smithingLevel, smithingXp) rather than JSONB for efficient querying and indexing.

Inventory

export const inventory = pgTable('inventory', {
  id: text('id').primaryKey(),
  characterId: text('character_id').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  itemId: text('item_id').notNull(),              // Item definition ID
  quantity: integer('quantity').notNull().default(1),
  slot: integer('slot').notNull(),                // Inventory slot (0-27)
  createdAt: bigint('created_at', { mode: 'number' }).notNull(),
});

// Unique constraint: one item per slot per character
export const inventoryIndexes = {
  characterSlotIdx: uniqueIndex('character_slot_idx')
    .on(inventory.characterId, inventory.slot),
};

Equipment

export const equipment = pgTable('equipment', {
  id: text('id').primaryKey(),
  characterId: text('character_id').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  itemId: text('item_id').notNull(),
  slotType: text('slot_type').notNull(),          // weapon, shield, helmet, etc.
  createdAt: bigint('created_at', { mode: 'number' }).notNull(),
});

// Equipment slot types
type EquipmentSlotType =
  | 'weapon'
  | 'shield'
  | 'helmet'
  | 'body'
  | 'legs'
  | 'boots'
  | 'gloves'
  | 'cape'
  | 'ring'
  | 'amulet'
  | 'ammo';

Bank System

Bank Storage

export const bankStorage = pgTable('bank_storage', {
  id: text('id').primaryKey(),
  characterId: text('character_id').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  itemId: text('item_id').notNull(),
  quantity: integer('quantity').notNull().default(1),
  slot: integer('slot').notNull(),                // Bank slot (0-479)
  tabIndex: integer('tab_index').default(0),      // Bank tab (0-9)
  createdAt: bigint('created_at', { mode: 'number' }).notNull(),
  updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
});

// 480 bank slots total (OSRS-style)
export const BANK_CONSTANTS = {
  MAX_SLOTS: 480,
  MAX_TABS: 10,
  SLOTS_PER_TAB: 48,
};

Bank Tabs

export const bankTabs = pgTable('bank_tabs', {
  id: text('id').primaryKey(),
  characterId: text('character_id').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  tabIndex: integer('tab_index').notNull(),       // 0-9
  name: text('name'),                             // Custom tab name
  icon: text('icon'),                             // Icon item ID
  createdAt: bigint('created_at', { mode: 'number' }).notNull(),
});

World Persistence

World Chunks

export const worldChunks = pgTable('world_chunks', {
  id: text('id').primaryKey(),                    // "chunk_x_z" format
  chunkX: integer('chunk_x').notNull(),
  chunkZ: integer('chunk_z').notNull(),
  data: jsonb('data').$type<ChunkModifications>(),
  lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
});

interface ChunkModifications {
  harvestedResources: Array<{
    resourceId: string;
    harvestedAt: number;
    respawnAt: number;
  }>;
  placedObjects: Array<{
    type: string;
    position: { x: number; y: number; z: number };
    placedBy: string;
    placedAt: number;
  }>;
}

Player Sessions

export const playerSessions = pgTable('player_sessions', {
  id: text('id').primaryKey(),
  characterId: text('character_id').notNull()
    .references(() => characters.id),
  loginTime: bigint('login_time', { mode: 'number' }).notNull(),
  logoutTime: bigint('logout_time', { mode: 'number' }),
  duration: integer('duration'),                   // Seconds played
  ipAddress: text('ip_address'),
});

Quest System Tables

Quest Progress

Tracks player quest state:
export const questProgress = pgTable('quest_progress', {
  id: serial('id').primaryKey(),
  playerId: text('playerId').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  questId: text('questId').notNull(),              // Quest identifier from manifest
  status: text('status').default('not_started').notNull(),
  currentStage: text('currentStage'),              // Current stage ID
  stageProgress: jsonb('stageProgress').default({}), // e.g., {"kills": 7}
  startedAt: bigint('startedAt', { mode: 'number' }),
  completedAt: bigint('completedAt', { mode: 'number' }),
}, (table) => ({
  uniquePlayerQuest: unique().on(table.playerId, table.questId),
  playerIdx: index('idx_quest_progress_player').on(table.playerId),
  statusIdx: index('idx_quest_progress_status').on(table.playerId, table.status),
}));
Status Values:
  • not_started — Quest not started
  • in_progress — Quest active
  • completed — Quest finished
ready_to_complete is a derived status computed at runtime when objectives are met. It’s not stored in the database.

Quest Audit Log

Immutable audit trail for security and debugging:
export const questAuditLog = pgTable('quest_audit_log', {
  id: serial('id').primaryKey(),
  playerId: text('playerId').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  questId: text('questId').notNull(),
  action: text('action').notNull(),                // "started", "progressed", "completed"
  questPointsAwarded: integer('questPointsAwarded').default(0),
  stageId: text('stageId'),
  stageProgress: jsonb('stageProgress').default({}),
  timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
  metadata: jsonb('metadata').default({}),
}, (table) => ({
  playerIdx: index('idx_quest_audit_log_player').on(table.playerId),
  questIdx: index('idx_quest_audit_log_quest').on(table.questId),
  timestampIdx: index('idx_quest_audit_log_timestamp').on(table.timestamp),
  actionIdx: index('idx_quest_audit_log_action').on(table.action),
}));
Features:
  • Immutable log (no updates or deletes)
  • Used for fraud detection and debugging
  • Indexed for efficient queries by player, quest, and timestamp

Statistics Tables

NPC Kills

export const npcKills = pgTable('npc_kills', {
  id: text('id').primaryKey(),
  characterId: text('character_id').notNull()
    .references(() => characters.id),
  npcId: text('npc_id').notNull(),                // NPC definition ID
  killCount: integer('kill_count').notNull().default(0),
  lastKillAt: bigint('last_kill_at', { mode: 'number' }),
});

// Track kills per NPC type
export const npcKillsIndexes = {
  characterNpcIdx: uniqueIndex('character_npc_idx')
    .on(npcKills.characterId, npcKills.npcId),
};

Quest System Tables

quest_progress

Tracks player quest state:
export const questProgress = pgTable('quest_progress', {
  id: serial('id').primaryKey(),
  playerId: text('playerId').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  questId: text('questId').notNull(),
  status: text('status').default('not_started').notNull(),
  currentStage: text('currentStage'),
  stageProgress: jsonb('stageProgress').default({}),
  startedAt: bigint('startedAt', { mode: 'number' }),
  completedAt: bigint('completedAt', { mode: 'number' }),
}, (table) => ({
  uniquePlayerQuest: unique().on(table.playerId, table.questId),
  playerIdx: index('idx_quest_progress_player').on(table.playerId),
  statusIdx: index('idx_quest_progress_status').on(table.playerId, table.status),
}));
Key Features:
  • UNIQUE constraint on (playerId, questId) prevents duplicate progress entries
  • CASCADE DELETE cleans up on character deletion
  • stageProgress JSONB stores flexible progress data (e.g., {"kills": 7, "copper_ore": 3})
  • status values: not_started, in_progress, completed
ready_to_complete is a derived status computed by QuestSystem when status === "in_progress" AND the current stage objective is met. It’s not stored in the database.

quest_audit_log

Immutable audit trail for security and analytics:
export const questAuditLog = pgTable('quest_audit_log', {
  id: serial('id').primaryKey(),
  playerId: text('playerId').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  questId: text('questId').notNull(),
  action: text('action').notNull(),  // "started", "progressed", "completed"
  questPointsAwarded: integer('questPointsAwarded').default(0),
  stageId: text('stageId'),
  stageProgress: jsonb('stageProgress').default({}),
  timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
  metadata: jsonb('metadata').default({}),
}, (table) => ({
  playerIdx: index('idx_quest_audit_log_player').on(table.playerId),
  questIdx: index('idx_quest_audit_log_quest').on(table.questId),
  playerQuestIdx: index('idx_quest_audit_log_player_quest').on(table.playerId, table.questId),
  timestampIdx: index('idx_quest_audit_log_timestamp').on(table.timestamp),
  actionIdx: index('idx_quest_audit_log_action').on(table.action),
}));
Use Cases:
  • Fraud detection and investigation
  • Debugging quest progression bugs
  • Analytics for game design
  • Customer support inquiries

Repository Pattern

All database access goes through typed repositories:
// From repositories/CharacterRepository.ts
export class CharacterRepository {
  constructor(private db: DrizzleDB) {}

  async findById(id: string): Promise<Character | null> {
    const result = await this.db
      .select()
      .from(characters)
      .where(eq(characters.id, id))
      .limit(1);
    return result[0] || null;
  }

  async findByAccountId(accountId: string): Promise<Character[]> {
    return this.db
      .select()
      .from(characters)
      .where(eq(characters.accountId, accountId));
  }

  async create(data: NewCharacter): Promise<Character> {
    const [character] = await this.db
      .insert(characters)
      .values({
        ...data,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      })
      .returning();
    return character;
  }

  async updatePosition(id: string, position: Position3D): Promise<void> {
    await this.db
      .update(characters)
      .set({
        positionX: position.x,
        positionY: position.y,
        positionZ: position.z,
        updatedAt: Date.now(),
      })
      .where(eq(characters.id, id));
  }

  async updateStats(id: string, stats: CharacterStats): Promise<void> {
    await this.db
      .update(characters)
      .set({
        stats,
        updatedAt: Date.now(),
      })
      .where(eq(characters.id, id));
  }
}

Data Types

Timestamps

All timestamps use bigint storing Unix milliseconds for precision:
// Store
const timestamp = Date.now(); // e.g., 1704067200000

// Retrieve
const date = new Date(timestamp);

Positions

Positions use real (float) for sub-tile precision:
// Tile center is (x + 0.5, z + 0.5)
positionX: 125.5,
positionY: 10.0,
positionZ: -42.5,

IDs

All IDs are text storing UUIDs as strings:
import { uuid } from '@hyperscape/shared';

const id = uuid(); // "550e8400-e29b-41d4-a716-446655440000"

Migrations

Drizzle handles migrations automatically. Run from packages/server/:
# Generate migration from schema changes
bunx drizzle-kit generate

# Apply migrations
bunx drizzle-kit migrate

# Push schema directly (dev only)
bunx drizzle-kit push

Recent Migrations

MigrationDescription
0021_add_quest_audit_log.sqlAdded quest audit logging table
0020_add_trading_activity_bans.sqlAdded trading, activity log, and user bans tables
0019_add_quest_progress.sqlAdded quest progression tracking
0018_add_agility_skill.sqlAdded agilityLevel and agilityXp columns
0016_add_prayer_system.sqlAdded prayer points and active prayers
0015_add_smithing_skill.sqlAdded smithingLevel and smithingXp columns
0014_add_mining_skill.sqlAdded miningLevel and miningXp columns
0013_add_auto_retaliate.sqlAdded auto-retaliate preference
0012_add_bank_placeholders.sqlAdded bank placeholder system
0011_add_bank_tabs.sqlAdded custom bank tabs
0010_add_player_deaths.sqlAdded death tracking
Migrations are located in packages/server/src/database/migrations/.

Quest System Tables

quest_progress

Tracks player quest progression:
export const questProgress = pgTable('quest_progress', {
  id: serial('id').primaryKey(),
  playerId: text('playerId').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  questId: text('questId').notNull(),
  status: text('status').default('not_started').notNull(),
  currentStage: text('currentStage'),
  stageProgress: jsonb('stageProgress').default({}),
  startedAt: bigint('startedAt', { mode: 'number' }),
  completedAt: bigint('completedAt', { mode: 'number' }),
}, (table) => ({
  uniquePlayerQuest: unique().on(table.playerId, table.questId),
  playerIdx: index('idx_quest_progress_player').on(table.playerId),
  statusIdx: index('idx_quest_progress_status').on(table.playerId, table.status),
}));
Status Values:
  • not_started — Quest not yet started
  • in_progress — Quest active, objectives incomplete
  • completed — Quest finished, rewards claimed
Note: ready_to_complete is a derived status (computed when status === "in_progress" AND current stage objective is met). Stage Progress Format:
{
  "kills": 7,              // Kill stage progress
  "copper_ore": 3,         // Gather stage progress (by item ID)
  "fire_created": 1        // Interact stage progress
}

quest_audit_log

Immutable audit trail for quest actions:
export const questAuditLog = pgTable('quest_audit_log', {
  id: serial('id').primaryKey(),
  playerId: text('playerId').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  questId: text('questId').notNull(),
  action: text('action').notNull(),  // "started", "progressed", "completed"
  questPointsAwarded: integer('questPointsAwarded').default(0),
  stageId: text('stageId'),
  stageProgress: jsonb('stageProgress').default({}),
  timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
  metadata: jsonb('metadata').default({}),
}, (table) => ({
  playerIdx: index('idx_quest_audit_log_player').on(table.playerId),
  questIdx: index('idx_quest_audit_log_quest').on(table.questId),
  timestampIdx: index('idx_quest_audit_log_timestamp').on(table.timestamp),
  actionIdx: index('idx_quest_audit_log_action').on(table.action),
}));
Use Cases:
  • Fraud detection and investigation
  • Debugging quest progression bugs
  • Analytics for game design
  • Customer support inquiries

Activity & Moderation Tables

activity_log

Tracks player actions for admin panel:
export const activityLog = pgTable('activity_log', {
  id: serial('id').primaryKey(),
  playerId: text('playerId').notNull()
    .references(() => characters.id, { onDelete: 'cascade' }),
  eventType: text('eventType').notNull(),
  action: text('action').notNull(),
  entityType: text('entityType'),
  entityId: text('entityId'),
  details: jsonb('details').default({}),
  position: jsonb('position'),
  timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
}, (table) => ({
  playerIdx: index('idx_activity_log_player').on(table.playerId),
  timestampIdx: index('idx_activity_log_timestamp').on(table.timestamp),
  eventTypeIdx: index('idx_activity_log_event_type').on(table.eventType),
}));

trades

Trade transaction history:
export const trades = pgTable('trades', {
  id: serial('id').primaryKey(),
  initiatorId: text('initiatorId')
    .references(() => characters.id, { onDelete: 'set null' }),
  receiverId: text('receiverId')
    .references(() => characters.id, { onDelete: 'set null' }),
  status: text('status').notNull(),  // "pending", "accepted", "declined", "completed"
  initiatorItems: jsonb('initiatorItems').default([]),
  receiverItems: jsonb('receiverItems').default([]),
  initiatorCoins: integer('initiatorCoins').default(0),
  receiverCoins: integer('receiverCoins').default(0),
  timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
}, (table) => ({
  initiatorIdx: index('idx_trades_initiator').on(table.initiatorId),
  receiverIdx: index('idx_trades_receiver').on(table.receiverId),
  timestampIdx: index('idx_trades_timestamp').on(table.timestamp),
}));

user_bans

Moderation ban records:
export const userBans = pgTable('user_bans', {
  id: serial('id').primaryKey(),
  bannedUserId: text('bannedUserId').notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  bannedByUserId: text('bannedByUserId').notNull()
    .references(() => users.id, { onDelete: 'set null' }),
  reason: text('reason'),
  expiresAt: bigint('expiresAt', { mode: 'number' }),
  createdAt: bigint('createdAt', { mode: 'number' }).notNull(),
  active: integer('active').default(1).notNull(),
}, (table) => ({
  bannedUserIdx: index('idx_user_bans_banned_user').on(table.bannedUserId),
  activeIdx: index('idx_user_bans_active').on(table.active),
  activeBannedIdx: index('idx_user_bans_active_banned').on(table.active, table.bannedUserId),
}));