drizzle-game-schema
Drizzle ORM database schema patterns for games including player profiles, inventories, leaderboards, game sessions, and achievements. Use when designing database schemas, creating migrations, optimizing queries, or implementing save systems. Triggers on requests for game database design, Drizzle schemas, player data storage, or leaderboard systems.
When & Why to Use This Skill
This Claude skill provides production-ready Drizzle ORM database schema patterns specifically optimized for game development. It streamlines the creation of complex persistence layers, covering essential game mechanics such as player profiles, multi-currency inventories, game session saves, and high-performance leaderboards using SQLite and Turso.
Use Cases
- Architecting a robust player profile system that tracks levels, experience, and real-time activity stats.
- Implementing transaction-safe inventory and currency systems to handle item acquisitions and balance updates.
- Designing game session 'save state' schemas that support serialized game data and automated play-duration tracking.
- Developing scalable leaderboard systems with optimized indexing for daily, weekly, or all-time global rankings.
- Setting up automated database migration workflows and schema synchronization for rapid game prototyping.
| name | drizzle-game-schema |
|---|---|
| description | Drizzle ORM database schema patterns for games including player profiles, inventories, leaderboards, game sessions, and achievements. Use when designing database schemas, creating migrations, optimizing queries, or implementing save systems. Triggers on requests for game database design, Drizzle schemas, player data storage, or leaderboard systems. |
Drizzle Game Schema
Production-ready database patterns for game persistence using Drizzle ORM with SQLite/Turso.
Schema Organization
packages/db/src/schema/
├── index.ts # Export all schemas
├── auth.ts # Better-Auth tables
├── player.ts # Player profiles, stats
├── inventory.ts # Items, currencies
├── game-session.ts # Active game state
├── progression.ts # Achievements, unlocks
└── social.ts # Leaderboards, friends
Core Schemas
Player Profile
// schema/player.ts
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { user } from './auth';
export const playerProfile = sqliteTable('player_profile', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
// Display
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
// Stats
totalScore: integer('total_score').notNull().default(0),
highScore: integer('high_score').notNull().default(0),
gamesPlayed: integer('games_played').notNull().default(0),
totalPlayTime: integer('total_play_time').notNull().default(0), // seconds
// Progression
level: integer('level').notNull().default(1),
experience: integer('experience').notNull().default(0),
// Timestamps
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
lastActiveAt: integer('last_active_at', { mode: 'timestamp' }),
});
// Indexes for common queries
export const playerProfileIndexes = {
byUserId: sqliteIndex('player_by_user').on(playerProfile.userId),
byScore: sqliteIndex('player_by_score').on(playerProfile.totalScore),
byLevel: sqliteIndex('player_by_level').on(playerProfile.level),
};
Inventory & Currencies
// schema/inventory.ts
import { sqliteTable, text, integer, real, primaryKey } from 'drizzle-orm/sqlite-core';
export const playerCurrency = sqliteTable('player_currency', {
playerId: text('player_id').notNull().references(() => playerProfile.id),
currencyType: text('currency_type').notNull(), // 'coins', 'tulipBulbs', 'seeds'
amount: integer('amount').notNull().default(0),
lifetimeEarned: integer('lifetime_earned').notNull().default(0),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.playerId, table.currencyType] }),
}));
export const inventoryItem = sqliteTable('inventory_item', {
id: text('id').primaryKey(),
playerId: text('player_id').notNull().references(() => playerProfile.id),
itemType: text('item_type').notNull(), // 'simulin', 'seed', 'decoration'
itemId: text('item_id').notNull(), // Reference to item definition
quantity: integer('quantity').notNull().default(1),
// Item state (for unique items like Simulins)
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown>>(),
acquiredAt: integer('acquired_at', { mode: 'timestamp' }).notNull(),
equippedSlot: text('equipped_slot'), // null if not equipped
});
export const inventoryIndexes = {
byPlayer: sqliteIndex('inv_by_player').on(inventoryItem.playerId),
byType: sqliteIndex('inv_by_type').on(inventoryItem.playerId, inventoryItem.itemType),
};
Game Session (Save State)
// schema/game-session.ts
import { sqliteTable, text, integer, blob } from 'drizzle-orm/sqlite-core';
export const gameSession = sqliteTable('game_session', {
id: text('id').primaryKey(),
playerId: text('player_id').notNull().references(() => playerProfile.id),
// Session metadata
sessionType: text('session_type').notNull(), // 'campaign', 'daily', 'event'
status: text('status').notNull().default('active'), // 'active', 'completed', 'abandoned'
// Game state (serialized)
gameState: text('game_state', { mode: 'json' }).$type<{
time: { season: number; day: number; phase: string };
resources: { tulipBulbs: number; coins: number; stamina: number };
hexes: Record<string, unknown>;
troubles: Record<string, unknown>;
score: number;
}>(),
// Metrics
startedAt: integer('started_at', { mode: 'timestamp' }).notNull(),
lastSavedAt: integer('last_saved_at', { mode: 'timestamp' }).notNull(),
completedAt: integer('completed_at', { mode: 'timestamp' }),
playDuration: integer('play_duration').notNull().default(0), // seconds
// Results (when completed)
finalScore: integer('final_score'),
rewards: text('rewards', { mode: 'json' }).$type<{
coins: number;
experience: number;
items: string[];
}>(),
});
Achievements & Unlocks
// schema/progression.ts
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
export const achievement = sqliteTable('achievement', {
id: text('id').primaryKey(),
name: text('name').notNull(),
description: text('description').notNull(),
category: text('category').notNull(), // 'farming', 'gambling', 'social'
// Requirements
requirement: text('requirement', { mode: 'json' }).$type<{
type: string;
target: number;
conditions?: Record<string, unknown>;
}>(),
// Rewards
rewardCoins: integer('reward_coins').default(0),
rewardExp: integer('reward_exp').default(0),
rewardItem: text('reward_item'),
// Display
iconUrl: text('icon_url'),
rarity: text('rarity').default('common'), // common, rare, epic, legendary
hidden: integer('hidden', { mode: 'boolean' }).default(false),
});
export const playerAchievement = sqliteTable('player_achievement', {
playerId: text('player_id').notNull().references(() => playerProfile.id),
achievementId: text('achievement_id').notNull().references(() => achievement.id),
progress: integer('progress').notNull().default(0),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
completedAt: integer('completed_at', { mode: 'timestamp' }),
claimed: integer('claimed', { mode: 'boolean' }).notNull().default(false),
}, (table) => ({
pk: primaryKey({ columns: [table.playerId, table.achievementId] }),
}));
export const unlock = sqliteTable('player_unlock', {
playerId: text('player_id').notNull().references(() => playerProfile.id),
unlockType: text('unlock_type').notNull(), // 'simulin', 'table', 'cosmetic'
unlockId: text('unlock_id').notNull(),
unlockedAt: integer('unlocked_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.playerId, table.unlockType, table.unlockId] }),
}));
Leaderboards
// schema/social.ts
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const leaderboardEntry = sqliteTable('leaderboard_entry', {
id: text('id').primaryKey(),
playerId: text('player_id').notNull().references(() => playerProfile.id),
boardType: text('board_type').notNull(), // 'daily', 'weekly', 'alltime', 'season_1'
period: text('period').notNull(), // '2026-01-10', '2026-W02', 'alltime'
score: integer('score').notNull(),
rank: integer('rank'), // Computed/cached
metadata: text('metadata', { mode: 'json' }).$type<{
gamesPlayed?: number;
bestCombo?: number;
favoriteTable?: string;
}>(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
scoreIdx: index('lb_score').on(table.boardType, table.period, table.score),
playerIdx: index('lb_player').on(table.playerId, table.boardType),
}));
Common Queries
Get Player with Stats
import { eq } from 'drizzle-orm';
async function getPlayerWithStats(userId: string) {
return db.query.playerProfile.findFirst({
where: eq(playerProfile.userId, userId),
with: {
currencies: true,
achievements: {
where: eq(playerAchievement.completed, true),
},
},
});
}
Update Currency (Transaction)
async function addCurrency(
playerId: string,
type: string,
amount: number
) {
return db.transaction(async (tx) => {
const current = await tx.query.playerCurrency.findFirst({
where: and(
eq(playerCurrency.playerId, playerId),
eq(playerCurrency.currencyType, type)
),
});
if (current) {
await tx.update(playerCurrency)
.set({
amount: current.amount + amount,
lifetimeEarned: amount > 0
? current.lifetimeEarned + amount
: current.lifetimeEarned,
updatedAt: new Date(),
})
.where(and(
eq(playerCurrency.playerId, playerId),
eq(playerCurrency.currencyType, type)
));
} else {
await tx.insert(playerCurrency).values({
playerId,
currencyType: type,
amount: Math.max(0, amount),
lifetimeEarned: Math.max(0, amount),
updatedAt: new Date(),
});
}
});
}
Leaderboard Query
async function getLeaderboard(
boardType: string,
period: string,
limit = 100
) {
return db.select({
rank: leaderboardEntry.rank,
score: leaderboardEntry.score,
player: {
id: playerProfile.id,
displayName: playerProfile.displayName,
avatarUrl: playerProfile.avatarUrl,
level: playerProfile.level,
},
})
.from(leaderboardEntry)
.innerJoin(playerProfile, eq(leaderboardEntry.playerId, playerProfile.id))
.where(and(
eq(leaderboardEntry.boardType, boardType),
eq(leaderboardEntry.period, period)
))
.orderBy(desc(leaderboardEntry.score))
.limit(limit);
}
Save Game State
async function saveGameState(
sessionId: string,
gameState: GameState
) {
await db.update(gameSession)
.set({
gameState: {
time: gameState.time,
resources: gameState.resources,
hexes: Object.fromEntries(gameState.hexes),
troubles: gameState.troubles,
score: gameState.score,
},
lastSavedAt: new Date(),
playDuration: sql`play_duration + ${AUTOSAVE_INTERVAL}`,
})
.where(eq(gameSession.id, sessionId));
}
Migration Workflow
# Generate migration from schema changes
bun run db:generate
# Apply migrations to database
bun run db:migrate
# Push schema directly (dev only)
bun run db:push
# Open Drizzle Studio
bun run db:studio
Performance Tips
- Index frequently filtered columns: playerId, timestamps, scores
- Use JSON columns sparingly: Good for flexible data, bad for querying
- Batch inserts: Use
insert().values([...])for bulk operations - Denormalize carefully: Store computed ranks, not just scores
- Archive old data: Move completed sessions to archive tables