diff --git a/app/controllers/game_landing_controller.rb b/app/controllers/game_landing_controller.rb
new file mode 100644
index 0000000..95795b4
--- /dev/null
+++ b/app/controllers/game_landing_controller.rb
@@ -0,0 +1,21 @@
+class GameLandingController < ApplicationController
+ include StudentAuthentication
+
+ layout "embed", only: :embed
+
+ def show
+ @game = Game.find(params[:id])
+ @game_attempt_token = GameAttempt.generate_token(student_id: current_student.id, game_id: @game.id)
+
+ logger.info("game attempt token: #{{ game_attempt_token: @game_attempt_token, student_id: current_student.id, game_id: @game.id }}")
+ end
+
+ def embed
+ @game = Game.find(params[:id])
+ end
+
+ private
+
+ def current_student = Current.student
+ helper_method :current_student
+end
diff --git a/app/javascript/controllers/quiz_controller.js b/app/javascript/controllers/quiz_controller.js
new file mode 100644
index 0000000..5acdf8c
--- /dev/null
+++ b/app/javascript/controllers/quiz_controller.js
@@ -0,0 +1,73 @@
+import { Controller } from "@hotwired/stimulus"
+
+const QUESTIONS = [
+ { text: "The sky is blue.", answer: true },
+ { text: "Fish can breathe air out of water.", answer: false },
+ { text: "The earth orbits the sun.", answer: true }
+]
+
+export default class extends Controller {
+ static targets = ["root"]
+ static values = { startUrl: String, finishUrl: String }
+
+ connect() {
+ this.token = new URLSearchParams(window.location.search).get("token")
+ this.questionIndex = 0
+ this.correctCount = 0
+ this.started = false
+ this.renderQuestion()
+ }
+
+ renderQuestion() {
+ const question = QUESTIONS[this.questionIndex]
+ this.rootTarget.innerHTML = `
+
${question.text}
+
+
+
+
+ `
+ this.rootTarget.querySelectorAll("button").forEach((button) => {
+ button.addEventListener("click", () => this.answer(button.dataset.answer === "true"))
+ })
+ }
+
+ async answer(value) {
+ if (!this.started) {
+ this.started = true
+ await this.post(this.startUrlValue)
+ }
+
+ if (value === QUESTIONS[this.questionIndex].answer) this.correctCount += 1
+
+ this.questionIndex += 1
+ if (this.questionIndex < QUESTIONS.length) {
+ this.renderQuestion()
+ } else {
+ await this.finish()
+ }
+ }
+
+ async finish() {
+ const outcome = this.correctCount === QUESTIONS.length ? "pass" : "fail"
+
+ await this.post(this.finishUrlValue, {
+ game_attempt: { outcome, score: this.correctCount }
+ })
+
+ this.rootTarget.innerHTML = `You scored ${this.correctCount} out of ${QUESTIONS.length}.
`
+ }
+
+ async post(url, extraParams = {}) {
+ const csrfToken = document.querySelector("meta[name='csrf-token']")?.content
+
+ await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken
+ },
+ body: JSON.stringify({ token: this.token, ...extraParams })
+ })
+ }
+}
diff --git a/app/views/game_landing/embed.html.erb b/app/views/game_landing/embed.html.erb
new file mode 100644
index 0000000..bbc39d5
--- /dev/null
+++ b/app/views/game_landing/embed.html.erb
@@ -0,0 +1,7 @@
+<%= tag.div data: {
+ controller: "quiz",
+ quiz_start_url_value: start_game_attempts_path,
+ quiz_finish_url_value: finish_game_attempts_path
+ } do %>
+
+<% end %>
diff --git a/app/views/game_landing/show.html.erb b/app/views/game_landing/show.html.erb
new file mode 100644
index 0000000..78de03c
--- /dev/null
+++ b/app/views/game_landing/show.html.erb
@@ -0,0 +1,7 @@
+<% content_for(:title, @game.title) %>
+
+<%= @game.title %>
+
+<%= tag.iframe src: embed_game_path(@game, token: @game_attempt_token),
+ class: "w-full h-[600px] border-0",
+ title: @game.title %>
diff --git a/app/views/layouts/embed.html.erb b/app/views/layouts/embed.html.erb
new file mode 100644
index 0000000..a1a5e11
--- /dev/null
+++ b/app/views/layouts/embed.html.erb
@@ -0,0 +1,16 @@
+
+
+
+ <%= content_for(:title) || "Endsideout" %>
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
+ <%= javascript_importmap_tags %>
+
+
+
+ <%= yield %>
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 48de98d..ba10797 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -26,6 +26,12 @@
resource :student_session, only: %i[new create destroy]
resources :student_homes, only: %i[index]
+ resources :games, only: [] do
+ member do
+ get :play, to: "game_landing#show"
+ get :embed, to: "game_landing#embed"
+ end
+ end
resources :classroom_rosters, only: %i[show], param: :uuid
resources :game_attempts, only: [] do
collection do
diff --git a/test/controllers/game_landing_controller_test.rb b/test/controllers/game_landing_controller_test.rb
new file mode 100644
index 0000000..1d00cd5
--- /dev/null
+++ b/test/controllers/game_landing_controller_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class GameLandingControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @student = students(:one)
+ @game = games(:one)
+ student_sign_in_as @student
+ end
+
+ test "unauthenticated request redirects to student login" do
+ student_sign_out
+ get play_game_url(@game)
+ assert_redirected_to new_student_session_url
+ end
+
+ test "show generates a game attempt token and embeds the game" do
+ get play_game_url(@game)
+ assert_response :success
+
+ iframe_src = assert_select("iframe").first["src"]
+ token = Rack::Utils.parse_nested_query(URI(iframe_src).query)["token"]
+ assert_equal(
+ { "student_id" => @student.id, "game_id" => @game.id },
+ GameAttempt.verify_token(token)
+ )
+ end
+
+ test "embed renders the quiz mount point" do
+ get embed_game_url(@game, token: "some-token")
+ assert_response :success
+ assert_select "[data-controller='quiz']"
+ end
+end
diff --git a/test/fixtures/game_attempts.yml b/test/fixtures/game_attempts.yml
index 8db4e1f..8e0dd1f 100644
--- a/test/fixtures/game_attempts.yml
+++ b/test/fixtures/game_attempts.yml
@@ -1,7 +1,6 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
- score: 1
outcome: completed
started_at: 2026-08-29 11:53:38
finished_at: 2026-08-29 11:53:38
@@ -10,7 +9,6 @@ one:
token: onetoken
two:
- score: 1
outcome: expired
started_at: 2026-08-29 11:53:38
finished_at: 2026-08-29 11:53:38