BricqsBricqs

Headless SDK: challenges

Wire a multi-step challenge into your own UI in under 50 lines. The SDK handles state, polling, optimistic updates, and reward claim. You write the markup.

Last updatedMay 2026

Key takeaways

Quick read
  • useChallenge returns the active challenge state, objective progress, and completion handlers.
  • Objective state is polled every 15 seconds. Use submitEvent to update optimistically when you control the action.
  • Completion fires automatically server-side. Listen with onCompleted for the handoff (claim reward, route to success).
  • Always render the full path from the start. Hidden objectives kill completion rate.
  • Use useChallenge inside a single screen. For shared dashboards, use useChallenges (plural) with a filter.

Quickstart

A working challenge in 40 lines

components/OnboardingChallenge.tsx·tsx
"use client";
import { useChallenge } from "@bricqs/headless-react";

export function OnboardingChallenge() {
  const {
    challenge,
    objectives,
    isCompleted,
    progressPercent,
    isLoading,
    error,
    onCompleted,
  } = useChallenge({ challengeId: "onboarding_v3" });

  // Side-effect handler for the completion moment
  onCompleted((reward) => {
    // route to success, claim reward, fire analytics
    window.location.href = `/welcome/done?reward=${reward.id}`;
  });

  if (isLoading) return <div className="animate-pulse h-12" />;
  if (error) return <p>Could not load the challenge.</p>;

  return (
    <section>
      <header className="flex items-baseline justify-between mb-4">
        <h2 className="font-bold">{challenge.title}</h2>
        <span className="text-sm text-slate-500">{progressPercent}% done</span>
      </header>
      <ol className="space-y-2">
        {objectives.map((o) => (
          <li
            key={o.id}
            className={`flex items-center gap-3 ${o.isComplete ? "opacity-60" : ""}`}
          >
            <span>{o.isComplete ? "✓" : "○"}</span>
            <span>{o.label}</span>
            {!o.isComplete && o.cta && (
              <a className="ml-auto text-blue-600" href={o.cta.href}>
                {o.cta.label}
              </a>
            )}
          </li>
        ))}
      </ol>
      {isCompleted && (
        <p className="mt-4 text-green-700 font-semibold">Challenge complete</p>
      )}
    </section>
  );
}

Anatomy

What the hook returns

Three concepts cover almost every UI you will build.

API

GET /challenges/:id/state returns objectives, progress, and reward. The hook polls this every 15 seconds.

SDK

useChallenge({ challengeId }) gives you the same shape, plus loading state, completion callback, and submitEvent for optimistic updates.

User sees

A live progress bar, a checklist that advances as the user acts, and a completion moment that hands off to your reward UI.

Optimistic updates

Update the UI before the server confirms

Polling is fine for slow-changing screens. For active flows where the user just did the action, submit the event from the client and let the SDK reconcile.

components/OnboardingStep.tsx·tsx
"use client";
import { useChallenge } from "@bricqs/headless-react";

export function CompleteProfileStep() {
  const { submitEvent } = useChallenge({ challengeId: "onboarding_v3" });

  async function onSave(profile: ProfileForm) {
    await saveProfileToYourBackend(profile);

    // Optimistically advance the challenge.
    // The SDK reconciles with the server on the next poll.
    submitEvent({
      eventType: "profile_completed",
      attributes: { has_avatar: !!profile.avatar },
      idempotencyKey: `profile_completed:${profile.userId}`,
    });
  }

  return <ProfileForm onSubmit={onSave} />;
}

submitEvent fires through the same pipeline as a server-side POST. Idempotency keys prevent double-credit if the user clicks twice.

Multiple challenges

Listing active challenges

On dashboards, render every active challenge with one hook.

components/ChallengeDashboard.tsx·tsx
"use client";
import { useChallenges } from "@bricqs/headless-react";

export function ChallengeDashboard() {
  const { challenges, isLoading } = useChallenges({
    status: "active",
    limit: 5,
  });

  if (isLoading) return <Skeleton count={3} />;
  if (challenges.length === 0) return <Empty />;

  return (
    <ul className="grid gap-4">
      {challenges.map((c) => (
        <li key={c.id} className="rounded-xl border p-5">
          <h3 className="font-bold mb-1">{c.title}</h3>
          <p className="text-sm text-slate-500 mb-3">{c.deadlineLabel}</p>
          <ProgressBar percent={c.progressPercent} />
        </li>
      ))}
    </ul>
  );
}

Completion handoff

Handle the success moment cleanly

components/OnboardingChallenge.tsx (completion)·tsx
onCompleted(async (event) => {
  // event = {
  //   challengeId,
  //   completedAt,
  //   reward: { id, type, value, claim_url? },
  //   facts: [...]
  // }

  // 1. Show your celebration UI
  setShowConfetti(true);

  // 2. Auto-claim the reward (or route to a claim screen)
  if (event.reward.type === "coupon") {
    const code = await claimReward(event.reward.id);
    setCouponCode(code);
  }

  // 3. Fire analytics
  analytics.track("challenge_completed", {
    challenge_id: event.challengeId,
    reward_id: event.reward.id,
  });
});

The completion event fires once per challenge per participant. The SDK guarantees this even on poll retries.

Common mistakes

What goes wrong

01Mistake

Calling submitEvent without an idempotency key. Double-clicks double-credit the objective.

Fix

Always pass idempotencyKey. Use a deterministic value (participant + action + period).

02Mistake

Hiding objectives that are not yet active. The user feels lost.

Fix

Render every objective from the start, marked as upcoming. Hidden steps tank completion rate.

03Mistake

Listening to onCompleted in a component that re-renders. Side-effects fire repeatedly.

Fix

Register onCompleted once at the top of the screen, or use the imperative client.on('challenge.completed') API.

04Mistake

Using useChallenge for non-challenge state (points, tier).

Fix

Use the dedicated hooks: usePoints, useTier, useStreak. They are smaller, faster, and shaped for their purpose.

05Mistake

Polling unnecessarily on screens where the user is unlikely to act.

Fix

Pass pollInterval={0} or unmount the hook when off-screen. The default 15-second poll is fine for active surfaces only.

Developer FAQ

Common questions when integrating gamification with Bricqs.

Ready to ship?

Wire it up with the Bricqs SDK or API

Headless SDK for React UIs, REST API for any backend. Same engine behind both.

1 brief to align the room2 mechanics max in version one
What happens next
01
Pick the mechanic
Choose the smallest working system for the brief.
02
Launch without rebuilds
Configure rules and rewards in one place.