You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

16 KiB

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.<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.

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_<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.

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 <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.

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 "<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.

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.