| name | game-database |
| description | Database design for multiplayer games with PostgreSQL. Use when designing schemas, handling game persistence, managing user accounts, implementing lobbies, or optimizing queries. Covers JSONB for game state and relational patterns. |
Game Database Skill
Overview
This skill provides expertise for designing and implementing database schemas for multiplayer turn-based games. It covers PostgreSQL patterns, game state persistence, user management, and the hybrid approach of relational tables with JSONB for complex game state.
Database Selection
Why PostgreSQL for Games
- JSONB support: Store complex game state as JSON while keeping it queryable
- ACID transactions: Critical for game state consistency
- Row-level locking: Handle concurrent updates safely
- Railway integration: Easy deployment and management
- Mature ecosystem: Excellent Node.js support via pg or Prisma
Schema Design Patterns
Core Tables
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100),
avatar_url VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
last_login TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
-- Games table (lobby + active games)
CREATE TABLE games (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
status VARCHAR(20) DEFAULT 'waiting', -- waiting, in_progress, completed, abandoned
host_id UUID REFERENCES users(id),
min_players INTEGER DEFAULT 2,
max_players INTEGER DEFAULT 4,
settings JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
started_at TIMESTAMP,
completed_at TIMESTAMP,
CONSTRAINT valid_status CHECK (status IN ('waiting', 'in_progress', 'completed', 'abandoned'))
);
-- Game players (join table)
CREATE TABLE game_players (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
game_id UUID REFERENCES games(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
faction VARCHAR(50),
seat_position INTEGER,
joined_at TIMESTAMP DEFAULT NOW(),
is_ready BOOLEAN DEFAULT false,
UNIQUE(game_id, user_id),
UNIQUE(game_id, seat_position)
);
-- Game state (the actual game data)
CREATE TABLE game_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
game_id UUID REFERENCES games(id) ON DELETE CASCADE UNIQUE,
version INTEGER DEFAULT 1,
current_player_id UUID REFERENCES users(id),
phase VARCHAR(50),
turn_number INTEGER DEFAULT 1,
age INTEGER DEFAULT 1,
state JSONB NOT NULL, -- The full game state
updated_at TIMESTAMP DEFAULT NOW()
);
-- Action history (for replay/undo)
CREATE TABLE game_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
game_id UUID REFERENCES games(id) ON DELETE CASCADE,
player_id UUID REFERENCES users(id),
action_type VARCHAR(50) NOT NULL,
action_data JSONB NOT NULL,
state_version INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes for common queries
CREATE INDEX idx_games_status ON games(status);
CREATE INDEX idx_games_host ON games(host_id);
CREATE INDEX idx_game_players_user ON game_players(user_id);
CREATE INDEX idx_game_players_game ON game_players(game_id);
CREATE INDEX idx_game_actions_game ON game_actions(game_id);
CREATE INDEX idx_game_states_game ON game_states(game_id);
JSONB for Game State
Store complex, nested game state as JSONB:
// Example game state stored in game_states.state
{
"players": {
"uuid-1": {
"money": 15,
"income": 5,
"pilots": 2,
"engineers": 3,
"technologies": ["tech-1", "tech-2"],
"blueprint": {
"slots": {
"frame-1": { "upgradeId": "upg-1" },
"drive-1": { "upgradeId": null }
}
},
"hangar": {
"launch": ["ship-1"],
"repair": []
}
}
},
"board": {
"locations": {
"construction-hall": { "workers": ["w1", "w2"] }
},
"rdBoard": {
"available": ["tech-3", "tech-4", "tech-5"]
}
},
"progress": 8,
"market": {
"heliumPrice": 5,
"visibleCards": ["card-1", "card-2", "card-3"]
}
}
Querying JSONB
-- Find games where a specific player has more than 20 money
SELECT g.id, g.name
FROM games g
JOIN game_states gs ON g.id = gs.game_id
WHERE gs.state->'players'->>'uuid-1'->>'money' > '20';
-- Find all technologies owned by a player
SELECT gs.state->'players'->'uuid-1'->'technologies' as techs
FROM game_states gs
WHERE gs.game_id = 'game-uuid';
-- Update a specific field in JSONB
UPDATE game_states
SET state = jsonb_set(
state,
'{players,uuid-1,money}',
to_jsonb((state->'players'->'uuid-1'->>'money')::int + 10)
)
WHERE game_id = 'game-uuid';
Data Access Patterns
Repository Pattern
// gameRepository.js
const { pool } = require('./db');
const gameRepository = {
async create(hostId, name, settings = {}) {
const result = await pool.query(`
INSERT INTO games (host_id, name, settings)
VALUES ($1, $2, $3)
RETURNING *
`, [hostId, name, settings]);
return result.rows[0];
},
async findById(gameId) {
const result = await pool.query(`
SELECT g.*, gs.state, gs.version
FROM games g
LEFT JOIN game_states gs ON g.id = gs.game_id
WHERE g.id = $1
`, [gameId]);
return result.rows[0];
},
async findWaitingGames() {
const result = await pool.query(`
SELECT g.*, COUNT(gp.id) as player_count
FROM games g
LEFT JOIN game_players gp ON g.id = gp.game_id
WHERE g.status = 'waiting'
GROUP BY g.id
ORDER BY g.created_at DESC
`);
return result.rows;
},
async updateState(gameId, newState, newVersion) {
const result = await pool.query(`
UPDATE game_states
SET state = $2, version = $3, updated_at = NOW()
WHERE game_id = $1 AND version = $3 - 1
RETURNING *
`, [gameId, newState, newVersion]);
if (result.rows.length === 0) {
throw new Error('Optimistic lock failed - state was modified');
}
return result.rows[0];
}
};
Transaction Handling
async function processGameAction(gameId, playerId, action) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Lock the game state row
const stateResult = await client.query(`
SELECT * FROM game_states
WHERE game_id = $1
FOR UPDATE
`, [gameId]);
const currentState = stateResult.rows[0];
// Validate and apply action
const validation = validateAction(currentState.state, action);
if (!validation.valid) {
await client.query('ROLLBACK');
return { success: false, error: validation.reason };
}
const newState = applyAction(currentState.state, action);
const newVersion = currentState.version + 1;
// Save new state
await client.query(`
UPDATE game_states
SET state = $1, version = $2, updated_at = NOW()
WHERE game_id = $3
`, [newState, newVersion, gameId]);
// Record action in history
await client.query(`
INSERT INTO game_actions (game_id, player_id, action_type, action_data, state_version)
VALUES ($1, $2, $3, $4, $5)
`, [gameId, playerId, action.type, action, newVersion]);
await client.query('COMMIT');
return { success: true, newState, version: newVersion };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Optimistic Locking
Prevent concurrent modification conflicts:
async function updateGameState(gameId, newState, expectedVersion) {
const result = await pool.query(`
UPDATE game_states
SET
state = $2,
version = version + 1,
updated_at = NOW()
WHERE game_id = $1 AND version = $3
RETURNING version
`, [gameId, newState, expectedVersion]);
if (result.rows.length === 0) {
// Version mismatch - someone else updated first
throw new OptimisticLockError('State was modified by another request');
}
return result.rows[0].version;
}
User Session Management
-- Sessions table
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
ip_address INET,
user_agent TEXT
);
CREATE INDEX idx_sessions_token ON sessions(token);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
// Session repository
const sessionRepository = {
async create(userId, token, expiresAt) {
await pool.query(`
INSERT INTO sessions (user_id, token, expires_at)
VALUES ($1, $2, $3)
`, [userId, token, expiresAt]);
},
async findByToken(token) {
const result = await pool.query(`
SELECT s.*, u.username, u.display_name
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = $1 AND s.expires_at > NOW()
`, [token]);
return result.rows[0];
},
async deleteExpired() {
await pool.query(`DELETE FROM sessions WHERE expires_at < NOW()`);
}
};
Game Lobby Queries
const lobbyRepository = {
// Get games available to join
async getAvailableGames() {
return pool.query(`
SELECT
g.id,
g.name,
g.settings,
g.max_players,
g.created_at,
u.display_name as host_name,
COUNT(gp.id) as current_players,
array_agg(json_build_object(
'id', pu.id,
'name', pu.display_name,
'faction', gp.faction
)) as players
FROM games g
JOIN users u ON g.host_id = u.id
LEFT JOIN game_players gp ON g.id = gp.game_id
LEFT JOIN users pu ON gp.user_id = pu.id
WHERE g.status = 'waiting'
GROUP BY g.id, u.display_name
HAVING COUNT(gp.id) < g.max_players
ORDER BY g.created_at DESC
`);
},
// Get player's active games
async getPlayerGames(userId) {
return pool.query(`
SELECT g.*, gs.phase, gs.current_player_id
FROM games g
JOIN game_players gp ON g.id = gp.game_id
LEFT JOIN game_states gs ON g.id = gs.game_id
WHERE gp.user_id = $1
AND g.status IN ('waiting', 'in_progress')
ORDER BY g.created_at DESC
`, [userId]);
}
};
Database Migrations
Use a migration system for schema changes:
// migrations/001_initial_schema.js
exports.up = async (client) => {
await client.query(`
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
-- ... rest of schema
);
`);
};
exports.down = async (client) => {
await client.query(`DROP TABLE IF EXISTS users CASCADE`);
};
// Simple migration runner
async function runMigrations() {
const client = await pool.connect();
await client.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
run_at TIMESTAMP DEFAULT NOW()
)
`);
const migrations = require('./migrations');
for (const [name, migration] of Object.entries(migrations)) {
const existing = await client.query(
'SELECT id FROM migrations WHERE name = $1',
[name]
);
if (existing.rows.length === 0) {
console.log(`Running migration: ${name}`);
await migration.up(client);
await client.query(
'INSERT INTO migrations (name) VALUES ($1)',
[name]
);
}
}
client.release();
}
Connection Pooling
// db.js
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Max connections in pool
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 2000
});
// Health check
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
async function healthCheck() {
const client = await pool.connect();
try {
await client.query('SELECT 1');
return true;
} finally {
client.release();
}
}
module.exports = { pool, healthCheck };
Railway PostgreSQL Setup
// Railway provides DATABASE_URL automatically
const connectionString = process.env.DATABASE_URL;
// For SSL in production
const pool = new Pool({
connectionString,
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: false }
: false
});
When This Skill Activates
Use this skill when:
- Designing database schemas for games
- Implementing game state persistence
- Building user authentication storage
- Creating lobby/matchmaking queries
- Handling concurrent state updates
- Setting up migrations
- Optimizing database queries