PlaycademyPlaycademy

Browser

Client-side SDK for browser environments

Overview

The browser SDK provides namespaces for interacting with the Playcademy platform from your project frontend.

All methods are accessed through the PlaycademyClient instance.

import { PlaycademyClient } from '@playcademy/sdk'

const client = await PlaycademyClient.init()

const user = await client.users.me() // get current user
await client.scores.submit(1500, { level: 5 }) // submit score

Initialization

PlaycademyClient.init() automatically detects your environment and configures the client accordingly.

See SDK Initialization for details.


Core Namespaces

client.users

Retrieve current user information.

// Get current user
const user = await client.users.me()

client.scores

Submit scores for your project.

// Submit a score
const result = await client.scores.submit(1500, {
    level: 5,
    difficulty: 'hard',
    perfectRun: true,
})

client.identity

Connect external identity providers (Google, Discord, etc.) to user accounts.

// Connect Google account
const result = await client.identity.connect({
    provider: 'google',
    callbackUrl: '/auth/callback',
})

if (result.success) {
    console.log('Connected:', result.user)
}

Integration Namespaces

client.timeback

Track learning activities with automatic XP calculation. Access user context, XP, and mastery progress for content gating.

user

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

Example
const id = client.timeback.user.id // User's Timeback ID
const role = client.timeback.user.role // 'student' | 'parent' | 'teacher' | ...
const enrollments = client.timeback.user.enrollments // App-scoped course enrollments (with enrollment id)
const orgs = client.timeback.user.organizations // App-scoped organizations

// Fetch fresh data from server (cached for 5 min)
const fresh = await client.timeback.user.fetch()
const forced = await client.timeback.user.fetch({ force: true })

// Refresh only enrollments from server (cached for 5 min)
const freshUser = await client.timeback.user.refresh({ only: ['enrollments'] })

// XP and mastery (cached for 5s)
const xp = await client.timeback.user.xp.fetch()
const mastery = await client.timeback.user.mastery.fetch()
const hgm = await client.timeback.user.highestGradeMastered.fetch({ subject: 'Math' })

if (hgm.highestGradeMastered === null) {
    // No mastered grade has been established for this subject yet.
}

Refresh & Fetch

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

See Timeback Integration → User Context for full xp, mastery, and highestGradeMastered options, including the highestGradeMastered: null response when no mastered grade exists yet.

App-Scoped

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

startActivity

Start tracking an activity. Only activityId is required:

Example
// Minimal (most common)
const { runId: generatedRunId } = client.timeback.startActivity({
    activityId: 'math-quiz-1', // automatically derived to "Math Quiz 1"
})

// With custom name override
client.timeback.startActivity({
    activityId: 'math-quiz-1',
    activityName: 'Advanced Multiplication Quiz',
})

// Resumable activity - reuse a stable runId across sessions
const activityId = 'math-quiz-1'
const runId = localStorage.getItem(`run:${activityId}`) ?? crypto.randomUUID()
localStorage.setItem(`run:${activityId}`, runId)
client.timeback.startActivity({ activityId }, { 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.

Auto-filled Metadata

The SDK automatically fills in metadata from your project config:

  • activityName: Derived from activityId ("math-quiz-1" → "Math Quiz 1")
  • appName, subject, sensorUrl: From playcademy.config.{js,json}

You can override any of these by providing them explicitly.

Automatic Tracking

Once started, the SDK flushes heartbeats every 15 seconds, auto-pauses the timer when the tab is hidden, and marks the activity inactive after long visible-idle stretches.

Tune it via the optional second argument (pausedHeartbeatTimeoutMs, heartbeatIntervalMs, inactivityTimeoutMs, runId).

See Timeback Integration → Activity Tracking for more details.

endActivity

End the current activity and submit results:

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 progress
await client.timeback.endActivity({
    correctQuestions: 8,
    totalQuestions: 10,
    masteredUnits: 1, // incremental
    // or: masteredUnitsAbsolute: 7 (absolute total, platform computes delta)
})

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

course.advance

Move the current student to the next configured Timeback course after mastery is complete:

Example
const result = await client.timeback.course.advance()

if (result.promotion.status === 'promoted') {
    console.log('Advanced to:', result.promotion.nextCourseId)
}

if (result.promotion.status === 'no-next-course') {
    console.log('Student completed the final configured course')
}

if (result.promotion.status === 'not-mastered') {
    console.log(
        `Keep practicing: ${result.promotion.masteredUnits}/${result.promotion.masterableUnits} units mastered`,
    )
}

// For games with multiple subject ladders
await client.timeback.course.advance({ subject: 'Math' })

no-next-course and not-mastered are returned as promotion results. Validation and configuration issues, such as unavailable mastery analytics or ambiguous multi-subject enrollment, reject as SDK ApiErrors.

course.unenroll

Unenroll the current student from their active Timeback course after mastery is complete:

Example
const result = await client.timeback.course.unenroll()

if (result.unenrollment.status === 'unenrolled') {
    console.log('Unenrolled from:', result.unenrollment.currentCourseId)
}

if (result.unenrollment.status === 'not-mastered') {
    console.log('Student must finish mastery before unenrolling')
}

// Explicitly allow an early exit
await client.timeback.course.unenroll({ subject: 'Math', force: true })

Forced early exits are logged by the platform for operational review.

{pause,resume}Activity

Pause the timer during instructional moments and resume when ready:

Example
client.timeback.startActivity({ activityId: 'math-quiz-1' })

// Student attempts problems...

if (studentAnswerWrong) {
    // Pause timer to show correct answer
    client.timeback.pauseActivity()

    // Let student learn from mistake
    showCorrectAnswer()

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

// End activity (only active problem-solving time counted)
await client.timeback.endActivity({
    correctQuestions: 8,
    totalQuestions: 10,
})

See Timeback Integration for complete documentation.

client.backend

Call your backend API routes.

These methods connect to the server-side routes you create in your server/api/ directory.

Learn more here.

Always Use sdk.backend

Don't use plain fetch() for your backend routes.

The SDK automatically includes the platform authentication token, which is required for c.get('playcademyUser') to work in your routes.

// ✅ Correct - includes platform token
const data = await client.backend.get('/my-route')

// ❌ Wrong - playcademyUser will be null
const data = await fetch('/api/my-route').then(r => r.json())

get

Make GET requests:

const data = await client.backend.get('/hello')
console.log(data.message)

// With custom headers
const data = await client.backend.get('/protected', {
    'X-Custom-Header': 'value',
})

post / put / patch

Make requests with body data:

// POST request
const result = await client.backend.post('/validate', {
    answer: 'paris',
})

// PUT request
await client.backend.put('/settings', {
    volume: 0.8,
})

// PATCH request
await client.backend.patch('/profile', {
    displayName: 'NewName',
})

delete

Delete resources:

await client.backend.delete('/cache/clear')

download

Download binary files:

const response = await client.backend.download('/files?key=report.pdf')
const blob = await response.blob()

// Trigger browser download
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'report.pdf'
a.click()
URL.revokeObjectURL(url)

Downloading Files

The download method returns a raw Response object, allowing you to access the blob, streams, or other binary data.

url

Build URLs for HTML elements (images, videos, etc.):

// Tagged template literal (recommended)
const imageUrl = client.backend.url`/assets/${sprite.key}`
<img src={imageUrl} />

// Regular function call
const videoUrl = client.backend.url('/videos/intro.mp4')
<video src={videoUrl} />

Perfect for catchall routes that serve files by path.

See Custom Routes and Bucket Storage for more examples.


Runtime & Lifecycle

client.runtime

Game lifecycle events, messaging, and static asset loading.

// Signal ready
client.runtime.ready()

// Exit
client.runtime.exit()

// Listen for pause
client.runtime.onPause(() => {
    pauseGame()
})

// Listen for resume
client.runtime.onResume(() => {
    resumeGame()
})

// Load static assets at runtime
const levelData = await client.runtime.assets.json`levels/level-${id}.json`
img.src = client.runtime.assets.url`badges/${badgeType}.png`
audio.src = client.runtime.assets.url`sfx/${soundEffect}.wav`

client.demo

Demo-mode identity and lifecycle helpers.

Always present on the client, but each method throws when client.mode is not 'demo'. Gate your demo-specific UI paths on the mode check and call freely inside that branch.

Use it to manage the anonymous demo player's display name and tell the host landing-page shell when the demo has ended.

Example
if (client.mode === 'demo') {
    const profile = await client.demo.profile.get()

    if (profile.isDefault) {
        await client.demo.profile.update({ displayName: 'linus-torvalds' })
    }

    client.demo.end(1500, {
        durationMs: 60_000,
        metadata: { level: 3, outcome: 'won' },
    })
}
Method / ArgumentTypeRequiredDescription
profile.get()Promise<{ displayName: string; isDefault: boolean }>Read the anonymous demo player's profile. isDefault tells you if the handle is still the placeholder.
profile.update(updates)Promise<{ displayName: string; isDefault: boolean }>Update the demo profile (today: required { displayName }) and get the updated row back
end(score, options?)voidSignal the end of the demo round to the landing shell. score is required; options (below) are extras
options.durationMsnumberActive demo time in milliseconds
options.metadataRecord<string, unknown>Any additional data the shell needs

See also

See Launch Modes to learn more about the different launch modes and what is available in each mode.


Event System

Listen for platform events:

// Pause event
client.on('pause', () => {
    pause()
})

// Resume event
client.on('resume', () => {
    resume()
})

// Exit requested
client.on('exit', () => {
    cleanup()
})

What's Next?

On this page