RECOMMENDED APPROACH: 12-14 weeks instead of 22 weeks KEY SIMPLIFICATIONS: - Use JSON storage for game state (already in that format) - 3 database tables instead of 10+ - game_instances (with player_state JSONB) - scenarios (with scenario_data JSONB) - npc_scripts (Ink JSON) - 6 API endpoints instead of 15+ - Bootstrap game - Load room (when unlocked) - Attempt unlock - Update inventory - Load NPC script (on encounter) - Sync state (periodic) VALIDATION STRATEGY: - Validate unlock attempts (server has solutions) - Validate room/object access (check unlocked state) - Validate inventory changes (check item in unlocked location) - NPCs: Load Ink scripts on encounter, run conversations 100% client-side - NPC door unlocks: Simple check (encountered NPC + scenario permission) WHAT WE DON'T TRACK: - Every event (client-side only) - Every conversation turn (no sync needed) - Every minigame action (only result matters) - Complex NPC permissions (simple rule: encountered = trusted) BENEFITS: - Faster development (12-14 weeks vs 22 weeks) - Easier maintenance (JSON matches existing format) - Better performance (fewer queries, JSONB indexing) - More flexible (easy to modify game state structure) - Simpler logic (clear validation rules) Updated README_UPDATED.md to recommend simplified approach first. Complex approach documentation retained for reference.
12 KiB
Simplified Rails Engine Migration Approach
Last Updated: 2025-11-20
Core Philosophy
Keep it simple: Use JSON storage for game state (it's already in that format), validate only what matters, and minimize server round-trips.
What We Actually Need to Track
1. Scenario JSON (Filtered for Client)
Server stores: Complete scenario with solutions Client receives: Filtered JSON with:
- Room layouts (connections, types)
- Objects visible but lock requirements hidden
- NPCs present but Ink scripts loaded on-demand
- No PINs, passwords, key IDs, or container contents
2. Player Current State (Simple JSON)
{
"currentRoom": "room_reception",
"position": { "x": 100, "y": 200 },
"unlockedRooms": ["room_reception", "room_office"],
"unlockedObjects": ["desk_drawer_123", "safe_456"],
"inventory": [
{ "type": "key", "name": "Office Key", "key_id": "office_key_1" },
{ "type": "lockpick", "name": "Lockpick Set" }
],
"encounteredNPCs": ["security_guard", "receptionist"],
"globalVariables": {
"alarm_triggered": false,
"player_favor": 5
}
}
That's it! One JSON blob per player per scenario.
3. NPC Ink Scripts (Lazy Loaded)
- When player encounters NPC → load Ink script
- All conversation happens client-side
- Only validate if NPC grants door unlock (simple check: has player encountered this NPC?)
Simplified Database Schema
One Main Table: game_instances
create_table :game_instances do |t|
t.references :user, null: false
t.references :scenario, null: false
# Game state as JSON
t.jsonb :player_state, default: {
currentRoom: 'room_reception',
position: { x: 0, y: 0 },
unlockedRooms: [],
unlockedObjects: [],
inventory: [],
encounteredNPCs: [],
globalVariables: {}
}
# Metadata
t.string :status, default: 'in_progress' # in_progress, completed, abandoned
t.datetime :started_at
t.datetime :completed_at
t.integer :score, default: 0
t.timestamps
t.index [:user_id, :scenario_id], unique: true
t.index :player_state, using: :gin # For JSON queries
end
create_table :scenarios do |t|
t.string :name, null: false
t.text :description
t.jsonb :scenario_data # Complete scenario JSON (with solutions)
t.boolean :published, default: false
t.timestamps
end
create_table :npc_scripts do |t|
t.references :scenario, null: false
t.string :npc_id, null: false
t.text :ink_script # Ink JSON
t.timestamps
t.index [:scenario_id, :npc_id], unique: true
end
That's it! 3 tables instead of 10+
Simplified API Endpoints
1. Bootstrap Game
GET /api/games/:game_id/bootstrap
Response:
{
"startRoom": "room_reception",
"scenarioName": "CEO Heist",
"playerState": { currentRoom, position, inventory, ... },
"roomLayout": {
// Just room IDs and connections, no solutions
"room_reception": {
"connections": { "north": "room_office" },
"locked": false
},
"room_office": {
"connections": { "south": "room_reception", "north": "room_ceo" },
"locked": true // But no lockType or requires!
}
}
}
2. Load Room (When Unlocked)
GET /api/games/:game_id/rooms/:room_id
Server checks: Is room in playerState.unlockedRooms?
- Yes → Return room data (objects, but still no lock solutions)
- No → 403 Forbidden
Response:
{
"roomId": "room_office",
"objects": [
{
"type": "desk",
"name": "Desk",
"locked": true, // But no "requires" field
"observations": "A locked desk drawer"
}
]
}
3. Attempt Unlock
POST /api/games/:game_id/unlock
Body:
{
"targetType": "door|object",
"targetId": "room_ceo|desk_drawer_123",
"method": "key|pin|password|lockpick",
"attempt": "1234|password123|key_id_5"
}
Server:
- Loads complete scenario JSON
- Checks if attempt matches requirement
- If valid:
- Adds to playerState.unlockedRooms or unlockedObjects
- Returns unlocked content
- If invalid:
- Returns failure message
Response (success):
{
"success": true,
"type": "door",
"roomData": { ... } // If door
// OR
"contents": [ ... ] // If container
}
4. Update Inventory
POST /api/games/:game_id/inventory
Body:
{
"action": "add|remove",
"item": { "type": "key", "name": "Office Key", "key_id": "..." }
}
Server validates:
- For "add": Is item in an unlocked container/room?
- Updates playerState.inventory
Response:
{ "success": true, "inventory": [...] }
5. Load NPC Script (On Encounter)
GET /api/games/:game_id/npcs/:npc_id/script
Server checks: Is NPC in current room OR already in playerState.encounteredNPCs?
- Yes → Return Ink script
- No → 403 Forbidden
Side effect: Add to playerState.encounteredNPCs
Response:
{
"npcId": "security_guard",
"inkScript": { ... }, // Full Ink JSON
"eventMappings": [...],
"timedMessages": [...]
}
6. Sync State (Periodic)
PUT /api/games/:game_id/state
Body:
{
"currentRoom": "room_office",
"position": { "x": 150, "y": 220 },
"globalVariables": { "alarm_triggered": false }
}
Server: Merges into playerState (validates room is unlocked)
That's it! 6 endpoints instead of 15+
What We DON'T Track Server-Side
❌ Every Event
- No event logging (unless needed for analytics later)
- NPCs listen to events client-side only
❌ Every Conversation Turn
- All NPC dialogue happens client-side
- No conversation history sync needed
- No story state tracking
❌ Every Minigame Action
- Minigames run 100% client-side
- Only unlock validation matters
❌ Complex Permissions
- No NPCPermission table
- Simple rule: If player encountered NPC, NPC can do its thing
Validation Strategy (Simplified)
When Server Validates
-
Unlock Attempts ✅
- Check attempt against scenario JSON
- Update unlocked state
-
Inventory Changes ✅
- Verify item exists in unlocked location
- Update inventory JSON
-
Room Access ✅
- Check room in unlockedRooms
- Return filtered room data
-
NPC Script Loading ✅
- Check NPC encountered or in current room
- Return Ink script
When Server Doesn't Validate
- Conversations ❌ - All client-side
- Movement ❌ - Trust client position
- Events ❌ - Client-side only
- Minigame Actions ❌ - Only result matters
- Global Variables ❌ - Sync periodically, don't validate every change
NPC Door Unlock Simplification
Scenario defines: NPC can unlock door X
{
"id": "security_guard",
"canUnlock": ["room_server"]
}
Client requests: NPC unlock door
POST /api/games/:game_id/npc_unlock
Body:
{
"npcId": "security_guard",
"doorId": "room_server"
}
Server validates:
- Is NPC in playerState.encounteredNPCs?
- Does scenario say NPC can unlock this door?
- If yes: Add to unlockedRooms, return room data
No conversation tracking, no trust levels, no complex permissions!
Migration Timeline (Simplified)
Total: 12-14 weeks (vs 22 weeks original)
Weeks 1-2: Setup
- Create Rails Engine
- 3 database tables
- Import scenarios as JSON
Weeks 3-4: Core API
- Bootstrap endpoint
- Room loading
- Unlock validation
Weeks 5-6: Client Integration
- Modify loadRoom() to fetch from server
- Add unlock validation
- NPC script lazy loading
Weeks 7-8: Inventory & State Sync
- Inventory API
- State sync endpoint
- Offline queue
Weeks 9-10: NPC Integration
- NPC script loading
- NPC door unlock (simple validation)
- Import all Ink scripts
Weeks 11-12: Testing & Polish
- Integration testing
- Performance optimization
- Security audit
Weeks 13-14: Deployment
- Staging deployment
- Load testing
- Production deployment
Savings: 8-10 weeks by simplifying!
Implementation Example: Unlock Validation
Server-Side (Simple)
class Api::UnlockController < ApplicationController
def create
game = GameInstance.find(params[:game_id])
# Load complete scenario (has solutions)
scenario = game.scenario.scenario_data
target_type = params[:target_type] # 'door' or 'object'
target_id = params[:target_id]
attempt = params[:attempt]
method = params[:method]
# Find target in scenario
target = find_target(scenario, target_type, target_id)
# Validate attempt
is_valid = validate_attempt(target, attempt, method)
if is_valid
# Update player state JSON
if target_type == 'door'
game.player_state['unlockedRooms'] << target_id
room_data = get_filtered_room_data(scenario, target_id)
game.save!
render json: { success: true, roomData: room_data }
else
game.player_state['unlockedObjects'] << target_id
contents = target['contents'] || []
game.save!
render json: { success: true, contents: contents }
end
else
render json: { success: false, message: 'Invalid attempt' }, status: 422
end
end
private
def validate_attempt(target, attempt, method)
case method
when 'key'
target['requires'] == attempt # key_id matches
when 'pin', 'password'
target['requires'] == attempt # PIN/password matches
when 'lockpick'
true # Client-side minigame passed, trust it
end
end
def get_filtered_room_data(scenario, room_id)
room = scenario['rooms'][room_id].dup
# Remove solutions from objects
room['objects']&.each do |obj|
obj.delete('requires')
obj.delete('contents') if obj['locked']
end
room
end
end
Client-Side (Unchanged)
async function handleUnlock(lockable, type) {
// Show minigame or prompt (unchanged)
const attempt = await getUnlockAttempt(lockable);
// NEW: Validate with server
const response = await fetch(`/api/games/${gameId}/unlock`, {
method: 'POST',
body: JSON.stringify({
targetType: type,
targetId: lockable.objectId,
method: lockable.lockType,
attempt: attempt
})
});
const result = await response.json();
if (result.success) {
// Unlock locally
unlockTarget(lockable, type);
// If door, load room
if (type === 'door' && result.roomData) {
createRoom(result.roomData.roomId, result.roomData, position);
}
}
}
Benefits of Simplified Approach
1. Faster Development
- 12-14 weeks vs 22 weeks
- 3 tables vs 10+
- 6 endpoints vs 15+
2. Easier Maintenance
- JSON storage matches existing format
- No ORM complexity
- Simple queries
3. Better Performance
- Fewer database queries
- Single JSON blob vs many joins
- JSONB indexing is fast
4. Flexible
- Easy to add new fields to JSON
- No migrations for game state changes
- Scenarios stay as JSON files
5. Simpler Logic
- No complex permissions
- No conversation state tracking
- Clear validation rules
What We Give Up (And Why It's OK)
❌ Conversation History Persistence
Why OK: NPCs work great client-side. If player refreshes, conversation resets. This is fine for a game session.
❌ Detailed Event Analytics
Why OK: Can add later if needed. Start simple.
❌ Global Variable Validation
Why OK: Sync periodically, rollback if server detects cheating. Don't block gameplay.
❌ Fine-Grained Permissions
Why OK: Simple rules work: encountered NPC = trusted. Scenario defines what NPCs can do.
Security Model (Simplified)
What's Secure ✅
- Scenario solutions never sent to client
- Unlock validation server-side
- Room/object access controlled
- NPC scripts lazy-loaded
What's Client-Trust ⚠️
- Player position (low risk)
- Global variables (sync server, detect cheating)
- Minigame success (only door unlock matters)
What We Detect
- Player accessing unearned rooms → 403
- Invalid unlock attempts → 422
- Impossible inventory items → reject
Good enough for educational game!
Conclusion
Original approach: Complex, over-engineered, 22 weeks Simplified approach: Pragmatic, maintainable, 12-14 weeks
Key insight: We don't need to track everything. Just track:
- What's unlocked (rooms/objects)
- What player has (inventory)
- Who player met (NPCs)
- Where player is (room/position)
Everything else can stay client-side!
Ready to implement this simpler approach.