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.
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."
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
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
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.
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
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.
When building collaborative features, action IDs and optimistic updates with rollback are essential. Race conditions are inevitable in real-time systems.
AI APIs are unpredictable. Implement retry logic with escalating parameters, validate all responses, and always have fallback strategies.
Discord notifications, Notion syncing, and AI generation all run in background jobs. This keeps the user interface responsive and provides reliability through retry mechanisms.
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.
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.