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
- Turn-based gameplay - Perfect fit for request/response cycle
- Complex data relationships - ActiveRecord handles it beautifully
- Server-authoritative gameplay - Prevents cheating, enables fair play
- Rapid content iteration - Rails generators and migrations are fantastic
- Cross-platform by default - Works on any device with a browser
When It Struggles
- Real-time multiplayer - WebSockets help but it's not ideal
- Complex animations - You'll write a lot of custom JavaScript
- Offline play - Possible with PWA but not straightforward
- Asset-heavy games - Managing sprites/sounds requires careful planning
- Frame-perfect timing - Forget about action games or platformers
Tips for Other Beginners
- Embrace the server - Don't fight Rails' architecture, use it
- Cache aggressively - Computed properties save database queries
- Use Stimulus sparingly - Only for UI interactions, not game logic
- Plan your models carefully - Migrations in production are harder with game data
- 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.