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.
 
 
 
 
 

7.0 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.
  • 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 the results-screen thumbs vote. 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 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.<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 two 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.

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.

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.

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/models/concerns/: Shared logic for ancestry, attachments, and tags.
  • config/locales/: YAML translation files.
  • config/question_scores.json: Answer scoring table.