-<%= tag.iframe src: embed_game_path(@game, token: @game_attempt_token),
+<%= tag.iframe src: external_game_url(@game.slug, 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
deleted file mode 100644
index a1a5e11..0000000
--- a/app/views/layouts/embed.html.erb
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
- <%= 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/initializers/cors.rb b/config/initializers/cors.rb
new file mode 100644
index 0000000..00e3fca
--- /dev/null
+++ b/config/initializers/cors.rb
@@ -0,0 +1,11 @@
+# The games run on another origin (see external_games.rb) and call the game
+# attempts API from the browser.
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins "https://#{Rails.configuration.x.external_games.host}"
+
+ resource "/game_attempts/*",
+ headers: %w[Content-Type],
+ methods: %i[post options]
+ end
+end
diff --git a/config/initializers/external_games.rb b/config/initializers/external_games.rb
new file mode 100644
index 0000000..fabaccc
--- /dev/null
+++ b/config/initializers/external_games.rb
@@ -0,0 +1,8 @@
+# Host that serves the EndsideOut games. The games are embedded in an iframe and
+# call back into the game_attempts API, so this host is also the CORS allowlist.
+Rails.application.config.x.external_games.host =
+ if Rails.env.production?
+ "endsideoutgames.netlify.app"
+ else
+ "staging--endsideout-games.netlify.app"
+ end
diff --git a/config/routes.rb b/config/routes.rb
index ba10797..cdf9e3e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,8 +1,12 @@
require "uri"
Rails.application.routes.draw do
- direct :external_game do |slug|
- URI::HTTPS.build(host: "endsideoutgames.netlify.app", path: "/games/#{slug}").to_s
+ direct :external_game do |slug, options|
+ URI::HTTPS.build(
+ host: Rails.configuration.x.external_games.host,
+ path: "/#{slug}",
+ query: options&.slice(:token)&.to_query.presence
+ ).to_s
end
namespace :admin do
@@ -29,7 +33,6 @@
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
diff --git a/docs/game-api.md b/docs/game-api.md
new file mode 100644
index 0000000..21346e0
--- /dev/null
+++ b/docs/game-api.md
@@ -0,0 +1,137 @@
+# Game API
+
+This document tells game developers how a game reports play to the EndsideOut LMS.
+A game runs inside an iframe (a web page embedded in another page). The LMS shows the iframe to a signed-in student.
+
+## Summary
+- The LMS loads your game in an iframe with a `token` GET parameter in the URL.
+- The game calls the LMS API using the token to report when the game starts and finishes.
+
+## API Request Documentation
+
+### Start a game
+
+```http
+POST https://LMS_HOST/game_attempts/start
+Content-Type: application/json
+```
+
+**Request Body Schema:**
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "The signed token from the URL."
+ }
+ },
+ "required": ["token"]
+}
+```
+
+**Example**
+```json
+{
+ "token": "abcd"
+}
+```
+
+### Finish a game
+
+```http
+POST https://LMS_HOST/game_attempts/finish
+Content-Type: application/json
+```
+
+**Request Body Schema:**
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "The signed token from the URL."
+ },
+ "game_attempt": {
+ "type": "object",
+ "properties": {
+ "outcome": {
+ "type": "string",
+ "description": "The result of the play.",
+ "enum": ["game_completed", "game_over"]
+ },
+ "score": {
+ "type": "integer",
+ "description": "The score of the student."
+ }
+ },
+ "required": ["outcome"]
+ }
+ },
+ "required": ["token", "game_attempt"]
+}
+```
+
+**Example**
+```json
+{
+ "token": "abcd",
+ "game_attempt": {
+ "outcome": "game_completed",
+ "score": 93
+ }
+}
+```
+
+
+## Details
+
+The LMS loads the game in an iframe using the following URL:
+
+```
+https:///?token=
+```
+
+The `slug` is the unique name of the game, such as `fruit-veggie-quiz`. The `token` is a signed value that identifies one play session of one student.
+The token is a GET parameter (a value in the URL query string).
+
+When your game loads, read the `token` from the URL. For example:
+
+```js
+const token = new URLSearchParams(window.location.search).get("token")
+```
+
+Send this exact value in every call to the LMS. Do not change it, and do not decode it.
+If the game loads without a `token`, the game cannot report play. In that case, do not call the API.
+
+## Hosts
+
+| Environment | Game host | LMS host |
+| --- | --- |----------------------------------|
+| Production | `endsideoutgames.netlify.app` | classroom.endsideout.org |
+| Staging | `staging--endsideout-games.netlify.app` | staging-classroom.endsideout.org |
+
+Only a game that runs on the matching game host can call the LMS from a browser.
+A call from any other origin fails the browser CORS check.
+
+## Request rules
+
+- Send a `POST` request with the header `Content-Type: application/json`.
+- Put the `token` in the JSON body.
+- Do not send cookies. Do not send a CSRF token. The `token` is the only credential.
+- Every response is JSON with a `status` field. The value is `ok` or `error`. An error also has a `message`.
+
+
+## Errors
+
+If a call fails, it returns status 422.
+
+| Message | Cause | Fix |
+| --- | --- | --- |
+| `Invalid token` | The token is missing, changed, or not from the LMS. | Send the `token` from the page URL without any change. |
+| `Game attempt has not been started` | You called `finish` before `start`. | Call `start` first. |
diff --git a/test/controllers/game_attempts_controller_test.rb b/test/controllers/game_attempts_controller_test.rb
index e272041..0f73a0a 100644
--- a/test/controllers/game_attempts_controller_test.rb
+++ b/test/controllers/game_attempts_controller_test.rb
@@ -7,7 +7,7 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest
game_id: game_attempts(:one).game_id
)
- post start_game_attempts_url, params: { token: token }
+ post start_game_attempts_url, params: { token: token }, as: :json
assert_response :success
response_json = JSON.parse(response.body)
@@ -32,7 +32,7 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest
assert_not attempt.finished?
- post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } }
+ post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } }, as: :json
assert_response :success
response_json = JSON.parse(response.body)
@@ -46,7 +46,7 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest
game_id: game_attempts(:one).game_id
)
- post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } }
+ post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } }, as: :json
assert_response :unprocessable_entity
# record is created, but not started, so it should not be finished
@@ -56,4 +56,57 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest
assert_equal "error", response_json["status"]
assert_equal "Game attempt has not been started", response_json["message"]
end
+
+ test "rejects a tampered token with a JSON error" do
+ post start_game_attempts_url, params: { token: "not-a-real-token" }, as: :json
+ assert_response :unprocessable_entity
+
+ response_json = JSON.parse(response.body)
+ assert_equal "error", response_json["status"]
+ assert_equal "Invalid token", response_json["message"]
+ assert_equal 0, GameAttempt.where(token: "not-a-real-token").count
+ end
+
+ test "allows CORS requests from the games host" do
+ origin = "https://#{Rails.configuration.x.external_games.host}"
+
+ post start_game_attempts_url, params: { token: valid_token }, as: :json, headers: { "Origin" => origin }
+
+ assert_response :success
+ assert_equal origin, response.headers["Access-Control-Allow-Origin"]
+ assert_includes response.headers["Vary"], "Origin"
+ end
+
+ test "does not allow CORS requests from other origins" do
+ post start_game_attempts_url, params: { token: valid_token }, as: :json,
+ headers: { "Origin" => "https://example.com" }
+
+ assert_nil response.headers["Access-Control-Allow-Origin"]
+ end
+
+ test "answers CORS preflight requests for start and finish" do
+ origin = "https://#{Rails.configuration.x.external_games.host}"
+
+ %i[start finish].each do |action|
+ process :options, "/game_attempts/#{action}", headers: {
+ "Origin" => origin,
+ "Access-Control-Request-Method" => "POST",
+ "Access-Control-Request-Headers" => "Content-Type"
+ }
+
+ assert_response :success
+ assert_equal origin, response.headers["Access-Control-Allow-Origin"]
+ assert_includes response.headers["Access-Control-Allow-Methods"].downcase, "post"
+ assert_includes response.headers["Access-Control-Allow-Headers"].downcase, "content-type"
+ end
+ end
+
+ private
+
+ def valid_token
+ GameAttempt.generate_token(
+ student_id: game_attempts(:one).student_id,
+ game_id: game_attempts(:one).game_id
+ )
+ end
end
diff --git a/test/controllers/game_landing_controller_test.rb b/test/controllers/game_landing_controller_test.rb
index 1d00cd5..ac3a507 100644
--- a/test/controllers/game_landing_controller_test.rb
+++ b/test/controllers/game_landing_controller_test.rb
@@ -13,21 +13,13 @@ class GameLandingControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to new_student_session_url
end
- test "show generates a game attempt token and embeds the game" do
+ test "show generates a game attempt token and embeds the external 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']"
+ iframe_src = URI(assert_select("iframe").first["src"])
+ assert_equal Rails.configuration.x.external_games.host, iframe_src.host
+ assert_includes iframe_src.path, @game.slug
+ assert_includes iframe_src.query, "token="
end
end
diff --git a/test/fixtures/game_attempts.yml b/test/fixtures/game_attempts.yml
index 8e0dd1f..273c6ab 100644
--- a/test/fixtures/game_attempts.yml
+++ b/test/fixtures/game_attempts.yml
@@ -9,7 +9,7 @@ one:
token: onetoken
two:
- outcome: expired
+ outcome: game_over
started_at: 2026-08-29 11:53:38
finished_at: 2026-08-29 11:53:38
student: two
diff --git a/test/integration/external_game_route_test.rb b/test/integration/external_game_route_test.rb
new file mode 100644
index 0000000..7391e56
--- /dev/null
+++ b/test/integration/external_game_route_test.rb
@@ -0,0 +1,10 @@
+require "test_helper"
+
+class ExternalGameRouteTest < ActionDispatch::IntegrationTest
+ test "the direct route works correctly" do
+ expected_host = "https://staging--endsideout-games.netlify.app"
+ assert_equal "#{expected_host}/game-1", external_game_url("game-1")
+ # ensure URL encoding is applied to the token query parameter
+ assert_equal "#{expected_host}/game-1?token=abc%3D%3D", external_game_url("game-1", token: "abc==")
+ end
+end
diff --git a/test/models/game_attempt_test.rb b/test/models/game_attempt_test.rb
index 35dfbf1..1d0844a 100644
--- a/test/models/game_attempt_test.rb
+++ b/test/models/game_attempt_test.rb
@@ -9,20 +9,20 @@ class GameAttemptTest < ActiveSupport::TestCase
attempt.start!
assert attempt.started?
+ refute attempt.finished?
end
- test "can complete a game attempt" do
+ test "can finish a game attempt" do
attempt = GameAttempt.create(student: students(:one), game: games(:one))
attempt.start!
refute attempt.finished?
- attempt.finish!("completed")
+ attempt.finish!(outcome: "win", score: 100)
assert attempt.finished?
end
-
test "new game attempts require a token" do
attempt = GameAttempt.new(student: students(:one), game: games(:one))
assert_not attempt.valid?