This project is a Ruby on Rails application developed for the IKEA Foundation. It is a multi-lingual, interactive game/educational tool where players progress through various stages, making choices or facing "chance" events.
mobility gem for translating content into numerous languages. Supported locales include: en, zh, hr, cs, da, nl, fi, fr, fr-CA, de, hu, it, ja, ko, nb, pl, pt, ro, sr, sk, sl, es, sv, uk.ancestry) for managing pages, stages, and interactive elements.Player model's progress JSONB field.GameController, featuring:
good_answer, bad_answer, or chance).chance, an outcome child is sampled.config/question_scores.json, not in the database. See below./admin/:locale/analytics reporting funnel drop-off,
answer distribution, the results-screen thumbs vote and who played — device, language and
country. See below.pg_search integration for content discovery.Node: The central content model. Templates are hierarchy-dependent:
startfacts, intro, stage, last_save, resultsgood_answer, bad_answer, chancegood_answer, bad_answerPlayer: Tracks session state, progress (per-stage answer_id / result_id), the
cumulative score, the scores hash keyed by food_waste, emissions, income,
the furthest screen reached (furthest_step), the device class they played on and
the results-screen thumbs vote (rating / rated_at).Asset & Attachment: Handles media and its contextual content (body text, styling) associated with nodes.User: Admin authentication and roles.Scores are not stored on nodes. config/question_scores.json holds them, and
GameController#score_entry_for maps a node to its entry by position:
stages[stage_index - 1].answers[answer.position - 1] for a normal answer.chance outcome, the parent chance node's entry is looked up the same way and
the outcome is read from its outcomes[child.position - 1].Each entry has an overall value (feeds player.score and the result band) and an
impact hash of food_waste / emissions / income deltas (feed the per-category
tones in GameHelper). type and early_exit are documentation only — nothing reads them.
Gotcha: because the mapping is positional, the JSON must mirror the node tree exactly.
A chance answer needs a {"type": "chance", "outcomes": [...]} entry at its position; if
it is flattened into sibling entries instead, the outcome lookup silently returns nil and
that branch scores nothing. Verify with a bin/rails runner walk of the tree after editing.
The two axes can diverge: a good chance outcome gives only overall: 1 where a safe good
answer gives 2, so a player can accumulate green impact but a low overall — and land a
"close call" headline above three positive impact texts.
GameHelper#result_state therefore raises the band from overall to at least
GameHelper#tone_floor, computed from the same impact_tone values the impact texts use:
all three positive floors at :best, none negative floors at :balanced. The guard only
lifts, never lowers — a high overall still reaches :best, and any negative metric still
allows :worst. The early-exit / last-save branch returns before the guard and is unaffected.
Consequence: the headline and the three impact texts are no longer independent. Retuning
impact values in question_scores.json can move the headline even when overall is
untouched, and changing IMPACT_TONE_BANDS changes both the texts and the band floor.
Each impact category needs three distinct tone strings — positive, neutral, negative
under game.results.<category> — in all 24 locale files. A missing tone is easy to miss
because nothing raises: neutral was a verbatim copy of positive in every locale, so an
income: 0 player was told they "earned some money". When touching these, sweep all locales
and check the three strings actually differ, not just that the key exists.
There is no event log. GameAnalytics derives everything from columns on players:
progress — what people did (the answer node they landed on for each stage).furthest_step — where people stopped. Written by GameController#track_step, an
after_action on the screen actions. Player#record_step only ever moves a player
forward, so the browser back button can't rewind the funnel.device — what they played on: mobile, tablet or desktop.locale and country — who they are, feeding the two breakdown panels beside devices.Step names are facts, intro, stage_<n>, stage_<n>_result, last_save, done,
results, ordered by Player.step_rank.
Gotcha: stage reach is computed from progress, not by comparing furthest_step
ranks. The last-save early exit jumps a player straight to the end of the game, so a rank
comparison would credit them with stages they never saw. The last-save branch is therefore
reported as its own panel rather than as a funnel step.
Gotcha: DemoActivity (see app/services/demo_activity.rb) invents players with no
gameplay to feed the leaderboard banner, which inflates every count and shows up as a huge
drop before "Facts". The dashboard warns about this while DemoActivity::ENABLED is true.
Player.device_from_user_agent classifies the User-Agent once, when GameController#start
creates the player; there is no client-side probe and nothing re-checks it later. The match
order matters — tablets are tested first, because an Android tablet's UA also says "Android"
and only phones add a Mobi token, so a mobile-first test would swallow every tablet.
Crawlers and anything unrecognisable are stored as nil rather than falling through to
desktop, and GameAnalytics#by_device drops nil rows — so the shares describe only the
players we could actually place, and a bot run can't quietly pad the desktop column.
Known blind spot: iPadOS 13+ sends a desktop Safari UA by default, so some iPads are
counted as desktop. Nothing short of client-side probing fixes it; the tablet share is a
floor, not an exact figure. Players created before this column existed are nil forever —
the UA was never stored, so there is nothing to backfill from.
Player#rate! stores rating as 1 / -1 (Player::RATINGS), one per player — voting
again overwrites. The results screen posts to game#rate via rating_controller.js, which
flips the button state immediately and does not await the response.
app/controllers/admin/: Admin backend logic.app/controllers/game_controller.rb: Main game loop (stage -> answer -> result -> results).app/controllers/api/: JSON endpoints (see docs/leaderboard_api.md).app/helpers/game_helper.rb: Result bands and per-impact tone thresholds.app/services/game_analytics.rb: Aggregates the admin analytics dashboard.app/controllers/admin/analytics_controller.rb: Admin analytics dashboard.app/models/concerns/: Shared logic for ancestry, attachments, and tags.config/locales/: YAML translation files.config/question_scores.json: Answer scoring table.