# 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, the results-screen thumbs vote 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`), 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. ## 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. 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. ### Thumbs up / down `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. ## 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.