# IKEA Foundation - Week 2026 Project 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. ## Key Features - **Multi-lingual Support:** Uses the `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. - **Hierarchical Content Management:** A "Node" system (using `ancestry`) for managing pages, stages, and interactive elements. - **Player Progression:** Tracks player progress, scores, and decisions across different stages in the `Player` model's `progress` JSONB field. - **Interactive Game Flow:** Managed by `GameController`, featuring: - **Stage:** Presents a stage's answer nodes (`good_answer`, `bad_answer`, or `chance`). - **Answer:** Records the player's selection; for `chance`, an outcome child is sampled. - **Stage result:** Shows the outcome node and applies its score. - **Last save / Results:** Final compost-vs-landfill choice, then the scored results screen. - **Screen animation:** Every screen swap is animated — CSS exits before the navigation, view transitions around the frame render, and a per-stage video that plays between an answer and the next stage. Mobile only. See below. - **Scoring:** Answer scores live in `config/question_scores.json`, not in the database. See below. - **Analytics:** An admin dashboard at `/admin/:locale/analytics` reporting funnel drop-off, answer distribution and who played — device, language and country. See below. - **Admin Interface:** A backend for managing nodes, assets (Active Storage), users, and translations. - **Search:** `pg_search` integration for content discovery. ## Technical Stack - **Framework:** Ruby on Rails 8.1.2 - **Language:** Ruby 3.4.9 - **Database:** PostgreSQL - **Asset Pipeline:** Propshaft with Importmap-rails and Stimulus/Turbo. - **Background Jobs:** Sidekiq with Redis. ## Core Models - `Node`: The central content model. Templates are hierarchy-dependent: - **Root (Depth 0):** `start` - **Level 1:** `facts`, `intro`, `stage`, `last_save`, `results` - **Level 2 (answers under a stage):** `good_answer`, `bad_answer`, `chance` - **Level 3 (outcomes under a chance):** `good_answer`, `bad_answer` - `Player`: 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`) and the `device` class they played on. - `Asset` & `Attachment`: Handles media and its contextual content (body text, styling) associated with nodes. - `User`: Admin authentication and roles. ## Scoring 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. - For a `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. ### Result band consistency guard 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. ### Results copy Each impact category needs three distinct tone strings — `positive`, `neutral`, `negative` under `game.results.` — 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. ## Analytics 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. - `utm_source` / `utm_medium` / `utm_campaign` / `utm_content` — how they got here. See below. Step names are `facts`, `intro`, `stage_`, `stage__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. ### Device `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. ### Traffic source (QR codes) The printed material — table talker, coffee machine banner, canteen poster, toilet talker, booth poster — each carries a QR code to the same game, differing only in `utm_content`: https://savethetomato.ikeafoundation.org/?utm_source=physical&utm_medium=qr &utm_campaign=ikea_foundation_week_2026&utm_content=table_talker `UtmTracking` (`app/controllers/concerns/utm_tracking.rb`) is included in `LanguagesController` and `GameController`. It parks the four tags in the session, and `GameController#start` writes them onto the player row. The session hop is not optional: the QR codes point at the bare domain, `LanguagesController#index` redirects to `/:locale` and drops the query string, and the player does not exist until the language page posts to `game#start`. Last touch wins — a second scan is a second visit, and the player created right after it belongs to the code that was actually scanned. Values are trimmed to 100 characters, and only the four known keys are ever read, so the query string cannot write anything else. `GameAnalytics#by_source` groups on `COALESCE(utm_content, utm_source, 'direct')` — `utm_content` is the only tag that differs between the codes, and untagged players all land in one bucket that is pinned to the bottom of the panel so the QR rows stay together at the top. **Not counted here:** the board game QR points at `dice.ikeafoundation.org`, a different app. Its `utm_content=board_game` scans are recorded there, not in this dashboard. ## Screens and animation 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. ### Exits `start_controller#exit` and `answer_controller#exit` add `is-exiting` to `
` 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. ### Stage videos 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. ### Transitions `SCREEN_TRANSITIONS` in `application.js` maps `">"` — the template class the layout puts on `
` — 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 `
` 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. ### The facts modal `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. ## Development shortcuts `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. ## Project Structure - `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.