Hundred Loops

Pattern 001. Shelf A

The streak lesson

One small lesson a day, a counter that goes up when you show up, and tomorrow’s lesson already in view. It is the most copied loop in learning apps, and the easiest one to build badly.

The loop

promptactionrewardreturn
  1. promptYou open it, because you decided to
  2. actionA two-minute lesson with a visible end
  3. rewardTwo new things you can do, and the count goes up by one
  4. returnTomorrow's lesson is already named on the home screen

A streak lesson is three parts that depend on each other. The lesson is small enough that “I have no time” is not true. The streak turns a row of small days into one number you can see. The next step is shown, so coming back needs no decision.

Take away the lesson and you have a counter that counts nothing. Take away the counter and day nine feels the same as day one. Take away the next step and people finish a lesson, feel good, and never think of it again.

Why it holds attention

Three mechanisms, each with evidence behind it:

The largest language-learning app publishes how much this loop matters to it: its former product lead wrote that the share of daily users on a streak of seven days or more nearly tripled, to over half, in the years its growth returned. The same essay warns that people with long streaks were always going to be the keen ones, so do not read a streak as the cause of everything.

The version you would defend

A streak can be a kindness or a threat. The difference is what happens on the day someone misses.

Build these in from the first commit:

  1. The lesson ends. After today’s lesson the home screen says so and offers nothing that counts. Practice is there for people who want it, and it is labelled “does not count”.
  2. A missed day is forgiven. Dit gives two rest days. One missed day spends a rest day instead of the streak. One comes back every seven days.
  3. A broken streak takes nothing else away. The message is “the streak starts again today; the 6 letters you know are still yours.” What was learned is the real asset. The number is a way of seeing it.
  4. No reminders until asked for. The starter sends none. If you add them, the person turns them on, picks the time, and one tap turns them off.
  5. Leaving is easy. Copy your data, erase your data, both on the first settings screen.

Run the regret test on your own build: if the person could see everything you know about why each screen is there, would they still do the lesson? For a two-minute lesson with a visible end, yes.

Data model

One record, kept on the device.

Field Meaning
streak, best current and longest run of days
lastDone the last day a lesson was finished, as YYYY-MM-DD
rests rest days in hand, 0 to 2
lesson index of the next lesson
known what has been learned so far
sound a preference

Dates are stored as calendar days in the person’s own time zone, not as timestamps. A streak is about days as that person lives them. Storing a timestamp and comparing against a server’s midnight is the most common streak bug there is.

Screens

  1. Home. The count, what the count means right now, today’s lesson, what you know. Empty state: “None yet. The first lesson takes about two minutes.”
  2. Lesson. One question at a time, a progress bar, “3 of 8”. A way out at the top.
  3. Done. What you gained, the streak, one button back. No upsell here.
  4. Settings. Sound, rest days explained, copy my data, erase my data.

Build it with an assistant

Give the assistant one slice at a time and run each before asking for the next. These prompts are written to be pasted.

Say this to your assistant
Make a single-page web app in three files: index.html, style.css, app.js. No libraries, no build step, no network requests. It teaches [YOUR SUBJECT] in 13 short lessons of two items each. For now build only the data: a LESSONS list and a function buildLesson(state, day) that returns a list of steps (show, pick one of four, produce the answer). Use a seeded random generator so the same day always gives the same steps. Show me the steps for day one as JSON in the page.
Say this to your assistant
Add the state object {streak, best, lastDone, rests, lesson, known} saved to localStorage under one key. Write a pure function finishDay(state, day) that takes dates as "YYYY-MM-DD" strings: same day does nothing; next day adds one; a gap is covered by rest days if there are enough, otherwise the streak restarts at 1; every seventh day gives one rest day back, up to 2. Do not use the clock inside this function. Then write six test cases for it as plain asserts in the console.
Say this to your assistant
Add a today() function that returns the local calendar day, and that returns the value of a ?day=YYYY-MM-DD address parameter when one is present. Build the home screen, the lesson screen and the done screen. After a lesson is finished today, the home screen must not offer another one.
Say this to your assistant
Add a settings dialog: a sound switch, "Copy my data" and "Erase my data" (two taps, no confirm pop-up). Then check the page at 360 px wide for sideways scroll, check every button is at least 44 px tall, and check keyboard focus is visible.

Read what comes back. If you cannot say what finishDay does on a three-day gap with one rest day left, you do not have an app yet, you have a file.

The starter, live

Dit
Morse code, two letters a day

Try this

  1. Do today’s lesson, then look at the home screen: there is no second lesson to do.
  2. Add ?day=2026-03-04 to the address to visit another day, and skip one: a rest day covers it.
  3. Skip ten days and read what the app says.
  4. Open Settings and erase everything in two taps.

Open Dit full screen

The whole streak rule is this function. It never looks at the clock, which is why it can be tested:

001-dit/app.js, region streak
// The streak rule, and the part that makes it one you can defend:
// a missed day spends a rest day instead of wiping the count, and a real break
// restarts the streak without taking away anything you learned.
function finishDay(state, day) {
  if (state.lastDone === day) return { changed: false, usedRests: 0, restarted: false };
  const gap = state.lastDone ? daysBetween(state.lastDone, day) : 1;
  const missed = Math.max(0, gap - 1);
  let usedRests = 0, restarted = false;
  if (missed === 0) state.streak += 1;
  else if (missed <= state.rests) { state.rests -= missed; usedRests = missed; state.streak += 1; }
  else { state.streak = 1; restarted = true; }
  if (state.streak % 7 === 0 && state.rests < MAX_RESTS) state.rests += 1; // earn one back each week
  state.best = Math.max(state.best, state.streak);
  state.lastDone = day;
  return { changed: true, usedRests: usedRests, restarted: restarted };
}
// What the home screen should say about the streak right now, before any lesson.
function streakStatus(state, day) {
  if (!state.lastDone) return { shown: 0, note: "A streak counts the days you did a lesson." };
  const gap = daysBetween(state.lastDone, day);
  if (gap <= 0) return { shown: state.streak, note: "Done for today. Best so far: " + state.best + "." };
  if (gap === 1) return { shown: state.streak, note: "Today's lesson keeps it going." };
  if (gap - 1 <= state.rests) return { shown: state.streak, note: "You missed " + (gap - 1) + (gap === 2 ? " day" : " days") + ". A rest day covers it if you do today's lesson." };
  return { shown: 0, note: "Welcome back. The streak starts again today; the " + state.known.length + " letters you know are still yours." };
}

And this is what makes a day repeatable. The same day always produces the same questions:

001-dit/app.js, region lesson
// A small random number generator with a seed, so a given day always asks the same
// questions. That makes the app testable, and nobody can reload until the questions get easy.
function rng(seedText) {
  let h = 2166136261;
  for (const ch of seedText) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619); }
  return function () { h ^= h << 13; h ^= h >>> 17; h ^= h << 5; return ((h >>> 0) % 100000) / 100000; };
}
function shuffle(list, rand) {
  const a = list.slice();
  for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
  return a;
}
function buildLesson(state, day, practice) {
  const rand = rng(day + (practice ? "p" : "l") + state.lesson);
  const fresh2 = practice || state.lesson >= LESSONS.length ? [] : LESSONS[state.lesson].split("");
  const pool = Array.from(new Set(state.known.concat(fresh2)));
  const steps = fresh2.map((L) => ({ kind: "show", letter: L }));
  const quizzed = shuffle(fresh2.concat(shuffle(pool, rand).slice(0, 2)), rand).slice(0, practice ? 5 : 4);
  quizzed.forEach((L) => {
    const others = shuffle(Object.keys(CODE).filter((x) => x !== L), rand).slice(0, 3);
    steps.push({ kind: "pick", letter: L, options: shuffle(others.concat(L), rand) });
  });
  shuffle(pool, rand).slice(0, 2).forEach((L) => steps.push({ kind: "key", letter: L }));
  return steps;
}

Make it yours

Shipping Dit as it stands would be shipping this guide’s example. Change at least these five things:

  1. The subject. Replace CODE and LESSONS. Knots, chords, sign-language letters, wine regions, the Greek alphabet, your own course.
  2. The name and the mark. Not a pun on a well-known app’s name. Search both stores first.
  3. The look. New palette, new type, your own drawing. The lamp is Dit’s; find yours in your subject.
  4. One rule. Three lessons a week instead of daily. A shared streak for two people. A streak counted in weeks.
  5. One screen. A map of the course. A certificate at the end. A “teach it back” step.
You may copyYou may not copyCheck first
Daily lessons, streak counters, rest days, progress bars, leagues: these are methods, and copyright does not protect a “procedure, process, system, method of operation” (17 U.S.C. 102(b)) Another app’s course text, audio, illustrations, mascot, sounds, or the particular look of its screens Any name with a famous app’s word in it. Apple: “You cannot use another developer’s icon, brand, or product name in your app’s icon or name” (guideline 4.1(c))
Facts and public-domain material as lesson content (Morse code, the periodic table, scales) A textbook’s wording or the selection and order of a published course The licence of any word list, audio or image set you did not make
The idea of a friendly character A specific character Whether children will use it: under 13 in the US brings COPPA, and reminders and streaks aimed at children are named in the UK and EU children’s design rules

Education, not legal advice. The full framework and every source are in the chapter “What you may copy”.

Ship it

Sources

See what else is in the guideOpen Dit full screen