- Hacktivity running Rails 7.0+
- PostgreSQL database
- User model with Devise
- Pundit for authorization (recommended)
# Gemfile (in Hacktivity repository)
gem 'break_escape', path: '../BreakEscape'bundle install
rails break_escape:install:migrations
rails db:migrate
rails db:seed # Creates missions from scenario directories# config/routes.rb
mount BreakEscape::Engine => "/break_escape"# config/initializers/break_escape.rb
BreakEscape.configure do |config|
config.standalone_mode = false # Mounted mode in Hacktivity
endEnsure your User model has these methods for Pundit authorization:
class User < ApplicationRecord
def admin?
# Your admin check logic
end
def account_manager?
# Optional: account manager check logic
end
end<!-- In your Hacktivity navigation -->
<%= link_to "BreakEscape", break_escape_path %>rails restart
# or
touch tmp/restart.txtNavigate to: https://your-hacktivity.com/break_escape/
You should see the mission selection screen.
# .env (or similar)
BREAK_ESCAPE_STANDALONE=false # Mounted mode (default)# config/initializers/break_escape.rb
BreakEscape.configure do |config|
# Mode
config.standalone_mode = false
# Demo user (only used in standalone mode)
config.demo_user_handle = ENV['BREAK_ESCAPE_DEMO_USER'] || 'demo_player'
endBreakEscape uses Pundit policies by default. It expects:
- Owner: Users can only access their own games
- Admin/Account Manager: Can access all games
- All Users: Can see published missions
- Admin/Account Manager: Can see all missions (including unpublished)
To customize authorization, create policy overrides in Hacktivity:
# app/policies/break_escape/game_policy.rb (in Hacktivity)
module BreakEscape
class GamePolicy < ::BreakEscape::GamePolicy
def show?
# Custom logic here
super || custom_access_check?
end
end
endBreakEscape adds 3 tables to your database:
-
break_escape_missions - Metadata for scenarios
name,display_name,description,published,difficulty_level
-
break_escape_games - Player game instances
player(polymorphic: User),mission_id,scenario_data(JSONB),player_state(JSONB)
-
break_escape_demo_users - Optional (standalone mode only)
- Only created if migrations run, can be safely ignored in mounted mode
Once mounted, these endpoints are available:
- Mission List:
GET /break_escape/missions - Play Mission:
GET /break_escape/missions/:id - Game View:
GET /break_escape/games/:id - Scenario Data:
GET /break_escape/games/:id/scenario - NPC Scripts:
GET /break_escape/games/:id/ink?npc=:npc_id - Bootstrap:
GET /break_escape/games/:id/bootstrap - State Sync:
PUT /break_escape/games/:id/sync_state - Unlock:
POST /break_escape/games/:id/unlock - Inventory:
POST /break_escape/games/:id/inventory
Static game assets are served from public/break_escape/:
- JavaScript:
public/break_escape/js/ - CSS:
public/break_escape/css/ - Images:
public/break_escape/assets/
These are served by the engine's static file middleware.
Solution: Ensure engine is mounted in config/routes.rb
mount BreakEscape::Engine => "/break_escape"Solution: Verify current_user method works in your ApplicationController
# In Hacktivity's ApplicationController
def current_user
# Should return User instance or nil
endSolution: Check that public/break_escape/ directory exists and contains game files
ls public/break_escape/js/
ls public/break_escape/css/
ls public/break_escape/assets/Solution: Verify bin/inklecate executable exists and is executable
chmod +x scenarios/inklecate
# Or ensure inklecate is in PATHSolution: Ensure your layout includes CSRF meta tags
<!-- In application.html.erb -->
<%= csrf_meta_tags %>Solution: Check PostgreSQL is running and migrations ran successfully
rails db:migrate:status | grep break_escape
# Should show all migrations as "up"Symptom: Browser console shows Refused to load the script 'https://cdn.jsdelivr.net/...'
or Refused to execute inline script.
Solution: Hacktivity's CSP is blocking BreakEscape's scripts. Follow the Content Security Policy (CSP) Configuration section above and add the required sources. The most common causes:
cdn.jsdelivr.net,unpkg.com, orajax.googleapis.commissing fromscript-src→ Phaser, EasyStar.js, Tippy.js, and the WebFont Loader all fail silentlycontent_security_policy_nonce_directivesdoes not includestyle-src→ inline<style nonce="...">blocks ongames/newandmissions/indexare blocked- Nonce generator not configured → every
<script nonce="...">tag in BreakEscape views renders with an empty nonce and is refused
Open the browser DevTools → Console. Each CSP violation names the blocked URL or
"inline script" / "inline style" and the directive that rejected it — use that
to pinpoint which source or directive is missing.
Symptom: Pixel/retro fonts don't appear; text uses a generic sans-serif.
Solution: Add Google Fonts to the CSP:
policy.style_src *policy.style_src, "https://fonts.googleapis.com"
policy.font_src *policy.font_src, "https://fonts.gstatic.com", :dataSymptom: Clicking the Crypto Workstation opens the panel but it stays empty.
Solution: Add frame and worker sources:
policy.frame_src *policy.frame_src, :self
policy.worker_src *policy.worker_src, :self, "blob:"blob: is required for CyberChef's Tesseract OCR and Forge prime web workers.
BreakEscape pre-generates NPC dialogue audio using the Gemini TTS API and commits the resulting MP3 files to the engine repository. This means no Gemini API key or quota is needed at runtime — audio is served straight from disk.
The cache lives at tts_cache/ inside the engine repository root:
BreakEscape/
tts_cache/
m01_first_contact/ ← per-scenario subdirectory
<md5hash>.mp3 ← one file per unique dialogue line
ceo_exfil/
...
The TtsService constant is:
CACHE_DIR = BreakEscape::Engine.root.join("tts_cache")Engine.root always resolves to the engine gem directory, so the cache path
is identical in both standalone mode and when the engine is mounted into
Hacktivity via path: in the Gemfile.
Audio is served through the authenticated POST /games/:id/tts controller
action, which validates that the requested text matches the NPC's actual Ink
dialogue before returning the cached MP3. Static-file fallback is not used —
all TTS requests go through the controller so authentication and text
validation cannot be bypassed.
When scenario dialogue changes or a new scenario is added, regenerate the cache with the batch rake task:
# From the BreakEscape engine directory
bundle exec rake app:break_escape:tts:batch_generate[scenario_name]
# e.g.
bundle exec rake app:break_escape:tts:batch_generate[m01_first_contact]Set GEMINI_API_KEY before running. The batch processor:
- skips lines already cached (cache-hit fast path)
- skips phone NPCs (
npcType: "phone") — these use client-side text chat - applies exponential back-off on quota errors
Commit the resulting tts_cache/<scenario>/ files to git so that Hacktivity
deployments pick them up automatically.
A helper script identifies and removes cache files that should no longer exist (e.g. audio generated for phone-NPC Ink dialogue before the batch processor was updated to skip them):
# Preview what would be deleted
ruby scripts/tts_cache_cleanup_phone.rb
# Actually delete
ruby scripts/tts_cache_cleanup_phone.rb --delete- First NPC interaction compiles
.ink→.json(~300ms) - Subsequent interactions use cached JSON (~10ms)
- Compiled files persist across restarts
- Production: Pre-compile all .ink files during deployment
- ERB templates render on game creation (~50ms)
- Scenario data cached in
games.scenario_dataJSONB - No re-rendering during gameplay
- Periodic sync every 30 seconds (configurable)
- Uses Rails cache for temporary state
- Database writes only on unlock/inventory changes
BreakEscape loads external libraries and uses inline scripts with nonces. When mounting the engine into Hacktivity you must extend the host CSP to allow the sources below, otherwise scripts, fonts, and the CyberChef iframe will be blocked.
Add or extend a content_security_policy initializer in Hacktivity:
# config/initializers/content_security_policy.rb (in Hacktivity)
Rails.application.configure do
config.content_security_policy do |policy|
# --- BreakEscape external script sources ---
# Phaser 3 + EasyStar.js
# Tippy.js + Popper.js
# WebFont Loader
policy.script_src *policy.script_src,
"https://cdn.jsdelivr.net",
"https://unpkg.com",
"https://ajax.googleapis.com"
# --- BreakEscape font sources ---
# Google Fonts stylesheet (loads as a <link>, but style-src covers @import)
policy.style_src *policy.style_src,
"https://fonts.googleapis.com"
# Google Fonts binary files + data URIs used by some icon sets
policy.font_src *policy.font_src,
"https://fonts.gstatic.com",
:data
# --- CyberChef iframe ---
# CyberChef is served from the engine's own /break_escape/assets/cyberchef/
# path, so 'self' is sufficient. The iframe has its own browsing context;
# it does NOT inherit the parent page's nonce, so its internal scripts are
# governed by the iframe's own CSP (or lack thereof for same-origin content).
policy.frame_src *policy.frame_src, :self
# --- CyberChef Web Workers (Tesseract OCR, Forge prime worker) ---
# These run inside the CyberChef iframe context, so worker-src must also
# allow 'self' and blob: (workers are often created via blob URLs).
policy.worker_src *policy.worker_src, :self, "blob:"
# --- Nonce directives ---
# Ensure nonces are generated for both scripts and styles so that the
# engine's inline <script nonce="..."> and <style nonce="..."> tags work.
end
# Generate a fresh nonce per request and apply it to both script-src and
# style-src (BreakEscape uses nonces on inline <style> blocks too).
config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
endNote: The
*policy.script_srcspread syntax preserves whatever Hacktivity already has in that directive (e.g.'self','nonce-...') and appends only the new sources. If Hacktivity's policy is built incrementally you may need to adjust the syntax to match its pattern.
| Source | Directive | Used by |
|---|---|---|
cdn.jsdelivr.net |
script-src |
Phaser 3.60, EasyStar.js 0.4.4 |
unpkg.com |
script-src |
Tippy.js 6, Popper.js 2 |
ajax.googleapis.com |
script-src |
WebFont Loader 1.6 |
fonts.googleapis.com |
style-src |
Google Fonts CSS |
fonts.gstatic.com |
font-src |
Google Fonts binary files |
data: |
font-src |
Icon data URIs in CSS |
'self' |
frame-src |
CyberChef iframe (/break_escape/assets/cyberchef/) |
'self' + blob: |
worker-src |
CyberChef's Tesseract OCR and Forge prime workers |
- CSRF Protection: All POST/PUT endpoints require valid CSRF tokens
- Authorization: Pundit policies enforce access control
- XSS Prevention: All inline scripts and styles use CSP nonces;
eval()is not used; inline event handlers (onclick,onerror) are not used — see CSP section above for required host configuration - SQL Injection: All queries use parameterized statements
- Session Security: Sessions tied to user authentication
- Game session duration
- Mission completion rates
- Unlock attempt failures (may indicate difficulty issues)
- Ink compilation times (should be ~300ms first time)
- State sync success rate
# Game creation
"[BreakEscape] Game created: ID=123, Mission=ceo_exfil"
# Ink compilation
"[BreakEscape] Compiling helper1_greeting.ink..."
"[BreakEscape] Compiled helper1_greeting.ink (45.2 KB)"
# Unlock validation
"[BreakEscape] Unlock validated: door=office, method=password"cd ../BreakEscape
git pull origin main
cd ../Hacktivity
bundle install
rails break_escape:install:migrations # Install new migrations
rails db:migrate
rails restartFor issues specific to BreakEscape engine:
- Check
README.mdin BreakEscape repository - Review implementation plan in
planning_notes/ - Check game client logs in browser console
For Hacktivity integration issues:
- Verify Devise authentication is working
- Check Pundit policies are configured
- Review Rails logs for errors