Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,5 @@ end
gem "tailwindcss-rails", "~> 4.6"
# Used for database seeding
gem "faker", "~> 3.8"
# Handles CORS for the game attempts API, which the games call from another origin
gem "rack-cors", "~> 3.0"
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ GEM
raabro (1.5.0)
racc (1.8.1)
rack (3.2.7)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
Expand Down Expand Up @@ -444,6 +447,7 @@ DEPENDENCIES
kamal
propshaft
puma (>= 5.0)
rack-cors (~> 3.0)
rails (~> 8.1.4)
reactionview
rubocop-rails-omakase
Expand Down Expand Up @@ -573,6 +577,7 @@ CHECKSUMS
raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
rack-cors (3.0.0) sha256=7b95be61db39606906b61b83bd7203fa802b0ceaaad8fcb2fef39e097bf53f68
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
rack-test (2.3.0) sha256=b118d177cc002c572fc1b6fd8d19c9ed5881a36e8d6f689f9e618f9f861241e8
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Curriculum is delivered through **schools** and their **classrooms**. A classroo

See [Architecture Decision Records (ADRs)](docs/adrs/README.md) for architectural decisions and guidelines.

See [Game API](docs/game-api.md) for how games report start and finish to the LMS.

## Getting Started 🛠️

Built on **Ruby on Rails 8** with Hotwire, Tailwind, SQLite, and the Solid stack (Queue/Cache/Cable), deployed with Kamal.
Expand Down
12 changes: 9 additions & 3 deletions app/controllers/game_attempts_controller.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
class GameAttemptsController < ApplicationController
# Games run on another origin and have no Rails session. The signed token is the credential.
skip_forgery_protection

before_action :game_attempt_from_token, only: %i[start finish]

rescue_from ActiveSupport::MessageVerifier::InvalidSignature do
render json: { status: "error", message: "Invalid token" }, status: :unprocessable_entity
end

def start
@game_attempt.start!
render json: { status: "ok" }
end

def finish
if !@game_attempt.started?
unless @game_attempt.started?
render json: { status: "error", message: "Game attempt has not been started" }, status: :unprocessable_entity
return
end

if @game_attempt.update(game_attempt_params)
@game_attempt.finish!
if @game_attempt.finish!(game_attempt_params)
render json: { status: "ok" }
end
end
Expand Down
9 changes: 0 additions & 9 deletions app/controllers/game_landing_controller.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
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
73 changes: 0 additions & 73 deletions app/javascript/controllers/quiz_controller.js

This file was deleted.

4 changes: 2 additions & 2 deletions app/models/game_attempt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ def started?
started_at.present?
end

def finish!(outcome = "completed")
update(finished_at: Time.current, outcome: outcome)
def finish!(params)
update(params.merge(finished_at: Time.current))
end

def finished?
Expand Down
7 changes: 0 additions & 7 deletions app/views/game_landing/embed.html.erb

This file was deleted.

2 changes: 1 addition & 1 deletion app/views/game_landing/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

<h1 class="text-xl font-bold mb-4"><%= @game.title %></h1>

<%= 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 %>
16 changes: 0 additions & 16 deletions app/views/layouts/embed.html.erb

This file was deleted.

11 changes: 11 additions & 0 deletions config/initializers/cors.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions config/initializers/external_games.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
137 changes: 137 additions & 0 deletions docs/game-api.md
Original file line number Diff line number Diff line change
@@ -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://<game host>/<slug>?token=<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. |
Loading
Loading