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.
Every screen lives inside the single turbo-frame#game, so there is no page render to hang an
animation off. Two mechanisms cover it: a class the outgoing screen puts on itself before
navigating, and a view transition wrapped around the frame render.
All of it is gated to (max-width: 1023.98px) and (prefers-reduced-motion: no-preference), in
the CSS and again in JS (TRANSITION_MEDIA in application.js, MOTION in
answer_controller.js). Desktop and reduced-motion get the plain instant swap, so a new
transition needs both halves of the gate or the two disagree — a JS-only gate leaves the
browser's default cross-fade running on desktop.
start_controller#exit and answer_controller#exit add is-exiting to <main> when the CTA is
tapped. The start screen animates while the facts modal is being fetched — the modal waits for the
outgoing wave before it opens — and the answer screen holds its own navigation until the video is
nearly over. The pattern is always the same: measure the block that has to leave, publish its
height as a custom property, and let a negative margin free the space while a transform carries
the block off-screen — --exit-shift for the answers container, --header-shift for the stage
header (is-finishing, used only on the way to the results, where the video takes the whole
screen). The hero is flex: 1, so it grows into whatever the margin
releases.
Every stage node carries a video attachment beside its image. stage_result and done render it
in the hero behind the copy at opacity: 0; the exit fades it up as the result copy slides away.
answer_controller then holds the navigation until timeupdate reports less than --dur-base
remaining, so the next screen assembles over the video's tail rather than after it — ended and a
rejected play() are the fallbacks, and a stage with no video just navigates. The intro video is
warmed up ahead of time by _preload_intro, a 1px preload="auto" element on the start and facts
screens (the facts modal is appended over the start page, so that copy keeps buffering while the
player reads).
Gotcha: the base .hero-container:has(video) rule (white text, shifted h2) is scoped to
.intro. Unscoped, it silently restyles every result screen the moment a stage gets a video.
SCREEN_TRANSITIONS in application.js maps "<from>><to>" — the template class the layout puts
on <main> — to a transition name. A match overrides Turbo's event.detail.render so the frame
swap runs inside document.startViewTransition(), with the name on html[data-screen-transition]
for the CSS to key off. The current set: slide-left (whole screen from the right), slide-up
(whole screen from the bottom, into the results), answer (hero cross-fade, options sink, result
copy rises) and stage-in (header updates in place, video cross-fades to the stage image, answers
slide up).
Anything that must animate after the snapshots are gone hangs off .is-settling, added to the
new <main> when the transition finishes. Both waves use it.
Gotcha: a view-transition-name on a descendant cuts it out of its ancestor's snapshot, so
the ancestor arrives with a transparent hole where it used to be. That is why the waves are hidden
during the transition and animated afterwards on the real element instead of being named.
Gotcha: anything that overflows during a transition gives the scroller a scrollbar, and a
scrollbar shrinks clientHeight. A wave sliding in from off-screen right did exactly that to the
facts dialog and left a strip of panel showing under the hero. Clip the container (.carousel-frame,
.hero-container while settling) and measure from getBoundingClientRect(), not clientHeight.
Gotcha: main.results paints no background of its own — body:has(.results) does — so its
snapshot is transparent wherever a child does not paint. It gets an explicit background for the
duration of slide-up, otherwise the video behind shows straight through it.
Gotcha: screen-slide-up travels 100dvh, not 100%. The results page is taller than the
viewport, so 100% would start it a page-and-a-half down and travel that whole distance.
Gotcha: the results wave is rotate: 180deg, and transform composes after rotate — a
transform-based slide arrives from the wrong side. wave-slide-in therefore animates the
translate property, which is applied before the rotation and means the same thing on every wave.
start.turbo_stream.erb appends the dialog to body#page, outside the frame, so the intro can
render behind it and the panel can then slide down to reveal it (modal#leave). Its CTA needs an
explicit data-turbo-frame="game" for that reason. modal_controller#settle waits on one
element's own transition — it filters out events from descendants and from ::backdrop, either of
which would otherwise end the wait early.
The carousel wave is a single overlay in .carousel-frame, not one per slide, so it stays put
while the slides scroll under it. It is positioned from --slide-copy-height, which
carousel_controller#equalizeHeights publishes when it levels the slides.
DevJump (app/controllers/concerns/dev_jump.rb, development only) opens any screen directly
instead of playing through: it creates a player on demand and back-fills the progress the screen
expects, scoring it the way the real answer action would. ?dev=good|bad|chance|chance_bad starts
a fresh player and answers the whole run that way; without it an existing session player is kept
and only the gaps are filled, so a jump can be played on from. ?dev_last_save=1 also answers the
last-save question. /:locale always clears the player, so the start screen looks like a first
visit. The ⚡ panel bottom-left (shared/_dev_jump) links every screen; its links are _top full
page loads so entrance animations replay from scratch.
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/controllers/concerns/dev_jump.rb: Development-only screen jumping.app/javascript/: Stimulus controllers for the game, flat (not under controllers/), each
registered in application.js, config/importmap.rb and the layout's importmap tag list.app/models/concerns/: Shared logic for ancestry, attachments, and tags.config/locales/: YAML translation files.config/question_scores.json: Answer scoring table.