Back to Portfolio

Building a Modern RPG Management System

From JSON Columns to Real-Time WebSockets
Ruby on Rails 8.0
Next.js 15
PostgreSQL
Redis
WebSockets
AI Integration

Building a comprehensive tabletop RPG management system requires balancing complex game mechanics with modern web development patterns. This article explores the architecture and implementation details of Chi-War, a full-stack application for managing Feng Shui 2 RPG campaigns, showcasing how to handle domain-specific logic, real-time collaboration, and AI integration in a production environment.

System Overview

Chi-War is a monorepo application consisting of:

Backend: Ruby on Rails 8.0 API with PostgreSQL and Redis
Frontend: Next.js 15 with TypeScript and Material-UI
Real-time: ActionCable WebSockets for collaborative features
Integrations: Discord bot, Notion sync, AI character generation

The application manages the unique mechanics of Feng Shui 2, including its "shot counter" combat system, character archetypes, and time-traveling "junctures."

Complex Domain Modeling with JSON Columns

One of the most interesting aspects of the backend is how it handles RPG character data using PostgreSQL JSON columns. Rather than creating dozens of database columns for every possible character attribute, the system uses structured JSON with intelligent defaults:

# app/models/character.rb
class Character < ApplicationRecord
  DEFAULT_ACTION_VALUES = {
    "Guns" => 0,
    "Martial Arts" => 0,
    "Sorcery" => 0,
    "Defense" => 0,
    "Toughness" => 0,
    "Speed" => 0,
    "Fortune" => 0,
    "Type" => "PC",
    "Archetype" => "",
    "Damage" => 0,
  }

  before_save :ensure_default_action_values
  before_save :ensure_integer_action_values

  private

  def ensure_default_action_values
    self.action_values ||= {}
    self.action_values = DEFAULT_ACTION_VALUES.merge(self.action_values)
  end
end

This approach provides several advantages:

Flexibility: Easy to add new character attributes without migrations
Type Safety: Default constants ensure consistent structure
Validation: Before-save callbacks maintain data integrity
Performance: Single JSON column vs. dozens of separate columns

AI-Powered Character Generation

The AI service demonstrates production-ready AI integration with robust error handling and domain-specific prompt engineering:

# app/services/ai_service.rb
class AiService
  def generate_character(description, campaign)
    prompt = build_prompt(description, campaign)
    max_retries = 3
    retry_count = 0
    max_tokens = 1000

    begin
      response = grok.send_request(prompt, max_tokens)
      if response['choices'] && response['choices'].any?
        content = response.dig("choices", 0, "message", "content")
        json = JSON.parse(content)
        return json if valid_json?(json)
        raise "Invalid JSON structure"
      end
    rescue JSON::ParserError, StandardError => e
      Rails.logger.error("Error generating character: #{e.message}")
      retry_count += 1
      if retry_count <= max_retries
        max_tokens += 1024  # Increase token limit on retry
        retry
      else
        raise "Failed after #{max_retries} retries: #{e.message}"
      end
    end
  end
end

Key features of this implementation:

Retry Logic: Automatically retries with increased token limits
Context Awareness: Incorporates campaign factions and junctures
Validation: Ensures generated JSON meets schema requirements
Domain Knowledge: Understands RPG mechanics and character types

RPG-Specific Dice Rolling Service

The dice rolling service implements Feng Shui 2's unique "exploding dice" mechanic where rolling a 6 triggers additional rolls:

# app/services/dice_roller.rb
module DiceRoller
  def exploding_die_roll
    rolls = []
    roll = die_roll
    rolls << roll
    until (roll != 6)
      result = exploding_die_roll
      roll = result[:sum]
      rolls << result[:rolls].flatten
    end
    {
      sum: rolls.flatten.sum,
      rolls: rolls.flatten
    }
  end

  def swerve
    positives = exploding_die_roll
    negatives = exploding_die_roll
    boxcars = positives[:rolls][0] == 6 && negatives[:rolls][0] == 6

    {
      positives: positives,
      negatives: negatives,
      total: positives[:sum] - negatives[:sum],
      boxcars: boxcars,
      rolled_at: DateTime.now
    }
  end
end

This service demonstrates how to implement domain-specific business logic while maintaining clean, testable code.

Real-Time Collaboration

The fight channel demonstrates scalable real-time architecture with presence tracking:

# app/channels/fight_channel.rb
class FightChannel < ApplicationCable::Channel
  def subscribed
    fight_id = params[:fight_id]
    stream_from "fight_#{fight_id}"

    # Store user presence in Redis
    redis_key = "fight:#{fight_id}:users"
    redis.sadd(redis_key, current_user.id)
    redis.expire(redis_key, 24 * 60 * 60) # 24-hour TTL

    broadcast_user_list(fight_id)
  end

  def unsubscribed
    fight_id = params[:fight_id]
    redis_key = "fight:#{fight_id}:users"
    redis.srem(redis_key, current_user.id)
    broadcast_user_list(fight_id)
  end

  private

  def broadcast_user_list(fight_id)
    redis_key = "fight:#{fight_id}:users"
    user_ids = redis.smembers(redis_key)
    users = User.where(id: user_ids).map do |user|
      { id: user.id, name: user.name, image_url: user.image_url }
    end
    
    ActionCable.server.broadcast("fight_#{fight_id}", { users: users })
  end
end

This channel provides:

Automatic Presence Tracking: Redis-based user presence with TTL cleanup
Real-time User Lists: Live updates of who's currently viewing
Scalable Architecture: Uses Redis for horizontal scaling

Lessons Learned

1. JSON Columns for Complex Domain Models

PostgreSQL JSON columns proved excellent for RPG data where the schema needs flexibility but still requires structure. The key is maintaining type safety through default constants and validation callbacks.

2. Real-Time Architecture Requires Conflict Resolution

When building collaborative features, action IDs and optimistic updates with rollback are essential. Race conditions are inevitable in real-time systems.

3. AI Integration Needs Robust Error Handling

AI APIs are unpredictable. Implement retry logic with escalating parameters, validate all responses, and always have fallback strategies.

4. External Integrations Should Be Asynchronous

Discord notifications, Notion syncing, and AI generation all run in background jobs. This keeps the user interface responsive and provides reliability through retry mechanisms.

5. Domain-Specific Logic Belongs in Services

Complex business rules like dice rolling, character generation, and combat mechanics are best encapsulated in dedicated service classes that can be easily tested and reused.

Conclusion

Building Chi-War taught valuable lessons about balancing domain complexity with technical architecture. The combination of flexible JSON storage, real-time WebSocket communication, AI integration, and external service orchestration creates a powerful platform for tabletop RPG management.

The key to success was understanding that RPG applications have unique requirements: complex, flexible data models; real-time collaboration; integration with existing tools; and AI-assisted content generation. By focusing on these domain-specific needs while applying modern web development patterns, we created a system that serves both technical and user requirements effectively.

The full source code demonstrates these patterns in action, showing how to build sophisticated web applications that bridge the gap between tabletop gaming and modern digital tools.

Backend CodeFrontend Code