🎯

game-database

🎯Skill

from fil512/upship

VibeIndex|
What it does

game-database skill from fil512/upship

game-database

Installation

Install skill:
npx skills add https://github.com/fil512/upship --skill game-database
1
AddedJan 27, 2026

Skill Details

SKILL.md

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.

Overview

# 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

```sql

-- 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:

```javascript

// 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

```sql

-- 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

```javascript

// 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

```javascript

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:

```javascript

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

```sql

-- 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);

```

```javascript

// 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

```javascript

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:

```javascript

// 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);

};

```

```javascript

// 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

```javascript

// 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

```javascript

// 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