NYC
skills/smithery/ai/game-database

game-database

SKILL.md

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
Weekly Installs
2
Repository
smithery/ai
First Seen
10 days ago
Installed on
claude-code2