CEO Sim | Automata
sections (10)
$ cd .. // back to projects
automata@latam: ~/projects/ceo-sim
USE CASE · slug: ceo-sim ·2026 · 4 min read · 866 words

> CEO Sim

// Multiplayer business simulation game embedded into Moodle/Canvas/Blackboard via LTI 1.3 — auto-provisions users, deep-links to rounds, syncs grades back via AGS.

Laravel 10Angular 15MySQL 8Redis 7LTI 1.3JWTDocker Compose
CEO Sim
▸ role
Backend · LTI 1.3 Integration
▸ status
ready
// section 01 · discovery

$ cat ./discovery.md

▸ overview

CEO Sim is a multiplayer business simulation where students manage virtual companies in competitive rounds. The Laravel 10 backend orchestrates rounds, financials and inter-company interactions; Angular 15 renders dashboards, charts and player decisions; Redis handles caching, rate-limits and queues. The standout is the LTI 1.3 integration: instructors launch the game from Moodle, Canvas or Blackboard, users are auto-provisioned with the right role from JWT claims, instructors can deep-link to a specific round, and final scores sync back to the LMS gradebook via AGS.

▸ problem

Educational tools live or die by their LMS integration. Manually syncing users between an LMS and a simulation is a non-starter for an institution with hundreds of students per semester. LTI 1.3 is the open standard, but its OIDC handshake, JWKS rotation, role mapping per LMS and grade passback are non-trivial to implement correctly.

▸ audience

Business schools, MBA programs and corporate training departments that use Moodle/Canvas/Blackboard and want to teach business acumen through simulation.

// section 03 · architecture

$ cat ./architecture.md

// section 03b · sequences

$ cat ./sequences.md

// runtime flows — who talks to whom, in what order

// OIDC handshake, JWT validation against the LMS JWKS, and idempotent user provisioning.

LTI 1.3 launch — LMS to auto-provisioned user · flow-01
loading…
// section 04 · infrastructure

$ cat ./infrastructure.md

▸ services
provider: Laravel 10 + MySQL 8 + Redis 7 + Docker Compose
  • app (Laravel HTTP + queue)
  • db (MySQL 8)
  • redis (7 — cache, rate limit, queue)
  • front (Angular 15)
  • moodle (test LMS, docker compose service for local e2e of LTI flows)
// section 05 · implementation

$ cat ./implementation.md

▸ frontend
  • · Angular 15
  • · Angular Material
  • · HighCharts
  • · NgRx
  • · RxJS
  • · Socket.IO (live updates)
▸ backend
  • · Laravel 10
  • · PHP 8.1
  • · JWT (tymon/jwt-auth 2)
  • · oat-sa LTI 1.3 (Core + Deep Linking + NRPS + AGS)
  • · OpenAI PHP SDK (AI coach)
  • · Maatwebsite Excel
  • · Google API client
▸ data
  • · MySQL 8
  • · Redis 7 (cache + queues)
  • · Google Sheets API for offline reports
▸ devops
  • · Docker Compose (with a Moodle container for e2e tests)
  • · Swagger / OpenAPI docs
  • · MySQL backup scripts in local/backups/
// section 06 · technical challenges

$ cat ./challenges/*.md

// 3 technical problems solved

01 / 03
challenge-01.md · lti · oidc · auth
▸ problem

Letting an instructor launch CEO Sim from any LMS without manual user sync.

constraint: The OIDC handshake requires nonce + state stored across two HTTP hops. The id_token JWT must be validated against the LMS JWKS endpoint (different per platform). LMS role claims differ: Canvas's Instructor is not Moodle's Instructor in the same JSON shape. Deep linking lets instructors target a specific round.

▸ approach

LtiSsoService handles the OIDC initiation (nonce stored in Redis), the launch endpoint validates the id_token JWT, and LtiUserMappingRepository provisions or reuses a User idempotently. resolveRoleId() maps LMS role URIs to internal role_ids. LtiDeploymentRepository persists context + NRPS/AGS URLs per deployment. A ValidateLtiLaunch middleware intercepts every launch.

app/Services/Lti/LtiSsoService.php php
// On launch (id_token already validated):
$platform = LtiPlatformRepository::findByIssuerAndClientId($issuer, $clientId);
$mapping  = LtiUserMappingRepository::findByPlatformAndLtiUserId(
    $platform->id, $claims['sub']
);

if (! $mapping) {
    $user = User::firstOrCreate(['email' => $claims['email']], [
        'name'     => $claims['name'],
        'password' => Hash::make(Str::random(16)),
    ]);
    $mapping = LtiUserMappingRepository::create([
        'lti_platform_id' => $platform->id,
        'lti_user_id'     => $claims['sub'],
        'user_id'         => $user->id,
        'lti_roles'       => $claims['roles'],
    ]);
}

$user->update(['role_id' => $this->resolveRoleId($claims['roles'])]);
return new JwtResponse($user, $this->getToolToken($user));
challenge-02.md · lti · ags · grades
▸ problem

Syncing CEO Sim round scores back into the LMS gradebook without creating duplicate line items.

constraint: Each deployment can have multiple line items (Round 1, Round 2, Final). Scores are POSTed via AGS with a timestamp and a progress status. The LMS can return 404 if an instructor deleted a line item; the tool must handle this without failing the round.

▸ approach

ags_lineitems_url is captured at launch time and persisted on LtiDeployment. When a round closes, the grade service iterates closed scores and POSTs to AGS with scoreGiven/scoreMaximum/timestamp/activityProgress/gradingProgress. Failed deliveries land on the queue with backoff; 404s mark the line item inactive locally.

app/Services/Lti/LtiGradeService.php php
foreach ($scores as $score) {
    $payload = [
        'userId'          => $score->user->lti_user_id,
        'scoreGiven'      => $score->final_score,
        'scoreMaximum'    => 100,
        'timestamp'       => $score->closed_at->toRfc3339String(),
        'activityProgress' => 'Completed',
        'gradingProgress'  => 'FullyGraded',
    ];
    $this->agsSender->send($deployment->ags_lineitems_url, $payload);
}
challenge-03.md · lti · tenant · config
▸ problem

Onboarding a new LMS instance (each is its own tenant with its own RSA keypair).

constraint: Tool needs to store issuer, client_id, JWKS URL, login/token URLs, plus the tool's own private/public RSA keys. Mistakes here turn into 'Issuer not found' or 'Invalid JWT signature' — debuggable but slow if the LMS owner does not see the right error.

▸ approach

lti_platforms table holds the full set of fields per LMS. plan-lti-technical.md documents every field and the order of operations. The Moodle container in docker-compose runs CEO Sim end-to-end so the same flow is exercised locally before going to a real LMS.

database/migrations/*_create_lti_platforms_table.php php
Schema::create('lti_platforms', function (Blueprint $table) {
    $table->id();
    $table->string('issuer', 500)->unique();
    $table->string('client_id', 255);
    $table->string('auth_login_url', 500);
    $table->string('auth_token_url', 500);
    $table->string('jwks_url', 500);
    $table->unsignedBigInteger('license_id'); // tenant
    $table->text('tool_private_key');
    $table->text('tool_public_key');
    $table->string('tool_key_id', 100);
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});
// section 07 · testing & ci

$ cat ./testing.md

▸ strategy

Unit tests cover OIDC flow, JWKS validation, role mapping and provisioning idempotency. Integration tests use the Moodle docker-compose service to drive the full launch → grade-passback loop against a real LMS. LMS JWT signatures are mocked with test RSA keys.

▸ tools
PHPUnit 10Mockery 1.4Pest (via Laravel)Swagger / OpenAPI for API contract
// section 09 · results

$ cat ./results.md

01 /
1460
commits on the backend repo
02 /
60
Eloquent models
03 /
283
migrations
04 /
126
controllers
05 /
44
Angular components
▸ outcomes

Production-grade LTI 1.3 integration that authenticates users, provisions roles and syncs grades. The Moodle-in-Docker e2e setup lets the team validate the full LMS handshake before promoting to any real instance. The simulation itself (rounds, financials, scoring) is the older, more mature half of the codebase.

// section 10 · lessons learned

$ cat ./lessons.md

// if I did it again

  • 01 /

    LTI 1.3 is straightforward IF you test against a real LMS

    The spec is precise but lossy: a wrong claim mapping, a missing nonce, a mismatched JWKS — each produces a generic error from the LMS side. Standing up a Moodle docker container and running the entire flow locally cut the debug loop from hours to minutes.

  • 02 /

    Grade passback is async, treat it like email delivery

    First version POSTed scores inline at round close and surfaced 502s to the user. Moving AGS submissions to the queue with backoff (and a queue dashboard) made the failure mode operational instead of UX.

// related

$ grep -l "tech" ./projects/*

// projects that share part of the stack

// next step

$ automata deploy --your-operation

// Let's talk about adapting this to your case.

./let-s-talk.sh