PlaycademyPlaycademy

Timeback

Track learning activities and award XP with educational integration

Overview

Integrate your project with Timeback to track learning activities and award XP based on student performance.

Timeback is 1EdTech-compliant, enabling deep interoperability between learning applications and educational systems.

Playcademy simplifies Timeback integration - showing activity tracking workflow

Getting Started

Command
$ playcademy init
Output
# ...Integrations:? Would you like to set up Timeback integration? Yes? Select subjects: Math? Select grade levels: 3, 4
$ playcademy timeback init

Configuration

After enabling Timeback, your playcademy.config.js file will include Timeback configuration:

playcademy.config.js
export default {
    name: 'My Math Project',
    integrations: {
        // ↓ Added
        timeback: {
            courses: [
                {
                    subject: 'Math',
                    grade: 3,
                    totalXp: null, // TODO: Set before setup
                    masterableUnits: null, // TODO: Set before setup
                },
                {
                    subject: 'Math',
                    grade: 4,
                    totalXp: null, // TODO: Set before setup
                    masterableUnits: null, // TODO: Set before setup
                },
            ],
        },
    },
}

Required Configuration Before Setup

You must configure totalXp and masterableUnits for each course before running playcademy timeback setup.

Both are critical for accurate progress tracking and completion calculation.

Configure these values based on your educational content:

playcademy.config.js
timeback: {
    courses: [
        {
            subject: 'Math',
            grade: 3,
            totalXp: 1000, // Total XP available in grade 3
            masterableUnits: 10, // Total levels/ranks/skills in grade 3
        },
        // ... other courses
    ],
}

Run Setup

Once configured, create the OneRoster resources:

Command
$ playcademy timeback setup$ playcademy timeback verify
Output
✔ Created 3 course(s)✔ Timeback integration set up successfully!  Grade 3 (Math): course-id-1  Grade 4 (Math): course-id-2  Grade 5 (Math): course-id-3✔ Verification complete

Run Before Deploying

Run playcademy timeback setup before deploying your project.

Custom Configuration

If you need to customize the Timeback configuration, you can do so by modifying your playcademy.config.js file.

(Note that customizing the configuration is not required, and the default configuration will work for most use cases.)

playcademy.config.js
/**
 * Shared configuration applies to all courses
 */
export default {
    name: 'Math Adventure',
    integrations: {
        timeback: {
            base: {
                organization: {
                    name: 'My School District',
                    type: 'district',
                },
                course: {
                    title: '{appName} - Grade {grade}', // Template variables
                    totalXp: 100, // Default XP for all courses
                    masterableUnits: 10, // Default units for all courses
                },
            },
            courses: [
                { subject: 'Math', grade: 3 },
                { subject: 'Math', grade: 4 },
            ],
        },
    },
}
/**
 * Customize individual courses
 */
export default {
    name: 'Math Adventure',
    integrations: {
        timeback: {
            courses: [
                {
                    subject: 'Math',
                    grade: 3,
                    totalXp: 100,
                    masterableUnits: 10,
                },
                {
                    subject: 'Math',
                    grade: 4,
                    title: 'Advanced 4th Grade Math',
                    courseCode: 'ADV-MATH-4',
                    totalXp: 150,
                    masterableUnits: 15,
                },
            ],
        },
    },
}
/**
 * Base config + per-course overrides
 */
export default {
    name: 'Math Adventure',
    integrations: {
        timeback: {
            base: {
                organization: {
                    name: 'My School',
                },
                course: {
                    title: '{appName} - Grade {grade}',
                    totalXp: 100, // Default
                    masterableUnits: 10, // Default
                },
            },
            courses: [
                { subject: 'Math', grade: 3 },
                {
                    subject: 'Math',
                    grade: 4,
                    title: 'Custom Title', // Overrides base
                    totalXp: 150, // Overrides base
                    masterableUnits: 15, // Overrides base
                },
            ],
        },
    },
}

Template Variables

Use {grade}, {subject}, {appSlug}, {appName} in base config strings.

The CLI will expand these variables for you when creating OneRoster resources.


Management Commands

CommandDescriptionWhen to Use
timeback setupCreate OneRoster coursesAfter enabling Timeback
timeback verifyCheck course statusAfter running timeback setup
timeback updateSync config changesAfter modifying courses in config
timeback cleanupRemove Timeback integrationRemove Timeback but keep your project

View Full Command Documentation

See Timeback CLI Reference for all options.

Common Workflows

Run this after configuring your courses in playcademy.config.js to initialize all OneRoster resources.

This creates courses, components, and the interactive resource for your app.

Command
$ playcademy timeback setup

After modifying your course configuration (e.g., changing masterableUnits or adding grades), sync those changes to OneRoster.

Use this whenever you update your Timeback config to keep your OneRoster resources in sync.

Command
$ playcademy timeback update

Check that all Timeback resources were created successfully and are properly configured in OneRoster.

Run this after setup to confirm everything is ready.

Command
$ playcademy timeback verify

Clean up Timeback integration from OneRoster if you need to remove it.

This deletes the resources but keeps your project intact.

Command
$ playcademy timeback cleanup

Learning Loops

Every app has two fundamental time scales. Understanding them helps you think about what to track and when.

ConceptScopeDurationYou Track
SessionChain of activities in one sittingMinutesXP, accuracy, time
UnitMastery-based goal tied to a standard (may require multiple sessions)Hours/Days/WeeksmasteredUnits

Sessions

A session is a single, focused learning activity that students complete in one sitting.

Call startActivity() when it begins and endActivity() when it ends.

Every session awards 0 or more XP based on accuracy and active time.

Units

A unit is a discrete mastery-based milestone that students work toward across multiple sessions.

When a student completes a unit (beats a level, earns a rank, etc.), report masteredUnits: 1 in endActivity().

The platform accumulates these to calculate pctCompleteApp: (masteredUnits / masterableUnits) × 100.

When to Report Mastery

Mastery is typically achieved when a session meets minimum accuracy and maximum time thresholds.

For example: completing a quiz with ≥90% accuracy in under 5 minutes might demonstrate mastery.

Your app defines the thresholds that determine when a unit is mastered.

Progress

Set masterableUnits in playcademy.config.js to define the total number of units in your course.

As students complete units, report masteredUnits: 1 to track their progress toward course completion.

Course progression example showing three courses with a student enrolled in Grade 3, completing units and calculating pctCompleteApp

What counts as a "unit" depends on your app's structure:

App StructureExample Units
Level-basedLevels, stages, worlds
Rank-basedRanks, tiers, badges
Skills-basedSkills, competencies, standards
Module-basedModules, quizzes, chapters
Custom structuresAny discrete learning milestone

Configure masterableUnits per course:

playcademy.config.js
export default {
    name: 'Astro Math',
    integrations: {
        timeback: {
            courses: [
                {
                    subject: 'FastMath',
                    grade: 3,
                    totalXp: 300,
                    masterableUnits: 3, // Grade 3: 3 units
                },
                {
                    subject: 'FastMath',
                    grade: 4,
                    totalXp: 450,
                    masterableUnits: 5, // Grade 4: 5 units
                },
            ],
        },
    },
}

Activity Tracking vs Mastery Tracking?

When students complete a discrete learning unit, report masteredUnits: 1 in endActivity().

The platform tracks cumulative progress and calculates pctCompleteApp as (masteredUnits / masterableUnits) × 100


Using Timeback in Your App

The @playcademy/sdk provides everything you need to access student context and track learning activities.

Learn more about the client.timeback namespace.

User Context

Access the user's Timeback context via client.timeback.user.

id

The user's unique Timeback identifier:

Example
const id = client.timeback.user.id
// 'abc123-def456-...'

role

The user's primary Timeback role:

Example
const role = client.timeback.user.role
// 'student' | 'parent' | 'teacher' | 'administrator' | 'guardian'

enrollments

Array of courses the user is enrolled in, scoped to your project:

Example
const enrollments = client.timeback.user.enrollments
// [{ id: 'enrollment-abc', subject: 'FastMath', grade: 3, courseId: '...' }, ...]

const freshUser = await client.timeback.user.refresh({ only: ['enrollments'] })

App-Scoped

Enrollments are filtered to courses defined in your playcademy.config.js.

Enrollment refresh uses the same 5-minute cache as user.fetch(): pass { force: true } to bypass it.

organizations

Array of all organizations (schools/districts) the user is affiliated with:

Example
const orgs = client.timeback.user.organizations
// [{ id: '...', name: 'Playcademy Studios', type: 'school', isPrimary: true }, ...]

App-Scoped

Like enrollments, organizations are app-scoped.

Only organizations associated with the user's enrollments for your project are included.

fetch()

Fetch fresh user data from the server. Results are cached for 5 minutes by default.

Example
// Fetch fresh data (cached for 5 min)
const fresh = await client.timeback.user.fetch()

// Force refresh bypassing cache
const forced = await client.timeback.user.fetch({ force: true })

When to Fetch

The user context is initialized when the app loads.

Use fetch() if you need to ensure you have the latest data, such as after a user might have been enrolled in a new course mid-session.

xp

Access the current student's XP data via client.timeback.user.xp.fetch().

Example
// Get XP for all courses
const xp = await client.timeback.user.xp.fetch()
// → { totalXp: 1500 }

// Get XP for a specific grade/subject (must match a course in your config)
const xp = await client.timeback.user.xp.fetch({
    grade: 3,
    subject: 'Math',
})
// → { totalXp: 800 }

// Get XP with per-course breakdown + today's XP
const xp = await client.timeback.user.xp.fetch({
    include: ['perCourse', 'today'],
})
// → {
//     totalXp: 1500,
//     todayXp: 50,
//     courses: [
//       { grade: 3, subject: 'Math', title: 'Math Grade 3', totalXp: 800, todayXp: 30 },
//       { grade: 4, subject: 'Math', title: 'Math Grade 4', totalXp: 700, todayXp: 20 },
//     ]
//   }

// Force fresh data (bypass 5s cache)
const fresh = await client.timeback.user.xp.fetch({ force: true })

App-Scoped

XP is scoped to courses defined in your playcademy.config.{js,json} file.

This method only returns the student's XP for your app, not their total XP across all of Timeback.

Options
FieldTypeDescriptionDefault
gradenumberGrade level to filter (must be used with subject)All grades
subjectstringSubject to filter (must be used with grade)All subjects
includestring[]Additional data: 'perCourse', 'today'[]
forcebooleanBypass cache and fetch fresh datafalse
Response
FieldTypeDescription
totalXpnumberTotal XP across queried courses
todayXpnumberToday's XP (if 'today' included)
coursesarrayPer-course breakdown (if 'perCourse' included)

Caching

XP data is cached for 5 seconds to avoid redundant network requests. Use force: true to bypass the cache.

mastery

Access the current student's mastery progress via client.timeback.user.mastery.fetch().

Example
// Get mastery for all courses
const mastery = await client.timeback.user.mastery.fetch()
// → { totalMasteredUnits: 7, totalMasterableUnits: 10 }

// Get mastery for a specific grade/subject (must match a course in your config)
const mastery = await client.timeback.user.mastery.fetch({
    grade: 3,
    subject: 'Math',
})
// → { totalMasteredUnits: 3, totalMasterableUnits: 3 }

// Get mastery with per-course breakdown
const mastery = await client.timeback.user.mastery.fetch({
    include: ['perCourse'],
})
// → {
//     totalMasteredUnits: 7,
//     totalMasterableUnits: 10,
//     courses: [
//       { grade: 3, subject: 'Math', title: 'Math Grade 3',
//         masteredUnits: 3, masterableUnits: 3, pctComplete: 100, isComplete: true },
//       { grade: 4, subject: 'Math', title: 'Math Grade 4',
//         masteredUnits: 4, masterableUnits: 7, pctComplete: 57.14, isComplete: false },
//     ]
//   }

// Force fresh data (bypass 5s cache)
const fresh = await client.timeback.user.mastery.fetch({ force: true })

App-Scoped

Mastery is scoped to courses defined in your playcademy.config.{js,json} file.

This method only returns the student's mastery for your app, not their total mastery across all of Timeback.

Options
FieldTypeDescriptionDefault
gradenumberGrade level to filter (must be used with subject)All grades
subjectstringSubject to filter (must be used with grade)All subjects
includestring[]Additional data: 'perCourse'[]
forcebooleanBypass cache and fetch fresh datafalse
Response
FieldTypeDescription
totalMasteredUnitsnumberTotal mastered units across queried courses
totalMasterableUnitsnumberTotal masterable units across queried courses
coursesarrayPer-course breakdown (if 'perCourse' included)

Caching

Mastery data is cached for 5 seconds to avoid redundant network requests. Use force: true to bypass the cache.

highestGradeMastered

Access the current student's highest grade mastered for a subject via client.timeback.user.highestGradeMastered.fetch().

Example
const hgm = await client.timeback.user.highestGradeMastered.fetch({
    subject: 'Math',
})
// → { subject: 'Math', highestGradeMastered: 4 }

if (hgm.highestGradeMastered === null) {
    // No mastered grade has been established for this subject yet.
    // Fall back to your normal starting point, such as enrollment grade.
}

// Force fresh data (bypass 5s cache)
const fresh = await client.timeback.user.highestGradeMastered.fetch({
    subject: 'Math',
    force: true,
})

Subject-Scoped

Highest grade mastered is queried by subject and uses Timeback EduBridge's highestGradeOverall value.

The requested subject must match at least one course in your playcademy.config.{js,json} file.

Options
FieldTypeDescriptionDefault
subjectstringSubject to queryRequired
forcebooleanBypass cache and fetch fresh datafalse
Response
FieldTypeDescription
subjectstringSubject used for the lookup
highestGradeMasterednumber | nullHighest grade mastered, or null when no data exists
No mastered grade yet
{
    "subject": "Math",
    "highestGradeMastered": null
}

Null Means Not Established

highestGradeMastered: null is a valid response, not a failed lookup. It means Timeback does not have a mastered-grade signal for that subject yet. Games should not coerce it to 0 or grade 1, and should not block the player. Fall back to your existing placement behavior, such as the student's enrolled grade, the lowest configured grade, or your normal onboarding flow.

Caching

Highest-grade-mastered data is cached for 5 seconds to avoid redundant network requests. Use force: true to bypass the cache.

Activity Tracking

Automatic Tracking Behavior

Once you call startActivity(), the SDK handles the rest without any game-side wiring:

  • Periodic heartbeats: Active/paused time is flushed every 15 seconds
  • Visibility-aware pause: Timer auto-pauses when the tab is hidden; the window resets if hidden past 10 minutes
  • Unload flush: A final heartbeat is sent on pagehide, so closing the tab still reports the last window

Each of these behaviors can be tuned via the options parameter to startActivity().

startActivity

Begin tracking a learning activity. Starts an internal timer and prepares data for OneRoster submission.

startActivity Example
// Minimal (most common)
const { runId } = client.timeback.startActivity({
    activityId: 'math-quiz-1',
    grade: 3,
    subject: 'Math',
})
// Activity name auto-derived: "Math Quiz 1"

// With custom name override
client.timeback.startActivity({
    activityId: 'multiplication-drill',
    activityName: 'Advanced Multiplication Drill',
    grade: 4,
    subject: 'Math',
})
Required Fields
FieldTypeDescriptionExample
activityIdstringIdentifier for this activity"math-quiz-1"
gradenumberGrade level3
subjectstringSubject area"Math"
Optional Fields
FieldTypeDescriptionDefault
activityNamestringDisplay name for the activityPrettified activityId
appNamestringApplication nameFrom playcademy.config.{js,json}
sensorUrlstringURL where activity is hostedDeployed project URL

Course Routing

The grade and subject fields determine which OneRoster course receives the activity data.

Ensure these match a course in your playcademy.config.{js,json} Timeback configuration.

Options

startActivity() accepts an optional second argument to tune tracking behavior:

Options Example
// Stop paused heartbeats after 5 minutes of hidden/idle auto-pause
client.timeback.startActivity(
    { activityId: 'math-quiz-1', grade: 3, subject: 'Math' },
    { pausedHeartbeatTimeoutMs: 5 * 60 * 1000 },
)

// Mark the activity inactive after 2 minutes without visible keyboard/mouse input
client.timeback.startActivity(
    { activityId: 'math-quiz-1', grade: 3, subject: 'Math' },
    { inactivityTimeoutMs: 2 * 60 * 1000 },
)

// Disable the periodic heartbeat interval (final unload/endActivity
// flushes still run)
client.timeback.startActivity(
    { activityId: 'math-quiz-1', grade: 3, subject: 'Math' },
    { heartbeatIntervalMs: Infinity },
)

// Resumable activity - reuse a stable runId across sessions so the
// dashboard can correlate related sessions into a single run
const activityId = 'math-quiz-1'
const runId = localStorage.getItem(`run:${activityId}`) ?? crypto.randomUUID()
localStorage.setItem(`run:${activityId}`, runId)
client.timeback.startActivity({ activityId, grade: 3, subject: 'Math' }, { runId })

startActivity() returns { runId }, using your supplied UUID when provided or a new SDK-generated UUID otherwise. During an active activity, the same value is available as client.timeback.currentRunId; it is undefined when no activity is active.

FieldTypeDescriptionDefault
pausedHeartbeatTimeoutMsnumberStop periodic heartbeats after a long automatic pause (Infinity disables)600000 (10 m)
heartbeatIntervalMsnumberHeartbeat flush interval (Infinity disables)15000 (15 s)
inactivityTimeoutMsnumberVisible keyboard/mouse idle timeout before pausing600000 (10 m)
runIdstringStable UUID to correlate resumable activitiesRandom UUID

Resumable Activities

If a single logical activity spans multiple sessions (student closes the app and comes back later), persist a runId in a storage medium of your choice and pass the same value on every startActivity() call.

Downstream systems use runId to collate heartbeats and endActivity events into a single run, so the dashboard shows the correct total time-on-task instead of just the final session.

When omitted, the SDK generates a fresh UUID on every call and each session is treated as its own run.

endActivity

End the current activity and submit results to OneRoster. Calculates XP based on accuracy and active time.

endActivity Example
// Auto-calculate XP based on score
await client.timeback.endActivity({
    correctQuestions: 8,
    totalQuestions: 10,
})

// Override XP calculation
await client.timeback.endActivity({
    correctQuestions: 8,
    totalQuestions: 10,
    xpAwarded: 15, // award exactly 15 XP
})

// Report mastery (e.g., unit completed)
await client.timeback.endActivity({
    correctQuestions: 8,
    totalQuestions: 10,
    masteredUnits: 1, // student mastered 1 unit
})

// Report absolute mastery (platform computes the delta)
await client.timeback.endActivity({
    correctQuestions: 8,
    totalQuestions: 10,
    masteredUnitsAbsolute: 7, // student has mastered 7 units total
})

// Attach custom Caliper extensions
await client.timeback.endActivity({
    correctQuestions: 3,
    totalQuestions: 10,
    extensions: {
        reason: 'below-threshold',
        threshold: 8,
        scored: 3,
    },
})

When to Report Mastery

Send masteredUnits: 1 when the student completes a discrete learning unit in your app:

  • Level-based: Student completes a level, stage, or world
  • Rank-based: Student earns a rank, tier, or badge
  • Skills-based: Student masters a skill, competency, or standard
  • Module-based: Student completes a module, quiz, or chapter

Pass a negative value (e.g. masteredUnits: -1) to subtract mastery after a failed reassessment.

The platform tracks mastery and calculates completion automatically based on your mastery configuration.

Alternatively: use masteredUnitsAbsolute to report the student's total mastered units. The platform computes the delta from the current recorded value. This is useful when your app tracks cumulative progress and doesn't want to calculate increments itself. You cannot provide both fields in the same call.

Required Fields
FieldTypeDescriptionExample
correctQuestionsnumberNumber of correct answers8
totalQuestionsnumberTotal number of questions10
Optional Fields
FieldTypeDescriptionDefault
xpAwardednumberOverride automatic XP calculationBased on time and accuracy
masteredUnitsnumberUnits mastered (negative to subtract)0
masteredUnitsAbsolutenumberTotal mastered units (platform computes delta)Not sent
extensionsobjectCustom Caliper metadataNot sent

Custom Extensions

Use extensions as an escape hatch for custom Caliper event metadata that should be recorded with the completed activity.

These values are attached to the progress event only. They do not affect course routing, timing, or XP calculation unless your downstream Timeback processing explicitly reads them.

XP Calculation

By default, XP is calculated as:

Base XP = Active time in minutes × Accuracy multiplier

AccuracyMultiplierExample (10 min)
100%1.25×10 min × 1.25 = 12.5 XP
80-99%1.0×10 min × 1.0 = 10 XP
< 80%0 XP (mastery not demonstrated)

Base rate: 1 minute of active learning = 1 XP

Re-attempts earn diminishing XP: 50% on 1st re-attempt, 25% on 2nd, 0% on 3rd+.

course.advance

Advance the current student to the next configured course in their Timeback ladder.

Call this after your app has reported enough mastery with endActivity(). The platform resolves the student's current course from their active enrollments, verifies the course is complete, then enrolls them in the next grade before unenrolling them from the current grade.

course.advance Example
// Single-ladder game: no arguments needed
const result = await client.timeback.course.advance()

if (result.promotion.status === 'promoted') {
    // Student moved from result.promotion.currentCourseId
    // to result.promotion.nextCourseId
}

if (result.promotion.status === 'no-next-course') {
    // Student completed the final configured course for this ladder
}

if (result.promotion.status === 'not-mastered') {
    // Student has more units to master before moving on
}

// Multi-ladder game: disambiguate by subject when needed
await client.timeback.course.advance({ subject: 'Math' })
Promotion Results
StatusMeaning
promotedThe student was enrolled in the next course and removed from this one
already-promotedThe student is already enrolled in the next course
no-next-courseThe current course is the last configured course in this ladder
not-masteredThe student has not mastered all units in the current course yet

Errors vs Results

Promotion outcomes are returned as values so your app can react in normal control flow. For example, no-next-course means the student reached the end of the configured ladder, and not-mastered means the student needs to complete more units before advancing.

Configuration and validation problems still reject as SDK ApiErrors. This includes missing Timeback setup, ambiguous multi-subject enrollment without { subject }, no active enrollment in this game's ladder, and unavailable mastery analytics.

course.unenroll

Unenroll the current student from their active course in the Timeback ladder.

By default, the platform verifies that the current course is mastered before removing the student. Pass force: true only when your game intentionally allows a student to leave the course early.

course.unenroll Example
// Single-ladder game: no arguments needed
const result = await client.timeback.course.unenroll()

if (result.unenrollment.status === 'unenrolled') {
    // Student was removed from result.unenrollment.currentCourseId
}

if (result.unenrollment.status === 'not-mastered') {
    // Student has more units to master before unenrolling
}

// Multi-ladder early exit: disambiguate and explicitly bypass mastery
await client.timeback.course.unenroll({ subject: 'Math', force: true })
Unenrollment Results
StatusMeaning
unenrolledThe student was removed from the current course
not-masteredThe student has not mastered all units in the current course yet

Forced early exits are written to service logs with the student, game, course, subject, grade, mastery counts when available, and requesting developer.

pauseActivity

Pause the activity timer. Use this during non-instructional moments like showing feedback or explanations.

pauseActivity Example
client.timeback.startActivity({
    activityId: 'speed-math-1',
    grade: 4,
    subject: 'Math',
})

// Student attempts a problem...

if (studentAnswerWrong) {
    // Pause timer during feedback
    client.timeback.pauseActivity()

    // Show correct answer or explanation
    await showCorrectAnswer()

    // Resume timer when they continue
    client.timeback.resumeActivity()
}

// End activity (only active time counted)
await client.timeback.endActivity({
    correctQuestions: 40,
    totalQuestions: 50,
})

When to Pause

Pause when:

  • Tutorial/instruction screens
  • Showing hints or explanations
  • Waiting for external resources to load
  • Any non-active learning time

This ensures XP reflects actual learning time.

resumeActivity

Resume the activity timer after a pause.

// After pausing
client.timeback.pauseActivity()

// ... show feedback ...

// Resume when ready
client.timeback.resumeActivity()

Must Call startActivity First

You must call startActivity() before using pauseActivity() or resumeActivity().

Calling these methods without an active activity will log a warning.


Game Metrics Reporting

If your game stores its own progress data, you can expose it to the platform so game-side totals and per-run activity metrics can be compared against Timeback analytics in the dashboard.

Add server/lib/metrics.ts and export a getMetrics function that returns your game's totals. When the dashboard is comparing visible activity runs, ctx.runIds contains the Timeback run IDs being requested so your resolver can filter its response.

server/lib/metrics.ts
import { eq } from 'drizzle-orm'

import { getDb, schema } from '../db'

import type { GameCourseMetrics, GameMetricsResponse, GameRunMetrics } from '@playcademy/sdk/types'

interface CourseAccumulator {
    grade: GameCourseMetrics['grade']
    subject: GameCourseMetrics['subject']
    totalXp: number
    masteredUnits: number
    activeTimeSeconds: number
    activities: GameRunMetrics[]
}

export async function getMetrics(ctx: MetricsResolverContext): Promise<GameMetricsResponse> {
    const db = getDb(ctx.env.DB)
    const rows = await db
        .select()
        .from(schema.progress)
        .where(eq(schema.progress.playcademyUserId, ctx.studentId))
    const requestedRunIds = new Set(ctx.runIds)
    const courses = new Map<string, CourseAccumulator>()

    for (const row of rows) {
        const courseKey = `${row.grade}:${row.subject}`
        let course = courses.get(courseKey)

        if (!course) {
            course = {
                grade: row.grade as GameCourseMetrics['grade'],
                subject: row.subject as GameCourseMetrics['subject'],
                totalXp: 0,
                masteredUnits: 0,
                activeTimeSeconds: 0,
                activities: [],
            }
            courses.set(courseKey, course)
        }

        course.totalXp += row.totalXp
        course.masteredUnits += row.masteredUnits
        course.activeTimeSeconds += row.activeTimeSeconds

        if (requestedRunIds.size > 0 && !requestedRunIds.has(row.runId)) {
            continue
        }

        course.activities.push({
            runId: row.runId,
            activityId: row.activityId,
            totalXp: row.totalXp,
            masteredUnits: row.masteredUnits,
            activeTimeSeconds: row.activeTimeSeconds,
            ...(row.score == null ? {} : { score: row.score }),
        })
    }

    return {
        studentId: ctx.studentId,
        courses: [...courses.values()],
    }
}
server/lib/metrics.ts
import type { GameMetricsResponse } from '@playcademy/sdk/types'

export async function getMetrics(ctx: MetricsResolverContext): Promise<GameMetricsResponse> {
    const data = await ctx.env.KV.get(`${ctx.studentId}:data`, 'json')

    return {
        studentId: ctx.studentId,
        courses: data?.courses ?? [],
    }
}

The studentId on the context object is the Playcademy user ID for the student being queried. Match grade and subject to the courses in your playcademy.config.js so the dashboard can pair each entry with the corresponding Timeback analytics.

When ctx.runIds is present, keep course totals course-wide and filter only the returned activities list to the requested runs.

Metric fields are optional. If your game only tracks XP and mastered units, omit activeTimeSeconds; the dashboard will show that time was not reported by the game instead of treating it as zero.

Optional

If you don't add a metrics resolver, the dashboard simply skips the comparison.


Local Development

The Vite Plugin automatically enrolls mock users in all courses defined in your playcademy.config.js.

vite.config.ts
export default defineConfig({
    plugins: [
        playcademy(), // All courses enrolled automatically with mock data
    ],
})

Customization

Override defaults for specific testing scenarios:

vite.config.ts
playcademy({
    timeback: {
        id: '...', // real student sourcedId for live testing
        role: 'teacher', // test different user roles
        organization: { id: '...', name: '...', type: 'school' }, // custom organization
        courses: {
            // FastMath:3 is enrolled by default if it is defined in your playcademy.config.js
            'FastMath:4': false, // not enrolled
            'FastMath:5': '00000033-0003-0003-0003-000000000003', // real course ID for integration testing
        },
    },
})

Use null or false to exclude a course from enrollment.

This can be useful for testing how your app behaves when a student is enrolled in specific grades only.

Full Configuration Options

Learn more about Timeback configuration.

Live Timeback Integration

To test against live Timeback services, you must also your .env file with credentials:

.env
# Required: Timeback API credentials
TIMEBACK_API_CLIENT_ID=your-client-id
TIMEBACK_API_CLIENT_SECRET=your-client-secret
TIMEBACK_API_AUTH_URL=https://auth.example.com

# Required: OneRoster and Caliper endpoints
TIMEBACK_ONEROSTER_API_URL=https://oneroster.example.com
TIMEBACK_CALIPER_API_URL=https://caliper.example.com

See Timeback Authentication and Endpoints for details.

Hotkeys

Press these keys in the terminal during development:

KeyAction
tCycle Timeback role (student → parent → teacher → ...)

See Development Mode for full configuration options.


What's Next?

On this page