Game Development

Building an RPG with Rails & Hotwire: A Beginner's Journey Through the Mystic Archipelago

From Final Fantasy dreams to Ruby on Rails reality - the unexpected path of building a web-based RPG

Introduction

As a game developer who grew up playing Final Fantasy and Pokemon Yellow, I never imagined I'd be building an RPG with Ruby on Rails. Most game developers reach for Unity, Godot, or even raw JavaScript. But here I was, a Rails enthusiast wondering: "Can I build a full RPG experience using Rails and Hotwire?"

The answer is yes - with some significant caveats and creative solutions.

Mystic Archipelago is a Philippine-themed RPG built entirely with Rails 8.0.2, Hotwire (Turbo + Stimulus), and Tailwind CSS. It features turn-based combat, a party system, 200+ items, 100+ enemies, dungeon crawling, and a complete quest system. This is the story of the hurdles I faced and how I overcame them.

Hurdle 1: Real-time Combat Without Page Reloads

The Challenge

Traditional Rails apps reload the entire page with each request. For an RPG combat system, this creates a terrible user experience. Players expect smooth animations, damage numbers floating up, and seamless turn transitions.

The Solution

I leveraged Turbo's ability to update specific parts of the page while adding custom JavaScript animations:

# app/controllers/combats_controller.rb
def attack
  @combat = current_user.character.current_combat

  # Store animation data in session
  animation_data = {
    player_action: "attack",
    player_damage: character_log&.damage || 0,
    player_critical: character_log&.description&.include?("Critical hit!"),
    combat_continues: @combat.active?,
    turn_phase: "player"
  }

  session[:combat_animation] = animation_data
  redirect_to combat_path(@combat)
end

Then in the view, I read this animation data and trigger appropriate CSS animations:

<!-- app/views/combats/show.html.erb -->
<% if @animation_data&.dig(:turn_phase) == 'enemy' && @animation_data[:enemy_damage] > 0 %>
  <div class="floating-number damage-number <%= 'critical-number' if @animation_data[:enemy_critical] %>" 
       style="top: 20px; right: 20px;">
    -<%= @animation_data[:enemy_damage] %>
  </div>
<% end %>

The key insight: Use server-side state to drive client-side animations, not the other way around.

Hurdle 2: Complex State Management

The Challenge

An RPG character has massive state: HP, MP, 6 core stats, equipment bonuses, status effects, party members, quest progress, inventory with 100+ items. Managing this across HTTP requests without a client-side state manager like Redux was daunting.

The Solution

I embraced Rails' strength - the database. Everything is persisted immediately:

# app/models/character.rb
class Character < ApplicationRecord
  # Comprehensive caching system for computed stats
  include CharacterStatCaching

  # Calculate total stats including equipment and passive bonuses
  def total_strength
    cached_total_stats[:strength]
  end

  # Equipment bonuses calculated on-demand
  def equipment_bonus(stat)
    equipped_items = inventories.equipped.includes(:item)
    equipped_items.sum { |inv| inv.item.stat_bonuses[stat.to_s] || 0 }
  end

  # Passive abilities from class progression
  def total_passive_bonus(effect_key)
    unlocked_passive_abilities.values.sum do |ability|
      ability[:effect][effect_key] || 0
    end
  end
end

Instead of complex client state, I used aggressive caching and computed properties on the server side.

Hurdle 3: Building an RPG Dialogue System

The Challenge

Every RPG needs dialogue boxes with typewriter effects, multiple messages, and keyboard controls. Building this in a server-rendered app seemed impossible.

The Solution

I created a global JavaScript dialogue system that any part of the app could use:

// app/views/shared/_dialogue_system.html.erb
window.RPGDialogueSystem = {
  typeText: function(element, text, speed = 20) {
    return new Promise((resolve) => {
      element.textContent = '';
      let i = 0;

      this.currentTypingInterval = setInterval(() => {
        if (i < text.length) {
          element.textContent += text.charAt(i);
          i++;
        } else {
          clearInterval(this.currentTypingInterval);
          resolve();
        }
      }, speed);
    });
  },

  showDialogue: async function(messages, options = {}) {
    // Handle arrays of messages with typewriter effect
    const dialogues = Array.isArray(messages) ? messages : [messages];

    for (const message of dialogues) {
      await this.typeText(dialogueContent, message);
      // Wait for player input (Space/Enter)
      await waitForContinue();
    }
  }
};

This system is framework-agnostic - it works with server-rendered HTML but provides client-side richness.

Hurdle 4: Performance with Complex Relationships

The Challenge

A single character has relationships to 20+ other models. Loading a town center view could generate 100+ queries. The dreaded N+1 problem was everywhere.

The Solution

Careful use of includes and custom query optimization:

# app/controllers/game/town_centers_controller.rb
def show
  @character = current_user.character
                          .includes(:location, :active_party_members, 
                                   active_quests: [:quest])

  # Batch load vendors with their shops and items
  @vendors = @location.vendors
                      .active
                      .includes(shops: { shop_items: :item })
                      .map do |vendor|
    {
      vendor: vendor,
      shop: vendor.primary_shop,
      is_open: vendor.open_during_current_time?
    }
  end

  # Single query for all available quests
  @available_quests = Quest.at_location(@location)
                          .for_level(@character.level)
                          .includes(:quest_objectives)
                          .select { |q| q.available_for?(@character) }
end

The lesson: Plan your queries like you plan your game mechanics - with careful attention to performance.

Hurdle 5: Mobile-First Game UI

The Challenge

Most Rails apps aren't designed for gaming. I needed touch controls, drag-and-drop inventory, and responsive combat interfaces.

The Solution

Stimulus controllers with mobile-first design:

// app/javascript/controllers/game_controls_controller.js
export default class extends Controller {
  connect() {
    if ('ontouchstart' in window) {
      this.setupTouchControls()
    } else {
      this.setupKeyboardControls()
    }
  }

  setupTouchControls() {
    // Virtual joystick for movement
    this.joystick = new VirtualJoystick({
      container: this.joystickTarget,
      mouseSupport: false
    })

    // Action buttons for combat
    this.actionButtonsTarget.addEventListener('touchstart', (e) => {
      e.preventDefault()
      const action = e.target.dataset.touchAction
      this.performAction(action)
    })
  }
}

Combined with Tailwind's responsive utilities, the game works seamlessly on mobile and desktop.

Hurdle 6: Managing Complex Game Data

The Challenge

An RPG needs massive amounts of content: 200+ items, 100+ enemies, dozens of quests with multiple objectives. Managing this data and keeping it consistent was overwhelming.

The Solution

A modular seed system with preservation capabilities:

# db/seeds/seeds.rb
puts "🎮 Starting Mystic Archipelago seed process..."

# Preserve player data if flag is set
if ENV['PRESERVE_PLAYER_DATA'] == 'true'
  PreservationService.backup_player_data
end

# Load in specific order
load Rails.root.join('db/seeds/locations/philippine_locations.rb')
load Rails.root.join('db/seeds/items/weapons.rb')
load Rails.root.join('db/seeds/game_content/enemies/tier1_enemies.rb')
load Rails.root.join('db/seeds/game_content/quests_main_story.rb')

if ENV['PRESERVE_PLAYER_DATA'] == 'true'
  PreservationService.restore_player_data
end

This allows rapid iteration on game content while preserving player progress during development.

Key Learnings

When Rails/Hotwire Shines for Games

  1. Turn-based gameplay - Perfect fit for request/response cycle
  2. Complex data relationships - ActiveRecord handles it beautifully
  3. Server-authoritative gameplay - Prevents cheating, enables fair play
  4. Rapid content iteration - Rails generators and migrations are fantastic
  5. Cross-platform by default - Works on any device with a browser

When It Struggles

  1. Real-time multiplayer - WebSockets help but it's not ideal
  2. Complex animations - You'll write a lot of custom JavaScript
  3. Offline play - Possible with PWA but not straightforward
  4. Asset-heavy games - Managing sprites/sounds requires careful planning
  5. Frame-perfect timing - Forget about action games or platformers

Tips for Other Beginners

  1. Embrace the server - Don't fight Rails' architecture, use it
  2. Cache aggressively - Computed properties save database queries
  3. Use Stimulus sparingly - Only for UI interactions, not game logic
  4. Plan your models carefully - Migrations in production are harder with game data
  5. Test with real data - 1000 items behave differently than 10

Conclusion

Building an RPG with Rails and Hotwire has been an incredible learning experience. While it's not the conventional choice, it proves that web technologies have come far enough to create engaging game experiences.

Rails excels at what RPGs need most: complex data management, user progression systems, and server-side game logic. Hotwire adds just enough interactivity to make it feel like a "real" game without the complexity of a full SPA framework.

Would I recommend Rails for every game? Absolutely not. But for turn-based, menu-driven games with complex progression systems? It's surprisingly capable.

The web platform is more powerful than ever. Sometimes the best tool isn't the obvious one - it's the one you know best and can push to its limits.

Christopher Lim

Christopher Lim

Rails developer and Unity explorer. Family man, lifelong learner, and builder turning ideas into polished applications. Passionate about quality software development and continuous improvement.

Back to All Posts
Reading time: 7 min read

Enjoyed this game development post?

Follow me for more insights on game development, Rails development, and software engineering excellence.