Skip to content
GenerateSpecs home.mdDownload this spec as Markdown.htmlDownload this spec as a single self-contained HTML file
mobile-appPUBLIC

Zahlenkette: Number Learning App for Preschoolers

A German-first iPhone and iPad app that teaches children aged 2–6 the numbers 1–20 through 12 short mini-games.

20,648 lines294,166 words32 sectionsgenerated in 1h 44mSep 29, 2026

Zahlenkette: Number Learning App for Preschoolers — Product Specification #

Version: 1.0 · Platform: native iPhone and iPad app (iOS/iPadOS 17.0+) · Language: German first · Status: final, ready for execution

Overview #

Zahlenkette (working title) is a German-first iPhone and iPad app that teaches children aged 2–6 the numbers 1–20 through 12 short, playful mini-games. It is built on German early-math didactics — the Zwanzigerfeld, red/blue groups of five, the Kraft der Fünf, structured quantities before scattered ones, Zahlzerlegung and the number line — and shows every number as three linked representations: numeral, word and quantity. Children who cannot read yet play alone: every instruction is spoken and every child-facing control is a picture or a numeral. An adaptive learning engine tracks mastery per skill and per number and picks the next tasks from each child's weak spots with spaced repetition. A gentle reward system (stars, a personal garden, 20 Zahlenfreunde characters, a sticker album and a daily five-minute Abenteuer) makes progress visible without dark patterns. Parents manage up to five child profiles, progress, time limits and settings in a parent area behind a parental gate, where a StoreKit 2 subscription with Family Sharing unlocks eight premium games; four games stay free forever. V1 runs fully offline with no backend, no third-party SDKs and no data collection; iCloud sync follows in V1.1.

This document is the complete build specification. It is written so that an AI coding agent or a developer can execute it without clarifying questions: every concern has one owning section, other sections reference it by number, and every open choice has a recorded default.

Table of Contents #

1. Before You Start #

This section lists every real-world decision that lies outside the code but affects the build, the App Store submission or the launch. Each decision has a default. The executor never blocks on an unanswered question: if the product owner has not answered by the time the decision is needed, the default applies, the executor records it in DECISIONS.md at the repository root (entry format in Section 6.12.1, usage in Section 1.4), and work continues.

1.1 How to read this document #

1.1.1 Section ownership model #

Every concern has exactly one owning section. The owning section is the single source of truth for that concern's values, rules and edge cases. Other sections may mention an owned value only when they need it for context, and then they state it identically and cite the owner (for example "the daily time limit defaults to 20 minutes, see Section 15"). If two sections ever appear to disagree, the owning section wins and the discrepancy is a documentation defect to be logged in DECISIONS.md.

Concern Owning section
Executor decisions and defaults 1
Vision, goals, success criteria, principles, non-goals 2
Personas and user journeys 3
Didactic rules (representations, Kraft der Fünf, Zwanzigerfeld, skill and level definitions) 4
Versions of Xcode, Swift, SDKs; module graph; concurrency model 5
Naming, folder layout, style, logging 6
SwiftData models, persistence, CloudKit readiness 7
Content JSON files and schemas 8
Mastery math, task selection, range widening, Abenteuer composition 9
Game framework, round lifecycle, hint ladder mechanics, feedback 10
The 12 games (4 free, 8 premium) 11, 12, 13
Stars, garden, decorations, Zahlenfreunde, stickers 14
Profiles, sessions, time limits, break nudges, interruptions 15
Parent area and parental gate 16
Subscriptions, prices, trial, entitlement, paywall 17
Screens (IDs S-01 to S-27) and navigation 18
Colors, typography, layout, accessibility 19
Audio engine, voice, localization, app name mechanism 20
Complete content and audio lists 21
App Store listing and parent-facing copy 22
Privacy, security, App Store compliance 23
Performance, offline, reliability 24
Testing and QA 25
Release scope and launch checklist 26
Milestones 27
Risks and decisions to revisit 28
Executor instructions 29
Glossary 30

1.1.2 Conventions used throughout #

Convention Meaning
"see Section 9.4" The referenced section owns the concern. Read it; do not re-derive the value.
Decision: A choice that was open and has been made. It is binding for V1. It may be revisited only through the process in Section 28.
"must", "must not" Mandatory. A build that violates it fails acceptance (Section 25).
"should" Strong default. Deviate only with a written reason in DECISIONS.md.
"may" Optional; the executor chooses.
code font An exact identifier, file name, key or raw value. Spell it exactly as written.
S-01 ... S-27 Screen IDs. Section 18 owns the screen list; every other section uses these IDs.
num.7, prompt.hoer_hin.intro Audio IDs. Section 20 owns the convention and Section 8 the validation rules. The German text of each line is owned by the section that specifies the behaviour (Sections 11 to 13 for game lines, Sections 14, 15 and 17 for reward, session and lock lines); Section 21 is the complete inventory and mirrors those texts exactly.
German words in italics or quotes UI strings, spoken lines, game names or didactic terms. They are used verbatim in the app. Section 30 translates every German term.
Version names Dependencies are named by major line only, and only in Section 5; every other section refers to "the toolchain of Section 5". The deployment target (iOS and iPadOS 17.0) is a product fact, not a toolchain version.

1.1.3 Language #

The specification prose is English. Every child-facing and parent-facing string, every spoken line, every game name and every German didactic term is German and appears in German in this document. V1 ships German only; the localization structure is ready for English from day one (Section 20).

1.2 Decisions with defaults #

Each row is a question the executor may put to the product owner. If there is no answer at the moment the decision is needed, the default in the fourth column applies. "Needed by" refers to the milestones in Section 27.

ID Question Why it matters Default if unanswered Reversible? Needed by Owner
D-01 Which bundle identifier? The bundle ID is permanent once the app record exists in App Store Connect. The StoreKit product IDs are derived from it (<bundleID>.premium.monthly, <bundleID>.premium.yearly), and the V1.1 iCloud container is derived from it. de.zahlenkette.app. Keep it even if the display name changes: the bundle ID is never shown to users, so the working-title rename risk does not affect it. Keep it even if the product domain ends up different. No M0 1
D-02 Apple Developer Program account type: organization or individual? The account type determines the seller name shown on the App Store product page. Parents choosing a kids app trust a company name more than a private person's name. An organization account needs a registered legal entity and a D-U-N-S number, which can take days to weeks to obtain. Organization account, so the seller name is a company. An individual account is acceptable if no legal entity exists; the seller name is then the individual's legal name. Converting later requires contacting Apple Developer Support, so choose before the App Store Connect app record is created. Hard M0 1
D-03 Final app name? The name appears on the Home Screen, in the App Store and in parent-facing texts. "Zahlenkette" is a working title and has not been cleared. Keep the working title "Zahlenkette". The name lives in exactly one place, APP_DISPLAY_NAME in Config/Brand.xcconfig; CFBundleDisplayName resolves to it, Swift code reads it via Brand.appName, marketing texts use the {{APP_NAME}} token, and audio never speaks the name (Section 20). A rename is a one-line change plus re-rendering marketing texts. The name checks (App Store availability, DPMA/EUIPO trademark search, domain) are on the launch checklist in Section 26. Yes, until launch M1 (end of week 3, Section 27) 20, 26
D-04 What if the name is already taken as an App Store name? App Store names are unique across the store. Keep the on-device display name "Zahlenkette" and use a descriptive App Store name that still contains it, as defined in the listing in Section 22. If the trademark search (Section 26) finds a conflicting registered mark in class 9 or 41, rename entirely via D-03. Yes, until launch M1 (end of week 3, Section 27) 22, 26
D-05 Subscription prices? Prices drive conversion and must exist as App Store Connect price points. Monthly 3,99 €, yearly 29,99 € (about 37% cheaper than twelve monthly payments), yearly preselected; CHF via Apple's automatic equalization. Section 17 owns prices. Yes (price changes follow Apple's rules for existing subscribers) M8 17
D-06 Free trial length? The trial is the main conversion lever for skeptical parents. 7 days, configured in App Store Connect as an introductory offer of type "Free" with duration "1 week" on both products. Apple determines eligibility per subscription group (Section 17). Yes M8 17
D-07 Family Sharing on the subscription? One family, several children, possibly several devices. Enabled on both products. Apple does not allow turning Family Sharing off again once enabled for a product; this is intended. No M8 17
D-08 Which storefronts at launch? German-only content is only useful in German-speaking markets; reviews in other storefronts from non-German speakers would hurt the rating. Germany, Austria and Switzerland only (storefronts DE, AT, CH); Liechtenstein, Luxembourg and all other storefronts are not selected at launch. Other storefronts are added when English content ships (V1.2, British English en-GB, Section 26). Yes M9 26
D-09 Enroll in the App Store Small Business Program? Reduces Apple's commission on paid transactions for developers below Apple's revenue threshold. Enroll before launch. Yes M9 1
D-10 Who is the voice? Every instruction is spoken; the voice is the child's main interface. Consistency across all lines matters more than variety. One professional adult female German speaker, neutral Hochdeutsch without regional coloring, so the voice is equally acceptable in Germany, Austria and Switzerland. Warm, calm, unhurried, never shrill. The same speaker records all lines, including the Zahlenfreunde lines (characters are distinguished by gentle performance, not by pitch-shifting). Pronunciation rules for number words are in Section 4.10; the recording specification is in Section 20. No child voice actors (consistency across recording sessions, and no child-performer labor arrangements needed). Yes, at re-recording cost M6 (development never waits: until recordings arrive, the TTS fallback of Section 20 speaks every line) 20, 21
D-11 Where do illustrations come from? The look carries the calm, warm character and must stay consistent across 60 decorations, 20 Zahlenfreunde, 12 avatars and 24 Punkt-zu-Punkt pictures. A commissioned illustrator, flat warm vector style with rounded shapes and limited palette consistent with Section 19, delivered as single-scale vector PDF or SVG and imported into the asset catalog with "Preserve Vector Data" enabled. The illustrator receives the asset list of Section 21 and the color rules of Section 19. Development never waits: until final art arrives, placeholders are drawn in code from simple SwiftUI shapes with the correct asset names (flat <category>_<slug>[_<state>] scheme, for example avatar_fuchs, Section 6.4.3), sizes and colors, so swapping art requires no code change. AI-generated art is not used for final assets (licensing and style-consistency risk). Yes M6 19, 21
D-12 Where do music and sound effects come from? Music and SFX must be calm, short and licensed for in-app distribution worldwide without attribution requirements inside the child area. Licensed from a royalty-free library with a perpetual license that covers use inside an app distributed on the App Store, or commissioned. Keep the license documents with the project records. If a license requires attribution, the credit appears in S-24 Help and legal (parent area) only. Yes M6 20, 21
D-13 Where is the privacy policy hosted? App Store Connect requires a privacy policy URL for every app, and Kids Category apps are reviewed with particular care. The app links to it from S-24 (behind the parental gate). A static HTML page on the product domain, path /datenschutz (for example https://zahlenkette.de/datenschutz), German, no cookies, no tracking, no third-party embeds, no web fonts loaded from third parties. The same static site hosts /impressum (imprint, legally required for a commercial offering in Germany and Austria) and /hilfe (support page, used as the App Store Connect support URL). Content outline in Section 22. Yes M9 22, 23
D-14 Which support email address? Required contact channel; shown in S-24 and in the App Store listing. hilfe@<product domain> (for example hilfe@zahlenkette.de), a monitored mailbox, answered in German within 2 business days. The app opens it via a mailto: link behind the parental gate. Yes M9 16, 22
D-15 Which product domain? Needed for privacy policy, imprint and support page before submission. Register zahlenkette.de; if unavailable, zahlenkette-app.de. Also register the .at and .ch equivalents if available (defensive). A rename (D-03) moves the static site to the new name's domain; the bundle ID does not change. Yes M1 (end of week 3, Section 27) 26
D-16 Where are the privacy policy URL, imprint URL and support email configured in the app? These values must not be scattered through the code. Decision: they live next to the app name in Config/Brand.xcconfig as PRIVACY_POLICY_URL, IMPRINT_URL, SUPPORT_URL and SUPPORT_EMAIL, are exposed through keys of the same names in the checked-in Info.plist (values $(PRIVACY_POLICY_URL) and so on), and are read in Swift through Brand (Section 20 owns the mechanism; Section 5.8.2 and Section 5.12 own the xcconfig files and the Info.plist). Config/Brand.xcconfig therefore holds exactly five settings: APP_DISPLAY_NAME plus these four. Note that // starts a comment in .xcconfig files; URLs are written with the $() escape, for example https:/$()/zahlenkette.de/datenschutz. Yes M8 20
D-17 Minimum OS? Determines available APIs (SwiftData and the Observation framework need iOS 17). Fixed: iOS and iPadOS 17.0 deployment target. Not a question to ask; listed so nobody lowers it. Smallest supported device: iPhone SE (2nd and 3rd generation, 4.7-inch, 375 x 667 pt). Fixed M0 5
D-18 Kids Category and which age band? Apple allows exactly one Kids Category age band per app ("5 and under", "6-8", "9-11"). The band decides where parents find the app and which review expectations apply. The audience spans ages 2 to 6, so it straddles two bands. In App Store Connect the primary category is Education; the app enters the Kids Category by selecting "Made for Kids" in the age-rating section with the age band "5 and under" (procedure in Section 26.9.3; compliance in Section 23). Reasons for the band: (1) most of the audience is 2 to 5, including the entire "Die Kleinen" level and most of "Vorschule"; (2) the content (numbers 1 to 20, no reading) matches what parents browsing "5 and under" look for; parents of 6-year-olds in their last year before school still find the app by search and category ranking; (3) the "6-8" band would place the app next to school-age arithmetic apps with reading and would mislead parents of 3-year-olds. Treat the Kids Category choice as hard to reverse: verify in the current App Store Connect Help at submission time whether an app can leave the Kids Category or change its band later, and record the finding in DECISIONS.md. Compliance rules are in Section 23. Hard M9 23, 26
D-19 Age rating questionnaire answers? Wrong answers can cause rejection or a mismatching rating. Answer every content question with "None", declare no unrestricted web access, no user-generated content, no chat, no advertising; the result is the lowest age rating. Section 23 owns the exact answers. Yes M9 23
D-20 Which families test with children via TestFlight? Two success criteria (Section 2.5) can only be measured with real children. Children do not have their own TestFlight access; parents install the build on the family device. At least 8 families with at least 10 children in total, recruited from the product owner's personal network, covering: at least 5 four-year-olds (for the unassisted-session criterion), at least 3 children aged 2 to 3, at least 3 children aged 5 to 6, at least one family using an iPhone SE, at least one family sharing one iPad between siblings, at least one family in Austria or Switzerland, and, if available, one child with a red-green color vision deficiency. Every family signs a written consent (German, GDPR-compliant) before testing. Sessions are documented in a written observation log only; no photos, videos or audio recordings are made (Section 25.15). Test data never leaves the device except when a parent voluntarily sends a data export (S-23) or answers a questionnaire. External TestFlight groups require Beta App Review; plan one to two days. Protocol in Section 25. Yes M7 25
D-21 Is there a didactic review by an educator? The didactic rules of Section 4 are the product's core promise. Yes, recommended but non-blocking: one paid review of Section 4 and of a TestFlight build by a German early-childhood educator or primary-school teacher before launch. Findings go into DECISIONS.md; launch proceeds if the review cannot be arranged. Yes M8 4
D-22 German spelling for Switzerland (ß vs. ss)? Swiss Standard German does not use "ß". A few parent-facing strings contain it (for example "größer", "Grüße"). V1 uses German (Germany) orthography for all three storefronts; children do not read, and parents in Switzerland understand "ß" without difficulty. The localization structure (Section 20) allows a later de-CH variant without code changes. Yes M6 20
D-23 Which handwriting model for Nachspuren? German schools use slightly different numeral forms; stroke order must be consistent. The German school print numerals and stroke orders defined in Section 12, identical for all storefronts. Regional variants are not configurable in V1. Yes M5 12
D-24 Source control and decision log? The executor works autonomously; decisions must be traceable. A private Git repository. DECISIONS.md at the repository root logs every default applied from this table, every "Decision:" revisited, and every discrepancy found between sections (entry format in Section 6.12.1). Yes M0 6, 29
D-25 How are purchases tested before App Store Connect agreements are active? Sandbox purchases need an active Paid Applications agreement, tax and banking data, which the product owner may not have completed yet. The executor tests with an Xcode StoreKit configuration file containing both products, the trial offer and the subscription group "Zahlenkette Premium" (Section 17). Sandbox and TestFlight purchase tests follow once the agreement is active. Yes M8 17, 25
D-26 Is an iCloud container reserved in V1? V1.1 enables CloudKit sync without a data migration. Decision: V1 does not add the iCloud capability or any CloudKit entitlement. The V1.1 container identifier is iCloud.de.zahlenkette.app (derived from the bundle ID). Section 7 owns sync enablement. Yes V1.1 7

1.3 What is not a question #

The following are fixed and must not be reopened by the executor. They are listed so nobody spends time on them.

Topic Fixed value Owner
Platforms Native iPhone and iPad (one universal app), portrait and landscape on both. No Android, web or Mac version. 5, 26
Minimum OS iOS and iPadOS 17.0 5
Frameworks Apple frameworks only; zero third-party packages; zero network calls except system-managed StoreKit traffic 5
Backend None. No accounts, no child logins, no server 2, 23
Tracking No ads, no analytics, no third-party SDKs, no tracking. Privacy label "Data Not Collected" 23
Notifications V1 sends no notifications of any kind 14
Profiles Up to 5 child profiles per device 15
Games 12 games, each with exactly 3 difficulty steps in V1; 4 free forever (Entdecken, Wie viele?, Hör hin, Was fehlt?) 11-13
Monetization model Auto-renewable subscription only; no one-time or lifetime purchase; the child never sees a purchase screen 17
Sync None in V1; in V1.1 an opt-in iCloud private database sync ("Mit iCloud synchronisieren", off by default, available only with an active subscription); parent-to-child Apple ID sharing (CKShare) in V2 at the earliest 7, 26
Language German text and voice in V1; English content (British English, en-GB) planned for V1.2 20, 26

1.4 Format of DECISIONS.md #

DECISIONS.md has exactly one entry format, defined in Section 6.12.1 (entry layout, log ID scheme and the complete list of allowed statuses). Entries are appended and never edited in place. This section only fixes how the questions of Section 1.2 are logged:

  • Every default applied from the table in Section 1.2 gets its own entry. The entry names the question ID of Section 1.2 (for example "1.2 D-15") in its references, so the log ID of Section 6.12.1 and the question ID of Section 1.2 are never confused.
  • The status of such an entry is default applied (no answer by the "Needed by" milestone) or answered by product owner. A later change of the value is a new entry with status revisited that references the earlier one.
  • A discrepancy found between two sections is logged with status spec discrepancy; the entry names both sections and states which owning section was followed (Section 1.1.1).

1.5 Scope at a glance #

The binding release scope, including the launch checklist, is in Section 26. This table is an orientation only.

Area V1 (launch) V1.1 V1.2 and quarterly drops V2 (earliest)
Numbers 1-20 1-20 1-20 Numbers to 100
Games 12 (4 free, 8 premium), 3 difficulty steps each Same At least 2 new games or major game extensions per quarter; new Punkt-zu-Punkt pictures Adding and subtracting within 10 and 20; Kaufladen with coins; clock
Learning engine Mastery per skill x number, spaced repetition, auto range, parent overrides Same Tuning from test data Extended to 100 and operations
Rewards Stars, Zahlengarten with 60 decorations, 20 Zahlenfreunde, 52-sticker album, daily Abenteuer Same New garden themes Same principles
Profiles Up to 5, local only Optionally synced across devices with the same Apple ID (iCloud private database; opt-in, subscription required) Same Parent and child with different Apple IDs via CKShare
Language German text and voice; structure ready for English Same English text and voice (British English, en-GB) Further languages as demand shows
Monetization Monthly and yearly subscription, 7-day trial, Family Sharing Same Same Same
Institutions None None None Optional Kita edition
Platforms iPhone and iPad Same Same Same (no Android, web or Mac planned)

2. Product Overview and Goals #

2.1 Vision #

Zahlenkette (working title, see Section 1.2 D-03) is a calm, German-first iPhone and iPad app in which children aged 2 to 6 build solid number sense for the numbers 1 to 20 through twelve short mini-games. Children play alone, without being able to read: every instruction is spoken, every child-facing control is a picture or a numeral. Every number is always met in three linked forms (numeral, word, quantity) and in the structures German early-math didactics relies on: groups of five, the Zwanzigerfeld, the bead chain and the number line (Section 4). An adaptive engine keeps every child in the zone where most tasks succeed on the first try (Section 9). Parents see honest progress per number and skill in a gated parent area (Section 16), without ads, tracking or manipulation.

The one-sentence promise to parents: Ihr Kind lernt die Zahlen bis 20 so, wie es auch in Kita und Grundschule gelernt wird: in Fünferschritten, ohne Druck, ohne Werbung.

2.2 Problem #

Problem Evidence of the problem in typical apps How Zahlenkette answers it
Most number apps ignore German early-math didactics Quantities are shown as unstructured piles; counting one by one is the only strategy; no five-structure, no Zwanzigerfeld Kraft der Fünf, Zwanzigerfeld and Rechenrahmen colors, structured before scattered, Zahlzerlegung, number line (Section 4)
Apps rely on reading Text buttons, written instructions, menus a 4-year-old cannot navigate Everything spoken; all child controls are pictures or numerals; one task per screen (Sections 10, 19)
Mistakes are punished Red crosses, buzzers, lost lives, "Game over" Hint ladder, neutral feedback, the solution is modeled and the task still counts as completed (Sections 4.13, 10)
Dark patterns Streaks, loot boxes, timers, purchase prompts in the child area, notifications nagging the child None of these exist (Section 14 lists the forbidden patterns)
Parents cannot see what the child actually learns Only playtime or level numbers Mastery per skill and number, "Gerade schwierig" in plain German (Section 16)
Privacy concerns Ad SDKs, analytics, accounts for children No backend, no accounts, no analytics, privacy label "Data Not Collected" (Section 23)

2.3 Market #

2.3.1 Launch markets #

Germany, Austria and Switzerland (App Store storefronts DE, AT, CH), German language only. Other storefronts follow with English content in V1.2 (Section 26).

For sizing orientation only (not a requirement): annual births in Germany have been roughly 680,000 to 800,000 in recent years, so roughly 3.5 to 4 million children aged 2 to 6 live in Germany; Austria and German-speaking Switzerland add several hundred thousand more. The addressable group is the subset of families with an iPhone or iPad who allow some screen time for learning.

2.3.2 Market-specific notes #

Topic Germany Austria Switzerland Consequence for the product
Preschool term "Vorschule" is the informal name for the last Kita year before school Last Kindergarten year before school (a compulsory Kindergarten year applies at age 5) Kindergarten is the first stage of school in most cantons, typically from age 4 The level name "Vorschule" is understood in all three countries as "the year or two before learning arithmetic formally". No school-system-specific content.
School start Around age 6 Around age 6 Primary school after two Kindergarten years, around age 6 The target range 1-20 matches the first months of Grade 1 in all three.
Number words Standard Standard ("zwei", never "zwo" in children's counting) Standard German in writing and in the app; children speak Swiss German dialect at home The voice uses neutral Hochdeutsch (Section 1.2 D-10, Section 4.10).
Orthography "ß" "ß" "ss" instead of "ß" V1 uses German (Germany) orthography everywhere (Section 1.2 D-22).
Currency EUR EUR CHF Prices per Section 17; CHF via Apple's automatic equalization.
Screen time guidance Public-health guidance in Germany (BZgA) recommends avoiding screen media for children under 3 and limiting screen time for children aged 3 to 6 to about 30 minutes per day Similar Similar The default daily time limit of 20 minutes (Section 15) fits within this guidance. Decision: parent-facing copy (Section 22) recommends playing together with children under 3.

2.4 Product principles #

These principles decide every open design question. When a requirement anywhere in this document seems to allow two readings, choose the reading that better satisfies these principles, in this order.

# Principle What it means concretely Where it is enforced
P1 Privacy by design No backend, no accounts, no analytics, no third-party SDKs, no network calls except system-managed StoreKit. No birthdate, no photo, no real name required. Data stays on the device and in the family's own device backup (V1); from V1.1 it may additionally sync through the family's own iCloud private database, only if a parent turns on "Mit iCloud synchronisieren" (off by default, subscription required, Section 7.15). 7, 23
P2 No punishment A wrong answer never removes anything, never shows red, never plays a failure sound, never ends a round. After the third wrong attempt the app models the solution and the task counts as completed (it still earns its star). 4.13, 10, 14
P3 Child autonomy without reading A 4-year-old can start, play and finish a session alone. Every instruction is spoken; every child control is a picture or a numeral, at least 60 x 60 pt. Nothing in the child area requires reading. 10, 18, 19, 20
P4 Three linked representations Every number is met as numeral, word (spoken and written) and structured quantity, and the three are shown together whenever a task resolves. 4.2
P5 Didactics first Kraft der Fünf, Zwanzigerfeld, Rechenrahmen colors, structured before scattered, Zahlzerlegung and number line are non-negotiable. Game ideas that conflict with them are changed, not the didactics. 4
P6 Calm design One task per screen; at most one looping ambient animation per screen; no flashing above 3 Hz; celebrations last at most 2.5 seconds; sound can be turned off and everything still works visually. 19, 20
P7 No dark patterns No streaks, no pressure timers, no leaderboards, no loot boxes, no random rewards, no fake scarcity, no notifications, nothing the child can buy, no purchase screen for the child, no ads. Rewards are never tied to money. 14, 17
P8 Honest progress Progress is mastery per skill and number, never time spent or levels unlocked. Parents see what is hard right now, in plain German. 9, 16
P9 Data-driven content Tasks, prompts, audio IDs, difficulty parameters, decorations, stickers and friends live in bundled JSON and String Catalogs. New content ships without code changes where a game's parameters allow it. 8
P10 Fair monetization Four games are free forever. Nothing earned is ever taken away when a subscription ends. Purchases, restore and subscription management exist only in the parent area behind the parental gate. 17

2.5 Goals and success criteria #

2.5.1 Goals #

ID Goal For whom
G1 Children aged 2 to 6 build number sense for 1 to 20: recognize numerals, link words to numerals and quantities, count, subitize, order, compare, decompose and write. Children
G2 Children play alone, calmly, for 3 to 8 minutes, and stop without conflict. Children, parents
G3 Parents trust the app: they see real progress, find no manipulation, and understand what they pay for. Parents
G4 The product earns enough through the subscription to fund the content roadmap (Section 17, Section 26). Business
G5 The code base lets a solo founder working with AI coding agents add games and content quickly and safely. Builder

2.5.2 Measurement constraints #

The app contains no analytics and makes no network calls of its own (Section 23). Every success criterion is therefore measured with exactly these sources and no others:

Source What it provides Limitations
App Store Connect: App Analytics Impressions, downloads, sessions and retention (Day 1, Day 7, Day 28) for users who opted in to share data with developers Only opt-in users; aggregated; thresholds hide small cohorts
App Store Connect: Sales and Trends, Subscriptions, Payments and Financial Reports Trial starts, trial-to-paid conversions, renewals, cancellations, refunds, proceeds Delayed by one to two days; no per-user detail
App Store Connect: Ratings and Reviews Average rating and written reviews per storefront Only users who rate
Xcode Organizer Crash reports, hangs, energy and launch-time metrics from users who share diagnostics with developers Opt-in only
Moderated usability tests Observed child behavior with the protocol of Section 25 Small samples; in-person
TestFlight test families Parent questionnaires; data exports (S-23) that parents voluntarily send; screenshots of the parent area Only consenting families (Section 1.2 D-20)
On-device parent views Mastery per number and skill, "Gerade schwierig", time used (S-18, S-19) Visible only to the family; never transmitted

Decision: the app never adds a feedback form, survey prompt, rating nag or any other mechanism that transmits data. The only in-app rating mechanism allowed is Apple's system review request, shown only inside the parent area (Section 16 owns when and whether it appears); it is never shown in the child area.

2.5.3 Success criteria #

ID Criterion Target How it is measured When
SC1 A 4-year-old completes a session without adult help At least 4 of 5 test children aged 4 (4;0 to 4;11) complete, unaided, the path S-05 Child home → S-09 Abenteuer intro → three rounds → S-10 Abenteuer soft end on an already set-up profile. "Without adult help" means no adult touches the device and no adult gives an operational instruction ("tipp da", "wisch mal"); neutral encouragement ("super") is allowed. Moderated usability test per Section 25, written observation log only (no video, photo or audio recording, Section 25.15), one session per child. Before launch (M7-M8) and after every change to S-05, S-06, S-07 or S-09
SC2 A 3-year-old completes a round without adult help At least 3 of 5 test children aged 3 complete one round of Entdecken ("Zähl mit") or Hör hin at step1 unaided, with the same definition as SC1. Same protocol as SC1. Before launch
SC3 Children understand a game from the spoken instruction and the visual demo alone For every one of the 12 games, at least 4 of 5 test children of the target level give a correct first answer in the first task of their first round, or a correct answer after the first hint, without adult help. Same protocol, one game per child per sitting, rotated across children. Before launch
SC4 Session length stays in the intended range Median child session between 3 and 8 minutes across the TestFlight families. Parent area time view (S-19, last 7 days chart) screenshots sent voluntarily by test families, plus observation. Beta, 4 weeks
SC5 Mastery gains are visible Among TestFlight children who play at least 3 sessions per week for 4 weeks: at least 75% show at least 3 more numbers in the "mastered" band in at least one of the core skills (recognize, name, count) at the end of week 4 than at the end of week 1. Band definitions per Section 9. Parents voluntarily send the data export (S-23) at the end of week 1 and week 4, or screenshots of the progress grid (S-19). Beta, 4 weeks
SC6 Parents understand progress At least 4 of 5 test parents correctly state, after looking at S-18 and S-19 for at most 2 minutes, which numbers or skills their child currently finds hard. Moderated parent interview (Section 25). Before launch
SC7 Parents can manage the subscription 5 of 5 test parents find the paywall, "Käufe wiederherstellen" and subscription management without help, starting from S-05. Moderated parent task test (Section 25). Before launch
SC8 Day-7 retention At least 35% of first-time users are active on day 7. App Analytics retention (Day 7) for opt-in users, per monthly install cohort, DE, AT and CH combined. Evaluated only once a monthly cohort has at least 500 first-time downloads; smaller cohorts are not evaluated. Verification step for the executor: confirm in the current App Store Connect documentation that Day 7 retention is shown and record the exact report name in DECISIONS.md. Monthly from launch month 2
SC9 Rating Average rating at least 4.6 in the German storefront, and no storefront below 4.3. App Store Connect Ratings and Reviews. Evaluated once a storefront has at least 50 ratings. Monthly
SC10 Refund rate Refunds below 2% of paid subscription transactions per calendar quarter. Sales and Trends / Financial Reports: refunded units divided by paid units in the same quarter. Quarterly
SC11 Trial-to-paid conversion At least 40% of free trials convert to a paid period. App Store Connect subscription reports (trial conversion), per monthly trial-start cohort, evaluated after the cohort's trials have ended. Monthly from launch month 2
SC12 Kids Category review passes cleanly The first submission is not rejected for a Kids Category, parental gate, privacy or data-collection reason. App Review result; any rejection reason is logged in DECISIONS.md and in Section 28's register. Launch
SC13 No data leaves the device Zero network requests from app code other than system-managed StoreKit traffic. Runtime traffic-capture pass of Section 25.14.2: a full play-through and a parent-area walk-through on a real device while traffic is captured (for example with rvictl and tcpdump on a Mac); pass condition: only Apple StoreKit and App Store hosts appear. Complemented by the source and binary scans of Section 25. Every release

Decision: SC8, SC9, SC10 and SC11 are business health indicators, not release gates. SC1, SC3, SC6, SC7, SC12 and SC13 are release gates for V1 (Section 26). SC2, SC4 and SC5 are strong signals: if missed, the gap and the planned correction are recorded in Section 28's register, but launch is not blocked.

2.5.4 Reaction to missed targets #

Missed criterion First response
SC1, SC2, SC3 Inspect observation logs for the exact step where the child hesitated; fix spoken instruction, visual demo or target size first; re-test with new children (never the same children, who have learned the flow).
SC4 Check round length (tasks per round per level, Section 9) and break nudge timing (Section 15).
SC5 Inspect exported mastery records against the engine test vectors (Section 9); check the first-try success rate target of 70-85% (Section 9).
SC8 Review first-session experience (journey J-01 in Section 3.3) and the daily Abenteuer; do not add streaks, notifications or other forbidden mechanics (Section 14).
SC9, SC10 Read written reviews and support emails for recurring themes.
SC11 Review paywall copy (Section 22) and the value parents see during the trial; do not shorten the trial or add urgency.

2.6 Non-goals #

The following are explicitly out of scope for V1. Section 26 owns the release scope and says which of them come later.

Non-goal Reason Earliest
Kita or teacher features (class lists, teacher dashboards, institutional licensing) Focus on families first V2 (optional Kita edition)
iCloud or CloudKit sync Local-only V1 reduces risk; the data model is already sync-ready (Section 7) V1.1 (opt-in parent toggle, subscription required)
Sharing progress between a parent's and a child's different Apple IDs (CKShare) Requires sharing UI and sharing-aware data handling V2 at the earliest
Numbers beyond 20 1-20 is the preschool range V2 (to 100)
Adding and subtracting Operations follow number sense V2 (within 10 and 20)
Kaufladen (shop game with coins), clock Separate topics V2
English content at launch German first; structure is ready V1.2
Android, web, Mac Native iPhone and iPad only Not planned
Ads, analytics, tracking, leaderboards, loot boxes Violates principles P1 and P7 Never
Anything the child can buy; any purchase screen shown to the child Violates P7 and Kids Category rules Never
Own backend, child accounts or logins Violates P1 Never
One-time or lifetime purchase Subscription funds continuous content (Section 17 documents the rejected alternatives) Not planned
Speech recognition (the child speaking into the app) Privacy (no microphone), reliability with young voices Not planned
Notifications of any kind Violates P7 Never (V1)

2.7 Product at a glance #

2.7.1 The twelve games #

Each game has exactly three difficulty steps in V1: step1 ("Leicht"), step2 ("Mittel"), step3 ("Schwer"). The primary skill is credited with weight 1.0 and the secondary skill with weight 0.5 per task (Section 9).

# GameID Game (German name) Tier Primary skill Secondary skill Core interaction Specified in
1 entdecken Entdecken Free name (only in the "Zähl mit" mode) recognize Tap a place in the Zwanzigerfeld, hear the number, see numeral, word and the split label ("5 + n" for 6 to 9, "10 + n" for 11 to 19, Section 4.3 DR-09); "Zähl mit" mode counts together 11
2 wie_viele Wie viele? Free count recognize Count objects: structured rows of five, then scattered, then moving 11
3 hoer_hin Hör hin Free name recognize Hear a number, tap the matching numeral 11
4 was_fehlt Was fehlt? Free order recognize Fill the gap in a bead chain; later counting backwards 11
5 blitzblick Blitzblick Premium subitize count Dots, dice or finger patterns shown for 1 to 2 seconds; how many? 12
6 mehr_weniger Mehr oder weniger Premium compare subitize Which side has more, which number is bigger, or equal? 12
7 nachspuren Nachspuren Premium write recognize Trace numerals in German print stroke order with guide arrows; finger primary, Apple Pencil optional 12
8 schuettelbox Schüttelbox Premium decompose subitize Shake the device, N beads fall into two halves, find the split (7 = 3 + 4); button fallback 12
9 froschsprung Froschsprung Premium order compare Frog jumps along the number line to a target; later "2 mehr", "1 weniger" 13
10 zahlenmonster Fütter das Zahlenmonster Premium count recognize Drag exactly N items into the monster's mouth 13
11 memory Memory Premium recognize subitize Match numeral, quantity and dice pattern cards 13
12 punkt_zu_punkt Punkt zu Punkt Premium order recognize Connect dots in number order to reveal a picture that goes into the sticker album 13

Entdecken's free-explore mode records no mastery and gives no stars; only its "Zähl mit" mode does (Section 11). Entdecken is never part of a daily Abenteuer; without a subscription the Abenteuer draws from the three Abenteuer-eligible free games Wie viele?, Hör hin and Was fehlt? (Sections 8.8.1, 9.16.2).

2.7.2 Product building blocks #

Building block One-line description Owner
Levels "Die Kleinen" (littleOnes, ages 2-4, start with 1-5) and "Vorschule" (vorschule, ages 4-6, start with 1-10, widen to 1-20) 4.12, 9
Adaptive learning engine Mastery per skill x number 1-20, spaced repetition, task mix, difficulty stepping, range widening and narrowing, parent overrides 9
Daily Abenteuer A 5-minute mix of 3 rounds chosen by the engine (never Entdecken), with a greeting and a soft end; one bonus per profile per day; an Abenteuer left early resumes at its next unplayed round the same local day; no streaks 9, 14, 15
Stars and Zahlengarten Stars earned per task and round are spent on 60 garden decorations; nothing can be bought with money 14
Zahlenfreunde 20 characters, one per number, befriended through mastery; they move into the garden and are never lost 14
Sticker album 52 stickers on 6 pages: Punkt-zu-Punkt pictures, friends, milestones 14
Profiles Up to 5 per device, avatar-based, no login 15
Parent area Behind a parental gate: progress, "Gerade schwierig", time, settings, subscription, data 16
Subscription Monthly and yearly, 7-day trial, Family Sharing, sold only in the parent area; unlocks the 8 premium games and all future games and content drops, nothing else (stars, friends, stickers, profiles and all difficulty steps of the free games are free) 17

3. Personas and User Journeys #

This section describes who uses the app and walks through every important path step by step. Journeys use the screen IDs S-01 to S-27; Section 18 owns the screen list, the elements on each screen and the navigation map. Where a journey step depends on a rule owned by another section, the rule is cited, not redefined.

3.1 Personas #

The app has two user groups: children (primary users, cannot read) and parents (set up, pay, follow progress). The five personas below are used in journeys, test scenarios (Section 25) and design reviews.

3.1.1 Persona overview #

Persona Age Role Level Device Key trait for design
Mila 3;4 (3 years, 4 months) Child, younger sibling "Die Kleinen" (littleOnes), range 1-5 Family iPad, shared with Jonas Short attention span, taps everything, cannot yet count reliably beyond 4
Jonas 5;8 Child, older sibling, starts school next summer "Vorschule" (vorschule), range 1-10, widening to 1-20 Same family iPad Red-green color vision deficiency; confident, wants "big" tasks; mirrors 3 and 7 when writing
Sarah 36 Parent of Mila and Jonas, Freiburg (Germany) - Family iPad (10th generation), own iPhone Privacy-conscious, limited time, wants honest progress, dislikes manipulative apps
Thomas 41 Parent of Lena, Graz (Austria) - iPhone SE (3rd generation, 4.7-inch), no iPad Skeptical of subscriptions, reads the small print, wants to cancel easily
Lena 4;6 Child, only child "Vorschule", range 1-10 Thomas's iPhone SE Counts to 10 reliably; plays alone on the sofa while Thomas cooks; plays with sound on, sometimes with the phone muted

3.1.2 Mila (3;4), "Die Kleinen" #

  • Situation: Goes to Kita. Can recite "eins, zwei, drei, vier, fünf" but skips or double-counts objects beyond four. Recognizes "her" numeral 3 because she is three.
  • Behavior with the app: Taps quickly and repeatedly, often before the instruction has finished. Loses interest after 3 to 4 minutes. Loves the garden and animals. Cannot read at all. Holds the iPad flat on her lap.
  • Needs: Short rounds (4 tasks per round for her level, Section 9). Big targets (at least 60 x 60 pt, Section 19). Instructions that are short and are repeated when she taps the speaker button. Tolerance for accidental taps. Immediate, gentle feedback.
  • Frustrations to avoid: Anything that looks like a failure, loud sounds, waiting, a screen that does not react to her taps, being thrown out of the game by an accidental tap on a navigation control.
  • Design implications: Taps during a spoken instruction are accepted as answers only once the task's answer controls are enabled (Section 10); accidental exits from a round are prevented as specified in Section 10; quantities stay within 1 to 5 until the engine widens her range (Section 9); decomposition tasks for her level use only the numbers 2 to 5 (Section 4.7).

3.1.3 Jonas (5;8), "Vorschule" #

  • Situation: Last Kita year ("Vorschulkind"). Counts to 20, but unsure at "dreizehn, vierzehn", occasionally says "einszehn". Recognizes numerals 1 to 10, confuses 12 and 21 when he sees them (he has seen 21 on a house number). Mirrors 3 and 7 when writing.
  • Behavior: Wants to be good; is annoyed by tasks he finds "Babykram". Plays 6 to 8 minutes. Uses the Apple Pencil his mother bought for her own use when he finds it on the table.
  • Color vision: Red-green color vision deficiency (deuteranomaly). The red and blue beads look similar to him in brightness. The shape rule (solid bead for the first five, "Lochperle" with a white center ring for the second five, larger gap between groups of five, Section 4.4 and Section 19) lets him see the structure without relying on hue.
  • Needs: Rising challenge (difficulty stepping, Section 9), widening to 1 to 20, games with a visible goal (Punkt zu Punkt picture, Zahlenfreunde), writing practice with correct stroke order.
  • Frustrations to avoid: Repetition of mastered items without variation, being treated like his little sister, losing Zahlenfreunde he has already befriended.
  • Design implications: Separate profile with its own level and range; the engine's task mix includes 10% stretch tasks (Section 9); befriended friends are never lost (Section 14).

3.1.4 Lena (4;6), "Vorschule" on an iPhone SE #

  • Situation: Only child in Graz. Counts to 10 reliably, recognizes numerals 1 to 5 and some up to 10. Knows the dice patterns from board games.
  • Behavior: Plays alone on the sofa while her father is in the kitchen and cannot help immediately. Small screen, held in both hands, often in landscape.
  • Needs: Everything must fit and remain tappable on 375 x 667 pt in both orientations (Section 19). Everything must work with the phone muted: the app plays voice even when the Ring/Silent switch is set to silent, as specified in Section 20; if system volume is zero, the visual path still carries every task (Section 20).
  • Design implications: Lena is the reference child for success criterion SC1 (a 4-year-old completes a session unaided, Section 2.5.3). The iPhone SE is the reference device for layout acceptance (Section 25).

3.1.5 Sarah (36), parent of Mila and Jonas #

  • Situation: Pharmacist, works part-time. One family iPad shared by both children. Reads app privacy labels before installing. Uses the iPad's Screen Time for overall limits but likes per-child limits inside the app.
  • Goals: Both children practice numbers calmly; she knows what each child can do; no ads, no data collection, no child accounts; the children cannot buy anything.
  • Frustrations: Apps that nag children to ask parents for purchases; streaks that cause tears; dashboards full of meaningless points.
  • Behavior in the app: Sets up Mila first, adds Jonas later in the parent area. Checks progress once or twice a week in the evening, for 2 to 3 minutes. Adjusts Jonas's range manually to 1 to 20 when his Kita teacher says he is ready.
  • Design implications: Profile picker by avatar only (S-04); per-profile time limits (Section 15); parental gate before every parent function (Section 16); "Gerade schwierig" in plain German (Section 16); data export and deletion (S-23).

3.1.6 Thomas (41), parent of Lena #

  • Situation: Engineer in Graz. iPhone SE, no iPad. Distrusts subscriptions after being caught by a hard-to-cancel one. Pays for apps that respect his family.
  • Goals: Try the free games first; understand exactly what he pays and when; cancel easily; keep what Lena earned if he cancels.
  • Behavior: Plays the free games with Lena for a week, then starts the 7-day trial of the yearly plan in the parent area. Cancels once to test, then resubscribes months later.
  • Design implications: Four games free forever with all three steps (Section 17); transparent paywall with price, trial end and cancellation note (Section 22); "Abo verwalten" opens Apple's subscription management (Section 17); after a lapse nothing earned is lost (Section 17, journey J-07).

3.2 Journey notation #

  • Each journey has an ID (J-01 to J-11), a primary persona, a precondition, numbered steps, and edge and failure paths.
  • A step names the screen (S-xx), what the user does, what the app does, and what is persisted.
  • "Spoken:" marks a line the voice speaks. The German text of a line is owned by the section that specifies the behaviour (Sections 11 to 13 for game lines; Sections 14, 15 and 17 for reward, session and lock lines), and Section 21 lists every line with its audio ID, mirroring those texts exactly. Lines quoted here are copies for orientation; the owning section wins.
  • Timings are typical values for orientation, not requirements, unless a section is cited.

3.3 J-01 First launch #

Persona: Sarah sets up Mila. Precondition: App freshly installed; no profile exists.

Step Screen User action App behavior Persisted
1 S-01 Splash/Loading Sarah taps the app icon. Shows the splash while content JSON is loaded and validated (Section 8) and the persistence store is opened (Section 7). Minimum display time none; maximum per Section 24. Nothing
2 S-02 First-launch welcome Reads the parent-facing welcome text. Shows the parent-facing welcome copy of Section 22.5.1 (Sie-form) and one primary button "Profil einrichten". S-02 plays no audio (the audio session stays inactive before child mode, Section 20.3.2). Contains no settings, no external links and no purchase elements (Section 15.4.1). Nothing
3 S-17 Parental gate Taps "Profil einrichten", reads the written question and types the answer. The parental gate always precedes S-03 (Section 15.4.1, Section 16.2). "Abbrechen" returns to S-02. If the gate cooldown is active, the gate shows it (Section 16.2.4). Nothing
4 S-03 Parent setup (one scrolling form) Fills in the form. One scrolling form (Section 15.4.1, copy in Section 22.5.2): optional nickname field (0 to 20 characters, live counter "n/20"; types "Mila" or leaves it empty); avatar grid of 12 animals (the first avatar not used by another profile is preselected; Sarah taps the fox); color row of 6 themes (theme.sonne preselected; Sarah taps the green one); level as two large option cards "Die Kleinen (2–4 Jahre)" and "Vorschule (4–6 Jahre)", none preselected (Sarah chooses "Die Kleinen"). The level can be changed later in S-20 without losing progress (Section 4.12). S-03 contains no "add another child" control and no link to Premium; further profiles are created only in the parent area (Section 15.4.2), and Premium is reached only from S-18 after the gate. Nothing yet (draft in memory)
5 S-03, sound check Taps "Ton testen". Plays num.5 ("fünf") through the voice channel so the parent can confirm the volume and that the Ring/Silent switch does not mute the app. This is the only audio before child mode (audio-session exception in Section 20.3.2). Nothing is stored; the sound-off visual path always works (Section 20). Nothing yet
6 S-03 Taps "Fertig" (enabled once a level is chosen). The profile and its per-child settings are created in one save with the defaults of Section 15.2.1 (time limit 20 minutes, range and difficulty on "Auto", level as chosen). ChildProfile and per-child settings (Section 7)
7 S-03, hand-off Hands the iPad to Mila. Hand-off inside S-03: "Fertig! Jetzt darf Ihr Kind übernehmen." / "Geben Sie das Gerät einfach weiter." for 3 seconds while Mila's avatar animates in; any tap after 1 second skips the rest (Section 15.4.1). Nothing
8 S-05 Child home - Mila's world opens automatically after the hand-off and a session starts. Spoken: the time-of-day greeting (Section 15.7.1). The Abenteuer button is the single gently animated element (Section 19: at most one looping ambient animation per screen). Session start (Section 15)
9 S-09 Abenteuer intro Mila taps the Abenteuer button, then the play button. The engine composes 3 rounds from the Abenteuer-eligible games she is entitled to (without a subscription: Wie viele?, Hör hin and Was fehlt?; Entdecken is never part of an Abenteuer, Section 9.16.2), starting at step1 in her range 1 to 5. The guide friend introduces the path of three stones (Section 15.10.2). There is no automatic start: round 1 begins when Mila taps the play button; if she does not tap within 20 seconds, the intro line is repeated once. Abenteuer record created when round 1 starts (Section 15.10.2)
10 S-07 Game screen Mila plays round 1 (4 tasks). Before the first task of a game the child has never played, the visual instruction demo runs (Section 10). Each task resolves with the three representations of the number (Section 4.2). Task outcomes and mastery updates after every task (Sections 7, 9)
11 S-08 Round end celebration Watches the stars fly to her counter. +1 star per task and +2 round bonus: 6 stars for a 4-task round (Section 14). Celebration at most 2.5 seconds. Within an Abenteuer, a short path transition follows and the next round starts without a choice (Section 15.10.3). The "first round" milestone sticker is awarded here (Section 14). Stars, milestone sticker
12 S-07, S-08 Rounds 2 and 3. As above. As above
13 S-10 Abenteuer soft end Watches the friend say goodbye. +5 Abenteuer bonus (Section 14). The music fades to silence; spoken: a calm closing line and a goodbye (Section 15.10.4). The "first Abenteuer" milestone is recorded here and its sticker celebration is shown on S-05 right after S-10 closes (Section 14 owns milestones). A home button appears; without input, S-10 returns to S-05 automatically 15 seconds after the last line. Stars, milestone sticker, Abenteuer marked complete for today
14 S-11 Garden Back on S-05, Mila taps the garden button. Garden with her star balance. A spoken hint shows that stars can be used to decorate (S-12). Nothing

Edge and failure paths:

Case Behavior
The app is terminated or killed during S-02, S-17 or S-03 before "Fertig" is saved No profile exists. The next launch starts again at S-02, and the gate is required again. Nothing partial is ever stored (Section 15.4.1).
A child, not a parent, opens the app first S-02 is silent and shows only parent-facing text and "Profil einrichten". A child who taps the button reaches the parental gate (S-17), which a pre-reader cannot solve; "Abbrechen" returns to S-02. Nothing can be configured before the gate is passed, and there is no "skip setup" path and no default profile (Section 15.4.1). Every later profile creation also happens behind the gate (S-25, journey J-09).
Parent skips the nickname The profile picker caption and every name in the parent area use the German animal name of the avatar (for example "Fuchs"), never an empty label (display-name rule, Section 15.2.1).
Parent chooses the wrong level Changeable any time in S-20; mastery records are kept; the engine continues from the recorded mastery (Section 9).
Device is muted or volume is zero The Ring/Silent switch does not mute the voice (Section 20). At zero system volume the parent hears nothing in the sound check and can raise the volume; setup continues regardless. Spoken instructions are complemented by visual demos everywhere (Sections 10, 20).
Content validation fails at S-01 Section 8 owns the behavior. The child never sees a technical error text; the fallback is defined in Sections 8 and 24.
Device storage is full when the profile is saved The save is rolled back as one unit of work (Section 7.12.2); the parent sees a plain German message (Sie-form) on S-03 and can retry.
iPad in landscape, iPhone SE in portrait S-02, S-17 and S-03 are laid out for both orientations on all devices (Section 19).
VoiceOver is on The parent-facing setup is fully VoiceOver-labeled (Section 19).

3.4 J-02 Daily session (child alone) #

Persona: Lena on the iPhone SE. Precondition: One profile (Lena), time limit 20 minutes, not yet reached today; today's Abenteuer already done.

Step Screen Child action App behavior
1 S-01 Taps the app icon. Loads. Because exactly one profile exists, it goes straight to the child home (Section 15).
2 S-05 Child home - Spoken time-of-day greeting (Section 15.7.1). Because today's Abenteuer is done, the Abenteuer button shows the sleeping friends; tapping it opens the garden and plays "Morgen gibt es ein neues Abenteuer." (Section 15.10.4). The game picker button is available.
3 S-06 Game picker Taps the game picker button. Shows all 12 games as large picture tiles without text; premium games show a lock badge if not entitled (Section 17.8). A single tap on an unlocked tile starts the game; there is no confirmation step (Section 18.3.6).
4 S-07 Game screen Taps "Wie viele?". Round of 5 tasks (Vorschule, Section 9). The engine chooses numbers and step (Section 9). Spoken instruction; the speaker button repeats it.
5 S-07 Answers a task correctly on the first try. Positive feedback, the number shown as numeral, written word, quantity and spoken word (Section 4.2). Mastery update (outcome firstTry).
6 S-07 Answers the next task wrongly twice, then correctly. First wrong answer: neutral feedback and hint 1; second wrong answer: hint 2; correct on attempt 3: outcome afterHint (Section 10 owns the hint ladder; Section 4.13 explains the didactic intent).
7 S-07 Answers a task wrongly three times. The app models the solution calmly; outcome shown; the task is complete and still earns its star (Section 14).
8 S-08 Round end - 7 stars (5 tasks + 2 bonus). Picture buttons: play again, back to game picker, home (Section 18).
9 S-06, S-07, S-08 Plays two more rounds of different games. Continuous play time passes 8 minutes during the third round. The round continues to its end; the break nudge is never shown during a task or mid-round. S-26 appears after the round's S-08 (and any S-27 overlay), once per session (Section 15.9).
10 S-26 Break nudge overlay Taps "Weiterspielen" (play-arrow picture button). State nudge: the guide friend yawns and asks whether she wants a break; two picture buttons, "Pause" (moon) and "Weiterspielen" (play arrow). "Weiterspielen" closes the overlay and play continues from where S-08 left off; no further nudge this session. Alternative: "Pause" switches S-26 to its resting state (the friends sleep, a goodbye line plays once, only a sun button remains); tapping the sun returns to S-05 (Section 15.9).
11 S-05 Leaves the app (Home gesture). Session time stops counting on backgrounding (Section 15).

Edge and failure paths:

Case Behavior
The child taps the home control during a round Section 10 owns the exit mechanics (accidental exits are prevented). A completed exit ends the round: completed tasks keep their outcomes and stars; the +2 round bonus is not awarded; the child sees no loss message.
The child taps random answers repeatedly Every wrong answer follows the hint ladder; after the third wrong answer the solution is shown. Section 10 owns tap debouncing and the handling of rapid taps. The engine's step-down rule reacts to repeated shown outcomes (Section 9).
The child does nothing for a long time Section 10 owns idle behavior (repeat the instruction after a pause; never a countdown or timeout that fails the task).
A Zahlenfreund is befriended during a round The celebration S-27 is queued and shown after the round's S-08, never in the middle of a task (Section 14).
The child reaches the time limit mid-round Journey J-08.
The device is rotated mid-task Layout adapts; task state, attempts and any partially dragged items are preserved (Section 10); the voice is not interrupted.
Only one profile exists but a sibling uses the device The sibling plays on Lena's profile. Separate profiles are created by the parent in the parent area (journey J-09).

3.5 J-03 Daily Abenteuer #

Persona: Jonas. Precondition: Jonas's profile; today's Abenteuer not yet completed; family has an active subscription.

Step Screen Action App behavior
1 S-04 Profile picker Jonas taps his avatar (two profiles exist). Opens his child home.
2 S-05 Taps the Abenteuer button. -
3 S-09 Abenteuer intro Watches the greeting, then taps the play button. The engine composes three rounds from the Abenteuer-eligible games (subscription active: all games except Entdecken), mixing focus, review and stretch tasks (Section 9.16). A path of three stones shows the chosen games (Section 15.10.2). No automatic start: round 1 begins on the play button; after 20 seconds without a tap the intro line repeats once. Target duration about 5 minutes.
4 S-07 / S-08 Plays three rounds. Between rounds: S-08, then any queued S-27 overlay, then a short path transition to the next stone (Section 15.10.3). The break nudge never appears while an Abenteuer is in progress (Section 15.9).
5 S-08, S-27 Friend befriended - If the number 7 met the befriending rule during round 3 (Section 14), the S-27 celebration for "Sieben" is shown after that round's S-08 and before the transition to S-10 (Section 14.5.4, Section 15.10.3).
6 S-10 Abenteuer soft end - +5 bonus stars. The friends yawn and lie down in the garden; a calm closing line and a goodbye; the music fades to silence (Section 15.10.4). The Abenteuer is marked complete for its start day's local date (Section 9).
7 S-05, S-11 Garden Returns home (home button, or automatically 15 seconds after the last line) and visits the garden. "Sieben" is now visible in the Zahlenfreunde area.
8 S-05 Taps the Abenteuer button again. Spoken: "Morgen gibt es ein neues Abenteuer." The button leads to the garden. There is no streak counter and no mention of missed days anywhere (Section 14).

Edge and failure paths:

Case Behavior
Jonas leaves the Abenteuer after round 1 Stars already earned (including the round bonus of round 1) stay. The +5 bonus is not awarded. The same local day, the Abenteuer button resumes the unfinished Abenteuer at the next unplayed round; the next local day the unfinished one is discarded silently and a new Abenteuer is composed (Section 9.16.6). The +5 is paid on the first completion of the local day.
Time limit reached during the Abenteuer The current task always finishes, then S-16 (journey J-08). The Abenteuer is not completed; no bonus; no message about it. The same local day it can be resumed after a parent extension (Section 9.16.6).
Free user (no subscription) The Abenteuer draws only from the three Abenteuer-eligible free games Wie viele?, Hör hin and Was fehlt? (Entdecken is never part of an Abenteuer; Sections 9.16.2, 17).
Subscription expires between composition and round 3 A running round is never interrupted. At each Abenteuer round start the coordinator re-checks the entitlement; a premium round not yet started is replaced by an eligible free game not already in the plan, or dropped if none exists (Section 9.16.6, Section 17).
Local midnight passes during the Abenteuer The Abenteuer completes normally and counts for the day on which it started (Section 9).
The device date or time zone changes For the Abenteuer, "today" is the current local day of the device's current calendar and time zone (Section 9.16.7); one bonus per profile per local day. The daily time limit uses the trusted local date instead, so setting the clock forward never unlocks a locked profile (Section 15.12).

3.6 J-04 Parent check-in #

Persona: Sarah, in the evening. Precondition: Two profiles (Mila, Jonas); both played this week.

Step Screen Action App behavior
1 S-04 or S-05 Presses and holds the grown-up icon (adult silhouette, top-right corner, Section 18) for 2 seconds. Opens the parental gate. The grown-up icon exists on S-04, S-05 and S-16; S-15 has its own parent icon (Section 18).
2 S-17 Parental gate Reads the written question (for example "Was ist sieben mal acht?") and types 56 on the keypad, confirms. The question is never spoken. Correct answer opens the parent area (Section 16).
3 S-18 Parent dashboard Sees an overview card per child: level, current range, numbers mastered, time played today and this week. -
4 S-19 Child progress detail Taps Jonas's card. Number x skill grid (1 to 20 x 8 skills) with the bands "Noch nicht geübt", "Wird geübt", "Fast sicher", "Sicher" (labels in Section 22.6.3, presentation in Section 16, band thresholds in Section 9). "Gerade schwierig" lists up to 3 weakest items in plain German, for example "Die Zahlen 13 und 14 am Zwanzigerfeld erkennen". Time chart for the last 7 days.
5 S-20 Child settings Opens Jonas's settings, sets the range from "Auto" to "1-20". Range override disables automatic range changes for Jonas (Section 9). Takes effect at the next round start.
6 S-18 Leaves the parent area. Returns to the screen from which the parent area was entered (S-04 or S-05). The gate grant ends (Section 16).

Edge and failure paths:

Case Behavior
Wrong gate answer A new question appears. After 3 wrong answers a 30-second cooldown with a visible countdown for the adult (Section 16). The cooldown is a parent-facing element, not a child-facing timer.
A child reaches the gate by accident The child cannot read the question; the child can return with the clearly recognizable back or close control (Section 16 and Section 18).
Sarah switches to another app for 3 minutes and returns Still in the parent area.
Sarah switches away for more than 5 minutes The gate is required again; the app returns to the child-facing screen from which the parent area was entered (Section 16).
Sarah turns off the background music S-21 Device settings: "Musik" off; the change applies to all profiles immediately (Section 16, Section 20).
No data yet for a child S-19 shows an empty state in plain German ("Noch keine Spiele gespielt") instead of an empty grid (Section 16).
Sarah wants to reset or delete data S-23 Data management; destructive actions need a second confirmation (Section 16). "Fortschritt zurücksetzen" keeps stars, garden, stickers and friends; the per-child "Alles zurücksetzen" resets everything for that child except today's time usage; the device-wide "Alle Daten löschen" removes every profile and leads to S-02 (Sections 14.11, 16.10).
Sarah exports data S-23 creates a JSON file and opens the system share sheet (Section 16).

3.7 J-05 Free user taps a locked game #

Persona: Lena. Precondition: No subscription.

Step Screen Action App behavior
1 S-06 Game picker Lena taps the Froschsprung tile, which carries a lock badge. -
2 S-15 Ask-a-parent (locked game) Watches. A friendly, child-safe animation: the padlock wiggles once next to the game's illustration. Spoken (session.locked_game): "Dieses Spiel ist noch zu. Frag deine Eltern." Then, optionally, session.locked_other: "Schau mal, diese Spiele kannst du jetzt spielen!" while the open tiles on S-06 are highlighted. No app name, no game name, no price, no money word, no purchase button, no urgency (Section 17.8). Two controls: back (return to S-06) and the parent icon (single tap).
3a S-06 Lena taps back. Returns to the game picker. Nothing is recorded; the tile keeps its lock badge.
3b S-17 Parental gate Thomas, now present, taps the parent icon. Gate as in J-04.
4 S-22 Premium / paywall Thomas sees the paywall inside the parent area. Journey J-06 continues.

Edge and failure paths:

Case Behavior
Lena taps the parent icon herself The gate appears; she cannot answer it; she returns via the back or close control. After 3 wrong answers the 30-second cooldown applies.
Lena taps locked tiles many times The spoken line plays at most once per session per profile (Section 17.8). Later taps in the same session show only the lock animation and highlight the open tiles, without any spoken line, so the lock can never become a nagging tool.
Lena has already used a premium game during a past subscription Same behavior; her past mastery and earned rewards remain (journey J-07).

3.8 J-06 Parent subscribes (trial) #

Persona: Thomas. Precondition: Parent area opened through the gate (from S-15 or from S-18); never subscribed before; online.

Step Screen Action App behavior
1 S-22 Premium / paywall Reads the paywall. Loads both products from the App Store (Section 17). Yearly plan preselected and highlighted, monthly plan visible. If eligible for the introductory offer, the trial is shown with its length and the price after the trial, both read from StoreKit (Product.displayPrice and the offer period; never hardcoded, Section 17). A clear list of what is unlocked (8 more games plus all future games and content drops, shared with the family through Family Sharing), what stays free forever (4 games and all rewards), a note that everything earned is kept after cancellation, links to terms and privacy policy, "Käufe wiederherstellen". Copy in Section 22.3.
2 System purchase sheet Taps the subscribe button and confirms with Face ID, Touch ID or password in Apple's sheet. StoreKit 2 purchase (Section 17).
3 S-22 - On a verified transaction, the entitlement becomes active immediately, is cached for offline use (Section 17), and S-22 switches to the subscription status view (plan, renewal or trial end date, "Abo verwalten").
4 S-05 / S-06 Thomas leaves the parent area and gives the phone to Lena. Lock badges are gone. Decision: there is no child-facing celebration or message connected to the purchase; rewards are never tied to money (Section 2.4, P7).

Edge and failure paths:

Case Behavior
Not eligible for the trial (subscribed before in this group) Prices shown without trial wording (Section 17).
Thomas cancels the system sheet Stays on S-22; no error message.
Purchase pending (for example Ask to Buy for a family member) S-22 shows a plain German pending state; the entitlement activates when the transaction arrives through the transaction update listener (Section 17).
Purchase fails or verification fails Plain German error message and retry; no entitlement is granted on unverified transactions (Section 17).
Products cannot be loaded (offline, App Store unreachable) S-22 shows "Der App Store ist gerade nicht erreichbar." with a retry button; no fake prices, no cached price strings (Section 17).
Another family member already subscribed with Family Sharing The entitlement arrives as a family-shared transaction; S-22 shows the active state; no purchase needed (Section 17).
Thomas reinstalled the app or uses a new device "Käufe wiederherstellen" syncs with the App Store (Section 17). Earned progress does not transfer in V1 (local only, except through a device backup restore); from V1.1 it syncs via iCloud for the same Apple ID if a parent turns on "Mit iCloud synchronisieren" (off by default, subscription required, Section 7.15).
Thomas wants to cancel "Abo verwalten" opens Apple's subscription management sheet (Section 17).

3.9 J-07 Subscription lapses #

Persona: Thomas and Lena. Precondition: Thomas cancelled during the trial or later; the paid period (and any billing grace period configured per Section 17) has ended.

Step Screen Action App behavior
1 S-01 Next launch. Entitlement refreshed from the App Store (Section 17). No active entitlement: premium games lock again.
2 S-06 Lena opens the game picker. The 8 premium games show the lock badge again; tapping one leads to S-15 (journey J-05).
3 S-05, S-11, S-13, S-14 Lena visits garden, friends, album. Everything earned stays: stars, decorations, befriended Zahlenfreunde (including those befriended through premium games), stickers (including Punkt-zu-Punkt stickers), mastery records from premium games (Section 17).
4 S-09 Starts an Abenteuer. Composed from free games only (Section 9, Section 17).
5 S-19 Thomas checks progress. The progress grid still shows all skills, including those mainly trained in premium games (for example write); Decision: those cells are shown with their last recorded band, not greyed out, because the data is still true.
6 S-22 Thomas opens Premium. Shows "Abo abgelaufen" and the paywall with the current offer; no trial if not eligible.

Edge and failure paths:

Case Behavior
Lapse happens while Lena is in a premium round The round in progress finishes; the next round start checks the entitlement (Section 17).
Device offline at expiration Section 17 owns the offline entitlement rule using the cached expiration date.
Thomas resubscribes later Premium games unlock immediately; all previous progress continues seamlessly.
Refund granted by Apple Treated like a lapse from the moment the revocation arrives (Section 17); nothing earned is removed.

3.10 J-08 Time limit reached #

Persona: Mila. Precondition: Mila's time limit is 20 minutes; she has played 19 minutes and 40 seconds today.

Step Screen Action App behavior
1 S-07 Mila is in the middle of a task when the limit is reached. Two minutes earlier the guide friend said "Gleich ist Zeit zum Ausruhen." once (Section 15.8.2). The current task always completes: the hint ladder bounds it and the inactivity rules of Section 10 apply; there is no cut-off (Section 15.8.3). Its outcome and star are recorded. The round is closed as interrupted and does not continue to the next task.
2 S-16 Time's up ("Zeit zum Ausruhen") - A calm evening scene: her friend sleeps in the garden. Spoken once (session.times_up): "Jetzt ist Zeit zum Ausruhen. Bis morgen!" The music fades out and stays off. No countdown, no alarm sound, no red. Stars from finished tasks are kept; no round bonus for an unfinished round (Section 15.8.4).
3 S-16 Mila taps around. Tapping the scene does nothing. Child mode for Mila is locked until the next trusted local midnight (Section 15.12). The only controls are the grown-up icon (press and hold 2 seconds, then the gate, for a +10 minute extension, Section 15.8.5) and, if more than one profile exists, the avatar button to S-04 (Section 18).
4 S-17 → extension sheet Sarah passes the gate and chooses "+10 Minuten für heute" (the sheet also offers "Zum Elternbereich" and "Abbrechen"). S-16 closes to S-05 and a new session starts; Mila can play 10 more minutes today (Section 15.8.5).

Edge and failure paths:

Case Behavior
Limit reached on S-05, S-06, S-11 or another non-game screen A drag in progress or a purchase whose buy button was already tapped completes; then S-16 appears within 1 second (Section 15.8.3).
Limit reached during S-08 celebration The celebration completes (at most 2.5 seconds), then S-16 (Section 15.8.3).
Mila relaunches the app after being locked Goes to S-16 directly (single profile) or shows her avatar in S-04 in its sleepy state with a small moon; tapping it shows S-16 (Section 15.5.2).
A sibling's profile is not locked Decision: the sibling may play; limits are per profile. A child may pick the sibling's avatar to continue playing; this is an accepted limitation, documented in the parent-facing help text (Section 22), which recommends the system Screen Time for device-wide limits.
Parent lowers the limit below today's used time The profile locks immediately; on return from the parent area the child lands on S-16 (Section 15.8.6).
Parent sets the limit to "Aus" (off) The lock is lifted immediately; on return to child mode the child lands on S-05 (Section 15.8.6).
Local midnight passes while S-16 is shown At the next trusted local midnight the lock ends; the next touch replaces S-16 with S-05 and starts a new session (Section 15.8.4). Setting the device clock forward does not end the lock (Section 15.12).

3.11 J-09 Sibling switching and adding a profile #

Persona: Sarah, Mila and Jonas on one iPad.

Adding the second profile:

Step Screen Action App behavior
1 S-05 (Mila) Sarah taps the parent button. Gate (S-17).
2 S-18 Taps "Weiteres Profil". Available while fewer than 5 profiles exist (Section 15.4.2).
3 S-25 Profile editor Chooses avatar (lion), color theme, nickname "Jonas", level "Vorschule". Avatars already used by another profile are dimmed and cannot be selected; the first unused avatar is preselected. Avatars are unique per device because children identify themselves by avatar (Section 15.2.1, Section 15.4.2).
4 S-18 Saves and leaves the parent area. From now on the app launches to S-04 (two or more profiles).

Switching:

Step Screen Action App behavior
1 S-05 (Mila) Jonas takes the iPad and taps the profile-switch control (avatar in the corner). Opens S-04. Any running round was already left; there is no switching from inside S-07 (the child first leaves the round, Section 10).
2 S-04 Profile picker Taps the lion. Opens Jonas's S-05. Session accounting switches to Jonas (Section 15).

Edge and failure paths:

Case Behavior
5 profiles exist "Weiteres Profil" is replaced by the notice "Maximal 5 Profile pro Gerät." (Section 16, Section 22.5.2).
A profile is deleted S-23, with confirmation. If only one profile remains, the app launches straight into its child home. "Kind löschen" is disabled while only one profile exists; the only way to zero profiles is the device-wide "Alle Daten löschen", which leads to S-02 (Section 15.13, Section 16.10).
A child plays on the sibling's profile Progress goes to the wrong profile. Accepted limitation; mitigated by unique avatars and color themes (Section 15.5.3).

3.12 J-10 Device offline #

Precondition: Airplane mode (for example on a flight), subscription active.

Area Behavior offline
Launch, all games, rewards, parent area Fully functional; the app never needs the network (Section 24).
Entitlement Uses the cached last known entitlement and expiration (Section 17).
Audio All recorded voice lines are bundled; the TTS fallback uses on-device voices (Section 20).
S-22 Premium / paywall Products cannot load: "Der App Store ist gerade nicht erreichbar." with retry. Status of an existing subscription is shown from the cache.
Restore, manage subscription Plain German message that an internet connection is needed.
External links in S-24 (privacy policy, imprint, support page) and the support email After the gate, the link is handed to the system; the system shows its own offline state. Decision: the app does not pre-check connectivity for links.

3.13 J-11 App interrupted #

Precondition: A child is in S-07 in the middle of a task.

Interruption Behavior
Incoming phone or FaceTime call, Siri, alarm Audio stops per the system audio interruption; the game pauses (Section 10). If the interruption lasted less than 3 seconds, the game resumes automatically without an overlay (Section 10.12.3). Otherwise, after the interruption ends, the game shows its pause overlay with one large play control; tapping it repeats the current instruction. The task's attempt count is unchanged.
App backgrounded (Home gesture, app switcher) The game pauses; session time stops counting (Section 15). On return within 5 minutes: automatic resume if the absence was shorter than 3 seconds, otherwise the pause overlay as above (Section 10.12.3). After more than 5 minutes the session ends and the app shows S-04 or S-05 (Section 15.11). If the parent area was open and the app was in the background for more than 5 minutes, the gate is required again (Section 16).
Control Center or Notification Center pulled down While the app is inactive, time counting stops and audio pauses; resume follows Section 10.12.3 (automatic if shorter than 3 seconds) and Section 15.11.
Headphones or Bluetooth speaker disconnected The playing voice line stops and the task's speaker button pulses once so the line can be replayed; the game does not pause and no pause overlay appears (Section 20.12, Section 15.11).
Screen locks (lock button or auto-lock) Treated as backgrounding (Section 10.12, Section 15.11). Auto-lock cannot happen while a task is on screen and not paused, because the idle timer is disabled only then (Section 15.11).
Battery runs out or the process is killed On the next launch, the app starts at S-01 and goes to S-04 or S-05. Every completed task was already persisted; the unfinished task is discarded without any mastery update (Section 7, Section 10).
App terminated by the system in the background Same as above.
Low Power Mode No behavior change except as specified in Section 24 (animations may reduce).
Device rotated during the pause Layout adapts; state preserved.

3.14 Screen coverage by journey #

Every screen appears in at least one journey. Section 25 derives UI test scenarios from this matrix.

Screen Journeys
S-01 Splash/Loading J-01, J-02, J-07, J-11
S-02 First-launch welcome J-01, J-04 (after "Alle Daten löschen")
S-03 Parent setup: create first profile J-01
S-04 Profile picker J-03, J-04, J-08, J-09
S-05 Child home J-01, J-02, J-03, J-04, J-06, J-07, J-08, J-09
S-06 Game picker J-02, J-05, J-06, J-07
S-07 Game screen J-01, J-02, J-03, J-08, J-11
S-08 Round end celebration J-01, J-02, J-03, J-08
S-09 Abenteuer intro J-01, J-03, J-07
S-10 Abenteuer soft end J-01, J-03
S-11 Garden (Zahlengarten) J-01, J-03, J-07
S-12 Decoration shop J-01 (hint), J-07
S-13 Zahlenfreunde gallery J-07
S-14 Sticker album J-07
S-15 Ask-a-parent (locked game) J-05, J-07
S-16 Time's up (Zeit zum Ausruhen) J-03, J-08
S-17 Parental gate J-01, J-04, J-05, J-08, J-09
S-18 Parent dashboard J-04, J-09
S-19 Child progress detail J-04, J-07
S-20 Child settings J-04, J-08
S-21 Device settings J-04
S-22 Premium/paywall J-05, J-06, J-07, J-10
S-23 Data management J-04, J-09
S-24 Help and legal J-10
S-25 Profile editor J-09
S-26 Break nudge overlay J-02
S-27 Friend befriended celebration J-02, J-03

4. Didactic Foundation #

This section explains the didactic approach and turns it into testable rules. Rules carry IDs (DR-01 and following) so that game sections, design reviews and tests can cite them. The didactic rules are binding for every game, every visual and every spoken line. Section 9 owns all numeric thresholds of the learning engine; Section 10 owns the mechanics of hints and feedback; Sections 11 to 13 implement the rules in each game; Section 19 owns the visual dimensions.

4.1 Background and sources #

The app follows the mainstream of German-language early-mathematics didactics as used in Kita and the first school year. The approach is informed by, among others:

  • Erich Ch. Wittmann and Gerhard N. Müller, "Das Zahlenbuch" (textbook series and accompanying materials): structured number representations, Zwanzigerfeld, Zwanzigerreihe, emphasis on the "Kraft der Fünf" and on active, discovery-based learning.
  • Michael Gaidoschik, publications on preventing arithmetic difficulties ("Rechenschwäche vorbeugen" and related work): moving children from counting one by one to structure-based strategies, the role of the five and ten structure, finger patterns used as structured images.
  • Günter Krauthausen, "Einführung in die Mathematikdidaktik" and work on representations and Arbeitsmittel: careful, consistent use of a small set of representations.
  • Kristin Krajewski and colleagues, the model of the development of number-quantity competence and the preschool program "Mengen, zählen, Zahlen": from number words and quantities without link, to linking number words with exact quantities, to understanding relations between numbers (part-whole, differences).
  • Rochel Gelman and C. R. Gallistel, "The Child's Understanding of Number": the five counting principles (Section 4.9).
  • Jerome Bruner's distinction of enactive, iconic and symbolic representation (enaktiv, ikonisch, symbolisch): the app lets children act (drag, trace, shake), see images (structured quantities) and use symbols (numerals, words).

No quotations from these works appear in the app or in marketing texts. Parent-facing copy may mention that the app "follows the approach used in German Kita and Grundschule" (Section 22); it does not claim endorsement by any author or institution.

4.2 Three linked representations #

Every number exists in three representations, and the child learns the links between them:

Representation German term Form in the app Example for 7
Numeral Zahlzeichen (Ziffer for single digits) Large numeral in SF Pro Rounded (Section 19) 7
Word Zahlwort Spoken by the voice (always) and written in lowercase below the numeral "sieben" (spoken and written)
Quantity Menge / Anzahl Structured quantity in the five-structure: dots, beads or objects (Section 4.4) 5 red solid beads + 2 blue Lochperlen

Terminology used in this document: a "Ziffer" is one of the symbols 0 to 9; a number from 10 to 20 is written with two Ziffern. "Numeral" in this document means the complete written number (for example "14").

4.2.1 Rules #

ID Rule
DR-01 Resolution triad. Whenever a task resolves (correct answer on any attempt, or solution shown after the third wrong attempt), the target number is shown as numeral, written word and structured quantity together, and the word is spoken once. This triad is the only moment where all three appear simultaneously; it lasts until the next task starts (Section 10 owns timing).
DR-02 Hidden answer. While a task is open, the representation that is being asked for is never shown or spoken. Example: in Hör hin the numeral is the answer, so the prompt contains only the spoken word; in Wie viele? the numeral is the answer, so the voice never says the count before the child answers.
DR-03 Written word style. The written word is always lowercase ("sieben", "vierzehn"), never hyphenated, displayed below the numeral and smaller than the numeral (Section 19 sets sizes). Children do not need to read it; it is exposure and it lets parents read along.
DR-04 Spoken word form. When a number stands alone (counting, triad, answer confirmation) the voice uses the counting form: "eins", never "ein". Inside sentences the grammatically correct form is used ("ein Apfel", "die Sieben").
DR-05 One quantity form per resolution. The triad's quantity is always the standard five-structured form (Section 4.4) at the size of the child's range stage (DR-19), even if the task itself used scattered objects, dice or fingers. The task's own representation stays visible next to it where space allows (Section 19 decides for small screens). This links every arrangement back to the one reference image.
DR-06 Sound off. If voice is turned off (Section 20), DR-01 still applies visually; the spoken word is omitted and nothing else changes.

4.2.2 Representation pairs per game #

Each game trains specific links between representations. Section 11 to 13 own the mechanics; this table fixes the didactic pairing that the mechanics must respect.

Game Given (stimulus) Asked for (response) Links trained
Entdecken (free explore) Child chooses a place in the Zwanzigerfeld Nothing; the app answers with the triad and the split Quantity → numeral, word; structure
Entdecken ("Zähl mit") Voice counts along as places fill, then asks Selecting the named number or place Word ↔ quantity ↔ numeral
Wie viele? Quantity (objects) Numeral Quantity → numeral
Hör hin Spoken word Numeral Word → numeral
Was fehlt? Ordered sequence on the bead chain with one gap Numeral for the gap Order → numeral
Blitzblick Structured quantity shown for a moment (dots, dice, fingers) Numeral Quantity (at a glance) → numeral
Mehr oder weniger Two quantities, or two numerals, or a quantity and a numeral Side with more, or the bigger number, or "gleich" Relation between numbers
Nachspuren Numeral model and spoken word Written numeral (traced) Word, numeral → handwriting (enactive)
Schüttelbox Quantity split into two parts The parts, or the missing part Part-whole
Froschsprung Spoken or written target, or relative instruction ("2 mehr") Position on the number line Numeral → position; relations
Fütter das Zahlenmonster Numeral and spoken word Quantity produced by dragging exactly N items Numeral, word → quantity (enactive)
Memory Cards with numeral, quantity or dice pattern Matching pairs Numeral ↔ quantity ↔ pattern
Punkt zu Punkt Dots labeled with numerals Connection in number order Numeral sequence (order)

4.3 Kraft der Fünf (the power of five) #

Children should see 7 as "five and two" at a glance instead of counting seven single objects. The five is the natural first unit (one hand) and the ten is the second (two hands). Every structured representation in the app is built on this.

ID Rule
DR-07 Five first. In every structured quantity, the first five elements form one complete group before any element of the next group appears. 1 to 5: one group. 6 to 10: a full group of five plus 1 to 5.
DR-08 Ten first for teens. 11 to 20 are shown as a full ten (two groups of five) plus 1 to 10 in the next row, again five first. 13 = full ten + 3; 18 = full ten + 5 + 3; 20 = two full tens.
DR-09 Split label. Where the app writes a split (Entdecken, resolution triad where Section 10 enables it), it uses exactly these forms: 1 to 5: no split label; 6 to 9: "5 + n" (for example "5 + 2" for 7); 10: "5 + 5"; 11 to 19: "10 + n" (for example "10 + 4" for 14); 20: "10 + 10". The five-structure inside the second row remains visible in the image even though the label says "10 + n".
DR-10 Structure in hints. For count and subitize tasks with target 6 or more in a structured arrangement, the second hint models the structured strategy ("Schau mal: fünf ... und noch zwei. Das sind sieben."), not counting one by one. For targets 11 to 20 the model is "zehn ... und noch vier. Das sind vierzehn." Section 21 lists the lines, Section 10 the hint ladder.
DR-11 Counting is allowed. Counting one by one is never forbidden, penalized or discouraged in words. The app models the structured strategy; it never tells the child "nicht zählen".
DR-12 Finger patterns (Fingerbilder). Finger images show numbers as whole patterns, never as fingers appearing one after another. German convention: counting starts with the thumb. 1 = thumb; 2 = thumb and index finger; 3 = thumb, index, middle; Decision: 4 = thumb, index, middle and ring finger (the little finger stays down); 5 = full hand. 6 to 10 = one full hand plus the pattern for n - 5 on the second hand, starting with the thumb. Hands are drawn as the child sees their own hands (back of the hands towards the viewer, fingers pointing up), the full hand on the left.
DR-13 Dice patterns (Würfelbilder). Standard pip patterns for 1 to 6 (pip layout in Section 19.7.4). Dice pips use one uniform color (ink, Section 19); dice are not five-coded because their structure is their own.

4.4 Zwanzigerfeld, bead chain and Rechenrahmen colors #

The app uses three structured layouts. All three follow the same color and shape logic, taken from the Rechenrahmen (school abacus with two rows of ten beads, each row five red and five blue).

4.4.1 Color and shape logic #

ID Rule
DR-14 Group coding. Within any structured quantity, elements are coded by their position within their own set: positions 1 to 5 red solid round bead (#D8453A); positions 6 to 10 blue "Lochperle", a round bead with a clear white center ring (#2D6BD4); positions 11 to 15 red solid; positions 16 to 20 blue Lochperle. Shape and color always change together, so the structure is visible without color vision (Section 19 owns the exact geometry).
DR-15 Group gap. Between two groups of five, the spacing is 1.5 times the normal element spacing. There is no additional gap at the ten boundary in the bead chain; the ten boundary is visible because the coding restarts with red (DR-14) and, in the Zwanzigerfeld, because a new row starts. In the compact arrangement (DR-20), where each row is one group of five, the 1.5× gap separates the two tens vertically.
DR-16 Colors are reserved. Red and blue bead coding means only "first five / second five". It is never used to mean right or wrong, never used to distinguish the parts of a decomposition, never used as a status color (for example for accepted tracing strokes or connected dots, which use ink, Section 19), and never used for decoration inside a task. Right/wrong feedback uses the success green #4FA36B and the neutral gray #8A8FA3 (Section 19); decomposition parts are distinguished by position (Section 4.7).
DR-17 Unstructured means uncoded. Scattered or moving arrangements (Section 4.5) never use the red/blue coding, because the coding without spatial grouping would carry no meaning and would give an unintended hint. They use a single uniform color or identical object pictures.
DR-18 Empty places. Unfilled places in a field are drawn as light outline circles in the neutral gray, so that the field size and structure stay visible.

4.4.2 Zwanzigerfeld layout #

The Zwanzigerfeld is a field of 2 rows x 10 places. Each row is split into two groups of five by a vertical gap (DR-15). Row 1 holds 1 to 10, row 2 holds 11 to 20. A quantity n fills the first n places in reading order: row 1 from left to right, then row 2 from left to right. This is the standard ("wide") arrangement; the compact arrangement for interactive fields in narrow windows is defined in DR-20.

Row 1:  (1)(2)(3)(4)(5)   [6][7][8][9][10]
Row 2:  (11)(12)(13)(14)(15)   [16][17][18][19][20]

( ) = red solid bead      [ ] = blue Lochperle
Example 13: row 1 full (5 red + 5 blue), row 2: 3 red, remaining 7 places empty outlines.

The place-to-coding mapping is a pure function. Reference logic (the concrete type lives where Section 5 places shared domain logic; Section 19 turns it into geometry):

/// Didactic coding of one place in a structured field (Section 4.4).
public struct StructuredPlace: Equatable, Sendable {
    public let index: Int        // 1...20, position within the set
    public let row: Int          // 0 for 1...10, 1 for 11...20
    public let column: Int       // 0...9 within the row
    public let fiveGroup: Int    // 0 or 1 within the row (column / 5)
    public let isFirstFive: Bool // true -> red solid bead, false -> blue Lochperle

    /// Returns nil outside 1...20 (no trapping; Section 6 forbids precondition in production code).
    public init?(index: Int) {
        guard (1...20).contains(index) else { return nil }
        self.index = index
        self.row = (index - 1) / 10
        self.column = (index - 1) % 10
        self.fiveGroup = self.column / 5
        self.isFirstFive = self.fiveGroup == 0
    }
}
ID Rule
DR-19 Field size follows the range stage. Task and resolution fields show only the places of the child's current range stage: range 1 to 5 shows one row of five places; range 1 to 10 shows one row of ten places (5 + 5); range 1 to 20 shows the full Zwanzigerfeld. Exception: Entdecken's free-explore mode always shows the full Zwanzigerfeld for every level, because exploring beyond the current range is welcome and records no mastery.
DR-20 Structure is preserved in every arrangement. A display field (one the child only looks at, including the resolution triad) is always 2 rows x 10 (or 1 row of 5 or 10 for the smaller ranges), in portrait and landscape, on iPhone and iPad; it is scaled, never reflowed. An interactive field (one whose places the child taps or fills) in a task area narrower than 714 pt (every iPhone in both orientations, narrow iPad windows) uses the compact arrangement: rows of exactly one group of five, in reading order (range 1 to 10: 2 rows of five, 1 to 5 above 6 to 10; range 1 to 20: 4 rows of five, 1 to 5, 6 to 10, 11 to 15, 16 to 20), with a 1.5× vertical gap between the two tens (between rows 2 and 3). No other reflow is allowed. The compact arrangement keeps five-first, ten-first and the coding of DR-14, and it lets every place keep a child touch target of at least 60 x 60 pt (Section 19); an interactive place is never shrunk below that minimum to force two rows of ten. The layout values (714 pt threshold, spacing) are owned by Section 11.2.4, the rendering (ZwanzigerfeldView with arrangement: .wide / .compact) by Section 19.7.3.

4.4.3 Bead chain (Zahlenkette) #

The bead chain is a linear string of 20 beads coded per DR-14 (red 1 to 5, blue 6 to 10, red 11 to 15, blue 16 to 20) with the group gap of DR-15. It shows order (Was fehlt?) and is the app's namesake.

ID Rule
DR-21 Chain wrapping. If a chain row does not fit the task-area width at the minimum bead and numeral sizes (Section 19; a row of ten beads needs about 820 pt on iPad and about 604 pt on iPhone, Section 11.5.3), the chain wraps, and it wraps only at five-group boundaries: two lines of ten when ten fit, otherwise lines of exactly five. Every line starts at 1, 6, 11 or 16. The wrapped chain keeps reading order (each line runs left to right; the next line continues below) and the coding of DR-14, so it stays structurally identical to the Zwanzigerfeld in its wide or compact arrangement (DR-20).
DR-22 Chain direction. The chain always runs left to right in increasing order. Counting backwards (Was fehlt?, later steps) moves right to left along the same chain; the chain itself is never mirrored. A backward segment is therefore displayed ascending from left to right (for example 6 to 15), with a right-to-left arrow showing the counting direction; only the choice of gaps follows the backward count (Section 11.5). A chain is never drawn in descending order.

4.4.4 Rechenrahmen #

V1 has no separate Rechenrahmen game. The Rechenrahmen contributes the color logic (DR-14) and the convention that counted beads sit on the left and uncounted places on the right (DR-18 empty outlines take the role of the unpushed beads).

4.5 Structured before scattered #

Children first learn to read quantities in the standard structure, then in other familiar patterns, and only then in arrangements without structure. Arrangements are classified as follows:

Class Name Description Coding
A1 Five-structured Row of five, row of ten, Zwanzigerfeld, bead chain (DR-07, DR-08) Red/blue per DR-14
A2 Familiar patterns Dice patterns 1 to 6, finger patterns 1 to 10, pairs (two rows of equal length with the remainder at the end) Uniform color (DR-13, DR-17); fingers as drawn hands
A3 Scattered, static Objects placed randomly without overlap, with a minimum distance between objects (Section 19) Uniform (DR-17)
A4 Moving Objects that move slowly on the stage (Wie viele? only) Uniform (DR-17)
ID Rule
DR-23 Progression. Within a game, lower difficulty steps use lower arrangement classes. The general mapping is: step1 uses A1 (and A2 in games whose subject is dice or finger patterns); step2 introduces A2 or A3; step3 introduces A3 or A4. Each game section (11 to 13) states its exact mapping and must not use a higher class at a lower step.
DR-24 No per-number gate in V1. Decision: the progression is controlled by the per-game difficulty step (Section 9), not by a separate per-number gate. A child who is at step2 of Wie viele? may meet a newly widened number scattered; the stretch share of the task mix (Section 9) keeps such encounters rare.
DR-25 Moving objects are slow and countable. A4 objects move at a speed at which a 5-year-old can follow each object with a finger, never leave the stage, never overlap, and never flash (Section 19: no flashing above 3 Hz). Counting moving objects is supported by letting counted objects be marked (Section 11).

4.6 Subitizing (Simultanerfassung) #

Subitizing is recognizing a quantity at a glance without counting. Perceptual subitizing of unstructured dots is limited to very small quantities (for young children about 3, for older preschoolers and adults about 4). Conceptual or quasi-simultaneous recognition uses structure: 7 is seen as 5 and 2, 14 as 10 and 4. Blitzblick and parts of other games train this.

DR-26 Subitizing limits. Tasks where a quantity is displayed only for a moment (flash tasks) respect these maximum quantities:

Arrangement littleOnes ("Die Kleinen") vorschule ("Vorschule")
A3 unstructured (scattered) at most 3 at most 4
A2 dice patterns 1 to 6 1 to 6
A2 finger patterns 1 to the range maximum (at most 10) 1 to 10
A1 five-structured 1 to the range maximum (at most 10; littleOnes never auto-widens to 20) 1 to the range maximum (up to 20, quasi-simultaneous as 10 + n)
ID Rule
DR-27 Display time is not a response timer. The flash duration (1 to 2 seconds, Section 12 owns the values per step) is how long the quantity is visible. After it disappears, the child has unlimited time to answer. There is never a countdown, a timer bar or a timeout.
DR-28 Unstructured quantities above the limit appear only in tasks where the quantity stays visible and counting is expected (Wie viele? with A3 or A4).
DR-29 Hints for flash tasks may show the quantity again, this time in A1 structure, before the next attempt (Section 12).

4.7 Zahlzerlegung (decomposition) #

Decomposing a number into parts (7 = 3 + 4) is the foundation of later adding and subtracting and of the part-whole concept. The Schüttelbox is the classic Vorschule material: a box with N beads and a divider; after shaking, the beads land on the two sides and the child states the split.

ID Rule
DR-30 Range of decomposition. littleOnes: numbers 2 to 5 only. vorschule: all two-part splits of 2 to 10 in V1. Splits of 11 to 20 (starting with the ten split 10 + n) are not trained in V1, because the 20-bead Schüttelbox does not fit the smallest supported screen; they follow in the content drop "Schüttelbox bis 20" (Section 17.12.1). In V1 the skill decompose therefore applies to 2 to 5 (littleOnes) and 2 to 10 (vorschule) only. 1 cannot be decomposed into two non-empty parts; the skill decompose does not apply to the number 1, and the parent progress grid shows that cell as not applicable (Section 16).
DR-31 Zero parts. 0 is not a target number in V1. A split with an empty part (7 = 7 + 0) is mathematically valid but is never the required answer; Section 12 specifies how the Schüttelbox avoids or handles it.
DR-32 Parts by position. The two parts are distinguished by position (left half, right half, separated by a divider), never by recoloring (DR-16). Each part, viewed as its own set, is coded per DR-14 and arranged five-first per DR-07 once the beads settle, so 3 appears as three red solid beads and 6 as five red and one blue.
DR-33 Credit goes to the whole. A decomposition task credits the skill decompose for the whole number n (the number that was split), not for the parts (Section 9).
DR-34 Order of parts. 3 + 4 and 4 + 3 are both correct descriptions of the same split when the child states the parts; the answer format that Section 12 defines determines whether order matters for a given task (for example "left side" and "right side").

4.8 Number line (Zahlenweg) #

Froschsprung uses a number line as a path of stepping stones: a linear, ordinal model of the numbers, where "more" is further along and "less" is further back.

ID Rule
DR-35 Direction. The line runs left to right in increasing order in every orientation. "Mehr" always means a move to the right, "weniger" a move to the left.
DR-36 Start at the bank. The frog starts on the bank before stone 1. The bank represents zero but is not labeled "0" and the word "null" is never spoken for it (0 is not a target number in V1, DR-31). Stones are numbered 1 to the range maximum.
DR-37 Five structure on the line. Stones are coded per DR-14 (red solid for 1 to 5 and 11 to 15, blue ring style for 6 to 10 and 16 to 20), with the group gap of DR-15. Stones 5, 10, 15 and 20 are additionally slightly larger, so the five and ten positions serve as landmarks.
DR-38 Never wrap. The number line never wraps into a second row. If the line does not fit, the view shows a window of consecutive stones that scrolls with the frog: at least 5 stones (one five-group) at compact width, at least 10 stones at regular width, always as many as the 60 pt touch-target rule allows (Section 19). The window boundaries are aligned to five-groups where possible, and a mini-map shows the whole line and the window's position (Section 13.1.4 owns layout).
DR-39 Labels. Section 13 decides which stones show numerals at each step (for example all stones at step1, only landmark stones at higher steps). The landmark stones 5, 10, 15 and 20 are always labeled, except when a landmark is the hidden target stone of a step3 task (the task asks for that position). The bank is never labeled (DR-36). Stone numerals are task content and use the child numeral size of Section 19 (at least 44 pt on iPhone, 64 pt on iPad).

4.9 Counting principles #

The five counting principles (Zählprinzipien) describe what a child must understand to count correctly. The app addresses each explicitly.

Principle German term Meaning Where the app trains it How the app supports it
One-to-one correspondence Eins-zu-eins-Zuordnung Every object is counted exactly once, with exactly one number word Wie viele?, Fütter das Zahlenmonster, Entdecken ("Zähl mit") Counted objects can be marked (Wie viele?); every dragged item produces exactly one spoken number word (Zahlenmonster); the voice counts along one place at a time (Entdecken)
Stable order Stabile Reihenfolge Number words are always said in the same order Was fehlt?, Punkt zu Punkt, Froschsprung, Entdecken ("Zähl mit") Always the same number word sequence; forward and backward counting on the chain
Cardinality Kardinalprinzip The last number word counted tells how many there are Wie viele?, Fütter das Zahlenmonster, Blitzblick After counting, the question "Wie viele sind es?" is answered with the last word; the resolution says "Das sind sieben."
Abstraction Abstraktionsprinzip Anything can be counted, whatever it looks like Wie viele? (changing object themes), Memory (numeral, quantity, dice), Mehr oder weniger Object types vary between tasks; equal numbers of different-looking objects are compared
Order irrelevance Irrelevanz der Anordnung The count does not depend on the order or arrangement Wie viele? at A3 and A4, Schüttelbox, Mehr oder weniger The same number in different arrangements; beads that change sides still total N
ID Rule
DR-40 Cardinality phrasing. Resolution lines for quantity tasks always end with the cardinal statement "Das sind n." (for example "Das sind sieben."), never with only the number word (Section 21 lists the lines).
DR-41 Counting along. When the voice counts along (hints, "Zähl mit", Zahlenmonster), each number word is synchronized with exactly one visual event (one element highlighted, placed or marked). The voice never counts faster than the visual events.

4.10 German number words 1 to 20 #

The voice pronounces number words in neutral Hochdeutsch (Section 1.2 D-10). The audio IDs follow the convention of Section 20: num.<n> for the statement form and num.<n>.q for the question intonation; Section 21 lists every file.

The "Structure" column is the split label (DR-09) and the spoken decomposition (DR-43): for 11 to 19 it is always "10 + (n − 10)", spoken "zehn und (n − 10)" (for example "zehn und sieben" for 17), never "zehn und fünf und zwei". Inside the second ten the image still shows five-first (DR-08), so 17 is drawn as a full ten, five red and two blue; that rendering structure never becomes a spoken or written label.

n Word Syllables Pronunciation (IPA) Structure Notes for recording and content Audio ID
1 eins eins [aɪ̯ns] - Counting form "eins"; "ein" only inside sentences (DR-04) num.1
2 zwei zwei [tsvaɪ̯] - Always "zwei", never "zwo" (the "zwo" variant used on the telephone or for dictation is never used) num.2
3 drei drei [dʁaɪ̯] - num.3
4 vier vier [fiːɐ̯] - Long vowel num.4
5 fünf fünf [fʏnf] - Clear final "f" num.5
6 sechs sechs [zɛks] 5 + 1 "chs" spoken as [ks] num.6
7 sieben sie-ben [ˈziːbn̩] 5 + 2 Two syllables clearly articulated ("sie-ben", not "siem") num.7
8 acht acht [axt] 5 + 3 num.8
9 neun neun [nɔɪ̯n] 5 + 4 num.9
10 zehn zehn [tseːn] 5 + 5 num.10
11 elf elf [ɛlf] 10 + 1 Irregular: no "-zehn". Children often say "einszehn"; content explicitly links "zehn und eins sind elf" num.11
12 zwölf zwölf [tsvœlf] 10 + 2 Irregular: no "-zehn"; clear "ö". Linked via "zehn und zwei sind zwölf" num.12
13 dreizehn drei-zehn [ˈdʁaɪ̯tseːn] 10 + 3 Inversion: ones spoken first, tens second; stress on first syllable num.13
14 vierzehn vier-zehn [ˈfɪʁtseːn] 10 + 4 Short vowel [ɪ] in "vier-" in this word (unlike "vier") num.14
15 fünfzehn fünf-zehn [ˈfʏnftseːn] 10 + 5 num.15
16 sechzehn sech-zehn [ˈzɛçtseːn] 10 + 6 Tricky: the "s" of "sechs" drops (not "sechszehn"), and "ch" is spoken [ç] as in "ich", not [ks]. Written form must be "sechzehn" num.16
17 siebzehn sieb-zehn [ˈziːptseːn] 10 + 7 Tricky: "-en" of "sieben" drops (not "siebenzehn"); "b" sounds like [p]. Written form must be "siebzehn" num.17
18 achtzehn acht-zehn [ˈaxtseːn] 10 + 8 num.18
19 neunzehn neun-zehn [ˈnɔɪ̯ntseːn] 10 + 9 num.19
20 zwanzig zwan-zig [ˈtsvantsɪç] 10 + 10 Decision: ending spoken [ɪç] (standard pronunciation), not the southern [ɪk] num.20

4.10.1 Modeling the teens as 10 + n #

German teen words name the ones before the ten ("vier-zehn" = four-ten), while the numeral writes the ten first ("14"). This inversion is a known source of errors (a child hears "vier" first and writes or picks 4, or writes "41").

ID Rule
DR-42 Ten first in images. Teens are always shown as a full ten plus n in the Zwanzigerfeld (DR-08) and labeled "10 + n" (DR-09).
DR-43 Decomposition sentence. In Entdecken and as the second hint for teen targets, the voice speaks the ten-first sentence "Zehn und vier sind vierzehn." (pattern: "Zehn und sind ."), which states the structure in the same order as the numeral. For 11 and 12 the same pattern applies ("Zehn und eins sind elf.", "Zehn und zwei sind zwölf."). For 20: "Zehn und zehn sind zwanzig."
DR-44 No syllable-synchronous highlighting for teens. Decision: the app does not highlight "the 4 beads" while the voice says "vier-" and then "the ten" while it says "-zehn", because this would map the spoken inversion onto the picture. Instead the triad shows the whole quantity at once and the decomposition sentence (DR-43) carries the structure.
DR-45 Diagnostic teen distractors. In numeral-choice tasks with a teen target, the distractor pool may include the unit number (for 14: 4), because choosing it reveals the inversion error. Section 11 to 13 decide per game and step whether this distractor type is used.

4.10.2 The number 0 #

Decision: V1 trains the numbers 1 to 20. Zero ("null") is not a target number, has no mastery record and is never the required answer of a task. The number line's start position represents zero without a label and is never named (DR-36). The word "null" is recorded once (num.0, statement form only, declared as the optional zero entry of numbers.json, Section 8.6) and is used only where a child writes the Ziffer 0 as part of 10 or 20 in Nachspuren (Section 12.4).

4.11 Skill taxonomy #

Eight skills are tracked per number 1 to 20 (Section 9 owns the mastery math). A task credits its primary skill with weight 1.0 and its secondary skill with weight 0.5 (Section 9). The raw values are those of enum Skill: String (Section 5, Section 7).

Raw value German name (UI) Definition Games (primary) Games (secondary) Observable evidence of the skill for number n Active for littleOnes Development level (Krajewski model)
recognize Ziffer erkennen Identifies the written numeral for n and distinguishes it from other numerals Memory Entdecken, Wie viele?, Hör hin, Was fehlt?, Nachspuren, Fütter das Zahlenmonster, Punkt zu Punkt Selects or matches the numeral n correctly on the first attempt when it is the target Yes Symbol knowledge supporting level 2
name Zahlwort zuordnen Links the spoken word for n to its numeral or quantity. The child never speaks into the app; there is no microphone use Hör hin, Entdecken ("Zähl mit" mode only) - After hearing the word, selects the matching numeral or quantity on the first attempt Yes Level 1 to 2 (word-quantity link)
count Zählen Determines how many objects a set has by counting, or produces a set of exactly n objects, following the counting principles (Section 4.9) Wie viele?, Fütter das Zahlenmonster Blitzblick States the correct count, or produces exactly n items, on the first attempt Yes Level 2 (exact quantity concept)
subitize Simultanerfassung (Blitzblick) Recognizes n at a glance without counting, using perception (small quantities) or structure (quasi-simultaneous) Blitzblick Mehr oder weniger, Schüttelbox, Memory Names the correct quantity after a short display, or matches a pattern to its numeral, on the first attempt Yes Level 2
order Ordnen (Zahlenreihe) Knows the position of n in the number sequence: predecessor, successor, forward and backward order, position on the number line Was fehlt?, Froschsprung, Punkt zu Punkt - Fills the gap with n, lands on n, or connects n in the correct order on the first attempt Yes Level 1 (sequence) to 3 (relations)
compare Vergleichen Decides whether one quantity or number is more, less or equal compared with another Mehr oder weniger Froschsprung Chooses the correct side or "gleich" on the first attempt Yes Level 3 (relations)
decompose Zerlegen Understands n as composed of two parts (part-whole) Schüttelbox - States the correct split or missing part of n on the first attempt Yes, numbers 2 to 5 only (DR-30); vorschule numbers 2 to 10 in V1 Level 3 (part-whole)
write Schreiben Writes the numeral n with correct shape, stroke order and direction Nachspuren - Traces n within the tolerance and stroke order of Section 12 without the solution being modeled Only via Nachspuren step1; never required for Zahlenfreunde (Section 14) Symbolic (enactive production)

Notes:

  • Entdecken's free-explore mode records no mastery; only "Zähl mit" credits name (primary) and recognize (secondary) (Section 11).
  • Which number a task credits (for example both numbers in a comparison, the whole in a decomposition) is defined per game in Sections 11 to 13 and in the task model of Section 10; DR-33 fixes the decomposition case.
  • Cells that are not applicable (the number 1 for decompose; numbers above 5 for decompose at littleOnes; numbers 11 to 20 for decompose at both levels in V1, DR-30) are never practiced for that level and are shown as not applicable in the parent area (Section 16). Section 9.4 applies the same ranges.

4.12 Levels and range stages #

4.12.1 Levels #

The level (enum Level: String) sets starting range, round length, active skills and permitted content. The level is chosen by the parent in S-03 or S-20; age is guidance only, because the app stores no birthdate.

Property littleOnes vorschule
German UI name "Die Kleinen" "Vorschule"
Typical age (guidance only) 2 to 4 years 4 to 6 years
Starting range stage r5 (1 to 5) r10 (1 to 10)
Automatic widening To r10 at most; never automatically to r20 To r20
Parent override May set any stage including r20 (Section 16) May set any stage
Tasks per round 4 5
Active skills recognize, name, count, subitize, order, compare, decompose (2 to 5 only), write (Nachspuren step1 only) All 8 skills; decompose for 2 to 10 in V1 (DR-30)
Subitizing limits Per DR-26 Per DR-26
Games available All 12 (premium games require the subscription, Section 17) All 12 (same)
Typical session 3 to 5 minutes 5 to 8 minutes
ID Rule
DR-46 Level change keeps data. Changing the level never deletes or resets mastery records, stars or rewards. Section 9 owns how the engine re-evaluates the range and difficulty after a level change.
DR-47 Guidance for choosing. The setup (S-03) and profile editor (S-25) recommend "Vorschule" for a child who already counts objects to 5 reliably, regardless of age, and "Die Kleinen" otherwise. The parent always decides.
DR-48 Under-3 co-play. Children under 3 are expected to explore more than to master. Content for littleOnes never requires skills beyond the table above, and parent-facing copy recommends playing together with children under 3 (Section 22).

4.12.2 Range stages #

RangeStage Numbers Didactic meaning Structured field shown in tasks (DR-19)
r5 1 to 5 One group of five; one hand One row of five places
r10 1 to 10 Two groups of five; two hands; the first ten One row of ten places (5 + 5)
r20 1 to 20 Two tens; the full Zwanzigerfeld Two rows of ten places

Widening moves to the next stage when the child is ready; narrowing returns to the previous stage when newly added numbers prove too hard. Section 9 owns the exact conditions (thresholds, minimum sessions, re-attempt delay) and the parent override that disables automatic range changes.

ID Rule
DR-49 Widening is invisible to the child. A range change is never announced as a level-up to the child and never celebrated with a special animation. New numbers simply appear. Parents see the current range in S-18 and S-19.
DR-50 Narrowing is invisible to the child. A return to the previous stage is never communicated to the child in any way.
DR-51 New numbers enter gently. Newly available numbers first appear through the engine's focus and stretch slots (Section 9) at the child's current difficulty step; no round consists only of new numbers.

4.13 Error philosophy #

Errors are information about the child's current strategy, not failures. The app responds to an error the way a good Kita teacher would: calmly, with a smaller next step, and by showing the structure. Section 10 owns the hint ladder mechanics (attempt counting, which visual changes happen, timing); Section 14 owns the rule that every completed task, including a shown solution, earns its star. This section fixes the didactic intent the mechanics must serve.

4.13.1 The hint ladder, didactically #

Attempt What happened Didactic purpose of the app's response Outcome recorded (Section 9)
1 correct - Confirm, show the triad (DR-01) firstTry
1 wrong First wrong answer Re-attend: the child may have mis-tapped or not listened. Repeat the question in other words and direct attention to the relevant part of the picture without revealing the answer -
2 correct - Confirm, triad afterHint
2 wrong Second wrong answer Scaffold: model a strategy, not the answer: structured strategy for quantities (DR-10), counting along with synchronized highlighting (DR-41), ten-first sentence for teens (DR-43), neighbor reference for order tasks ("Nach der Sechs kommt ...") -
3 correct - Confirm, triad afterHint
3 wrong Third wrong answer Model: calmly show the solution with the triad and the cardinal statement (DR-40), then continue. The child experiences completion, not failure shown

4.13.2 Rules #

ID Rule
DR-52 No negative words. The app never says or shows "falsch", "nein", "leider", "schade", "Fehler" or equivalent. Wrong-answer lines are invitations ("Schau noch mal genau.", "Probier es noch einmal."). Section 21 lists the exact lines.
DR-53 No negative signals. No red (red is reserved for beads, DR-16), no buzzer, no shaking screen, no sad faces, no removal of anything. Wrong-answer feedback uses the neutral gray #8A8FA3, a soft neutral sound and at most a gentle movement of the chosen element, and never a haptic (Section 10, Section 19.10).
DR-54 Hints teach. A hint always adds information that makes the task more solvable (attention, structure, strategy). A hint never simply repeats the same prompt a third time with no change.
DR-55 The shown solution is a learning moment. After shown, the triad and the cardinal statement are presented with the same calm tone as a correct answer. The child is not asked to "try again" on the same task.
DR-56 Difficulty follows errors, silently. Repeated difficulty lowers the difficulty step or narrows the range per Section 9, without telling the child.
DR-57 Parents see difficulties, children do not. Error patterns surface only in the parent area as "Gerade schwierig" in plain German (Section 16). The child area never shows accuracy, percentages, scores or comparisons.
DR-58 Diagnostic distractors. Where a game offers answer choices, the distractors come from categories that reveal typical errors, so that a wrong choice carries information: neighbors of the target (n - 1, n + 1: counting errors), the unit number for teen targets (DR-45: inversion), visually similar numerals (6 and 9; 1 and 7; 2 and 5 as a mirror pair), and for littleOnes simply other numbers of the current range. Sections 11 to 13 decide per game and step which categories apply and how many choices are shown.

4.13.3 Typical errors and the app's didactic response #

Typical error Likely cause Response (implemented through the hint ladder and content, Sections 10 to 13)
Count is off by one One-to-one correspondence: an object skipped or counted twice Scaffold hint: count along with each object highlighted once (DR-41); counted objects can be marked (Section 11)
Counts again from 1 when asked "Wie viele?" Cardinality not yet understood Resolution always ends with "Das sind n." (DR-40); hint: "Die letzte Zahl sagt, wie viele es sind."
Counts 7 structured beads one by one Kraft der Fünf not yet used; not an error Correct answers are accepted; the triad and hints model "fünf und noch zwei" (DR-10, DR-11)
Hears "vierzehn", picks 4 Inversion of the teen word Ten-first sentence (DR-43), ten-first image (DR-42)
Says or expects "einszehn", "zweizehn" Irregular 11 and 12 "Zehn und eins sind elf" (DR-43); extra exposure through the engine's focus on weak items (Section 9)
Picks the side with bigger or more spread-out objects as "more" Judgement by size or area, not number Mehr oder weniger uses sets whose visual size misleads at higher steps (Section 12); hint pairs objects one-to-one
Writes 3, 7, 9 mirrored Normal at this age Nachspuren shows stroke direction arrows and start dots; mirrored traces are not accepted as correct, and the modeled solution shows the stroke order (Section 12)
Continues the sequence wrongly when counting backwards Backward order is harder than forward Was fehlt? introduces backward counting only at the higher step (Section 11); hint uses the neighbor reference in forward direction first ("Vor der Sieben kommt ...")
Guesses quickly without looking Impulsive tapping, especially at 2 to 3 years Answer controls enable only after the instruction (Section 10); repeated shown outcomes lower the difficulty (Section 9)

4.14 Didactic acceptance checks #

Section 25 turns these into test cases. A build fails didactic acceptance if any check fails.

Check Verifies
Every task type resolves with numeral, lowercase written word, five-structured quantity and spoken word DR-01, DR-03, DR-05
Every written split label and spoken decomposition for 11 to 19 has the form "10 + (n − 10)" / "zehn und (n − 10)" DR-09, DR-43
No prompt shows or speaks the representation being asked for DR-02
Every structured quantity 6 to 20 renders five-first and ten-first with correct coding, including with a simulated red-green color vision deficiency (shapes distinguishable) DR-07, DR-08, DR-14
No red/blue coding appears on scattered, moving, dice or decomposition-part representations except as DR-32 allows for each part viewed as its own set DR-13, DR-16, DR-17, DR-32
The field size matches the range stage in all tasks; Entdecken free explore shows the full field DR-19
Display fields render 2 x 10 (or one row for smaller ranges) in every size class; interactive fields render wide (rows of ten) at task-area widths of at least 714 pt and compact (rows of five, 1.5× gap between the tens) below, and no interactive place has a touch target below 60 x 60 pt DR-20
The bead chain wraps only at five-group boundaries and is never drawn in descending order; the number line never wraps, shows at least 5 stones at compact width and at least 10 at regular width, and never labels or names the bank DR-21, DR-22, DR-36, DR-38
No flash task exceeds the subitizing limits for the level DR-26
No task has 0 as the required answer; decompose never targets 1 and, in V1, never targets 11 to 20 DR-30, DR-31, Section 4.10.2
All 20 number word recordings match the spelling and pronunciation notes of Section 4.10, including "zwei", "sechzehn", "siebzehn" and "zwanzig" with [ɪç] 4.10
No child-facing line contains "falsch", "nein", "leider", "schade" or "Fehler" DR-52
No child-facing screen shows accuracy, percentages or range/level changes DR-49, DR-50, DR-57

5. Technology Stack and Architecture #

This section is the only place in the document where tool, language and SDK versions are stated. Every other section refers to "the toolchain in Section 5" instead of naming a version.

5.1 Versions and Toolchain #

Item Version line Notes
Xcode 27.x Current stable Xcode at the time of writing. Ships the iOS 27 SDK and a Swift 6.x toolchain.
Swift toolchain 6.x The toolchain bundled with Xcode 27.x. No separately installed toolchains.
Swift language mode Swift 6 Strict concurrency checking is therefore complete and errors, not warnings (see 5.6).
Base SDK iOS 27 SDK (bundled with Xcode 27.x) Always build with the SDK of the installed Xcode.
Deployment target iOS 17.0 and iPadOS 17.0 Set once in Config/Base.xcconfig (IPHONEOS_DEPLOYMENT_TARGET = 17.0) and in Package.swift (.iOS(.v17)).
Swift package tools version 6.0 floor // swift-tools-version: 6.0 in Packages/ZahlenketteKit/Package.swift. Raising it is allowed when a needed manifest feature requires it; record the change in DECISIONS.md.
Unit test framework Swift Testing (import Testing), bundled with the toolchain Used for all unit and integration tests.
UI test framework XCTest / XCUITest, bundled with Xcode Used only for UI tests and performance metrics.
Formatter swift-format, bundled with the Swift 6.x toolchain Invoked as xcrun swift-format. Configuration in Section 6.13.
Third-party packages None See 5.1.1.

These version lines are a known-good floor, not a lockfile. At build time, install the current stable release of each dependency (npm install <pkg>@latest, or your ecosystem's equivalent), confirm the major line still matches, and let the lockfile record the exact resolved versions.

5.1.1 How the version rule applies to this project #

  • The project has zero third-party packages. There is no Package.resolved with external pins, and none may ever be added (see 5.2.2 and Section 29).
  • The rule above therefore applies to Xcode, the Swift toolchain and the iOS SDK. At the start of work and before each release build:
    1. Install the current stable Xcode of the 27.x line (or, if a newer major line is current, stay on 27.x until a DECISIONS.md entry approves the move after a full test pass).
    2. Confirm xcrun swift --version reports a Swift 6.x toolchain and that the project still compiles in Swift 6 language mode with zero warnings.
    3. Record the exact Xcode version and build number that produced the build in the repository file .xcode-version (single line, e.g. the output of xcodebuild -version | head -1), and add a DECISIONS.md entry whenever it changes. This file is the project's equivalent of a lockfile.
  • Verification step (once, milestone M0): confirm Xcode 27.x still supports an iOS 17.0 deployment target (archive a Release build and check there is no deployment-target warning), and confirm Swift Testing tests run on an iOS 17.x simulator runtime. If Swift Testing cannot run on the iOS 17.x runtime, run unit tests on the current simulator runtime only, keep iOS 17.x coverage through the XCUITest suite, and record this in DECISIONS.md.
  • Install the iOS 17.x simulator runtime in addition to the current one (Xcode > Settings > Components). It is required for the minimum-OS test pass and for an "iPhone SE (3rd generation)" simulator with the 375 x 667 pt screen, which may no longer be offered with the newest runtime.

5.2 Frameworks #

5.2.1 Apple frameworks used #

Framework Used for Owning module(s) Allowed importers
Swift standard library, Foundation Value types, Date, UUID, Calendar, JSON coding, Bundle all all
Darwin (mach_continuous_time, mach_timebase_info) Sleep-inclusive monotonic time for the trusted day clock (Section 15.12). This is a required-reason API (system boot time, reason 35F9.1, declared in the privacy manifest, Section 23.6) ZKCore Only the file ZKCore/Time/TrustedDayClock.swift; allowlisted by name in the policy check (Section 23.10)
SwiftUI All UI, app lifecycle (App, Scene, scenePhase) App, ZKDesignSystem, ZKGameKit, games, ZKParentArea Not ZKCore, ZKLearningEngine, ZKContent
Observation @Observable models and services App, ZKAudio, ZKStore, ZKPersistence, ZKRewards, ZKGameKit, games, ZKParentArea Not ZKCore, ZKLearningEngine
SwiftData Persistence of all child data (Section 7) ZKPersistence ZKPersistence only; the App target imports it only in App/Composition/ to build the container
CoreData Only for the Debug-only CloudKit schema initialization and schema compatibility test (Section 7.2.2, 7.15) ZKPersistence ZKPersistence only, inside #if DEBUG or test targets
SpriteKit (via SwiftUI SpriteView) Physics of Schüttelbox; moving stage of Wie viele? step 3 GameSchuettelbox, GameWieViele Those two game targets only
PencilKit PKCanvasView ink capture for Nachspuren (finger and Apple Pencil) GameNachspuren GameNachspuren only
Core Motion CMMotionManager accelerometer for shake detection in Schüttelbox GameSchuettelbox GameSchuettelbox (shake detection with an injected manager) and the App target (creates the single app-wide CMMotionManager and reads isAccelerometerAvailable at bootstrap for DeviceSettingsStore.hasAccelerometer, Section 7.9.3)
AVFoundation AVAudioEngine + AVAudioPlayerNode (voice, SFX), AVAudioPlayer (music loop), AVSpeechSynthesizer (de-DE TTS fallback), AVAudioSession ZKAudio ZKAudio only
StoreKit (StoreKit 2 API only) Products, Transaction.currentEntitlements, Transaction.updates, AppStore.sync(), entitlement resolution and cache ZKStore ZKStore; ZKParentArea additionally imports StoreKit for its SwiftUI integration only: the environment actions @Environment(\.purchase) (PurchaseAction) and @Environment(\.displayStoreKitMessage), and the view modifiers manageSubscriptionsSheet, refundRequestSheet, offerCodeRedemption. Purchase results and StoreKit messages are passed into EntitlementService (Section 17.5.4), so ZKStore never needs UIKit
Swift Charts Parent-area time and progress charts ZKParentArea ZKParentArea only
Core Haptics / UIKit feedback via SwiftUI .sensoryFeedback Haptic feedback (Section 19.10 owns the haptics map) ZKDesignSystem, ZKGameKit Views only, through the design-system modifiers that read the \.hapticsEnabled environment value (Section 6.7). The App target imports Core Haptics once at bootstrap to read CHHapticEngine.capabilitiesForHardware().supportsHaptics for DeviceSettingsStore.supportsHaptics (Section 7.9.3)
UIKit Only where SwiftUI has no equivalent: UIApplication notifications (memory warning, significant time change), UIImage(named:) existence checks in content validation, UIViewRepresentable for PKCanvasView, UIWindowScene.sizeRestrictions App, ZKContent (image existence check only), GameNachspuren As listed. ZKStore and ZKParentArea never import UIKit
os (Logger, OSSignposter, OSAllocatedUnfairLock) Logging (Section 6.9), performance signposts (Section 24), the lock of the adjustable clock (5.4.2) all all
Testing (Swift Testing), XCTest Tests test targets test targets only

Decision: no other Apple framework is linked in V1 without a DECISIONS.md entry. Section 24 decides whether MetricKit is used; if it is, it is imported only by the App target. Network (URLSession, Network) is forbidden everywhere; StoreKit's own networking is system-managed and is the only network activity of the app (Section 23 owns the enforcement check).

5.2.2 Third-party dependencies #

None. No Swift packages, CocoaPods, Carthage frameworks, binary frameworks, analytics, crash reporters, ad SDKs or font files from third parties. The system font SF Pro Rounded is used via Font.system(.body, design: .rounded) and is not bundled. A pull request that adds a dependency to Package.swift other than local targets, or adds a package reference to Zahlenkette.xcodeproj, is rejected (Section 6.12 checklist).

5.3 Module Architecture #

5.3.1 Package decision #

Decision: one local Swift package, Packages/ZahlenketteKit, with one library product per target, plus the Xcode app project Zahlenkette.xcodeproj that contains the app target (the composition root), the app-level test target and the UI test target. Rationale: one Package.swift makes the dependency graph visible and enforced in one file; separate library products let each game compile and be tested in isolation; the app project stays thin.

5.3.2 Module graph #

+---------------------------------------------------------------------+
|           Zahlenkette (app target) - composition root               |
|           links every library product listed below                  |
+---------------------------------------------------------------------+
        |                     |                         |
        v                     v                         v
+----------------+    +----------------+        +----------------+
|  Game* (x12)   |    |  ZKParentArea  |        |   ZKRewards    |
+----------------+    +----------------+        +----------------+
        |               |  |  |  |  |                  |
        v               |  |  |  |  +--> ZKLearningEngine
+----------------+      |  |  |  +-----> ZKStore        |
|   ZKGameKit    |      |  |  +--------> ZKDesignSystem |
+----------------+      |  +-----------> ZKPersistence <+
   |     |     |        +--------------> ZKCore
   |     |     +--> ZKDesignSystem
   |     +--------> ZKAudio ----> ZKContent
   +--------------> ZKContent

Every library target also depends on ZKCore (arrows omitted).
ZKLearningEngine, ZKContent, ZKPersistence and ZKStore depend on nothing except ZKCore; ZKCore depends on nothing.

Read the diagram together with the table below; the table is authoritative.

Target Responsibility Allowed direct dependencies (Package.swift) May import (transitive closure) Forbidden imports Extra Apple frameworks
ZKCore Pure domain vocabulary shared by everything: Skill, Level, RangeStage, GameID, GameTier, DifficultyStep, TaskOutcome, TaskBucket, SelectionReason, GameMode, RoundContext, RoundPlan, PlannedTask, NumberFact, AudioID, the persisted enums DifficultyCap, RangeOverride, StarReason, StickerSource, SessionEndReason (raw values in Section 7.3.3), DecorationSize, MasteryBand, LocalDate, AppClock, SystemClock, FixedClock, AdjustableClock, TrustedDayClock (Section 15.12), SplitMix64 (seedable RNG), Brand, ZKLog, A11yID (accessibility identifier constants, Section 6.10). Each of these types is declared exactly once, here; no other module re-declares a type with the same name none Foundation, os, Darwin (TrustedDayClock.swift only) SwiftUI, SwiftData, UIKit, Observation, any ZK target none
ZKLearningEngine Adaptive learning engine (Section 9): mastery math, task selection, difficulty stepping, range widening, Abenteuer composition. Pure, deterministic, synchronous ZKCore ZKCore SwiftUI, SwiftData, UIKit, Observation, every other ZK target none
ZKContent Loads, decodes and validates bundled JSON content (Section 8); exposes an immutable ContentCatalog ZKCore ZKCore SwiftUI, SwiftData, every other ZK target UIKit (image existence check only)
ZKPersistence SwiftData schema, PersistenceController, repositories, maintenance (integrity, dedup, pruning), export/reset/delete, DeviceSettingsStore (Section 7) ZKCore ZKCore SwiftUI views (SwiftUI may be imported only for @Entry environment keys), ZKLearningEngine, ZKContent, games SwiftData, CoreData (Debug/test only)
ZKAudio AudioService protocol and its production class LiveAudioService: voice, SFX, music channels, TTS fallback, interruptions (Section 20). Receives device toggles as AudioSettings through update(settings:) from the app, never from DeviceSettingsStore ZKCore, ZKContent ZKCore, ZKContent SwiftData, ZKPersistence, ZKStore, games AVFoundation
ZKDesignSystem Tokens (color, type, spacing), components BeadView, BeadChainView, ZwanzigerfeldView, DiceView, FingerPatternView, NumeralTile, BigButton, LockBadge, plus speaker, home and progress-dot components (Section 19); declares the environment values \.appClock and \.hapticsEnabled (set by the app from DeviceSettingsStore) ZKCore ZKCore SwiftData, ZKPersistence, ZKStore, ZKContent, ZKAudio, games SwiftUI
ZKGameKit Shared game framework (Section 10): GameModule, GameContext, round lifecycle, hint ladder, feedback, input handling, GameEventSink, GameRegistry ZKCore, ZKContent, ZKAudio, ZKDesignSystem ZKCore, ZKContent, ZKAudio, ZKDesignSystem SwiftData, ZKPersistence, ZKStore, ZKRewards, ZKLearningEngine, games SwiftUI
ZKRewards Reward logic (Section 14): star awards, decoration purchase and placement rules, Zahlenfreunde befriending effects, sticker awards, milestones. It never reads content or calls the engine: the app passes resolved inputs (decoration price and friend requirement from ContentCatalog, friend-eligible numbers computed by the engine), Section 14.12 ZKCore, ZKPersistence ZKCore, ZKPersistence SwiftUI views, ZKStore, ZKLearningEngine, ZKContent, games none
ZKStore StoreKit 2 EntitlementService and entitlement cache (Section 17) ZKCore ZKCore SwiftData, ZKPersistence, UIKit, games StoreKit
ZKParentArea Parental gate and all parent screens S-17 to S-25 (Sections 16, 17 UI); the StoreKit purchase and message-display actions of SwiftUI ZKCore, ZKPersistence, ZKStore, ZKDesignSystem, ZKLearningEngine those five ZKGameKit, ZKAudio, ZKRewards, games, UIKit, CoreMotion, CoreHaptics, MessageUI SwiftUI, Swift Charts, StoreKit (SwiftUI environment actions and view modifiers only, 5.2.1)
GameEntdecken, GameWieViele, GameHoerHin, GameWasFehlt, GameBlitzblick, GameMehrOderWeniger, GameNachspuren, GameSchuettelbox, GameFroschsprung, GameZahlenmonster, GameMemory, GamePunktZuPunkt One mini-game each (Sections 11-13): task generator, round model, views. Games read device toggles only through GameSettingsSnapshot (Section 10.3.4) ZKGameKit ZKGameKit, ZKCore, ZKContent, ZKAudio, ZKDesignSystem ZKPersistence, ZKStore, ZKRewards, ZKLearningEngine, ZKParentArea, any other game target, SwiftData SpriteKit (GameWieViele, GameSchuettelbox), PencilKit (GameNachspuren), CoreMotion (GameSchuettelbox, using the injected app-wide CMMotionManager)
Zahlenkette (app target in Zahlenkette.xcodeproj) Composition root: builds AppEnvironment, registers games, bootstraps, routes, lifecycle, session management (Section 15); hosts child-mode shell screens (S-01 to S-16, S-26, S-27), the Debug developer menu and the test hooks (5.9) every library product everything third-party code SwiftUI, SwiftData (composition only), UIKit (notifications, window scene), CoreMotion (shared manager and capability check), CoreHaptics (capability check only)

Decision: child-mode shell screens (profile picker, child home, game picker, Abenteuer flow, garden, shop, friends gallery, sticker album, ask-a-parent, time's up, break nudge, befriending celebration) live in the app target under App/ChildMode/, not in a separate package target. They compose ZKGameKit, ZKRewards, ZKPersistence and ZKDesignSystem, which only the composition root is allowed to combine.

Decision: types that cross the engine-to-game boundary are declared in ZKCore so that neither ZKLearningEngine nor ZKGameKit depends on the other. The boundary contract is RoundPlan (what one round will contain) and PlannedTask (one task in it). Section 9 specifies how they are produced (9.3.1, 9.9); Section 10 specifies how they are consumed (10.4.2). The declaration below is the only one in the code base; ZKGameKit and ZKLearningEngine use these types unchanged and declare no type with the same name:

// ZKCore/Planning/
public enum GameMode: String, Sendable, Hashable, Codable {
    case standard       // every planned round
    case freeExplore    // Entdecken free explore; never produced by the engine (Section 11)
}

public enum RoundContext: Sendable, Hashable, Codable {
    case single                         // a round started from the game picker
    case abenteuer(roundIndex: Int)     // 0...2 inside the daily Abenteuer
}

public enum SelectionReason: String, Sendable, Hashable, Codable {
    case focus, review, maintenance, stretchNumber, stretchStep, fallback
    /// Mapping onto the three-valued TaskBucket used by the framework and persistence (Section 9.3.1).
    public var bucket: TaskBucket { get }
}

public struct PlannedTask: Sendable, Hashable, Codable, Identifiable {
    public let id: UUID                 // derived deterministically from the round id (Section 9.3.1)
    public let skill: Skill             // primary skill credited by this task
    public let number: Int              // 1...20, the number the task is about
    public let bucket: TaskBucket       // .focus, .review, .stretch; always reason.bucket
    public let step: DifficultyStep     // the round step, or round step + 1 for .stretchStep
    public let reason: SelectionReason
}

public struct RoundPlan: Sendable, Hashable, Codable, Identifiable {
    public let id: UUID                 // the round ID; also the ledger sourceID of the round bonus
    public let gameID: GameID
    public let step: DifficultyStep
    public let level: Level
    public let range: RangeStage
    public let tasks: [PlannedTask]     // count: Section 9.9.2 (per level, or the step override of the game)
    public let abenteuerID: UUID?       // non-nil exactly when context is .abenteuer
    public let seed: UInt64             // task-generation seed for the framework (Section 10.4.2)
    public let isReentry: Bool          // planned in re-entry mode (Section 9.15)
    public let mode: GameMode           // .standard for every plan produced by the engine
    public let context: RoundContext
}

Everywhere the framework or the app needs "the round ID", it uses RoundPlan.id. A new field is added to this declaration (never to a copy elsewhere) and requires a DECISIONS.md entry. TaskBucket is the only bucket type; no other bucket enum exists.

5.3.3 Enforcing the graph #

  1. Package.swift (5.3.4) declares exactly the "allowed direct dependencies" above. Nothing else. scripts/check-imports.sh verifies this by reading swift package dump-package and failing on any target dependency not in the table and on any remote package.
  2. Xcode can sometimes resolve an import of a package module that is not a declared dependency because all package modules share one build products directory. Therefore scripts/check-imports.sh also greps every Sources/<Target>/**/*.swift file and every App/**/*.swift file for import lines and fails if a target imports a module outside its "may import" column or a framework outside its "extra Apple frameworks" column. It runs locally before every commit (scripts/lint.sh, Section 6.13) and in CI (Section 25.20). Forbidden symbols (network, notifications, tracking, @Attribute(.unique) and the other rules of Section 23.10) are checked by scripts/policy-check.swift; together these two scripts are the only dependency and policy checks (5.13).
  3. @_exported import is forbidden.
  4. The access level package is used for API shared between targets inside ZahlenketteKit that the app target does not need; public is used only for API the app target or another target needs across the package boundary.

5.3.4 Package manifest #

// swift-tools-version: 6.0
import PackageDescription

let gameTargets = [
    "GameEntdecken", "GameWieViele", "GameHoerHin", "GameWasFehlt",
    "GameBlitzblick", "GameMehrOderWeniger", "GameNachspuren", "GameSchuettelbox",
    "GameFroschsprung", "GameZahlenmonster", "GameMemory", "GamePunktZuPunkt",
]

let coreTargets: [(name: String, deps: [String], resources: Bool)] = [
    ("ZKCore", [], false),
    ("ZKLearningEngine", ["ZKCore"], false),
    ("ZKContent", ["ZKCore"], false),
    ("ZKPersistence", ["ZKCore"], false),
    ("ZKAudio", ["ZKCore", "ZKContent"], false),
    ("ZKDesignSystem", ["ZKCore"], true),        // Media.xcassets with color tokens and UI glyphs
    ("ZKGameKit", ["ZKCore", "ZKContent", "ZKAudio", "ZKDesignSystem"], false),
    ("ZKRewards", ["ZKCore", "ZKPersistence"], false),
    ("ZKStore", ["ZKCore"], false),
    ("ZKParentArea", ["ZKCore", "ZKPersistence", "ZKStore", "ZKDesignSystem", "ZKLearningEngine"], false),
]

var targets: [Target] = []
for t in coreTargets {
    targets.append(.target(
        name: t.name,
        dependencies: t.deps.map { .target(name: $0) },
        resources: t.resources ? [.process("Resources")] : nil
    ))
    targets.append(.testTarget(
        name: "\(t.name)Tests",
        dependencies: [.target(name: t.name), "TestSupport"],
        resources: [.copy("Fixtures")]
    ))
}
for g in gameTargets {
    targets.append(.target(name: g, dependencies: ["ZKGameKit"]))
    targets.append(.testTarget(name: "\(g)Tests", dependencies: [.target(name: g), "TestSupport"]))
}
// Shared test doubles and fixtures (FakeAudioService, FakeEntitlementService, FakeTransactionSource,
// in-memory persistence helpers, content fixtures). FixedClock and SplitMix64 are ZKCore types, not test types.
targets.append(.target(
    name: "TestSupport",
    dependencies: ["ZKCore", "ZKContent", "ZKPersistence", "ZKAudio", "ZKStore", "ZKGameKit"],
    path: "Tests/TestSupport"
))

let package = Package(
    name: "ZahlenketteKit",
    defaultLocalization: "de",
    platforms: [.iOS(.v17)],
    products: (coreTargets.map(\.name) + gameTargets).map { .library(name: $0, targets: [$0]) },
    targets: targets
)

Notes:

  • TestSupport is a regular target at Packages/ZahlenketteKit/Tests/TestSupport that only test targets and the app's test targets depend on; the app target itself must never link it (checked by scripts/check-imports.sh). It is the only test-support target; its name is TestSupport everywhere.
  • Test targets without fixtures omit the resources: argument; the executor removes .copy("Fixtures") for targets that have no Fixtures folder, because SwiftPM rejects a missing resource path.
  • The manifest uses Swift 6 language mode by virtue of tools version 6.0. No unsafeFlags and no upcoming-feature flags are set in V1 (see 5.6.1).

5.4 Composition Root #

5.4.1 AppEnvironment #

AppEnvironment is the single dependency container. It is created once in ZahlenketteApp, owns every service, and is never a global singleton (no static let shared).

// App/Composition/AppEnvironment.swift
import CoreMotion
import SwiftUI
import ZKCore; import ZKContent; import ZKPersistence; import ZKAudio
import ZKStore; import ZKLearningEngine; import ZKRewards; import ZKGameKit

@MainActor
@Observable
final class AppEnvironment {
    let clock: any AppClock
    let continuousTime: any ContinuousTimeSource    // Section 15.12, input of TrustedDayClock
    let persistence: PersistenceController          // Section 7.10
    let profiles: any ProfileRepository             // Section 7.10
    let progress: any ProgressRepository            // Section 7.10
    let rewardStore: any RewardRepository           // Section 7.10
    let usage: any UsageRepository                  // Section 7.10
    let dataManagement: any DataManagementService   // Section 7.17-7.18
    let maintenance: any MaintenanceService         // Section 7.13, 7.19
    let deviceSettings: DeviceSettingsStore         // Section 7.9
    let content: any ContentStore                   // Section 8
    let audio: any AudioService                     // Section 20.5
    let entitlements: any EntitlementService        // Section 17.5.4
    let engine: any LearningEngine                  // Section 9
    let rewards: any RewardService                  // Section 14.12
    let games: GameRegistry                         // 5.4.3
    let roundResults: RoundResultCoordinator        // 5.4.5
    let lifecycle: LifecycleCoordinator             // 5.7
    let router: AppRouter                           // Section 18
    let motion: CMMotionManager                     // the single app-wide instance (5.6.1), injected into Schüttelbox

    /// Production wiring. Never throws: every failure has a fallback (5.4.4).
    /// `entitlements` is created and started earlier, in ZahlenketteApp.init (5.4.4 step 8), and passed in.
    static func live(entitlements: any EntitlementService,
                     arguments: LaunchArguments = .current) async -> AppEnvironment
    /// In-memory store, fixed clock (2026-10-01 09:00 local), fake audio, premium on. For #Preview only.
    static func preview(profiles: PreviewProfileSet = .twoChildren) -> AppEnvironment
}

Injection into SwiftUI: the root view receives AppEnvironment via .environment(appEnvironment); in addition every service is placed into EnvironmentValues under its own key so that package views (which cannot see AppEnvironment) read services with @Environment(\.audioService) and so on (Section 6.6).

Environment key Type Declared in module Default when not injected
\.appClock any AppClock ZKDesignSystem (ZKCore has no SwiftUI) SystemClock()
\.hapticsEnabled Bool ZKDesignSystem true; the app sets it from DeviceSettingsStore.hapticsEnabled at the root view
\.contentStore any ContentStore ZKGameKit EmptyContentStore() (no games, logs a fault)
\.audioService any AudioService ZKAudio SilentAudioService() (no-op, logs a fault)
\.entitlementService any EntitlementService ZKStore LockedEntitlementService() (free tier only, no StoreKit calls)
\.profileRepository, \.progressRepository, \.rewardRepository, \.usageRepository, \.dataManagementService repository protocols ZKPersistence Unconfigured... stubs that throw PersistenceError.notConfigured
\.deviceSettings DeviceSettingsStore ZKPersistence an instance backed by a throwaway UserDefaults(suiteName:). Read only by the app target and ZKParentArea (Section 7.9.3)
\.learningEngine any LearningEngine ZKParentArea (only view module that needs it) the default engine value

Defaults exist only to satisfy the compiler and previews; production code always injects. Every default logs Logger.fault once when used in a Debug build.

Implementation note for Swift 6: an @Entry default value must be constructible from a nonisolated context. The service protocols are @MainActor class-bound protocols declared by their owning sections (5.4.2); their stub implementations declare a nonisolated init(). Verification step: if the compiler rejects a stub default, declare that entry as an optional ((any AudioService)? = nil) and expose a non-optional accessor in the declaring module that falls back to the stub; record the variant used in DECISIONS.md.

5.4.2 Service protocols #

AppClock and GameRegistry are owned here and declared in full below. Every other service protocol is declared exactly once, by its owning section, and is only referenced here with the members the composition root, LifecycleCoordinator and RoundResultCoordinator call. If a member listed here is missing from the owner's declaration, the owner's section is completed; no second declaration is written.

// ZKCore — owned by this section
public protocol AppClock: Sendable {
    /// Current wall-clock instant.
    var now: Date { get }
    /// Gregorian calendar with timeZone = .autoupdatingCurrent. Local dates are always
    /// Gregorian even if the device uses another calendar system.
    var calendar: Calendar { get }
}

public extension AppClock {
    var today: LocalDate { LocalDate(date: now, calendar: calendar) }
    func localDate(for date: Date) -> LocalDate { LocalDate(date: date, calendar: calendar) }
    func startOfDay(for date: Date) -> Date { calendar.startOfDay(for: date) }
    func startOfDay(_ day: LocalDate) -> Date      // 00:00 local of that day
}

/// A calendar day in the device's current time zone, serialized as "yyyy-MM-dd".
public struct LocalDate: Hashable, Comparable, Codable, Sendable, CustomStringConvertible {
    public let year: Int, month: Int, day: Int
    public init(date: Date, calendar: Calendar)
    public init?(string: String)                     // strict "yyyy-MM-dd", nil otherwise
    public var string: String { get }                // zero-padded "2026-10-01"
    public func adding(days: Int, calendar: Calendar) -> LocalDate
}

public struct SystemClock: AppClock { public init() }
public struct FixedClock: AppClock { public init(now: Date, timeZone: TimeZone) }   // unit tests, previews

/// Developer-menu time travel (5.10.2) and `-uiTestFixedNow` (5.9). The offset is applied on top of
/// the system clock, so time keeps advancing from the shifted instant.
/// Compiled in every configuration (package targets cannot see the app's compilation conditions,
/// 5.8.1); only the app target creates it, and only under `#if DEBUG || UITEST_HOOKS`.
public final class AdjustableClock: AppClock {        // Sendable: only immutable lets + a lock
    public init(base: any AppClock = SystemClock())
    public var offset: TimeInterval { get }           // read under OSAllocatedUnfairLock
    public func setOffset(_ seconds: TimeInterval)
    public func reset()
}

TrustedDayClock and ContinuousTimeSource (ZKCore, Time/TrustedDayClock.swift) are specified in Section 15.12. The production ContinuousTimeSource reads mach_continuous_time() converted with mach_timebase_info (sleep-inclusive, restarts at 0 after a reboot). TrustedDayClock combines that value with AppClock.now and a persisted anchor (UserDefaults key zk.clock.trustedAnchor, Section 7.9.2, read and written by the app target through DeviceSettingsStore and passed in as a value) to derive the trusted local date used for the daily time limit.

// ZKContent — excerpt only; Section 8.15 holds the declaration
public protocol ContentStore: Sendable {
    var catalog: ContentCatalog { get }                        // immutable, Sendable
    var validationReport: ContentValidationReport { get }      // shown in the developer menu
    func isGameAvailable(_ id: GameID) -> Bool                 // false if its config is invalid
}
public enum ContentLoader {
    public static func load(baseURL: URL, localeIdentifier: String) async -> any ContentStore
}

Audio (ZKAudio, declared in Section 20.5). The protocol is named AudioService; its production class is LiveAudioService, the test double in TestSupport is FakeAudioService, and the environment default is SilentAudioService (5.4.1). Its event type is AudioEvent. AudioID is a ZKCore type (5.3.2), not a ZKAudio type. Members used by the composition root and the coordinators:

Member (Section 20.5) Used by
activate() async, deactivate() async LifecycleCoordinator (5.7.2), first child-mode screen (Section 20.3.2)
update(settings: AudioSettings) Composition root, whenever a DeviceSettingsStore toggle changes
preload(_:) async, releaseRoundCache() Game container (Section 10)
stopVoice(), setMusic(_:), playSFX(_:) LifecycleCoordinator, child shell screens
makeEventStream() -> AsyncStream<AudioEvent> LifecycleCoordinator (5.7.4)
purgeCaches(keeping: Set<AudioID>) LifecycleCoordinator on memory warnings (5.7.5)

Entitlements (ZKStore, declared in Section 17.5.4, which is authoritative). EntitlementService is @MainActor, class-bound and Observable; its members include state, isPremium, start(), refresh(force:) async, loadOffer(), restore(), the pending StoreKit message count, and func isUnlocked(_ tier: GameTier) -> Bool, which the child router uses for lock badges. Purchases and StoreKit message display are performed in ZKParentArea through SwiftUI's @Environment(\.purchase) and @Environment(\.displayStoreKitMessage); their results are passed into the service (Section 17.5.4), so ZKStore imports no UIKit. The production class is StoreKitEntitlementService; the environment default is LockedEntitlementService (5.4.1). start() is called in ZahlenketteApp.init (5.4.4 step 8).

// ZKLearningEngine — excerpt only; Section 9 holds the declaration (pure, Sendable, synchronous)
public protocol LearningEngine: Sendable {
    func planRound(_ request: RoundRequest, rng: inout some RandomNumberGenerator) -> RoundPlan
    func planAbenteuer(_ request: AbenteuerRequest, rng: inout some RandomNumberGenerator) -> AbenteuerPlan
    func apply(_ outcome: TaskOutcomeInput, to state: LearnerSnapshot, at now: Date) -> LearnerUpdate
}

Rewards (ZKRewards, declared in Section 14.12). The protocol is named RewardService; the production class is LiveRewardService and the test double is FakeRewardService. Every member throws RewardError (typed throws), inserts into the shared ModelContext and never saves (the caller's unit of work saves, 5.4.5). It uses the ZKCore StarReason and DecorationSize and declares neither. Members used by RoundResultCoordinator and the child shell: recordTaskCompleted(profileID:taskAttemptID:), recordRoundCompleted(profileID:roundID:), recordAbenteuerCompleted(profileID:abenteuerID:localDate:), befriend(profileID:numbers:) (numbers already computed by the engine's friend-eligibility function, Section 9), awardPictureSticker(profileID:stickerID:) for pictureCompleted events (Section 10.13.1), purchase(profileID:item:) with a DecorationOffer built by the app from ContentCatalog, place(profileID:ownedDecorationID:at:) and returnToInventory(profileID:ownedDecorationID:). Each returns the RewardEvents (or placement and purchase results) used for celebrations (Section 14.12).

// ZKPersistence — excerpt only; the single declaration of every repository is in Section 7.10.3
@MainActor public protocol ProgressRepository: AnyObject, Sendable {
    func masteryRecords(profileID: UUID) throws(PersistenceError) -> [MasteryRecord]
    func record(_ write: TaskResultWrite) throws(PersistenceError)     // no save; unit of work
    func learningState(profileID: UUID) throws(PersistenceError) -> LearningState
    func gameProgress(profileID: UUID, gameID: GameID) throws(PersistenceError) -> GameProgress
}

5.4.3 GameRegistry #

GameRegistry is declared in ZKGameKit and populated only by the app target (App/Composition/GameRegistry+All.swift). It maps each GameID to a factory producing that game's GameModule (the protocol itself is specified in Section 10). It returns any GameModule; the game container wraps the module in AnyGameModule for type erasure (Section 10.2). No other registry type exists.

// ZKGameKit
@MainActor
public final class GameRegistry {
    public typealias Factory = @MainActor () -> any GameModule

    public init()
    /// Registers a factory. Registering the same GameID twice is a programmer error:
    /// assertionFailure in Debug, the second registration is ignored in Release.
    public func register(_ id: GameID, factory: @escaping Factory)
    /// Returns a fresh module instance, or nil if the game is not registered or hidden.
    public func makeModule(_ id: GameID) -> (any GameModule)?
    /// Registered and not hidden, in GameID.allCases order.
    public var availableGameIDs: [GameID] { get }
    /// Hides games whose content failed validation (Section 8). Called once at bootstrap.
    public func hide(_ ids: Set<GameID>)
    /// Debug check: every GameID.allCases is registered and module.id == key.
    public func verifyCompleteness() -> [GameRegistryIssue]
}
// App/Composition/GameRegistry+All.swift
import GameEntdecken; import GameWieViele; import GameHoerHin; import GameWasFehlt
import GameBlitzblick; import GameMehrOderWeniger; import GameNachspuren; import GameSchuettelbox
import GameFroschsprung; import GameZahlenmonster; import GameMemory; import GamePunktZuPunkt

extension GameRegistry {
    static func allGames() -> GameRegistry {
        let r = GameRegistry()
        r.register(.entdecken)      { EntdeckenGame() }
        r.register(.wieViele)       { WieVieleGame() }
        r.register(.hoerHin)        { HoerHinGame() }
        r.register(.wasFehlt)       { WasFehltGame() }
        r.register(.blitzblick)     { BlitzblickGame() }
        r.register(.mehrWeniger)    { MehrOderWenigerGame() }
        r.register(.nachspuren)     { NachspurenGame() }
        r.register(.schuettelbox)   { SchuettelboxGame() }
        r.register(.froschsprung)   { FroschsprungGame() }
        r.register(.zahlenmonster)  { ZahlenmonsterGame() }
        r.register(.memory)         { MemoryGame() }
        r.register(.punktZuPunkt)   { PunktZuPunktGame() }
        return r
    }
}

Decision: GameID case names are lowerCamelCase with explicit raw values; the raw value is the stable identifier used in content file names, JSON, audio IDs and persistence:

// ZKCore
public enum GameID: String, CaseIterable, Codable, Sendable {
    case entdecken     = "entdecken"
    case wieViele      = "wie_viele"
    case hoerHin       = "hoer_hin"
    case wasFehlt      = "was_fehlt"
    case blitzblick    = "blitzblick"
    case mehrWeniger   = "mehr_weniger"
    case nachspuren    = "nachspuren"
    case schuettelbox  = "schuettelbox"
    case froschsprung  = "froschsprung"
    case zahlenmonster = "zahlenmonster"
    case memory        = "memory"
    case punktZuPunkt  = "punkt_zu_punkt"
}

Tier (free/premium) is not decided by the registry; it comes from content (games/<gameId>.json, Section 8) and is checked against EntitlementService by the child shell (Section 17).

5.4.4 Bootstrap sequence #

Performed by AppEnvironment.live() while S-01 (splash) is visible. Steps run in this order; none may crash.

# Step Blocking before first child screen Failure fallback
1 Parse LaunchArguments (only under DEBUG || UITEST_HOOKS, 5.9; Release ignores all arguments) yes ignore unknown arguments
2 Create clock (SystemClock; AdjustableClock in Debug for the developer menu, or when -uiTestFixedNow is given) and the ContinuousTimeSource of the trusted day clock (Section 15.12) yes none needed
3 Register UserDefaults defaults, ensure zk.device.installationID exists, write the device capability flags supportsHaptics and hasAccelerometer into DeviceSettingsStore (Section 7.9), and run the crash-loop check on zk.launch.inProgress / zk.launch.crashCount (Section 7.12.3) yes none needed
4 Open the SwiftData store (Section 7.12), with one retry after 200 ms yes store recovery procedure (Section 7.12.3), then in-memory store
5 Launch integrity checks (Section 7.19) yes log and continue
6 Load and validate content off the main actor (ContentLoader.load); Release runs every check except image existence (Section 8.15) yes invalid items skipped, invalid games hidden (Section 8)
7 Build GameRegistry.allGames(), hide games reported invalid by content yes Debug: verifyCompleteness() issues shown in developer menu
8 EntitlementService.start(). Executed before this sequence, in ZahlenketteApp.init, before the first scene appears (Section 17.6.1): it reads the entitlement cache synchronously and registers the Transaction.updates listener (critical path, at most 5 ms). Only the first refresh runs here, asynchronously no (refresh) cached or free status
9 Audio: create LiveAudioService and apply update(settings:). The audio session is activated with activate() only when the first child-mode screen appears, after its first frame (Sections 20.3.2 and 24.6.1); S-02 and S-03 play no audio except the "Ton testen" exception of Section 20.3.2 no voice falls back to TTS or visuals (Section 20)
10 Route: no profiles -> S-02; exactly 1 -> S-05; 2 or more -> S-04 (Section 18) yes S-02
11 Deferred maintenance 2 s after the first child or parent screen appears: star-balance recompute, pruning (max once per local day), dedup (Section 7.13, 7.19, 7.20). Skipped for this launch in crash-loop safe mode (Section 7.12.3) no log and continue

The time budget for steps 1-10 is part of the cold-launch budget in Section 24.

5.4.5 RoundResultCoordinator (unit of work per task) #

Games never touch persistence, rewards or the engine. They emit events through GameEventSink (declared in ZKGameKit, event payloads specified in Section 10.13). The app's RoundResultCoordinator (App/Composition/RoundResultCoordinator.swift; the only implementation of GameEventSink, no other router type) performs, for each taskCompleted event, exactly this sequence on the main actor:

  1. Map the task event to the engine input (EngineBridge, app target) using the current LearnerSnapshot built from repositories.
  2. Call engine.apply(...) (pure) to get new mastery values, Leitner state, difficulty-stepping counters, controller and range-state updates.
  3. progress.record(TaskResultWrite): inserts the TaskAttempt, upserts MasteryRecords (primary and secondary skill), updates GameNumberStat, GameProgress (including gameStateJSON when the event carries TaskResult.updatedGameState, Section 10.13.2) and LearningState (Section 7).
  4. rewards.recordTaskCompleted(profileID:taskAttemptID:): appends the taskSolved ledger entry and updates the cached balance.
  5. Compute newly eligible Zahlenfreunde with the engine's eligibility function (Section 9) and pass the numbers to rewards.befriend(profileID:numbers:).
  6. persistence.save() once. This single save makes steps 3-5 atomic (the "Task completed" save point, Section 7.12.2).
  7. Publish the returned RewardEvents to the child UI (star fly-in, befriending celebration trigger).

On round completion (roundCompleted) the coordinator performs rewards.recordRoundCompleted(profileID:roundID:), rewards.awardPictureSticker(profileID:stickerID:) when the round emitted a pictureCompleted event (Punkt zu Punkt, Section 10.13.1; the payload carries no profile ID, the coordinator adds it), session counters, milestone checks, then one save() (the "Round completed" save point, Section 7.12.2). On any thrown error in a unit of work, including an out-of-space error, the coordinator calls persistence.rollback(), logs the error, and the child flow continues without the reward animation for that task (never a child-visible error; the parent-area notice rules are in Section 7.12.2).

5.5 Supported Devices, Orientations and Windowing #

5.5.1 Devices #

Property Value
Device families iPhone and iPad (TARGETED_DEVICE_FAMILY = 1,2), one universal binary
Minimum OS iOS 17.0 / iPadOS 17.0 (5.1)
Supported hardware Every iPhone and iPad that can run iOS/iPadOS 17.0 or later
Smallest layout reference iPhone SE (2nd and 3rd generation), 4.7-inch, 375 x 667 pt portrait, 667 x 375 pt landscape
Largest layout reference iPad Pro 13-inch, landscape
Apple Pencil Optional enhancement in Nachspuren on iPad (any Pencil model supported by PencilKit); never required
Required device capabilities none beyond the Xcode default (arm64). Decision: the accelerometer is not declared as required because Schüttelbox has a button fallback
Mac (Designed for iPad) and Apple Vision Pro availability Disabled. Decision: in App Store Connect, uncheck "Make this app available" for Mac with Apple silicon and for Apple Vision Pro; these platforms are out of scope in V1

5.5.2 Orientations #

Device Supported orientations Info.plist key
iPhone Portrait, Landscape Left, Landscape Right (not Portrait Upside Down) UISupportedInterfaceOrientations
iPad Portrait, Portrait Upside Down, Landscape Left, Landscape Right UISupportedInterfaceOrientations~ipad

No screen locks orientation. Layout rules per orientation are owned by Section 19; per-screen behavior by Section 18. A rotation during a task keeps the task state (the round model is independent of layout) and re-lays out within one animation; in-flight drags are cancelled and the dragged item returns to its origin without counting as an answer (Section 10).

5.5.3 iPad windowing #

Decision: every layout must work at any window width of 375 pt or more and any window height of 375 pt or more, on both iPhone and iPad. The app does not set UIRequiresFullScreen.

Explanation:

  • Recent iPadOS versions let users freely resize app windows, and Apple has deprecated UIRequiresFullScreen; relying on it to force a full-screen layout is not future-proof and would degrade the app on current iPads.
  • The iPhone SE portrait width (375 pt) and landscape height (375 pt) are already the design minimums (Section 19), so a resizable iPad window that is at least that large reuses the same compact layouts.
  • Decision: as a best-effort hint, at scene connection the app sets windowScene.sizeRestrictions?.minimumSize = CGSize(width: 375, height: 375) on iPad. Verification step: on the current iPadOS, confirm whether the system honors the minimum size. Whether or not it does, the child area must degrade gracefully below 375 pt: the root child container measures its size and, when width or height is below 375 pt, renders the 375 pt compact layout scaled down uniformly (scaleEffect, aspect-fit, centered, cream background filling the rest). Touch targets are then physically smaller than 60 pt; this is accepted only for such undersized windows. The parent area uses standard adaptive SwiftUI layout and scrolls.
  • Decision: one window only. UIApplicationSupportsMultipleScenes = false. Two simultaneous windows could run two child sessions at once, which the session and time-limit model (Section 15) does not support.
  • Size-class changes (e.g., window resize from regular to compact) are handled exactly like rotation (5.5.2).

5.6 Concurrency Model #

5.6.1 Rules #

Rule Detail
Language mode Swift 6 language mode in every target, complete strict concurrency. The build must have zero concurrency warnings and errors.
Upcoming features None enabled in V1 (no default-actor-isolation setting, no NonisolatedNonsendingByDefault). Isolation is written explicitly. Changing this requires a DECISIONS.md entry.
UI and game state Every SwiftUI view model, round controller, coordinator, router and service facade is @MainActor. Views are implicitly main-actor.
Engine ZKLearningEngine consists of Sendable value types and a Sendable engine struct. All functions are synchronous, nonisolated, deterministic (injected RandomNumberGenerator and explicit now). It is called directly from the main actor because one call takes less than 5 ms on iPhone SE (Section 9, Section 24).
ZKCore All types are Sendable value types except AdjustableClock, which is a final class with an OSAllocatedUnfairLock.
Content Decoding and validation run in a nonisolated async function on the cooperative pool (Task.detached(priority: .userInitiated) inside ContentLoader.load). The result (ContentCatalog) is an immutable Sendable struct handed to the main actor.
Persistence SwiftData is used only on the main actor through ModelContainer.mainContext. No ModelActor, no background contexts in V1: data volumes are small (Section 7.16). @Model instances never cross an isolation boundary; when an identity must cross, pass the logical id: UUID. ModelContainer is Sendable and may be passed if ever needed.
Export encoding Export DTOs are built on the main actor from models, are Sendable Codable structs, and are JSON-encoded off the main actor (Section 7.17).
Audio AudioService (production class LiveAudioService) is a @MainActor facade. The confinement of AVFoundation objects, the decoding actor and the synchronous tap-to-sound path are specified in Section 20.4. AVFoundation completion handlers and notifications hop to the main actor with Task { @MainActor in ... } before touching facade state.
Motion The app target creates exactly one CMMotionManager for the process (Apple recommends a single instance) and injects it into GameSchuettelbox through the game environment. It delivers accelerometer samples to a private serial OperationQueue (maxConcurrentOperationCount = 1). The shake detector is a final class confined to that queue (documented @unchecked Sendable confinement wrapper), not a main-actor type. Only discrete shake events are forwarded to the main actor through @MainActor callbacks. Updates run only while a Schüttelbox task is on screen (Section 12.5).
SpriteKit Scenes are created, updated and read on the main thread; they communicate with the round model through @MainActor closures. SpriteView is paused (isPaused = true) whenever the scene is not visible or the scene phase is not .active.
StoreKit The Transaction.updates listener is one long-lived Task started in EntitlementService.start(), which ZahlenketteApp.init calls before the first scene appears (5.4.4 step 8), so Ask-to-Buy approvals, renewals and refunds delivered during launch are never missed. The task is stored in the service and never cancelled during the app's lifetime.
Forbidden DispatchQueue.main.async in new code (use main-actor isolation), nonisolated(unsafe), @preconcurrency import of Apple frameworks unless the compiler requires it (then with a DECISIONS.md entry), Thread.sleep, busy waits, @unchecked Sendable except documented confinement wrappers each listed in DECISIONS.md.

5.6.2 Task lifetimes #

  • Views start async work with .task { } so SwiftUI cancels it when the view disappears.
  • Long-lived tasks exist only in services: the Transaction.updates listener (ZKStore), the audio event stream consumer (App, LifecycleCoordinator), the session activity ticker (App, Section 15; 1 Hz Task.sleep(for:) loop active only in child mode).
  • Every Task that outlives a view is stored in a property and cancelled in the owner's teardown path, except the StoreKit listener.

5.7 App Lifecycle #

LifecycleCoordinator (app target, @MainActor) is the single subscriber to lifecycle signals. It fans out to the services listed below. Behavior visible to the child (pause overlay, session end, time-limit screens) is owned by Sections 10 and 15; this section owns the plumbing.

5.7.1 Signals observed #

Signal Source
Scene phase .active / .inactive / .background @Environment(\.scenePhase) in the root view, forwarded via .onChange(of:)
Audio interruption began/ended, output route lost AudioService.makeEventStream() delivering AudioEvents (ZKAudio observes AVAudioSession notifications)
Memory warning UIApplication.didReceiveMemoryWarningNotification
Significant time change (midnight, time-zone change, carrier time update) UIApplication.significantTimeChangeNotification and NSNotification.Name.NSSystemTimeZoneDidChange
Entitlement change EntitlementService.state observation

5.7.2 Scene phase handling #

Transition Actions (in order)
active -> inactive (Control Center, Notification Center, app switcher, incoming-call banner, system alert) 1. Tell the active round controller to pause (Section 10.12 pause semantics). 2. Pause the session activity ticker (active time stops counting, Section 15). 3. audio.stopVoice() (the line is replayed on resume); music and SFX follow Section 20.3. 4. Pause SpriteKit scenes and stop motion updates.
inactive -> background 1. Flush in-memory active seconds into DailyUsage and SessionRecord (the "Activity flush" save point, Section 7.12.2). 2. persistence.save(). 3. Record backgroundedAt = clock.now in memory. 4. await audio.deactivate() (Section 20.3.2). 5. Update zk.clock.highWaterMark and the trusted-clock anchor zk.clock.trustedAnchor (Section 7.9.2, Section 15.12). The app declares no background modes and does no work in the background.
background -> inactive -> active 1. Compute away = clock.now - backgroundedAt. 2. If away >= 300 s: end the session with reason background (Section 15.7.1) and revoke any parental-gate grant (Section 16). 3. If the trusted local date or clock.today differs from the value at backgrounding: run the day-rollover procedure (5.7.3). 4. await audio.activate() when a child-mode screen is showing. 5. entitlements.refresh(force: false) asynchronously. 6. Resume the round controller: automatically if the absence lasted less than 3 s, otherwise through the pause overlay where the child taps to continue (Section 10.12.3). 7. V1.1 only: run the sync dedup pass (Section 7.20).
inactive -> active without backgrounding Steps 4 and 6 above; the session continues.
Termination No work is relied upon. Data is already persisted at the save points in Section 7.12.2; at most the current, unfinished task is lost (Section 24).

5.7.3 Day rollover #

Triggered by a significant-time-change notification, by returning to .active on a different local date, or by the 1 Hz session ticker detecting a date change while in child mode. Two dates are distinguished:

  • The trusted local date (TrustedDayClock, Section 15.12) alone drives time-limit state: DailyUsage attribution (close the current accumulation and start a new row for the new trusted date, Section 7.5.9) and the lock "until local midnight" (a locked child is unlocked only when the trusted date changes, Section 15.8). Setting the device clock forward therefore does not unlock a locked child.
  • The AppClock date (clock.today) drives the daily Abenteuer availability (Section 15.10), the engine's due dates (Section 9) and scheduling of pruning for the new date (Section 7.13).

A running round is not interrupted by a rollover.

5.7.4 Audio interruptions #

AudioService translates AVAudioSession.interruptionNotification and routeChangeNotification into AudioEvents. LifecycleCoordinator reacts:

  • interruptionBegan: pause the round exactly as for active -> inactive.
  • interruptionEnded: await audio.activate(); if the interruption lasted less than 3 s the round resumes automatically, otherwise it stays in the pause overlay until the child taps continue (Section 10.12.3).
  • outputRouteLost (headphones unplugged, Bluetooth disconnected): the current voice line stops and the speaker button pulses once; no pause overlay is shown and the round continues (Section 20.12).

Section 20 owns the audio session category and all audio details.

5.7.5 Memory warnings #

On a memory warning, in this order: audio.purgeCaches(keeping:) with the current round's audio IDs; which buffers are dropped and which resident tier is kept is owned by Section 20.10.1; SpriteKit texture atlases of scenes not on screen are released; SwiftUI image caches are not managed manually. The content catalog (under 1 MB decoded) is kept. SwiftData's context is not reset. The event is logged at notice level with the current screen ID. No user-visible effect.

5.8 Build Configurations and Settings Files #

5.8.1 Configurations #

Configuration Used for Swift flags Optimization Developer menu Launch arguments
Debug Local development, simulator, unit and UI tests DEBUG compilation condition (app target and package targets; SwiftPM defines DEBUG for Debug builds) -Onone compiled in (5.10) honored (5.9)
Profile Performance tests on devices (Performance.xctestplan, Section 25.2.2 and Section 24.3.2) UITEST_HOOKS (app target only) Same as Release: -O, whole module, same asset compilation compiled out honored (5.9)
Release TestFlight (internal and external) and App Store none -O, whole module compiled out (#if DEBUG) ignored

Decision: exactly these three configurations. There is no "Staging" and no "Internal" configuration. Profile exists only so that performance tests can seed data with the test hooks while measuring optimized code; it is never archived for distribution (the release workflow archives Release only, Section 25.20). All TestFlight builds are Release builds without the developer menu; anything that must be testable in TestFlight must be testable in Release (e.g., StoreKit sandbox purchases, Section 17).

Decision: UITEST_HOOKS is set only on the app target (Config/Profile.xcconfig). Local package targets built by Xcode do not receive the app target's SWIFT_ACTIVE_COMPILATION_CONDITIONS, and in the Profile configuration they are built without DEBUG. Therefore package code never branches on UITEST_HOOKS or on test hooks at all: every test hook (launch-argument parsing, DebugSeeder, fake service selection) lives in the app target under #if DEBUG || UITEST_HOOKS, and packages expose only ordinary injectable types (for example AdjustableClock, FakeEntitlementService).

Decision: Debug and Release use the same bundle identifier (default de.zahlenkette.app, Section 1), so StoreKit product IDs (<bundleID>.premium.monthly, <bundleID>.premium.yearly) are identical in both.

5.8.2 xcconfig files #

File Contents
Config/Base.xcconfig PRODUCT_BUNDLE_IDENTIFIER = de.zahlenkette.app, IPHONEOS_DEPLOYMENT_TARGET = 17.0, TARGETED_DEVICE_FAMILY = 1,2, SWIFT_VERSION = 6.0, SWIFT_STRICT_CONCURRENCY = complete, MARKETING_VERSION, CURRENT_PROJECT_VERSION, DEVELOPMENT_TEAM (Section 1), INFOPLIST_FILE = App/Info.plist, GENERATE_INFOPLIST_FILE = NO, ENABLE_USER_SCRIPT_SANDBOXING = YES, #include "Brand.xcconfig"
Config/Brand.xcconfig Exactly five settings, the only place these values are defined (Section 20.19 owns the name mechanism; Section 1.2 D-16): APP_DISPLAY_NAME = Zahlenkette, PRIVACY_POLICY_URL = https:/$()/zahlenkette.de/datenschutz, IMPRINT_URL = https:/$()/zahlenkette.de/impressum, SUPPORT_URL = https:/$()/zahlenkette.de/hilfe, SUPPORT_EMAIL = hilfe@zahlenkette.de. The $() escape is required because // starts a comment in .xcconfig files. The domain follows Section 1.2 D-13 to D-15; if the fallback domain is registered, these four values change in the same commit
Config/Debug.xcconfig #include "Base.xcconfig", SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG, SWIFT_OPTIMIZATION_LEVEL = -Onone
Config/Release.xcconfig #include "Base.xcconfig", SWIFT_OPTIMIZATION_LEVEL = -O, SWIFT_COMPILATION_MODE = wholemodule, VALIDATE_PRODUCT = YES
Config/Profile.xcconfig #include "Release.xcconfig", SWIFT_ACTIVE_COMPILATION_CONDITIONS = UITEST_HOOKS, VALIDATE_PRODUCT = NO

Warnings-as-errors rule (used by every CI workflow, Section 25.20, and by scripts/ci-local.sh): CI builds pass SWIFT_TREAT_WARNINGS_AS_ERRORS=YES on the xcodebuild command line; the xcconfig files do not set it, so local Debug builds show warnings without failing. Verification step: introduce a deliberate warning in one package target once and confirm the CI build fails; if command-line settings do not reach package targets, add a scripts/check-warnings.sh step that fails when xcodebuild output contains warning: lines from project sources.

5.8.3 StoreKit configuration file #

App/StoreKit/Products.storekit is a locally authored StoreKit configuration file (not synced from App Store Connect) containing the subscription group "Zahlenkette Premium" with the two auto-renewable products de.zahlenkette.app.premium.monthly (1 month) and de.zahlenkette.app.premium.yearly (1 year), their German display names, prices (3,99 EUR and 29,99 EUR), the 7-day free-trial introductory offer on both, and Family Sharing enabled on both (all values owned by Section 17). It is selected in the shared scheme Zahlenkette under Run > Options > StoreKit Configuration for Debug runs, and used by SKTestSession in tests (Section 25). It is not part of the Release build. If the bundle ID changes (Section 1), the product IDs in this file must be updated in the same commit.

5.9 Launch Arguments (Debug and Profile only) #

This table is the single registry of launch arguments; Sections 10, 24, 25 and 29 refer to it and define no arguments of their own. App/Composition/LaunchArguments.swift parses them under #if DEBUG || UITEST_HOOKS (Debug and Profile, 5.8.1); in Release the parser is compiled out and every argument is ignored. All arguments share the -uiTest prefix.

Argument Effect
-uiTestSeed <name> Replaces the store with a freshly generated seeded store before the container opens (seed names and contents in Section 25.10.3; fresh = no store and all zk.* keys except zk.device.installationID removed). While any seed is given, the engine and task-generator RNG is SplitMix64 with a fixed seed, so runs are deterministic
-uiTestFixedNow <ISO-8601> AppClock is an AdjustableClock starting at that instant (time keeps advancing normally)
-uiTestDisableAnimations Sets animation durations to zero (UIView and SwiftUI transactions)
-uiTestTimings instant The game framework uses GameTimings.instant (Section 10): no waits between feedback, next prompt and celebrations
-uiTestForceReduceMotion The app's Reduce Motion provider reports true
-uiTestAudio stub | fail stub: FakeAudioService records requested audio IDs (exposed through the accessibility element debug.audioLog, Section 6.10) and plays nothing; voice lines complete instantly. fail: every playback fails (Section 24.14 test)
-uiTestEntitlement notSubscribed | subscribed | expired Replaces the entitlement service with FakeEntitlementService in that fixed state
-uiTestRevealAnswers Exposes the correct answer of the current task through the accessibility value of S07.root so journeys can play rounds deterministically
-uiTestForcePlan <gameId>:<step>:<n1,n2,...> The next round of that game uses exactly these numbers at that step (the engine is bypassed for that one plan); used by game-specific UI tests

There is no argument that bypasses or auto-solves the parental gate. UI tests solve the gate like a parent (Section 25.10.4).

5.10 Developer Menu (Debug only) #

5.10.1 Access #

The developer menu exists only in the Debug configuration (#if DEBUG around every file in App/Debug/); it is compiled out of Profile and Release, so no TestFlight or App Store build contains it. It is reachable only from inside the parent area, which itself requires the parental gate (Section 16): a single row "Entwicklermenü" at the bottom of the parent dashboard (S-18). There is no other entry point (no long press on a version label, no shake gesture, no hidden taps in child mode, no launch argument). A Release build contains neither the row nor the code; scripts/check-imports.sh also fails if any file outside #if DEBUG references DeveloperMenu.

5.10.2 Contents #

This is the single list of developer-menu panels; Section 29 refers to it.

Panel Function
Engine-Inspektor Per profile: the 20 x 8 mastery grid with raw score, attempts, boxIndex, intervalDays, nextDueAt, band; GameProgress per game (step, counters, gameStateJSON size); LearningState (range stage, probation and session counters, stretch share, comfort mode); last 50 TaskAttempts with their bucket; the next RoundPlan the engine would produce for a chosen game (dry run, nothing saved). Read-only
Inhalte prüfen Runs content validation (Section 8) on demand and shows ContentStore.validationReport: errors and warnings per file with JSON path, hidden games, missing audio IDs (with TTS fallback flag), missing string keys, missing image assets
Asset-Status Counts and lists: voice audio IDs total / recorded / resolved to TTS; SFX and music present / missing; illustration names total / final / placeholder; filter "nur fehlende". Prints the same numbers as scripts/check-assets.sh (Section 29.6.4)
Platzhalter markieren Toggle (default on): draws a small grey "P" badge on every placeholder illustration and plays voice placeholders with a lower TTS pitch so testers hear which lines are not final
Komponentengalerie The design-system component gallery (Section 27.3.2)
Uhr verschieben Shows clock.now, clock.today and the trusted local date; buttons "+1 Stunde", "+1 Tag", "+7 Tage", "+30 Tage", "Datum setzen" (date picker), "Zurücksetzen". Implemented via AdjustableClock.setOffset. After each change the lifecycle coordinator runs the day-rollover procedure (5.7.3)
Premium simulieren Picker: "Aus (echt)" (StoreKit), "Kostenlos", "Premium"; default "Aus (echt)". Implemented by swapping in FakeEntitlementService
Testdaten "Testdaten erzeugen" with the seeds of 5.9 (-uiTestSeed names, Section 25.10.3), "Testprofile löschen", "Wartung jetzt ausführen" (integrity + prune + dedup regardless of the once-per-day rule), store file size, row counts per entity, "Export-JSON anzeigen" (pretty-printed in-app)
Audio List of all audio IDs from content with play buttons and a "fehlt" marker; toggle "TTS erzwingen"
Layout Toggle to show touch-target outlines (60 pt minimum) and safe-area guides in child mode
Logs Explanation text only: logs are read with Console.app filtering the app's subsystem prefix (Section 6.9); the app does not collect logs itself

Developer-menu strings are German like the rest of the parent area but are not part of the translation workload (they live in Localizable.xcstrings marked "Do not translate").

5.11 Capabilities and Entitlements #

Capability V1 Notes
In-App Purchase enabled Added under Signing & Capabilities. Required for StoreKit 2 subscriptions (Section 17)
iCloud / CloudKit NOT enabled Enabled in V1.1 only; exact steps in Section 7.15. In V1.1 the capability is present but sync runs only while the parent has switched on "Mit iCloud synchronisieren" and premium is active (Section 7.15.2)
Push Notifications not enabled V1 sends no notifications of any kind. V1.1 CloudKit sync adds the entitlement only as part of Section 7.15 (silent pushes, never user-visible)
Background Modes none No background audio, fetch or processing in V1
Data Protection default Section 23 decides the file-protection class and whether the explicit entitlement is added
Game Center, Sign in with Apple, App Groups, Associated Domains, HealthKit, Siri not used

App/Zahlenkette.entitlements exists (Xcode creates it with the In-App Purchase capability) and contains no iCloud keys in V1.

5.12 Info.plist #

App/Info.plist is a checked-in file (GENERATE_INFOPLIST_FILE = NO) so every key is reviewable.

Key Value Reason
CFBundleDisplayName $(APP_DISPLAY_NAME) Single source of the app name (Config/Brand.xcconfig)
PRIVACY_POLICY_URL, IMPRINT_URL, SUPPORT_URL, SUPPORT_EMAIL $(PRIVACY_POLICY_URL), $(IMPRINT_URL), $(SUPPORT_URL), $(SUPPORT_EMAIL) Values from Config/Brand.xcconfig (5.8.2); read in Swift only through Brand (Section 20.19.2)
CFBundleName $(PRODUCT_NAME) Internal
CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleShortVersionString / CFBundleVersion $(MARKETING_VERSION) / $(CURRENT_PROJECT_VERSION)
CFBundleDevelopmentRegion de German is the development language
CFBundleLocalizations [de] Decision: en is added only when English content ships (V1.2, Section 20); declaring it earlier would show partially English system UI
LSRequiresIPhoneOS YES
LSApplicationCategoryType public.app-category.education
UILaunchScreen dictionary with UIColorName = LaunchBackground (cream #FFF7EC, in App/Assets.xcassets) and no image Calm, instant launch; S-01 draws the logo
UISupportedInterfaceOrientations Portrait, LandscapeLeft, LandscapeRight 5.5.2
UISupportedInterfaceOrientations~ipad all four 5.5.2
UIApplicationSceneManifest UIApplicationSupportsMultipleScenes = NO 5.5.3
UIRequiresFullScreen absent 5.5.3
UIRequiredDeviceCapabilities [arm64] 5.5.1
UIFileSharingEnabled, LSSupportsOpeningDocumentsInPlace NO No Files-app exposure of the data store
ITSAppUsesNonExemptEncryption NO The app uses no encryption beyond what the OS provides
UIBackgroundModes absent 5.11
NSMicrophoneUsageDescription, NSCameraUsageDescription, NSPhotoLibrary*, NSUserTrackingUsageDescription, NSLocation* absent Not used. The child never speaks into the app
NSMotionUsageDescription absent (see decision below)

Decision: reading raw accelerometer data through CMMotionManager (startAccelerometerUpdates / startDeviceMotionUpdates) does not require a usage-description string and does not show a permission prompt; NSMotionUsageDescription is required for motion-activity and pedometer data (CMMotionActivityManager, CMPedometer), which the app does not use. Verification step (milestone that ships Schüttelbox): run Schüttelbox on a physical iPhone and iPad and confirm no permission prompt appears and no console error mentions a missing purpose string; upload a TestFlight build and confirm App Store Connect reports no missing purpose-string issue. If either check fails, add NSMotionUsageDescription with the value "Die Bewegungssensoren werden nur für das Spiel „Schüttelbox“ genutzt. Es werden keine Bewegungsdaten gespeichert." (no app name, so a rename needs no change) and record it in DECISIONS.md.

The privacy manifest App/PrivacyInfo.xcprivacy is specified in Section 23.

5.13 Repository Layout #

<repository root>/
├── Zahlenkette.xcodeproj                 # app project: targets Zahlenkette, ZahlenketteTests, ZahlenketteUITests
│   └── xcshareddata/xcschemes/Zahlenkette.xcscheme   # shared scheme (StoreKit config; test plans of Section 25.2.2, default Fast)
├── App/                                  # app target sources (composition root)
│   ├── ZahlenketteApp.swift              # @main, creates AppEnvironment, root view, scenePhase hook
│   ├── Composition/
│   │   ├── AppEnvironment.swift
│   │   ├── AppEnvironment+Live.swift
│   │   ├── AppEnvironment+Preview.swift
│   │   ├── LaunchArguments.swift
│   │   ├── GameRegistry+All.swift
│   │   ├── EngineBridge.swift            # SwiftData records <-> engine snapshots
│   │   ├── RoundResultCoordinator.swift  # GameEventSink implementation
│   │   └── LifecycleCoordinator.swift
│   ├── Routing/
│   │   └── AppRouter.swift               # child-mode router (Section 18)
│   ├── Session/                          # session manager, activity ticker, time limit (Section 15.14)
│   ├── ChildMode/                        # S-01..S-16, S-26, S-27 (one folder per screen)
│   │   ├── Splash/  ProfilePicker/  Home/  GamePicker/  GameHost/  RoundEnd/
│   │   ├── Abenteuer/  Garden/  DecorationShop/  Friends/  StickerAlbum/
│   │   ├── AskParent/  TimesUp/  BreakNudge/  Befriended/
│   │   └── Onboarding/                   # S-02, S-03
│   ├── Debug/                            # developer menu, all files wrapped in #if DEBUG
│   ├── TestHooks/                        # DebugSeeder and fake-service selection, wrapped in #if DEBUG || UITEST_HOOKS (5.9)
│   ├── Localization/
│   │   ├── Localizable.xcstrings         # app and parent UI strings (Section 20)
│   │   └── Content.xcstrings             # content lines referenced by JSON (Section 20)
│   ├── Assets.xcassets                   # AppIcon, LaunchBackground color
│   ├── StoreKit/
│   │   └── Products.storekit             # local StoreKit testing (5.8.3)
│   ├── Info.plist
│   ├── PrivacyInfo.xcprivacy             # the privacy manifest (Section 23.6); this is its only location
│   └── Zahlenkette.entitlements
├── Resources/                            # bundled into the app as folder references (paths preserved)
│   ├── Content/                          # Section 8
│   │   ├── manifest.json
│   │   ├── numbers.json
│   │   ├── prompts.json
│   │   ├── decorations.json
│   │   ├── stickers.json
│   │   ├── friends.json
│   │   ├── avatars.json
│   │   ├── games/                        # entdecken.json ... punkt_zu_punkt.json (12 files)
│   │   └── dotpictures/                  # <pictureId>.json (24 files in V1)
│   ├── Audio/
│   │   ├── de/                           # voice lines, <audioId>.m4a (e.g. num.7.m4a)
│   │   └── common/                       # sfx.<key>.m4a, music.<key>.m4a (locale-independent)
│   └── Illustrations.xcassets            # game art, friends, avatars, decorations, dot-picture reveals
├── Packages/
│   └── ZahlenketteKit/
│       ├── Package.swift                 # 5.3.4
│       ├── Sources/
│       │   ├── ZKCore/  ZKLearningEngine/  ZKContent/  ZKPersistence/  ZKAudio/
│       │   ├── ZKDesignSystem/           # contains Resources/Media.xcassets (color tokens, glyphs)
│       │   ├── ZKGameKit/  ZKRewards/  ZKStore/  ZKParentArea/
│       │   └── GameEntdecken/ GameWieViele/ GameHoerHin/ GameWasFehlt/ GameBlitzblick/
│       │       GameMehrOderWeniger/ GameNachspuren/ GameSchuettelbox/ GameFroschsprung/
│       │       GameZahlenmonster/ GameMemory/ GamePunktZuPunkt/
│       └── Tests/
│           ├── TestSupport/              # fakes and fixtures shared by all tests
│           └── <Target>Tests/            # one per library target, e.g. ZKLearningEngineTests/
├── Tests/
│   ├── ZahlenketteTests/                 # app-level integration tests (Swift Testing), incl. content validation of Resources/
│   ├── ZahlenketteUITests/               # XCUITest journeys (Section 25)
│   ├── ZahlenkettePerfTests/             # XCTest performance tests (Section 25.11), run in Profile
│   └── TestPlans/                        # Fast, UISmoke, Full, Performance .xctestplan (Section 25.2.2)
├── Config/
│   ├── Base.xcconfig
│   ├── Brand.xcconfig                    # APP_DISPLAY_NAME + the four URL/e-mail keys (5.8.2)
│   ├── Debug.xcconfig
│   ├── Release.xcconfig
│   └── Profile.xcconfig                  # Release + UITEST_HOOKS (5.8.1)
├── Marketing/
│   ├── de/                               # App Store texts with {{APP_NAME}} token (Section 22)
│   └── rendered/                         # output of render-marketing.sh, git-ignored (Section 6.11.3)
├── scripts/                              # the complete list of repository scripts
│   ├── format.sh                         # swift-format format in place (Section 6.13)
│   ├── lint.sh                           # swift-format lint --strict + check-imports.sh + policy-check.swift + greps (6.13)
│   ├── install-hooks.sh                  # git config core.hooksPath scripts/git-hooks (Section 6.11.3)
│   ├── git-hooks/pre-commit              # runs lint.sh
│   ├── check-imports.sh                  # module graph: Package.swift dependencies and import lines (5.3.3)
│   ├── policy-check.swift                # forbidden symbols and allowlists (Section 23.10)
│   ├── binary-check.sh                   # archived-binary checks (Section 23.10)
│   ├── validate-audio.sh                 # audio file completeness, Release build phase (Section 20.14)
│   ├── check-brand.sh                    # app name appears only via Brand (Section 20.19)
│   ├── check-assets.sh                   # launch asset completeness (Section 29.6.4)
│   ├── render-marketing.sh               # replaces {{APP_NAME}} from Brand.xcconfig into Marketing/rendered/
│   ├── coverage-check.swift              # coverage gate, with coverage-thresholds.json (Section 25.3)
│   ├── coverage-thresholds.json
│   └── ci-local.sh                       # runs the CI workflow steps locally (Section 25.20)
├── ci_scripts/                           # Xcode Cloud hooks: ci_post_clone.sh, ci_pre_xcodebuild.sh, ci_post_xcodebuild.sh (Section 25.20)
├── .swift-format                         # Section 6.13
├── .gitignore                            # Section 6.11
├── .xcode-version                        # exact Xcode version used (5.1.1)
├── DECISIONS.md                          # decision log (Section 6.12)
└── README.md                             # build, test, run instructions; links sections of this spec by number

Rules:

  • Resources/Content and Resources/Audio are added to the app target as folder references so their paths are preserved in the bundle (Bundle.main.url(forResource: "Content", withExtension: nil)). The composition root passes that URL to ContentLoader and the Audio URL to AudioService; packages never read Bundle.main paths themselves except Brand (Info.plist) and image lookup by name.
  • String Catalogs live in the app target. Package code looks strings up in Bundle.main (the default for String(localized:) and Text), so every key used in a package must exist in the app's catalog; the content-and-strings validation test (Section 8) enforces this.
  • Illustrations referenced by content are in Resources/Illustrations.xcassets (app bundle); design-system tokens and glyphs are in the ZKDesignSystem package resource catalog (Bundle.module).
  • No file of the repository contains credentials. There are none to store: StoreKit needs no keys in the app.
  • A new script is added to the scripts/ list above in the same commit; there are no other dependency or policy scripts (5.3.3). The optional scripts/check-warnings.sh of 5.8.2 is added only if that verification step requires it.

6. Conventions and Coding Standards #

These rules apply to every file in the repository. They exist so that code written in different sessions, by the founder or by AI coding agents, reads as if one person wrote it. Where a rule is checked automatically, the check is named. Tool versions are those in Section 5.

6.1 General Principles #

  1. The spec is the source of truth. Code follows the owning section of each concern (section table at the start of the document). When code must deviate or decide something the spec leaves open, the decision is recorded in DECISIONS.md (6.12) in the same commit.
  2. English for all code, identifiers, comments, commit messages, DECISIONS.md and logs. German only inside String Catalogs, content JSON string values that are IDs of German things (e.g. the avatar ID avatar.fuchs, content ID formats in Section 8.3), and test fixtures that assert German output.
  3. Identifiers are ASCII only. German words in identifiers are transliterated: ä -> ae, ö -> oe, ü -> ue, ß -> ss (HoerHinGame, SchuettelboxScene, groesse). Checked by the swift-format rule IdentifiersMustBeASCII.
  4. Source files are UTF-8, LF line endings, one trailing newline.
  5. No dead code, no commented-out code, and no unfinished-work marker comments (the conventional uppercase to-do and fix-me tags) in main. An unfinished item is either done or recorded as a DECISIONS.md entry or a task in the milestone plan (Section 27). scripts/lint.sh fails on those marker tags in App/, Packages/ and Tests/.

6.2 Naming #

6.2.1 Types and members #

Kind Convention Examples
Types (struct, class, enum, protocol, actor) UpperCamelCase nouns MasteryRecord, RoundPlan, ContentCatalog
Protocols describing a capability or service Noun naming the role, no Protocol suffix, no I prefix AudioService, ProgressRepository, AppClock
Production implementation of a service protocol Live + protocol name, or the technology name + protocol name where the technology is the point (repositories, StoreKit) LiveAudioService, StoreKitEntitlementService, SwiftDataProgressRepository
Test doubles (in TestSupport, or in the owning module when previews also need them) Fake (working in-memory implementation), Spy (records calls), Stub (fixed answers) + protocol name FakeAudioService, FakeEntitlementService, SpyGameEventSink, StubContentStore
Preview implementations Preview + protocol name PreviewEntitlementService
Default environment stubs Silent/Locked/Empty/Unconfigured + protocol name (Section 5.4.1) SilentAudioService, LockedEntitlementService
SwiftUI screen (one per screen ID in Section 18) <Name>Screen ProfilePickerScreen, ParentDashboardScreen
Screen model <Name>Model, @MainActor @Observable final class ProfilePickerModel
Reusable view component <Name>View BeadChainView, ZwanzigerfeldView
View modifier type <Name>Modifier, exposed through a View extension method in lowerCamelCase ChildTapTargetModifier / .childTapTarget()
Game module type <GameName>Game conforming to GameModule HoerHinGame, PunktZuPunktGame
Game round model and view <GameName>RoundModel, <GameName>RoundView MemoryRoundModel, MemoryRoundView
Game task generator <GameName>TaskGenerator (pure struct) WasFehltTaskGenerator
Game parameters (decoded from games/<gameId>.json) <GameName>Parameters BlitzblickParameters
Errors <Module-or-domain>Error enum PersistenceError, ContentError
One name per concept A type name exists in exactly one module. A protocol is never given a second name (no AudioServing next to AudioService, no RewardServicing next to RewardService); shared vocabulary types live in ZKCore (Section 5.3.2) AudioService, RewardService, RoundPlan
Functions and properties lowerCamelCase; verbs for side effects (recordAttempt), nouns for values (starBalance), is/has/can prefixes for Bool (isUnlocked, hasSeenCelebration)
Enum cases lowerCamelCase; raw values given explicitly where persisted or in JSON case wieViele = "wie_viele"
Constants static let inside an enum namespace or the owning type; lowerCamelCase MasteryParameters.firstTryGain
Generic parameters Descriptive UpperCamelCase (Element, Generator), single letters only for trivial cases
Acronyms Treated as words: Id is never used, ID is written in full caps as a suffix (profileID, gameID, audioID), URL, JSON stay caps (baseURL, exportJSON)

Game names in identifiers use the transliterated German game name without spaces: Entdecken, WieViele, HoerHin, WasFehlt, Blitzblick, MehrOderWeniger, Nachspuren, Schuettelbox, Froschsprung, Zahlenmonster, Memory, PunktZuPunkt. These match the target names in Section 5.3.2.

6.2.2 Files #

  • One primary type per file; the file is named after it (MasteryRecord.swift). Small private helper types used only by that type may live in the same file.
  • Extensions adding a conformance or a concern live in <Type>+<Concern>.swift (ChildProfile+Export.swift, GameRegistry+All.swift).
  • SwiftData models are nested in the versioned schema enum and live in SchemaV1+<Model>.swift (Section 7.14).
  • Protocol and its live implementation live in separate files (AudioService.swift, LiveAudioService.swift).
  • Folder names are UpperCamelCase (Repositories/, Views/); resource folders are lowercase where they mirror bundle paths (Resources/Content/games/).

6.2.3 Tests #

Item Convention Example
Unit-test target <Target>Tests ZKLearningEngineTests
Test file <TypeUnderTest>Tests.swift; for cross-type behavior <Behavior>Tests.swift MasteryUpdateTests.swift, RangeWideningTests.swift
Swift Testing suite @Suite("<Human readable>") struct <TypeUnderTest>Tests @Suite("Mastery update") struct MasteryUpdateTests
Swift Testing test @Test("<sentence stating the expected behavior>") func <behaviorInLowerCamelCase>() @Test("firstTry at step2 adds 0.15 x (1 - score)") func firstTryAtStep2AddsFullGain()
Parameterized vectors @Test(arguments:) over a named fixture array; the fixture type is <Name>Vector @Test(arguments: MasteryVector.all) func matchesVector(_ v: MasteryVector)
Tags Defined once in TestSupport/Tags.swift: .engine, .content, .persistence, .rewards, .store, .game, .slow
UI-test class <JourneyName>UITests (journeys from Section 3) FirstLaunchUITests, TimeLimitReachedUITests
UI-test method test_<scenario>_<expectedResult>() test_singleProfile_skipsPickerAndOpensHome()

Test rules: no sleep/Task.sleep to wait for results (use injected clocks, confirmation, or XCUITest waitForExistence(timeout:) with a maximum of 5 s); no dependence on the real date, time zone, locale or random seed (use FixedClock, a fixed TimeZone(identifier: "Europe/Berlin"), and SplitMix64 with a literal seed); #require instead of force unwraps; each test is independent and uses its own in-memory store.

6.3 Folder Layout per Target #

Every target under Packages/ZahlenketteKit/Sources/ follows the layout below. A folder is created only when it has files.

Target Folders
ZKCore Domain/ (Skill, Level, RangeStage, GameID, GameTier, DifficultyStep, TaskOutcome, TaskBucket, NumberFact, AudioID, DifficultyCap, RangeOverride, StarReason, StickerSource, SessionEndReason, DecorationSize, MasteryBand), Planning/ (RoundPlan, PlannedTask, SelectionReason, GameMode, RoundContext), Time/ (AppClock, SystemClock, FixedClock, AdjustableClock, LocalDate, TrustedDayClock, ContinuousTimeSource), Random/ (SplitMix64), Brand/ (Brand), Logging/ (ZKLog), Accessibility/ (A11yID)
ZKLearningEngine Engine/ (protocol, default implementation), Snapshots/ (input and output value types), Mastery/, Scheduling/ (Leitner), Selection/ (task mix), Difficulty/, Range/, Abenteuer/, Insights/ ("Gerade schwierig", Zahlenfreund eligibility), Parameters/ (all numeric constants from Section 9)
ZKContent Loading/, Schemas/ (Decodable DTOs, one per file type), Catalog/ (immutable domain catalog), Validation/ (rules and report)
ZKPersistence Schema/ (SchemaV1.swift, SchemaV1+<Model>.swift, MigrationPlan.swift), Controller/ (PersistenceController, store recovery), Repositories/ (one protocol file and one SwiftData implementation file each), Writes/ (value types passed into repositories), Maintenance/ (integrity, pruning, dedup), Export/ (export DTOs and builder), Settings/ (DeviceSettingsStore, UserDefaults keys), Environment/ (@Entry keys), Errors/
ZKAudio Service/, Playback/, Speech/ (TTS fallback), Session/ (AVAudioSession handling), Environment/
ZKDesignSystem Tokens/ (Color, Typography, Spacing, Motion, Haptics), Components/ (one file per component), Modifiers/, Layout/ (child layout metrics per size class), Environment/ (appClock, hapticsEnabled), Resources/Media.xcassets
ZKGameKit Module/ (GameModule, AnyGameModule, GameContext, GameMetadata), Registry/ (GameRegistry), Round/ (RoundController state machine), Hints/, Feedback/, Input/, Layout/ (common task layout slots), Events/ (GameEventSink and payloads), Environment/
ZKRewards Stars/, Garden/, Friends/, Stickers/, Milestones/, Service/
ZKStore Service/, Products/, Cache/, Environment/
ZKParentArea Gate/, Dashboard/, Progress/, ChildSettings/, DeviceSettings/, Premium/, DataManagement/, Help/, ProfileEditor/, Shared/
Every game target see template below

Game target template (example Hör hin):

Sources/GameHoerHin/
├── HoerHinGame.swift              # GameModule conformance: id, supported skills, makeRound
├── HoerHinParameters.swift        # Decodable parameters of games/hoer_hin.json + validate()
├── HoerHinTask.swift              # the concrete task value type (Sendable, Equatable)
├── HoerHinTaskGenerator.swift     # pure: (PlannedTask, step parameters, level, inout RNG) -> HoerHinTask
├── HoerHinRoundModel.swift        # @MainActor @Observable, drives ZKGameKit's round lifecycle
└── Views/
    ├── HoerHinRoundView.swift
    └── HoerHinAnswerGrid.swift
Tests/GameHoerHinTests/
├── HoerHinParametersTests.swift
├── HoerHinTaskGeneratorTests.swift   # every step x level x range, many seeds
└── HoerHinRoundModelTests.swift

SpriteKit scenes (Wie viele?, Schüttelbox) live in Scene/ inside the game target; PencilKit bridging (Nachspuren) lives in Canvas/; Core Motion shake detection (Schüttelbox, using the injected app-wide CMMotionManager, Section 5.6.1) lives in Motion/.

App target layout is given in Section 5.13: one folder per child-mode screen under App/ChildMode/, each containing <Name>Screen.swift and <Name>Model.swift.

6.4 Data Identifiers, JSON Style and Asset Naming #

6.4.1 JSON #

Rule Detail
Key style lowerCamelCase for every key in every JSON file (content files, export file, Products.storekit excepted because Xcode owns its format). Examples: schemaVersion, tasksPerRound, ttsFallbackKey, exportedAt
Enumerated string values The Swift raw value of the corresponding enum: lowerCamelCase for Skill, Level, TaskOutcome, TaskBucket (littleOnes, firstTry), and the defined snake_case raw values for GameID (wie_viele)
IDs Lowercase ASCII, digits, underscore, dot or hyphen only as defined by the owning section; stable forever once shipped; never shown to users; never localized. Content ID formats: Section 8. Audio ID format: Section 8 and Section 20 (num.7, prompt.hoer_hin.intro)
Numbers JSON numbers, never strings. Integers for counts and numbers 1-20; decimals with .
Dates ISO 8601 UTC with Z, no fractional seconds (2026-10-01T07:15:00Z); local calendar days as "yyyy-MM-dd" strings
Optional values Omitted when absent (not null), matching Swift's synthesized Codable behavior
Formatting of committed JSON 2-space indentation, keys in the order the schema tables list them, UTF-8, trailing newline. Content validation (Section 8) does not depend on key order
Decoding JSONDecoder with default key strategy (keys are already camelCase), dateDecodingStrategy = .iso8601. Unknown keys are ignored (forward compatibility) but reported as warnings in the Debug content validation report

6.4.2 Other identifier namespaces #

Namespace Format Owner Example
UserDefaults keys zk.<area>.<name> lowerCamelCase name Section 7.9 zk.device.musicEnabled
Logger subsystem <bundleID>.<module> 6.9 de.zahlenkette.app.engine
Localization keys 6.5 6.5 / Section 20 game.hoer_hin.title
Accessibility identifiers 6.10 6.10 S07.answer.7
Signpost names <Module>.<operation> Section 24 Engine.planRound

6.4.3 Asset naming #

This subsection is the single asset-naming scheme. Sections 8 (asset-name fields and validation), 19.8 and 21.13 use it unchanged.

Asset kind Location Name format Examples
Illustrations referenced by content Resources/Illustrations.xcassets, one folder per category for tidiness only; "Provides Namespace" is OFF on every folder, so the folder never becomes part of the name <category>_<slug>[_<state>] lowercase snake_case ASCII; the slug is the last segment of the content ID (avatar.fuchs -> fuchs); <state> is an illustration state or variant defined by the owning section avatar_fuchs, friend_07_idle, friend_07_silhouette, deco_tulpe, dotpic_stern_reveal, obj_apfel, sticker_milestone_first_round
Game art not referenced by content same catalog, category game game_<gameId>_<element> game_hoer_hin_icon, game_froschsprung_lilypad, game_zahlenmonster_body
Garden scene art same catalog, category garden garden_<element> garden_background_day, garden_friend_house
Design-system colors ZKDesignSystem/Resources/Media.xcassets token name in lowerCamelCase (Section 19 owns tokens) beadRed, backgroundCream
Design-system glyphs same catalog glyph_<name> glyph_speaker, glyph_home, glyph_lock, glyph_parent
App icon, launch color App/Assets.xcassets AppIcon, LaunchBackground
Audio files Resources/Audio/<locale>/ or Resources/Audio/common/ <audioId>.m4a exactly (Section 20) num.7.m4a, sfx.pop.m4a

Categories are fixed: avatar, theme, friend, deco, dotpic, sticker, obj (counting objects and foods), game, garden, bg, ui, glyph (design-system glyphs). Every asset name matches ^[a-z][a-z0-9_]*$ (the pattern Section 8 validates). Avatars and all other named things use their slug, never an ordinal (avatar_fuchs, not avatar_01). Number-bearing names are zero-padded to two digits where they sort in lists (friend_07, friend_17). Content JSON (assetName, revealAssetName, iconAssetName and similar fields, Section 8) stores the full flat asset name, and content validation checks that each asset exists.

6.5 Localization Key Naming #

Keys follow a dot-separated grammar. Each segment is lowercase snake_case ASCII.

<area>.<context>.<element>[.<qualifier>...]
Area Used for Examples
common Words reused across the parent UI common.button.done, common.button.cancel
onboarding S-02, S-03 onboarding.welcome.title
child Child-mode accessibility labels and the rare visible text (captions under avatars) child.home.a11y.play_button
game Game display names and step labels (content keys in Content.xcstrings, rule 1) game.hoer_hin.title, game.step.leicht
gate Parental gate gate.question.format, gate.cooldown.message
parent Parent-area screens parent.dashboard.title, parent.progress.band.almost, parent.settings.time_limit.option.off
paywall S-22 paywall.plan.yearly.title
data S-23 data.export.button, data.delete_all.confirm_word
help S-24 help.privacy_policy.link
error Parent-facing error and notice messages error.store_recovered.message
debug Developer menu (Debug only, marked "Do not translate") debug.time_travel.plus_one_day

Rules:

  1. Localizable.xcstrings holds app and parent UI keys. Content.xcstrings holds every key referenced by content JSON, including the game.* keys used by content (game titles game.<gameId>.title, step labels game.step.leicht, game.step.mittel, game.step.schwer), number words (number.<n>.word) and picture and sticker names. The content key patterns are owned by Section 8.3 and follow the same segment grammar; Sections 20.17 and 21 use them unchanged.
  2. Game display-name keys use the GameID raw value as context: game.<gameId>.title (in Content.xcstrings, rule 1).
  3. Keys describe meaning and location, never the German wording. A key is never reused for a different meaning; if the meaning changes, create a new key and delete the old one.
  4. Every key has a translator comment in the catalog describing where it appears and any placeholder meaning.
  5. Placeholders use positional format specifiers (%1$@, %2$lld). Counts use String Catalog plural variations, never manual if count == 1.
  6. The app name is never written into a string value; strings that mention it take a placeholder filled with Brand.appName (Section 20).
  7. Code never contains user-facing string literals. It uses String(localized: "parent.dashboard.title"), Text("parent.dashboard.title") (a LocalizedStringKey), or LocalizedStringResource. Keys used from package code must exist in the app's catalogs (Localizable.xcstrings, or Content.xcstrings for content keys) because package lookups resolve against Bundle.main (Section 5.13).
  8. Accessibility labels in the parent area are localized strings under the same area with an a11y segment (parent.progress.a11y.cell_format).

6.6 State Management and Dependency Access #

Rule Detail
Observation only Models use the Observation framework (@Observable). ObservableObject, @Published, @StateObject, @ObservedObject, @EnvironmentObject are forbidden (checked by scripts/lint.sh).
Model isolation Every @Observable class is @MainActor final class.
Ownership A view that creates a model holds it in @State private var model. A view that receives a model holds it as let model (read) or @Bindable var model (two-way bindings). Models are created by the router or the parent screen and passed down; a view never constructs a model with side effects in its initializer.
Model initializers Cheap and side-effect free. Loading happens in func load() async, called from the view's .task { await model.load() }.
Services Views read services from the environment: @Environment(\.audioService) private var audio. Models receive services through their initializer (constructor injection), which keeps them unit-testable without SwiftUI. Environment keys are declared per Section 5.4.1.
No singletons No static let shared, no global mutable variables. Brand is a namespace of pure functions reading Bundle.main, not a singleton.
Non-UI members Service references, tasks and caches inside @Observable models are marked @ObservationIgnored.
SwiftData in UI Views never hold @Model objects and never use @Query. Screen models call repositories and expose value-type view states (struct ProgressGridState). This avoids faults on deleted objects and keeps views previewable. Screen models refresh when PersistenceController.changeToken changes (Section 7.10).
Derived state Computed properties on the model, not duplicated stored properties.
Settings Device settings live in DeviceSettingsStore (Section 7.9), which only the app target and ZKParentArea read. Other modules receive values, never the store: ZKAudio gets AudioSettings through update(settings:) (Section 20.5), games get GameSettingsSnapshot (Section 10.3.4), and design-system haptics modifiers read the \.hapticsEnabled environment value that the app sets (Section 5.4.1). Every UserDefaults key is registered in Section 7.9.2. UserDefaults is accessed directly only in ZKPersistence, ZKStore/Cache/ (entitlement cache, Section 17), ZKParentArea/Gate/ (gate state, Section 16) and App/Composition/LaunchArguments.swift (compiled under DEBUG || UITEST_HOOKS) (checked by scripts/lint.sh).
Time and randomness Current time only from the injected AppClock (clock.now, clock.today); Date(), Date.now and Calendar.current are forbidden outside ZKCore/Time/ and tests with fixed values (checked by scripts/lint.sh); SwiftData models declare Date.distantPast as the default and receive real timestamps from the clock (Section 7.3.1). Randomness only from an injected RandomNumberGenerator; the composition root creates a SystemRandomNumberGenerator, or SplitMix64(seed:) with a fixed seed when -uiTestSeed is given (Section 5.9). The only allowed use of mach_continuous_time is ZKCore/Time/TrustedDayClock.swift (Section 15.12).

6.7 SwiftUI Composition Rules #

  1. Screen = <Name>Screen view + <Name>Model. Screens map 1:1 to screen IDs S-01 to S-27 (Section 18).
  2. A view's body stays under about 60 lines. Extract subviews as small structs (preferred, cheaper diffing) rather than long @ViewBuilder computed properties.
  3. No business logic in views: no repository or engine calls, no mastery math, no string assembly beyond choosing a key. Views call model intents (model.didTapTile(n)).
  4. Child-mode controls come only from ZKDesignSystem (BigButton, NumeralTile, speaker and home buttons, etc.). A raw Button with a text label is forbidden in child mode. Every child tappable applies .childTapTarget(), which enforces the 60 x 60 pt minimum (Section 19) and adds the accessibility identifier.
  5. Spacing, sizes, colors, fonts and animation durations come from design tokens (Section 19). Numeric literals for layout are allowed only inside ZKDesignSystem.
  6. Layout: prefer stacks, Grid, ViewThatFits, custom Layout types and containerRelativeFrame. GeometryReader is allowed only at a screen root to compute layout metrics once. No fixed-frame screens: every screen must work from 375 x 375 pt upwards (Section 5.5.3).
  7. AnyView is forbidden except at the game-host boundary where a GameModule returns its round view (Section 10). Use generics and @ViewBuilder elsewhere.
  8. Async work starts in .task, not in onAppear. Work that must survive a view is owned by a model or service.
  9. Animations use the Motion tokens, which honor Reduce Motion (Section 19). No withAnimation with literal durations outside ZKDesignSystem.
  10. Every screen and component has #Previews for iPhone SE portrait, iPhone SE landscape and an iPad size, using preview fixtures (AppEnvironment.preview(...) in the app target, PreviewFixtures in packages). Previews never touch the on-disk store or play real audio.
  11. Navigation follows Section 18 (child router, NavigationStack in the parent area). Views never present parent screens directly from child mode; they call the router, which inserts the parental gate.
  12. .sensoryFeedback is attached only through design-system modifiers that check the \.hapticsEnabled environment value (set by the app from the haptics device setting, Section 5.4.1); the haptics map is Section 19.10.

6.8 Error Handling #

6.8.1 Error types #

Each module that can fail defines one error enum. Public APIs use typed throws (throws(PersistenceError)); internal helpers may use untyped throws and map at the boundary.

Module Error type Representative cases
ZKContent ContentError fileMissing(path), decodingFailed(path, codingPath, reason), schemaVersionUnsupported(path, found), validationFailed([ContentIssue])
ZKPersistence PersistenceError notConfigured, storeOpenFailed(reason), saveFailed(reason), fetchFailed(reason), profileNotFound(UUID), profileLimitReached, invalidValue(field, reason), exportFailed(reason)
ZKAudio AudioError engineStartFailed(reason), fileMissing(AudioID), decodeFailed(AudioID), sessionActivationFailed(reason)
ZKStore StoreError productsUnavailable, purchaseFailed(reason), verificationFailed, userCancelled, pending, syncFailed(reason)
ZKRewards RewardError insufficientStars(balance, price), unknownCatalogItem(id), itemRequirementNotMet(id), slotOccupied(x, y), persistence(PersistenceError)
ZKGameKit GameError parametersInvalid(GameID, reason), taskGenerationFailed(GameID, reason), assetMissing(name)
ZKLearningEngine none The engine is total: invalid inputs are clamped or ignored and reported through a diagnostics array in its output (Section 9). It never throws and never traps.

Every error enum conforms to Error, Sendable, Equatable, CustomStringConvertible. description is for logs and must not contain personal data (no nicknames).

6.8.2 Forbidden constructs in non-test code #

fatalError, precondition, preconditionFailure, try!, as!, force unwrap !, implicitly unwrapped optionals, and unowned. Allowed instead: guard/if let, assert/assertionFailure (Debug-only trap), typed errors, and fallbacks. The single exception is @available(*, unavailable) required init?(coder:) in a UIView subclass, which may call fatalError because it is unreachable. Checked by swift-format rules NeverForceUnwrap, NeverUseForceTry, NeverUseImplicitlyUnwrappedOptionals and by scripts/lint.sh greps for the rest.

6.8.3 Fallback behavior (never crash on content or data problems in Release) #

Failure Debug behavior Release behavior Child sees Parent sees
Content file missing or undecodable assertionFailure + developer-menu report Skip file; features depending on it are hidden (a game with invalid config is hidden, Section 8) Fewer items; never an error Nothing (logged)
Single content item invalid report entry Item skipped Nothing Nothing
Audio file missing report entry + log TTS fallback de-DE; if TTS fails, visual-only path (Section 20) Visuals or TTS voice Nothing
Image asset missing report entry Neutral placeholder shape in the item's color Placeholder Nothing
Store cannot be opened log .fault One retry after 200 ms, then store recovery (Section 7.12.3) Normal start (possibly empty) One-time notice in the parent area
Save fails log .error Roll back the unit of work (Section 5.4.5), retry at the next save point; parent-area notice immediately when the device is out of space, otherwise after 3 consecutive failures (Section 7.12.2) Round continues; reward animation for that task skipped Notice from Section 7.12.2 (final copy Section 22)
StoreKit unavailable/offline log .info Cached entitlement (Section 17) Unchanged access Status shows "zuletzt geprüft …"
Game task generation fails assertionFailure Round ends early and gracefully after the tasks already generated; if zero tasks, return to game picker without stars (Section 10) Friendly return Nothing
Unknown enum raw value in stored data log .error Computed accessor returns the documented default (Section 7.3) Nothing Nothing

6.8.4 Presenting errors #

  • The child never sees error text, codes or system alerts caused by the app.
  • Parent-facing messages are short German sentences from Localizable.xcstrings under the error area, stating what happened and what the parent can do. Raw error descriptions are never shown.
  • StoreKit's own system sheets (purchase confirmation, sign-in) are the only system UI that can appear, and only in the parent area.

6.9 Logging #

Rule Detail
API os.Logger only. print, debugPrint, NSLog, dump are forbidden in non-test code (checked by scripts/lint.sh).
Factory ZKLog.logger(_ module: LogModule, category: String) -> Logger in ZKCore. Each type that logs holds private let log = ZKLog.logger(.engine, category: "Selection").
Subsystem One subsystem per module: "\(bundleID).\(module)" where bundleID is Bundle.main.bundleIdentifier (fallback de.zahlenkette.app) and module is one of app, core, engine, content, persistence, audio, design, gamekit, rewards, store, parent, game.<gameId> (e.g. de.zahlenkette.app.game.hoer_hin).
Category The component within the module (Selection, Maintenance, Lifecycle, RoundController).
Levels debug: high-volume diagnostics (task selection details); info: normal milestones (round started, content loaded in 120 ms); notice: noteworthy state changes (range widened, memory warning, day rollover); error: recoverable failures (save failed, audio file missing); fault: programmer errors and invariant violations that were recovered from (duplicate registration, unconfigured environment default).
Personal data Never log nicknames, not even as private. Profile IDs are logged as \(profileID, privacy: .private(mask: .hash)). Numbers 1-20, skills, game IDs, steps, outcomes, durations and counts are logged .public. No other user-entered text exists.
Destination On-device unified logging only. The app never writes log files, never reads OSLogStore, never exports or transmits logs. Developers read logs with Console.app or log stream --predicate 'subsystem BEGINSWITH "de.zahlenkette.app"' on a connected device or simulator.
Volume No logging inside per-frame code (SpriteKit update, drag onChanged). At most one info line per task.
Signposts OSSignposter intervals for the performance budgets in Section 24, using the same subsystems.

6.10 Accessibility Identifier Convention #

All interactive and test-relevant elements carry an accessibility identifier so XCUITest (Section 25) can find them without relying on German labels. This is the only identifier scheme in the project; Sections 10, 19, 23, 24 and 25 use it and define no other prefix (no child., game., gate., settings. or childHome. prefixes).

Format:

<screen>.<element>[.<qualifier>]
  • <screen> is the screen ID from Section 18 without the hyphen: S01 ... S27. In the game container S-07, elements owned by the shared framework (Section 10) use S07.<element> in every game; elements that exist only in one game use S07.<gameId>.<element>.
  • Every screen's root view carries <screen>.root (e.g. S05.root), used by UI tests to wait for a screen.
  • <element> is lowerCamelCase and names the role, not the look: speaker, home, answer, gameTile, avatarTile, keypadKey, confirm, lockBadge, progressDot, decorationSlot.
  • <qualifier> distinguishes repeated elements by stable data, never by screen position: a number (S07.answer.7), a game ID (S06.gameTile.hoer_hin), a profile index in creation order (S04.avatarTile.0), a grid slot (S11.decorationSlot.3_1), a digit (S17.keypadKey.8).

Examples:

Identifier Element
S05.play Main play button on the child home
S05.abenteuer Abenteuer button
S05.parentEntry Grown-up entry icon (press-and-hold)
S06.gameTile.memory Memory tile in the game picker
S06.gameTile.memory.lockBadge Lock badge on a premium tile
S05.root Root of the child home
S07.root Root of the game container (its accessibility value carries the answer under -uiTestRevealAnswers, Section 5.9)
S07.speaker, S07.home Replay and home buttons in every game
S07.progress.2 Third progress dot of the round
S07.pause.resume Continue button of the pause overlay
S07.answer.12 Answer option with value 12 in the framework answer area (e.g. numeral tile 12 in Hör hin)
S07.target.<id> Drop target of a drag interaction
S07.check Confirm ("fertig") button where a game uses one
S07.wie_viele.fieldCell.7 Cell 7 of an interactive Zwanzigerfeld in one game
S11.decorationSlot.3_1 Garden slot column 3, row 1
S17.question, S17.keypadKey.5, S17.confirm, S17.cooldown Parental gate
S20.timeLimit.picker Time-limit picker in child settings
S22.subscribe Subscribe button on the paywall
S23.deleteAll "Alle Daten löschen" button
debug.audioLog Test-hook element that is not part of a screen (only in Debug and Profile builds, Section 5.9); the prefix debug. is reserved for such elements

All identifiers are defined as static string constants or builder functions in ZKCore/Accessibility/A11yID.swift (pure strings, no UI import) so that app, packages and the UI-test target use the same values. Identifiers are never localized and never shown. Accessibility labels (what VoiceOver reads) are separate and localized (6.5, Section 19).

6.11 Git Workflow #

The founder works alone with AI coding agents. The workflow keeps main always releasable and every change traceable to the spec.

6.11.1 Branches #

Branch Rule
main Always builds, all tests green, lint clean. No direct commits except DECISIONS.md typo fixes.
Work branches <type>/<milestone>-<slug>: type in feat, fix, content, refactor, test, docs, chore, spike; milestone is m0 ... m10 (Section 27); slug is lowercase kebab-case. Examples: feat/m3-hoer-hin, content/m6-dotpictures-step2, fix/m4-gate-cooldown.
Spikes spike/... branches are for experiments (e.g. the CloudKit dry run, Section 7.15.1); they are never merged. Findings go into DECISIONS.md on a normal branch.

One branch covers one task from the milestone plan. An AI agent session starts from an up-to-date main, works on its branch, runs scripts/lint.sh and the full test plan, and merges with a squash merge (via a pull request on the hosting service if one is used, otherwise git merge --squash locally). Branches are deleted after merge.

6.11.2 Commit messages #

Conventional Commits:

<type>(<scope>): <imperative summary, max 72 chars>

<body: what changed and why, wrapped at 72 chars>

Spec: <section numbers implemented or affected, e.g. 9.4, 9.6>
Decision: <DECISIONS.md IDs added or used, e.g. D-0012> (omit if none)
<attribution trailers required by the agent tooling, if any>
  • type: as for branches, plus perf and build.
  • scope: module short name (core, engine, content, persistence, audio, design, gamekit, rewards, store, parent, app), a game (game-hoer-hin), or repo, ci, assets, marketing.
  • The squash-merge commit on main uses the same format; its summary describes the whole branch.
  • Tags: v<MARKETING_VERSION> (e.g. v1.0.0) on the exact commit that produced an App Store build; TestFlight-only builds are not tagged. CURRENT_PROJECT_VERSION increases monotonically with every uploaded build.

6.11.3 Repository hygiene #

.gitignore contains at least:

.DS_Store
xcuserdata/
*.xcuserstate
DerivedData/
build/
.build/
.swiftpm/
*.ipa
*.dSYM.zip
*.xcresult
*.p8
*.p12
*.mobileprovision
/Marketing/rendered/
  • Final audio (.m4a) and illustrations are committed. Recording masters (WAV sessions) and illustration source files are stored outside the repository by the founder. Decision: no Git LFS in V1; the committed binary assets (audio and illustrations within the download-size budget of Section 24) are manageable, and avoiding LFS keeps Xcode Cloud setup simple.
  • A pre-commit hook scripts/git-hooks/pre-commit runs scripts/lint.sh. Enable it once per clone with scripts/install-hooks.sh, which runs git config core.hooksPath scripts/git-hooks.
  • scripts/render-marketing.sh writes rendered marketing texts to Marketing/rendered/, which is ignored.

6.12 Decision Log and Code Review #

6.12.1 DECISIONS.md #

A single Markdown file at the repository root. This subsection defines its only entry format; Sections 1.4 and 29.5 refer to it. Each entry starts with a bold title line **D-NNNN: <title>** (log ID: D- plus a four-digit running number, never reused) followed by the fields below. Append-only: new entries are added at the end; entries are never edited in substance; a changed decision gets a new entry that supersedes the old one.

Entry format:

**D-0007: Warnings-as-errors reaches package targets via xcodebuild setting**

- Date: 2026-10-14
- Status: accepted
- Spec: 5.8.2
- References: none
- Context: The spec asks to verify whether SWIFT_TREAT_WARNINGS_AS_ERRORS on the
  command line applies to local package targets.
- Decision: Verified with a deliberate warning in ZKCore; the CI build failed as intended.
  No extra script needed.
- Consequences: None.

Allowed values of Status (complete list):

Status Use
default applied A default from the question table of Section 1.2 was applied because no answer arrived by its "Needed by" milestone
answered by product owner The product owner answered a Section 1.2 question
accepted A decision taken during implementation (verification outcomes, choices where the spec is silent)
proposed (needs founder) The executor proposes a decision that needs the founder's confirmation; work continues with the proposal
revisited A later change of an earlier decision's value; References names the earlier entry
spec discrepancy Two sections disagree; the entry names both and states which owning section was followed
superseded by D-NNNN Set only by appending the superseding entry; the old entry's status line is the one permitted edit

References lists related items: earlier entries (D-0003), Section 1.2 question IDs written with their section number (1.2 D-15) so they are never confused with log IDs, and Section 28.2 revisit items by their IDs (RV-NN, e.g. RV-07).

Record an entry when:

  1. The spec says "record in DECISIONS.md" (for example the verification steps in Sections 5.1.1, 5.4.1, 5.5.3, 5.8.2, 5.12 and 7.15).
  2. Code deviates from the spec, for any reason.
  3. The spec is silent and a non-trivial choice was made (anything another agent could plausibly have done differently).
  4. A new Apple framework, a new @unchecked Sendable confinement wrapper, a raised tools version, or a toolchain change (.xcode-version) is introduced.

The first entries created in milestone M0 are: D-0001 exact Xcode version, D-0002 Swift Testing on the minimum-OS simulator runtime (outcome, Section 5.1.1), D-0003 environment default variant (5.4.1), D-0004 warnings-as-errors verification (5.8.2). Later milestones add the motion usage-string verification (5.12), the iPad minimum window size verification (5.5.3) and the CloudKit dry run (7.15.1).

6.12.2 Code review checklist #

Every branch is reviewed against this list before merging (by the founder, or by a second AI agent session acting as reviewer). Items marked (auto) are checked by scripts or the build.

Architecture and scope

  • Change implements only the task of this branch; spec sections cited in the commit.
  • (auto) No new dependency in Package.swift other than local targets; no package reference in the Xcode project.
  • (auto) Module graph respected (scripts/check-imports.sh); games import nothing forbidden.
  • No network API use (URLSession, Network), no analytics, no notifications, no tracking (Section 23 check).
  • No prices, purchase UI or external links reachable in child mode without the parental gate.
  • Deviations or new choices recorded in DECISIONS.md.

Correctness

  • Engine code is pure: no clock, RNG, persistence or UI access except injected parameters.
  • Canonical numbers (mastery gains, intervals, prices, limits) are named constants referencing their owning section; no duplicated literals.
  • All persistence writes go through repositories inside a unit of work ending in one save() (Section 7.12.2).
  • New or changed SwiftData properties follow the CloudKit rules (Section 7.2) and the migration rules (Section 7.14).
  • Every new content reference (audio ID, string key, asset name) exists and passes content validation.
  • Edge cases from the owning section have tests.

Concurrency and safety

  • (auto) Zero warnings in the Swift language mode of Section 5.
  • Main-actor isolation explicit on UI types; no @unchecked Sendable without a DECISIONS.md entry.
  • (auto) No forbidden constructs (6.8.2), no print, no Date() / Calendar.current outside allowed places, no unfinished-work marker comments.

UI and accessibility

  • Checked on iPhone SE portrait and landscape and one iPad size (previews or simulator screenshots attached to the PR/merge notes).
  • Child tappables at least 60 x 60 pt; child controls are pictures or numerals only.
  • Accessibility identifiers per 6.10; parent-area VoiceOver labels localized.
  • Reduce Motion variant present for new animations; no flashing above 3 Hz; celebrations at most 2.5 s.
  • Sound-off path works (voice, music and SFX all off).
  • No hardcoded user-facing strings; new keys have translator comments.

Tests and quality

  • (auto) All tests in Zahlenkette.xctestplan pass; content validation passes.
  • New logic has unit tests with deterministic clock and RNG; test names follow 6.2.3.
  • (auto) swift-format lint --strict clean.
  • Logs contain no personal data; log levels per 6.9.

6.13 Formatting with swift-format #

Formatting and style linting use only swift-format, which ships with the toolchain of Section 5. No third-party linters or formatters (no SwiftLint, no SwiftFormat by Nick Lockwood).

Commands (wrapped by the scripts):

# scripts/format.sh — rewrite files in place
xcrun swift-format format --in-place --recursive --parallel \
  --configuration .swift-format App Packages Tests

# scripts/lint.sh — fail on any violation, then run the repository checks
xcrun swift-format lint --strict --recursive --parallel \
  --configuration .swift-format App Packages Tests
scripts/check-imports.sh
xcrun swift scripts/policy-check.swift

scripts/lint.sh additionally greps App/, Packages/*/Sources/ and Tests/ and fails on: the uppercase to-do and fix-me marker tags, print(, debugPrint(, NSLog(, fatalError( (except the documented init?(coder:) exception), precondition(, try!, as!, ObservableObject, @Published, @StateObject, @ObservedObject, @EnvironmentObject, @Query, UserDefaults outside the allowlist in 6.6, Date() / Date.now / Calendar.current outside ZKCore/Time/ and test targets, URLSession, import Network. The framework and symbol allowlists of Section 23.10 (for example mach_continuous_time only in ZKCore/Time/TrustedDayClock.swift, no import MessageUI anywhere) are enforced by scripts/policy-check.swift.

Committed configuration file .swift-format at the repository root:

{
  "version": 1,
  "lineLength": 120,
  "indentation": { "spaces": 4 },
  "tabWidth": 4,
  "maximumBlankLines": 1,
  "respectsExistingLineBreaks": true,
  "lineBreakBeforeControlFlowKeywords": false,
  "lineBreakBeforeEachArgument": false,
  "lineBreakBeforeEachGenericRequirement": false,
  "prioritizeKeepingFunctionOutputTogether": true,
  "indentConditionalCompilationBlocks": true,
  "indentSwitchCaseLabels": false,
  "lineBreakAroundMultilineExpressionChainComponents": false,
  "spacesAroundRangeFormationOperators": false,
  "multiElementCollectionTrailingCommas": true,
  "fileScopedDeclarationPrivacy": { "accessLevel": "private" },
  "rules": {
    "AllPublicDeclarationsHaveDocumentation": false,
    "AlwaysUseLiteralForEmptyCollectionInit": true,
    "AlwaysUseLowerCamelCase": true,
    "AmbiguousTrailingClosureOverload": true,
    "BeginDocumentationCommentWithOneLineSummary": false,
    "DoNotUseSemicolons": true,
    "DontRepeatTypeInStaticProperties": true,
    "FileScopedDeclarationPrivacy": true,
    "FullyIndirectEnum": true,
    "GroupNumericLiterals": true,
    "IdentifiersMustBeASCII": true,
    "NeverForceUnwrap": true,
    "NeverUseForceTry": true,
    "NeverUseImplicitlyUnwrappedOptionals": true,
    "NoAccessLevelOnExtensionDeclaration": true,
    "NoAssignmentInExpressions": true,
    "NoBlockComments": true,
    "NoCasesWithOnlyFallthrough": true,
    "NoEmptyTrailingClosureParentheses": true,
    "NoLabelsInCasePatterns": true,
    "NoLeadingUnderscores": true,
    "NoParensAroundConditions": true,
    "NoPlaygroundLiterals": true,
    "NoVoidReturnOnFunctionSignature": true,
    "OmitExplicitReturns": false,
    "OneCasePerLine": true,
    "OneVariableDeclarationPerLine": true,
    "OnlyOneTrailingClosureArgument": true,
    "OrderedImports": true,
    "ReplaceForEachWithForLoop": true,
    "ReturnVoidInsteadOfEmptyTuple": true,
    "TypeNamesShouldBeCapitalized": true,
    "UseEarlyExits": false,
    "UseExplicitNilCheckInConditions": true,
    "UseLetInEveryBoundCaseVariable": true,
    "UseShorthandTypeNames": true,
    "UseSingleLinePropertyGetter": true,
    "UseSynthesizedInitializer": true,
    "UseTripleSlashForDocumentationComments": true,
    "UseWhereClausesInForLoops": false,
    "ValidateDocumentationComments": false
  }
}

Notes:

  • Verification step (milestone M0): run xcrun swift-format dump-configuration and compare its key and rule names with the file above. If the installed swift-format reports an unknown key or rule, remove or rename it to the toolchain's name and record the change in DECISIONS.md. The intent of each setting stays: 4-space indentation, 120-column lines, no force unwraps or force tries, ASCII identifiers, ordered imports.
  • Suppression: // swift-format-ignore: <RuleName> on the line before a declaration is allowed only with a trailing reason comment, and never for NeverForceUnwrap, NeverUseForceTry or IdentifiersMustBeASCII.
  • Xcode's editor settings for the project (in the shared project settings) match: 4 spaces, no tabs, page guide at 120, trim trailing whitespace.

6.14 Swift Language Rules #

Topic Rule
Types Prefer struct and enum. Classes are final unless designed for subclassing (none in V1 except SwiftData @Model, which must be classes and are declared final).
Access control Default internal. package for API used by other ZahlenketteKit targets only. public only for API the app target needs. private for file-local helpers (swift-format fileScopedDeclarationPrivacy).
Optionals Use guard let early exits. Never compare Bools to literals. Prefer ?? default with a named default constant.
Numbers Int for counts and numbers 1-20; Double for scores; TimeInterval for stored seconds; Duration for sleeps and timeouts. Never Float except where an Apple API requires it.
Collections Deterministic ordering whenever order is observable (sort by a stable key before presenting or selecting); never rely on Set or Dictionary iteration order in engine or generator code.
Canonical values Every number owned by a spec section is a named constant in the owning module with a doc comment citing the section, e.g. /// Section 9: gain on a first-try answer. static let firstTryGain = 0.15.
Documentation /// doc comments on every public and package declaration: one summary sentence, then parameters/returns where non-obvious.
Strings No user-facing string literals (6.5). String comparisons on identifiers use raw values, never display text.
Dates Stored as Date (UTC instant). Local-day logic only through AppClock.calendar and LocalDate (Section 5.4.2).
Extensions on Apple types Allowed in the module that needs them, internal or package, never public.
Macros Only Apple macros (@Observable, @Model, #Predicate, #Preview, @Entry, Swift Testing macros). No custom macros in V1.

6.15 Comments and Documentation in the Repository #

  • Code comments explain why, not what. A comment that restates the code is removed in review.
  • A spec reference in a comment uses the form // Spec 9.4 so it can be grepped.
  • README.md contains: prerequisites (Section 5.1), how to open and build, how to run tests, how to run lint and format, how to use the StoreKit configuration, how to reach the developer menu, and the list of scripts. It does not duplicate the spec.

7. Data Model and Persistence #

This section defines every persisted entity, its fields and defaults, the storage stack, repositories, retention, integrity checks, migrations, the V1.1 iCloud sync enablement, the export file format and the reset/delete semantics. The meaning of learning values (how scores change, when a range widens) is owned by Section 9; the meaning of reward values by Section 14; session and time-limit accounting by Section 15. This section owns how and where those values are stored.

7.1 Scope and Principles #

  1. All child data lives in one SwiftData store on the device, module ZKPersistence (Section 5.3.2). V1 stores data locally only; nothing is sent anywhere.
  2. The schema is CloudKit-compatible from day one (7.2), so that V1.1 iCloud sync (private database, same Apple ID only) is enabled by configuration alone, without a schema migration (7.15).
  3. The store holds child data only. Device-level preferences live in UserDefaults (7.9). The subscription entitlement is not stored in SwiftData (7.8; Section 17).
  4. Data minimization: no real names, no birthdates, no photos, no audio recordings, no location, no identifiers that leave the device. The optional nickname (0-20 characters) is the only free text.
  5. Append-only where history matters (star ledger, awards), aggregate where volume matters (mastery, daily usage), prune raw logs after a fixed period (attempts and sessions after 90 days).
  6. Every write goes through a repository into the single main-actor ModelContext and is committed by one explicit save() per unit of work (7.12.2).

7.2 CloudKit-Compatible Modeling Rules #

7.2.1 Rules #

# Rule Reason
R1 No @Attribute(.unique) anywhere. No #Unique macro. CloudKit cannot enforce uniqueness across devices. Uniqueness is achieved by logical keys plus the dedup pass (7.20).
R2 Every stored property is optional or has a declared default value. Required for CloudKit mirroring; records may arrive with fields missing.
R3 Every relationship is optional (Type? for to-one, [Type]? = [] for to-many). CloudKit does not guarantee that related records arrive together.
R4 Every relationship has an inverse. The inverse is declared with @Relationship(inverse:) on exactly one side (the parent side). CloudKit mirroring requires inverses; declaring it on both sides causes macro cycles.
R5 Delete rules are only .cascade (parent to child) and .nullify (child to parent, the default). Never .deny, never .noAction. CloudKit supports cascade and nullify only.
R6 No ordered relationships; code never relies on the order of a to-many array. Order comes from explicit fields (sortIndex, createdAt, number). CloudKit does not support ordered relationships.
R7 Enums are stored as their raw String or Int in a property named <name>Raw, with a computed accessor in an extension that falls back to a documented default for unknown raw values (7.3.3). Raw scalars are stable in CloudKit, readable in the export, and tolerate values written by newer app versions.
R8 No collection-typed or Codable-struct attributes. Short lists are stored as comma-separated Strings with computed accessors. Single exception: GameProgress.gameStateJSON (7.5.6), an opaque per-game state string of at most 8 KB that only the owning game interprets; after V1.1 the last writer wins for it. Avoids opaque transformable blobs whose whole value is overwritten on sync.
R9 No @Attribute(.externalStorage), no Data blobs, no images. Nothing binary needs storing; keeps CloudKit records small.
R10 Property names avoid Objective-C/NSObject and CloudKit-reserved names: never description, hash, class, self, superclass, recordID, recordName, recordType, creationDate, modificationDate, creatorUserRecordID, lastModifiedUserRecordID, and nothing with a CD_ prefix. Core Data and CloudKit reserve these.
R11 After V1.1 ships the schema to the CloudKit production environment, entities and properties are never renamed or deleted and their types never change. Only additive changes are allowed (7.14). The CloudKit production schema is additive-only.
R12 Every entity has a logical id: UUID = UUID() set at creation and never changed. It is the identity used across devices, in the export and in cross-entity references. persistentModelID is never stored or exported. persistentModelID is device-local.

7.2.2 Automated compatibility check #

A test in ZKPersistenceTests named CloudKitCompatibilityTests builds a Core Data model from the SwiftData types with NSManagedObjectModel.makeManagedObjectModel(for: SchemaV1.models) and asserts for every entity:

  • uniquenessConstraints is empty (R1);
  • every NSRelationshipDescription has isOptional == true, a non-nil inverseRelationship, deleteRule in { .cascadeDeleteRule, .nullifyDeleteRule }, and isOrdered == false (R3-R6);
  • every NSAttributeDescription has isOptional == true or a non-nil defaultValue (R2);
  • no attribute or relationship name is in the reserved list of R10.

Verification step (milestone that builds persistence): if the attribute default check fails for properties that are declared with defaults in Swift (because the SwiftData-to-Core-Data translation does not surface declared defaults as defaultValue), keep the other assertions, replace the default assertion with a source check in scripts/lint.sh that fails if a stored property inside a @Model class has neither ? nor =, and record this in DECISIONS.md. In either case the CloudKit dry run (7.15.1) is the final proof and is performed before V1 is released.

7.3 Conventions Shared by All Models #

7.3.1 Common properties #

Property Type Default Present on Notes
id UUID UUID() all entities Logical identity (R12). Set once in init.
profileID UUID? nil all entities except ChildProfile Denormalized copy of profile?.id. All queries filter on this scalar (7.11). Set in init, never changed except by the V1.1 profile merge (7.20.5).
profile ChildProfile? nil all entities except ChildProfile and PlacedDecoration Relationship used for cascade delete and CloudKit linkage.
createdAt Date Date.distantPast all entities Set in init from AppClock.now. distantPast marks a value that was never set (integrity check, 7.19).
updatedAt Date Date.distantPast mutable entities only Set on every mutation by the repository. Used by the dedup rules (7.20).

Decision: declared defaults for timestamps are Date.distantPast, not Date.now, so that the schema default is a constant and an unset timestamp is detectable. Initializers always take the timestamp from the injected clock.

7.3.2 Access control and mutation #

Model properties are declared public internal(set) var so the app and other modules can read them but only repositories inside ZKPersistence can mutate them. Verification step: if the @Model macro in the installed toolchain rejects internal(set), declare them public var, and the code review checklist item "all persistence writes go through repositories" (Section 6.12.2) becomes the only guard; record it in DECISIONS.md.

Relationship assignment rule: insert a new object into the context first, then assign its relationships (context.insert(record); record.profile = profile). Assigning relationships between an inserted and a not-yet-inserted object is avoided because it is a known source of SwiftData inconsistencies.

7.3.3 Stored enums and their defaults #

Stored property Swift accessor type Raw values Fallback for unknown raw value
levelRaw: String Level littleOnes, vorschule .littleOnes
rangeStageRaw: Int RangeStage? 5 (r5, numbers 1-5), 10 (r10, 1-10), 20 (r20, 1-20); 0 = not initialized nil (engine cold start, Section 9)
rangeOverrideRaw: Int RangeOverride 0 = .auto, 5, 10, 20 = fixed stage .auto
difficultyCapRaw: String DifficultyCap auto, maxStep1 ("Leicht"), maxStep2 ("Mittel"), allSteps ("Schwer") .auto
stepRaw, currentStepRaw, highestStepRaw: String DifficultyStep step1, step2, step3 .step1
gameIDRaw: String GameID? the 12 raw values (entdecken, wie_viele, hoer_hin, was_fehlt, blitzblick, mehr_weniger, nachspuren, schuettelbox, froschsprung, zahlenmonster, memory, punkt_zu_punkt) nil; records with unknown game IDs are kept but ignored by queries that need a game
skillRaw, secondarySkillRaw: String Skill? recognize, name, count, subitize, order, compare, decompose, write nil; mastery records with unknown skills are deleted by the integrity check (7.19)
outcomeRaw, lastOutcomeRaw: String TaskOutcome? firstTry, afterHint, shown nil
bucketRaw: String? TaskBucket? focus, review, stretch nil
reasonRaw: String StarReason? taskSolved, roundCompleted, abenteuerCompleted, decorationPurchased nil; entry still counts in the balance
sourceRaw: String StickerSource? dotPicture, friend, milestone nil
endReasonRaw: String? SessionEndReason? profileSwitch, background, idle, parentArea, timeLimit, terminated, dataDeleted (meanings: Section 15.7.1) nil

RangeStage, Level, Skill, GameID, DifficultyStep, TaskOutcome and TaskBucket are ZKCore types (Section 5.3.2). Decision: RangeStage uses the upper bound as its raw Int (case r5 = 5, r10 = 10, r20 = 20). RangeOverride, DifficultyCap, StarReason, StickerSource and SessionEndReason are declared in ZKCore as well, exactly once, with the raw values in this table; ZKLearningEngine, ZKRewards, ZKParentArea and the session manager use these declarations and declare no enum of the same meaning. The persisted raw values are the contract (they also appear in the export, 7.17):

// ZKCore/Domain/
public enum RangeOverride: Int, Sendable, Hashable, Codable, CaseIterable {
    case auto = 0, r5 = 5, r10 = 10, r20 = 20     // parent override "Auto / 1-5 / 1-10 / 1-20"
}
public enum DifficultyCap: String, Sendable, Hashable, Codable, CaseIterable {
    case auto, maxStep1, maxStep2, allSteps       // "Auto" / "Leicht" / "Mittel" / "Schwer"
}
public enum StarReason: String, Sendable, Hashable, Codable, CaseIterable {
    case taskSolved, roundCompleted, abenteuerCompleted, decorationPurchased
}
public enum StickerSource: String, Sendable, Hashable, Codable, CaseIterable {
    case dotPicture, friend, milestone
}
public enum SessionEndReason: String, Sendable, Hashable, Codable, CaseIterable {
    case profileSwitch, background, idle, parentArea, timeLimit, terminated, dataDeleted
}

DecorationSize (Section 14) and MasteryBand (Section 9) are ZKCore types too; neither is persisted (a band is computed on read, 7.7).

Accessor pattern:

extension ProfileSettings {
    public var level: Level {
        Level(rawValue: levelRaw) ?? .littleOnes
    }
}
// Mutation goes through the repository:
//   settings.levelRaw = newLevel.rawValue; settings.updatedAt = clock.now

7.3.4 Local dates and comma-separated lists #

  • localDate: String holds LocalDate.string ("yyyy-MM-dd", Gregorian calendar, device time zone at the moment of writing; Section 5.4.2). A malformed value is treated as absent.
  • CSV lists (only AbenteuerRecord.plannedGameIDs) contain raw values joined by , without spaces; the accessor splits, trims, drops empty and unknown values, and preserves order.

7.4 Entity Overview #

Entity Cardinality Mutable Retention Purpose
ChildProfile 0-5 per device (V1.1: may exceed 5 after sync, 7.20.5) yes until deleted The child: nickname, avatar, theme, cached star values
ProfileSettings exactly 1 per profile yes with profile Parent overrides: level, range, difficulty cap, daily limit
LearningState exactly 1 per profile yes with profile Engine state that is not per skill x number: range stage, widening probation counters, success-rate controller (stretch share, comfort mode, recent first-try window), re-entry state
MasteryRecord 0-160 per profile (8 skills x 20 numbers), created lazily yes with profile Mastery and spaced-repetition state per skill x number
GameNumberStat 0-240 per profile (12 games x 20 numbers), created lazily yes with profile Per game x number counters; input to the Zahlenfreund rule "first-try correct in 3 distinct games"
GameProgress 0-12 per profile, created lazily yes with profile Current difficulty step and stepping counters per game; opaque per-game state
TaskAttempt one per completed task no (append-only) 90 days Attempt log
SessionRecord one per session yes while open 90 days Session log
DailyUsage one per profile x local date x device yes 365 days Active seconds per day, parent bonus time, limit in force, warning state
StarLedgerEntry one per star event no (append-only) with profile Star history; balance = sum
OwnedDecoration one per purchase no (append-only) with profile A decoration the child bought
PlacedDecoration 0-1 per owned decoration, max 24 per profile yes with owner Garden slot of an owned decoration
FriendState 0-20 per profile only celebrationShown with profile Befriended Zahlenfreunde
StickerAward 0-52 per profile only isNew with profile Stickers in the album
AbenteuerRecord 0-1 per profile x local date (V1) yes 365 days Daily Abenteuer status

7.4.1 ER diagram #

erDiagram
    ChildProfile ||--o| ProfileSettings : "settings (cascade)"
    ChildProfile ||--o| LearningState : "learningState (cascade)"
    ChildProfile ||--o{ MasteryRecord : "mastery (cascade)"
    ChildProfile ||--o{ GameNumberStat : "gameNumberStats (cascade)"
    ChildProfile ||--o{ GameProgress : "gameProgress (cascade)"
    ChildProfile ||--o{ TaskAttempt : "attempts (cascade)"
    ChildProfile ||--o{ SessionRecord : "sessions (cascade)"
    ChildProfile ||--o{ DailyUsage : "dailyUsage (cascade)"
    ChildProfile ||--o{ StarLedgerEntry : "starLedger (cascade)"
    ChildProfile ||--o{ OwnedDecoration : "ownedDecorations (cascade)"
    OwnedDecoration ||--o| PlacedDecoration : "placement (cascade)"
    ChildProfile ||--o{ FriendState : "friends (cascade)"
    ChildProfile ||--o{ StickerAward : "stickers (cascade)"
    ChildProfile ||--o{ AbenteuerRecord : "abenteuer (cascade)"

    ChildProfile {
        UUID id
        String nickname
        String avatarID
        String colorThemeID
        Int sortIndex
        Int cachedStarBalance
        Int cachedLifetimeStars
    }
    ProfileSettings {
        UUID id
        UUID profileID
        String levelRaw
        Int rangeOverrideRaw
        String difficultyCapRaw
        Int dailyLimitMinutes
    }
    LearningState {
        UUID id
        UUID profileID
        Int rangeStageRaw
        Int widenedFromRaw
        Int sessionsInStage
        Double stretchShare
        Bool comfortMode
    }
    MasteryRecord {
        UUID id
        UUID profileID
        String skillRaw
        Int number
        Double score
        Int attempts
        Int boxIndex
        Date nextDueAt
    }
    GameNumberStat {
        UUID id
        UUID profileID
        String gameIDRaw
        Int number
        Int firstTryCount
    }
    GameProgress {
        UUID id
        UUID profileID
        String gameIDRaw
        String currentStepRaw
        String gameStateJSON
    }
    TaskAttempt {
        UUID id
        UUID profileID
        String gameIDRaw
        String skillRaw
        Int number
        String outcomeRaw
        Date createdAt
    }
    SessionRecord {
        UUID id
        UUID profileID
        Date startedAt
        Int activeSeconds
    }
    DailyUsage {
        UUID id
        UUID profileID
        String localDate
        String deviceID
        Int activeSeconds
        Int bonusSeconds
        Int limitMinutes
    }
    StarLedgerEntry {
        UUID id
        UUID profileID
        Int amount
        String reasonRaw
        UUID sourceID
    }
    OwnedDecoration {
        UUID id
        UUID profileID
        String catalogItemID
        Int pricePaid
    }
    PlacedDecoration {
        UUID id
        UUID profileID
        Int slotX
        Int slotY
    }
    FriendState {
        UUID id
        UUID profileID
        Int number
        Date befriendedAt
    }
    StickerAward {
        UUID id
        UUID profileID
        String stickerID
        String sourceRaw
    }
    AbenteuerRecord {
        UUID id
        UUID profileID
        String localDate
        Bool isCompleted
    }

The diagram shows key fields only; 7.5 lists every property.

7.5 Entity Definitions #

Column meanings: "Default" is the declared default in the model; "Set by" names the component that writes the value; "Opt." marks Swift optionals. Every entity also follows 7.3.1.

7.5.1 ChildProfile #

Property Type Default Opt. Set by Notes
id UUID UUID() no ProfileRepository Logical profile identity; referenced by all profileID fields
nickname String "" no ProfileRepository 0-20 characters after normalization (7.5.1.1). Shown only in the parent area and as the caption under the avatar in the profile picker
avatarID String "" no ProfileRepository One of the 12 avatar IDs in avatars.json (Section 8, Section 15). Empty or unknown -> first avatar in avatars.json at display time; the integrity check repairs it (7.19)
colorThemeID String "" no ProfileRepository One of the 6 theme IDs (Section 15). Same fallback rule as avatarID
sortIndex Int 0 no ProfileRepository Display order in the picker: ascending sortIndex, then createdAt, then id. New profile gets max + 1
cachedStarBalance Int 0 no RewardRepository Cache of the sum of all ledger amounts (7.7). Never the source of truth
cachedLifetimeStars Int 0 no RewardRepository Cache of the sum of positive ledger amounts; drives the 100 and 500 lifetime-star milestones (Section 14)
lastPlayedAt Date? nil yes UsageRepository Last completed task; parent dashboard
isPendingDeletion Bool false no DataManagementService true while a chunked deletion is in progress (7.18.3). Such profiles are invisible everywhere
createdAt Date .distantPast no ProfileRepository
updatedAt Date .distantPast no repositories
settings ProfileSettings? nil yes ProfileRepository to-one, cascade, inverse ProfileSettings.profile
learningState LearningState? nil yes ProfileRepository to-one, cascade, inverse LearningState.profile
mastery [MasteryRecord]? [] yes ProgressRepository to-many, cascade
gameNumberStats [GameNumberStat]? [] yes ProgressRepository to-many, cascade
gameProgress [GameProgress]? [] yes ProgressRepository to-many, cascade
attempts [TaskAttempt]? [] yes ProgressRepository to-many, cascade
sessions [SessionRecord]? [] yes UsageRepository to-many, cascade
dailyUsage [DailyUsage]? [] yes UsageRepository to-many, cascade
starLedger [StarLedgerEntry]? [] yes RewardRepository to-many, cascade
ownedDecorations [OwnedDecoration]? [] yes RewardRepository to-many, cascade
friends [FriendState]? [] yes RewardRepository to-many, cascade
stickers [StickerAward]? [] yes RewardRepository to-many, cascade
abenteuer [AbenteuerRecord]? [] yes UsageRepository to-many, cascade
7.5.1.1 Nickname normalization and validation #

Applied by ProfileRepository on create and update, in this order: trim leading and trailing whitespace and newlines; replace any internal run of whitespace or newlines with a single space; remove control characters (Unicode category Cc and Cf); truncate to 20 Characters (grapheme clusters, so an emoji or a letter with combining marks counts as one). An empty result is valid (no nickname). The UI (Section 16) prevents typing beyond 20 characters; the repository enforces it regardless.

7.5.1.2 Full declaration (reference pattern for all models) #
import Foundation
import SwiftData

extension SchemaV1 {
    @Model
    public final class ChildProfile {
        public internal(set) var id: UUID = UUID()
        public internal(set) var nickname: String = ""
        public internal(set) var avatarID: String = ""
        public internal(set) var colorThemeID: String = ""
        public internal(set) var sortIndex: Int = 0
        public internal(set) var cachedStarBalance: Int = 0
        public internal(set) var cachedLifetimeStars: Int = 0
        public internal(set) var lastPlayedAt: Date? = nil
        public internal(set) var isPendingDeletion: Bool = false
        public internal(set) var createdAt: Date = Date.distantPast
        public internal(set) var updatedAt: Date = Date.distantPast

        @Relationship(deleteRule: .cascade, inverse: \ProfileSettings.profile)
        public internal(set) var settings: ProfileSettings? = nil
        @Relationship(deleteRule: .cascade, inverse: \LearningState.profile)
        public internal(set) var learningState: LearningState? = nil
        @Relationship(deleteRule: .cascade, inverse: \MasteryRecord.profile)
        public internal(set) var mastery: [MasteryRecord]? = []
        @Relationship(deleteRule: .cascade, inverse: \GameNumberStat.profile)
        public internal(set) var gameNumberStats: [GameNumberStat]? = []
        @Relationship(deleteRule: .cascade, inverse: \GameProgress.profile)
        public internal(set) var gameProgress: [GameProgress]? = []
        @Relationship(deleteRule: .cascade, inverse: \TaskAttempt.profile)
        public internal(set) var attempts: [TaskAttempt]? = []
        @Relationship(deleteRule: .cascade, inverse: \SessionRecord.profile)
        public internal(set) var sessions: [SessionRecord]? = []
        @Relationship(deleteRule: .cascade, inverse: \DailyUsage.profile)
        public internal(set) var dailyUsage: [DailyUsage]? = []
        @Relationship(deleteRule: .cascade, inverse: \StarLedgerEntry.profile)
        public internal(set) var starLedger: [StarLedgerEntry]? = []
        @Relationship(deleteRule: .cascade, inverse: \OwnedDecoration.profile)
        public internal(set) var ownedDecorations: [OwnedDecoration]? = []
        @Relationship(deleteRule: .cascade, inverse: \FriendState.profile)
        public internal(set) var friends: [FriendState]? = []
        @Relationship(deleteRule: .cascade, inverse: \StickerAward.profile)
        public internal(set) var stickers: [StickerAward]? = []
        @Relationship(deleteRule: .cascade, inverse: \AbenteuerRecord.profile)
        public internal(set) var abenteuer: [AbenteuerRecord]? = []

        init(id: UUID, nickname: String, avatarID: String, colorThemeID: String,
             sortIndex: Int, now: Date) {
            self.id = id
            self.nickname = nickname
            self.avatarID = avatarID
            self.colorThemeID = colorThemeID
            self.sortIndex = sortIndex
            self.createdAt = now
            self.updatedAt = now
        }
    }
}

Child-side pattern (every other entity): a plain optional back-reference without @Relationship, plus the denormalized profileID:

extension SchemaV1 {
    @Model
    public final class MasteryRecord {
        public internal(set) var id: UUID = UUID()
        public internal(set) var profileID: UUID? = nil
        public internal(set) var profile: ChildProfile? = nil
        public internal(set) var skillRaw: String = ""
        public internal(set) var number: Int = 0
        public internal(set) var score: Double = 0.0
        // ... remaining properties exactly as in 7.5.4 ...
        init(profileID: UUID, skill: Skill, number: Int, now: Date) { /* sets fields */ }
    }
}

7.5.2 ProfileSettings #

Exactly one per profile, created together with the profile.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes
profile ChildProfile? nil yes inverse of ChildProfile.settings
levelRaw String "littleOnes" no Level. The first-launch setup and profile editor always set it explicitly (Section 15). Level semantics: Section 4
rangeOverrideRaw Int 0 no RangeOverride: 0 = Auto, 5 = "1-5", 10 = "1-10", 20 = "1-20". Any other value -> Auto. Override precedence: Section 9
difficultyCapRaw String "auto" no DifficultyCap: auto, maxStep1 ("Leicht"), maxStep2 ("Mittel"), allSteps ("Schwer"). Semantics: Section 9
dailyLimitMinutes Int 20 no Allowed values: 0 (= Off), 10, 15, 20, 30, 45, 60. Any other value is replaced by 20 by the integrity check. Enforcement: Section 15
createdAt Date .distantPast no
updatedAt Date .distantPast no

7.5.3 LearningState #

Exactly one per profile. Stores engine state that is not per skill x number and that must survive pruning of the attempt log and app relaunches. Section 9 defines how each value is read and updated (the mapping to the engine's RangeState, ControllerState and EngineProfileState fields is in Section 9.19); this table defines storage only. Property names match the engine field names.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes
profile ChildProfile? nil yes inverse of ChildProfile.learningState
rangeStageRaw Int 0 no Current automatic range stage (5/10/20). 0 = not initialized; the engine's cold-start rule applies (Section 9). Parent override does not change this value; it takes precedence at planning time
rangeStageEnteredAt Date? nil yes When the current stage became active
sessionsInStage Int 0 no Counted sessions since the automatic stage last changed (Section 9's session definition). Also governs re-widening after a narrow-back (Section 9.12.2)
widenedFromRaw Int 0 no Engine widenedFrom: the stage before the last automatic widening while that widening is on probation; 0 = nil (no probation). Target of a narrow-back
lastWidenedAt Date? nil yes
probationAttempts Int 0 no Non-stretch tasks on numbers new in the current stage since the last widening
probationFirstTry Int 0 no First-try outcomes among probationAttempts
probationSessions Int 0 no Counted sessions containing at least one such task
lastNarrowedAt Date? nil yes
stretchShare Double 0.10 no Current share of stretch tasks, kept within 0.0-0.20 by Section 9's success-rate controller
comfortMode Bool false no Controller comfort mode (review share raised, Section 9.14)
recentFirstTryRaw String "" no Engine recentFirstTry: at most 20 characters, each "1" (firstTry) or "0", oldest first. The accessor ignores other characters and keeps the last 20
reentryRoundsRemaining Int 0 no Re-entry rounds still to plan after a long absence (Section 9.15)
totalSessions Int 0 no Lifetime session count for this profile
lastCountedSessionEndedAt Date? nil yes End of the last session with at least one completed task; used for long-absence re-entry (Section 9.15)
createdAt Date .distantPast no
updatedAt Date .distantPast no

7.5.4 MasteryRecord #

One per profile x skill x number (1-20), created lazily on the first attempt that credits that skill x number. Absence of a record means band notStarted.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes Logical key part 1
profile ChildProfile? nil yes
skillRaw String "" no Logical key part 2 (Skill)
number Int 0 no Logical key part 3; valid 1-20
score Double 0.0 no 0.0-1.0; start 0.0; update rules in Section 9
attempts Int 0 no Tasks that credited this skill x number (primary or secondary)
firstTryCorrect Int 0 no Of those, outcome firstTry
afterHintCount Int 0 no Outcome afterHint
shownCount Int 0 no Outcome shown
lastOutcomeRaw String "" no TaskOutcome of the latest credited task
lastPracticedAt Date? nil yes
nextDueAt Date? nil yes Start of the local day of lastPracticedAt + intervalDays (Section 9)
intervalDays Int 0 no Leitner interval for boxIndex
boxIndex Int 0 no 0-5
firstMasteredAt Date? nil yes Set the first time the record meets Section 9's mastered rule; never cleared (feeds "mastery gains over the last 30 days" in the parent area, Section 16). Reset progress deletes the record, so it starts over
createdAt Date .distantPast no
updatedAt Date .distantPast no

Band (notStarted, practicing, almost, mastered) is computed, never stored (7.7).

7.5.5 GameNumberStat #

One per profile x game x number, created lazily by ProgressRepository.record(_:) for the task's number (not for secondary skills; the stat is per game, not per skill).

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes Logical key part 1
profile ChildProfile? nil yes
gameIDRaw String "" no Logical key part 2
number Int 0 no Logical key part 3; 1-20
attempts Int 0 no Completed tasks of this game about this number
firstTryCount Int 0 no Of those, outcome firstTry
lastPracticedAt Date? nil yes
createdAt Date .distantPast no
updatedAt Date .distantPast no

The Zahlenfreund rule (Section 14; eligibility computed by Section 9) counts, for a number, the distinct gameIDRaw values with firstTryCount >= 1. This aggregate is kept permanently because the attempt log is pruned after 90 days.

7.5.6 GameProgress #

One per profile x game, created lazily when the child first starts a round of that game.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes Logical key part 1
profile ChildProfile? nil yes
gameIDRaw String "" no Logical key part 2
currentStepRaw String "step1" no Current difficulty step (stepping rules: Section 9). Always read through the parent difficulty cap at planning time; the stored value is not lowered when the cap changes
highestStepRaw String "step1" no Highest step ever reached
consecutiveFirstTry Int 0 no Consecutive firstTry outcomes at the current step
consecutiveNonFirstTry Int 0 no Consecutive afterHint or shown outcomes
consecutiveShown Int 0 no Consecutive shown outcomes
roundsCompleted Int 0 no
tasksCompleted Int 0 no
lastItemID String? nil yes Not used by any game in V1 (games that rotate content, such as Punkt zu Punkt, keep that in gameStateJSON). Kept in the schema so a later version can use it without a migration; always nil in V1
gameStateJSON String "" no Opaque per-game state owned by the game (GameStateStore, Sections 10.3.4 and 12.1.4), UTF-8 JSON of at most 8 KB; "" = no state. Written in the task save point from TaskResult.updatedGameState (5.4.5). A value longer than 8 KB is not written (the previous value stays) and a fault is logged. Exception to R8 (7.2.1); last writer wins after V1.1
lastPlayedAt Date? nil yes
createdAt Date .distantPast no
updatedAt Date .distantPast no

7.5.7 TaskAttempt #

One row per completed task (a task is completed when its outcome is firstTry, afterHint or shown; Section 10). Tasks abandoned mid-way (child leaves the round) are not recorded. Append-only. Entdecken's free-explore mode records nothing (Section 11).

Property Type Default Opt. Notes
id UUID UUID() no Also the sourceID of the taskSolved ledger entry
profileID UUID? nil yes
profile ChildProfile? nil yes
createdAt Date .distantPast no Completion time
localDate String "" no Local date of completion
sessionID UUID? nil yes Logical reference to SessionRecord.id
roundID UUID? nil yes RoundPlan.id (Section 5.3.2)
abenteuerID UUID? nil yes Logical reference to AbenteuerRecord.id when part of an Abenteuer
gameIDRaw String "" no
stepRaw String "step1" no Step at which the task was played
levelRaw String "littleOnes" no Level at that time
rangeStageRaw Int 0 no Effective range at that time (after parent override)
skillRaw String "" no Primary skill credited (weight 1.0)
secondarySkillRaw String? nil yes Secondary skill credited (weight 0.5); nil if none or not active for the level
number Int 0 no Number the task was about (1-20)
outcomeRaw String "" no firstTry, afterHint, shown
wrongAnswers Int 0 no 0-3 wrong answers before completion
hintLevelReached Int 0 no 0 = none, 1, 2; the solution demonstration is represented by outcome shown
bucketRaw String? nil yes focus, review, stretch from the plan
durationMs Int 0 no From task presentation to completion, excluding paused time
deviceID String "" no Installation ID (7.9)

7.5.8 SessionRecord #

One per session. Section 15 defines when a session starts and ends and how active time is counted.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes
profile ChildProfile? nil yes
deviceID String "" no
localDate String "" no Local date of startedAt
startedAt Date .distantPast no
lastActivityAt Date .distantPast no Updated at each flush (7.12.2); used to close sessions left open by a crash
endedAt Date? nil yes nil while open
endReasonRaw String? nil yes SessionEndReason
activeSeconds Int 0 no Counted active time (Section 15 rules)
tasksCompleted Int 0 no
roundsCompleted Int 0 no
starsEarned Int 0 no Sum of positive ledger amounts during the session
breakNudgeShown Bool false no The break nudge (Section 15) was shown in this session
createdAt Date .distantPast no
updatedAt Date .distantPast no

At most one open session (endedAt == nil) per profile and device. Opening a new session closes any other open session of the same profile on this device with reason terminated and endedAt = lastActivityAt.

7.5.9 DailyUsage #

One row per profile x local date x device. Decision: rows are partitioned by deviceID so that in V1.1 two devices never write the same record (which last-writer-wins sync would otherwise overwrite). The day's total is the sum over all rows of that profile and local date.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes Logical key part 1
profile ChildProfile? nil yes
localDate String "" no Logical key part 2
deviceID String "" no Logical key part 3
activeSeconds Int 0 no Foreground child-mode active seconds on this device for that day (Section 15 counting rules). Monotonically increasing
bonusSeconds Int 0 no Extra time granted by the parent through the gate on this device (+600 per grant, Section 15)
limitMinutes Int 0 no The daily limit in force for this date (0 = off), updated whenever it changes that day; read by the parent time chart (Section 16.6.5)
warningAllowanceSeconds Int? nil yes The allowance value for which the 2-minute warning was already spoken (Section 15.7.2); nil = not yet spoken
limitReachedAt Date? nil yes First time the limit was reached today on this device
createdAt Date .distantPast no
updatedAt Date .distantPast no

Effective limit for a profile and day = dailyLimitMinutes x 60 + sum(bonusSeconds); used = sum(activeSeconds), both summed over all device rows of that date (Section 15 owns enforcement). localDate of a DailyUsage row is always the trusted local date (Section 15.12), not the raw device date.

7.5.10 StarLedgerEntry #

Append-only. Never updated. Deleted only when the whole profile is deleted or all its progress is reset (7.18). Amounts are owned by Section 14.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes
profile ChildProfile? nil yes
amount Int 0 no Positive for earning (taskSolved +1, roundCompleted +2, abenteuerCompleted +5), negative for spending (decorationPurchased = minus the price)
reasonRaw String "" no StarReason
sourceID UUID? nil yes The event that caused the entry: TaskAttempt.id, RoundPlan.id, AbenteuerRecord.id or OwnedDecoration.id. Idempotency key together with profileID and reasonRaw
createdAt Date .distantPast no

Idempotency: RewardRepository.appendLedgerEntry first checks whether an entry with the same profileID, reasonRaw and sourceID exists; if so it does nothing and returns the existing entry. A retried save can therefore never double-award.

7.5.11 OwnedDecoration #

One row per purchase. Append-only (never deleted except by profile deletion or "reset all", 7.18). Whether an item can be bought more than once is a catalog rule owned by Section 14; the model supports both.

Property Type Default Opt. Notes
id UUID UUID() no Also the sourceID of the purchase ledger entry
profileID UUID? nil yes
profile ChildProfile? nil yes
catalogItemID String "" no ID from decorations.json (Section 8). If the ID no longer exists in content, the item is kept but hidden in the garden and inventory
pricePaid Int 0 no Stars paid (positive number), equal to the catalog price at purchase time
purchasedAt Date .distantPast no
ledgerEntryID UUID? nil yes The matching StarLedgerEntry.id
placement PlacedDecoration? nil yes to-one, cascade, inverse PlacedDecoration.ownedDecoration. nil = in inventory
createdAt Date .distantPast no

Rationale for separating ownership from placement: ownership is written once and ties to the ledger; placement changes often (drag, move, return to inventory). Keeping them in separate records means a placement change never rewrites the purchase record, which keeps V1.1 sync conflicts confined to placement.

7.5.12 PlacedDecoration #

Exists only while the owned decoration stands in the garden. Returning an item to the inventory deletes this record.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes Denormalized for slot queries; this entity has no profile relationship (cascade comes through OwnedDecoration)
ownedDecoration OwnedDecoration? nil yes inverse of OwnedDecoration.placement
slotX Int 0 no Column 0-5 of the 6 x 4 garden grid (Section 14)
slotY Int 0 no Row 0-3
placedAt Date .distantPast no Last time the item was put on this slot
createdAt Date .distantPast no
updatedAt Date .distantPast no

Constraints (enforced by RewardRepository, repaired by the integrity check): at most one placement per slot per profile; slot within 0-5 x 0-3; at most one placement per owned decoration. Moving an item updates slotX, slotY, placedAt, updatedAt of the same record. Items larger than one slot, if Section 14 defines any, are anchored at slotX, slotY; footprint rules are Section 14's.

7.5.13 FriendState #

One per befriended number. Created once, never deleted except by profile deletion or "reset all" (7.18). A friend is never lost because scores drop or the subscription lapses (Section 14).

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes Logical key part 1
profile ChildProfile? nil yes
number Int 0 no Logical key part 2; 1-20
befriendedAt Date .distantPast no
celebrationShown Bool false no The befriending celebration (S-27) has been shown. If the app is closed before it is shown, it is shown at the next suitable moment (Section 14)
createdAt Date .distantPast no

7.5.14 StickerAward #

One per sticker in the album. Duplicates are impossible by rule (Section 14) and repaired by the dedup pass.

Property Type Default Opt. Notes
id UUID UUID() no
profileID UUID? nil yes Logical key part 1
profile ChildProfile? nil yes
stickerID String "" no Logical key part 2; ID from stickers.json (Section 8)
sourceRaw String "" no dotPicture, friend, milestone
awardedAt Date .distantPast no
isNew Bool true no Shows a "new" marker until the child opens the album page containing it
createdAt Date .distantPast no

Completed Punkt-zu-Punkt pictures are identified by their dotPicture stickers; no separate "completed pictures" entity exists.

7.5.15 AbenteuerRecord #

One per profile and local date on which an Abenteuer was started. Section 15 owns the flow; Section 14 the bonus.

Property Type Default Opt. Notes
id UUID UUID() no Also the sourceID of the Abenteuer bonus ledger entry and abenteuerID in attempts
profileID UUID? nil yes Logical key part 1
profile ChildProfile? nil yes
localDate String "" no Logical key part 2
plannedGameIDs String "" no CSV of the 3 planned GameID raw values in order (e.g. "hoer_hin,blitzblick,memory")
roundsCompleted Int 0 no 0-3
startedAt Date .distantPast no
completedAt Date? nil yes
isCompleted Bool false no All rounds done and the soft end reached
bonusAwarded Bool false no The +5 bonus ledger entry was written
createdAt Date .distantPast no
updatedAt Date .distantPast no

Resume of an unfinished Abenteuer is derived from this record; no separate state is stored. Today's record with isCompleted == false is the Abenteuer in progress: its games are plannedGameIDs and the next round to play is index roundsCompleted (Section 9.16.6). Tapping the Abenteuer again on the same local day resumes at that round; on a new local day the unfinished record is left as it is and a new record is created for the new date. The engine's lastComposedGames is plannedGameIDs of the newest record, and lastCompletedFor is the newest localDate with isCompleted == true (Section 9.19).

7.6 Relationships and Delete Rules #

Parent -> child Parent property Child property Parent delete rule Child-side rule
ChildProfile -> ProfileSettings settings (to-one) profile cascade nullify
ChildProfile -> LearningState learningState (to-one) profile cascade nullify
ChildProfile -> MasteryRecord mastery profile cascade nullify
ChildProfile -> GameNumberStat gameNumberStats profile cascade nullify
ChildProfile -> GameProgress gameProgress profile cascade nullify
ChildProfile -> TaskAttempt attempts profile cascade nullify
ChildProfile -> SessionRecord sessions profile cascade nullify
ChildProfile -> DailyUsage dailyUsage profile cascade nullify
ChildProfile -> StarLedgerEntry starLedger profile cascade nullify
ChildProfile -> OwnedDecoration ownedDecorations profile cascade nullify
OwnedDecoration -> PlacedDecoration placement (to-one) ownedDecoration cascade nullify
ChildProfile -> FriendState friends profile cascade nullify
ChildProfile -> StickerAward stickers profile cascade nullify
ChildProfile -> AbenteuerRecord abenteuer profile cascade nullify

Cross-entity references that are not ownership (TaskAttempt.sessionID, .roundID, .abenteuerID, StarLedgerEntry.sourceID, OwnedDecoration.ledgerEntryID) are logical UUID values, not relationships. They may dangle after pruning (for example, a taskSolved ledger entry whose TaskAttempt was pruned after 90 days is normal); code treats a missing target as "no longer available", never as an error.

7.7 Derived Values and Caches #

Value Source of truth Cache Recompute
Star balance sum(StarLedgerEntry.amount) for the profile ChildProfile.cachedStarBalance Updated in the same unit of work as every ledger append. Recomputed from the ledger during deferred launch maintenance (7.13.1); if the cache differs, it is corrected and a notice log line records the difference. Every decoration purchase recomputes the sum from the ledger inside the purchase unit of work and uses that value, never the cache, to decide affordability
Displayed balance max(0, balance) none A negative sum can only arise from V1.1 offline purchases on two devices (7.20.3); it is shown as 0 and purchases are blocked until the sum covers the price
Lifetime stars earned sum(amount where amount > 0) ChildProfile.cachedLifetimeStars Same as balance
Mastery band MasteryRecord.score, .attempts per Section 9 thresholds none Computed on read
Friends count count(FriendState) none fetchCount
Time used today sum(DailyUsage.activeSeconds) over device rows of today in-memory counter in the session ticker (Section 15) Loaded at session start and after day rollover
Abenteuer done today AbenteuerRecord for today with isCompleted == true none Query

7.8 What Is Not Stored in SwiftData #

Data Where Owner
Subscription entitlement and its cache (last known status, expiration) UserDefaults, key zk.entitlement.cacheV1 Section 17. Rationale: entitlement belongs to the Apple ID and the device's StoreKit state, not to a child; it must be readable before the store opens; StoreKit remains the source of truth
Device toggles (voice, music, SFX, haptics, motion, pencil-only; V1.1 iCloud sync) UserDefaults, keys zk.device.* 7.9
Parental-gate cooldown state UserDefaults keys zk.gate.*; the grant itself is memory only Section 16
Trusted-clock anchor, recovery and launch flags, review-request state UserDefaults, keys in 7.9.2 Sections 15.12, 7.12.3, 16.14
Content (numbers, games, prompts, catalogs) read-only JSON in the app bundle Section 8
Audio and images app bundle Sections 20, 21
Logs unified logging, on device only Section 6.9

7.9 UserDefaults Keys and DeviceSettingsStore #

7.9.1 Why device settings are not in SwiftData #

Decision: voice, music, SFX, haptics, motion and pencil-only toggles are stored per device in UserDefaults.standard, not in SwiftData, for three reasons:

  1. They describe the device's situation, not the child: a parent may mute music on the family iPad in the living room but keep it on the phone used in the car; headphones, room and volume differ per device.
  2. They apply to all profiles on the device (Section 16 "Geräteeinstellungen").
  3. When V1.1 enables iCloud sync, only the SwiftData store is mirrored. UserDefaults.standard is never synced (the app does not use NSUbiquitousKeyValueStore), so muting one device can never silently mute another.

7.9.2 Keys #

All keys are registered with defaults via UserDefaults.standard.register(defaults:) at bootstrap (Section 5.4.4 step 3). This table is the complete registry: every key the app writes is listed here with its reset behaviour, and other sections use these exact names. Keys have the form zk.<area>.<name>. There is no "first launch completed" flag; routing uses the profile count (Section 5.4.4 step 10).

Key Type Default Meaning Reset by "Alle Daten löschen"
zk.device.installationID String (UUID) generated once at first launch if missing Opaque per-installation ID used only to partition DailyUsage rows and tag attempts and sessions (V1.1 sync). Never sent anywhere, never shown. A reinstall creates a new ID no
zk.device.voiceEnabled Bool true "Sprache" toggle (Section 20 behavior) yes
zk.device.musicEnabled Bool true "Musik" toggle yes
zk.device.sfxEnabled Bool true "Soundeffekte" toggle yes
zk.device.hapticsEnabled Bool true Haptics toggle yes
zk.device.motionEnabled Bool true Schüttelbox may use the accelerometer; false = button only (Section 12) yes
zk.device.pencilOnly Bool false Nachspuren accepts only Apple Pencil input on iPad (Section 12, Section 16) yes
zk.maintenance.lastPruneLocalDate String "" Local date of the last pruning run (7.13) yes
zk.maintenance.lastDedupAt Double 0 Unix time of the last dedup pass (7.20) yes
zk.device.iCloudSyncEnabled Bool false V1.1 only: the parent toggle "Mit iCloud synchronisieren" (7.15.2). Never read in V1 yes
zk.clock.highWaterMark Double 0 Largest clock.now observed at any save point (Unix time). Diagnostic value shown in the developer menu panel "Uhr verschieben" (Section 5.10.2); the time-limit day is protected by the trusted day clock, not by this value no
zk.clock.trustedAnchor Data (JSON of TrustedDayClock.Anchor) absent Anchor of the trusted day clock (Section 15.12): wall time, continuous time, drift start no
zk.recovery.pendingNotice Bool false A store recovery happened; the parent area shows a one-time notice (7.12.3) yes
zk.launch.inProgress Bool false Set at process start, cleared 10 s after the first child or parent screen is interactive (crash-loop detection, 7.12.3) yes
zk.launch.crashCount Int 0 Consecutive launches that did not clear zk.launch.inProgress; at 2 the launch runs in safe mode (7.12.3) yes
zk.entitlement.cacheV1 Data (JSON of EntitlementCache) absent Last known entitlement and expiration for offline start (Section 17.7.1). Contains no transaction identifiers no
zk.gate.consecutiveWrong Int 0 Wrong gate answers in a row (Section 16.2.4) yes
zk.gate.cooldownUntil Double 0 Unix time until which the gate is in cooldown (Section 16.2.4) yes
zk.app.lastActiveProfileID String (UUID) "" Profile last active in child mode, written by the session manager (Section 15.14); a random UUID, never shown yes
zk.review.lastRequest Data (JSON: version, date, sessions counted) absent Rating-request bookkeeping (Section 16.14) yes
zk.parent.levelHintDismissed.<profileID> Double absent Unix time when the parent dismissed the level-up hint for that profile; the hint stays hidden for 30 days (Section 15.6.3). Also removed when that profile is deleted (7.18.3) yes
zk.debug.* reserved, Debug builds only Developer menu (Section 5.10) yes

7.9.3 DeviceSettingsStore #

// ZKPersistence/Settings/DeviceSettingsStore.swift
@MainActor
@Observable
public final class DeviceSettingsStore {
    public init(defaults: UserDefaults = .standard)

    public var voiceEnabled: Bool { get set }
    public var musicEnabled: Bool { get set }
    public var sfxEnabled: Bool { get set }
    public var hapticsEnabled: Bool { get set }
    public var motionEnabled: Bool { get set }
    public var pencilOnly: Bool { get set }
    /// V1.1: "Mit iCloud synchronisieren" (7.15.2). Takes effect at the next launch.
    public var iCloudSyncEnabled: Bool { get set }

    /// Hardware capabilities, not persisted. Written once by the app target at bootstrap
    /// (Section 5.4.4 step 3) from CHHapticEngine and CMMotionManager; ZKParentArea reads them
    /// to decide which device settings to show (Section 16.8) without importing those frameworks.
    public internal(set) var supportsHaptics: Bool       // default true until set
    public internal(set) var hasAccelerometer: Bool      // default true until set
    public func setCapabilities(supportsHaptics: Bool, hasAccelerometer: Bool)

    /// Stable per-installation ID; created on first access if missing.
    public var installationID: String { get }

    /// Trusted-clock anchor (Section 15.12), stored as JSON under zk.clock.trustedAnchor.
    public var trustedAnchor: TrustedDayClock.Anchor? { get set }

    /// Resets all zk.device.* toggles to defaults (installationID is kept).
    public func resetToDefaults()
}

Each setter writes UserDefaults immediately and updates the observable property. Only the app target and ZKParentArea read the store (through @Environment(\.deviceSettings) or constructor injection). Other modules receive values: the app passes AudioSettings to AudioService.update(settings:) (Section 20.5) whenever voice, music or SFX change; the game container builds GameSettingsSnapshot for the games (Section 10.3.4); and the root view sets the \.hapticsEnabled environment value declared in ZKDesignSystem (Section 5.4.1).

7.10 Storage Controller and Repositories #

7.10.1 PersistenceController #

// ZKPersistence/Controller/PersistenceController.swift
public enum StoreHealth: Sendable, Equatable {
    case normal
    case recovered(at: Date)          // store was unreadable and replaced (7.12.3)
    case inMemoryFallback             // nothing is persisted this run
    case saveFailing(consecutive: Int, outOfSpace: Bool)   // 7.12.2
    case safeMode                     // crash-loop safe mode for this launch (7.12.3)
}

@MainActor
@Observable
public final class PersistenceController {
    public let container: ModelContainer
    public var context: ModelContext { container.mainContext }

    /// Increments after every successful save. Screen models observe it to refresh.
    public private(set) var changeToken: Int
    public private(set) var health: StoreHealth

    /// Opens (or recovers) the on-disk store. Never throws; see 7.12.3.
    public static func open(storeURL: URL, clock: any AppClock) -> PersistenceController
    /// In-memory store for tests, previews and UI tests.
    public static func inMemory(clock: any AppClock) -> PersistenceController

    /// Default on-disk location: Application Support/ZahlenketteData/Zahlenkette.store
    public static var defaultStoreURL: URL { get }

    /// Commits the current unit of work if there are changes. Updates zk.clock.highWaterMark.
    public func save() throws(PersistenceError)
    /// Discards uncommitted changes of the current unit of work.
    public func rollback()
}

7.10.2 Write value types #

// ZKPersistence/Writes/
public struct ProfileDraft: Sendable, Equatable {
    public var nickname: String
    public var avatarID: String
    public var colorThemeID: String
    public var level: Level
    public var dailyLimitMinutes: Int          // default 20
}

public struct ProfileChange: Sendable, Equatable {
    public var nickname: String?
    public var avatarID: String?
    public var colorThemeID: String?
    public var sortIndex: Int?
}

public struct SettingsChange: Sendable, Equatable {
    public var level: Level?
    public var rangeOverride: RangeOverride?
    public var difficultyCap: DifficultyCap?
    public var dailyLimitMinutes: Int?
}

public struct MasteryValues: Sendable, Equatable {
    public var skill: Skill
    public var number: Int
    public var score: Double
    public var attempts: Int
    public var firstTryCorrect: Int
    public var afterHintCount: Int
    public var shownCount: Int
    public var lastOutcome: TaskOutcome
    public var lastPracticedAt: Date
    public var nextDueAt: Date
    public var intervalDays: Int
    public var boxIndex: Int
    public var firstMasteredAt: Date?
}

public struct GameProgressValues: Sendable, Equatable {
    public var gameID: GameID
    public var currentStep: DifficultyStep
    public var highestStep: DifficultyStep
    public var consecutiveFirstTry: Int
    public var consecutiveNonFirstTry: Int
    public var consecutiveShown: Int
    public var gameStateJSON: String?          // nil = unchanged; "" clears; at most 8 KB (7.5.6)
}

public struct LearningStateValues: Sendable, Equatable {
    public var rangeStage: RangeStage?
    public var rangeStageEnteredAt: Date?
    public var sessionsInStage: Int
    public var widenedFrom: RangeStage?        // stored as widenedFromRaw, nil = 0
    public var lastWidenedAt: Date?
    public var probationAttempts: Int
    public var probationFirstTry: Int
    public var probationSessions: Int
    public var lastNarrowedAt: Date?
    public var stretchShare: Double
    public var comfortMode: Bool
    public var recentFirstTry: [Bool]          // stored as recentFirstTryRaw, oldest first, max 20
    public var reentryRoundsRemaining: Int
    public var totalSessions: Int
    public var lastCountedSessionEndedAt: Date?
}

public struct TaskResultWrite: Sendable {
    public var attemptID: UUID
    public var profileID: UUID
    public var completedAt: Date
    public var localDate: LocalDate
    public var sessionID: UUID?
    public var roundID: UUID
    public var abenteuerID: UUID?
    public var gameID: GameID
    public var step: DifficultyStep
    public var level: Level
    public var range: RangeStage
    public var skill: Skill
    public var secondarySkill: Skill?
    public var number: Int
    public var outcome: TaskOutcome
    public var wrongAnswers: Int
    public var hintLevelReached: Int
    public var bucket: TaskBucket?
    public var durationMs: Int
    public var deviceID: String
    public var mastery: [MasteryValues]                 // primary and, if credited, secondary
    public var gameProgress: GameProgressValues
    public var learningState: LearningStateValues?      // nil = unchanged
}

7.10.3 Repository protocols #

All repositories are @MainActor, operate on PersistenceController.context, and never call save() themselves (the caller commits the unit of work), except where a method's documentation says it is self-committing (data management and maintenance operations).

@MainActor
public protocol ProfileRepository: AnyObject, Sendable {
    /// Visible profiles (isPendingDeletion == false), ordered by sortIndex, createdAt, id.
    func allProfiles() throws(PersistenceError) -> [ChildProfile]
    func profile(id: UUID) throws(PersistenceError) -> ChildProfile?
    func visibleProfileCount() throws(PersistenceError) -> Int
    /// Creates profile + ProfileSettings + LearningState. Throws .profileLimitReached at 5.
    func createProfile(_ draft: ProfileDraft) throws(PersistenceError) -> ChildProfile
    func updateProfile(id: UUID, _ change: ProfileChange) throws(PersistenceError)
    func settings(profileID: UUID) throws(PersistenceError) -> ProfileSettings
    func updateSettings(profileID: UUID, _ change: SettingsChange) throws(PersistenceError)
}

@MainActor
public protocol ProgressRepository: AnyObject, Sendable {
    func masteryRecords(profileID: UUID) throws(PersistenceError) -> [MasteryRecord]
    func masteryRecord(profileID: UUID, skill: Skill, number: Int) throws(PersistenceError) -> MasteryRecord?
    func gameNumberStats(profileID: UUID) throws(PersistenceError) -> [GameNumberStat]
    /// Returns the existing record or inserts one with defaults (committed with the next save).
    func gameProgress(profileID: UUID, gameID: GameID) throws(PersistenceError) -> GameProgress
    func allGameProgress(profileID: UUID) throws(PersistenceError) -> [GameProgress]
    /// Returns the existing record or inserts one with defaults.
    func learningState(profileID: UUID) throws(PersistenceError) -> LearningState
    func updateLearningState(profileID: UUID, _ values: LearningStateValues) throws(PersistenceError)
    /// Most recent attempts first.
    func recentAttempts(profileID: UUID, limit: Int) throws(PersistenceError) -> [TaskAttempt]
    func attempts(profileID: UUID, since: Date) throws(PersistenceError) -> [TaskAttempt]
    /// Inserts the TaskAttempt, upserts MasteryRecords, GameNumberStat, GameProgress,
    /// LearningState (if given), increments GameProgress.tasksCompleted, sets
    /// ChildProfile.lastPlayedAt. Idempotent on attemptID. Does not save.
    func record(_ write: TaskResultWrite) throws(PersistenceError)
    /// Increments GameProgress.roundsCompleted and sets lastPlayedAt. Does not save.
    func recordRoundCompleted(profileID: UUID, gameID: GameID, at: Date) throws(PersistenceError)
}

@MainActor
public protocol RewardRepository: AnyObject, Sendable {
    func ledger(profileID: UUID) throws(PersistenceError) -> [StarLedgerEntry]          // oldest first
    /// Sum of all amounts, computed from the ledger (not the cache).
    func computedBalance(profileID: UUID) throws(PersistenceError) -> Int
    func computedLifetimeStars(profileID: UUID) throws(PersistenceError) -> Int
    /// Idempotent on (profileID, reason, sourceID). Updates both caches. Does not save.
    @discardableResult
    func appendLedgerEntry(profileID: UUID, amount: Int, reason: StarReason,
                           sourceID: UUID?, at: Date) throws(PersistenceError) -> StarLedgerEntry
    func ownedDecorations(profileID: UUID) throws(PersistenceError) -> [OwnedDecoration]
    /// Inserts OwnedDecoration + negative ledger entry. Caller has checked affordability. Does not save.
    func insertPurchase(profileID: UUID, catalogItemID: String, price: Int,
                        at: Date) throws(PersistenceError) -> OwnedDecoration
    func placements(profileID: UUID) throws(PersistenceError) -> [PlacedDecoration]
    /// Creates or moves the placement. Throws .invalidValue if the slot is out of range or occupied
    /// by another item. Does not save.
    func place(ownedDecorationID: UUID, slotX: Int, slotY: Int, at: Date) throws(PersistenceError)
    /// Deletes the placement (item returns to inventory). Does not save.
    func returnToInventory(ownedDecorationID: UUID) throws(PersistenceError)
    func friends(profileID: UUID) throws(PersistenceError) -> [FriendState]
    /// No-op if the number is already befriended. Does not save.
    @discardableResult
    func befriend(profileID: UUID, number: Int, at: Date) throws(PersistenceError) -> FriendState
    func markCelebrationShown(friendID: UUID) throws(PersistenceError)
    func stickers(profileID: UUID) throws(PersistenceError) -> [StickerAward]
    /// No-op if the sticker is already awarded. Does not save.
    @discardableResult
    func awardSticker(profileID: UUID, stickerID: String, source: StickerSource,
                      at: Date) throws(PersistenceError) -> StickerAward
    func markStickersSeen(ids: [UUID]) throws(PersistenceError)
}

@MainActor
public protocol UsageRepository: AnyObject, Sendable {
    /// Closes other open sessions of the profile on this device (reason terminated). Does not save.
    func openSession(profileID: UUID, deviceID: String, at: Date,
                     localDate: LocalDate) throws(PersistenceError) -> SessionRecord
    func updateSession(id: UUID, addActiveSeconds: Int, tasks: Int, rounds: Int,
                       stars: Int, at: Date) throws(PersistenceError)
    func setBreakNudgeShown(sessionID: UUID) throws(PersistenceError)
    func closeSession(id: UUID, reason: SessionEndReason, at: Date) throws(PersistenceError)
    func openSessions(deviceID: String) throws(PersistenceError) -> [SessionRecord]
    func sessions(profileID: UUID, since: Date) throws(PersistenceError) -> [SessionRecord]
    /// Row for (profile, date, device); inserted if missing.
    func dailyUsage(profileID: UUID, localDate: LocalDate, deviceID: String) throws(PersistenceError) -> DailyUsage
    func addActiveSeconds(_ seconds: Int, profileID: UUID, localDate: LocalDate,
                          deviceID: String, at: Date) throws(PersistenceError)
    func grantBonus(seconds: Int, profileID: UUID, localDate: LocalDate,
                    deviceID: String, at: Date) throws(PersistenceError)
    /// Records the allowance for which the 2-minute warning was spoken (Section 15.7.2).
    func setWarningAllowance(_ seconds: Int, profileID: UUID, localDate: LocalDate,
                             deviceID: String) throws(PersistenceError)
    /// Records the limit in force for that date (0 = off), whenever it changes.
    func setLimitMinutes(_ minutes: Int, profileID: UUID, localDate: LocalDate,
                         deviceID: String) throws(PersistenceError)
    func setLimitReached(profileID: UUID, localDate: LocalDate, deviceID: String, at: Date) throws(PersistenceError)
    /// Totals over all device rows, one element per local date in [from, to], zero-filled.
    func usageTotals(profileID: UUID, from: LocalDate, to: LocalDate) throws(PersistenceError) -> [DailyUsageTotal]
    func abenteuer(profileID: UUID, localDate: LocalDate) throws(PersistenceError) -> AbenteuerRecord?
    func startAbenteuer(profileID: UUID, localDate: LocalDate, plannedGames: [GameID],
                        at: Date) throws(PersistenceError) -> AbenteuerRecord
    func updateAbenteuer(id: UUID, roundsCompleted: Int, completedAt: Date?,
                         bonusAwarded: Bool, at: Date) throws(PersistenceError)
}

public struct DailyUsageTotal: Sendable, Equatable {
    public var date: LocalDate
    public var activeSeconds: Int
    public var bonusSeconds: Int
    public var limitMinutes: Int               // max over device rows
}

@MainActor
public protocol DataManagementService: AnyObject, Sendable {
    /// Builds the export document (7.17) for one profile or all; returns a temporary file URL.
    func exportJSON(profileIDs: [UUID]?, appVersion: String, appBuild: String,
                    contentVersion: Int, contentVersionName: String) async throws(PersistenceError) -> URL
    /// Self-committing. Scope in 7.18.1.
    func resetLearningProgress(profileID: UUID) async throws(PersistenceError)
    /// Self-committing. Scope in 7.18.2.
    func resetAllProgress(profileID: UUID) async throws(PersistenceError)
    /// Self-committing, chunked. 7.18.3.
    func deleteProfile(id: UUID) async throws(PersistenceError)
    /// Self-committing, chunked. 7.18.4.
    func deleteAllData(deviceSettings: DeviceSettingsStore) async throws(PersistenceError)
    /// True while a store recovery folder exists (7.12.3).
    var hasRecoveryData: Bool { get }
    /// Deletes the recovery folder ("Wiederherstellungsdaten löschen", Section 16.10.2).
    func deleteRecoveryData() throws(PersistenceError)
}

@MainActor
public protocol MaintenanceService: AnyObject, Sendable {
    /// 7.19. Runs synchronously at bootstrap. Self-committing.
    func runLaunchIntegrityChecks(now: Date) -> IntegrityReport
    /// 7.13. Runs at most once per local day unless forced. Self-committing, chunked.
    func runDeferredMaintenance(force: Bool) async -> MaintenanceReport
    /// 7.20. Self-committing.
    func runDedupPass() -> DedupReport
}

7.11 Query Patterns #

Rules:

  1. Every per-profile query filters on the scalar profileID, never by traversing profile?.id. The captured value is typed UUID? to match the property type.
  2. Sort explicitly; never rely on fetch order or relationship array order.
  3. Use fetchCount for counts and propertiesToFetch for sums over large tables.
  4. No string range comparisons in predicates. Local-date filtering is done in memory after fetching by profileID (the tables involved hold at most a few hundred rows per profile) or via Date fields.
  5. No @Query in views (Section 6.6).
  6. Fetch errors are mapped to PersistenceError.fetchFailed; callers treat a failed read as "no data" and log.
// Mastery grid for one profile (at most 160 rows)
func masteryRecords(profileID: UUID) throws(PersistenceError) -> [MasteryRecord] {
    let pid: UUID? = profileID
    let descriptor = FetchDescriptor<MasteryRecord>(
        predicate: #Predicate { $0.profileID == pid },
        sortBy: [SortDescriptor(\.number), SortDescriptor(\.skillRaw)]
    )
    return try fetch(descriptor)
}

// Single logical-key lookup (upsert path)
func masteryRecord(profileID: UUID, skill: Skill, number: Int) throws(PersistenceError) -> MasteryRecord? {
    let pid: UUID? = profileID
    let raw = skill.rawValue
    var descriptor = FetchDescriptor<MasteryRecord>(
        predicate: #Predicate { $0.profileID == pid && $0.skillRaw == raw && $0.number == number },
        sortBy: [SortDescriptor(\.createdAt)]
    )
    descriptor.fetchLimit = 1        // duplicates (V1.1) are merged by the dedup pass; oldest wins meanwhile
    return try fetch(descriptor).first
}

// Star balance from the ledger (partial fetch of one column)
func computedBalance(profileID: UUID) throws(PersistenceError) -> Int {
    let pid: UUID? = profileID
    var descriptor = FetchDescriptor<StarLedgerEntry>(predicate: #Predicate { $0.profileID == pid })
    descriptor.propertiesToFetch = [\.amount]
    return try fetch(descriptor).reduce(0) { $0 + $1.amount }
}

// Idempotency check before appending a ledger entry
func existingEntry(profileID: UUID, reason: StarReason, sourceID: UUID) throws(PersistenceError) -> StarLedgerEntry? {
    let pid: UUID? = profileID
    let raw = reason.rawValue
    let sid: UUID? = sourceID
    var descriptor = FetchDescriptor<StarLedgerEntry>(
        predicate: #Predicate { $0.profileID == pid && $0.reasonRaw == raw && $0.sourceID == sid }
    )
    descriptor.fetchLimit = 1
    return try fetch(descriptor).first
}

// Recent attempts (success-rate window, "same number not twice in a row")
func recentAttempts(profileID: UUID, limit: Int) throws(PersistenceError) -> [TaskAttempt] {
    let pid: UUID? = profileID
    var descriptor = FetchDescriptor<TaskAttempt>(
        predicate: #Predicate { $0.profileID == pid },
        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
    )
    descriptor.fetchLimit = limit
    return try fetch(descriptor)
}

// Profile limit
func visibleProfileCount() throws(PersistenceError) -> Int {
    let descriptor = FetchDescriptor<ChildProfile>(predicate: #Predicate { $0.isPendingDeletion == false })
    return try count(descriptor)
}

// Pruning candidates (chunked)
func attemptsOlderThan(_ cutoff: Date, limit: Int) throws(PersistenceError) -> [TaskAttempt] {
    var descriptor = FetchDescriptor<TaskAttempt>(
        predicate: #Predicate { $0.createdAt < cutoff },
        sortBy: [SortDescriptor(\.createdAt)]
    )
    descriptor.fetchLimit = limit
    return try fetch(descriptor)
}

Verification step: the predicates above compare an optional stored property with a captured optional value. If a predicate fails to compile or fails at runtime on the minimum supported OS (Section 5.1), rewrite it with an explicit optional pattern ($0.profileID.flatMap { $0 == profileID } ?? false) and cover it with a repository test that runs on the minimum-OS simulator runtime (Section 5.1.1).

The engine snapshot for a profile (Section 9) is built by the app's EngineBridge from: all MasteryRecords, all GameNumberStats, all GameProgress, the LearningState, ProfileSettings, the last 40 TaskAttempts, the AbenteuerRecords of the last 7 local dates (the engine's AbenteuerHistory, 7.5.15), and the entitled game set. For one profile this is at most about 450 small objects and is fetched in well under the planning budget of Section 24.

7.12 Container Setup, Save Points and Failure Handling #

7.12.1 Container setup (V1) #

// ZKPersistence/Schema/SchemaV1.swift
public enum SchemaV1: VersionedSchema {
    public static let versionIdentifier = Schema.Version(1, 0, 0)
    public static var models: [any PersistentModel.Type] {
        [ChildProfile.self, ProfileSettings.self, LearningState.self, MasteryRecord.self,
         GameNumberStat.self, GameProgress.self, TaskAttempt.self, SessionRecord.self,
         DailyUsage.self, StarLedgerEntry.self, OwnedDecoration.self, PlacedDecoration.self,
         FriendState.self, StickerAward.self, AbenteuerRecord.self]
    }
}

// Current-version aliases used by all code outside Schema/
public typealias ChildProfile = SchemaV1.ChildProfile
public typealias ProfileSettings = SchemaV1.ProfileSettings
public typealias LearningState = SchemaV1.LearningState
public typealias MasteryRecord = SchemaV1.MasteryRecord
public typealias GameNumberStat = SchemaV1.GameNumberStat
public typealias GameProgress = SchemaV1.GameProgress
public typealias TaskAttempt = SchemaV1.TaskAttempt
public typealias SessionRecord = SchemaV1.SessionRecord
public typealias DailyUsage = SchemaV1.DailyUsage
public typealias StarLedgerEntry = SchemaV1.StarLedgerEntry
public typealias OwnedDecoration = SchemaV1.OwnedDecoration
public typealias PlacedDecoration = SchemaV1.PlacedDecoration
public typealias FriendState = SchemaV1.FriendState
public typealias StickerAward = SchemaV1.StickerAward
public typealias AbenteuerRecord = SchemaV1.AbenteuerRecord

// ZKPersistence/Schema/MigrationPlan.swift
public enum ZKMigrationPlan: SchemaMigrationPlan {
    public static var schemas: [any VersionedSchema.Type] { [SchemaV1.self] }
    public static var stages: [MigrationStage] { [] }
}

// PersistenceController.open (core of it)
let schema = Schema(versionedSchema: SchemaV1.self)
let configuration = ModelConfiguration(
    "Zahlenkette",
    schema: schema,
    url: storeURL,                       // Application Support/ZahlenketteData/Zahlenkette.store
    allowsSave: true,
    cloudKitDatabase: .none              // V1: explicitly local, even if an iCloud entitlement existed
)
let container = try ModelContainer(for: schema, migrationPlan: ZKMigrationPlan.self,
                                   configurations: [configuration])
container.mainContext.autosaveEnabled = false
  • The directory Application Support/ZahlenketteData/ is created with FileManager.createDirectory(withIntermediateDirectories: true) before opening. Its name is internal and independent of the app's display name.
  • The store is part of the device backup (iCloud Backup or computer backup) by default; the app does not exclude it. File protection class: Section 23.
  • Autosave is disabled so that a unit of work is committed atomically by one explicit save().
  • Strict-concurrency note: versionIdentifier is a static let to avoid mutable global state. Verification step: if the installed SDK declares the requirement in a way a let cannot satisfy, use a computed static var versionIdentifier: Schema.Version { Schema.Version(1, 0, 0) }.

7.12.2 Units of work and save points #

A unit of work is a group of repository calls followed by exactly one PersistenceController.save(). On any thrown error inside the group, the caller calls rollback() (Section 5.4.5). This table is the single list of save points; Sections 15 and 24 refer to it (Section 24's SP labels map to these rows in order).

Save point Unit of work contents Initiator
Task completed TaskAttempt, MasteryRecords, GameNumberStat, GameProgress (including gameStateJSON), LearningState, taskSolved ledger entry, cache update, new FriendStates, friend StickerAwards, session counters, ChildProfile.lastPlayedAt RoundResultCoordinator
Round completed roundCompleted ledger entry, GameProgress.roundsCompleted, milestone stickers, dot-picture sticker (Punkt zu Punkt, from the pictureCompleted event), session counters, Abenteuer progress (AbenteuerRecord.roundsCompleted) RoundResultCoordinator
Abenteuer started / completed AbenteuerRecord insert or update, abenteuerCompleted ledger entry App Abenteuer flow
Decoration purchased OwnedDecoration, negative ledger entry, caches RewardService
Decoration placed, moved, returned PlacedDecoration insert, update or delete Garden model
Parent change ProfileSettings or ChildProfile change, profile creation ZKParentArea models
Session opened / closed SessionRecord Session manager (Section 15)
Activity flush Pending active seconds into DailyUsage and SessionRecord, lastActivityAt Session ticker every 15 s of counted time in child mode (SessionTimingRules.usageFlushIntervalSeconds, Section 15.7.2), at every scene-phase change and at session end
Maintenance Integrity repairs, pruning chunks (500 objects per save), dedup merges MaintenanceService

Consequence (Section 24): if the app is terminated mid-round, at most the current unfinished task and up to 15 s of active-time accounting are lost; star awards are never partially written, because a ledger entry is committed in the same save as the task that earned it.

Save failures: save() maps the error to PersistenceError.saveFailed, classifies it (out of space = NSFileWriteOutOfSpaceError, or SQLite SQLITE_FULL (13) in the underlying error; everything else = other), sets health = .saveFailing(consecutive: n, outOfSpace:), and the caller always rolls back the unit of work. Decision: an out-of-space failure is rolled back like any other failure; keeping unsaved changes would mix several units of work into one later save and break their atomicity. Retries never loop: the next save point tries once. The child flow continues unchanged and never shows an error. Parent notices on the dashboard S-18 (final copy in Section 22):

Condition Notice
Out of space (shown from the first such failure) "Auf dem Gerät ist kein Speicher mehr frei. Neuer Fortschritt kann gerade nicht gespeichert werden. Bitte geben Sie Speicherplatz frei."
3 consecutive failures of another kind "Der Fortschritt konnte zuletzt nicht gespeichert werden. Bitte starten Sie die App neu. Hilft das nicht, schreiben Sie uns." with the support contact

Both notices disappear after the first successful save (health = .normal).

7.12.3 Store recovery #

This subsection is the single store-recovery procedure; Sections 23.7 and 24.9 refer to it. If ModelContainer creation fails (corrupt file, failed migration, disk error):

  1. Log a fault with the error description and retry opening once after 200 ms. If the retry succeeds, continue normally.
  2. Otherwise create Application Support/ZahlenketteData/Recovery/<yyyyMMdd-HHmmss>/ and move Zahlenkette.store, Zahlenkette.store-wal and Zahlenkette.store-shm into it. At most one recovery folder exists: a newer recovery replaces (deletes) the older folder. The remaining folder is deleted automatically 30 days after its creation (the age is read from the folder name, not from file timestamps; checked at launch). Until then it is kept so that a later app update or the developer's support advice can use it.
  3. Open a fresh, empty store at the normal URL. On success set health = .recovered(at:) and zk.recovery.pendingNotice = true; the app starts with the first-launch flow (no profiles). The parent area shows once: "Die gespeicherten Daten konnten nicht geöffnet werden. Das Gerät hat eine Sicherungskopie behalten." (final copy: Section 22), with the support contact. While the folder exists, the data screen S-23 offers "Wiederherstellungsdaten löschen" (Section 16.10.2), which calls DataManagementService.deleteRecoveryData().
  4. If the fresh store also fails (for example the disk is full), open an in-memory store, set health = .inMemoryFallback, and show a persistent parent-area notice that progress is not being saved. The child can still play. There is no dead-end screen.
  5. The recovery folder contains the data of every profile that existed. It is therefore also deleted by "Kind löschen" (7.18.3) and "Alle Daten löschen" (7.18.4), so a deleted child's data never outlives the deletion.

Crash-loop protection (runs before the store is opened, Section 5.4.4 step 3):

  1. At process start, if zk.launch.inProgress is still true, the previous launch did not complete: increment zk.launch.crashCount. Then set zk.launch.inProgress = true.
  2. When the counter has reached 2, this launch runs in safe mode (health = .safeMode unless a store problem sets another state): deferred maintenance and the other deferred launch jobs (Section 5.4.4 step 11, Section 24.6.1) are skipped. The launch integrity checks (7.19) run in both modes.
  3. 10 s after the first child or parent screen is interactive, set zk.launch.inProgress = false and zk.launch.crashCount = 0.

7.13 Retention, Pruning and Maintenance Scheduling #

7.13.1 Deferred maintenance #

MaintenanceService.runDeferredMaintenance runs 2 s after the first screen after launch appears (Section 5.4.4 step 11), and again after a day rollover (Section 5.7.3). It does, in order:

  1. Star-balance and lifetime-star recompute for every profile (7.7).
  2. Pruning (7.13.2) if zk.maintenance.lastPruneLocalDate != clock.today.string or force == true.
  3. Dedup pass (7.20) if at least 12 hours have passed since zk.maintenance.lastDedupAt, or force == true, or (V1.1) a remote-change notification was received since the last pass.
  4. Temporary export files older than 24 hours in tmp/Export/ are deleted.

Each step saves in chunks of at most 500 changed objects and yields (await Task.yield()) between chunks so the UI stays responsive. If the app is backgrounded during maintenance, the current chunk is saved and the rest is skipped until the next run.

7.13.2 Retention table #

Entity Kept for Cutoff rule
TaskAttempt 90 days Delete rows with createdAt < cutoff90
SessionRecord 90 days Delete closed rows with startedAt < cutoff90; open rows are never pruned
DailyUsage 365 days Delete rows whose localDate is before reference - 365 days (in-memory date comparison)
AbenteuerRecord 365 days Same as DailyUsage
All other entities as long as the profile exists never pruned

Aggregates that survive pruning: MasteryRecord (learning state), GameNumberStat (per-game first-try evidence), GameProgress, LearningState (including range-widening evidence counters), DailyUsage (time history), StarLedgerEntry (star history), awards.

Reference time: reference = min(clock.now, newest TaskAttempt.createdAt of any profile, or clock.now if there is none); cutoff90 = startOfDay(reference) - 90 days in the device calendar. Decision: using the newest real record as an upper bound means a device clock set far into the future cannot wipe the attempt log; a clock set into the past only delays pruning.

Pruning uses fetch plus context.delete(_:) per object in chunks of 500, not a batch delete, so that deletions are tracked by persistent history and propagate reliably once V1.1 sync is enabled.

After a successful run, zk.maintenance.lastPruneLocalDate = clock.today.string.

7.14 Schema Versioning and Migration Plan #

  • V1 ships SchemaV1 (version 1.0.0) and ZKMigrationPlan with one schema and no stages (7.12.1).
  • Every future schema change creates a new SchemaVn enum containing the full set of models (copied, then changed), updates the typealiases to point to SchemaVn, and appends a stage to ZKMigrationPlan.
  • Allowed changes (CloudKit-safe, R11): add an entity; add an optional or defaulted property; add an optional relationship with inverse. These use MigrationStage.lightweight(fromVersion:toVersion:).
  • Forbidden changes after V1.1 is released: rename or delete an entity or property, change a property type, make a property non-optional without default, add uniqueness, add .deny. A property that is no longer needed is left in place and ignored (documented in DECISIONS.md as deprecated).
  • Before V1.1 is released (V1 only, sync never enabled), renames are technically possible with @Attribute(originalName:). Decision: do not use this; apply the post-V1.1 rules from day one so no migration can surprise the V1.1 sync launch.
  • Custom stages (MigrationStage.custom) are allowed only for local data transforms (for example filling a new property from existing values) and must be idempotent, because in V1.1+ a record may be migrated on one device and arrive unchanged from another device running an older app version. Older app versions ignore fields they do not know.
  • The export file has its own schemaVersion (7.17), independent of the SwiftData schema version.
  • Test: MigrationTests opens a store file created by the previous released version (committed as a test fixture Fixtures/store-v<N>.store for each released schema) with the current container and asserts all records survive. For V1 there is no previous schema; the fixture for SchemaV1 is created and committed at the V1 release.

7.15 V1.1 iCloud Sync Enablement #

V1 does not enable iCloud. V1.1 adds sync through the user's private CloudKit database. Sync is opt-in: the parent switches on "Mit iCloud synchronisieren" (device setting in S-21, default off, release scope in Section 26.4), and the toggle is available only while premium is active (Section 17.4). Because the V1 schema already follows 7.2, no schema migration is needed; the steps below are configuration, verification and dedup activation.

7.15.1 CloudKit dry run (performed during V1, before V1 release) #

On a throwaway branch spike/cloudkit-dry-run (never merged): perform steps 1-4 of 7.15.2 against a development container, launch the app on two simulators or devices signed in to the same test Apple Account, create a profile and play a round on one, and confirm the data appears on the other. Record the result in DECISIONS.md. Any schema problem found is fixed in V1 before release.

7.15.2 Enablement steps (V1.1) #

  1. Capability: in Xcode, Signing & Capabilities, add iCloud, check CloudKit, and add the container iCloud.<bundleID> (default iCloud.de.zahlenkette.app). Xcode adds the Push Notifications capability (aps-environment) automatically; keep it.

  2. Background mode: add Background Modes > Remote notifications (UIBackgroundModes = [remote-notification]). CloudKit uses silent pushes to announce changes. Silent pushes show nothing to the user and need no notification permission; V1.1 still shows no notifications (Section 14 forbidden patterns remain intact).

  3. Configuration: two ModelConfigurations over the same schema and the same store URL, which differ only in cloudKitDatabase. The choice is made once per launch, before the container opens, and never changes while the container is open:

    let syncOn = deviceSettings.iCloudSyncEnabled && entitlements.isPremium   // cached state at launch
    let configuration = ModelConfiguration(
        "Zahlenkette",
        schema: schema,
        url: storeURL,                                          // unchanged, so local data is mirrored
        allowsSave: true,
        cloudKitDatabase: syncOn ? .private("iCloud.de.zahlenkette.app") : .none
    )

    Switching (reopen procedure): when the parent changes the toggle, or when the entitlement changes so that syncOn would change, the app saves pending work, and applies the new value at the next launch. The S-21 row then shows "Wird beim nächsten Start der App übernommen." Decision: no in-process container swap in V1.1, because @Model instances held by screen models would point at a closed container. On lapse of the subscription sync stops at the next launch and all local data stays on the device; data already in iCloud stays in the family's private database and is used again when sync is switched back on. Turning the toggle off never deletes local data.

  4. Initialize the development schema: run the Debug-only initializer once on a device or simulator signed in to a developer test Apple Account (developer menu button "CloudKit-Schema initialisieren", added in V1.1):

    #if DEBUG
    import CoreData
    
    enum CloudKitSchemaInitializer {
        static func run(containerIdentifier: String) throws {
            let url = URL.temporaryDirectory.appending(path: "cloudkit-schema-init.store")
            let description = NSPersistentStoreDescription(url: url)
            description.cloudKitContainerOptions =
                NSPersistentCloudKitContainerOptions(containerIdentifier: containerIdentifier)
            description.shouldAddStoreAsynchronously = false
            guard let model = NSManagedObjectModel.makeManagedObjectModel(for: SchemaV1.models) else {
                throw PersistenceError.storeOpenFailed(reason: "model translation failed")
            }
            let container = NSPersistentCloudKitContainer(name: "SchemaInit", managedObjectModel: model)
            container.persistentStoreDescriptions = [description]
            let loadError = LoadErrorBox()
            container.loadPersistentStores { _, error in loadError.value = error }
            if let error = loadError.value { throw error }
            try container.initializeCloudKitSchema()
            for store in container.persistentStoreCoordinator.persistentStores {
                try container.persistentStoreCoordinator.remove(store)
            }
        }
    }
    
    /// Synchronous load (shouldAddStoreAsynchronously = false), so the box is only
    /// touched on the calling thread. Documented confinement wrapper (Section 5.6.1).
    final class LoadErrorBox: @unchecked Sendable { var value: Error? }
    #endif

    Verification step: confirm this API usage against the current Apple documentation for syncing SwiftData with CloudKit; if the SDK offers a SwiftData-native schema initialization, use it instead and record the change in DECISIONS.md.

  5. Verify in the CloudKit Console (development environment): record types CD_ChildProfile, CD_ProfileSettings, ... (one per entity, 15 in total) exist in the zone com.apple.coredata.cloudkit.zone with CD_-prefixed fields for every property.

  6. Deploy the schema to production in the CloudKit Console ("Deploy Schema Changes") before submitting V1.1 to App Review. From now on R11 applies strictly.

  7. Existing local data: on the first launch with sync switched on, the mirroring exports the existing local records to the user's private database. Verification step: install a V1 build, create two profiles with progress, upgrade to the V1.1 build on the same device, and confirm all records appear on a second device of the same Apple Account and in the CloudKit Console.

  8. Enable the dedup triggers: run MaintenanceService.runDedupPass() at launch, on every return to .active, and debounced (5 s) after NSPersistentStoreRemoteChange notifications observed on NotificationCenter.default with object: nil. Verification step: confirm the notification is delivered for SwiftData stores; if not, rely on launch and foreground triggers only.

  9. Parent area (V1.1 additions, UI owned by Section 16): the toggle "Mit iCloud synchronisieren" in S-21 (disabled with an explanation when premium is not active), a status line "iCloud-Abgleich: aktiv / aus / nicht angemeldet / eingeschränkt" based on the toggle and CKContainer(identifier:).accountStatus() (the CloudKit framework is imported only for this, in the app target), and the action "Profile zusammenführen" (7.20.5).

  10. Privacy: data stays in the family's own private iCloud database, which the developer cannot access; the App Privacy label remains "Data Not Collected" (Section 23 re-verifies at V1.1). The privacy policy is updated to mention optional iCloud sync.

7.15.3 Behavior and limitations #

Topic Behavior
Same Apple Account only The private database syncs only between devices signed in to the same Apple Account. A child using a device with their own (Family Sharing child) Apple Account does not see the parent device's data. Sharing across Apple Accounts requires CKShare and is V2 at the earliest (Section 26). The parent area Help explains this in one sentence
Toggle off, or premium not active Configuration .none: purely local, exactly like V1
No iCloud account / iCloud disabled for the app The store keeps working locally; mirroring starts when an account becomes available
Account sign-out or switch Verification step: sign out of iCloud on a test device with synced data and observe the store. If local mirrored data is removed on sign-out, the Help text states that data is kept in iCloud and returns after signing in again, and recommends an export before signing out
iCloud storage full Sync pauses; local use is unaffected; the status line shows "eingeschränkt"
Conflict resolution Treated as last-writer-wins per record (the conservative assumption; the design is also correct if resolution is per field). Design consequences: append-only entities (TaskAttempt, StarLedgerEntry, OwnedDecoration, FriendState, StickerAward) are written once by one device, so no two devices ever write the same record and nothing is lost; per-device partitioning (DailyUsage.deviceID, SessionRecord created per device) avoids concurrent writes; aggregates that two devices can update concurrently (MasteryRecord, GameNumberStat, GameProgress, LearningState, ProfileSettings) may lose the other device's most recent increment, which is accepted because these values are estimates that the next attempts correct
Why the append-only star ledger is safe Each entry is created exactly once, on one device, for one event with its own sourceID, and is never modified. Sync therefore only ever adds entries; the union of all devices' entries is the complete history, and the balance (sum) converges to the same value on every device regardless of arrival order. A stored balance field would not have this property: two devices adding stars concurrently would overwrite each other's balance under last-writer-wins
Daily time limit across devices Usage is summed over device rows once they have synced; until then each device counts what it knows. Accepted: the limit is a parental aid, not a security boundary (Section 15)
Mixed app versions Older versions ignore fields added later (7.14)

7.16 Storage Size Estimate #

Row sizes include SQLite overhead and the relationship index, rounded up.

Entity Approx. bytes per row Rows per profile, typical Rows per profile, heavy Size per profile, typical Size per profile, heavy
ChildProfile + ProfileSettings + LearningState 600 total 1 set 1 set < 1 KB < 1 KB
MasteryRecord 200 120 160 (cap) 24 KB 32 KB
GameNumberStat 130 150 240 (cap) 20 KB 31 KB
GameProgress 170 12 12 (cap) 2 KB 2 KB
TaskAttempt (90 days) 260 2,700 (30 tasks on 90 days) 7,200 (80 tasks x 90 days) 0.7 MB 1.9 MB
SessionRecord (90 days) 190 180 540 34 KB 103 KB
DailyUsage (365 days) 150 260 730 (2 devices, daily) 39 KB 110 KB
AbenteuerRecord (365 days) 150 200 365 30 KB 55 KB
StarLedgerEntry (per year, never pruned) 110 9,000 (30 tasks + 6 rounds on 250 days) 36,000 (80 tasks + 16 rounds + 1 Abenteuer, 365 days) 1.0 MB / year 4.0 MB / year
OwnedDecoration + PlacedDecoration 250 150 600 38 KB 150 KB
FriendState, StickerAward 150 70 72 (cap) 11 KB 11 KB

Totals:

Scenario Size
Typical family: 2 profiles, 1 year about 4 MB
Typical family: 2 profiles, 3 years about 8 MB
Worst case: 5 profiles, heavy daily use, 3 years about 80 MB (dominated by the ledger: 5 x 3 x 4 MB = 60 MB)
WAL file up to about 4 MB between automatic checkpoints

Decision: the star ledger is never compacted in V1 or V1.1. Compaction (replacing old entries by a checkpoint entry) would break the union property that makes sync safe (7.15.3); the worst case stays well within typical device storage and within the storage budget of Section 24. Export sizes follow the same proportions (JSON is about 2-3 times larger than the store for the same rows).

7.17 Export Format #

The parent can export one child's data (per-child action, Section 16.10.1) or all children's data (device-level action "Alle Daten exportieren", Section 16.10.2) as a JSON file via the system share sheet. The format is defined here completely. Import is not supported in V1 (Decision: the export exists for transparency and data portability; restoring is done by device backup or, from V1.1, by iCloud sync).

7.17.1 File #

Property Value
Encoding UTF-8 JSON, JSONEncoder with outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes], dateEncodingStrategy = .iso8601 (UTC, Z, no fractional seconds)
Key order Alphabetical (because of .sortedKeys), which makes exports deterministic for tests
Optional values Omitted when absent (never null)
File name One profile: <AppName>-Export-<DisplayName>-<yyyy-MM-dd>.json, where <DisplayName> is the profile's display name per Section 15.2.1 (the nickname, or the avatar's German name when the nickname is empty). All profiles: <AppName>-Export-<yyyy-MM-dd>.json. <AppName> is Brand.appName; name parts are sanitized to ASCII letters, digits and - (umlauts transliterated, other characters removed, an empty result replaced by Kind). This is the only export file-name pattern; Sections 16 and 23 refer to it
Location Written to FileManager.default.temporaryDirectory/Export/, handed to the share sheet, deleted when the share sheet closes, and swept after 24 hours (7.13.1)
Build Export DTOs are created on the main actor from the models, then encoded in a detached task (Section 5.6.1). The share sheet opens only after encoding finished
Contents excluded Device settings, installation ID, entitlement, gate state, logs, persistent model IDs

7.17.2 Top-level object #

Key Type Required Description
format string yes Constant "zahlenkette-export" (an internal identifier that does not change if the app is renamed)
schemaVersion integer yes 1. Incremented only for incompatible changes; added keys do not change it
exportedAt string (date-time) yes Time of export
appVersion string yes CFBundleShortVersionString, e.g. "1.0.0"
appBuild string yes CFBundleVersion, e.g. "42"
contentVersion integer yes contentVersion from Resources/Content/manifest.json (Section 8.5)
contentVersionName string yes contentVersionName from the same manifest, e.g. "2026.10.1"
scope string yes "allProfiles" or "singleProfile"
profiles array of Profile yes Visible profiles in picker order; one element for singleProfile

7.17.3 Profile object #

Key Type Required Source
id string (UUID) yes ChildProfile.id
nickname string yes may be ""
avatarID string yes
colorThemeID string yes
createdAt date-time yes
lastPlayedAt date-time no
settings Settings yes
learningState LearningState yes
stars Stars yes
mastery array of Mastery yes sorted by number, then skill
gameNumberStats array of GameNumberStat yes sorted by gameID, then number
gameProgress array of GameProgress yes sorted by gameID
attempts array of Attempt yes last 90 days, oldest first
sessions array of Session yes last 90 days, oldest first
dailyUsage array of DailyUsage yes summed over devices, one element per date with a row, oldest first
abenteuer array of Abenteuer yes oldest first
friends array of Friend yes sorted by number
stickers array of Sticker yes sorted by awardedAt
decorations array of Decoration yes sorted by purchasedAt

Sub-objects:

Object Keys (type)
Settings level ("littleOnes" | "vorschule"), rangeOverride ("auto" | "r5" | "r10" | "r20": the case names of RangeOverride, whose stored raw values are 0/5/10/20, 7.3.3), difficultyCap ("auto" | "maxStep1" | "maxStep2" | "allSteps"), dailyLimitMinutes (integer, 0 = off)
LearningState rangeStage ("r5" | "r10" | "r20", omitted if not initialized), rangeStageEnteredAt (date-time, optional), sessionsInStage (int), widenedFrom ("r5" | "r10", optional, present only during probation), lastWidenedAt (optional), lastNarrowedAt (optional), stretchShare (number), comfortMode (bool), totalSessions (int)
Stars balance (int, ledger sum, may be negative only after V1.1 conflicts), lifetimeEarned (int), ledger (array of LedgerEntry, oldest first)
LedgerEntry id (UUID), amount (int), reason ("taskSolved" | "roundCompleted" | "abenteuerCompleted" | "decorationPurchased"), sourceID (UUID, optional), createdAt (date-time)
Mastery skill (Skill raw value), number (1-20), score (number, rounded to 3 decimals), band ("notStarted" | "practicing" | "almost" | "mastered", computed per Section 9), attempts, firstTryCorrect, afterHint, shown (ints), boxIndex (int), intervalDays (int), lastPracticedAt, nextDueAt, firstMasteredAt (date-times, optional)
GameNumberStat gameID, number, attempts, firstTryCount, lastPracticedAt (optional)
GameProgress gameID, currentStep ("step1"...), highestStep, roundsCompleted, tasksCompleted, lastPlayedAt (optional)
Attempt id, createdAt, gameID, step, level, skill, secondarySkill (optional), number, outcome ("firstTry" | "afterHint" | "shown"), wrongAnswers, hintLevelReached, durationMs, bucket (optional), roundID (optional), sessionID (optional), abenteuerID (optional)
Session id, startedAt, endedAt (optional), endReason (optional; a SessionEndReason raw value, 7.3.3), activeSeconds, tasksCompleted, roundsCompleted, starsEarned
DailyUsage date ("yyyy-MM-dd"), activeSeconds, bonusSeconds, limitMinutes (0 = off)
Abenteuer id, date, plannedGames (array of GameID raw values), roundsCompleted, completed (bool), startedAt, completedAt (optional)
Friend number, befriendedAt
Sticker stickerID, source ("dotPicture" | "friend" | "milestone"), awardedAt
Decoration id, catalogItemID, pricePaid, purchasedAt, slot (object { "x": int, "y": int }, omitted if in inventory)

Records with unknown raw values (written by a newer app version) are exported with the raw string as stored.

7.17.4 Example #

{
  "appBuild" : "42",
  "appVersion" : "1.0.0",
  "contentVersion" : 1,
  "contentVersionName" : "2026.10.1",
  "exportedAt" : "2026-11-02T18:04:11Z",
  "format" : "zahlenkette-export",
  "profiles" : [
    {
      "abenteuer" : [
        {
          "completed" : true,
          "completedAt" : "2026-11-02T16:21:40Z",
          "date" : "2026-11-02",
          "id" : "7C1E2A9B-5D0B-4B0E-9E8F-2F4B7D2E1A01",
          "plannedGames" : ["hoer_hin", "wie_viele", "was_fehlt"],
          "roundsCompleted" : 3,
          "startedAt" : "2026-11-02T16:16:02Z"
        }
      ],
      "attempts" : [
        {
          "abenteuerID" : "7C1E2A9B-5D0B-4B0E-9E8F-2F4B7D2E1A01",
          "bucket" : "focus",
          "createdAt" : "2026-11-02T16:16:31Z",
          "durationMs" : 6400,
          "gameID" : "hoer_hin",
          "hintLevelReached" : 0,
          "id" : "0B8F7C1D-2E3A-4F5B-8C9D-0A1B2C3D4E5F",
          "level" : "vorschule",
          "number" : 7,
          "outcome" : "firstTry",
          "roundID" : "5E6F7A8B-9C0D-4E1F-8A2B-3C4D5E6F7A8B",
          "secondarySkill" : "recognize",
          "sessionID" : "1A2B3C4D-5E6F-4A7B-8C9D-0E1F2A3B4C5D",
          "skill" : "name",
          "step" : "step2",
          "wrongAnswers" : 0
        }
      ],
      "avatarID" : "avatar.fuchs",
      "colorThemeID" : "theme.sonne",
      "createdAt" : "2026-10-01T15:00:00Z",
      "dailyUsage" : [
        { "activeSeconds" : 1140, "bonusSeconds" : 0, "date" : "2026-11-02", "limitMinutes" : 20 }
      ],
      "decorations" : [
        {
          "catalogItemID" : "deco.sonnenblume",
          "id" : "9F8E7D6C-5B4A-4392-8170-6F5E4D3C2B1A",
          "pricePaid" : 10,
          "purchasedAt" : "2026-10-20T15:40:00Z",
          "slot" : { "x" : 2, "y" : 1 }
        }
      ],
      "friends" : [
        { "befriendedAt" : "2026-10-28T16:02:13Z", "number" : 3 }
      ],
      "gameNumberStats" : [
        { "attempts" : 14, "firstTryCount" : 11, "gameID" : "hoer_hin", "lastPracticedAt" : "2026-11-02T16:16:31Z", "number" : 7 }
      ],
      "gameProgress" : [
        { "currentStep" : "step2", "gameID" : "hoer_hin", "highestStep" : "step2", "lastPlayedAt" : "2026-11-02T16:18:05Z", "roundsCompleted" : 23, "tasksCompleted" : 115 }
      ],
      "id" : "3F2504E0-4F89-41D3-9A0C-0305E82C3301",
      "lastPlayedAt" : "2026-11-02T16:21:30Z",
      "learningState" : {
        "comfortMode" : false,
        "rangeStage" : "r10",
        "rangeStageEnteredAt" : "2026-10-01T15:02:00Z",
        "sessionsInStage" : 19,
        "stretchShare" : 0.1,
        "totalSessions" : 19
      },
      "mastery" : [
        {
          "afterHint" : 2,
          "attempts" : 12,
          "band" : "mastered",
          "boxIndex" : 3,
          "firstMasteredAt" : "2026-10-30T16:05:00Z",
          "firstTryCorrect" : 9,
          "intervalDays" : 4,
          "lastPracticedAt" : "2026-11-02T16:16:31Z",
          "nextDueAt" : "2026-11-05T23:00:00Z",
          "number" : 7,
          "score" : 0.831,
          "shown" : 1,
          "skill" : "name"
        }
      ],
      "nickname" : "Mia",
      "sessions" : [
        {
          "activeSeconds" : 612,
          "endReason" : "background",
          "endedAt" : "2026-11-02T16:27:10Z",
          "id" : "1A2B3C4D-5E6F-4A7B-8C9D-0E1F2A3B4C5D",
          "roundsCompleted" : 4,
          "starsEarned" : 31,
          "startedAt" : "2026-11-02T16:15:40Z",
          "tasksCompleted" : 18
        }
      ],
      "settings" : {
        "dailyLimitMinutes" : 20,
        "difficultyCap" : "auto",
        "level" : "vorschule",
        "rangeOverride" : "auto"
      },
      "stars" : {
        "balance" : 142,
        "ledger" : [
          { "amount" : 1, "createdAt" : "2026-11-02T16:16:31Z", "id" : "C0FFEE00-1234-4567-89AB-CDEF01234567", "reason" : "taskSolved", "sourceID" : "0B8F7C1D-2E3A-4F5B-8C9D-0A1B2C3D4E5F" }
        ],
        "lifetimeEarned" : 312
      },
      "stickers" : [
        { "awardedAt" : "2026-10-01T15:09:12Z", "source" : "milestone", "stickerID" : "sticker.milestone.first_round" }
      ]
    }
  ],
  "schemaVersion" : 1,
  "scope" : "singleProfile"
}

The IDs avatar.fuchs, theme.sonne, deco.sonnenblume and sticker.milestone.first_round follow the content ID formats of Section 8.3 and exist in the V1 content files (Sections 14 and 15.3). The example's arrays are shortened to one element each.

7.17.5 Export tests #

ExportTests seed a fixed in-memory store with FixedClock, export, and compare the output byte-for-byte with a committed golden file Fixtures/export-golden.json; a second test decodes the golden file with the export DTOs to guarantee round-trip decodability.

7.18 Reset and Delete Semantics #

All deletes are hard deletes (records are removed, not flagged), except the transient isPendingDeletion flag used to make large deletions crash-safe. Every operation requires the parental gate and a confirmation (Section 16 owns UI and copy). After each operation the app returns the parent to the screen defined in Section 16 and refreshes via changeToken. In V1.1 all deletions propagate to the family's other devices through sync.

7.18.1 "Fortschritt zurücksetzen" (reset learning progress of one child) #

Scope per Section 14: learning state only; rewards are kept.

Deleted Reset to defaults Kept
all MasteryRecord, GameNumberStat, GameProgress, TaskAttempt of the profile LearningState values (range stage back to not-initialized, so the cold-start rule for the current level applies; all counters 0; stretchShare 0.10) ChildProfile, ProfileSettings (overrides, limit, level), StarLedgerEntry, OwnedDecoration, PlacedDecoration, FriendState, StickerAward, SessionRecord, DailyUsage, AbenteuerRecord

Kept friends stay befriended even though their mastery is gone (friends are never lost, Section 14). One unit of work (at most a few thousand objects); the parent sees a progress indicator while it runs.

7.18.2 "Alles zurücksetzen" for one child (reset everything, keep the profile) #

Deleted Reset to defaults Kept
everything in 7.18.1 plus StarLedgerEntry, OwnedDecoration (cascades PlacedDecoration), FriendState, StickerAward, SessionRecord, AbenteuerRecord, and every DailyUsage row except the rows of today's trusted local date LearningState as in 7.18.1; cachedStarBalance = 0, cachedLifetimeStars = 0, lastPlayedAt = nil ChildProfile identity (id, nickname, avatar, theme, sort order), ProfileSettings, and today's DailyUsage rows (scope per Section 14.11)

Executed in chunks of 500 deletions per save. If interrupted, the next launch's integrity check finds a partially reset profile harmless (every remaining record is valid on its own); the parent can run the reset again.

Today's DailyUsage rows are kept so that a reset never grants extra play time on the same day (Section 14.11). The per-child action is named "Alles zurücksetzen" in the UI; it is distinct from the device-wide "Alle Daten löschen" (7.18.4).

7.18.3 "Kind löschen" (delete one child) #

  1. Set isPendingDeletion = true on the profile and save. From this moment the profile is invisible everywhere (picker, parent area, counts toward the 5-profile limit: no).
  2. Delete child records in chunks of 500 (ledger, attempts, and the other to-many relationships), saving after each chunk and yielding between chunks.
  3. Delete the ChildProfile itself (cascade removes whatever remains) and save. Remove the key zk.parent.levelHintDismissed.<profileID> (7.9.2) and delete the store recovery folder if one exists (7.12.3), because it contains this child's data.
  4. If the app terminates between steps, the next launch's integrity check resumes the deletion for every profile with isPendingDeletion == true (7.19).
  5. "Kind löschen" is disabled while only one profile exists (Sections 15.13 and 16.10.1); deleteProfile(id:) throws PersistenceError.invalidValue for the last visible profile. The only way to reach zero profiles is "Alle Daten löschen" (7.18.4). After a deletion the parent stays in the parent area (Section 16); when child mode is entered next, routing follows Section 5.4.4 step 10. Deleting the profile that is currently playing is impossible because deletion requires the parent area (Section 15).

7.18.4 "Alle Daten löschen" (delete all data) #

  1. Mark all profiles isPendingDeletion = true, save.
  2. Delete each profile as in 7.18.3.
  3. DeviceSettingsStore.resetToDefaults() and remove every key marked "yes" in the reset column of 7.9.2. Kept, and listed here explicitly: zk.device.installationID, zk.clock.highWaterMark, zk.clock.trustedAnchor (clock-tampering protection must survive a wipe, otherwise deleting all data would reset the trusted day) and zk.entitlement.cacheV1 (the subscription belongs to the Apple Account and is re-derived from StoreKit anyway; keeping the cache avoids a wrong "free" state while offline).
  4. Delete temporary export files and the store recovery folder (7.12.3).
  5. The app returns to the first-launch flow (S-02).

The typed confirmation word and double confirmation are owned by Section 16.

7.19 Launch Integrity Checks #

MaintenanceService.runLaunchIntegrityChecks(now:) runs synchronously at bootstrap after the store opens (Section 5.4.4 step 5), before any child screen. It is designed to finish in under 150 ms for a typical store; checks that scale with the ledger or attempt log are part of deferred maintenance instead (7.13.1). Every repair is logged at notice with counts only. The run ends with one save.

# Check Repair
1 Profiles with isPendingDeletion == true Resume deletion (7.18.3) for each; this may run beyond the 150 ms target and is shown behind S-01
2 Each visible profile has exactly one ProfileSettings and one LearningState Create missing ones with defaults; if more than one exists, merge per 7.20 (keep latest updatedAt)
3 profileID equals profile?.id on every per-profile record of the small tables (ProfileSettings, LearningState, MasteryRecord, GameNumberStat, GameProgress, FriendState, StickerAward, OwnedDecoration, PlacedDecoration via its owner) Set profileID from the relationship when the relationship is non-nil
4 Orphans: records with profile == nil (and PlacedDecoration with ownedDecoration == nil) Delete if createdAt is more than 7 days ago; otherwise ignore (queries filter by profileID, and in V1.1 the parent record may still be arriving through sync)
5 Open sessions (endedAt == nil) of this device Close with endReasonRaw = "terminated", endedAt = lastActivityAt
6 MasteryRecord value ranges Clamp score to 0.0-1.0; clamp boxIndex to 0-5; set intervalDays to the interval of boxIndex (Section 9 table) if inconsistent; delete records with number outside 1-20 or an unknown skillRaw
7 GameNumberStat and FriendState numbers outside 1-20 Delete
8 ProfileSettings.dailyLimitMinutes not in {0, 10, 15, 20, 30, 45, 60}; unknown levelRaw, rangeOverrideRaw, difficultyCapRaw Replace with defaults (20, littleOnes, 0, auto)
9 GameProgress.currentStepRaw / highestStepRaw unknown; highestStep < currentStep Replace with step1; set highest to current
10 ChildProfile.avatarID / colorThemeID empty or unknown in content Set to the first avatar / theme in content order (runs after content is loaded; if content failed, skipped)
11 Nickname longer than 20 characters or containing control characters Normalize (7.5.1.1)
12 Placements: slot out of range, two placements on one slot, two placements for one owned decoration Delete the offending placements, keeping the one with the latest updatedAt per slot and per owner (items return to inventory)
13 Timestamps equal to Date.distantPast where a real value is required (createdAt, befriendedAt, awardedAt, purchasedAt, startedAt) Set to now (the record is kept)
14 More than 5 visible profiles No repair. Possible only after V1.1 sync; the picker shows all, creation stays blocked until the count is below 5 (7.20.5)
15 GameProgress.gameStateJSON longer than 8 KB (UTF-8) or not valid JSON; LearningState.recentFirstTryRaw longer than 20 characters or containing characters other than 0/1; widenedFromRaw not in {0, 5, 10} or not below rangeStageRaw Set gameStateJSON to "" (the game starts from its default state); keep the last 20 valid characters; set widenedFromRaw to 0 and the probation counters to 0

The star-balance recompute and the dedup pass are deliberately not part of this synchronous check (they scale with data volume); they run 2 s after the first screen (7.13.1). Until then the cached balance is shown; purchases always recompute (7.7).

7.20 Dedup and Merge Strategy #

Duplicates of a logical key cannot occur in V1 through normal operation (all writes are upserts on one device). They can occur in V1.1 when two devices create the same logical record before either has received the other's copy, and after a profile merge (7.20.5). The dedup pass runs in V1 too (cheap, and it exercises the code path); in V1.1 it also runs on remote changes (7.15.2 step 8).

7.20.1 General procedure #

For each entity with a logical key: fetch all records (per profile), group by the logical key, and for each group with more than one record choose a survivor, apply the merge rule to the survivor, delete the others, and save in chunks of 500. The pass is idempotent: running it twice produces the same result.

7.20.2 Rules per entity #

Entity Logical key Survivor Merge rule
ChildProfile id oldest createdAt Same id twice is a sync artifact: keep one, move relationships of the others to it. Two different profiles for the same real child are not merged automatically (7.20.5)
ProfileSettings profileID latest updatedAt Survivor's values win entirely (a parent's latest decision)
LearningState profileID latest updatedAt Survivor's values (including comfortMode, recentFirstTryRaw, reentryRoundsRemaining and the probation counters); totalSessions = max; lastCountedSessionEndedAt = latest non-nil
MasteryRecord profileID + skillRaw + number the record with the latest lastPracticedAt (ties: latest updatedAt) Survivor keeps its score, boxIndex, intervalDays, nextDueAt, lastOutcomeRaw, lastPracticedAt (the most recent evidence is the best estimate of current mastery); counters attempts, firstTryCorrect, afterHintCount, shownCount = sum (duplicates were created independently, so their attempts are disjoint); firstMasteredAt = earliest non-nil
GameNumberStat profileID + gameIDRaw + number oldest createdAt attempts and firstTryCount = sum; lastPracticedAt = latest
GameProgress profileID + gameIDRaw latest updatedAt Survivor keeps currentStepRaw, the consecutive counters and gameStateJSON; highestStepRaw = max; roundsCompleted, tasksCompleted = sum; lastItemID from survivor; lastPlayedAt = latest
TaskAttempt id any Keep one (identical copies)
SessionRecord id latest updatedAt Keep survivor
DailyUsage profileID + localDate + deviceID latest updatedAt activeSeconds = max (the same device's monotonic counter), bonusSeconds = max, limitMinutes = max, warningAllowanceSeconds = max of the non-nil values (nil if both are nil), limitReachedAt = earliest non-nil
StarLedgerEntry profileID + reasonRaw + sourceID when sourceID != nil; otherwise id earliest createdAt Keep survivor only; never sum. Entries with different sourceIDs are never duplicates, even if they look alike
OwnedDecoration id any Keep one
PlacedDecoration per ownedDecoration, and per profileID + slotX + slotY latest updatedAt Delete the others; their items return to the inventory
FriendState profileID + number earliest befriendedAt celebrationShown = OR
StickerAward profileID + stickerID earliest awardedAt isNew = AND
AbenteuerRecord profileID + localDate a completed record if any (earliest completedAt), else the oldest startedAt roundsCompleted = max; isCompleted = OR; bonusAwarded = OR

After merging, the star caches of affected profiles are recomputed from the ledger.

7.20.3 Accepted anomalies #

Anomaly Cause Handling
Two Abenteuer bonuses on the same day The same child completes the daily Abenteuer on two devices while both are offline Both ledger entries are kept (different sourceIDs; union semantics). Worth 5 extra stars at most per day; not corrected
Negative star balance Two devices offline spend the same stars Displayed as 0; purchases blocked until earned back; decorations are never taken away (7.7)
Lost increment in an aggregate Concurrent updates of the same MasteryRecord, GameProgress or LearningState on two devices Last writer wins for that record; subsequent attempts correct the estimate
A friend befriended on one device while the rule is not yet met on the other Evidence synced later Friends are never removed; the friend exists on both devices after sync

7.20.4 Dedup triggers #

Version Triggers
V1 Deferred maintenance, at most every 12 hours (7.13.1); developer menu "Wartung jetzt ausführen"
V1.1 Additionally at launch, on every return to .active, and 5 s after the last remote-change notification (7.15.2 step 8)

7.20.5 Profile merge (V1.1) #

When a family used the app on two devices before enabling sync, the same child may exist twice (different profile IDs), and the device may show more than 5 profiles. V1.1 adds the parent action "Profile zusammenführen" (behind the gate; UI owned by Section 16 in V1.1): the parent picks a source profile B and a target profile A.

Algorithm (one chunked, self-committing operation):

  1. Delete B's ProfileSettings and LearningState (A's parent settings and range state win).
  2. For every other record of B: set profile = A and profileID = A.id.
  3. Run the dedup pass for profile A (7.20.2), which merges mastery, stats, progress, friends, stickers, placements and Abenteuer records by their logical keys; the star ledgers are simply united.
  4. Recompute A's star caches.
  5. Delete B (now without children) and save.

Profile creation stays blocked while more than 4 visible profiles exist; nothing is deleted automatically.

8. Content System and Data Files #

8.1 Scope and Principles #

This section owns the format, loading, validation and versioning of every bundled content file, the identifier conventions used across content, and the procedures for adding content without writing code. It does not own the content itself: the decoration catalog and the sticker list are owned by Section 14, game parameter keys and per-game lines by Sections 11 to 13, the complete audio and text inventory by Section 21, String Catalog mechanics and TTS behaviour by Section 20.

Principles:

  1. Data, not code. Everything that varies per game step, per number, per decoration, per sticker, per picture or per spoken line lives in JSON under Resources/Content/. Code contains rules and mechanics only.
  2. No display text in JSON. JSON files contain identifiers only: string keys (resolved in the String Catalogs, Section 20), audio IDs (resolved to files in Resources/Audio/<locale>/, Section 20), utterance template IDs (resolved in the content itself, 8.7 and 8.8) and asset names (resolved in Resources/Illustrations.xcassets). A JSON file never contains a German or English sentence.
  3. Locale-independent JSON. Adding a language never changes a JSON file except the manifest's locale list (8.5).
  4. Immutable at runtime. Content is decoded and validated once at launch into an immutable, Sendable value (ContentCatalog, 8.15) and never mutated afterwards.
  5. Never crash on content. Release builds skip invalid items and hide games with invalid configuration; Debug builds assert and report (8.17). The content validation test (8.18) makes invalid content unshippable.
  6. IDs are permanent. An identifier that has shipped is never renamed, re-used for a different thing, or deleted (8.19).

8.2 Files, Folders and Bundling #

Repository layout (the full repository tree is in Section 5.13):

Resources/
├── Content/
│   ├── manifest.json
│   ├── numbers.json
│   ├── prompts.json
│   ├── decorations.json
│   ├── stickers.json
│   ├── friends.json
│   ├── avatars.json
│   ├── games/
│   │   ├── entdecken.json
│   │   ├── wie_viele.json
│   │   ├── hoer_hin.json
│   │   ├── was_fehlt.json
│   │   ├── blitzblick.json
│   │   ├── mehr_weniger.json
│   │   ├── nachspuren.json
│   │   ├── schuettelbox.json
│   │   ├── froschsprung.json
│   │   ├── zahlenmonster.json
│   │   ├── memory.json
│   │   └── punkt_zu_punkt.json
│   └── dotpictures/
│       └── <pictureId>.json          (24 files in V1, e.g. stern.json)
├── Audio/
│   ├── de/<audioId>.m4a              (voice lines, e.g. num.7.m4a)
│   └── common/<audioId>.m4a          (sfx.* and music.*, locale-independent)
└── Illustrations.xcassets            (all images referenced by content)
App/Localization/
├── Localizable.xcstrings             (app and parent UI strings)
└── Content.xcstrings                 (every string key referenced from content JSON)

Bundling rules:

Item How it is bundled Runtime access
Resources/Content Folder reference in the app target (paths preserved) The composition root resolves Bundle.main.url(forResource: "Content", withExtension: nil) and passes that URL to ContentLoader (8.15). ZKContent never reads Bundle.main paths itself.
Resources/Audio Folder reference in the app target Section 20 (the production audio service LiveAudioService receives the Audio URL). ZKContent receives the same URL only to build the audio-existence index for validation.
Resources/Illustrations.xcassets Asset catalog in the app target. Folders only organise the catalog (one folder per asset category, Section 6.4.3); "Provides Namespace" is off on every folder, so every image and color set is addressed by its flat name Images are looked up by their flat name in Bundle.main.
Content.xcstrings, Localizable.xcstrings String Catalogs in the app target (compiled into <locale>.lproj/Content.strings and Localizable.strings plus .stringsdict) Bundle.main, table "Content" or "Localizable".

Verification step (milestone M1): after the first build, open the built .app bundle and confirm that Content/manifest.json, Content/games/hoer_hin.json and Audio/de/num.7.m4a exist at exactly these relative paths. If Xcode flattened any folder, re-add it as a folder reference (not a group) and record the fix in DECISIONS.md.

8.3 Identifier Conventions #

All identifiers are lowercase ASCII. German umlauts and ß are transliterated (ä → ae, ö → oe, ü → ue, ß → ss). No spaces, no hyphens, no uppercase, including asset names.

Identifier Pattern (regular expression) Examples Owner of the values
Slug (one segment) ^[a-z][a-z0-9_]{0,39}$ tulpe, first_round, stern —
Game ID one of the 12 GameID raw values (Section 5.4.3) hoer_hin, punkt_zu_punkt Section 5
Audio ID ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$, maximum 64 characters num.7, num.7.q, prompt.hoer_hin.intro, hint.common.listen_again, fb.correct.01, friend.7.hello, sfx.correct Section 21
Audio ID prefixes num.<n> (n = 1–20, plus num.0, 8.6), num.<n>.q (n = 1–20), prompt.<gameId>.<key>, prompt.common.<key> (carrier fragments, 8.7), hint.<gameId>.<key>, hint.common.<key>, fb.correct.<nn>, fb.tryagain.<nn>, fb.solution.<key>, session.<key>, friend.<n>.<key>, label.<kind>.<slug>, sfx.<key>, music.<key> see left IDs and texts of prompt.<gameId>.*, hint.<gameId>.* and label.dot.* lines: the game's section (11–13); all other prefixes: Section 21. Section 21 mirrors every line verbatim
Session line key session.<key> where <key> is one snake_case segment (no further dots) session.greeting_morning, session.new_friend, session.locked_game Section 21
String key ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$, maximum 80 characters number.7.word, prompt.hoer_hin.intro, deco.tulpe.name Section 21 (text), this section (key conventions)
Decoration ID deco.<slug> deco.tulpe, deco.regenbogenbruecke Section 14.4.5
Sticker ID sticker.dot.<pictureId>, sticker.friend.<n>, sticker.milestone.<slug> sticker.dot.stern, sticker.friend.7, sticker.milestone.first_round Section 14.7
Dot picture ID slug stern, schildkroete Section 13 and Section 14.7.2
Friend ID the number n, integer 1–20 7 Section 14.5
Avatar ID avatar.<slug> avatar.fuchs Section 15
Color theme ID theme.<slug> theme.sonne Section 15 (list), Section 19 (colors)
Step ID step1, step2, step3 (the DifficultyStep raw values, Section 10.4.1) step2 Section 10
Utterance template ID <scope>.<name>: scope common (declared in prompts.json) or a GameID raw value (declared in that game's file); name [a-z0-9_]+. Template IDs are not audio IDs and have no files common.quantity, hoer_hin.task_where_is Section 20.6.2 (template semantics), this section (storage)
Asset name ^[a-z][a-z0-9_]*$, flat form <category>_<slug>[_<state>] with the categories of Section 6.4.3 (avatar, theme, friend, deco, dotpic, sticker, game, garden, bg, ui); numbers zero-padded to two digits deco_tulpe, friend_07_happy, dotpic_stern_reveal, game_hoer_hin_icon, avatar_fuchs Section 6.4.3 (naming scheme), this section (pattern and validation)

Decision: the label.<kind>.<slug> audio prefix is used for spoken names of decorations (label.deco.<slug>, optional and not recorded in V1; a missing decoration label is not an error) and for the reveal line of every dot picture (label.dot.<pictureId>, required, 8.12). Section 21 lists every label line that is recorded.

String key conventions (all keys referenced from content live in Content.xcstrings):

Content String key
Number word number.<n>.word ("sieben")
Text of a voice line identical to its audio ID (e.g. prompt.hoer_hin.intro)
TTS-optimised variant of a voice line (only where pronunciation needs help) <audioId>.tts
Game title game.<gameId>.title
Step labels game.step.leicht, game.step.mittel, game.step.schwer
Friend name friend.<n>.name ("Sieben")
Decoration name <decorationId>.name (e.g. deco.tulpe.name)
Sticker name (parent-facing and VoiceOver) <stickerId>.name
Dot picture name dotpicture.<pictureId>.name
Avatar name <avatarId>.name
Color theme name <themeId>.name

8.4 Common JSON Rules #

Rule Detail
Encoding UTF-8 without byte-order mark, LF line endings, 2-space indentation. Comments are not allowed (JSON has none).
Top level Every file is a JSON object whose first key is "schemaVersion".
schemaVersion Integer. V1 = 1. The loader supports exactly the versions in ContentSchema.supportedVersions (V1: [1]). A file with an unsupported version is invalid as a whole (8.17).
Integers JSON numbers without a fraction. A value like 4.0 where an integer is expected is a type error.
Decimals JSON numbers; decoded as Double.
Booleans true / false only (never 0/1, never strings).
Absent vs null An optional field may be absent or null; both mean "use the default". A required field that is absent or null is an error.
Unknown keys Ignored by the decoder, reported as a warning (C007, 8.16) so typos are found.
Ordering Arrays whose order matters say so explicitly (e.g. steps, points). All other arrays are sorted by the loader (by ID or sortIndex) before exposure, so file order never influences behaviour.
Duplicate keys in one object Invalid JSON for this project; the test (8.18) detects them with a strict tokenizer pass and reports C002.
Size No single content file may exceed 256 KB. The whole Content/ folder stays within the content JSON and String Catalog allotment in Section 24.4.

Per-item decoding: every array of items (numbers, lines, decorations, stickers, friends, avatars, themes, steps, points) is first decoded as an array of raw JSON values and then each element is decoded individually. One malformed element therefore invalidates only that element, never its siblings (8.17).

// ZKContent — a lossless JSON value used for per-item decoding and for game parameters.
public enum JSONValue: Sendable, Hashable, Codable {
    case null
    case bool(Bool)
    case number(Double)
    case string(String)
    case array([JSONValue])
    case object([String: JSONValue])

    /// Decodes a typed value from this JSON value (re-encodes with JSONEncoder, decodes with JSONDecoder).
    public func decode<T: Decodable>(_ type: T.Type) throws -> T
    /// Key set of an object value, empty for other cases. Used for unknown-key warnings.
    public var objectKeys: Set<String> { get }
}

8.5 manifest.json #

The manifest is the entry point. The loader reads it first and loads exactly the files it lists; files that exist but are not listed are ignored at runtime and reported by the test as orphans (C017).

Field Type Required Constraints Example
schemaVersion Int yes in ContentSchema.supportedVersions 1
contentVersion Int yes ≥ 1; strictly greater than the value in the previous App Store release (8.19) 1
contentVersionName String yes ^\d+\.\d+(\.\d+)?$; shown in the parent area's "Hilfe & Rechtliches" footer (Section 16) and written into the data export as contentVersionName, next to the integer contentVersion (export format, Section 7.17) "1.0"
sourceLocale String yes must be "de" in V1 "de"
voiceLocales [String] yes non-empty, unique, contains sourceLocale; each must be a localization present in both String Catalogs and a folder Resources/Audio/<locale>/ ["de"]
files Object yes keys exactly numbers, prompts, decorations, stickers, friends, avatars; values are paths relative to Content/, ending in .json see example
games [String] yes unique GameID raw values; V1 lists all 12; the file for each is games/<gameId>.json see example
dotPictures [String] yes unique dot picture IDs; the file for each is dotpictures/<pictureId>.json see example
{
  "schemaVersion": 1,
  "contentVersion": 1,
  "contentVersionName": "1.0",
  "sourceLocale": "de",
  "voiceLocales": ["de"],
  "files": {
    "numbers": "numbers.json",
    "prompts": "prompts.json",
    "decorations": "decorations.json",
    "stickers": "stickers.json",
    "friends": "friends.json",
    "avatars": "avatars.json"
  },
  "games": [
    "entdecken", "wie_viele", "hoer_hin", "was_fehlt",
    "blitzblick", "mehr_weniger", "nachspuren", "schuettelbox",
    "froschsprung", "zahlenmonster", "memory", "punkt_zu_punkt"
  ],
  "dotPictures": [
    "stern", "haus", "fisch", "sonne", "herz", "boot", "blume", "luftballon",
    "schmetterling", "schnecke", "katze", "hase", "auto", "rakete", "tannenbaum", "vogel",
    "elefant", "giraffe", "dinosaurier", "schiff", "eisenbahn", "burg", "schildkroete", "einhorn"
  ]
}

8.6 numbers.json #

One entry per number 1–20. It carries the number word key, the two audio IDs, the rendering structure used by Entdecken and the structured renderings, the dice and finger pattern availability, and the friend reference. The spoken decomposition used by solution lines (Section 10.8.3) and by the {split x} template token (Section 20.6.2) is derived from n (rule below), not from structure. An optional top-level zero entry declares the word and audio for 0 (8.6.1).

Field Type Required Constraints Example (n = 7)
n Int yes 1–20, unique 7
wordKey String yes must equal number.<n>.word and exist in Content.xcstrings "number.7.word"
audioId String yes must equal num.<n> "num.7"
questionAudioId String yes must equal num.<n>.q "num.7.q"
structure [Int] yes must equal the structure rule below [5, 2]
dice [Int] or null yes (may be null) must equal the dice rule below [5, 2]
fingers [Int] or null yes (may be null) must equal the finger rule below [5, 2]
friendId Int yes must equal n and exist in friends.json 7

Structure rule ("Kraft der Fünf", tens first, then a five, then the remainder; Section 4 owns the didactics). structure is a rendering structure only: it tells Entdecken and the structured representations how to group the beads. It is never spoken as such. The spoken decomposition and the split label follow Section 4 (DR-09, DR-43): teens are "zehn und (n−10)", split label "10 + (n−10)".

n structure (rendering) Spoken decomposition (Section 10.8.3) Split label
1–5 [n] none (the number itself) none
6–9 [5, n−5] "fünf und (n−5)" "5 + (n−5)"
10 [5, 5] "fünf und fünf" "5 + 5"
11–14 [10, n−10] "zehn und (n−10)" "10 + (n−10)"
15 [10, 5] "zehn und fünf" "10 + 5"
16–19 [10, 5, n−15] "zehn und (n−10)" (e.g. 17: "zehn und sieben") "10 + (n−10)" (e.g. "10 + 7")
20 [10, 10] "zehn und zehn" "10 + 10"

Examples: 7 → [5, 2], 10 → [5, 5], 13 → [10, 3], 17 → [10, 5, 2] (rendered as ten, five and two; spoken "zehn und sieben"), 20 → [10, 10]. The spoken parts are exposed as NumberFact.spokenParts (below): [] for 1–5, [5, n−5] for 6–10, [10, n−10] for 11–20. Zwanzigerfeld rows are derived, never stored: row 1 holds min(n, 10) beads, row 2 holds max(0, n − 10) beads, each row split 5 | 5 (Section 19).

Dice rule (standard die faces; two dice use a five for 7–10 so the "Kraft der Fünf" stays visible): n = 1–6 → [n] (one die); n = 7–10 → [5, n−5] (two dice); n = 11–20 → null (no dice pattern; games must not request one).

Finger rule (German convention: counting starts with the thumb; Section 19 renders it): n = 1–5 → [n] (one hand); n = 6–10 → [5, n−5] (two hands, first hand full); n = 11–20 → null.

Decision: the rules are also implemented in code as NumberFact.derived(_:) (ZKCore). The JSON remains the source that games read; the code rule exists so that validation can compare against it and so that an invalid entry can be replaced by its derived value in Release (8.17).

// ZKCore
public struct NumberFact: Sendable, Hashable, Codable {
    public let n: Int                    // 1...20
    public let wordKey: String           // "number.7.word"
    public let audioID: AudioID          // "num.7"      (JSON key: audioId)
    public let questionAudioID: AudioID  // "num.7.q"    (JSON key: questionAudioId)
    public let structure: [Int]          // [5, 2]
    public let dice: [Int]?              // nil when unavailable
    public let fingers: [Int]?           // nil when unavailable
    public let friendID: Int             // == n        (JSON key: friendId)

    public var fieldRows: (row1: Int, row2: Int) { (min(n, 10), max(0, n - 10)) }
    public var digits: [Int] { n < 10 ? [n] : [n / 10, n % 10] }
    /// Parts of the spoken decomposition (Section 4, DR-09/DR-43): [] for 1...5,
    /// [5, n - 5] for 6...10, [10, n - 10] for 11...20. Independent of `structure`.
    public var spokenParts: [Int] { n <= 5 ? [] : (n <= 10 ? [5, n - 5] : [10, n - 10]) }
    /// The rule-based value used for validation and as Release fallback.
    public static func derived(_ n: Int) -> NumberFact
}

Full example (excerpt showing 5 of the 20 required entries; the shipped file contains all 20 in ascending order):

{
  "schemaVersion": 1,
  "numbers": [
    { "n": 1,  "wordKey": "number.1.word",  "audioId": "num.1",  "questionAudioId": "num.1.q",  "structure": [1],         "dice": [1],    "fingers": [1],    "friendId": 1 },
    { "n": 5,  "wordKey": "number.5.word",  "audioId": "num.5",  "questionAudioId": "num.5.q",  "structure": [5],         "dice": [5],    "fingers": [5],    "friendId": 5 },
    { "n": 7,  "wordKey": "number.7.word",  "audioId": "num.7",  "questionAudioId": "num.7.q",  "structure": [5, 2],      "dice": [5, 2], "fingers": [5, 2], "friendId": 7 },
    { "n": 17, "wordKey": "number.17.word", "audioId": "num.17", "questionAudioId": "num.17.q", "structure": [10, 5, 2],  "dice": null,   "fingers": null,   "friendId": 17 },
    { "n": 20, "wordKey": "number.20.word", "audioId": "num.20", "questionAudioId": "num.20.q", "structure": [10, 10],    "dice": null,   "fingers": null,   "friendId": 20 }
  ]
}

Number audio (num.<n>, num.<n>.q) is declared by numbers.json itself; it is not repeated in prompts.json. Its text for captions and TTS is the number word (wordKey); the question variant uses the same word with a question mark appended at runtime for TTS (Section 20).

8.6.1 The optional zero entry #

The digit 0 is spoken only by Nachspuren's digit prompts when the child traces the 0 of 10 or 20 (Section 12.4). It is not a number the child learns: it has no mastery records, no question form, no friend, no dice or finger pattern. Nachspuren digit prompts are the only use of num.0; Froschsprung's start bank is unlabeled and never speaks "null" (Section 13.1), and no other game or template may reference num.0 (validation C022).

Field Type Required Constraints Value
zero Object no (required when any game file or CodeReferencedVoiceLines.all references num.0, C014) — see below
zero.wordKey String yes must equal number.0.word and exist in Content.xcstrings ("null") "number.0.word"
zero.audioId String yes must equal num.0 "num.0"
{
  "schemaVersion": 1,
  "zero": { "wordKey": "number.0.word", "audioId": "num.0" },
  "numbers": [
    { "n": 1, "wordKey": "number.1.word", "audioId": "num.1", "questionAudioId": "num.1.q", "structure": [1], "dice": [1], "fingers": [1], "friendId": 1 }
  ]
}

(The excerpt shows 1 of the 20 required entries; the shipped file lists all 20 as in the example above.)

num.0 is a voice line of category number like num.1…num.20 (captions and TTS use number.0.word); ContentCatalog.zero exposes it (8.15). Because V1 Nachspuren traces 10 and 20 (Section 12.4), the shipped numbers.json contains the zero entry.

8.7 prompts.json (Voice-Line Registry) #

prompts.json declares every voice line other than number words (8.6) and friend lines (8.11): game prompts, carrier fragments, hints, feedback, solution fragments, session lines and labels. A voice line is declared exactly once across numbers.json, prompts.json and friends.json. The file also holds the shared utterance templates (templates, 8.7.1).

Field Type Required Constraints Example
schemaVersion Int yes 1 1
lines [Line] yes unique audioId across the three declaring files —
lines[].audioId String yes audio ID pattern (8.3); its prefix must match category (table below) "prompt.hoer_hin.intro"
lines[].stringKey String yes exists in Content.xcstrings for every locale in voiceLocales; convention: equal to audioId "prompt.hoer_hin.intro"
lines[].ttsFallbackKey String yes exists in Content.xcstrings for every locale in voiceLocales; equals stringKey unless a pronunciation-tuned <audioId>.tts entry is needed "prompt.hoer_hin.intro"
lines[].category String yes one of the values below "prompt"
templates [Template] no []; unique template IDs, each with scope common (8.3); object shape in 8.7.1 see 8.7.1
category Required audio ID prefix Referenced from
prompt prompt.<gameId>. games/<gameId>.json promptIds, template segments of that game
carrier prompt.common. template segments only (templates here and utterances in game files); a carrier is a recorded sentence fragment such as "Wo ist die" that is joined with a number word at runtime (Section 20.6)
hint hint.<gameId>. or hint.common. games/<gameId>.json hintIds, framework code (Section 10.8.5)
feedbackCorrect fb.correct. framework code
feedbackTryAgain fb.tryagain. framework code
feedbackSolution fb.solution. framework code, games/<gameId>.json solutionIds
session session. app code (Sections 14, 15, 16)
label label. decorations.json nameAudioId, dot picture nameAudioId

Rules:

  • stringKey is the written form of the line. It is used for the sound-off caption path and VoiceOver where Section 20 requires it, and as the text spoken by TTS when ttsFallbackKey equals it.
  • ttsFallbackKey is what AVSpeechSynthesizer speaks when the recorded file for the current locale is missing (Section 20). Decision: a separate .tts key is used only when the written form would be mispronounced (for example "Zwanzigerfeld" or numerals written as digits); the default is to reuse stringKey.
  • Lines referenced from code (framework and app) are listed in the ZKContent constant CodeReferencedVoiceLines.all: [AudioID], maintained together with Section 21. The validator checks that each of them is declared (C014). Lines of category prompt, carrier and hint that no game file and no template references are reported as orphans (C018, warning).
  • A carrier line is never played alone; it is only reachable through a template. A prompt.common.* ID declared with any other category is C015.
  • Voice lines never contain the app name (Section 20 name mechanism), so a rename needs no re-recording.
{
  "schemaVersion": 1,
  "lines": [
    { "audioId": "prompt.hoer_hin.intro",     "stringKey": "prompt.hoer_hin.intro",     "ttsFallbackKey": "prompt.hoer_hin.intro",     "category": "prompt" },
    { "audioId": "prompt.hoer_hin.where_is",  "stringKey": "prompt.hoer_hin.where_is",  "ttsFallbackKey": "prompt.hoer_hin.where_is",  "category": "prompt" },
    { "audioId": "prompt.common.das_sind",    "stringKey": "prompt.common.das_sind",    "ttsFallbackKey": "prompt.common.das_sind",    "category": "carrier" },
    { "audioId": "prompt.common.das_ist_eins","stringKey": "prompt.common.das_ist_eins","ttsFallbackKey": "prompt.common.das_ist_eins","category": "carrier" },
    { "audioId": "hint.hoer_hin.listen_again","stringKey": "hint.hoer_hin.listen_again","ttsFallbackKey": "hint.hoer_hin.listen_again","category": "hint" },
    { "audioId": "hint.common.listen_again",  "stringKey": "hint.common.listen_again",  "ttsFallbackKey": "hint.common.listen_again",  "category": "hint" },
    { "audioId": "fb.correct.01",             "stringKey": "fb.correct.01",             "ttsFallbackKey": "fb.correct.01",             "category": "feedbackCorrect" },
    { "audioId": "fb.tryagain.01",            "stringKey": "fb.tryagain.01",            "ttsFallbackKey": "fb.tryagain.01",            "category": "feedbackTryAgain" },
    { "audioId": "fb.solution.schau",   "stringKey": "fb.solution.schau",   "ttsFallbackKey": "fb.solution.schau",   "category": "feedbackSolution" },
    { "audioId": "session.new_friend",        "stringKey": "session.new_friend",        "ttsFallbackKey": "session.new_friend",        "category": "session" },
    { "audioId": "label.dot.stern",           "stringKey": "label.dot.stern",           "ttsFallbackKey": "label.dot.stern",           "category": "label" }
  ],
  "templates": [
    {
      "id": "common.quantity",
      "segments": ["prompt.common.das_sind", "{n}"],
      "variants": [
        { "when": { "n": 1 }, "segments": ["prompt.common.das_ist_eins"] }
      ]
    }
  ]
}

The German texts of these example keys are owned by the sections named in 8.3 and mirrored in Section 21 (for instance hint.common.listen_again = "Hör nochmal zu.", Section 10.8.5). This example shows the format only; the shipped file declares every line of the Section 21 inventory and every shared template of Section 21.4.

8.7.1 Utterance template object #

Templates are data (Section 20.6.2 owns their meaning and expansion by UtteranceBuilder; this subsection owns the storage format and validation). The same object shape is used in prompts.json templates (scope common) and in a game file's utterances (scope = that gameId, 8.8.1).

Field Type Required Default Constraints Example
id String yes — template ID pattern (8.3); unique across all templates of the catalog; scope must match the declaring file "common.quantity"
segments [String] yes — non-empty; each element is a declared audio ID or a slot token of Section 20.6.2 ({x}, {x.q}, {split x}, {count x y}, {title}, _, ~<ms>) ["prompt.common.das_sind", "{n}"]
variants [Variant] no [] ordered; the first variant whose when matches replaces segments see example
variants[].when Object yes — non-empty; keys are binding names ([a-z]+, used as a slot in segments), values are integers (equality only) { "n": 1 }
variants[].segments [String] yes — same rules as segments ["prompt.common.das_ist_eins"]
localeOverrides Object no {} keys are locales listed in manifest.voiceLocales other than sourceLocale; each value { "segments": [...], "variants": [...] } with the same rules; used when a later language needs a different word order (Section 20.18); V1 content uses none {}

Template validation (codes in 8.16):

Rule Code
Template ID malformed, duplicated, or its scope does not match the declaring file C025
A literal segment is not a declared audio ID, or is a prompt.<gameId>. / hint.<gameId>. line of another game C026
A slot token is malformed (unknown token form, empty binding name, ~<ms> outside 1–2000), or {x.q} is used for a binding the game cannot guarantee to lie in 1–20 (declared by the game's content schema, 8.8.3) C027
Structural rule broken: two adjacent _ tokens, a template starting or ending with _, a segment naming another template (templates are flat), a when value that is not an integer, or a when key that is not a binding used in the template C028
A template ID referenced by a game file (promptTemplateIds, hintTemplateIds) or required by a game's content schema does not exist C029

8.8 games/.json #

One file per game. It contains the game envelope (identity, tier, skills, round length, step list, prompt and hint IDs, game-specific utterance templates), an optional game-wide gameParameters object and, per step, a game-specific parameters object. The envelope is owned here and is the only valid spelling of a game file; the keys inside gameParameters, parameters and parametersByLevel are owned by the game's specification (Sections 11 to 13). Every game section shows its file in exactly this envelope.

8.8.1 Envelope fields #

Field Type Required Default Constraints Example
schemaVersion Int yes — 1 1
gameId String yes — GameID raw value; must equal the file name without .json "hoer_hin"
tier String yes — "free" or "premium"; must equal GameID.tier (Section 17.5.1); mismatch is an error that hides the game, so content can never unlock a premium game "free"
primarySkill String yes — Skill raw value; must equal the game table below "name"
secondarySkill String yes — Skill raw value; must equal the game table below; must differ from primarySkill "recognize"
displayNameKey String no game.<gameId>.title exists in Content.xcstrings "game.hoer_hin.title"
iconAssetName String no game_<gameId>_icon asset name pattern (8.3); image exists "game_hoer_hin_icon"
tasksPerRound Object yes — keys littleOnes, vorschule; integers 1–8. Default values per Section 9 are 4 and 5; any other value requires the game's section (11–13) to state it. A step may override it (8.8.2) { "littleOnes": 4, "vorschule": 5 }
expectedRoundSeconds Int or Object yes — either one integer for both levels, or an object with keys littleOnes, vorschule; each 20–600. Used by the Abenteuer composition (Section 9.16) and by star pacing estimates (Section 14.3). A step may override it (8.8.2) 75 or { "littleOnes": 80, "vorschule": 70 }
enabledLevels [String] no ["littleOnes", "vorschule"] non-empty subset of the two Level raw values ["littleOnes", "vorschule"]
littleOnesMaxStep Int no 3 1–3; the highest step a littleOnes child can reach in this game (Section 9.11); ignored if littleOnes is not enabled 2
abenteuerEligible Bool no true Entdecken must be false (Section 9.16) true
distinctNumbersPerRound Bool no false true forbids the same planned number twice in one round (Section 9.9); the game's section decides false
promptIds [String] yes — non-empty, unique; each is a declared line of category prompt whose ID starts with prompt.<gameId>.; must contain prompt.<gameId>.intro (Section 10.5.3, T04) plus every role the game's content schema requires (8.8.3) ["prompt.hoer_hin.intro", "prompt.hoer_hin.where_is"]
hintIds Object yes — keys exactly level1 and level2 (the two hint rungs before the solution, Section 10.7); each a non-empty array of declared hint lines. hintIds.levelN lists every line the game may use at that rung (including fragments the game's hint templates compose); which line or template plays for a given task is decided by the game's rules (Sections 11–13), never by rotation. Rotation among variants applies only to fb.correct.* and fb.tryagain.* (Section 10.8.4) see 8.8.5
solutionIds [String] no [] declared lines of category feedbackSolution; game-specific solution fragments beyond the framework's standard fragments (Section 10.8.3) ["fb.solution.hier_fehlt_die"]
utterances [Template] no [] game-specific utterance templates in the object shape of 8.7.1; every template ID has scope <gameId> (e.g. hoer_hin.task_where_is); literal segments may use this game's prompt/hint lines, carrier lines, fb.* lines and number slots see 8.8.5
gameParameters Object no {} game-wide values that do not vary per step (for example Nachspuren glyph definitions, Section 12.4); keys owned by the game's section and validated by its content schema (GameContentSchema.gameParameterKeys, 8.8.3); an undeclared key is C038 Nachspuren's glyphs object (Section 12.4)
steps [Step] yes — ordered; see 8.8.2 —

Fixed game table (the validator's reference; mismatch → C030/C031, game hidden):

gameId tier primarySkill secondarySkill abenteuerEligible
entdecken free name recognize false
wie_viele free count recognize true
hoer_hin free name recognize true
was_fehlt free order recognize true
blitzblick premium subitize count true
mehr_weniger premium compare subitize true
nachspuren premium write recognize true
schuettelbox premium decompose subitize true
froschsprung premium order compare true
zahlenmonster premium count recognize true
memory premium recognize subitize true
punkt_zu_punkt premium order recognize true

Entdecken's primary skill name applies only in its "Zähl mit" mode; its free-explore mode records nothing (Section 11). nachspuren.json must declare "littleOnesMaxStep": 1 because write is active for littleOnes only at step1 (Section 4.11); the validator enforces this (C039).

8.8.2 Step object #

Field Type Required Default Constraints Example
id String yes — DifficultyStep raw value; the array holds exactly step1, step2, step3 in this order in V1 "step1"
labelKey String yes — game.step.leicht for step1, game.step.mittel for step2, game.step.schwer for step3; exists in Content.xcstrings "game.step.leicht"
numberMin Int yes — 1–20 1
numberMax Int yes — numberMin–20 5
numberRangeByLevel Object no both levels use numberMin–numberMax optional keys littleOnes, vorschule, each { "min": Int, "max": Int } inside numberMin–numberMax { "littleOnes": { "min": 1, "max": 10 } }
parameters Object yes — game-owned keys (Sections 11–13); validated by the game's content schema (8.8.3); may be {} see 8.8.5
parametersByLevel Object no {} optional keys littleOnes, vorschule, each an object whose keys are a subset of the game's declared parameter keys { "littleOnes": { "choiceCount": 2 } }
tasksPerRound Int or Object no envelope tasksPerRound one integer for both levels, or an object with optional keys littleOnes, vorschule (a missing level inherits the envelope value); integers 1–8. The task count of a round played at this step (Section 9.9.2); used where a step changes the round length, for example Memory's 3/4/6 pairs (Section 13.3) 6
expectedRoundSeconds Int or Object no envelope expectedRoundSeconds same shape as the envelope field (missing level inherits); each 20–600. Expected duration of a round at this step (Section 9.16.5) 170
minRange String no "r5" (no restriction) r5, r10 or r20: the step is planned only when the child's active range stage is at least this stage (Section 9.9.3). step1 must not set a value above r5 (C035) "r20"
promptIds [String] no game-level promptIds replaces the game-level list for this step —
hintIds Object no game-level hintIds replaces level1 and/or level2 for this step (keys given are replaced, others inherited) —
promptTemplateIds [String] no the game's default task templates (its schema's requiredTemplateNames) ordered, non-empty, unique; each an existing template ID of scope common or <gameId> (C029); the task-prompt templates the game may use at this step; the game's section decides how it chooses among them ["hoer_hin.task_where_is", "hoer_hin.task_show_me"]
hintTemplateIds Object no the game's default hint templates optional keys level1, level2, each a non-empty array of existing template IDs (C029); keys given replace the default for that rung { "level1": ["hoer_hin.hint_listen_again"] }

The difficulty factor of a step is not stored in JSON; it is a property of DifficultyStep (step1 0.8, step2 1.0, step3 1.2; Section 9.5).

Step coverage rule (C035): so that every enabled game can always be planned (Section 9.9), step1 of every game must allow at least 2 numbers within 1–5 for littleOnes (if enabled) and at least 2 numbers within 1–10 for vorschule (if enabled), after applying numberRangeByLevel, and step1 must not declare a minRange above r5.

8.8.3 Game parameters and the per-game content schema #

The generic shape is fixed here; the keys are owned by the games.

  • parameters is the common parameter block of a step. parametersByLevel.<level> is the level block. The effective parameters for a task are the step's parameters overlaid key-by-key (shallow merge) by parametersByLevel.<level> of the child's level. This is the only parameter layout; Section 10.3.3 applies it when it builds GameParameters. A game may also express level differences inside its own parameter values (for example an object keyed by level, as Blitzblick does in Section 12.2.13); that is the game's choice and is validated by the game's schema.
  • gameParameters (8.8.1) holds game-wide values that are the same for every step and level. It is not merged into the step parameters; the game reads it from GameDefinition.gameParameters (exposed to the game through GameMetadata, Section 10.3.2).
  • Every game declares and validates its own parameter schema. ZKContent cannot import game targets, so each game target provides a type conforming to GameContentSchema, and the composition root passes all 12 schema types to ContentLoader (8.15). ZKContent runs the generic checks (declared keys, types, ranges, required keys, unknown keys) and then calls the game's cross-field validation.
// ZKContent
public enum ParameterType: Sendable, Hashable {
    case int(ClosedRange<Int>)
    case double(ClosedRange<Double>)
    case bool
    case string(allowed: Set<String>?)          // nil = any non-empty string
    case intArray(element: ClosedRange<Int>, count: ClosedRange<Int>)
    case stringArray(allowed: Set<String>?, count: ClosedRange<Int>)
    case weights(allowedKeys: Set<String>)      // object of key -> Double > 0, sum 1.0 ± 0.001
    case object                                 // free-form object, checked by `validateStep` only
    case any                                    // any JSON value, checked by `validateStep` only
}

public struct ParameterKeySpec: Sendable, Hashable {
    public let key: String
    public let type: ParameterType
    public let required: Bool                    // required in the effective (merged) parameters
    public init(_ key: String, _ type: ParameterType, required: Bool = true)
}

public protocol GameContentSchema: Sendable {
    static var gameID: GameID { get }
    /// Keys allowed in `parameters` and `parametersByLevel.<level>`. Any other key is an error (C038).
    static var parameterKeys: [ParameterKeySpec] { get }
    /// Keys allowed in the envelope's `gameParameters`. Empty when the game has none. Any other key is C038.
    static var gameParameterKeys: [ParameterKeySpec] { get }
    /// Prompt roles this game needs; each role r requires "prompt.<gameId>.<r>" in promptIds.
    static var requiredPromptRoles: Set<String> { get }        // always contains "intro"
    /// Template names this game's code uses by default; each name t requires the template
    /// "<gameId>.t" in `utterances` (or, for names starting with "common.", a shared template). C029 if absent.
    static var requiredTemplateNames: Set<String> { get }
    /// Binding names this game guarantees to supply with values in 1...20 (allowed in {x.q} slots, C027).
    static var questionBindings: Set<String> { get }            // usually ["n"]
    /// Cross-field and cross-file checks (e.g. dot pictures per step for Punkt zu Punkt).
    /// Called once per step and level with the merged, generically validated parameters.
    static func validateStep(
        _ step: GameStepDefinition,
        level: Level,
        effectiveParameters: [String: JSONValue],
        catalog: ContentCatalogDraft
    ) -> [ContentIssue]
    /// Checks of `gameParameters` beyond the generic key/type checks. Called once per game.
    static func validateGame(
        gameParameters: [String: JSONValue],
        catalog: ContentCatalogDraft
    ) -> [ContentIssue]
}

Games without game-wide values or special template needs return empty sets and an empty issue list; a protocol extension in ZKContent provides these defaults (gameParameterKeys = [], requiredTemplateNames = [], questionBindings = ["n"], validateGame returning []).

Each game target also declares its typed parameters struct (<Game>StepParameters: Decodable, Sendable, Section 10.3.3) and decodes it from the effective parameters at round start. Because the content schema has already validated the same data, that decode cannot fail in a shipped build; if it does, Section 10.4.4 applies.

8.8.4 Decoded types and what is derived from them #

// ZKContent
public struct GameDefinition: Sendable, Hashable {
    public let gameID: GameID
    public let tier: GameTier
    public let primarySkill: Skill
    public let secondarySkill: Skill
    public let displayNameKey: String
    public let iconAssetName: String
    public let tasksPerRound: [Level: Int]
    public let expectedRoundSeconds: [Level: Int]
    public let enabledLevels: Set<Level>
    public let littleOnesMaxStep: DifficultyStep
    public let abenteuerEligible: Bool
    public let distinctNumbersPerRound: Bool
    public let promptIDs: [AudioID]
    public let hintIDs: [HintRung: [AudioID]]            // .level1, .level2
    public let solutionIDs: [AudioID]
    public let utteranceTemplateIDs: [String]            // IDs of this game's `utterances` (templates live in ContentCatalog.templates)
    public let gameParameters: [String: JSONValue]       // envelope `gameParameters`, validated
    public let steps: [GameStepDefinition]               // ordered, contiguous from step1
}

public enum HintRung: String, Sendable, Hashable, Codable { case level1, level2 }

public struct GameStepDefinition: Sendable, Hashable {
    public let step: DifficultyStep
    public let labelKey: String
    public let numberRange: [Level: ClosedRange<Int>]    // numberRangeByLevel applied
    public let tasksPerRound: [Level: Int]               // step override applied (else the envelope value)
    public let expectedRoundSeconds: [Level: Int]        // step override applied (else the envelope value)
    public let minRange: RangeStage                      // .r5 when absent
    public let parameters: [String: JSONValue]           // common block
    public let parametersByLevel: [Level: [String: JSONValue]]
    public let promptIDs: [AudioID]                      // step override applied
    public let hintIDs: [HintRung: [AudioID]]            // step override applied
    public let promptTemplateIDs: [String]               // step override applied (else the game's defaults)
    public let hintTemplateIDs: [HintRung: [String]]     // step override applied (else the game's defaults)
    public func effectiveParameters(for level: Level) -> [String: JSONValue]
}

// ZKContent — decoded template object of 8.7.1, expanded by UtteranceBuilder (Section 20.6)
public struct UtteranceTemplate: Sendable, Hashable, Codable {
    public struct Variant: Sendable, Hashable, Codable {
        public let when: [String: Int]
        public let segments: [String]
    }
    public struct LocaleOverride: Sendable, Hashable, Codable {
        public let segments: [String]
        public let variants: [Variant]
    }
    public let id: String                                // "<scope>.<name>"
    public let segments: [String]
    public let variants: [Variant]                       // [] when absent
    public let localeOverrides: [String: LocaleOverride] // [:] when absent
}

Two views are derived from GameDefinition, both by the composition root:

View Owner Derivation
GameMetadata (Section 10.3.2) Section 10 promptIDs becomes a dictionary keyed by the last ID segment (prompt.hoer_hin.intro → key "intro"); hintIDs[.level1/.level2] map to HintLevel.level1/.level2; solutionIDs map to HintLevel.solution; StepConfiguration.numberRange is numberRange per level; parameters is wrapped in GameParameters with the level overlay; gameParameters is passed unchanged; the step's promptTemplateIDs/hintTemplateIDs are the template IDs the game hands to UtteranceBuilder (Section 10.3.1).
GameCapabilities (Section 9.3.3) Section 9 Skills, tier, per-level round length and expected seconds, per-step number ranges, per-step task count and expected seconds (stepTasksPerRound, stepExpectedSeconds, filled only for steps that override the envelope), per-step stepMinRange, littleOnesMaxStep, abenteuerEligible, distinctNumbersPerRound.

8.8.5 Example: games/hoer_hin.json #

The file below is structurally complete: it passes validation once every referenced line is declared in prompts.json. The envelope structure is owned here; the line IDs, template contents and parameters keys and values shown are those of Section 11.4, which owns them.

{
  "schemaVersion": 1,
  "gameId": "hoer_hin",
  "tier": "free",
  "primarySkill": "name",
  "secondarySkill": "recognize",
  "displayNameKey": "game.hoer_hin.title",
  "iconAssetName": "game_hoer_hin_icon",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": { "littleOnes": 50, "vorschule": 60 },
  "enabledLevels": ["littleOnes", "vorschule"],
  "littleOnesMaxStep": 3,
  "abenteuerEligible": true,
  "distinctNumbersPerRound": false,
  "promptIds": ["prompt.hoer_hin.intro", "prompt.hoer_hin.where_is", "prompt.hoer_hin.show_me"],
  "hintIds": {
    "level1": ["hint.hoer_hin.listen_again"],
    "level2": ["hint.hoer_hin.so_many"]
  },
  "solutionIds": [],
  "utterances": [
    { "id": "hoer_hin.task_where_is",    "segments": ["prompt.hoer_hin.where_is", "{n.q}"] },
    { "id": "hoer_hin.task_show_me",     "segments": ["prompt.hoer_hin.show_me", "{n}"] },
    { "id": "hoer_hin.hint_listen_again", "segments": ["hint.hoer_hin.listen_again", "{n}"] },
    { "id": "hoer_hin.hint_so_many",     "segments": ["hint.hoer_hin.so_many", "{n}"] }
  ],
  "steps": [
    {
      "id": "step1",
      "labelKey": "game.step.leicht",
      "numberMin": 1,
      "numberMax": 20,
      "numberRangeByLevel": { "vorschule": { "min": 1, "max": 10 } },
      "parameters": { "optionCount": 2, "distractorSlots": [["far"]], "farExcludesConfusables": true },
      "parametersByLevel": {}
    },
    {
      "id": "step2",
      "labelKey": "game.step.mittel",
      "numberMin": 1,
      "numberMax": 20,
      "parameters": { "optionCount": 3, "distractorSlots": [["near1"], ["confusableAuditory", "confusableVisual", "near2"]] },
      "parametersByLevel": {}
    },
    {
      "id": "step3",
      "labelKey": "game.step.schwer",
      "numberMin": 1,
      "numberMax": 20,
      "numberRangeByLevel": { "vorschule": { "min": 6, "max": 20 } },
      "parameters": {
        "optionCount": 4,
        "distractorSlots": [["tensPartner", "confusableAuditory"], ["confusableVisual", "near1"], ["near1", "near2"]],
        "requireTeenOptionAtR20": true
      },
      "parametersByLevel": {}
    }
  ]
}

Notes on the example: littleOnes targets are limited by the active range stage (Section 9.9.2), so numberMin/numberMax 1–20 is safe for them; the step coverage rule (8.8.2) is met because step1 offers 1–5 for littleOnes and 1–10 for vorschule. No step overrides tasksPerRound, expectedRoundSeconds or minRange; the task templates are the game's defaults (requiredTemplateNames = task_where_is, task_show_me, hint_listen_again, hint_so_many). An example of the per-step overrides is Memory (Section 13.3: "tasksPerRound": 3, 4, 6 on its three steps) and Punkt zu Punkt (Section 13.4: "minRange": "r10" on step2, "r20" on step3).

8.9 decorations.json #

The decoration catalog (items, prices, unlocks) is owned by Section 14.4.5; this is its file format.

Field Type Required Default Constraints Example
schemaVersion Int yes — 1 1
sizePrices Object yes — exactly { "small": 10, "medium": 25, "large": 50, "special": 100 } (Section 14.4.5); any other value is C050 for the whole file see example
items [Item] yes — unique id —
items[].id String yes — deco.<slug> "deco.tulpe"
items[].size String yes — small, medium, large, special "small"
items[].price Int yes — must equal sizePrices[size] (C050) 10
items[].assetName String yes — deco_<slug> (8.3); image exists "deco_tulpe"
items[].nameKey String no <id>.name exists in Content.xcstrings "deco.tulpe.name"
items[].nameAudioId String no none declared line of category label, pattern label.deco.<slug>; optional, V1 records no decoration name lines, so V1 items omit the field "label.deco.tulpe" (content drop only; not recorded in V1)
items[].soundId String no none (a tap plays sfx.garden_tap, Section 14.6) sfx.deco_<slug> with a file in Audio/common/; fallback when absent is sfx.garden_tap (Section 14.6) "sfx.deco_ente" (content drop only; V1 ships none, Section 21.11)
items[].unlock Object no { "type": "none" } { "type": "none" } or { "type": "minFriends", "count": k } with k 1–20; k > 10 is warning C052 because a littleOnes child without a range override cannot reach it (Section 14.4.5) { "type": "minFriends", "count": 3 }
items[].sortIndex Int yes — unique within a size; shop shelf order ascending 3
items[].retired Bool no false retired items are hidden from the shop but still render if owned (8.19) false
{
  "schemaVersion": 1,
  "sizePrices": { "small": 10, "medium": 25, "large": 50, "special": 100 },
  "items": [
    { "id": "deco.tulpe",        "size": "small",   "price": 10,  "assetName": "deco_tulpe",        "nameKey": "deco.tulpe.name",        "sortIndex": 2,  "unlock": { "type": "none" } },
    { "id": "deco.ente",         "size": "medium",  "price": 25,  "assetName": "deco_ente",         "nameKey": "deco.ente.name",         "sortIndex": 11, "unlock": { "type": "none" } },
    { "id": "deco.igel",         "size": "medium",  "price": 25,  "assetName": "deco_igel",         "nameKey": "deco.igel.name",         "sortIndex": 9,  "unlock": { "type": "minFriends", "count": 2 } },
    { "id": "deco.teich",        "size": "large",   "price": 50,  "assetName": "deco_teich",        "nameKey": "deco.teich.name",        "sortIndex": 3,  "unlock": { "type": "minFriends", "count": 2 } },
    { "id": "deco.baumhaus",     "size": "special", "price": 100, "assetName": "deco_baumhaus",     "nameKey": "deco.baumhaus.name",     "sortIndex": 1,  "unlock": { "type": "minFriends", "count": 3 } }
  ]
}

This excerpt shows 5 of the 60 V1 items (values from Section 14.4.5). The shipped file contains all 60. No V1 item sets nameAudioId or soundId: decoration name lines are optional and are not recorded in V1, and decoration sounds are optional content drops (a tap on a placed item without its own sound plays sfx.tap, Section 14.6).

ZKRewards does not import ZKContent. The app target builds the reward service's purchase input (DecorationOffer: decoration ID, price, required friend count) from a DecorationDefinition and passes it in (Section 14.12).

// ZKCore (listed in Section 5.3.2): shared by ZKContent and ZKRewards, declared once
// public enum DecorationSize: String, Sendable, Hashable, Codable, CaseIterable { case small, medium, large, special }

// ZKContent
public enum UnlockRequirement: Sendable, Hashable { case none, minFriends(Int) }
public struct DecorationDefinition: Sendable, Hashable, Identifiable {
    public let id: String
    public let size: DecorationSize
    public let price: Int
    public let assetName: String
    public let nameKey: String
    public let nameAudioID: AudioID?
    public let soundID: AudioID
    public let unlock: UnlockRequirement
    public let sortIndex: Int
    public let retired: Bool
}

8.10 stickers.json #

The sticker list and album layout are owned by Section 14.7 (6 pages, 52 places in V1: page 1 friends 1–10, page 2 friends 11–20, pages 3–5 dot pictures of step 1–3, page 6 milestones). This is the file format.

Field Type Required Default Constraints Example
schemaVersion Int yes — 1 1
pages [Page] yes — ordered; index 1…P contiguous —
pages[].index Int yes — 1–12 1
pages[].tabAssetName String yes — sticker_tab_<slug>; image exists (the picture tab of Section 14.7.1) "sticker_tab_friends"
pages[].places Int yes — 1–12; number of places on the page 10
pages[].columns Int yes — 1–6; row length (Section 14.7.1 arrangement) 5
stickers [Sticker] yes — unique id —
stickers[].id String yes — pattern per kind (8.3) "sticker.friend.7"
stickers[].kind String yes — friend, dotPicture, milestone "friend"
stickers[].page Int yes — an existing page index 1
stickers[].place Int yes — 0 … places − 1 of that page; (page, place) unique 6
stickers[].assetName String yes — image exists; friend stickers reuse the friend's happy art friend_<nn>_happy (Section 24.4.3); dot-picture stickers sticker_dot_<pictureId>; milestone stickers sticker_milestone_<key> "friend_07_happy"
stickers[].nameKey String no <id>.name exists in Content.xcstrings "sticker.friend.7.name"
stickers[].source Object yes — friend: { "n": 1–20 } and id = sticker.friend.<n>; dotPicture: { "pictureId": "<id>" } and id = sticker.dot.<pictureId>; milestone: { "milestone": "<key>" } and id = sticker.milestone.<key> { "n": 7 }
stickers[].retired Bool no false retired stickers are never awarded again; already awarded ones stay visible false

Milestone keys (Section 14.7.4): first_round, first_abenteuer, friends_5, friends_10, friends_20, stars_100, stars_500, first_decoration. The award logic for each key is code in ZKRewards; a sticker with an unknown milestone key is C061 (skipped).

Coverage rules: exactly one non-retired friend sticker per n = 1–20; exactly one non-retired milestone sticker per milestone key; exactly one non-retired dot-picture sticker per non-retired dot picture, and the picture's stickerId points back to it (C062).

Decision: the album UI (Section 14.7) renders pages and places from this file, so a later content drop can add a page (for example page 7 for new dot pictures) without code, as long as the page count stays ≤ 12.

{
  "schemaVersion": 1,
  "pages": [
    { "index": 1, "tabAssetName": "sticker_tab_friends",    "places": 10, "columns": 5 },
    { "index": 2, "tabAssetName": "sticker_tab_friends_2",  "places": 10, "columns": 5 },
    { "index": 3, "tabAssetName": "sticker_tab_dots_1",     "places": 8,  "columns": 4 },
    { "index": 4, "tabAssetName": "sticker_tab_dots_2",     "places": 8,  "columns": 4 },
    { "index": 5, "tabAssetName": "sticker_tab_dots_3",     "places": 8,  "columns": 4 },
    { "index": 6, "tabAssetName": "sticker_tab_milestones", "places": 8,  "columns": 4 }
  ],
  "stickers": [
    { "id": "sticker.friend.7",               "kind": "friend",     "page": 1, "place": 6, "assetName": "friend_07_happy",       "source": { "n": 7 } },
    { "id": "sticker.dot.stern",              "kind": "dotPicture", "page": 3, "place": 0, "assetName": "sticker_dot_stern",            "source": { "pictureId": "stern" } },
    { "id": "sticker.milestone.first_round",  "kind": "milestone",  "page": 6, "place": 0, "assetName": "sticker_milestone_first_round", "source": { "milestone": "first_round" } }
  ]
}

This excerpt shows all 6 pages and 3 of the 52 stickers. The shipped file contains all 52 stickers of Section 14.7.

8.11 friends.json #

The 20 Zahlenfreunde and their personalities are owned by Section 14.5. This file maps each number to its name key, art and voice lines.

Field Type Required Default Constraints Example (n = 7)
schemaVersion Int yes — 1 1
friends [Friend] yes — exactly n = 1…20, unique —
friends[].n Int yes — 1–20 7
friends[].nameKey String no friend.<n>.name exists in Content.xcstrings ("Sieben") "friend.7.name"
friends[].assets Object yes — keys exactly idle, happy, sleepy, silhouette (the four states of Section 14.5.1); each an existing image see example
friends[].voiceLines [Line] yes — contains role hello exactly once; roles unique —
friends[].voiceLines[].role String yes — slug; V1 roles: hello (required). Further roles may be added by Section 21 without code only if a feature already plays them by role "hello"
friends[].voiceLines[].audioId String yes — friend.<n>.<role>; declared here (not in prompts.json) "friend.7.hello"
friends[].voiceLines[].stringKey String yes — exists in Content.xcstrings "friend.7.hello"
friends[].voiceLines[].ttsFallbackKey String yes — exists in Content.xcstrings "friend.7.hello"
{
  "schemaVersion": 1,
  "friends": [
    {
      "n": 7,
      "nameKey": "friend.7.name",
      "assets": {
        "idle": "friend_07_idle",
        "happy": "friend_07_happy",
        "sleepy": "friend_07_sleepy",
        "silhouette": "friend_07_silhouette"
      },
      "voiceLines": [
        { "role": "hello", "audioId": "friend.7.hello", "stringKey": "friend.7.hello", "ttsFallbackKey": "friend.7.hello" }
      ]
    }
  ]
}

This excerpt shows 1 of the 20 required entries.

8.12 dotpictures/.json #

One file per Punkt-zu-Punkt picture. The picture set, its IDs and the step assignment are the 24 pictures listed in 8.5 (the manifest.dotPictures order equals the album order of Section 14.7.2): step1 stern, haus, fisch, sonne (5 points) and herz, boot, blume, luftballon (10 points); step2 schmetterling, schnecke, katze, hase, auto, rakete, tannenbaum, vogel (10 points); step3 elefant, giraffe, dinosaurier, schiff, eisenbahn, burg, schildkroete, einhorn (20 points). Section 13.4 uses exactly these IDs; the dot layouts are authored per Section 13.4.

Coordinate system: the drawing area is a square. x and y are normalised to 0…1 with the origin at the top-left corner, x to the right, y downward. The game scales the square to the largest square that fits its task area (Section 13).

Field Type Required Default Constraints Example
schemaVersion Int yes — 1 1
pictureId String yes — slug; equals the file name without .json and is listed in manifest.dotPictures "stern"
step String yes — step1, step2 or step3 "step1"
nameKey String no dotpicture.<pictureId>.name exists in Content.xcstrings "dotpicture.stern.name"
nameAudioId String yes — declared label line, must equal label.dot.<pictureId>; the reveal line Punkt zu Punkt plays when the picture is complete (for example "Ein Stern!", Section 13.4) "label.dot.stern"
closed Bool no false true: after the last dot the line also connects back to the first dot true
points [Point] yes — ordered by number; see rules —
points[].number Int yes — consecutive integers starting at points[0].number; first number 1–20, last number ≤ 20; 3–20 points 1
points[].x Double yes — 0.05–0.95 0.5
points[].y Double yes — 0.05–0.95 0.08
revealAssetName String yes — dotpic_<pictureId>_reveal; image exists; drawn inside the same unit square "dotpic_stern_reveal"
outlineAssetName String no none faint background art shown under the dots, if Section 13 uses one "dotpic_stern_outline"
stickerId String yes — sticker.dot.<pictureId>; exists in stickers.json with kind dotPicture and source.pictureId equal to this picture "sticker.dot.stern"
sortIndex Int yes — unique within a step; rotation order (Section 13) 0
retired Bool no false retired pictures are no longer offered; earned stickers stay false

Geometry rules:

Rule Severity
Every coordinate in 0.05–0.95 (keeps dots and their 60 pt hit areas inside the canvas margin) error C081
Minimum distance between any two dot centres ≥ 0.17 of the canvas side (keeps neighbouring 60 pt hit areas and the 44 pt / 64 pt number labels apart on the smallest canvas, Section 13.4; any remaining overlap of hit areas resolves to the nearest centre, Section 10.9.3) error C082
A connecting segment passes within 0.03 of a dot that is not one of its endpoints warning C083
Dot count per step matches the Punkt zu Punkt step parameters checked by the Punkt zu Punkt content schema (Section 13), error C038
{
  "schemaVersion": 1,
  "pictureId": "stern",
  "step": "step1",
  "nameKey": "dotpicture.stern.name",
  "nameAudioId": "label.dot.stern",
  "closed": true,
  "points": [
    { "number": 1, "x": 0.50, "y": 0.10 },
    { "number": 2, "x": 0.75, "y": 0.86 },
    { "number": 3, "x": 0.10, "y": 0.39 },
    { "number": 4, "x": 0.90, "y": 0.39 },
    { "number": 5, "x": 0.25, "y": 0.86 }
  ],
  "revealAssetName": "dotpic_stern_reveal",
  "stickerId": "sticker.dot.stern",
  "sortIndex": 0,
  "retired": false
}

The five points are the tips of a star, numbered in drawing order, so connecting 1-2-3-4-5 and closing back to 1 draws the five-pointed star. They pass every geometry rule: all coordinates lie in 0.05–0.95, the closest pair of dots is 0.49 apart (C082 requires ≥ 0.17), and no segment passes within 0.03 of a dot that is not one of its endpoints. stern is a 5-point step1 picture (Section 13.4).

8.13 avatars.json #

Avatars (12 animals) and color themes (6) for child profiles (Section 15 owns the list and the picker; Section 19 owns the colors).

Field Type Required Default Constraints Example
schemaVersion Int yes — 1 1
avatars [Avatar] yes — unique id; V1 has exactly 12 non-retired avatars —
avatars[].id String yes — avatar.<slug> "avatar.fuchs"
avatars[].assetName String yes — avatar_<slug>; image exists "avatar_fuchs"
avatars[].sleepyAssetName String yes — avatar_<slug>_sleepy; image exists (used when no friend is present on S-10/S-16, Section 14.5.5) "avatar_fuchs_sleepy"
avatars[].nameKey String no <id>.name exists in Content.xcstrings; used for VoiceOver and the parent area "avatar.fuchs.name"
avatars[].sortIndex Int yes — unique; picker order 0
avatars[].retired Bool no false retired avatars are hidden from the picker; a profile that uses one keeps showing it false
colorThemes [Theme] yes — unique id; V1 has exactly 6 —
colorThemes[].id String yes — theme.<slug> "theme.sonne"
colorThemes[].primaryColorName String yes — a color set in Illustrations.xcassets, theme_<slug>_primary "theme_sonne_primary"
colorThemes[].backgroundColorName String yes — a color set, theme_<slug>_background "theme_sonne_background"
colorThemes[].nameKey String no <id>.name exists in Content.xcstrings "theme.sonne.name"
colorThemes[].sortIndex Int yes — unique 0
{
  "schemaVersion": 1,
  "avatars": [
    { "id": "avatar.fuchs", "assetName": "avatar_fuchs", "sleepyAssetName": "avatar_fuchs_sleepy", "nameKey": "avatar.fuchs.name", "sortIndex": 0 },
    { "id": "avatar.baer",  "assetName": "avatar_baer",  "sleepyAssetName": "avatar_baer_sleepy",  "nameKey": "avatar.baer.name",  "sortIndex": 1 }
  ],
  "colorThemes": [
    { "id": "theme.sonne", "primaryColorName": "theme_sonne_primary", "backgroundColorName": "theme_sonne_background", "nameKey": "theme.sonne.name", "sortIndex": 0 },
    { "id": "theme.meer",  "primaryColorName": "theme_meer_primary",  "backgroundColorName": "theme_meer_background",  "nameKey": "theme.meer.name",  "sortIndex": 1 }
  ]
}

This excerpt shows 2 of 12 avatars and 2 of 6 themes; the authoritative lists are in Sections 15 and 21.

8.14 String Catalog Entries Referenced by Content #

Content.xcstrings (Section 20 owns catalog mechanics; its source language is German) contains every key referenced from content JSON and nothing else. Format excerpt (Xcode writes this file; shown so the validator's parser is unambiguous):

{
  "sourceLanguage": "de",
  "version": "1.0",
  "strings": {
    "number.7.word": {
      "localizations": { "de": { "stringUnit": { "state": "translated", "value": "sieben" } } }
    },
    "prompt.hoer_hin.intro": {
      "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Hör gut zu!" } } }
    }
  }
}

A key "exists for locale L" when strings[key].localizations[L] has either a stringUnit with a non-empty value whose state is translated, or a variations object (plural or device) whose every leaf stringUnit satisfies the same condition. Keys marked "shouldTranslate": false count as existing in every locale if they exist in the source language.

8.15 Loading: ContentLoader, ContentStore and ContentCatalog #

Loading runs once per launch during bootstrap step 6 (Section 5.4.4), off the main actor, and produces an immutable catalog.

// ZKContent
public struct ContentLoadOptions: Sendable {
    public var schemas: [any GameContentSchema.Type]     // all 12 in the app; tests may pass fewer
    public var resolvers: ContentResolvers
    public var mode: ValidationMode
    public init(schemas: [any GameContentSchema.Type], resolvers: ContentResolvers, mode: ValidationMode)
}

public enum ValidationMode: Sendable {
    case debug      // all checks incl. image assets; assertion on errors (8.17)
    case release    // all checks except image-asset existence and size warnings; no assertion
    case test       // all checks incl. release gates (8.18); never asserts, returns the report
}

public struct ContentResolvers: Sendable {
    /// true if the key exists for the locale in the given table ("Content" or "Localizable").
    public var stringExists: @Sendable (_ key: String, _ table: String, _ locale: String) -> Bool
    /// true if Resources/Audio/<locale>/<id>.m4a (or Audio/common/ for sfx./music.) exists.
    public var audioFileExists: @Sendable (_ id: AudioID, _ locale: String) -> Bool
    /// true if an image or color set with this name exists. Not called in .release mode.
    public var assetExists: @Sendable (_ name: String) -> Bool
}

public enum ContentLoader {
    /// Short form named in Section 5.4.2. Runs the generic checks only (no game schemas),
    /// with bundle-based resolvers and the build's default mode. Used by previews and tools.
    public static func load(baseURL: URL, localeIdentifier: String) async -> any ContentStore
    /// Full form. The composition root (bootstrap step 6, Section 5.4.4) and the tests use this form
    /// and pass all 12 GameContentSchema types, so parameter errors hide games in production.
    public static func load(baseURL: URL, localeIdentifier: String, options: ContentLoadOptions) async -> any ContentStore
}

public protocol ContentStore: Sendable {
    var catalog: ContentCatalog { get }                        // immutable, Sendable
    var validationReport: ContentValidationReport { get }      // shown in the developer menu (Section 5.10)
    func isGameAvailable(_ id: GameID) -> Bool                 // false if hidden (8.17)
}

public struct ContentCatalog: Sendable {
    public let manifest: ContentManifest
    public let numbers: [NumberFact]                           // always exactly 20, index n-1
    public let zero: VoiceLine?                                // num.0 from numbers.json `zero` (8.6.1); nil if absent
    public let voiceLines: [AudioID: VoiceLine]                // numbers (incl. num.0) + prompts + friends
    public let templates: [String: UtteranceTemplate]          // shared `templates` + every game's `utterances`, valid only
    public let games: [GameID: GameDefinition]                 // only available (valid) games
    public let decorations: [DecorationDefinition]             // non-retired first by size, then sortIndex; retired kept for rendering
    public let stickerPages: [StickerPage]
    public let stickers: [StickerDefinition]
    public let friends: [FriendDefinition]                     // always exactly 20, index n-1
    public let dotPictures: [DotPictureDefinition]             // valid only, by step then sortIndex
    public let avatars: [AvatarDefinition]                     // at least 1 (8.17)
    public let colorThemes: [ColorThemeDefinition]             // at least 1 (8.17)

    /// n outside 1...20 is a programming error: it is logged at fault level and clamped to 1...20; never traps.
    public func number(_ n: Int) -> NumberFact
    /// Same clamping rule as `number(_:)`.
    public func friend(_ n: Int) -> FriendDefinition
    public func template(_ id: String) -> UtteranceTemplate?
    public func decoration(id: String) -> DecorationDefinition?
    public func sticker(id: String) -> StickerDefinition?
    public func dotPictures(for step: DifficultyStep) -> [DotPictureDefinition]
    public func voiceLine(_ id: AudioID) -> VoiceLine?
}

public struct VoiceLine: Sendable, Hashable {
    public let audioID: AudioID
    public let stringKey: String
    public let ttsFallbackKey: String
    public let category: VoiceLineCategory       // .number, .prompt, .carrier, .hint, .feedbackCorrect, .feedbackTryAgain,
                                                 // .feedbackSolution, .session, .label, .friend
    public let isRecorded: [String: Bool]        // locale -> file present (from the audio index)
}

public struct ContentValidationReport: Sendable {
    public let contentVersion: Int
    public let issues: [ContentIssue]            // sorted by severity, file, path
    public let hiddenGames: [GameID: [ContentIssue]]
    public let durationMs: Int
    public var errorCount: Int { get }
    public var warningCount: Int { get }
}

Load sequence (all steps inside one detached task, Task.detached(priority: .userInitiated)):

  1. Build the audio index: enumerate Audio/<locale>/ for every locale in the manifest (after step 2) and Audio/common/ once with FileManager.contentsOfDirectory, store file names without extension in a Set<String> per folder. Existence checks are then O(1).
  2. Read and decode manifest.json. On failure use the built-in default manifest (ContentManifest.builtInV1, identical to the example in 8.5) and record C001/C002.
  3. Decode, per file, with per-item decoding (8.4): numbers.json (including zero), prompts.json (lines and templates), friends.json (voice-line registry complete after this step), then stickers.json, decorations.json, avatars.json, each dotpictures/<id>.json, each games/<id>.json (including utterances and gameParameters).
  4. Run the generic validation (8.16) on every item, including every template (8.7.1); then the cross-file checks; then, for each game, its GameContentSchema.validateGame once and validateStep for each step and each enabled level.
  5. Apply the invalid-content policy (8.17): drop invalid items, substitute derived defaults where defined, compute hidden games.
  6. Build the immutable ContentCatalog and ContentValidationReport; log a summary at notice level (category content, Section 6): content version, counts, error and warning counts, duration.
  7. Hand the result to the main actor; the composition root calls GameRegistry.hide(_:) with the hidden games (Section 5.4.4 step 7).

Performance: the whole sequence takes at most 150 ms on iPhone SE (2nd generation) in Release mode (measured with the signpost ContentLoad, part of the launch budget owned by Section 24). The decoded catalog is well under 1 MB of memory. The catalog is never reloaded during the process lifetime.

Live resolvers (composition root, app target):

Resolver Implementation
stringExists Resolve Bundle.main.path(forResource: locale, ofType: "lproj"); if it is nil, every key is missing for that locale; otherwise create Bundle(path:) once per locale (cached) and test localizedString(forKey: key, value: sentinel, table: table) != sentinel with sentinel = "__zk_missing__".
audioFileExists The audio index of step 1.
assetExists Debug only: UIImage(named: name, in: .main, with: nil) != nil or UIColor(named: name, in: .main, compatibleWith: nil) != nil, with the flat asset name (namespaces are off, 8.2).

Verification step (milestone M1): confirm with one missing and one present key that the compiled Content.strings in de.lproj is found by the stringExists resolver on the iOS 17.0 simulator runtime and on the current runtime.

8.16 Validation Rules #

Every issue has a code, a severity, the file, a JSON path (e.g. steps[1].parameters.optionCount) and a message in English (developer-facing only).

// ZKContent
public struct ContentIssue: Sendable, Hashable, Identifiable {
    public enum Severity: String, Sendable, Codable { case error, warning }
    public let id: UUID
    public let code: String          // "C012"
    public let severity: Severity
    public let file: String          // "games/hoer_hin.json"
    public let path: String          // "hintIds.level1[0]"
    public let message: String
}
Code Severity Rule
C001 error A file listed in the manifest (or the manifest itself) is missing
C002 error JSON syntax error or duplicate key in an object
C003 error schemaVersion missing or unsupported
C004 error Required field missing or null
C005 error Wrong JSON type (including a fraction where an integer is expected)
C006 error Value out of the documented range or not in the allowed set
C007 warning Unknown key (possible typo)
C008 error Identifier does not match its pattern (8.3), including asset names (flat pattern ^[a-z][a-z0-9_]*$)
C009 error Duplicate identifier (IDs, n, (page, place), sortIndex where unique)
C010 error Reference to an unknown item (sticker, picture, friend, voice line)
C011 error String key missing for a locale in voiceLocales (table Content)
C012 error Voice line has no audio file for the source locale de AND its ttsFallbackKey does not exist
C013 warning Voice line has no audio file for a locale but a valid TTS fallback (release gate for de, 8.18)
C014 error Audio ID referenced (by content, a template segment or CodeReferencedVoiceLines.all) but not declared; this includes num.0 without a zero entry in numbers.json
C015 error Voice-line category does not match its audio ID prefix (prompt.common. requires carrier, prompt.<gameId>. requires prompt); or a prompt/hint ID of another game is referenced
C016 error Image or color asset missing (Debug and test only)
C017 warning Orphan file: content file not listed in the manifest, or audio file without a declared line, or SFX/music file not referenced by CodeReferencedAudio.effects
C018 warning Declared prompt/hint line referenced by no game
C019 warning Image larger than 1.25× the maximum pixel size of its category (Section 24.4.3) (test only)
C020 error numbers.json does not contain exactly n = 1–20
C021 error structure, dice, fingers, audio IDs, wordKey or friendId differ from the rule in 8.6
C022 error zero entry malformed (wordKey not number.0.word or audioId not num.0), or num.0 referenced by any game file or template other than Nachspuren's (8.6.1)
C025 error Template ID malformed, duplicated, or its scope does not match the declaring file (8.7.1)
C026 error Template segment is not a declared audio ID, or names a prompt/hint line of another game
C027 error Template slot token malformed, or {x.q} used for a binding not in the game's questionBindings
C028 error Template structure invalid (adjacent or leading/trailing _, nested template, non-integer or unused when key)
C029 error Referenced or required template ID does not exist (promptTemplateIds, hintTemplateIds, requiredTemplateNames)
C030 error Game tier differs from GameID.tier (Section 17.5.1)
C031 error Game skills differ from the fixed game table (8.8.1)
C032 error Steps are not exactly step1, step2, step3 in order
C034 error numberMin > numberMax, a range outside 1–20, a numberRangeByLevel range outside the step range, or a step tasksPerRound / expectedRoundSeconds / minRange override with a wrong shape or out of range (8.8.2)
C035 error Step coverage rule violated (8.8.2), including a step1 minRange above r5
C036 error Required prompt role missing (intro or a role from the game's schema)
C037 error hintIds does not contain non-empty level1 and level2
C038 error Parameter invalid (in parameters, parametersByLevel or gameParameters): undeclared key, wrong type, out of range, missing required key, or the game's validateStep / validateGame reported an error
C039 error littleOnesMaxStep out of range, or nachspuren with littleOnesMaxStep ≠ 1, or entdecken with abenteuerEligible = true
C040 warning Game file present in the manifest but no game registered in code (the file is ignored)
C041 error Game registered in code but missing from the manifest or its file missing
C050 error sizePrices differs from the price table of Section 14.4.5, or an item price differs from its size price
C051 error Unlock requirement malformed or count outside 1–20
C052 warning Unlock count > 10 (unreachable for littleOnes without a range override)
C060 error Sticker page/place conflict or place outside the page capacity
C061 error Sticker source does not match its kind or id, or unknown milestone key
C062 error Sticker coverage rule violated (8.10)
C070 error friends.json does not contain exactly n = 1–20
C071 error Friend voice line missing role hello, or audio ID not friend.<n>.<role>
C072 error Friend asset state missing
C080 error Dot numbers not consecutive, fewer than 3 or more than 20 points, or last number > 20
C081 error Dot coordinate outside 0.05–0.95
C082 error Two dots closer than 0.17
C083 warning Segment passes within 0.03 of a non-endpoint dot
C084 error Dot picture pictureId differs from its file name, stickerId differs from sticker.dot.<pictureId>, or nameAudioId differs from label.dot.<pictureId>
C090 error No valid avatar or no valid color theme remains
C091 error Release gate counts not met (8.18)

Checks summarised by the requirements of this section:

  1. Every referenced audio ID exists for locale de or has a TTS fallback key that exists for de (C012, C013, C014).
  2. Every string key exists in the String Catalog for every locale in voiceLocales (C011).
  3. Every number is within 1–20 (C006, C020, C034, C080).
  4. All IDs are unique within their namespace (C009); voice lines are unique across the three declaring files; template IDs are unique across prompts.json and all game files (C025).
  5. Prices match the size table (C050).

8.17 Invalid Content Behaviour #

General behaviour:

Build On error-level issues On warnings
Debug After loading completes: log every issue at error/notice level (category content), then one assertionFailure("Content validation failed with <n> errors. See Developer Menu > Content validation report."). Continuing from the debugger applies the Release policy below. The assertion is suppressed when any -uiTest… launch argument of the registry in Section 5.9 is present or when running SwiftUI previews, so UI tests and previews report through the developer menu instead. Logged; listed in the developer menu report (Section 5.10.2).
Release Never crash, never show a technical message to the child. Apply the per-item policy below; log each issue once at error level. Logged at debug level only.

Per-item policy (Debug after the assertion, and Release):

Invalid item Policy
manifest.json missing or undecodable Use ContentManifest.builtInV1 (the 8.5 example compiled into ZKContent).
A numbers.json entry, or the whole file Replace each missing or invalid entry with NumberFact.derived(n). The catalog always has 20 numbers.
An invalid template (8.7.1) Dropped. A game whose required or referenced template is missing is hidden (C029). If a shared template used by framework code is missing, the framework plays nothing for that utterance and shows the visual path (Section 20), as for any line that cannot be spoken.
A prompts.json line, or the whole file Drop the invalid line. For each referenced but undeclared audio ID, create an implicit line (stringKey = ttsFallbackKey = the audio ID, category from the prefix) if that key exists in Content.xcstrings or a recorded file exists; otherwise the ID stays unresolved.
A voice line that cannot be spoken or captioned Section 20 plays nothing for it and shows the visual path. Games are hidden only through the rules below.
A game file missing, undecodable, with tier or skill mismatch (C030, C031), with invalid steps (C032, C034, C035, C039), with a missing required prompt role or hint rung (C036, C037), with a missing template (C029), or with any parameter error (C038) in any step or in gameParameters The game is hidden: not shown on the game picker, never scheduled in an Abenteuer, never planned by the engine. Decision: a parameter error in any step hides the whole game (not only that step) because the stepping state machine (Section 9.11) assumes all three steps exist.
Game registered in code but hidden or missing Same as above. A hidden game's mastery records, stars, friends and progress are untouched and reappear when the game becomes available again.
A decoration item Removed from the shop. If a profile owns or placed it, the ownership and placement records are kept unchanged; the item is not rendered and its slot is treated as free for new placements; if a later content version restores the item, it renders again (if its slot has meanwhile been reused, the newer placement is shown and the restored item returns to the tray, Section 14). Stars are never refunded or lost.
decorations.json as a whole Empty shop; the garden renders owned items whose definitions are unavailable as described above.
A sticker Not shown and not awarded. An award already recorded stays in the data; the album shows the place empty until the content is fixed.
A friend entry, or the whole file Replace with defaults: nameKey friend.<n>.name, assets friend_<nn>_<state>, voice line friend.<n>.hello. Befriending logic (Section 9.18) never depends on this file.
A dot picture Not offered. Its sticker, if already earned, stays. If a Punkt zu Punkt step is left without valid pictures, the Punkt zu Punkt content schema reports C038 and the game is hidden.
An avatar or theme Hidden from the picker. A profile that uses it is shown with the first valid avatar/theme by sortIndex; the stored ID is not changed. If none remain (C090), the built-in avatar.default / theme.default (asset avatar_default, color sets theme_default_primary, theme_default_background, which must exist in the asset catalog) are used.

Child-facing effect of a hidden game: the game tile is absent; the engine's candidate set (Section 9.9) and Abenteuer composition (Section 9.16) exclude it. If fewer than two games remain available, the child home still shows the garden, the friends and the album; the game picker shows only the available games. The parent area's "Hilfe & Rechtliches" screen shows, only when at least one game is hidden in a Release build, the line "Einige Inhalte konnten nicht geladen werden. Bitte aktualisieren Sie die App oder installieren Sie sie neu." (string key parent.help.content_problem in Localizable.xcstrings; Section 22 owns the final wording).

8.18 Content Validation Tests #

Two test suites guard content. Both are Swift Testing suites and belong to the Fast test plan (and therefore also to Full; the four test plans are defined in Section 25.2.2). CI runs them on every push (Section 25), and developers run them locally with the Fast plan; a failure blocks merging.

Suite Location What it validates
ContentFixtureTests Packages/ZahlenketteKit/Tests/ZKContentTests/ Loader and validator behaviour against small fixture folders in Fixtures/ (valid set, one fixture per issue code, malformed JSON, duplicate keys, unknown keys, per-item decoding isolation, derived-default substitution, hidden-game computation). Every issue code in 8.16 has at least one fixture that triggers it and one that does not.
ShippedContentTests Tests/ZahlenketteTests/ (app-level test target, which links all 12 game targets) The real Resources/ folder of the repository, in .test mode, with all 12 GameContentSchema types.

ShippedContentTests locates the repository root by walking up from #filePath until it finds Zahlenkette.xcodeproj, then uses Resources/Content, Resources/Audio, Resources/Illustrations.xcassets, App/Localization/Content.xcstrings and App/Localization/Localizable.xcstrings directly from disk. Its resolvers are file-based: String Catalogs are parsed as JSON (8.14), audio existence is a directory listing, asset existence checks for a <name>.imageset or <name>.colorset folder anywhere inside the .xcassets folder (folders are organisational only, namespaces are off, 8.2); two asset folders with the same flat name anywhere in the catalog are reported as C009.

Required tests in ShippedContentTests:

Test Assertion
shippedContentHasNoErrors Zero error-level issues.
shippedContentWarningsAreAcknowledged Every warning's code + file + path is listed in Tests/ZahlenketteTests/Fixtures/acknowledged-content-warnings.json; new warnings fail the test until acknowledged.
allGamesAvailable All 12 games are available; none hidden.
everyVoiceLineRecordedForGerman (release gate) No C013 for locale de: every declared voice line has a recorded German file. Tagged .releaseGate; it may be disabled in the CI configuration only before the release-candidate milestone (Section 27) while recordings are in production, and must pass for every TestFlight and App Store build.
catalogCountsV1 (release gate) At least 60 non-retired decorations with at least 24 small, 20 medium, 12 large, 4 special; exactly 52 stickers in V1 with 20 friend, 8 milestone and 24 dot-picture stickers; exactly the 24 dot pictures of 8.5, 8 non-retired per step as assigned in 8.12; exactly 12 avatars and 6 themes; exactly 20 friends and 20 numbers plus the zero entry.
stringKeysUsedInCodeExist Every string literal passed to String(localized:) or Text in App/ and Packages/ZahlenketteKit/Sources/ exists in Localizable.xcstrings or Content.xcstrings (source scan with a regular expression over .swift files; Section 5.13 rule for package strings).
contentSizeWithinBudget Total size of Resources/Content plus both String Catalogs is within the content allotment of Section 24.4 (constant ContentBudget.maxContentBytes, set to the Section 24.4 value).
engineCanPlanEveryGame For each game, level and range stage, the learning engine's canPlan (Section 9.9) returns true for a fresh profile (guards the step coverage rule end to end).

Debug-launch validation: every Debug run of the app performs the full .debug validation at launch (8.17), so a content mistake made while developing is caught before a test run.

8.19 Content Versioning and ID Permanence #

Rule Detail
schemaVersion Changes only when the JSON shape of a file type changes incompatibly. A V1 app supports exactly schema 1. Because content ships inside the app bundle, app code and content always move together; a schema bump is made in the same commit as the loader change.
contentVersion Increases by at least 1 in every App Store release whose content differs from the previous release (a test compares it with the value recorded in Tests/ZahlenketteTests/Fixtures/last-released-content-version.txt, which the release workflow in Section 25 updates).
Where the content version appears Data export (contentVersion and contentVersionName, Section 7.17), developer menu, the notice log line at load. Persisted child data never stores the content version and never depends on it.
Permanence of IDs Decoration, sticker, dot picture, avatar, theme, audio and game IDs are never renamed or reused. To withdraw an item, set retired: true (where the field exists); owned items and earned stickers keep rendering.
Removed steps Not allowed in V1 (every game has step1–step3). If a later version ever reduces a game's steps, the engine clamps stored steps to the highest available step (Section 9.11).
Changed prices Not allowed: the price table is fixed (Section 14).
Changed dot layouts Allowed (a picture's dots may be re-authored); earned stickers are unaffected.
Persisted references Persistence (Section 7) stores content IDs as strings. On load of a child's data, references to IDs that are unknown or retired follow the per-item policy in 8.17.

8.20 Adding Content Without Code #

Each procedure ends with: bump contentVersion (8.19), run the test plan (8.18), fix all errors, acknowledge or fix all warnings, commit.

8.20.1 New dot picture #

  1. Author the picture per Section 13 (dot count for its step, square canvas).
  2. Add art to Resources/Illustrations.xcassets: dotpic_<pictureId>_reveal (and dotpic_<pictureId>_outline if used) and the sticker art sticker_dot_<pictureId>, all in the dotpic and sticker folders (flat names, 8.3).
  3. Create Resources/Content/dotpictures/<pictureId>.json (8.12) with stickerId sticker.dot.<pictureId> and the next free sortIndex for its step.
  4. Add the picture ID to manifest.dotPictures.
  5. Add the sticker to stickers.json (kind dotPicture) on the page of its step, in a free place; if the page is full, add a new page (8.10) with its tab art.
  6. Add dotpicture.<pictureId>.name and sticker.dot.<pictureId>.name to Content.xcstrings. Add the reveal line (required, 8.12): declare label.dot.<pictureId> in prompts.json (category label), add its text key (e.g. "Ein Stern!"), record Resources/Audio/de/label.dot.<pictureId>.m4a, and set nameAudioId.
  7. Note: the V1 release gate expects exactly 8 pictures per step; when a content drop adds pictures, update the counts in catalogCountsV1 together with the content (the test encodes the shipped catalog, not a limit of the format).

8.20.2 New decoration #

  1. Choose size and ID deco.<slug>; the price is fixed by size (Section 14.4.5).
  2. Add art deco_<slug> to the asset catalog (maximum pixel size per Section 24.4.3).
  3. Append an item to decorations.json with the next sortIndex of its size and an unlock requirement.
  4. Add deco.<slug>.name to Content.xcstrings. Optionally add sfx.deco_<slug>.m4a in Resources/Audio/common/ and set soundId (without it, a tap plays sfx.garden_tap, Section 14.6), and/or a spoken name label.deco.<slug> (as in 8.20.1 step 6; optional, V1 records none).

8.20.3 New prompt, hint or feedback line #

  1. Choose the audio ID by the prefix rules (8.3).
  2. Add the German text under the same key to Content.xcstrings (and a <audioId>.tts key only if TTS mispronounces the text).
  3. Declare the line in prompts.json with its category.
  4. Record Resources/Audio/de/<audioId>.m4a per the recording specification in Section 20. Until the recording exists, TTS speaks the fallback key (C013 warning; release gate).
  5. Reference it: add to the game's promptIds or hintIds (or solutionIds), and add it to the template that speaks it (utterances in the game file or templates in prompts.json, 8.7.1) if it is composed with a number. A prompt that a game plays by role (e.g. prompt.<gameId>.<role>) is used by the game automatically if the role is already known to its code; a line for a new role requires game code and is therefore not a content-only change. Adding a line to a hint rung does not make the framework rotate among the rung's lines: which line plays is decided by the game's rules (8.8.1). The only lines with content-only variant rotation are fb.correct.* and fb.tryagain.* (Section 10.8.4): a new fb.correct.<nn> joins the rotation pool when it is declared and listed in CodeReferencedVoiceLines.all.
  6. A carrier fragment (prompt.common.<key>, category carrier) is declared the same way and becomes usable as soon as a template references it.

8.20.4 New difficulty step for a parameterised game #

V1 fixes three steps per game (DifficultyStep has exactly step1–step3, Section 10.4.1, and the stepping rules of Section 9.11 assume them). What is content-only:

  1. Re-tuning any step: change numberMin/numberMax, numberRangeByLevel, parameters and parametersByLevel within the keys and ranges declared by the game's content schema. Example: make Hör hin step 2 use four options for Vorschule by setting parametersByLevel.vorschule.optionCount to 4.
  2. Re-shaping the progression, for example moving the scattered representation of Wie viele? from step 2 to step 3 by editing the representation weights of both steps (keys per Section 11).

Adding a fourth step (step4) is not content-only: it requires a new DifficultyStep case with its label and difficulty factor (Section 10.4.1), the parent difficulty-cap mapping (Section 9.13) and the game's content schema to accept it. Decision: this is recorded in DECISIONS.md if ever done; V1 and its content drops use re-tuning only.

8.20.5 New language (English, planned for V1.2) #

  1. Add the localization en to the Xcode project and to CFBundleLocalizations (Section 5.12 notes this is done when English ships).
  2. Translate every key of Content.xcstrings and Localizable.xcstrings into en (Section 20 owns translation workflow; number words: "one" … "twenty").
  3. Record every voice line into Resources/Audio/en/<audioId>.m4a with the same audio IDs (number words, prompts, hints, feedback, session, friend lines, labels).
  4. Add "en" to manifest.voiceLocales.
  5. No JSON file other than the manifest changes. Locale-specific pronunciation keys (.tts) are added only where English TTS needs them.
  6. The validator then checks all keys and files for both locales; missing English recordings produce C013 warnings, missing English strings produce C011 errors.

8.21 Resource Bundling and Size #

Decision: V1 bundles all content, audio and illustrations in the app binary. On-Demand Resources, Background Assets and any other download mechanism are not used.

Justification:

  • Offline guarantee (Section 24): the app must be fully usable at first launch without network. Any downloaded asset pack would break the first session on a plane or in a car.
  • Size: Section 24.4 budgets the download at 160 MB (hard cap 250 MB), with about 105–110 MB expected for V1 (content JSON and String Catalogs under 1 MB, audio about 30 MB, illustrations about 63 MB). This is below the size at which a download mechanism pays off.
  • Simplicity and privacy: no asset hosting, no download state machine, no failure UI, no network calls (Section 23).

Consequences:

  • Content drops (new pictures, decorations, games) ship as regular app updates with an increased contentVersion.
  • App thinning handles per-device image scales; audio is identical for all devices.
  • The size check contentSizeWithinBudget (8.18) and the App Thinning Size Report (Section 24.4) guard the budget.

9. Adaptive Learning Engine #

9.1 Purpose, Scope and Boundaries #

The adaptive learning engine decides what a child practises next and records what the child has learned. It is the single owner of:

Concern Subsection
Engine API, input and output value types 9.2, 9.3
Skill activity and applicability per level and number 9.4
Mastery score update, worked examples 9.5
Mastery bands and the "mastered" definition 9.6
Spaced repetition (Leitner boxes), due calculation, local days, time zones, clock changes 9.7
Which records a task credits 9.8
Task selection for a single-game round 9.9
Cold start 9.10
In-game difficulty stepping 9.11
Range widening and narrowing 9.12
Parent overrides, level change, precedence 9.13
Success-rate controller 9.14
Re-entry after a long absence 9.15
Abenteuer composition and daily availability 9.16
"Gerade schwierig" computation 9.17
Zahlenfreund eligibility function 9.18
State the engine needs persisted 9.19
Determinism and numeric rules 9.20
Performance 9.21
Edge cases 9.22
Test vectors 9.23

Not owned here: the didactic definitions of skills and levels (Section 4), persistence models and their fields (Section 7; 9.19 lists what the engine needs), the task and event model and hint ladder (Section 10), per-game task generation and credit mapping (Sections 11–13), reward effects of befriending and stars (Section 14), session boundaries and Abenteuer screens (Section 15), parent-area presentation (Section 16), wording of parent-facing text (Section 22).

Design rules:

  1. Pure. ZKLearningEngine depends only on ZKCore (Section 5.3.2). It never touches SwiftData, SwiftUI, audio, files, the network, Date() or a global random source. Inputs and outputs are Sendable value types (snapshots), never @Model objects.
  2. Deterministic. The same inputs, the same now, the same Calendar and the same random generator state always produce the same outputs (9.20).
  3. Synchronous and fast. Every call completes in well under 5 ms on iPhone SE (2nd generation) and is made directly from the main actor (Section 5.6.1; budgets in 9.21).
  4. Invisible to the child. Range changes, step changes and the task mix are never announced (Section 4, DR-49 to DR-51, DR-56).

9.2 Integration Flow #

The engine is called only by app-target coordinators (EngineBridge, RoundResultCoordinator, the Abenteuer coordinator and the parent-settings flow; Section 5.4). EngineBridge builds LearnerSnapshot values from the repositories (Section 7) and writes LearnerUpdate results back.

Moment Caller Engine call Effect
Session start (profile enters S-05 with no open session, Section 15.7.1) Lifecycle / session coordinator beginSession(_:at:) Detects long absence and starts re-entry mode (9.15). Section 14.5.3 then evaluates all 20 friends with eligibleFriends(_:numbers:).
Child home and game picker appear Child shell canPlan(_:for:) per available, entitled game Games that cannot be planned for this profile are not offered (never happens with valid content, 8.8.2).
Round start (single game) Game host planRound(_:rng:) Returns the RoundPlan (9.9). The framework consumes this ZKCore value unchanged as its round input (Section 10.4.2).
Abenteuer start (S-09) Abenteuer coordinator abenteuerStatus(_:at:), then planAbenteuer(_:rng:) for a new one Returns the 3 games (9.16). Each round's tasks are planned with planRound when that round starts.
Abenteuer round about to start and the entitlement changed Abenteuer coordinator replaceAbenteuerRound(_:roundIndex:request:rng:) Replaces a premium round not yet started with a free game (9.16.6).
Every completed task (taskCompleted, Section 10.13) RoundResultCoordinator apply(_:to:at:) Mastery, Leitner, stepping counters, probation counters, controller window, friend evidence, newly eligible friends (9.5–9.18). Persisted in the task save point (Section 7.12.2).
Round completed, or round ended with at least one completed task RoundResultCoordinator endRound(_:state:at:) Success-rate controller adjustment (9.14), re-entry countdown (9.15).
Session end (Section 15.7.1) Session coordinator endSession(_:state:at:) Session counting, range widening and narrowing (9.12).
Parent changes range, difficulty cap or level (S-20) Parent settings flow applySettingsChange(_:to:) Precedence and clamping (9.13).
Parent "Fortschritt zurücksetzen" Data management (Section 16) initialProfileState(level:) Engine state returns to the cold-start values of the level (9.10); overrides stay as set (Section 14.11).
Parent area progress, "Gerade schwierig", developer menu ZKParentArea, developer menu band(_:), applicability(_:number:level:games:), difficultItems(_:limit:), dry-run planRound Read-only.

9.3 Engine API and Value Types #

9.3.1 Shared types from ZKCore #

The engine uses these ZKCore types unchanged: Skill, Level, RangeStage, GameID, GameTier, DifficultyStep (with difficultyFactor 0.8 / 1.0 / 1.2, Section 10.4.1), TaskOutcome (firstTry, afterHint, shown), TaskBucket (focus, review, stretch), LocalDate, MasteryBand (9.6), RangeOverride and DifficultyCap (the persisted parent overrides, Section 7.3.3), GameMode, RoundContext, SelectionReason, RoundPlan and PlannedTask. Each is declared exactly once in ZKCore (Section 5.3.2); no other module redeclares them. Iteration order of Skill.allCases is recognize, name, count, subitize, order, compare, decompose, write; of GameID.allCases the declaration order in Section 5.4.3.

RoundPlan and PlannedTask are the single engine-to-game contract. Their complete field list is in Section 5.3.2; the engine fills every field as follows:

Type Field Value set by planRound
RoundPlan id request.roundID
gameID request.game.gameID
step the round step (9.9.3)
level state.profile.level
range the active range stage (9.13)
tasks the N planned tasks (9.9.6)
abenteuerID request.abenteuerID
seed the final draw of the slot algorithm (9.9.6), the task-generation seed for the framework (Section 10.4.2)
isReentry true when planned in re-entry mode (9.15)
mode always .standard (Entdecken's free-explore mode is never planned, 9.8)
context request.context (.single or .abenteuer(roundIndex:))
PlannedTask id derived deterministically (below)
skill always the game's primary skill
number the chosen number
bucket reason.bucket
step the round step, or round step + 1 for .stretchStep
reason the SelectionReason of the pick
// ZKCore (listed in Section 5.3.2)
public enum SelectionReason: String, Sendable, Hashable, Codable {
    case focus, review, maintenance, stretchNumber, stretchStep, fallback
    /// Mapping onto the three-valued TaskBucket used by the framework and persistence.
    public var bucket: TaskBucket {
        switch self {
        case .focus, .fallback: return .focus
        case .review, .maintenance: return .review
        case .stretchNumber, .stretchStep: return .stretch
        }
    }
}

PlannedTask.id is derived deterministically: the 16 bytes of RoundPlan.id with bytes 14 and 15 replaced by the task index as a big-endian UInt16.

9.3.2 Engine state snapshots #

// ZKLearningEngine
public struct SkillNumber: Hashable, Comparable, Sendable, Codable {
    public let skill: Skill
    public let number: Int                        // 1...20
    public init(_ skill: Skill, _ number: Int)
    // Comparable: by Skill.allCases index, then number.
}

public struct MasterySnapshot: Sendable, Hashable, Codable {
    public var key: SkillNumber
    public var score: Double = 0.0                // 0.0...1.0
    public var attempts: Int = 0                  // number of credits received (primary or secondary)
    public var firstTryCorrect: Int = 0           // credits whose outcome was firstTry
    public var lastPracticedAt: Date? = nil
    public var nextDueAt: Date? = nil             // start of the local due day, stored for display and queries
    public var intervalDays: Int = 0              // intervals[boxIndex]
    public var boxIndex: Int = 0                  // 0...5
    public var firstMasteredAt: Date? = nil       // set once, never cleared (Section 16.6.3)
    public static func empty(_ key: SkillNumber) -> MasterySnapshot
}

// Declared in ZKCore (Section 5.3.2), shown here for reference only:
// public enum MasteryBand: String, Sendable, Hashable, Codable, CaseIterable { case notStarted, practicing, almost, mastered }
// public enum RangeOverride: Int, Sendable, Hashable, Codable, CaseIterable { case auto = 0, r5 = 5, r10 = 10, r20 = 20 }
// public enum DifficultyCap: String, Sendable, Hashable, Codable, CaseIterable { case auto, maxStep1, maxStep2, allSteps }
// Raw values are persisted and exported (Section 7.3.3). Parent-facing labels: "Automatisch", "1–5", "1–10", "1–20"
// for RangeOverride; "Automatisch", "Leicht", "Mittel", "Schwer" for DifficultyCap (Section 16.7).

public struct RangeState: Sendable, Hashable, Codable {
    public var autoStage: RangeStage              // the automatic stage (used when rangeOverride == .auto)
    public var widenedFrom: RangeStage?           // non-nil while an automatic widening is on probation
    public var sessionsInStage: Int               // counted sessions since autoStage last changed
    public var probationAttempts: Int             // non-stretch tasks on new numbers since widening
    public var probationFirstTry: Int             // of those, firstTry outcomes
    public var probationSessions: Int             // counted sessions containing >= 1 such task
}

public struct ControllerState: Sendable, Hashable, Codable {
    public var stretchShare: Double = 0.10        // 0.00...0.20 in steps of 0.05
    public var comfortMode: Bool = false          // review share 0.30 instead of 0.20
    public var recentFirstTry: [Bool] = []        // non-stretch tasks, oldest first, at most 20
}

public struct GameStepState: Sendable, Hashable, Codable {
    public var currentStep: DifficultyStep = .step1
    public var consecutiveFirstTry: Int = 0
    public var consecutiveShown: Int = 0
    public var consecutiveNonFirstTry: Int = 0
}

public struct EngineProfileState: Sendable, Hashable, Codable {
    public var level: Level
    public var rangeOverride: RangeOverride        // parent override, .auto by default
    public var difficultyCap: DifficultyCap        // parent override, .auto by default
    public var range: RangeState
    public var controller: ControllerState
    public var reentryRoundsRemaining: Int         // 0 normally; 2 after a long absence (9.15)
    public var lastCountedSessionEndedAt: Date?    // end of the last session with >= 1 completed task
    public var voiceOn: Bool                       // device "Sprache" toggle at call time (Section 16)
}

public struct AbenteuerHistory: Sendable, Hashable, Codable {
    public var lastCompletedFor: LocalDate?        // local date of the start day of the last completed Abenteuer
    public var lastComposedGames: [GameID]         // games of the most recently composed Abenteuer
    public var inProgress: AbenteuerPlan?          // composed and started, not completed
    public var inProgressNextRound: Int            // 0...2, index of the next round to play
}
// Not stored as such: EngineBridge rebuilds AbenteuerHistory from AbenteuerRecord rows on every call (9.19).

public struct LearnerSnapshot: Sendable {
    public var profile: EngineProfileState
    public var mastery: [SkillNumber: MasterySnapshot]   // a missing key means an empty record
    public var gameSteps: [GameID: GameStepState]        // a missing key means step1 with zero counters
    public var firstTryGames: [Int: Set<GameID>]         // number -> games with >= 1 firstTry task on it
    public var befriended: Set<Int>                      // numbers whose friend already exists (Section 14)
    public var abenteuer: AbenteuerHistory
    public var calendar: Calendar                        // AppClock.calendar at call time (Section 5.4.2)
}

9.3.3 Game capabilities #

The engine does not import ZKContent. EngineBridge converts each available GameDefinition (Section 8.8.4) into GameCapabilities:

// ZKLearningEngine
public struct GameCapabilities: Sendable, Hashable {
    public let gameID: GameID
    public let tier: GameTier
    public let primarySkill: Skill
    public let secondarySkill: Skill
    public let enabledLevels: Set<Level>
    public let tasksPerRound: [Level: Int]                               // envelope value
    public let expectedRoundSeconds: [Level: Int]                        // envelope value
    public let littleOnesMaxStep: DifficultyStep
    public let stepRanges: [DifficultyStep: [Level: ClosedRange<Int>]]   // numberMin...numberMax per step and level
    public let stepTasksPerRound: [DifficultyStep: [Level: Int]]         // only steps that override tasksPerRound (Section 8.8.2)
    public let stepExpectedSeconds: [DifficultyStep: [Level: Int]]      // only steps that override expectedRoundSeconds
    public let stepMinRange: [DifficultyStep: RangeStage]                // only steps with minRange above r5
    public let abenteuerEligible: Bool
    public let distinctNumbersPerRound: Bool

    /// Task count of a round at `step` for `level`: the step override if present, else the envelope value.
    public func taskCount(step: DifficultyStep, level: Level) -> Int
    /// Expected seconds of a round at `step` for `level`: the step override if present, else the envelope value.
    public func expectedSeconds(step: DifficultyStep, level: Level) -> Int
}

Examples: Memory declares stepTasksPerRound 3 / 4 / 6 for step1 / step2 / step3 (Section 13.3); Punkt zu Punkt declares stepMinRange r10 for step2 and r20 for step3 (Section 13.4). Every other V1 game uses the envelope values only.

9.3.4 Requests, inputs and results #

// ZKLearningEngine
public struct SessionTaskEntry: Sendable, Hashable, Codable {
    public let gameID: GameID
    public let number: Int
    public let outcome: TaskOutcome
    public let isStretch: Bool
}

public struct RoundRequest: Sendable {
    public let roundID: UUID
    public let abenteuerID: UUID?
    public let context: RoundContext                  // .single, or .abenteuer(roundIndex:) when abenteuerID != nil
    public let game: GameCapabilities
    public let state: LearnerSnapshot
    public let sessionHistory: [SessionTaskEntry]     // completed tasks of the current session, oldest first
    public let now: Date
}

public struct CreditInput: Sendable, Hashable {
    public let skill: Skill
    public let weight: Double                         // 1.0 or 0.5
}

public struct TaskOutcomeInput: Sendable {
    public let gameID: GameID
    public let roundID: UUID
    public let targetNumber: Int                      // TaskResult.targetNumber (Section 10.13.2)
    public let step: DifficultyStep                   // the task's step
    public let bucket: TaskBucket                     // from the planned task
    public let outcome: TaskOutcome
    public let credits: [CreditInput]                 // TaskResult.creditedSkills (voice rule applied, Section 10.4.3)
}

public enum EngineEvent: Sendable, Hashable {
    case stepChanged(GameID, from: DifficultyStep, to: DifficultyStep)
    case rangeWidened(from: RangeStage, to: RangeStage)
    case rangeNarrowed(from: RangeStage, to: RangeStage)
    case rangeConfirmed(RangeStage)
    case reentryStarted(daysAway: Int)
    case masteredFirstTime(SkillNumber)
    case creditRejected(SkillNumber, reason: String)   // diagnostics only; the coordinator logs it
}

public struct LearnerUpdate: Sendable {
    public var changedMastery: [MasterySnapshot]       // sorted by key
    public var changedGameSteps: [GameID: GameStepState]
    public var profile: EngineProfileState
    public var firstTryGames: [Int: Set<GameID>]       // changed entries only
    public var abenteuer: AbenteuerHistory
    public var newlyEligibleFriends: Set<Int>          // eligible now and not yet befriended
    public var events: [EngineEvent]
}

public struct EngineRoundSummary: Sendable {
    public let gameID: GameID
    public let completedTasks: Int                     // >= 1
    public let wasCompleted: Bool                      // false for rounds ended early
}

public struct EngineSessionSummary: Sendable {
    public let tasks: [SessionTaskEntry]               // all completed tasks of the session
    public let endedAt: Date
}

public enum ProfileSettingsChange: Sendable, Hashable {
    case rangeOverride(RangeOverride)
    case difficultyCap(DifficultyCap)
    case level(Level)
}

Abenteuer types are in 9.16.1; "Gerade schwierig" types in 9.17.

9.3.5 The engine protocol #

The three members named in Section 5.4.2 are required with those signatures; this section adds the rest.

// ZKLearningEngine
public protocol LearningEngine: Sendable {
    // Section 5.4.2
    func planRound(_ request: RoundRequest, rng: inout some RandomNumberGenerator) -> RoundPlan
    func planAbenteuer(_ request: AbenteuerRequest, rng: inout some RandomNumberGenerator) -> AbenteuerPlan
    func apply(_ outcome: TaskOutcomeInput, to state: LearnerSnapshot, at now: Date) -> LearnerUpdate

    // Lifecycle
    func beginSession(_ state: LearnerSnapshot, at now: Date) -> LearnerUpdate
    func endRound(_ summary: EngineRoundSummary, state: LearnerSnapshot, at now: Date) -> LearnerUpdate
    func endSession(_ summary: EngineSessionSummary, state: LearnerSnapshot, at now: Date) -> LearnerUpdate
    func applySettingsChange(_ change: ProfileSettingsChange, to state: LearnerSnapshot,
                             games: [GameCapabilities]) -> LearnerUpdate
    func initialProfileState(level: Level, voiceOn: Bool) -> EngineProfileState

    // Planning helpers
    func canPlan(_ game: GameCapabilities, for state: LearnerSnapshot) -> Bool
    func activeRange(_ profile: EngineProfileState) -> ClosedRange<Int>
    func abenteuerStatus(_ state: LearnerSnapshot, at now: Date) -> AbenteuerStatus
    func replaceAbenteuerRound(_ plan: AbenteuerPlan, roundIndex: Int, request: AbenteuerRequest,
                               rng: inout some RandomNumberGenerator) -> AbenteuerPlan

    // Read-only queries
    func isActive(_ skill: Skill, number: Int, level: Level) -> Bool
    func applicability(_ skill: Skill, number: Int, level: Level, games: [GameCapabilities]) -> Applicability
    func isMastered(_ record: MasterySnapshot) -> Bool
    func band(_ record: MasterySnapshot) -> MasteryBand
    func dueDate(_ record: MasterySnapshot, calendar: Calendar, now: Date) -> LocalDate?
    func isDue(_ record: MasterySnapshot, calendar: Calendar, now: Date) -> Bool
    func difficultItems(_ state: LearnerSnapshot, games: [GameCapabilities], limit: Int) -> [DifficultItem]
    func eligibleFriends(_ state: LearnerSnapshot, numbers: Set<Int>) -> Set<Int>
}

public struct DefaultLearningEngine: LearningEngine {
    public static let algorithmVersion = 1            // written into the data export (Section 16)
    public let configuration: EngineConfiguration
    public init(configuration: EngineConfiguration = .v1)
}

algorithmVersion increases whenever a constant in 9.3.6 or a rule in this section changes; the value is included in the data export so exported records can be interpreted.

9.3.6 Engine configuration (all constants) #

public struct EngineConfiguration: Sendable, Hashable {
    public static let v1: EngineConfiguration        // values in the table below
}
Constant V1 value Used in
firstTryRate 0.15 9.5
afterHintRate 0.04 9.5
shownRate 0.10 9.5
Credit weights primary 1.0, secondary 0.5 9.5, 9.8
Difficulty factor step1 0.8, step2 1.0, step3 1.2 (DifficultyStep.difficultyFactor) 9.5
masteryScoreThreshold 0.80 9.6
masteryMinAttempts 6 9.6
almostThreshold 0.50 9.6
leitnerIntervals [0, 1, 2, 4, 7, 14] days for box 0–5 9.7
defaultTasksPerRound littleOnes 4, vorschule 5 (game files may differ, per level or per step, only where Sections 11–13 say so; Section 8.8) 9.9
Base shares focus 0.70, review 0.20, stretch 0.10 9.9, 9.14
stretchShareRange 0.00–0.20, adjustment step 0.05 9.14
comfortReviewShare 0.30 9.14
controllerWindow 20 non-stretch tasks 9.14
controllerMinSamples 10 9.9, 9.14
targetFirstTryBand 0.70–0.85 9.14
introWindow littleOnes 2, vorschule 4 9.9, 9.10
focusBaseWeight 0.25 9.9
overduePerDay, overdueCapDays 0.10, 10 9.9
secondToLastFactor 0.5 9.9
inRoundReuseFactor 0.4 per earlier use in the same round 9.9
struggleFactor 0.3 (number with >= 2 shown outcomes in this game in the current session) 9.9
maxStretchPerRound 1 when N <= 5, 2 when N >= 6 9.9
reviewCapShare 0.4 (0.6 in re-entry) of N, rounded up 9.9, 9.15
Frontier offsets and weights +1 → 1.0, +2 → 0.5 9.9
stepStretchMinScore 0.50 9.9
probationNewNumberCap floor(N / 2) new-number tasks per round 9.9, 9.12
Stepping up after 5 consecutive firstTry; down after 3 consecutive shown or 4 consecutive non-firstTry 9.11
Widening >= 80% of the stage's numbers with core mean >= 0.70, core = recognize, name, count; >= 3 counted sessions in the stage 9.12
Narrowing new-number first-try rate < 0.50 over >= 12 tasks spanning >= 2 counted sessions 9.12
Automatic stage limits littleOnes r5–r10, vorschule r10–r20 9.12
Re-entry absence > 30 local days; 2 rounds; shares focus 0.40, review 0.60, stretch 0; round step − 1 9.15
Abenteuer 3 rounds; overhead estimate 40 s; upper target 330 s; previous-Abenteuer penalty 0.4; same-primary-skill penalty 0.3; secondary weakness weight 0.5; name-primary penalty with voice off 1.0; jitter 0.1; max 3 swaps 9.16
"Gerade schwierig" attempts >= 3, not mastered, limit 3 9.17
Friend rule >= 3 mastered active skills incl. count or subitize; write excluded for littleOnes; firstTry in >= 3 distinct games 9.18
epsilon 1e-9 9.20

Tests may construct other configurations; production always uses .v1.

9.4 Skill Activity and Applicability #

isActive(skill, number, level):

Skill littleOnes vorschule
recognize, name, count, subitize, order, compare 1–20 1–20
decompose 2–5 2–10
write 1–20, reachable only through Nachspuren step1 (littleOnesMaxStep = step1, Section 8.8.1) 1–20

A number outside 1–20 is never active. The number 1 is never active for decompose (Section 4.11). In V1, decompose covers 2–10 for vorschule and 2–5 for littleOnes (Section 4, DR-30); the ten split of 11–20 ("10 + n") arrives with the "Schüttelbox bis 20" content drop (Section 17.12.1), which extends this table together with the game's steps.

applicability(skill, number, level, games) classifies a cell of the parent progress grid (Section 16.6.2):

Result Condition
.notApplicable No game in games (all available games, any tier, any level) has skill as primary or secondary skill with a step range containing number for a level at which the skill is active; or the skill is never active for the number at any level (decompose × 1, and decompose × 11–20 in V1).
.notInLevel Not .notApplicable, but isActive is false for this level, or no game can present the number for this skill at this level's allowed steps (e.g. decompose × 6–10 for littleOnes).
.active Otherwise.
public enum Applicability: String, Sendable, Hashable { case active, notInLevel, notApplicable }

Entitlement never affects applicability: a free user's subitize cells are .active and simply stay notStarted.

9.5 Mastery Model and Update Rule #

One MasteryRecord exists per (profile, skill, number 1–20) (Section 7). Its snapshot is MasterySnapshot. A score starts at 0.0.

For each credit (skill s, weight w) of a completed task with outcome o and difficulty factor d of the task's step:

firstTry:   score ← score + 0.15 · w · d · (1 − score)
afterHint:  score ← score + 0.04 · w · (1 − score)
shown:      score ← score − 0.10 · w · score
then:       score ← min(1.0, max(0.0, score))
            attempts ← attempts + 1
            firstTryCorrect ← firstTryCorrect + (o == firstTry ? 1 : 0)
            Leitner update (9.7), lastPracticedAt ← now, nextDueAt recomputed (9.7)
            if firstMasteredAt == nil and isMastered(record) → firstMasteredAt ← now
  • w is 1.0 for the primary skill and 0.5 for the secondary skill (9.8).
  • d applies to firstTry only; afterHint and shown do not use it.
  • Scores never decay with time. Forgetting is handled by due-review scheduling (9.7), not by lowering scores.
  • Each credit updates its own record independently; the order of credits within one task does not matter.

9.5.1 Worked example (one record through six tasks) #

Record (count, 7), starting empty; now in each row is the time of that task.

# Task w d Calculation Score after Box after Interval
1 Wie viele?, step2, firstTry (primary) 1.0 1.0 0 + 0.15 × 1 × 1 × 1.0 0.150000 1 1
2 Wie viele?, step2, firstTry (primary) 1.0 1.0 0.15 + 0.15 × 0.85 0.277500 2 2
3 Wie viele?, step2, afterHint (primary) 1.0 — 0.2775 + 0.04 × 0.7225 0.306400 2 2
4 Zahlenmonster, step2, shown (primary) 1.0 — 0.3064 − 0.10 × 0.3064 0.275760 0 0
5 Blitzblick, step1, firstTry (secondary: count) 0.5 0.8 0.27576 + 0.06 × 0.72424 0.319214 1 1
6 Wie viele?, step3, firstTry (primary) 1.0 1.2 0.3192144 + 0.18 × 0.6807856 0.441756 2 2

After task 6: attempts 6, firstTryCorrect 4, band practicing (score < 0.50).

9.5.2 How many tasks until "mastered" #

Only firstTry outcomes, starting from 0.0; the score after k tasks is 1 − (1 − f)^k with f = 0.15 · w · d.

Credit f Tasks to reach >= 0.80 Score at that point
Primary, step1 0.120 13 0.8102
Primary, step2 0.150 10 0.8031
Primary, step3 0.180 9 0.8323
Secondary, step1 0.060 27 0.8118
Secondary, step2 0.075 21 0.8055
Secondary, step3 0.090 18 0.8169
afterHint only (any step, primary) 0.040 40 0.8046

The attempts rule (>= 6) therefore never binds for firstTry-only histories; it protects against mastery from a handful of lucky high-step answers if constants are ever tuned.

Effect of a shown outcome: the score is multiplied by 0.90 (primary) or 0.95 (secondary).

9.6 Mastery Bands and the "Mastered" Definition #

isMastered(r)  ⇔  r.score ≥ 0.80 − ε  AND  r.attempts ≥ 6          (ε = 1e-9, 9.20)

band(r) =
  notStarted  if r.attempts == 0
  mastered    else if isMastered(r)
  almost      else if r.score ≥ 0.50 − ε
  practicing  otherwise
Band German label (Section 22) Condition
notStarted Noch nicht geübt attempts 0
practicing Wird geübt attempts ≥ 1 and score < 0.50
almost Fast sicher 0.50 ≤ score, and not mastered (includes score ≥ 0.80 with attempts < 6)
mastered Sicher score ≥ 0.80 and attempts ≥ 6

Mastery is current, not permanent: a mastered record whose score drops below 0.80 returns to almost or practicing. firstMasteredAt keeps the first time it was reached. Zahlenfreunde, once befriended, are never lost (Section 14.5.3).

9.7 Spaced Repetition, Due Dates and Local Days #

9.7.1 Leitner boxes #

boxIndex 0 1 2 3 4 5
intervalDays 0 1 2 4 7 14

Per credit: firstTry → boxIndex = min(boxIndex + 1, 5); afterHint → unchanged; shown → boxIndex = 0. Then intervalDays = intervals[boxIndex]. Primary and secondary credits move boxes the same way.

9.7.2 Local days and due calculation #

All day arithmetic uses the Calendar passed in LearnerSnapshot.calendar (Gregorian, device time zone at call time, Section 5.4.2) and the ZKCore LocalDate type. Never add multiples of 86,400 seconds.

today(now)            = LocalDate(date: now, calendar: calendar)
effectiveLast(r, now) = min(r.lastPracticedAt, now)                       // clock-backwards guard
dueDate(r, now)       = LocalDate(date: effectiveLast, calendar: calendar).adding(days: r.intervalDays)
isDue(r, now)         = r.lastPracticedAt != nil  AND  today(now) ≥ dueDate(r, now)
overdueDays(r, now)   = isDue ? (days from dueDate to today(now)) : 0
  • The engine always recomputes dueDate from lastPracticedAt and intervalDays in the current calendar. The stored nextDueAt is only a cache for display and queries: it is written on every update as calendar.startOfDay(dueDate) in the time zone at update time.
  • A box-0 record (interval 0) is due on the day it was practised; it can be selected again in the same session (subject to the recency rules of 9.9).
  • Records with lastPracticedAt == nil are never due (they are also never mastered).

9.7.3 Time-zone changes and daylight saving time #

  • Travelling: because the due date is recomputed in the current time zone, a record practised late in the evening in Berlin and evaluated the next morning in New York is due by New York's local days (test vector TV-10). A record can become due up to one local day earlier or later than the cached nextDueAt suggested; this is accepted and never harms the child.
  • DST: LocalDate.adding(days:) and startOfDay handle 23- and 25-hour days; the due date is a calendar day, not an instant (test vector TV-09).
  • The cached nextDueAt is refreshed at the next update of that record. EngineBridge does not rewrite caches on time-zone change.

9.7.4 Clock set backwards #

If the device clock is earlier than a record's lastPracticedAt:

  • effectiveLast = now for due calculation, so the record is due intervalDays local days after the (new) today; nothing is due "in the past" and nothing is blocked for long (TV-11).
  • On the next credit, lastPracticedAt ← now (it may move backwards). Decision: the latest evidence always defines the schedule; the engine keeps no clock high-water mark and ignores any clock anchor kept elsewhere (the trusted day clock of Section 15.12 governs only time-limit state; engine due dates and Abenteuer availability use the now and calendar passed in).
  • Abenteuer availability with a backwards clock: 9.16.7.
  • Re-entry detection with a backwards clock: a negative absence is treated as 0 days (9.15).

9.8 Which Records a Task Credits #

One task credits exactly one number, targetNumber (Section 10.4.3). TaskOutcomeInput.credits lists the credited skills and weights as reported by the framework, which already applies the voice-off rule for name (Section 10.4.3). The default reported credits are (primarySkill, 1.0) and (secondarySkill, 0.5); per-game exceptions are defined in Sections 11–13.

Engine validation of credits (invalid credits are dropped and reported as EngineEvent.creditRejected; the task's other effects still apply):

Check Rejected when
Number targetNumber outside 1–20 (then all credits are dropped)
Skill not active for (targetNumber, profile level) (9.4)
Weight not 1.0 or 0.5 (compared with ε)
Duplicates the same skill twice: keep the higher weight

Other per-task effects of apply (all in one LearnerUpdate):

Effect Rule
Mastery and Leitner 9.5, 9.7, for every accepted credit
Difficulty stepping counters 9.11; only for tasks with bucket != .stretch
Probation counters 9.12; only for tasks with bucket != .stretch
Controller window append outcome == .firstTry for tasks with bucket != .stretch; keep the last 20 (9.14)
Friend evidence if outcome == .firstTry: insert gameID into firstTryGames[targetNumber] (any bucket, any credits)
Newly eligible friends eligibleFriends(updatedState, numbers: [targetNumber]) (9.18)
masteredFirstTime events for each record whose firstMasteredAt was set by this update

Entdecken free-explore never produces a TaskResult and therefore never reaches the engine (Section 10.13.2).

9.9 Task Selection for a Round #

9.9.1 Overview #

planRound produces the whole round at round start: N planned tasks, each with a number, a step and a selection reason. Mastery does not change during planning; outcomes of the round influence the next round, never the current plan (Section 10.4.2).

In a single-game round the credited skill is fixed by the game (its primary skill, plus its secondary skill at half weight), so selection chooses numbers. Skill variety comes from two places: the secondary skill is blended into each number's effective score (9.9.4), and the Abenteuer chooses games covering different, weak skills (9.16).

9.9.2 Inputs and derived values #

Symbol Definition
L state.profile.level
A activeRange(profile) (9.13): the override stage if rangeOverride != .auto (.r5 → r5, .r10 → r10, .r20 → r20), else range.autoStage; r5 = 1…5, r10 = 1…10, r20 = 1…20. stage(A) denotes that stage
p, q game.primarySkill, game.secondarySkill
maxStep min(level cap, parent cap): level cap = game.littleOnesMaxStep for littleOnes, step3 for vorschule; parent cap = step1 for maxStep1, step2 for maxStep2, step3 for auto and allSteps
N game.taskCount(step: roundStep, level: L), i.e. game.stepTasksPerRound[roundStep]?[L] ?? game.tasksPerRound[L], evaluated after the round step is fixed (9.9.3)
H numbers of sessionHistory, oldest first (all games of the session)
reentry profile.reentryRoundsRemaining > 0
started(n) some record (any skill) for n has attempts > 0
P(step) pool: numbers n in A ∩ game.stepRanges[step][L] with isActive(p, n, L); P(step) = ∅ when game.stepMinRange[step] exists and is above stage(A) (the step needs a wider range, Section 8.8.2)

9.9.3 Round step and planability #

roundStep = min(gameSteps[game].currentStep, maxStep)
if reentry: roundStep = max(step1, roundStep − 1)
while |P(roundStep)| < 2 and roundStep > step1: roundStep = roundStep − 1
canPlan(game) ⇔ L ∈ game.enabledLevels AND |P(step1)| ≥ 2
N = game.taskCount(step: roundStep, level: L)

The stored step is not changed by this lowering; it only selects a step whose pool is usable. A step whose minRange is above the active stage has an empty pool, so it is skipped by the same loop (for example Punkt zu Punkt step3 in r10 plays at step2, TV-48), and it is never a step-stretch target (9.9.4). Because the round step can differ from the stored step, N is taken from the round step: a Memory round lowered from step3 to step2 has 4 tasks, not 6 (TV-49). step1 never has a minRange above r5 (Section 8.8.2). The content rule of Section 8.8.2 guarantees canPlan for every enabled game in every stage (test engineCanPlanEveryGame, Section 8.18). If planRound is nevertheless called for an unplannable game, it returns a defensive plan: step1, N tasks cycling through A ∩ stepRanges[step1][L] ascending (or through 1…5 if that is empty), all with reason .fallback; the coordinator logs a fault.

9.9.4 Candidate sets (buckets) #

Effective score of a number for this game:

s_p(n) = score of (p, n);  s_q(n) = score of (q, n)
secondaryCounts(n) = isActive(q, n, L)
s_eff(n) = secondaryCounts(n) ? (s_p(n) + 0.5 · s_q(n)) / 1.5 : s_p(n)

Intro window W: the introWindow (littleOnes 2, vorschule 4) smallest numbers of P(roundStep) with started(n) == false.

Bucket Members
Focus F n ∈ P(roundStep), not isMastered((p, n)), and (started(n) or n ∈ W)
Review R n ∈ P(roundStep), isMastered((p, n)), isDue((p, n))
Maintenance M n ∈ P(roundStep), isMastered((p, n)), not due
Stretch, number kind frontier numbers (below)
Stretch, step kind numbers of P(roundStep + 1) with s_eff ≥ 0.50, else all of P(roundStep + 1)

Every n of P(roundStep) that is started or in W belongs to exactly one of F, R, M; unstarted numbers beyond the window belong to none until they enter the window.

Frontier numbers (number stretch) exist only when all hold: rangeOverride == .auto; not in re-entry; the level's automatic maximum is above A's maximum (littleOnes: frontier numbers never exceed 10; vorschule: never exceed 20). They are max(A) + 1 (weight 1.0) and max(A) + 2 (weight 0.5), each kept only if it lies in stepRanges[roundStep][L] and isActive(p, n, L).

Step stretch exists only when: roundStep < maxStep, not in re-entry, and P(roundStep + 1) is non-empty.

Stretch is eligible for the round only when controller.recentFirstTry.count ≥ 10 (cold-start protection, 9.10) and not in re-entry.

9.9.5 Weights #

For a candidate n at slot i, let seq = H + [numbers already planned in this round].

R(n) (recency) =
    0                       if n == last(seq)                       // never the same number twice in a row
  × 0.5                     if n == secondToLast(seq)
  × 0.4^k                   k = times n already appears in this round's plan
  × 0 additionally          if game.distinctNumbersPerRound and k ≥ 1
  (R starts at 1.0 and is multiplied by each applicable factor)

S(n) (struggle) = 0.3 if sessionHistory has ≥ 2 entries with gameID == game, number == n, outcome == shown; else 1.0

O(n) (overdue)  = 1 + 0.10 · min(overdueDays((p, n)), 10)

Focus weight        W_F(n) = (0.25 + (1 − s_eff(n))) · O(n) · R(n) · S(n)
Review weight       W_R(n) = O(n) · R(n) · S(n)
Maintenance weight  W_M(n) = (1 + min(daysSince((p, n).lastPracticedAt), 30) / 10) · R(n) · S(n)
Stretch-number      W_X(n) = frontierWeight(n) · R(n)
Stretch-step        W_Y(n) = R(n)

daysSince counts local days from LocalDate(effectiveLast) to today (0 if negative). In re-entry mode, the review bucket is replaced as described in 9.15.

New-number cap during probation (Section 4, DR-51: no round consists only of new numbers): while range.widenedFrom != nil and rangeOverride == .auto, numbers greater than max(widenedFrom) are "new"; once floor(N / 2) tasks of the plan use new numbers (any reason), every new number gets weight 0 for the remaining slots.

9.9.6 Slot algorithm #

Uniform draws: u = Double(rng.next() >> 11) * 0x1p-53 (a value in [0, 1)). The exact order of draws is part of the contract (test vectors depend on it).

shares:   stretchShare  = controller.stretchShare                       (0.00…0.20)
          reviewShare   = controller.comfortMode ? 0.30 : 0.20
          focusShare    = 1 − reviewShare − stretchShare
          (re-entry: focus 0.40, review 0.60, stretch 0.00)
caps:     maxStretch    = N ≥ 6 ? 2 : 1
          reviewCap     = ceil(0.4 · N)        (re-entry: ceil(0.6 · N))

for i in 0 ..< N:
    u1 = uniform()
    wanted = u1 < focusShare ? focus
           : u1 < focusShare + reviewShare ? review
           : stretch
    if wanted == stretch and (i == 0 or i == N − 1 or stretchCount ≥ maxStretch
                              or not stretchEligible):
        wanted = focus
    if wanted == review and reviewCount ≥ reviewCap:
        wanted = focus
    kind = none
    if wanted == stretch:
        hasNumber = frontier candidates with W_X > 0 exist
        hasStep   = step-stretch candidates with W_Y > 0 exist
        if hasNumber and hasStep: u2 = uniform(); kind = u2 < 0.5 ? number : step
        else if hasNumber: kind = number
        else if hasStep:   kind = step
        else: wanted = focus
    u3 = uniform()
    chain = wanted == focus   ? [focus, review, maintenance]
          : wanted == review  ? [review, maintenance, focus]
          : [stretch(kind), focus, review, maintenance]
    for bucket in chain:
        C = candidates of bucket with weight > 0
        if C not empty: n = weightedPick(C, u3); record (n, bucket); break
    if nothing picked:                                   // safety net, unreachable with |P| ≥ 2
        n = smallest number of P(roundStep) not equal to last(seq) (or P's smallest), reason .fallback
    append PlannedTask(number: n, step: stretch-step ? roundStep + 1 : roundStep, reason: …)

seed = rng.next()                                         // final draw, becomes RoundPlan.seed

weightedPick(C, u): sort C by weight descending, then number ascending; target = u · Σweights; walk the sorted list accumulating weights and return the first candidate whose running sum is greater than target.

Rules visible in the algorithm:

  • The first and the last task of a round are never stretch tasks (start and end with something the child can do).
  • The same number never appears twice in a row, also across round boundaries within a session (weight 0 for last(seq)).
  • A number's reuse within a round is damped (× 0.4 per earlier use); games with distinctNumbersPerRound never repeat a number. For such games, if |P(roundStep)| < N, N is reduced to |P(roundStep)| (minimum 2 by the planability rule).
  • The mix targets 70 / 20 / 10 (focus / review / stretch) by default; when a bucket is empty, the slot falls back along its chain, so the realised mix adapts to what is available (e.g. no due reviews early on).

9.9.7 Worked example A: cold start, littleOnes, Hör hin #

Input: littleOnes, new profile (no records), rangeOverride auto, stage r5, Hör hin (p = name, q = recognize), step1 range 1–10, stored step step1, controller default (stretch 0.10, window empty, so stretch not eligible), empty session history, N = 4.

Derived: A = 1…5; P(step1) = {1, 2, 3, 4, 5}; nothing started, so W = {1, 2}; F = {1, 2}; R = M = ∅; all s_eff = 0, so W_F = 1.25 before recency.

Scripted draws: 0.10, 0.30, 0.50, 0.90, 0.20, 0.60, 0.95, 0.10 (u1 and u3 of slots 0–3; slot 3 draws no u2 because its stretch wish is converted to focus before the kind is chosen), then 0.5 for the final seed draw: 9 values in total, exactly as in TV-35.

Slot u1 → wanted u3 Candidates (weight) Pick
0 0.10 → focus 0.30 1 (1.25), 2 (1.25); target 0.75 1
1 0.50 → focus 0.90 1 excluded (last); 2 (1.25) 2
2 0.20 → focus 0.60 1 (1.25 × 0.5 second-to-last × 0.4 reuse = 0.25); 2 excluded 1
3 0.95 → stretch → focus (last slot; also not eligible) 0.10 1 excluded; 2 (1.25 × 0.5 × 0.4 = 0.25) 2

Plan: [1, 2, 1, 2], all step1, all reason focus. After this round, 1 and 2 are started, so the next round's window is {3, 4} and F = {1, 2, 3, 4}.

9.9.8 Worked example B: vorschule, Wie viele?, mixed state #

Input: vorschule; rangeOverride auto; stage r10; not on probation; Wie viele? (p = count, q = recognize); step ranges (for this test): step1 1–10, step2 1–20, step3 1–20; stored step step2; cap auto (maxStep step3); controller stretch 0.10, comfort off, window of 12 entries (stretch eligible); not in re-entry; session history numbers [8, 10]; N = 5; now = 2026-10-12 09:00 Europe/Berlin (today = 2026-10-12).

Records (count = primary, recognize = secondary):

n count score / attempts / box / interval / lastPracticed recognize score Status
1, 2, 4, 6 0.90 / 10 / 4 / 7 / 2026-10-09 0.90 mastered, due 10-16, not due → M
3 0.90 / 10 / 3 / 4 / 2026-10-06 0.90 mastered, due 10-10, overdue 2 → R
5 0.90 / 10 / 3 / 4 / 2026-10-08 0.90 mastered, due 10-12, overdue 0 → R
7 0.30 / 5 / 1 / 1 / 2026-10-08 0.60 due 10-09, overdue 3 → F
8 0.10 / 4 / 0 / 0 / 2026-10-12 07:00 0.00 F
9 0.50 / 6 / 2 / 2 / 2026-10-11 0.90 F
10 0.00 / 2 / 0 / 0 / 2026-10-12 07:05 0.00 F
11, 12 no records no records frontier (weights 1.0, 0.5)

Focus base weights: 7: s_eff = (0.30 + 0.30) / 1.5 = 0.40 → (0.25 + 0.60) × 1.3 = 1.105; 8: s_eff = 0.0667 → 1.18333; 9: s_eff = 0.63333 → 0.61667; 10: s_eff = 0 → 1.25. Review base weights: 3 → 1.2; 5 → 1.0.

Scripted draws: 0.05, 0.50, 0.75, 0.60, 0.95, 0.30, 0.20, 0.40, 0.70, 0.85, 0.10 (slot 2 also draws u2), then 0.5 for the final seed draw: 12 values in total, exactly as in TV-36.

Slot Draws Wanted Candidates after recency (weight) Pick, reason
0 u1 0.05, u3 0.50 focus 7 (1.105), 9 (0.61667), 8 (1.18333 × 0.5 = 0.59167); 10 excluded (last); total 2.31333, target 1.15667 9, focus
1 u1 0.75, u3 0.60 review 3 (1.2), 5 (1.0); total 2.2, target 1.32 5, review
2 u1 0.95, u2 0.30, u3 0.20 stretch (number and step both available; u2 < 0.5 → number) 11 (1.0), 12 (0.5); total 1.5, target 0.30 11, stretchNumber
3 u1 0.40, u3 0.70 focus 10 (1.25), 8 (1.18333), 7 (1.105), 9 (0.61667 × 0.4 = 0.24667); total 3.785, target 2.6495 7, focus
4 u1 0.85, u3 0.10 review (count 1 < cap 2) 3 (1.2), 5 (1.0 × 0.4 = 0.4); total 1.6, target 0.16 3, review

Plan: [9 focus, 5 review, 11 stretchNumber, 7 focus, 3 review], every task at step2; RoundPlan.step = step2; the next draw becomes seed.

9.10 Cold Start #

initialProfileState(level:voiceOn:):

Field littleOnes vorschule
range.autoStage r5 r10
range.widenedFrom, counters nil, 0 nil, 0
rangeOverride, difficultyCap auto, auto (unless the parent set them) auto, auto
controller stretch 0.10, comfort off, empty window same
reentryRoundsRemaining 0 0
Game steps step1 for every game step1 for every game

Behaviour of a new profile:

  • littleOnes: numbers are introduced two at a time from the smallest (window 2): the first rounds use 1 and 2, then 3 and 4 enter, then 5. Step1 of every game uses structured representations (rows of five, dice, fingers; Sections 4 and 11–13), so the first experiences are small, structured quantities.
  • vorschule: numbers 1–4 first (window 4), widening quickly to all of 1–10 as numbers get started; step1 everywhere.
  • "Started" is shared across games: numbers practised in Wie viele? are immediately available in Hör hin without re-entering the window.
  • No stretch tasks until 10 non-stretch outcomes are recorded (2 to 3 rounds), so the controller has evidence before anything harder appears.
  • Stepping up happens naturally after 5 consecutive firstTry outcomes in a game (9.11), so a capable child leaves step1 within the first round or two.

9.11 In-Game Difficulty Stepping #

State per (profile, game): GameStepState (persisted in GameProgress, Section 7). Only tasks with bucket != .stretch affect it. The step used for a round is fixed at plan time; a step change affects the next round only (Section 10.4.2).

Effective ceiling: maxStep as in 9.9.2 (littleOnes game cap, parent cap). Floor: step1.

on outcome o (non-stretch task of this game):
  if currentStep > maxStep: currentStep = maxStep; reset counters      // cap lowered since last round
  switch o:
    firstTry:  cFT += 1; cShown = 0; cNFT = 0
    afterHint: cFT = 0;  cShown = 0; cNFT += 1
    shown:     cFT = 0;  cShown += 1; cNFT += 1
  if cFT ≥ 5:
      if currentStep < maxStep: currentStep += 1; emit stepChanged
      reset counters
  else if cShown ≥ 3 or cNFT ≥ 4:
      if currentStep > step1: currentStep −= 1; emit stepChanged
      reset counters

"Reset counters" sets cFT, cShown and cNFT to 0. When a trigger fires at the floor or ceiling, the counters reset without a step change (so the next evidence window starts fresh).

State Event Next state
step k, counters any 5th consecutive firstTry, k < maxStep step k+1, counters 0
step k = maxStep 5th consecutive firstTry step k, counters 0
step k > step1 3rd consecutive shown step k−1, counters 0
step k > step1 4th consecutive non-firstTry (afterHint or shown, any mix) step k−1, counters 0
step1 either step-down trigger step1, counters 0
any afterHint cFT 0, cShown 0, cNFT +1
any a round with the game ends early counters unchanged (the discarded unfinished task has no outcome, Section 10.12.4)

Counters persist across rounds, sessions and Abenteuer rounds of the same game. Tasks from the unfinished task of an exited round never reach the engine.

9.12 Range Widening and Narrowing #

Applies only while rangeOverride == .auto. With a parent override, RangeState is frozen completely (no counting, no evaluation) and resumes unchanged when the parent returns to Auto.

Counted session: a session with at least one completed task (Section 15.7.1). Evaluation happens only in endSession, so the range never changes mid-session and at most one range change happens per session end.

9.12.1 Per-task probation counting (in apply) #

While widenedFrom != nil: a non-stretch task whose targetNumber > max(widenedFrom) increments probationAttempts, and additionally probationFirstTry if its outcome is firstTry.

9.12.2 Session-end evaluation (in endSession) #

if summary has 0 tasks: return unchanged (not a counted session)
profile.lastCountedSessionEndedAt = summary.endedAt
if rangeOverride != .auto: return
range.sessionsInStage += 1
if widenedFrom != nil and summary contains ≥ 1 non-stretch task with number > max(widenedFrom):
    range.probationSessions += 1

// 1. Probation decision
if widenedFrom != nil and probationAttempts ≥ 12 and probationSessions ≥ 2:
    rate = probationFirstTry / probationAttempts
    if rate < 0.50 − ε:  autoStage = widenedFrom; emit rangeNarrowed
    else:                emit rangeConfirmed(autoStage)
    widenedFrom = nil; probation counters = 0; if narrowed: sessionsInStage = 0
    return

// 2. Widening check (only when not on probation)
next = autoStage.next, allowed only if next ≤ levelAutoMax (littleOnes r10, vorschule r20)
if widenedFrom == nil and next exists and sessionsInStage ≥ 3:
    numbers = 1 … max(autoStage)
    ready(n) = mean(score(recognize,n), score(name,n), score(count,n)) ≥ 0.70 − ε     // missing records count 0
    if count(ready) ≥ ceil(0.80 · |numbers|):
        widenedFrom = autoStage; autoStage = next; sessionsInStage = 0; probation counters = 0
        emit rangeWidened
  • r5 needs 4 of 5 numbers ready; r10 needs 8 of 10.
  • After narrowing, sessionsInStage restarts at 0, so widening is re-attempted after at least 3 more counted sessions, as required.
  • The core skills (recognize, name, count) are the same for both levels and are all trainable with the free games (Section 14.5.2), so free users widen normally.
  • Frontier stretch numbers (9.9.4) give the child early contact with the next numbers before widening; their outcomes do not count for probation.

9.12.3 Worked example #

A littleOnes child, stage r5, 3 counted sessions in the stage, no probation. Core scores at the end of session 3:

n recognize name count mean ready
1 0.90 0.85 0.88 0.8767 yes
2 0.80 0.75 0.70 0.7500 yes
3 0.72 0.70 0.74 0.7200 yes
4 0.70 0.66 0.75 0.7033 yes
5 0.40 0.35 0.50 0.4167 no

4 of 5 ready (80 %) and 3 sessions → widen: autoStage r10, widenedFrom r5. New numbers are 6–10.

  • Session 4: 7 non-stretch tasks on 6–10, 3 firstTry → probation 7 / 3, 1 session. No decision (fewer than 12 tasks and 2 sessions).
  • Session 5: 6 more tasks on 6–10, 2 firstTry → probation 13 / 5, 2 sessions. Rate 5 / 13 = 0.385 < 0.50 → narrow back to r5, widenedFrom nil, sessionsInStage 0.
  • Sessions 6–8: the child plays in r5 (with occasional stretch tasks on 6 and 7). At the end of session 8, sessionsInStage = 3; if the readiness condition still holds, the range widens again.
  • Alternative for session 5: 8 firstTry out of 13 (0.615) → rangeConfirmed(r10); probation ends; r10 becomes permanent for automatic purposes (a littleOnes child never widens to r20 automatically).

9.13 Parent Overrides, Level Change and Precedence #

Precedence, highest first:

  1. Availability and entitlement. Hidden games (Section 8.17) and games the profile is not entitled to (Section 17) are never planned or composed. The engine receives only playable games; it does not check entitlements itself.
  2. Parent overrides (Section 16.7): range setting and difficulty cap.
  3. Level rules: active skills (9.4), littleOnesMaxStep, automatic stage limits, tasks per round.
  4. Engine adaptivity: widening/narrowing, stepping, bucket mix, stretch, controller, re-entry.
Override Effect
Range r5 / r10 / r20 activeRange = the chosen stage (any stage at either level, e.g. r20 for littleOnes). Automatic range evaluation is frozen. Frontier stretch numbers are disabled: nothing above the chosen range is ever planned. Step stretch remains.
Range Auto activeRange = range.autoStage; evaluation resumes with the frozen counters.
Difficulty cap maxStep1 ("Leicht") maxStep = step1 (no step stretch possible).
Difficulty cap maxStep2 ("Mittel") maxStep = step2.
Difficulty cap allSteps ("Schwer") or auto ("Automatisch") maxStep = step3 (subject to littleOnesMaxStep). Decision: in V1 auto and allSteps produce identical engine behaviour; they are stored distinctly because the parent UI presents "Automatisch" as the recommended default and "Schwer" as an explicit choice (Section 16.7).

applySettingsChange rules:

Change Engine effect
.rangeOverride(x) Store x. No other state changes. Applies from the next round.
.difficultyCap(c) Store c. For every game whose stored step exceeds the new maxStep: set it to maxStep and reset its counters.
.level(new) (Section 15.6.3) Store the level. If rangeOverride == .auto: littleOnes → vorschule sets autoStage = max(autoStage, r10); vorschule → littleOnes sets autoStage = min(autoStage, r10). In both directions widenedFrom = nil, probation counters 0, sessionsInStage 0. Game steps above the new level's cap are clamped (e.g. Nachspuren to step1 for littleOnes) with counters reset. Mastery records are kept; records of skills inactive at the new level are kept but not scheduled.

"Parent override below current mastery" is handled in 9.22.

9.14 Success-Rate Controller #

Goal: keep the child's first-try rate on non-stretch tasks between 70 % and 85 %.

  • Window: controller.recentFirstTry, the last 20 non-stretch outcomes across all games (true = firstTry), appended in apply.
  • Adjustment runs in endRound (once per round with at least one completed task), only when the window holds at least 10 entries.
rate = count(true) / count
if rate ≥ 0.70 − ε: comfortMode = false
if rate > 0.85 + ε:
    stretchShare = min(0.20, stretchShare + 0.05)
else if rate < 0.70 − ε:
    if stretchShare > 0 + ε: stretchShare = max(0.00, stretchShare − 0.05)
    else: comfortMode = true
stretchShare = round(stretchShare · 100) / 100          // no floating drift
  • stretchShare moves between 0.00 and 0.20 in steps of 0.05 (default 0.10), so the focus share moves between 0.60 and 0.80 with review at 0.20.
  • Comfort mode is the second lever when stretch is already 0 and the child is still below 70 %: review share 0.30 (mastered items, easy wins), focus 0.70. It ends as soon as the rate is back to at least 70 %.
  • Stepping down (9.11) and narrowing (9.12) act on the same signal at a coarser grain; the controller only fine-tunes the mix.

Worked example: stretch 0.10, window of 20 with 18 firstTry → rate 0.90 > 0.85 → stretch 0.15 (focus 0.65). Later the window shows 13 of 20 (0.65) with stretch 0.00 → comfort mode on (review 0.30, focus 0.70).

9.15 Re-entry After a Long Absence #

Detection in beginSession:

if lastCountedSessionEndedAt != nil:
    daysAway = days from LocalDate(lastCountedSessionEndedAt) to today(now)      // negative → 0
    if daysAway > 30:
        reentryRoundsRemaining = 2
        controller = ControllerState()           // stretch 0.10, comfort off, empty window
        every GameStepState: counters reset (steps kept)
        emit reentryStarted(daysAway)

While reentryRoundsRemaining > 0 (single-game rounds and Abenteuer rounds alike):

Aspect Re-entry behaviour
Shares focus 0.40, review 0.60, stretch 0.00
Review bucket all mastered numbers of the pool (due or not; after 30 days nearly all are due anyway), weight W = (0.5 + s_eff(n)) · R(n) · S(n), so the most secure numbers come first; the maintenance bucket is empty in re-entry because its members are in review
Review cap ceil(0.6 · N)
Round step one below the planned step (not below step1); the stored step is unchanged
Stretch none (neither number nor step)
Countdown endRound decrements reentryRoundsRemaining by 1 (not below 0)

Nothing else changes: scores do not decay, stars and friends are untouched, the range is not narrowed by the absence itself. If re-entry shows real forgetting, the normal mechanisms respond (shown outcomes reset boxes, stepping down, narrowing).

9.16 Abenteuer Composition and Daily Availability #

9.16.1 Types #

// ZKLearningEngine
public struct AbenteuerRequest: Sendable {
    public let abenteuerID: UUID
    public let games: [GameCapabilities]      // available and entitled games only (9.13)
    public let state: LearnerSnapshot
    public let now: Date
}

public struct AbenteuerRound: Sendable, Hashable, Codable {
    public let index: Int                     // 0...2
    public let gameID: GameID
    public let taskCount: Int                 // game.taskCount(step:level:) at the game's round step at composition time
    public let expectedSeconds: Int
}

public struct AbenteuerPlan: Sendable, Hashable, Codable {
    public let id: UUID
    public let localDate: LocalDate           // day the Abenteuer was composed; it counts for this day
    public let rounds: [AbenteuerRound]       // 3 normally; fewer only if fewer eligible games exist
    public let estimatedSeconds: Int          // overhead + sum of expectedSeconds
}

public enum AbenteuerStatus: Sendable, Hashable {
    case startNew
    case resume(AbenteuerPlan, nextRoundIndex: Int)
    case completedToday                       // the button shows the garden (Section 15.10)
    case unavailable                          // no eligible game at all
}

9.16.2 Eligible pool #

A game is eligible when it is in request.games, abenteuerEligible == true (Entdecken is not), the level is enabled, and canPlan is true. The free-only pool in V1 is therefore Wie viele?, Hör hin and Was fehlt?.

9.16.3 Scoring #

weakness(s) = 1 − mean over n ∈ activeRange with applicability(s, n, L) == .active of score(s, n)
              (missing records count 0; a skill with no active numbers has weakness 0)
G(g) = weakness(g.primary) + 0.5 · weakness(g.secondary)
       − 0.4  if g ∈ abenteuer.lastComposedGames
       − 1.0  if !profile.voiceOn and g.primary == .name        (Section 10.4.3)
       + 0.1 · u_g                                               (jitter)

The jitter draws u_g are taken once per eligible game, in GameID.allCases order, before any selection.

9.16.4 Selection #

chosen = []
repeat 3 times (or until the pool is exhausted):
    candidates = eligible games not in chosen
    if chosen.count == 2 and no chosen game is free: candidates = free candidates only
    score'(g) = G(g) − 0.3 · (number of chosen games with the same primary skill)
    pick max score' (ties: GameID.allCases order)

Guarantees: never the same game twice; at least one free game (the third pick is forced to be free if needed; with a premium subscription the free pool always has 3 eligible games); only entitled games.

9.16.5 Time fit and order #

Target: about 5 minutes. expected(g) = g.expectedSeconds(step: s_g, level: L), where s_g is the round step planRound would use for g now (9.9.3, including the re-entry lowering); overhead estimate 40 s (intro S-09, transitions, round-end celebrations, soft end S-10; Section 15.10). Each round has the game's normal task count at that step (9.9.2, Section 15.10.3); time fit is achieved by game choice only.

total = 40 + Σ expected(chosen)
swaps = 0
while total > 330 and swaps < 3:
    for c in chosen sorted by expected descending (ties GameID order):
        alts = eligible games not in chosen with expected(alt) < expected(c),
               such that chosen − c + alt still contains a free game
        fitting = alts with total − expected(c) + expected(alt) ≤ 330
        if fitting not empty: alt = max G (ties: smaller expected, then GameID order)
        else if alts not empty: alt = min expected (ties: max G, then GameID order)
        else: continue with next c
        replace c by alt; swaps += 1; recompute total; break
    if no replacement happened: stop

Order of the three rounds ("warm start, challenge in the middle, secure ending"): comfort(g) = 1 − weakness(g.primary); round 1 = highest comfort; round 2 = lowest comfort; round 3 = the remaining game (ties: GameID order).

Each round's tasks are planned by planRound when that round starts (with the abenteuerID set), so outcomes of round 1 inform round 2.

9.16.6 Continuation, entitlement change and fewer games #

Situation Rule
Child leaves the Abenteuer after a round The plan stays in abenteuer.inProgress with inProgressNextRound. The same local day, abenteuerStatus returns .resume and the Abenteuer continues at the next unplayed round (Sections 3.5 and 15.10.5); the +5 bonus is paid on the first completion of the day (Section 14). A new local day discards it silently (.startNew).
Premium lapses between composition and a later round When a round is about to start, the coordinator checks entitlement (Section 17); for a premium round not yet started it calls replaceAbenteuerRound, which picks the highest-G free eligible game not already in the plan (ties GameID order). If none exists, the round is dropped and the Abenteuer continues with the remaining rounds.
Fewer than 3 eligible games (only possible with hidden content) Compose as many rounds as there are eligible games (1 or 2); 0 → .unavailable.
Local midnight passes during an Abenteuer It completes normally and counts for plan.localDate (the start day).

9.16.7 Daily availability #

today = LocalDate(now, calendar)
if abenteuer.lastCompletedFor == today                                   → .completedToday
else if inProgress != nil and inProgress.localDate == today
        and inProgressNextRound < inProgress.rounds.count                → .resume
else if eligible pool is empty                                          → .unavailable
else                                                                     → .startNew

On completion the coordinator marks the plan's AbenteuerRecord as completed; the next rebuild (9.19) therefore yields lastCompletedFor = plan.localDate and no inProgress. The +5 bonus is granted by Section 14 once per local date. Clock set backwards: equality is tested, not ordering, so a backwards clock can at worst offer one extra Abenteuer; it never blocks one. Time-zone change: today is the current zone's local date (Section 15).

9.16.8 Worked examples #

Common inputs: vorschule, range r10, voice on, weakness per skill: recognize 0.20, name 0.30, count 0.25, subitize 0.80, order 0.40, compare 0.70, decompose 0.90, write 0.95 (constructed by giving every active number of that skill the score 1 − weakness). Previous Abenteuer: Schüttelbox, Memory, Hör hin. ZeroRNG (every draw 0, so jitter 0). Expected seconds (test inputs): Wie viele? 75, Hör hin 60, Was fehlt? 80, Blitzblick 70, Mehr oder weniger 90, Nachspuren 150, Schüttelbox 100, Froschsprung 90, Zahlenmonster 90, Memory 120, Punkt zu Punkt 180. Tasks per round 5.

G values: Wie viele? 0.35; Hör hin 0.30 + 0.10 − 0.4 = 0.00; Was fehlt? 0.50; Blitzblick 0.80 + 0.125 = 0.925; Mehr oder weniger 0.70 + 0.40 = 1.10; Nachspuren 0.95 + 0.10 = 1.05; Schüttelbox 0.90 + 0.40 − 0.4 = 0.90; Froschsprung 0.40 + 0.35 = 0.75; Zahlenmonster 0.35; Memory 0.20 + 0.40 − 0.4 = 0.20; Punkt zu Punkt 0.50.

Example A (premium): pick 1 Mehr oder weniger (1.10); pick 2 Nachspuren (1.05); pick 3 forced free: Was fehlt? (0.50). Total 40 + 90 + 150 + 80 = 360 > 330. Swap the longest (Nachspuren 150): all shorter alternatives fit; the highest G is Blitzblick (0.925): total 40 + 90 + 70 + 80 = 280. Comfort: Was fehlt? 0.60, Mehr oder weniger 0.30, Blitzblick 0.20. Result: [Was fehlt?, Blitzblick, Mehr oder weniger], 5 tasks each, estimated 280 s.

Example B (free only): pool Wie viele?, Hör hin, Was fehlt?; all three chosen; total 40 + 75 + 60 + 80 = 255 ≤ 330. Comfort: Wie viele? 0.75, Hör hin 0.70, Was fehlt? 0.60. Result: [Wie viele?, Was fehlt?, Hör hin], estimated 255 s.

9.17 "Gerade schwierig" #

public struct DifficultItem: Sendable, Hashable {
    public let skill: Skill
    public let number: Int
    public let score: Double
    public let attempts: Int
    public let firstTryCorrect: Int
    public let band: MasteryBand
}

difficultItems(state, games, limit: 3):

  1. Candidates: records with attempts ≥ 3, not mastered, number in activeRange, and applicability == .active for the profile's level.
  2. Sort by score ascending; ties by attempts − firstTryCorrect descending; then by lastPracticedAt descending (more recent first); then by SkillNumber order.
  3. Return the first limit items (0 to 3).

Presentation is owned elsewhere: Section 16.6.4 displays the items in this order with the everyday tip per skill; Section 22.6.4 owns the sentence template, stated here identically: %1$@: Die Zahl %2$d ist gerade noch schwierig., where %1$@ is the skill's German display name (Ziffer erkennen, Zahlwort zuordnen, Zählen, Simultanerfassung, Ordnen, Vergleichen, Zerlegen, Schreiben) and %2$d the number, e.g. "Zählen: Die Zahl 8 ist gerade noch schwierig." An empty result shows the empty state of Section 22.6.2. IncorrectInfo.errorKind (Section 10.6.1) is not used by this computation in V1.

Example: records (count, 8) 0.22 / 7 attempts / 1 firstTry; (order, 15) 0.22 / 5 / 1; (name, 12) 0.30 / 4 / 1; (subitize, 9) 0.10 / 2 / 0; (recognize, 3) 0.85 / 9 / 8; range r20, vorschule. (subitize, 9) is excluded (2 attempts), (recognize, 3) is mastered. Result: (count, 8) [6 non-firstTry] before (order, 15) [4], then (name, 12).

9.18 Zahlenfreund Eligibility #

The rule is owned by Section 14.5.2; the function lives here and is the only implementation.

countedSkills(n) = { s ∈ Skill : isActive(s, n, L) and isMastered((s, n))
                                 and not (L == littleOnes and s == write) }
eligible(n) ⇔ n ∉ befriended
              AND |countedSkills(n)| ≥ 3
              AND (count ∈ countedSkills(n) OR subitize ∈ countedSkills(n))
              AND |firstTryGames[n]| ≥ 3
eligibleFriends(state, numbers) = { n ∈ numbers ∩ 1…20 : eligible(n) }
  • The level is the level at evaluation time.
  • firstTryGames[n] contains every game in which a task with target number n ended firstTry at least once (any bucket, 9.8).
  • The function is pure; the reward effect (friend moves in, sticker, celebration) is Section 14's. It is called after each task (apply returns newlyEligibleFriends for the task's number), at round completion for the round's numbers, and at session start for all 20 numbers (Section 14.5.3).
  • Free-tier reachability: recognize, name, count and order are all trainable with the free games, and there are 4 free games for the ≥ 3-games condition (Section 14.5.2); covered by test vector TV-24 and the engine test in Section 25.

9.19 State the Engine Needs Persisted #

Section 7 owns the models; this table names the exact stored fields the engine's values map to. EngineBridge reads them into LearnerSnapshot before every call and writes the LearnerUpdate back in the save point of Section 7.12.2.

Engine field Persisted in (Section 7) Mapping
MasterySnapshot (all fields incl. firstMasteredAt) MasteryRecord (7.5.4) One per (profile, skill, number); created lazily on first credit
GameStepState GameProgress (7.5.6): currentStepRaw, consecutiveFirstTry, consecutiveShown, consecutiveNonFirstTry Kept after subscription lapse (Section 17)
firstTryGames derived from GameNumberStat (7.5.5: per profile, game, number: first-try count) A game is in the set if its first-try count ≥ 1
befriended Section 14's friend records Read only
EngineProfileState.level, rangeOverride, difficultyCap ProfileSettings (7.5.2): levelRaw, rangeOverrideRaw (Int 0/5/10/20), difficultyCapRaw (auto/maxStep1/maxStep2/allSteps) Read; written only via applySettingsChange results
RangeState.autoStage LearningState (7.5.3) rangeStageRaw 0 = not initialized → cold-start value of the level (9.10)
RangeState.widenedFrom LearningState.widenedFromRaw 0 = nil; 5/10/20 = stage
RangeState.sessionsInStage LearningState.sessionsInStage
RangeState.probationAttempts, probationFirstTry, probationSessions LearningState.probationAttempts, probationFirstTry, probationSessions Same names
ControllerState.stretchShare LearningState.stretchShare
ControllerState.comfortMode LearningState.comfortMode (Bool, default false)
ControllerState.recentFirstTry LearningState.recentFirstTryRaw (String, default "") At most 20 characters "1"/"0", oldest first; any other character is dropped on read
EngineProfileState.reentryRoundsRemaining LearningState.reentryRoundsRemaining (Int, default 0)
EngineProfileState.lastCountedSessionEndedAt LearningState.lastCountedSessionEndedAt (Date?, default nil) End of the last session with at least one completed task
AbenteuerHistory not stored as such; rebuilt by EngineBridge from AbenteuerRecord rows (7.5.15) on every call inProgress = today's record with isCompleted == false: its games from plannedGameIDs (in order), id and localDate from the record, round task counts and expected seconds recomputed from the current GameCapabilities (9.16.5); inProgressNextRound = roundsCompleted. lastCompletedFor = the newest localDate of a record with isCompleted == true. lastComposedGames = plannedGameIDs of the newest record (completed or not). A record of an earlier day with isCompleted == false is ignored (a new local day discards it, 9.16.6)
voiceOn device settings (Section 16.8) Read
calendar AppClock.calendar Not persisted

Composition writes a new AbenteuerRecord (plannedGameIDs, roundsCompleted 0); each completed Abenteuer round increments roundsCompleted; completion sets isCompleted. A round replaced after an entitlement change (9.16.6) rewrites plannedGameIDs in place.

Section 14.11 and Section 16 define the reset scopes; "Fortschritt zurücksetzen" deletes mastery, game progress and GameNumberStat and writes initialProfileState(level:voiceOn:) into LearningState while keeping the parent overrides.

9.20 Determinism and Numeric Rules #

Rule Detail
Randomness Only through the inout some RandomNumberGenerator parameter. Production passes SplitMix64 (the single seedable generator, ZKCore, Section 5.3.2) seeded per call from SystemRandomNumberGenerator; in builds that parse the UI-test launch arguments (`DEBUG
Uniform conversion Double(rng.next() >> 11) * 0x1p-53. No Double.random(in:using:), no shuffled(using:) inside the engine (their draw consumption is not part of this contract).
Draw order Exactly as specified in 9.9.6 and 9.16.3; no hidden draws.
Time Only the now and calendar passed in. Never Date(), never Calendar.current.
Iteration order Never iterate a Dictionary or Set without sorting first (Swift's hashing is randomly seeded per process). Numbers ascend; skills follow Skill.allCases; games follow GameID.allCases.
Floating point Double. Threshold comparisons use ε = 1e-9 in the child's favour (≥ t − ε, > t + ε for upper band limits). Scores are stored unrounded; stretchShare is rounded to 2 decimals after each change.
Identifiers RoundPlan.id is request.roundID; PlannedTask.id is derived (9.3.1); the engine never creates random UUIDs.
Purity Given equal inputs, outputs are equal (Equatable); a test calls every function twice and compares.

Test random generators (in TestSupport, Section 5.3.4):

/// Returns the scripted uniform values in order; traps (test failure) when exhausted.
public struct ScriptedUniformRNG: RandomNumberGenerator {
    public init(_ values: [Double])                       // each in [0, 1)
    public mutating func next() -> UInt64                  // UInt64(value * 0x1p53) << 11
}
/// Always returns 0 (every uniform draw is 0.0).
public struct ZeroRNG: RandomNumberGenerator { public init(); public mutating func next() -> UInt64 }

9.21 Performance #

Operation Budget on iPhone SE (2nd generation), Release Notes
planRound ≤ 1 ms median, ≤ 2 ms p99 Leaves room within the 5 ms task-generation budget PB-08 (Section 24) for the game's generator
planAbenteuer ≤ 2 ms p99 12 games × 8 skills × 20 numbers
apply ≤ 0.5 ms p99 At most 2 credits
endSession, difficultItems, eligibleFriends (20 numbers) ≤ 1 ms p99 each

Implementation guidance: index mastery by a fixed 8 × 20 array built once per call from the dictionary; precompute LocalDate of today once per call; candidate sets hold at most 20 numbers; no allocations inside the slot loop beyond the plan array.

Measurement: EnginePerformanceTests (ZKLearningEngineTests) runs each operation 1,000 times on a fully populated snapshot (160 records, 12 games, 60-entry session history) with ContinuousClock and asserts median < 0.5 ms and maximum < 5 ms on the simulator as an early warning; the on-device check uses the signpost EnginePlan in the Section 24 performance pass.

9.22 Edge Cases #

Case Behaviour
Only free games entitled Planning and Abenteuer use only Entdecken ("Zähl mit"), Wie viele?, Hör hin, Was fehlt? (Abenteuer without Entdecken). subitize, compare, decompose and write stay notStarted (cells .active, Section 16). Widening works (core skills are free). Friends are reachable (9.18). Mastery of premium games already earned is kept after a lapse and used again on resubscription.
Parent sets a range below current mastery (e.g. r5 while 1–10 are mastered) The pool is 1–5; focus is empty, so slots fall back to review (due) and maintenance (least recently practised first). No frontier numbers (override). Step stretch continues and the controller raises the stretch share while first-try rates are high; stepping moves games up to maxStep. Nothing is lost: the automatic stage stays frozen at r10 and returns when the parent selects Auto.
Parent sets r20 for a littleOnes child Pool 1–20; decompose still 2–5 only; write still step1 only; friends 11–20 become reachable.
Everything in the range mastered and nothing due All slots fall back to maintenance (oldest first, never the same number twice in a row); stretch continues (step stretch, and frontier numbers if auto and below the level's automatic maximum). Widening normally already happened; at vorschule r20 / step3 with everything mastered the app keeps a calm maintenance mix.
Step with minRange above the active stage (e.g. Punkt zu Punkt step3 while the child is in r10) The step's pool is empty, so the round is played at the highest lower step with a usable pool; the stored step is unchanged and the step becomes playable as soon as the range widens (9.9.3, TV-48).
Step with its own task count (Memory 3 / 4 / 6 pairs) N follows the round step, including a step lowered by re-entry or minRange (9.9.2, TV-49). Stars follow the actual task count (Section 14).
Game pool smaller than N with distinctNumbersPerRound N reduced to the pool size (minimum 2); stars follow the actual task count (Section 14).
All weighted candidates zero (theoretical) Safety net in 9.9.6: smallest pool number other than the last one, reason .fallback.
Time-zone change Due dates and "today" are recomputed in the current zone (9.7.3); Abenteuer availability uses the current zone's date (9.16.7).
Clock set backwards effectiveLast = now for due calculation; lastPracticedAt may move backwards on the next credit; absence is clamped to 0; an extra Abenteuer is possible, a blocked one is not (9.7.4, 9.16.7).
Clock set forwards (e.g. + 60 days) then back Forward: many items due, possibly re-entry mode (harmless, review-heavy). Back: as above.
Absence longer than 30 local days Re-entry for 2 rounds: review-first, one step easier, no stretch; controller reset (9.15).
Level change during probation Probation cleared; stage per 9.13.
Difficulty cap lowered while a stored step is higher Clamped immediately in applySettingsChange; also defensively in apply (9.11).
Game content re-tuned between versions (ranges changed) Pools are recomputed at every plan; nothing about ranges is stored. A stored step above the steps available is clamped to the highest step (Section 8.19).
Credit for an inactive skill (e.g. decompose × 7 for littleOnes) Dropped with creditRejected (9.8).
Target number outside the active range (stretch or defensive clamping, Section 10.4.2) Credited normally (records exist for all 1–20); not part of probation unless above max(widenedFrom) and non-stretch.
Voice off name credits are not reported by the framework (Section 10.4.3); the engine does no further filtering. Name-primary games stay eligible for the Abenteuer but are deprioritised (score − 1.0, 9.16.3). Hör hin remains playable: single-game rounds are planned normally when the child chooses it, and the game presents its quantity-card variant (Section 11.4.6).
Mid-round exit The unfinished task never reaches the engine; completed tasks are already applied; endRound is called if ≥ 1 task completed.
Round of a hidden game in progress when content fails Cannot happen: content is validated at launch only.
Sessions with 0 tasks Not counted for range decisions and not used for re-entry detection (Section 15.7.1).
A friend's number becomes eligible while the child is at a different level later Eligibility is evaluated with the level at evaluation time; a friend, once befriended, stays (Section 14).

9.23 Test Vectors #

All vectors run in ZKLearningEngineTests with EngineConfiguration.v1, calendar Gregorian with the stated time zone, and the stated RNG. Score expectations use a tolerance of 1e-6. Dates are ISO 8601; "Berlin" = Europe/Berlin, "NY" = America/New_York.

ID Input Expected output
TV-01 Empty (count, 7); apply firstTry, credit (count, 1.0), step2, now 2026-10-12T09:00 Berlin score 0.150000; attempts 1; firstTryCorrect 1; box 1; interval 1; nextDueAt 2026-10-13T00:00+02:00
TV-02 (recognize, 7) score 0.5, box 2; firstTry, credit (recognize, 0.5), step1 score 0.530000; box 3; interval 4
TV-03 (count, 7) score 0.6, box 3; afterHint, credit (count, 1.0), step3 score 0.616000; box 3; interval 4
TV-04 (count, 7) score 0.9, attempts 12, box 4; shown, credit (count, 1.0), now 2026-10-12T09:00 Berlin score 0.810000; box 0; interval 0; nextDueAt 2026-10-12T00:00+02:00; isDue true at now
TV-05 (recognize, 7) score 0.4; shown, credit (recognize, 0.5) score 0.380000
TV-06 (count, 7) score 0.99, box 5; firstTry, credit (count, 1.0), step3 score 0.991800; box 5; interval 14
TV-07 The six-task sequence of 9.5.1 from an empty record scores 0.150000, 0.277500, 0.306400, 0.275760, 0.319214, 0.441756; final box 2, attempts 6, firstTryCorrect 4, band practicing
TV-08 Records: (a) score 0.82, attempts 5; (b) 0.82, 6; (c) 0.79999999995, 10; (d) 0.7999, 10; (e) 0.0, 0; (f) 0.49, 3 bands: (a) almost; (b) mastered; (c) mastered (ε); (d) almost; (e) notStarted; (f) practicing. For (b) reached by apply: firstMasteredAt = that task's now, event masteredFirstTime
TV-09 Record box 1, firstTry at 2026-10-24T20:00 Berlin (DST ends 2026-10-25) box 2, interval 2; nextDueAt 2026-10-26T00:00+01:00 (= 2026-10-25T23:00Z); isDue at 2026-10-25T22:30Z false, at 2026-10-25T23:30Z true
TV-10 lastPracticedAt 2026-10-01T22:30Z, interval 1; now 2026-10-02T13:00Z calendar NY: dueDate 2026-10-02, isDue true; calendar Berlin: dueDate 2026-10-03, isDue false
TV-11 lastPracticedAt 2026-10-10T08:00Z, interval 4; now 2026-10-05T08:00Z (Berlin, clock set back) dueDate 2026-10-09; isDue false; overdueDays 0. After apply firstTry at that now: lastPracticedAt 2026-10-05T08:00Z
TV-12 Game at step2, cap auto (vorschule); outcomes firstTry × 5 after the 5th: step3, counters 0, event stepChanged(step2 → step3)
TV-13 Step2; outcomes firstTry, firstTry, afterHint, firstTry × 5 step change only at the 8th task (to step3)
TV-14 Step3; outcomes shown × 3 after the 3rd: step2, counters 0
TV-15 Step2; outcomes afterHint, shown, afterHint, afterHint after the 4th: step1 (cShown sequence 0, 1, 0, 0; cNFT 1, 2, 3, 4)
TV-16 Step1; outcomes shown × 3 step1, counters 0, no event
TV-17 Step2, cap maxStep2; firstTry × 5 step2, counters 0, no event
TV-18 Step3, then applySettingsChange(.difficultyCap(.maxStep1)) step1, counters 0
TV-19 Stretch task (bucket stretch), step2 state with cFT 4; outcome firstTry step2, cFT still 4 (stretch tasks are neutral); mastery updated with the stretch task's step factor
TV-20 littleOnes, auto, r5, sessionsInStage 2, core means as in 9.12.3; endSession with 5 tasks sessionsInStage 3 → widen: autoStage r10, widenedFrom r5, sessionsInStage 0, event rangeWidened(r5 → r10)
TV-21 Same as TV-20 but sessionsInStage 1 before the call sessionsInStage 2; no change
TV-22 Probation r5 → r10; session A: 7 tasks on 6–10 with 3 firstTry; session B: 6 tasks with 2 firstTry after A: probation 7/3, sessions 1, no decision; after B: 13/5, 2 sessions → rangeNarrowed(r10 → r5), widenedFrom nil, sessionsInStage 0
TV-23 Same as TV-22 but session B with 5 firstTry (total 8/13) rangeConfirmed(r10), widenedFrom nil, autoStage r10
TV-24 littleOnes, auto, r10, all numbers 1–10 mastered in all core skills, 10 sessions no widening (littleOnes automatic maximum r10)
TV-25 Controller: stretch 0.10, window 20 with 18 true; endRound stretch 0.15, comfort off
TV-26 Controller: stretch 0.00, window 20 with 13 true; endRound stretch 0.00, comfort on; planning shares focus 0.70 / review 0.30 / stretch 0.00
TV-27 Controller: stretch 0.10, window with 9 entries unchanged
TV-28 Friend, vorschule, n = 7: mastered count, recognize, name; firstTryGames {wie_viele, hoer_hin, memory} eligible {7}
TV-29 As TV-28 but mastered recognize, name, order (no count or subitize) not eligible
TV-30 As TV-28 but firstTryGames {wie_viele, hoer_hin} not eligible
TV-31 littleOnes, n = 4: mastered write, recognize, count; 3 games not eligible (write excluded for littleOnes: 2 counted skills)
TV-32 As TV-28 with 7 ∈ befriended eligible set empty
TV-33 Free-only reachability: vorschule, n = 3 mastered in recognize, name, count, order via wie_viele, hoer_hin, was_fehlt firstTry eligible {3}
TV-34 "Gerade schwierig" records of 9.17 [(count, 8), (order, 15), (name, 12)]
TV-35 Worked example A (9.9.7), ScriptedUniformRNG([0.10, 0.30, 0.50, 0.90, 0.20, 0.60, 0.95, 0.10, 0.5]) (the 9th value is consumed by the final seed draw) numbers [1, 2, 1, 2]; steps all step1; reasons all focus; seed = UInt64(0.5 * 0x1p53) << 11; the script is consumed exactly
TV-36 Worked example B (9.9.8), script [0.05, 0.50, 0.75, 0.60, 0.95, 0.30, 0.20, 0.40, 0.70, 0.85, 0.10, 0.5] numbers [9, 5, 11, 7, 3]; reasons [focus, review, stretchNumber, focus, review]; buckets [focus, review, stretch, focus, review]; steps all step2
TV-37 Worked example A of 9.16.8 with ZeroRNG rounds [was_fehlt, blitzblick, mehr_weniger], taskCount 5 each, estimatedSeconds 280
TV-38 Worked example B of 9.16.8 (free only) with ZeroRNG rounds [wie_viele, was_fehlt, hoer_hin], estimatedSeconds 255
TV-39 abenteuerStatus: lastCompletedFor 2026-10-12, now 2026-10-12T20:00 Berlin .completedToday; with now 2026-10-13T00:01 Berlin → .startNew; with now 2026-10-11T10:00 (clock back) → .startNew
TV-40 inProgress composed 2026-10-12 with nextRound 1; now 2026-10-12T17:00 .resume(plan, 1); at 2026-10-13T08:00 → .startNew
TV-41 beginSession, lastCountedSessionEndedAt 2026-08-01T10:00, now 2026-09-05T10:00 Berlin (35 days) reentryRoundsRemaining 2; controller reset; event reentryStarted(35)
TV-42 Re-entry round, Wie viele? stored step step2, vorschule RoundPlan.step step1; no stretch tasks; isReentry true; after endRound reentryRoundsRemaining 1
TV-43 beginSession with lastCountedSessionEndedAt 2026-10-20, now 2026-10-05 (clock back) no re-entry
TV-44 Parent override rangeOverride = .r5 for a profile with 1–10 mastered, Hör hin, vorschule every planned number in 1–5; no reason stretchNumber; no immediate repeats
TV-45 Level change vorschule → littleOnes with autoStage r20, Nachspuren at step3 autoStage r10; widenedFrom nil; Nachspuren step1 with counters 0
TV-46 apply with credits [(decompose, 1.0)], littleOnes, targetNumber 7 no mastery change; event creditRejected
TV-47 Determinism: plan TV-36 twice with identical inputs identical RoundPlan values
TV-48 Punkt zu Punkt capabilities with stepMinRange {step2: r10, step3: r20}, step ranges step1 1–10, step2 1–10, step3 1–20; vorschule, stored step step3, cap auto, rangeOverride auto, stage r10; ZeroRNG RoundPlan.step step2; no task with step step3 (no step stretch to step3); stored step still step3. Same input with stage r20 → RoundPlan.step step3
TV-49 Memory capabilities with envelope tasksPerRound {littleOnes 4, vorschule 5}, stepTasksPerRound {step1: 3, step2: 4, step3: 6} for both levels and distinctNumbersPerRound true; vorschule, stage r20, stored step step3; ZeroRNG 6 planned tasks with 6 distinct numbers, maxStretch 2. Same input in re-entry (round step step2) → 4 tasks. canPlan true at every stage
TV-50 isActive(.decompose, number:, level:) vorschule: 1 false, 2 true, 10 true, 11 false, 20 false; littleOnes: 5 true, 6 false. applicability(.decompose, number: 15, level: .vorschule, games: all V1 games) = .notApplicable

Property tests (random inputs, 500 cases each, SplitMix64 with fixed seeds): no plan contains the same number in two consecutive positions (including the last session-history number); every planned number lies in the active range or is a frontier number with reason stretchNumber; no plan's first or last task is a stretch task; every score stays in 0…1 after any outcome sequence; boxIndex stays in 0…5; stepping never leaves step1…maxStep; an Abenteuer never contains a game twice, always contains a free game, and contains only games from the request.

10. Shared Game Framework #

10.1 Purpose, Scope and Ownership #

The shared game framework lives in the library target ZKGameKit inside the local package Packages/ZahlenketteKit (module graph in Section 5). Every one of the 12 game targets (GameEntdecken, GameWieViele, GameHoerHin, GameWasFehlt, GameBlitzblick, GameMehrOderWeniger, GameNachspuren, GameSchuettelbox, GameFroschsprung, GameZahlenmonster, GameMemory, GamePunktZuPunkt) is built on it. A game target depends only on ZKGameKit (and transitively on ZKCore, ZKContent, ZKAudio, ZKDesignSystem); games never import each other, ZKPersistence, ZKStore or ZKLearningEngine.

This section is the single owner of:

Concern Subsection
GameModule protocol, type erasure (AnyGameModule) 10.3
How a round consumes RoundPlan (declared in ZKCore, Section 5.3.2), task model (GameTask) 10.4
Round lifecycle state machine, transitions and timings 10.5
Answer evaluation contract and the definition of an "attempt" 10.6
Outcome classification (firstTry / afterHint / shown) as applied by the framework 10.6.4
Hint ladder mechanics (levels 1, 2, solution demonstration) 10.7
Feedback sequencing (correct, try again, solution) 10.8
Input handling (taps, drags and their tap alternative, tracing and shaking hand-off, debouncing, multi-touch, touch slop) 10.9
Speaker/replay button and the visual instruction demo (demo hand) 10.10
Inactivity handling 10.11
Pause, interruption, resume, mid-round exit 10.12
Events emitted to the learning engine, persistence and rewards 10.13
Meaning of the common layout slots of the game screen (S-07); their geometry is owned by Section 19.5.2 10.14
Which haptic events games trigger (the haptics map itself is owned by Section 19.10) 10.15
Reduce Motion variants inside games 10.16
Shared task-generation utilities (distractor picker, answer-option order, scatter layout) 10.17
Testing hooks 10.18

Not owned here (referenced only): the RoundPlan/PlannedTask types (Section 5.3.2, fields added in Section 9.3.1); mastery math, task selection, difficulty stepping and range widening (Section 9); content file schemas, including the voice templates (Section 8); star amounts and celebration content (Section 14); session time limit, break nudge and session boundaries (Section 15); the audio service API, templates, concatenation gaps, TTS fallback and the sound-off principle (Section 20); the inventory of audio lines and SFX (Section 21 mirrors the IDs and texts named here); visual tokens, component sizes, screen geometry, the haptics map and animation durations of design-system components (Section 19); screen IDs and navigation (Section 18); accessibility identifier format (Section 6.10); launch arguments (Section 5.9); per-game rules (Sections 11–13).

Design invariants every game inherits from this framework (a game cannot switch them off):

  1. Never red for errors, never a buzzer, never a lost star, never a visible error count, never a response timer. The "try again" colour is the neutral #8A8FA3; red is reserved for beads (Section 19).
  2. One task per screen. A task always ends in completion: either the child answers correctly or the app demonstrates the solution and the child taps the highlighted answer. A task can therefore never be "failed".
  3. Every task has a spoken prompt (when voice is on), a visual equivalent of the prompt (always), a speaker/replay button, and a demo-hand path (10.10).
  4. The child never has to read. All child-facing controls are pictures or numerals.

10.2 Module Surface #

ZKGameKit exports:

Type Kind Purpose
GameModule protocol Implemented once per game target
GameRegistry @MainActor class Declared in ZKGameKit, populated by the app target with one factory per game (Section 5.4.3)
AnyGameModule type-erasing wrapper Used by the game container to host any GameModule (10.3.4)
GameMetadata, StepConfiguration structs Game identity and configuration derived from games/<gameId>.json (Section 8.8.4)
GameTask<Payload> struct One concrete, fully generated task
TaskGenerationContext struct Everything makeTask may read besides the planned task (10.4.3)
GameStateStore protocol Per-profile, per-game persistent game state (10.4.5)
TaskPayload protocol Game-specific task data (distractors, layout, positions)
RoundController<Module> @Observable @MainActor class The lifecycle state machine
RoundPhase, PauseReason, RoundEndReason enums Controller state
AnswerEvaluation enum Result of evaluating one child input
HintLevel enum .none, .level1, .level2, .solution
GameAudio, LiveGameAudio protocol, class Thin adapter over the AudioService of ZKAudio (Section 20.5) that plays Utterance values; injectable for tests (10.18.1)
GameEvent, TaskResult, RoundSummary, RoundEndSummary, PictureCompleted enums/structs Emitted events (10.13)
GameEventSink protocol Receiver of events (implemented by the app's RoundResultCoordinator, Section 5.4.5)
SessionGate protocol Asked at every task boundary whether to continue or end the round (Section 15 implements)
GameTimings struct All durations in this section, injectable (.standard, .instant)
InputArbiter @MainActor class First-touch-wins arbitration, debouncing and input locks (10.9)
GameScreenLayout SwiftUI view The common slot layout (10.14)
DemoHandView, PauseOverlayView, ProgressDotsView, SpeakerButton, HoldToExitButton SwiftUI views Shared chrome
DistractorPicker, OptionOrder, ScatterLayout structs Shared deterministic task-generation utilities (10.17)

Types used by the framework but declared elsewhere (never redeclared in ZKGameKit): RoundPlan, PlannedTask, TaskBucket, SelectionReason, DifficultyStep, TaskOutcome, GameMode, AudioID, SplitMix64 (the seeded RandomNumberGenerator shared by the learning engine and the games) and AppClock in ZKCore (Sections 5.3.2 and 9.3.1); Utterance, UtteranceBuilder, VoicePriority, VoiceCompletion and the AudioService protocol in ZKAudio (Section 20.5).

10.3 The GameModule Protocol #

10.3.1 Signature #

import SwiftUI
import ZKCore
import ZKContent
import ZKAudio

/// Marker for game-specific task data. Must be a value type.
public protocol TaskPayload: Sendable, Equatable, Codable {}

/// One committed child answer. Game-specific (e.g. a tapped numeral, a dropped bead, a count).
public protocol GameAnswer: Sendable, Equatable {}

@MainActor
public protocol GameModule: AnyObject {
    associatedtype Payload: TaskPayload
    associatedtype Answer: GameAnswer
    associatedtype TaskView: View

    /// Stable identifier, equals the raw value used in content files.
    static var id: GameID { get }

    /// Built from `games/<gameId>.json` by the content store (Section 8). Never hardcoded.
    var metadata: GameMetadata { get }

    /// Skills this game can credit. Must contain metadata.primarySkill and metadata.secondarySkill.
    var supportedSkills: Set<Skill> { get }

    /// Modes the game offers. All games offer `.standard`; only Entdecken also offers `.freeExplore`.
    var supportedModes: Set<GameMode> { get }

    /// Pure and deterministic: same planned task, context and generator state -> same GameTask.
    /// Throws `TaskGenerationError` if the planned task cannot be realised (see 10.4.4).
    func makeTask(
        from planned: PlannedTask,
        context: TaskGenerationContext,
        using generator: inout SplitMix64
    ) throws(TaskGenerationError) -> GameTask<Payload>

    /// Pure, synchronous, side-effect free, < 1 ms. See 10.6.
    func evaluate(_ answer: Answer, for task: GameTask<Payload>, state: TaskAttemptState) -> AnswerEvaluation

    /// Voice for each prompt/hint/solution stage of a task. Pure. Each implementation selects a
    /// template ID (declared as data in prompts.json `templates` or in the game's `utterances`,
    /// Sections 8.7, 8.8.1 and 20.6.2), computes the bindings and expands it with the builder.
    func spokenPrompt(for task: GameTask<Payload>, using builder: UtteranceBuilder) throws(TemplateError) -> Utterance
    func spokenHint(_ level: HintLevel, for task: GameTask<Payload>, state: TaskAttemptState,
                    using builder: UtteranceBuilder) throws(TemplateError) -> Utterance
    func spokenSolution(for task: GameTask<Payload>, using builder: UtteranceBuilder) throws(TemplateError) -> Utterance

    /// The answer the solution demonstration highlights and the child must tap (10.7.4).
    func solutionAnswer(for task: GameTask<Payload>) -> Answer

    /// Builds the task view. The view observes the controller and renders
    /// hint visuals itself according to controller.hintLevel (10.7).
    func makeTaskView(controller: RoundController<Self>) -> TaskView

    /// Only Entdecken returns a view; default implementation returns nil.
    func makeFreeExploreView(environment: GameEnvironment) -> AnyView?
}

public extension GameModule {
    func makeFreeExploreView(environment: GameEnvironment) -> AnyView? { nil }
}

Voice composition model (Decision): there is exactly one model. Game code never concatenates audio IDs by hand; it chooses a template ID and supplies number bindings, and UtteranceBuilder (Section 20.5) expands the template data into an Utterance. A single recorded line without a number slot may also be played directly as Utterance.clip(id). The composition tables in Sections 10.8 and 11–13 describe the segments of those templates. If build throws (unknown template, unbound slot), the controller logs a .fault, plays no voice for that stage and continues on the visual path; the content validation test (Sections 8.16 and 20.6.2) makes this unreachable in shipped builds.

10.3.2 Metadata #

public struct GameMetadata: Sendable, Equatable {
    public let id: GameID
    public let tier: GameTier                    // .free / .premium
    public let displayNameKey: String            // String Catalog key, e.g. "game.hoer_hin.title" (Section 8.3)
    public let iconAssetName: String             // flat asset name, e.g. "game_hoer_hin_icon" (Section 6.4.3)
    public let primarySkill: Skill
    public let secondarySkill: Skill
    public let tasksPerRound: [Level: Int]        // littleOnes 4, vorschule 5 (Section 9)
    public let expectedRoundSeconds: [Level: Int]
    public let abenteuerEligible: Bool            // false only for Entdecken (Section 8.8.1)
    public let steps: [StepConfiguration]         // exactly step1...step3 in V1
    public let promptIDs: [String: AudioID]       // key -> audio ID, e.g. "intro" -> "prompt.hoer_hin.intro"
    public let hintIDs: [HintLevel: [AudioID]]    // every line the game may use at that rung (Section 8.8.1)
    public let gameParameters: GameParameters     // envelope `gameParameters`: game-owned keys not tied to a step
}

public struct StepConfiguration: Sendable, Equatable {
    public let step: DifficultyStep
    public let labelKey: String                   // "game.step.leicht" / "game.step.mittel" / "game.step.schwer"
    public let numberRange: [Level: ClosedRange<Int>]   // numberMin/numberMax with numberRangeByLevel applied
    public let tasksPerRound: [Level: Int]?       // optional per-step override (Section 8.8.2); nil = game value
    public let expectedRoundSeconds: [Level: Int]? // optional per-step override (Section 8.8.2)
    public let minRange: RangeStage?              // optional: the step is never planned below this stage (Section 9.9.3)
    public let parameters: [Level: GameParameters] // effective parameters per level, see 10.3.3
}

The structure and validation of the JSON that produces GameMetadata are owned by Section 8 (envelope 8.8.1, step object 8.8.2, derivation 8.8.4). The keys inside parameters, parametersByLevel and gameParameters are owned by each game's specification (Sections 11–13).

10.3.3 Game Parameters Resolution #

The effective parameters of a task are the step's parameters overlaid key-by-key (shallow merge) by parametersByLevel.<level> of the child's level (Section 8.8.3). Level blocks are optional. Game-level configuration that is not tied to a step lives in the envelope's gameParameters object (Section 8.8.1) and is exposed as GameMetadata.gameParameters. Each game declares a Decodable parameters struct (<Game>StepParameters, and <Game>GameParameters where it uses gameParameters) with a validation function; GameParameters holds the raw JSON object and decodes lazily into the game's struct at round start. If decoding or validation fails, the round does not start (10.4.4, row "invalid parameters") and the content validation test (Section 8) fails in CI.

10.3.4 Registration and Type Erasure #

GameRegistry (declared in ZKGameKit, populated by the app target, Section 5.4.3) returns a fresh any GameModule per GameID. The game container wraps it in AnyGameModule for type erasure:

@MainActor
public final class AnyGameModule {
    public init<M: GameModule>(_ module: M)
    public let id: GameID
    public let metadata: GameMetadata
    public let supportedSkills: Set<Skill>
    public let supportedModes: Set<GameMode>
    public func makeRoundView(plan: RoundPlan, environment: GameEnvironment) -> AnyView
    public func makeFreeExploreView(environment: GameEnvironment) -> AnyView?
}

@MainActor
public struct GameEnvironment {
    public let audio: any GameAudio
    public let utterances: UtteranceBuilder        // built by the app from prompts.json templates and all game utterances
    public let eventSink: any GameEventSink
    public let sessionGate: any SessionGate
    public let stateStore: any GameStateStore      // per (profile, game), loaded before the round (10.4.5)
    public let settings: GameSettingsSnapshot      // Section 10.3.4 (declared below)
    public let timings: GameTimings
    public let clock: any AppClock
}
// ZKGameKit
public struct GameSettingsSnapshot: Sendable, Equatable {
    public let voiceOn: Bool          // zk.device.voiceEnabled
    public let sfxOn: Bool            // zk.device.sfxEnabled
    public let hapticsOn: Bool        // zk.device.hapticsEnabled && device supports haptics
    public let reduceMotion: Bool     // SwiftUI accessibilityReduceMotion
    public let motionEnabled: Bool    // zk.device.motionEnabled && hasAccelerometer (Schüttelbox, 12.5.5)
    public let pencilOnly: Bool       // zk.device.pencilOnly (Nachspuren, iPad only, 12.4)
    public let level: Level
    public let rangeStage: RangeStage
}

GameSettingsSnapshot is built by the app from the device settings (Section 7.9.3) and the SwiftUI environment; games never read settings storage directly. It is captured at round start and refreshed on every awaitingInput entry (so a parent changing a toggle mid-session takes effect at the next task, never mid-sequence). Entitlement checks happen before a round view is requested (Section 17); the framework never checks entitlements.

10.4 Round Input and Task Model #

10.4.1 Shared Enums #

// ZKCore
public enum DifficultyStep: String, CaseIterable, Codable, Sendable, Comparable {
    case step1, step2, step3            // "Leicht", "Mittel", "Schwer"
    public var difficultyFactor: Double // 0.8 / 1.0 / 1.2 (Section 9)
}

public enum TaskOutcome: String, Codable, Sendable { case firstTry, afterHint, shown }

// ZKGameKit
public enum HintLevel: Int, Codable, Sendable, Comparable {
    case none = 0, level1 = 1, level2 = 2, solution = 3
}

DifficultyStep and TaskOutcome are declared once in ZKCore (Section 5.3.2) because the learning engine, persistence and the app use them; this section defines their meaning; GameMode is declared in Section 5.3.2. HintLevel is framework-only.

10.4.2 RoundPlan #

Round input is the RoundPlan with its PlannedTasks, declared once in ZKCore (Section 5.3.2, fields added in Section 9.3.1). The learning engine produces it (Section 9.9); because both ZKGameKit and ZKLearningEngine depend only on ZKCore, the app passes the engine's plan to the game container unchanged. ZKGameKit declares no round-input type of its own.

How the framework uses the plan's fields:

Field Use in the framework
RoundPlan.id The round's identity; copied into every event as roundID (10.13.2); task IDs derive from it
RoundPlan.gameID, .level, .range, .step Select the module, the effective parameters (10.3.3) and the active range stage
RoundPlan.tasks Planned tasks in order; the round has exactly tasks.count tasks
RoundPlan.seed Seeds the round's SplitMix64 for task generation, feedback rotation and layouts
RoundPlan.isReentry Informational only (logged); the engine has already chosen easier steps (Section 9.15)
RoundPlan.abenteuerID Non-nil: the round belongs to an Abenteuer (intro rule T04, event field abenteuerID)
RoundPlan.mode Always .standard for engine plans; the container rejects .freeExplore plans (free explore has no plan)
RoundPlan.context .single or .abenteuer(roundIndex:); drives the Abenteuer intro rule T04 and the S-08 buttons
PlannedTask.id Becomes GameTask.id and TaskResult.taskID
PlannedTask.skill, .number, .step Credited skill (always the game's primary skill in V1), credited target number (1…20), task step (may be one above the round step for a stretchStep task)
PlannedTask.bucket (TaskBucket: .focus, .review, .stretch) Copied into TaskResult.bucket

A planned round is always GameMode.standard; free explore (Entdecken) has no RoundPlan and emits no TaskResult.

Rules:

  • The task count equals the plan's task count (tasks.count). The engine decides it from the game's tasksPerRound for the level (vorschule 5, littleOnes 4) or a per-step override (Sections 8.8.2 and 9.9.2). The framework treats a plan with 1–8 tasks as valid (for tests and future content) and rejects 0 or more than 8 tasks.
  • The engine only plans numbers and steps that the game's step configuration allows for the level and range stage (the allowed range per step and level, numberMin/numberMax with numberRangeByLevel, and the optional step minRange are data in games/<gameId>.json, Section 8.8.2, which the engine reads). The framework re-validates: if number is outside the step's range intersected with the active range stage, the game generates the task for the nearest valid number, marks TaskResult.clampedFrom with the original number, and logs a warning (category gamekit, Section 6). This is a defensive path; the engine test suite (Section 9) must keep it unused.
  • The difficulty step is fixed per task at plan time. In-game difficulty stepping (Section 9) changes the step for the next round, never mid-round.

10.4.3 GameTask #

public struct GameTask<Payload: TaskPayload>: Sendable, Equatable, Identifiable {
    public let id: UUID                        // = PlannedTask.id (derived from RoundPlan.id + index, Section 9.3.1)
    public let index: Int                      // 0-based position in the round
    public let gameID: GameID
    public let skill: Skill                    // primary skill credited
    public let secondarySkill: Skill?          // metadata.secondarySkill; nil if the game disables it for this task kind
    public let targetNumber: Int               // the one credited number
    public let additionalNumbers: [Int]        // numbers shown or asked but NOT credited (e.g. second gap)
    public let step: DifficultyStep
    public let kind: String                    // game-specific task kind, e.g. "countTo", "backward"
    public let payload: Payload                // distractors, positions, object type, etc.
}

public struct TaskGenerationContext {
    public let level: Level
    public let rangeStage: RangeStage
    public let parameters: GameParameters      // effective, merged for the task's step and level (10.3.3)
    public let gameParameters: GameParameters  // envelope-level game parameters (10.3.2)
    public let previousTask: PreviousTaskInfo? // target and correct-option slot of the previous tasks in the round (10.17.3)
    public let voiceOn: Bool
    public let reduceMotion: Bool
    public let stateStore: any GameStateStore  // synchronous access to the game's persistent state (10.4.5)
}

Credit rule: one task credits exactly one number. The primary skill is updated with weight 1.0 and the secondary skill with weight 0.5, both for targetNumber, using the outcome and difficulty factor (Section 9). additionalNumbers never receive credit. Decision rationale: it keeps the engine's per-task bookkeeping one-to-one with its plan entries.

Voice-dependent credit rule (owned here): the skill name links the spoken number word to a numeral or quantity and cannot be practised without voice. When the "Sprache" toggle is off at the moment a task is presented, a task whose primary skill is name credits only recognize with weight 0.5 and does not credit name. The TaskResult carries the actually credited skills (10.13.2) and the flag voiceOn, so the engine applies what it receives and does no extra filtering. Section 9.16.3 deprioritises name-primary games in the Abenteuer when voice is off; this framework enforces only the crediting.

10.4.4 Generation Failures #

Situation Behaviour
makeTask throws for one planned task Log error; retry makeTask once for the same planned entry with the generator advanced by one draw. If the retry also throws, drop the entry and continue with the next planned task (the round becomes one task shorter).
More than one entry dropped in a round End the round with reason .contentFailure; if at least one task was completed, emit roundCompleted with isShortened = true (the child sees the normal celebration and receives the normal round bonus: the child is never penalised for an app defect); if no task was completed, return to the game picker (S-06) silently.
Invalid parameters (decode/validation failure) at round start The round does not start; the controller enters failed(.invalidContent); the container returns to S-06 after a 1.0 s calm "Ups" illustration without sound. In Debug builds an assertion fires. The content validation test (Section 8) makes this unreachable in shipped builds.
Required audio file missing Handled by ZKAudio (TTS fallback, Section 20). The framework continues; the visual path is always present.

10.4.5 Persistent Game State (GameStateStore) #

Some games keep a small amount of state across rounds per profile (for example Schüttelbox split coverage, Section 12.5.6, and Punkt zu Punkt picture rotation, Section 13.4.5). Games cannot import ZKPersistence (Section 5), so the framework provides the state through a store object:

// ZKGameKit
@MainActor
public protocol GameStateStore: AnyObject {
    /// Decodes the state loaded before the round. Returns nil when nothing was saved yet or the
    /// stored JSON cannot be decoded (the game then starts with empty state and logs an .error).
    func load<State: Codable & Sendable>(_ type: State.Type) -> State?
    /// Stages a new state. The staged value is attached to the next TaskResult of this round as
    /// `updatedGameState` and persisted in that task's save point. Encoded size is limited to
    /// 8 KB; a larger value is rejected (not staged) and logged as a .fault.
    func save<State: Codable & Sendable>(_ state: State)
}

@MainActor
public final class RoundGameStateStore: GameStateStore {
    public init(initialJSON: String)             // GameProgress.gameStateJSON; "" = no state
    public var stagedJSON: String? { get }       // consumed by RoundController when it emits taskCompleted
}

Rules:

  1. Before the round starts, the app reads GameProgress.gameStateJSON for the (profile, game) pair (Section 7.5.6) and creates a RoundGameStateStore with it. Access is synchronous, so makeTask stays pure with respect to its inputs: the loaded state is part of the generation context.
  2. A game stages state only from makeTask or when a task completes; the controller copies stagedJSON into TaskResult.updatedGameState of the task being completed and then clears the staged value. RoundResultCoordinator writes it to GameProgress.gameStateJSON in the task save point (Sections 5.4.5 and 7.12.2). State staged for an unfinished task that is discarded (10.12.4) is discarded with it.
  3. Every game state struct carries an integer v (format version). The value is opaque to the app and to persistence; its schema is owned by the game's section.
  4. Free explore never uses the store.

10.5 Round Lifecycle State Machine #

10.5.1 States #

public enum RoundPhase: Equatable, Sendable {
    case loading
    case intro
    case presenting(taskIndex: Int)
    case awaitingInput(taskIndex: Int)
    case evaluating(taskIndex: Int)
    case feedbackCorrect(taskIndex: Int)
    case feedbackTryAgain(taskIndex: Int, hint: HintLevel)     // hint is .level1 or .level2
    case demonstratingSolution(taskIndex: Int)
    case awaitingSolutionTap(taskIndex: Int)
    case taskTransition(fromIndex: Int)
    case roundComplete
    case celebration
    case exited(RoundEndReason)
    case failed(RoundFailure)
}

public enum PauseReason: Sendable, Equatable {
    case backgrounded, systemInterruption, inactivity
}

public enum RoundEndReason: Sendable, Equatable {
    case completed, userExit, timeLimit, inactivityTimeout, backgroundTimeout, contentFailure
}

@MainActor @Observable
public final class RoundController<Module: GameModule> {
    public private(set) var phase: RoundPhase
    public private(set) var pause: PauseReason?            // orthogonal to phase
    public private(set) var currentTask: GameTask<Module.Payload>?
    public private(set) var attemptState: TaskAttemptState // attempts so far, wrong answers, hint level
    public private(set) var hintLevel: HintLevel
    public private(set) var completedCount: Int
    public private(set) var isInputEnabled: Bool           // derived: phase accepts input && pause == nil && unlock time reached
    public private(set) var showsDemoHand: Bool

    public init(module: Module, plan: RoundPlan, environment: GameEnvironment)

    // Called by the task view
    public func submit(_ answer: Module.Answer)             // commits an answer (an attempt candidate)
    public func noteInteraction()                           // any touch in task/answer area; resets inactivity
    public func replayPrompt()                              // speaker button
    public func requestExit()                               // after a completed hold-to-exit
    // Called by the container / app
    public func start()
    public func pauseRound(_ reason: PauseReason)
    public func resumeRound()
    public func requestEndAfterCurrentTask(_ reason: RoundEndReason) // time limit: the current task still completes (T30)
}

pause is orthogonal: while pause != nil, all timers of the current phase are suspended (remaining time preserved), audio is stopped (not queued), and input is disabled. resumeRound() restores the phase with its remaining time; see 10.12 for which phases restart their audio.

10.5.2 Timing Constants #

All durations are properties of GameTimings; .standard values below; .instant sets every duration to 0 and every watchdog to 50 ms for tests.

Name Standard value Meaning
loadingIndicatorDelay 0.3 s Show the calm loading illustration only if loading takes longer than this
loadingTimeout 3.0 s Loading watchdog; on expiry, failed(.loadTimeout)
taskEntrance 0.35 s Task content entrance animation (fade + scale 0.96→1.0; Reduce Motion: fade 0.2 s)
inputUnlockAfterPromptStart 0.6 s Input becomes enabled 0.6 s after the task prompt starts playing
inputUnlockVoiceOff 0.6 s With voice off: input enabled 0.6 s after the entrance ends
selectionEcho 0.15 s Visual confirmation of the chosen answer before feedback starts
correctFeedbackMax 2.5 s Hard cap for correct feedback (celebrations never exceed 2.5 s)
correctFeedbackVoiceOff 1.0 s Correct feedback duration when voice is off
postFeedbackPause 0.3 s Silence between the end of feedback audio and the task transition
tryAgainLead 0.25 s Delay between the neutral sound and the try-again/hint line
wrongChoiceSettle 0.4 s Wrong choice is shown selected, then settles back/fades
solutionDemoHandTravel 0.8 s Demo hand travels to the correct answer
taskTransition 0.4 s Cross-fade between tasks
roundCompleteLead 0.3 s Pause between last feedback and the celebration
celebrationMax 2.5 s Round celebration length cap (content per Section 14)
demoHandIdle 6 s Inactivity before the demo hand plays (10.11)
repromptIdle 20 s Inactivity before the gentle re-prompt
secondRepromptIdle 45 s Inactivity before the second re-prompt with demo hand
pauseOverlayIdle 90 s Inactivity before the pause overlay
pauseOverlayTimeout 300 s Time in the inactivity pause overlay before the round ends
shortInterruptionAutoResume 3 s Interruptions shorter than this resume automatically
backgroundRoundTimeout 300 s Backgrounded at least this long: round ends (aligns with the session rule in Section 15)
audioWatchdogFloor 6 s If an audio completion callback never arrives, advance after max(expected duration + 1 s, 6 s)
holdToExit 1.0 s Press-and-hold duration of the home button
speakerDebounce 0.8 s Minimum interval between two accepted speaker-button taps

10.5.3 Transition Table #

Guards are evaluated in order; the first matching row wins. "Voice" means the device-level "Sprache" toggle (Section 16) is on.

ID From Trigger Guard Action To
T01 (init) start() — Decode parameters; create the round generator SplitMix64(seed: plan.seed); generate task 0; preload audio for the round (intro, all task prompts, hint/solution lines, num.* needed, feedback pool; IDs from UtteranceBuilder.referencedIDs, Section 20.5) via GameAudio.preload; emit roundStarted loading
T02 loading preload + generation finished parameters valid — intro
T03 loading parameters invalid or loadingTimeout — Log; show "Ups" illustration 1.0 s failed
T04 intro entry round is the first round of this game in the current session, or plan.abenteuerID != nil Play prompt.<gameId>.intro (voice) and show the game's intro visual (1.5 s illustration of the mechanic) stays until audio completes (or 1.5 s when voice off), then presenting(0)
T05 intro entry any other round Skip intro (repeat rounds in the same session go straight to the task) presenting(0)
T06 presenting(i) entry — Emit taskPresented; run taskEntrance; then play the task prompt (spokenPrompt) when voice is on; start inactivity timers (10.11) at entrance end awaitingInput(i) immediately at entrance end (input enablement is separate, see T07)
T07 awaitingInput(i) time since prompt start ≥ 0.6 s (voice) or since entrance end ≥ 0.6 s (voice off) — isInputEnabled = true same
T08 awaitingInput(i) submit(answer) input enabled Lock input; selectionEcho evaluating(i)
T09 evaluating(i) evaluation .correct — Record attempt; compute outcome (10.6.4); play correct feedback (10.8.1) feedbackCorrect(i)
T10 evaluating(i) evaluation .incorrect wrong attempts now 1 Record attempt; hint level 1 (10.7.2) feedbackTryAgain(i, .level1)
T11 evaluating(i) evaluation .incorrect wrong attempts now 2 Record attempt; hint level 2 (10.7.3) feedbackTryAgain(i, .level2)
T12 evaluating(i) evaluation .incorrect wrong attempts now 3 Record attempt; start solution demonstration (10.7.4) demonstratingSolution(i)
T13 evaluating(i) evaluation .partial — Play the game's partial-progress response (e.g. one gap filled); no attempt counted awaitingInput(i) (input enabled immediately)
T14 evaluating(i) evaluation .notAnAttempt — Return the item to its origin (drags) silently awaitingInput(i) (input enabled immediately)
T15 feedbackTryAgain(i, h) hint audio started — Input re-enabled 0.6 s after the hint line starts (voice) or 0.6 s after the visual nudge starts (voice off) awaitingInput(i)
T16 demonstratingSolution(i) solution line and hand travel finished — Only the solution answer is interactive; it keeps a soft green (#4FA36B) ring awaitingSolutionTap(i)
T17 awaitingSolutionTap(i) submit(answer) answer == solutionAnswer Play sfx.soft_confirm (no haptic, 10.15); no correct-feedback line; outcome shown taskTransition(i) after 0.6 s
T18 awaitingSolutionTap(i) submit(answer) answer != solutionAnswer Ignored (other options are disabled; this is defensive) same
T19 feedbackCorrect(i) feedback finished (audio completion + postFeedbackPause, capped by correctFeedbackMax) — Emit taskCompleted taskTransition(i)
T20 taskTransition(i) entry — Emit taskCompleted if not yet emitted (T17 path); increment completedCount; fill progress dot i; ask SessionGate.checkpoint(after:of:abenteuerID:) see T21–T24
T21 taskTransition(i) checkpoint .endNow(reason) (time limit, Section 15) — Emit roundEnded(reason: .timeLimit) exited(.timeLimit)
T22 taskTransition(i) a pending end stored by T30 exists checkpoint was .continue Emit roundEnded(reason: pending reason) exited(pending reason)
T23 taskTransition(i) checkpoint .continue no pending end; i + 1 < task count Generate task i+1; cross-fade taskTransition presenting(i+1)
T24 taskTransition(i) checkpoint .continue no pending end; i + 1 == task count roundCompleteLead roundComplete
T25 roundComplete entry — Emit roundCompleted celebration
T26 celebration entry — Container presents the round-end celebration S-08 (Section 14 content, ≤ 2.5 s); in an Abenteuer the Abenteuer coordinator decides what follows (Sections 9 and 15). The break nudge S-26 is never shown during a round; the container may show it after S-08 (Section 15.9) exited(.completed) when S-08 is dismissed
T27 any active phase requestExit() — Stop audio; discard the unfinished task (10.12.4); emit roundEnded(.userExit) exited(.userExit)
T28 any active phase pauseRound(r) — Suspend timers, stop audio, disable input same phase, pause = r
T29 any paused phase resumeRound() — Restore per 10.12.3 same phase, pause = nil
T30 any active phase except taskTransition, roundComplete, celebration requestEndAfterCurrentTask(r) — Store pending end; applied at the next T20 checkpoint (T22), after the session gate. The current task always completes: the hint ladder bounds it (at most three wrong attempts, then the solution), and the inactivity rules of 10.11 still apply. If the inactivity timeout ends the round while a time-limit end is pending, the round ends with .timeLimit and the unfinished task is discarded (10.12.4). same

"Any active phase" means every phase except exited and failed.

10.5.4 Timeline of a Typical Task (voice on) #

t (s) Event
0.00 presenting(i): entrance animation starts
0.35 Entrance ends; prompt audio starts ("Wo ist die Sieben?"); inactivity timers start
0.95 Input enabled (0.6 s after prompt start)
2.10 Child taps tile "7"; selectionEcho 0.15 s
2.25 Evaluation .correct; sfx.correct + haptic success; num.7 then fb.correct.nn
~3.9 Feedback audio finished; +0.3 s
~4.2 taskTransition 0.4 s; progress dot fills
~4.6 Next task presenting(i+1)

With voice off the same task takes: entrance 0.35 s, input at 0.95 s, correct feedback 1.0 s, transition 0.4 s.

10.6 Answer Evaluation Contract #

10.6.1 Evaluation Result #

public enum AnswerEvaluation: Sendable, Equatable {
    case correct
    case incorrect(IncorrectInfo)       // counts as one attempt
    case partial(PartialInfo)           // correct sub-step of a multi-part answer; not an attempt
    case notAnAttempt                   // e.g. drop outside any target, empty submit
}

public struct IncorrectInfo: Sendable, Equatable {
    public let chosenValue: Int?        // e.g. the numeral tapped, the count submitted
    public let optionID: String?        // stable ID of the chosen option, used to fade it (10.7.2)
    public let errorKind: ErrorKind     // .offByOne, .offByTwoOrMore, .confusableNumeral, .tensConfusion, .other
}

public struct TaskAttemptState: Sendable, Equatable {
    public var wrongAttempts: Int                 // 0...3
    public var fadedOptionIDs: Set<String>
    public var partialProgress: [String: Int]     // game-defined (e.g. filled gaps)
    public var downgradeFirstTry: Bool            // set by a game-specific assist (e.g. Blitzblick replay, Section 12)
    public var hintLevelReached: HintLevel
}

Contract for every evaluate implementation:

  1. Pure: depends only on its arguments. No audio, no animation, no persistence.
  2. Deterministic: the same inputs always return the same result.
  3. Fast: < 1 ms on iPhone SE (2nd gen).
  4. Exhaustive: every possible Answer value maps to exactly one case.
  5. Tolerant: an answer that is mathematically equivalent to the target is .correct (e.g. in a free-order two-gap chain, filling the gaps in either order).
  6. errorKind is informational only (logged in TaskResult.wrongAnswers for Section 9's "Gerade schwierig" analysis and for tuning); it never changes feedback severity.

10.6.2 What Counts as an Attempt #

An attempt is one committed answer that the game evaluates as .correct or .incorrect. The following are never attempts:

Interaction Why
Taps on the speaker button, home button, progress dots Chrome, not answers
Marking/unmarking objects in Wie viele? Counting support (Section 11.3)
Filling or clearing dots in Entdecken "Zähl mit" before pressing the check button Building the answer, not committing it
A drag released outside every drop target .notAnAttempt; the item returns
A tap on a faded (already tried) option Disabled; ignored with no sound
Any input while isInputEnabled == false Ignored
A submit that the game defines as empty (e.g. check button with zero dots) .notAnAttempt; the check button gives a gentle wiggle and the prompt replays

10.6.3 Repeated Identical Answers #

Decision: after a wrong choice among discrete options (tiles, beads, cards), that option fades to 35 % opacity and becomes non-interactive for the rest of the task (fadedOptionIDs). A child therefore cannot repeat the identical wrong answer, and each wrong attempt narrows the choice. For answers without discrete options (e.g. a dot count), the same wrong value may be submitted again and counts as a new attempt. This is a framework invariant: no game uses a different opacity or keeps a wrong option tappable (Sections 12 and 13 cite this rule; in Mehr oder weniger a dimmed side card or "gleich" button is disabled in the same way).

10.6.4 Outcome Classification #

Condition at task completion Outcome
Correct on attempt 1, and downgradeFirstTry == false firstTry
Correct on attempt 1, and downgradeFirstTry == true afterHint
Correct on attempt 2 or 3 afterHint
Third wrong attempt; the app demonstrated the solution and the child tapped it shown

Not downgrading: replaying the prompt with the speaker button, the demo hand, re-prompts triggered by inactivity, marking objects, pausing and resuming. These are instructions, not help with the answer. The only game-specific downgrade in V1 is defined in Section 12 (Blitzblick replay at step 2/3).

10.7 Hint Ladder #

10.7.1 Overview #

Wrong attempts Hint level Audio Visual Input
0 .none Task prompt Task as presented Enabled 0.6 s after prompt start
1 .level1 (soft nudge) sfx.try_again → 0.25 s → fb.tryagain.nn → 0.15 s → game's hint level 1 line The wrong option settles and fades to 35 %; a gentle visual nudge toward the relevant part of the task (game-specific, e.g. the target numeral pulses once in the prompt area, the gap glows) Enabled 0.6 s after the hint line starts
2 .level2 (strong scaffolding) sfx.try_again → 0.25 s → game's hint level 2 line (no try-again line, to keep it short) Strong scaffolding (game-specific): count-along highlighting object by object, five-group emphasis (the first five objects/beads get a soft outline and the voice says "fünf" at the group boundary), reduced options, target representation shown Enabled when the scaffolding animation ends (or 0.6 s after the hint line starts if the animation is shorter)
3 .solution Solution line fb.solution.* composed per 10.8.3, e.g. "Das ist die Sieben. Schau: fünf und zwei." Demo hand travels to the correct answer; correct answer gets the soft green ring; all other options fade to 35 % and are disabled; five-group structure of the target is shown where the game has a quantity Only the correct answer is interactive

Never: red, a buzzer, a shaking "no" animation, a sad face, a lost star, a counter of mistakes, the word "falsch".

10.7.2 Level 1 Mechanics #

  1. The chosen wrong option shows the "selected" state for wrongChoiceSettle (0.4 s), then animates back to its resting state and fades to 35 % (dragged items float back to their origin first, 0.35 s ease-out).
  2. sfx.try_again is a short, neutral, soft low wooden sound, never a buzzer (Section 21.11 describes it); it is played even when voice is off if SFX is on.
  3. The try-again line is chosen from fb.tryagain.01–fb.tryagain.08 (Section 21) by the rotation rule in 10.8.4.
  4. The game's level-1 hint line follows (Sections 11–13 define the content per game). Typical content: restate the target in a different way ("Hör nochmal: sieben.").
  5. The visual nudge runs for at most 1.2 s, uses at most one pulse cycle per 0.8 s and at most 2 pulses (Section 19.9 limit: ≤ 1.5 Hz, ≤ 2 repetitions; well under the 3 Hz flashing limit).

10.7.3 Level 2 Mechanics #

  1. Same settle/fade of the wrong option.
  2. sfx.try_again, then the level-2 line.
  3. Scaffolding animations are sequential and child-paced: count-along highlights advance at 0.5 s per object (step 1 and littleOnes) or 0.4 s per object (otherwise), with num.k spoken per object when voice is on. Five-group emphasis: after every fifth object the highlight pauses 0.3 s and the group gets a soft outline that stays visible until the task ends.
  4. Input is disabled during the scaffolding animation; a tap in the task area during the animation skips the rest of the animation (the full final state appears instantly) and enables input 0.3 s later. Decision rationale: children who already see the answer should not have to wait.
  5. Scaffolding state persists for the rest of the task (outlines stay, removed options stay removed).

10.7.4 Solution Demonstration #

  1. On the third wrong attempt, all options except the correct one fade to 35 % and become disabled.
  2. The demo hand (10.10.2) appears at the bottom centre of the answer area and travels (0.8 s, ease-in-out) to the correct answer; it performs one press gesture (scale 1.0→0.9→1.0 over 0.4 s) without committing anything.
  3. Simultaneously the solution line plays (10.8.3), followed by hint.common.tap_glowing "Tipp auf das, was leuchtet." (voice on). For quantity games the quantity is shown in its five-group structure while the line says the decomposition.
  4. The correct answer keeps a soft green ring (#4FA36B, 3 pt, slow breathing opacity 60–100 % at 0.5 Hz) until tapped. For drag answers, tapping the highlighted item makes it fly into its target (0.35 s); dragging it there also works.
  5. On tap: sfx.soft_confirm (no haptic, 10.15), 0.6 s, then taskTransition. No fb.correct line (the child did not solve it; the app does not pretend), but also nothing negative; the progress dot fills exactly like for every other task and the star is awarded (+1 per task, any outcome, Section 14).
  6. Inactivity rules (10.11) apply in awaitingSolutionTap; the re-prompt repeats the solution line; the demo hand repeats the travel-and-press.

10.7.5 Hint Content Contract per Game #

Each game specification (Sections 11–13) must define, per task kind: the level-1 line (audio ID + German text), the level-1 visual nudge, the level-2 line, the level-2 scaffolding, the solution line composition and the solution visual. Hint lines use IDs hint.<gameId>.<key> or hint.common.<key>; solution lines use fb.solution.<key> fragments (Section 21 lists all lines).

10.8 Feedback Catalogue and Sequencing #

10.8.1 Correct Feedback #

Sequence (voice on):

  1. sfx.correct (a soft, bright chime) at t = 0 together with the visual: the chosen answer scales 1.0→1.08→1.0 (0.3 s), gets a soft green glow (#4FA36B), and 3–5 small warm-yellow (#F5B82E) sparkles drift up from it and fade within 0.8 s.
  2. At t = 0.15 s the voice part starts; a line is drawn from the task's pool (10.8.4):
    • All lines fb.correct.01–fb.correct.12 are standalone sentences without a number slot (Section 21.7.1). If the answer is a number, num.n (statement intonation) plays first and the line follows 0.2 s after it.
  3. Total duration is capped at correctFeedbackMax (2.5 s): if the sequence would exceed it, a standalone fb.correct line is skipped for that task (the number word always plays).

Voice off: step 1 only, 1.0 s total, and the numeral of the answer is shown enlarged above the answer for 0.8 s.

Haptic: success pattern (10.15).

10.8.2 Try-Again Feedback #

As in 10.7.2 and 10.7.3. The try-again lines are warm, never negative (e.g. "Probier es nochmal!", "Oh, schauen wir nochmal.", Section 21.7.2). The neutral colour #8A8FA3 is used only for the short settle outline of the wrong choice.

10.8.3 Solution Line Composition #

Solution lines are templates (Section 20.6.2) built from carrier and solution fragments (concatenation gap timing per Section 20.6.4):

Answer type Composition (template, Section 21.4) Example (n = 7 unless stated)
Numeral (n = 1–5) prompt.common.das_ist_die + num.n (common.numeral) "Das ist die Drei."
Numeral (n = 6–20) prompt.common.das_ist_die + num.n + fb.solution.schau + decomposition (common.numeral_split) "Das ist die Sieben. Schau: fünf und zwei."
Quantity (n = 1) prompt.common.das_ist_eins (one recorded line; variant of common.quantity) "Das ist eins."
Quantity (n = 2–5) prompt.common.das_sind + num.n (common.quantity) "Das sind vier."
Quantity (n = 6–20) prompt.common.das_sind + num.n + fb.solution.schau + decomposition (common.quantity_split) "Das sind siebzehn. Schau: zehn und sieben."
Game-specific Defined in Sections 11–13 "Hier fehlt die Sechs."

The spoken decomposition (template token {split n}, Section 20.6.2) is joined with prompt.common.und ("und") and follows the didactic rules of Section 4 (teens are "10 + (n−10)"): 6–9 → "fünf und (n−5)"; 10 → "fünf und fünf"; 11–19 → "zehn und (n−10)" (e.g. 17 → "zehn und sieben", 15 → "zehn und fünf"); 20 → "zehn und zehn". The structure field of numbers.json (Section 8.6) is used only for rendering five-group visuals, never for the spoken decomposition of teens.

Grammar rule for all carrier sentences: when a number word precedes a noun ("sieben Enten"), the number 1 uses a dedicated line with the correct article form ("eine Ente", "einen Punkt"); num.1 ("eins") is only used standalone or as the numeral noun ("die Eins").

10.8.4 Rotation Rules for Variation Pools #

  • fb.correct: the pool depends on the answer type: numeral answers draw from fb.correct.01–fb.correct.10; quantity answers with n ≥ 2 from fb.correct.01–fb.correct.09, fb.correct.11 and fb.correct.12; quantity answers with n = 1 and answers that are not a number from fb.correct.01–fb.correct.09 (and fb.correct.12 for quantity-building and comparison tasks). Pick uniformly at random (using the round's SplitMix64) from the pool, excluding the last 3 lines used in the current session.
  • fb.tryagain: pick from the 8 lines, excluding the last 2 used in the session.
  • Pools and exclusion history live in a per-session FeedbackRotation object held by the container, so an Abenteuer's three rounds share the history.

10.8.5 Audio IDs Required by the Framework #

The framework itself (independent of any game) uses these IDs; Section 21 lists their text and description, Section 20 their playback.

Audio ID Use
sfx.tap Touch-down on a chrome control (home, speaker, resume, check button)
sfx.tile_select Touch-down on an answer element (numeral tile, card, side, tray bead)
sfx.correct Correct answer
sfx.try_again Wrong answer (neutral, soft, never a buzzer)
sfx.soft_confirm Tapping the demonstrated solution
sfx.drag_pickup, sfx.drag_drop Drag start, successful drop
sfx.return Dragged item floats back to origin
sfx.dot_on, sfx.dot_off A Zwanzigerfeld dot or bead fills / empties (shared by several games, Section 11)
sfx.mark A counting object is marked or unmarked (Section 11.3)
sfx.star A progress dot fills (it carries the star of the completed task)
fb.correct.01–fb.correct.12 Correct feedback pool (10.8.1, 10.8.4)
fb.tryagain.01–fb.tryagain.08 Try-again pool
prompt.common.das_ist_die, prompt.common.das_ist_eins, prompt.common.das_sind, prompt.common.und, fb.solution.schau Carrier and solution fragments ("Das ist die", "Das ist eins.", "Das sind", "und", "Schau:")
hint.common.listen_again "Hör nochmal zu." (prefix of the inactivity re-prompt, 10.11)
hint.common.tap_glowing "Tipp auf das, was leuchtet." (after the solution line, 10.7.4)
hint.common.your_turn "Jetzt du!" (after the demo hand when voice is on)
session.pause "Bist du noch da? Tipp auf den Pfeil, dann geht es weiter." (pause overlay)
session.resume "Weiter geht's!" (leaving the pause overlay)
session.hold_to_exit "Halt den Finger drauf, dann geht es nach Hause." (short tap on home)

Section 21 mirrors every ID and German text in this table, including the framework-owned lines sfx.soft_confirm, sfx.return, sfx.mark, hint.common.your_turn and session.hold_to_exit.

10.9 Input Handling #

10.9.1 Input Types #

Input Used by Recogniser Commit moment
Tap Numeral tiles, beads in trays, dots, cards, check button, sides DragGesture(minimumDistance: 0) wrapped by InputArbiter (so touch-down and touch-up are both observed) Touch-up inside the tap region (10.9.3)
Drag Was fehlt? beads, Zahlenmonster items, Froschsprung frog DragGesture(minimumDistance: 8) via InputArbiter Release over a drop target
Trace Nachspuren PKCanvasView (Section 12); framework only receives the committed stroke set as Answer Game-defined (Section 12)
Shake Schüttelbox Core Motion (Section 12); framework receives a "shaken" event, not an attempt Game-defined
Tap on SpriteKit node Wie viele? step 3, Schüttelbox SKScene.touchesBegan/Ended forwarded through InputArbiter.acquire/release Touch-up on node

10.9.2 First-Touch-Wins (Multi-Touch Rejection) #

InputArbiter is a @MainActor object shared by all interactive elements of one task:

  1. The first touch that begins on an interactive element acquires the arbiter (acquire(touchToken)).
  2. While acquired, touch-downs on any other interactive element are ignored completely (no highlight, no sound).
  3. The arbiter is released when the owning touch ends or is cancelled.
  4. Touches that begin on non-interactive regions (e.g. a resting palm on the background) never acquire the arbiter, so a child resting one hand on the screen can still tap with the other.
  5. Two touches beginning within the same frame on two targets: the one delivered first by SwiftUI wins; the other is ignored.
  6. Pinch/zoom/rotate are not recognised anywhere in child mode. System gestures (Home indicator swipe, Control Center) are not suppressed; see 10.12 for their effect. Decision: the game screen defers system edge gestures with .defersSystemGestures(on: .all) so an accidental swipe from an edge needs a second swipe; verification step: on an iPhone with a Home indicator, confirm that a single swipe up from the bottom during a round does not leave the app, and a second swipe does.

10.9.3 Tap Tolerance (Touch Slop) and Long Presses #

Rule Value
Touch-down feedback Element scales to 0.95 within one frame and plays sfx.tile_select (answer elements) or sfx.tap (chrome controls) if SFX is on; response < 50 ms; no haptic (10.15)
Commit On touch-up, if the touch-up location is inside the element's hit region expanded by 16 pt on every side
Movement tolerance Movement up to 40 pt from touch-down still commits (toddlers slide while tapping); beyond 40 pt the tap is cancelled (element returns to normal, no sound) unless the element is draggable, in which case it becomes a drag at 8 pt
Target switching A touch belongs to the element where it began; sliding onto a neighbour never selects the neighbour
Long press A press of any length commits on release (no maximum duration); holding never triggers a secondary action in child mode except the home button (10.9.7)
Hit region At least 60×60 pt for every child control (Section 19); visual may be smaller only for counting objects and dots whose hit region is still 60×60 pt; overlapping hit regions resolve to the element whose centre is nearest to the touch-down point

10.9.4 Debouncing and Double-Tap Protection #

Rule Value
After a commit All answer elements are locked until the controller re-enables input (T07, T13, T14, T15, T16)
Same element re-tap A second commit on the same element within 0.35 s of the previous one is ignored (covers double taps on toggles such as object marks and dots)
Toggle elements (object marks, Entdecken dots) Per-element debounce 0.25 s
Speaker button Accepted at most once per 0.8 s
Check / submit button Accepted at most once per 1.0 s
Taps during the celebration or transition Ignored

10.9.5 Drag Rules #

Rule Value
Start threshold 8 pt movement
Dragged item Lifts: scale 1.1, soft shadow, follows the finger with its centre offset preserved; sfx.drag_pickup
Drop target acceptance Item centre within the target's frame expanded by 24 pt; if several targets qualify, the one with the nearest centre
Magnetic snap When within 24 pt of a target, a soft outline appears on that target (preview)
Drop on target Evaluated (submit); correct → snaps (0.2 s) + sfx.drag_drop
Drop elsewhere .notAnAttempt; item floats back to origin (0.35 s ease-out), sfx.return
Drag cancelled by the system (interruption) Item returns to origin; not an attempt
Tap on a draggable item Equivalent to dragging it to the currently active target (each game defines its active target, e.g. the first open gap in Was fehlt? or the monster's mouth in Zahlenmonster, where each tap feeds exactly one item or pack). Decision: every drag interaction in every game has this single-tap alternative, for both levels and all steps, because many 2–3-year-olds cannot drag reliably; no game may switch it off. Outside games (the garden, Section 14.4.3) placement additionally works tap-then-tap: tap an item, then tap the destination.

10.9.6 Input During the Prompt #

Decision: input is locked for the first 0.6 s of the spoken prompt and allowed afterwards, even while the prompt is still playing. An answer committed while the prompt is still playing stops the prompt immediately (fade 80 ms) and proceeds with evaluation. Rationale: children who already know what to do are not forced to wait; 0.6 s protects against taps carried over from the previous screen.

10.9.7 Home Button (Mid-Round Exit Control) #

Decision: the home button (top-left, house pictogram, 60×60 pt; 72×72 pt on iPad) requires a press-and-hold of 1.0 s.

  • On touch-down a circular progress ring fills around the icon over 1.0 s (Reduce Motion: the ring fills in 4 discrete quarter steps).
  • Release before 1.0 s: the ring empties (0.2 s); the first such short tap in a session plays session.hold_to_exit and the demo hand shows a press-and-hold on the home button once; later short taps show only the ring.
  • Completing the hold: requestExit() (no haptic, 10.15).
  • VoiceOver: the button's accessibility action exits immediately (an adult using assistive technology is not slowed down).
  • The home button is always visible and enabled in every phase except celebration (S-08 has its own exit, Section 18). This satisfies the "no dead ends" rule of Section 18.

10.10 Speaker Button and Visual Instruction Demo #

10.10.1 Speaker / Replay Button #

  • Position: top-right slot (10.14), speaker pictogram, 60×60 pt (72×72 pt on iPad). Present on every task of every game.
  • Tap (voice on): replays the current task prompt (spokenPrompt), not the intro. In feedbackTryAgain/awaitingInput after a hint, it replays the prompt followed by the most recent hint line. In awaitingSolutionTap it replays the solution line.
  • While audio is playing: a tap within 1.0 s of the start of the current line is ignored; later taps restart the prompt from the beginning.
  • The button animates (sound waves, 3 bars, 2 Hz max) while a prompt is playing; static otherwise.
  • Replays never affect the outcome (10.6.4) and never count as attempts; they reset the inactivity timers.
  • Voice off: the button shows a pointing-hand pictogram instead of the speaker, and a tap plays the demo hand (10.10.2). Its accessibility label becomes "Zeigen" instead of "Nochmal hören".
  • Enabled in awaitingInput, feedbackTryAgain (after the hint line started) and awaitingSolutionTap; disabled (50 % opacity) otherwise.

10.10.2 Demo Hand #

The demo hand (DemoHandView) is a friendly illustrated child hand with a pointing index finger (skin tone: the round's tone from the diverse set defined in Section 19.7.5, derived from the round seed, so the demo hand and any finger patterns of the round match).

When it plays:

Trigger Condition
Task entrance of the first task of a round Voice off (always), or the game is played for the first time by this profile (voice on or off)
Inactivity 6 s Every task, voice on or off (10.11)
Speaker button Voice off
Solution demonstration Always (travels to the correct answer, 10.7.4)

What it shows (never the answer, except in the solution demonstration):

Game interaction type Demo gesture
Choose one option (tiles, sides, cards) Hand appears above the centre of the answer area, moves along the row of options (left to right, 1.2 s) without touching any, then performs a tap gesture on empty space below the row centre with a small ripple. It never rests on a specific option.
Drag to target A translucent "ghost" item (a neutral grey bead/item without numeral) is dragged by the hand from the tray area to the target (1.2 s), then disappears. The real items do not move.
Tap objects to mark Hand taps the top-left-most object once; the mark appears on the ghost overlay only, then fades (marks are not part of the answer, so this does not reveal anything).
Build a quantity (dots) and press check Hand taps the first dot (ghost fill), then moves to the check button and taps it (ghost press).
Trace / shake Defined in Section 12.

Duration ≤ 2.0 s per play, at most one play per 6 s. Input remains enabled during the demo hand; any touch cancels it immediately. With Reduce Motion, the hand does not travel: it appears at the start pose, cross-fades (0.3 s) to the end pose, then fades out.

When voice is on and the demo hand was triggered by inactivity, hint.common.your_turn ("Jetzt du!") plays after the hand disappears.

10.10.3 Visual Prompt Equivalents (Sound-Off Path) #

Section 20 owns the principle that the whole app works without sound. In games this is implemented as: each game defines a visual prompt shown in the prompt area (Sections 11–13 specify it per task kind), the demo hand plays at task entrance when voice is off, and correct/try-again/solution feedback have visual forms (10.8). Games whose core skill needs the voice follow the voice-dependent credit rule in 10.4.3.

10.11 Inactivity Handling #

Inactivity timers run only in awaitingInput and awaitingSolutionTap and are reset by any touch in the task or answer area (noteInteraction()), by a speaker tap, and by resuming from pause. They never punish: no sound effect, no countdown, no outcome change.

Idle time Action
6 s Demo hand plays once (10.10.2); voice on: followed by "Jetzt du!"
20 s Gentle re-prompt: hint.common.listen_again ("Hör nochmal zu.") + the task prompt (voice on); voice off: demo hand again and the visual prompt pulses once
45 s Second re-prompt identical to the 20 s one, plus the demo hand
90 s pauseRound(.inactivity): pause overlay (10.12.2) with session.pause spoken once; because the round is paused, the device idle timer is re-enabled and the screen can auto-lock
90 s + 300 s Round ends with reason .inactivityTimeout (10.12.4); the app returns to the child home S-05

UIApplication.shared.isIdleTimerDisabled is true only while an S-07 task is on screen and the round is not paused (Section 15.11 owns this rule; the container applies it on every phase and pause change). Session time accounting during the inactivity pause is owned by Section 15 (time in the pause overlay does not count as active time).

10.12 Pause, Interruption, Resume and Exit #

10.12.1 Pause Sources #

Source Detection Pause reason
App leaves foreground (Home, app switcher, lock button, Control Center, Notification Center, incoming full-screen call) scenePhase != .active .backgrounded
Audio session interruption (call banner accepted, Siri, alarm) AVAudioSession.interruptionNotification type .began (forwarded by ZKAudio, Section 20) .systemInterruption
Inactivity 90 s 10.11 .inactivity
Output route lost (headphones unplugged, Bluetooth disconnected) AudioEvent.outputRouteLost from ZKAudio No pause: the current voice line stops, the speaker button pulses once and the task stays interactive (Section 20.12)

The break nudge never pauses a round: it is shown only after the round's S-08 (Section 15.9). The game screen contains no entry to the parent area; the parental gate cannot interrupt a round. The grown-up entry icon (press-and-hold 2 s, then the parental gate) exists only on S-04, S-05 and S-16 and as the parent icon inside S-15 (Section 18).

10.12.2 Pause Overlay #

  • Full-screen dimmer (ink #2B2A33 at 40 %) over the game, the task stays visible underneath (frozen).
  • One large resume button in the centre: round play pictogram (triangle), 120×120 pt (iPhone) / 160×160 pt (iPad), warm yellow #F5B82E.
  • The home button stays usable in its normal place (hold-to-exit).
  • Voice on: session.pause is spoken once when the overlay appears (all three pause reasons).
  • SpriteKit scenes are paused (isPaused = true); SwiftUI animations freeze by removing their driving timelines.

10.12.3 Resume Semantics #

Situation Behaviour
.backgrounded / .systemInterruption shorter than 3 s Automatic resume, no overlay, no sound
.backgrounded / .systemInterruption 3 s to < 300 s Pause overlay; tap on resume → overlay fades (0.2 s), session.resume ("Weiter geht's!", voice on), then the rows below apply
.backgrounded for ≥ 300 s On return the round has ended with .backgroundTimeout; the app shows the child home S-05 or the profile picker per Section 15's session rules
.inactivity Resume button → same as above
After resume in awaitingInput or feedbackTryAgain The current task prompt (and the latest hint line, if any) is replayed from the start; input enabled 0.6 s after it starts; attempt count, faded options, hint level, scaffolding state and marks are preserved
After resume in awaitingSolutionTap Solution line replays; only the correct answer stays interactive
After resume in evaluating, feedbackCorrect, taskTransition The interrupted feedback is not replayed; the controller proceeds directly to the next phase (e.g. the next task)
After resume in presenting The entrance restarts
After resume in demonstratingSolution The demonstration restarts from the beginning
After resume in intro Intro is skipped; first task is presented
Drag in progress when paused Drag is cancelled, item returns to origin, not an attempt

10.12.4 Mid-Round Exit and What Is Recorded #

Item Recorded?
Tasks completed before the exit Yes. Each taskCompleted event is persisted immediately when emitted (10.13.3); mastery updates, attempt log and the +1 star per task stay.
The unfinished current task (even with wrong attempts already made) Decision: discarded completely — no TaskResult, no mastery change, no star, no effect on difficulty stepping. Rationale: an exit is often a toddler accident or a parent intervention; partial data would bias mastery downward. The wrong attempts are logged at debug level only (not persisted).
Round completion bonus (+2 stars) Not awarded (the round is not completed), except in the content-failure case in 10.4.4.
Event roundEnded(RoundEndSummary) with reason, completed count, planned count
Abenteuer in progress The Abenteuer coordinator receives the event. The Abenteuer stays unfinished: tapping the Abenteuer button again on the same local day resumes it at its next unplayed round; a new local day discards it (Section 9.16.6)
Session time Continues to count per Section 15 while in child mode

Exit reasons and their destinations:

Reason Destination
.userExit Child home S-05 (or the game picker S-06 if the round was started from S-06; Decision: back to where the child came from)
.timeLimit "Zeit zum Ausruhen" S-16 (Section 15)
.inactivityTimeout Child home S-05
.backgroundTimeout Per Section 15
.contentFailure S-08 if ≥ 1 task completed, else S-06

10.13 Events Emitted #

10.13.1 Event Types #

public enum GameEvent: Sendable, Equatable {
    case roundStarted(RoundStarted)
    case taskPresented(TaskPresented)
    case attemptEvaluated(AttemptRecord)
    case hintShown(HintShown)
    case taskCompleted(TaskResult)
    case roundCompleted(RoundSummary)
    case roundEnded(RoundEndSummary)       // any end other than normal completion
    case pictureCompleted(PictureCompleted) // Punkt zu Punkt: a dot picture was finished (Section 13.4.10)
}

@MainActor
public protocol GameEventSink: AnyObject {
    func handle(_ event: GameEvent)
}

@MainActor
public protocol SessionGate: AnyObject {
    /// Called at every task boundary (T20). Section 15 implements it (time limit).
    /// The break nudge is not a checkpoint result: it is shown only after S-08 (Section 15.9).
    func checkpoint(after completed: Int, of planned: Int, abenteuerID: UUID?) -> SessionCheckpoint
}

public enum SessionCheckpoint: Sendable, Equatable {
    case `continue`, endNow(RoundEndReason)
}

10.13.2 Payloads #

public struct TaskResult: Sendable, Equatable, Codable {
    public let taskID: UUID                   // PlannedTask.id
    public let roundID: UUID                  // RoundPlan.id
    public let gameID: GameID
    public let mode: GameMode                 // .standard only; free-explore never emits TaskResult
    public let taskKind: String
    public let targetNumber: Int
    public let clampedFrom: Int?              // see 10.4.2
    public let step: DifficultyStep
    public let bucket: TaskBucket             // from PlannedTask.bucket
    public let creditedSkills: [SkillCredit]  // e.g. [(count, 1.0), (recognize, 0.5)]; see voice rule 10.4.3
    public let outcome: TaskOutcome
    public let attempts: Int                  // 1...4 (4 = three wrong + tapping the shown solution)
    public let wrongAnswers: [IncorrectInfo]  // up to 3
    public let hintLevelReached: HintLevel
    public let voiceOn: Bool
    public let presentedAt: Date              // from AppClock
    public let completedAt: Date
    public let activeDurationMs: Int          // presented→completed minus paused time
    public let abenteuerID: UUID?             // RoundPlan.abenteuerID
    public let updatedGameState: String?      // staged GameStateStore JSON, persisted in the task save point (10.4.5)
}

public struct SkillCredit: Sendable, Equatable, Codable {
    public let skill: Skill
    public let weight: Double                 // 1.0 or 0.5
}

public struct RoundSummary: Sendable, Equatable, Codable {
    public let roundID: UUID
    public let gameID: GameID
    public let step: DifficultyStep           // the round's base step (RoundPlan.step)
    public let abenteuerID: UUID?
    public let taskCount: Int
    public let outcomes: [TaskOutcome]
    public let isShortened: Bool              // content-failure path, 10.4.4
    public let startedAt: Date
    public let completedAt: Date
}

public struct RoundEndSummary: Sendable, Equatable, Codable {
    public let roundID: UUID
    public let gameID: GameID
    public let reason: RoundEndReason
    public let completedTasks: Int
    public let plannedTasks: Int
    public let abenteuerID: UUID?
}

public struct PictureCompleted: Sendable, Equatable, Codable {
    public let roundID: UUID
    public let pictureID: String              // dot picture ID, e.g. "stern" (Section 8.12)
    public let stickerID: String              // "sticker.dot.<pictureId>" (Section 14.7)
    public let completedAt: Date
}

No event carries a profile ID: games do not know profiles. The app attaches the active profile when it handles an event.

attemptEvaluated, taskPresented and hintShown are for the developer menu (Section 5) and debug logging; they are not persisted.

10.13.3 Consumers and Ordering #

The app's RoundResultCoordinator implements GameEventSink (Section 5.4.5, which owns the exact call sequence; Section 7.12.2 owns the save points). On taskCompleted it performs one unit of work:

  1. Maps the result to the engine input and applies the mastery update, Leitner state, difficulty-stepping counters and range state through the learning engine (Section 9).
  2. Records the attempt, mastery records, game statistics and GameProgress (including gameStateJSON when updatedGameState is non-nil, 10.4.5) through the repositories (Section 7).
  3. Awards +1 star through the reward service (Section 14.12).
  4. Evaluates Zahlenfreund eligibility (Section 9 computes, Section 14 celebrates). A befriending celebration is never shown mid-round: it is queued and shown after the round's S-08 (Section 14.5.4).
  5. Saves once; on a save error it rolls back (Section 7.12.2) and the child flow continues unchanged.

On roundCompleted: +2 star round bonus (Section 14), GameProgress bookkeeping and milestone checks in the round save point. On pictureCompleted: the dot-picture sticker is awarded by the reward service in the same round save point (Section 7.12.2; a picture already in the album gives no second sticker, Section 14.7). On roundEnded: session bookkeeping only.

Ordering guarantees: events for one round are delivered in order, on the main actor, exactly once; pictureCompleted is emitted before the roundCompleted of the same round. The coordinator must finish handling a taskCompleted within the 0.4 s task transition; persistence errors are logged and never block or alter the child's flow (Section 24).

Stars are never shown as a per-task counter inside the game screen; the child sees the star total in S-08 and on the child home (Section 14).

10.14 Common Layout Slots (Game Screen S-07) #

10.14.1 Slots #

This section owns what each slot means; Section 19.5.2 (game container row) owns all geometry: top-bar heights, the split between task and answer area per layout class, margins and option arrangement. Sizes of the chrome components are owned by Section 19.7.7.

Slot Content Rules
Home (top-left) Hold-to-exit house button (HomeButton, 10.9.7) Always present; size per Section 19.7.7
Progress dots (top-centre) One dot per task in the round Not interactive; see 10.14.3
Speaker (top-right) Replay / show-me button (SpeakerButton, 10.10.1) Always present; size per Section 19.7.7
Prompt area Visual prompt (e.g. target numeral, object pictogram, a question-mark bubble) Sits at the top of the task area (.stacked) or at the top of the answer panel (.sideBySide); takes its height from that area; collapses to 0 when the game has no visual prompt. A numeral that is task content uses the child.numeral token or larger (Section 19.3.2)
Task area The game's main scene Share of the screen per Section 19.5.2; minimum 16 pt inner margin
Answer area Options (tiles, bead tray, check button) Share and option arrangement per Section 19.5.2; 12 pt minimum spacing between options (Section 19.6)

10.14.2 Layout Templates #

The template follows the layout class of the window (Section 19.5.1); games do not choose it:

Template Layout classes Arrangement
.stacked compactPortrait, regularPortrait Top bar (home, dots, speaker) → task area (with the prompt area at its top) → answer area at the bottom
.sideBySide compactLandscape, regularLandscape Top bar → below it, task area on the leading side and a panel on the trailing side containing the prompt area above the answer area

A game may place its answer elements inside the task area and leave the answer area empty (Hör hin, Section 11.4.3; Entdecken free explore, Section 11.2.6); it never changes the template.

Layouts are computed from the container's size (GeometryReader / ViewThatFits), not from device model checks, so every iPad window width down to 375 pt works (Section 5). Rotation during a task re-lays out the scene with a 0.3 s animated transition (Reduce Motion: instant); task state, marks and positions expressed in normalised coordinates are preserved (scattered layouts store positions in 0…1 unit space, 10.17.2).

10.14.3 Progress Dots #

  • One dot per task of the round (ProgressDots component; sizes, spacing and colours per Section 19.7.7).
  • States: upcoming (neutral outline), current (larger ink outline), completed (filled warm yellow).
  • Completed dots look identical regardless of outcome (firstTry, afterHint, shown): no shame signal.
  • No numbers, no "3/5" text, no timer bar.
  • Filling animation 0.3 s with sfx.star (Reduce Motion: fade).
  • Free-explore mode shows no progress dots.

10.15 Haptics #

Section 19.10 owns the haptics map (feedback types and intensities) and the .zkHaptic modifier, which does nothing when the device setting "Haptik" (Section 16) is off or the hardware has no haptic engine. Inside games the framework triggers only these events of that map:

Event Map entry (Section 19.10)
Correct answer Correct answer (.success)
Drag pick-up Drag pick-up
Drop snapped into a target Drop snaps into a target (.impact(weight: .light))
Round complete (S-08) Round complete (.success, Section 14)

Decision (identical in Section 19.10): no haptic for wrong answers, so nothing can feel like a punishment, and no per-tap haptic on tiles, dots, beads or object marks. Tapping the demonstrated solution and completing the home-button hold play no haptic either. Games add haptics only where their section cites a Section 19.10 entry (for example beads landing in Schüttelbox). Never more than one haptic per 150 ms (the framework coalesces).

10.16 Reduce Motion Variants #

When accessibilityReduceMotion is on (read from the SwiftUI environment and captured in GameSettingsSnapshot):

Element Standard Reduce Motion
Task entrance Fade + scale 0.96→1.0, 0.35 s Fade 0.2 s
Task transition Cross-fade 0.4 s with slight slide Cross-fade 0.2 s
Correct feedback Scale bounce + sparkles Soft green glow fade-in 0.2 s; no sparkles
Wrong option settle Return animation + fade Fade only
Drag return Float back 0.35 s Cross-fade out at drop point and in at origin, 0.2 s
Demo hand Travels Start pose → end pose cross-fade (10.10.2)
Count-along highlight (hint level 2) Highlight pops (scale 1.1) Highlight ring appears without scaling
Solution ring Breathing opacity Static ring
Speaker "playing" bars Animated Static icon with a small dot indicator
Moving objects (Wie viele? step 3) SpriteKit movement Static (Section 11.3 defines the replacement)
Progress dot fill Scale + fill Fade
Celebration Section 14 variant Section 14 reduced variant

Reduce Motion never changes task difficulty or mastery credit, except where a game specification states a replacement task form.

10.17 Shared Task-Generation Utilities #

10.17.1 DistractorPicker #

public enum DistractorStrategy: String, Codable, Sendable {
    case nearest          // closest remaining candidate by distance 1, 2, 3, ... (ties in seeded order)
    case near1            // n-1, n+1
    case near2            // n-2, n+2
    case far              // |d - n| >= 3
    case confusableVisual // see table below
    case confusableAuditory
    case tensPartner      // n±10 (e.g. 7 <-> 17)
    case fivePartner      // n±5 (e.g. 7 <-> 12, 3 <-> 8)
    case anyInRange
}

public struct DistractorPicker: Sendable {
    public init(range: ClosedRange<Int>, excluded: Set<Int>)
    /// Returns `count` distinct distractors, one per slot, in slot order.
    /// Each slot lists strategies tried in order; falls back to .anyInRange.
    public func pick(target: Int, slots: [[DistractorStrategy]], using generator: inout SplitMix64) -> [Int]
}

Rules (all games):

  1. Distractors are distinct, never equal to the target, and lie within the child's active range stage (1–5, 1–10 or 1–20) intersected with the step's allowed range. Numbers outside what the child is currently learning are never shown.
  2. For each slot, strategies are tried in order; within a strategy, candidates are shuffled with the seeded generator; the first valid candidate wins.
  3. If no strategy yields a candidate, .anyInRange is used; if the range is too small for the requested option count (e.g. 4 options in range 1–3 cannot occur because ranges start at 1–5), the option count is reduced to the number of available values, minimum 2.
  4. Confusable tables:
Kind Pairs (symmetric)
confusableVisual 1–7, 6–9, 2–5, 3–8, 16–19, 11–17, 12–15, 13–18, and each teen with its unit digit (12–2 … 19–9)
confusableAuditory 2–3 ("zwei"/"drei"), each teen with its unit word (13–3, 14–4, 16–6, 17–7, 18–8, 19–9; "dreizehn"/"drei" etc.), 12–20 ("zwölf"/"zwanzig"), 11–12 ("elf"/"zwölf", both irregular teens)

Two-digit reversals (12–21, 13–31) are excluded because numbers above 20 never appear.

DistractorPicker is the only distractor utility. Game sections express their preferences as strategy slots; for example "nearest numbers first" is [[.nearest], [.nearest], …], "n ± 10 first" is [.tensPartner, .nearest], "n ± 5 first" is [.fivePartner, .nearest].

10.17.2 ScatterLayout #

public struct ScatterLayout: Sendable {
    /// Positions are returned in unit space (0...1 on both axes) so rotation preserves them.
    public static func positions(
        count: Int,
        objectDiameter: Double,          // in points, for the current container
        container: CGSize,               // task area minus inner margins
        minCenterDistanceFactor: Double, // e.g. 1.25 x diameter
        edgeMargin: Double,              // e.g. 12 pt
        avoidRows: Bool,                 // true: reject layouts where >= 4 objects are collinear within 8 pt
        using generator: inout SplitMix64
    ) -> [CGPoint]
}

Algorithm: Poisson-disc style dart throwing — up to 30 candidates per object, up to 5 full restarts; if still unsuccessful, fall back to a jittered grid (grid cell ≥ minimum distance, jitter ±20 % of the cell). All objects lie fully inside the container with the edge margin. On re-layout (rotation), unit positions are scaled to the new container; if that violates the minimum distance (container aspect change), the layout is recomputed with the same seed and the marks follow their objects by identity.

10.17.3 OptionOrder (Position of the Correct Answer) #

public struct OptionOrder: Sendable {
    /// Returns the slot index of the correct option for the next task of the round.
    public static func correctSlot(optionCount: Int, previousCorrectSlots: [Int],
                                   using generator: inout SplitMix64) -> Int
}

One rule for every game that shows discrete answer options (tiles, cards, beads in a tray):

  1. With 3 or more options, the correct option never sits in the same slot as the previous task's correct option.
  2. With 2 options, the correct option never sits in the same slot in more than 2 consecutive tasks.
  3. Within these constraints the slot is uniform at random (seeded); the distractors fill the remaining slots in seeded random order.

The slots of previous tasks come from TaskGenerationContext.previousTask (the round's history). The rule restarts at the start of every round.

10.18 Testing Hooks #

10.18.1 Deterministic RoundController #

RoundController is fully deterministic given: the RoundPlan (incl. seed), GameEnvironment.timings, an injected AppClock (Section 5; tests use a manual clock), an injected FakeGameAudio and a SpyGameEventSink.

// ZKGameKit: thin adapter over the ZKAudio service (Section 20.5)
@MainActor
public protocol GameAudio: AnyObject {
    func preload(_ ids: Set<AudioID>) async
    /// Speaks the utterance with the given priority and returns when it finished, was interrupted
    /// or was suppressed (voice off). Wraps AudioService.speak(_:priority:) + completion(of:).
    func play(_ utterance: Utterance, priority: VoicePriority) async -> VoiceCompletion
    func playSFX(_ id: AudioID)
    func stopVoice()
}

@MainActor
public final class LiveGameAudio: GameAudio {
    public init(service: any AudioService)
}

// TestSupport (Section 6.2.1 naming: Fake / Spy / Stub)
public final class FakeGameAudio: GameAudio { /* records every call; completes utterances instantly or on demand */ }
public final class SpyGameEventSink: GameEventSink { public private(set) var events: [GameEvent] = [] }
public final class StubSessionGate: SessionGate { /* returns a scripted sequence of checkpoints */ }

Prompts, hints, re-prompts and solutions use priority .instruction; correct and try-again feedback and count-alongs triggered by the child use .feedback (arbitration rules in Section 20.7).

RoundController exposes @_spi(Testing) helpers: advance(by:) (drives the manual clock and fires due timers), forcePhase(_:) is deliberately NOT provided (tests must reach states through real transitions).

Required unit tests (Swift Testing, in ZKGameKitTests), at minimum:

Test Asserts
Correct first attempt Phases T06→T07→T08→T09→T19→T20; TaskResult.outcome == .firstTry, attempts == 1
One wrong then correct Hint level 1 audio sequence (sfx.try_again, fb.tryagain.*, hint L1); outcome afterHint
Two wrong then correct Hint level 2; outcome afterHint; wrong options faded
Three wrong Solution demonstration; only the solution answer accepted; outcome shown; attempts == 4
Input lock Submit before 0.6 s after prompt start is ignored
Replay does not downgrade Speaker taps before a correct first answer → firstTry
Inactivity 6 s demo hand, 20 s and 45 s re-prompt, 90 s pause, 390 s roundEnded(.inactivityTimeout)
Exit mid-task No taskCompleted for the unfinished task; roundEnded(.userExit) with correct counts
Background < 3 s Auto-resume, no overlay
Background ≥ 300 s roundEnded(.backgroundTimeout)
Time limit endNow(.timeLimit) at checkpoint → no further task presented
Time limit pending mid-task requestEndAfterCurrentTask(.timeLimit) during task 2 → task 2 still completes (including hint ladder and solution), then roundEnded(.timeLimit)
No break nudge mid-round SessionCheckpoint has no break-nudge case; a full round runs without a pause for any session reason
Game state A state staged via GameStateStore.save appears only in the next TaskResult.updatedGameState; state staged during a discarded task is not emitted
Picture completed A module emitting pictureCompleted produces it before roundCompleted, with no profile ID
Voice-off name credit Task with primary name and voice off → creditedSkills == [(recognize, 0.5)]
Rotation of feedback lines No fb.correct line repeats within 4 consecutive correct feedbacks
Determinism Two controllers with the same plan and scripted inputs produce identical event streams

Every game target additionally tests makeTask (determinism, parameter ranges, distractor rules) and evaluate (exhaustive per task kind) with the game's own parameter tables (Sections 11–13).

10.18.2 UI-Test Hooks #

Launch arguments are defined only in the single registry of Section 5.9 (parsed under DEBUG || UITEST_HOOKS, never in Release). The arguments that affect games are -uiTestSeed <name> and -uiTestFixedNow <ISO-8601> (deterministic plans and generation), -uiTestTimings instant (uses GameTimings.instant), -uiTestForcePlan <gameId>:<step>:<n1,n2,…> (bypasses the engine and starts a round with the given step and target numbers), -uiTestForceReduceMotion, -uiTestAudio stub and -uiTestRevealAnswers. Voice off is tested through the device settings of a seeded store, not through a separate argument.

Accessibility identifiers follow the format of Section 6.10 (constants in A11yID). The framework assigns:

Identifier Element
S07.home Home (hold-to-exit) button
S07.speaker Speaker / show-me button
S07.progressDot.<i> Progress dot i (0-based)
S07.pauseResume Resume button of the pause overlay
S07.<gameId>.answer.<value> Numeral answer option with that value (e.g. S07.hoer_hin.answer.12)
S07.<gameId>.target.<id> Drop target (e.g. a gap)
S07.<gameId>.check Check ("Fertig") button

Games add their own identifiers as S07.<gameId>.<element>[.<qualifier>].

10.18.3 Preview Support #

Every game provides SwiftUI previews for each task kind at each step on "iPhone SE (3rd generation)" portrait and landscape and on an 11-inch iPad, using FakeGameAudio (also compiled into ZKGameKit under #if DEBUG for previews), GameTimings.instant and a fixed seed.

11. Game Specifications: Free Games #

The four games in this section are free forever, with all three steps, for every profile (Section 17). They are the games the child meets first. For a child without a subscription the Abenteuer draws only from the three Abenteuer-eligible free games Wie viele?, Hör hin and Was fehlt? (Entdecken is never part of an Abenteuer; Sections 8.8.1 and 9.16.2). Each game is its own target (GameEntdecken, GameWieViele, GameHoerHin, GameWasFehlt) built on the shared framework in Section 10. The round lifecycle, the hint ladder, feedback sequencing, input rules, inactivity handling, pause/resume and events are defined only in Section 10; this section only adds what is specific to each game.

11.1 Conventions for All Game Specifications #

11.1.1 Template #

Every game in Sections 11–13 is specified with the same subsections: purpose and skills, level availability, screen layout, round structure, task generation per step, object sets and layouts (where relevant), prompts, hints and solution, correct feedback, parameters JSON, mastery credit, edge cases, acceptance criteria.

11.1.2 Number Ranges #

  • Each step declares an allowed target range in games/<gameId>.json (numberMin/numberMax, with numberRangeByLevel for per-level ranges, Section 8.8.2). The effective target range of a task is: step range for the child's level ∩ the child's active range stage (r5 = 1–5, r10 = 1–10, r20 = 1–20; Section 9 decides the stage, including parent overrides).
  • The learning engine only plans target numbers inside the effective range (Section 9 reads the ranges from the content file). A task is never generated with a target outside it (defensive clamping in Section 10.4.2).
  • Distractors, visible chain beads and all other numbers shown to the child also stay inside the active range stage (Section 10.17.1, rule 1).
  • If the effective range of a step is empty for the child (e.g. a step range 11–20 with stage r10), the engine does not plan that step; the game then uses the highest step whose effective range is non-empty. Section 9 owns this fallback; the ranges below are chosen so that it is rarely needed.

11.1.3 Numeral Options #

Property iPhone iPad
NumeralTile size (in the answer area) Size tier S/M per Section 19.7.6 (72×72 pt / 80×80 pt; numeral 44 pt / 48 pt) Size tier L per Section 19.7.6 (104×104 pt, numeral 64 pt)
NumeralTile size (Hör hin, tiles in the task area) 88×88 pt, numeral 56 pt 128×128 pt, numeral 84 pt
Spacing between options ≥ 12 pt (Section 19.6; 16 pt default) 20 pt
Numeral colour Ink #2B2A33 on white; never red or blue (colour must never cue the answer) same

Order of options: seeded, following the framework rule for the correct answer's position (Section 10.17.3): with 3 or more options the correct option never sits in the same slot as in the previous task; with 2 options it never sits in the same slot in more than 2 consecutive tasks. Numerals that are task content always use the child.numeral token or larger (44 pt iPhone / 64 pt iPad minimum, Section 19.3.2).

11.1.4 Object Pictograms #

Counting objects are flat illustrations that fit a circle (Section 19 style), with one object type per task. This table is the single catalogue of counting objects for all games: Section 13.2 (Zahlenmonster foods) uses the types marked as food, and Section 21.13.7 lists the same art. Asset names follow the flat scheme of Section 6.4.3: obj_<typeID> (e.g. obj_apfel).

typeID Singular / plural (German) Can move (Wie viele? step 3) Food (Zahlenmonster, Section 13.2)
apfel Apfel / Äpfel no yes
birne Birne / Birnen no yes
erdbeere Erdbeere / Erdbeeren no yes
karotte Karotte / Karotten no yes
banane Banane / Bananen no yes
stern Stern / Sterne no no
herz Herz / Herzen no no
blume Blume / Blumen no no
muschel Muschel / Muscheln no no
knopf Knopf / Knöpfe no no
ball Ball / Bälle yes no
ente Ente / Enten yes no
fisch Fisch / Fische yes no
schmetterling Schmetterling / Schmetterlinge yes no
marienkaefer Marienkäfer / Marienkäfer yes no
vogel Vogel / Vögel yes no
auto Auto / Autos yes no
schnecke Schnecke / Schnecken yes no

Decision: 18 types; no sweets or snacks (no Kekse, no Brezeln) appear as counting objects or foods (Section 23.12); the five foods are fruit and vegetables.

Rules: objects must never be red-and-blue five-group coded themselves (the five-group coding belongs to beads and hint badges); ladybird and strawberry art use a darker, clearly non-bead red and are never placed in a bead context.

11.1.5 German Grammar in Composed Lines #

  • "die Sieben" (feminine noun) when the numeral is meant; "sieben Äpfel" when a quantity is meant; "eins" when the quantity 1 is answered alone.
  • Where a number precedes a noun, the number 1 uses its own line with the correct article ("einen Punkt"), never num.1 (Section 10.8.3).
  • Question intonation (num.n.q) is used only at the end of a question ("Wo ist die Sieben?").
  • Every "Composition" column in Sections 11.2–11.5 describes one voice template (Section 20.6.2). The game-specific templates are declared in the game file's utterances array (Sections 8.8.1 and 20.6.2); the JSON examples below list them. Shared carriers (prompt.common.*), solution fragments (fb.solution.*) and shared templates (common.*) are declared once in prompts.json (Sections 8.7 and 21.3–21.4). The game sections own the IDs and German texts of prompt.<gameId>.* and hint.<gameId>.* lines; Section 21 mirrors them.
  • Spoken decompositions follow Section 4: 6–9 "fünf und (n−5)", 10 "fünf und fünf", 11–19 "zehn und (n−10)", 20 "zehn und zehn" (Section 10.8.3). Written split labels match: "5 + 2", "10 + 7".

11.2 Entdecken #

11.2.1 Purpose and Skills #

Entdecken (explore) presents the Zwanzigerfeld (the 2×10 dot field in five-groups, Section 4) as the child's reference picture of numbers 1–20. Every number appears in its three linked representations: quantity (filled dots), numeral, and word (spoken and written), plus its structured decomposition (7 = 5 + 2; 17 = 10 + 7).

Mode Primary skill Secondary skill Mastery Stars
Free explore ("Entdecken") — — none none
"Zähl mit" (count together) name (weight 1.0) recognize (weight 0.5) yes yes (Section 14)

GameID entdecken, tier free.

11.2.2 Level Availability #

Level Free explore Zähl mit steps
littleOnes Yes step1, step2, step3 (step3 only the "backward" kind)
vorschule Yes step1, step2, step3 (both step3 kinds)

11.2.3 Modes and Entry #

  • From the game picker (S-06), the Entdecken tile opens a mode chooser inside the game container: two picture tiles, 160×160 pt (iPhone) / 220×220 pt (iPad), side by side (stacked vertically in iPhone portrait if the width is < 360 pt):
    • Left: "Entdecken" — a hand touching a dot field.
    • Right: "Zähl mit" — a dot field with a speech bubble containing the dots of 1, 2, 3.
  • On entry (voice on): prompt.entdecken.choose_mode "Was möchtest du machen?", then each tile gets a soft highlight while its line plays: prompt.entdecken.mode_explore "Hier kannst du die Punkte entdecken." and prompt.entdecken.mode_zaehl_mit "Hier zählen wir zusammen." Tapping a tile during the lines stops the audio and opens the mode (after the Section 10.9.6 0.6 s input lock from the first line's start).
  • The chooser has the home button (hold-to-exit, Section 10.9.7) and the speaker button (replays the chooser lines).
  • Entdecken is never part of an Abenteuer (abenteuerEligible: false, Sections 8.8.1 and 9.16.2); it is always opened from the game picker, with the chooser.
  • The chooser remembers nothing: it is shown every time.

11.2.4 Zwanzigerfeld Geometry (Both Modes) #

The field shows as many dots as the active range stage: r5 → 5 dots, r10 → 10 dots, r20 → 20 dots. Numbers beyond the stage are not shown (the parent can widen the range, Section 16).

Arrangement When Layout
Wide Task-area width ≥ 714 pt r5: 1 row of 5. r10: 1 row of 10, split 5 | 5. r20: 2 rows of 10, each split 5 | 5; row 1 = 1–10, row 2 = 11–20 (the standard Zwanzigerfeld, Section 4)
Compact Task-area width < 714 pt (all iPhones in both orientations; narrow iPad windows) r5: 1 row of 5. r10: 2 rows of 5 (row 1 = 1–5, row 2 = 6–10). r20: 4 rows of 5 (1–5, 6–10, 11–15, 16–20) with a 1.5× vertical gap between rows 2 and 3 (between the two tens)

In both arrangements dots 1–5 and 11–15 render as red solid beads, 6–10 and 16–20 as blue "Lochperlen" (with the white centre ring) when filled (Section 19 owns bead rendering). Empty dots: white fill with a 2 pt #8A8FA3 outline.

Decision: an interactive field uses the compact arrangement whenever the task-area width is < 714 pt, because ten 60 pt touch targets with 12 pt spacing need 714 pt of width (no child target is ever below 60 × 60 pt, Section 19.6). Each compact row is one five-group, so the five-structure and the red/blue coding are preserved; Section 4 (DR-20) allows this form for interactive fields. Display-only fields elsewhere (e.g. quantity cards) stay 2 × 10 and are scaled, never reflowed. Section 19.7.3 renders both arrangements with the same ZwanzigerfeldView (parameter arrangement: .wide / .compact).

Measure iPhone iPad
Dot visual diameter 48 pt 60 pt
Dot hit region 60×60 pt 72×72 pt
Spacing between dot hit regions 12 pt 12 pt
Five-group gap (horizontal, wide) / ten gap (vertical, compact) 18 pt (1.5×) 18 pt

11.2.5 Dot Interaction Semantics (Both Modes) #

Let c be the current count (number of filled dots, 0…max). Dots always fill in order 1…c (the field never has holes).

Tap on dot k Result (steps with "direct" fill mode and free explore)
k > c (unfilled) c := k (dots c+1…k fill)
k < c (filled, not the last) c := k (dots k+1…c empty)
k == c (the last filled dot) c := k − 1 (the last dot empties)

Decision: tapping the last filled dot empties it. Tapping any other filled dot reduces the count to that dot. This makes the field symmetric (a child can always go one back) and allows counting backwards by repeatedly tapping the last dot.

Stepwise fill mode (Zähl mit step1 only): a tap on any unfilled dot adds exactly one dot (c := c + 1); taps on filled dots behave as in the table. This enforces one-to-one counting.

Animation: dots change one by one, 40 ms apart, forward when filling and backward when emptying (max 20 dots = 0.8 s). Reduce Motion: all changed dots fade within 0.15 s. A new tap during an animation completes the previous animation instantly, then starts the new one. Each dot that fills plays sfx.dot_on, each dot that empties sfx.dot_off (very soft; at most one every 40 ms). No haptic (Section 10.15).

Speech after a change: num.c when c ≥ 1 (in stepwise and backward modes see 11.2.9); when c becomes 0, no word is spoken (only the sfx.dot_off of the emptied dot). A new tap stops the previous word (fade 50 ms).

11.2.6 Free-Explore Mode #

Screen layout:

Device / orientation Layout
iPhone SE portrait (375×667) Top bar (home left, speaker right, no progress dots) → Zwanzigerfeld (compact, centred) → info panel below the field: numeral (96 pt) and written word (28 pt) on the left, split view on the right; clear button bottom-left of the panel
iPhone SE landscape (667×375) .sideBySide (Section 10.14.2): field (compact) on the leading side; info panel stacked on the trailing side: numeral (80 pt), word (24 pt), split view, clear button
iPad portrait Field (wide if the task area is ≥ 714 pt wide, otherwise compact) in the upper centre; info panel below: numeral (140 pt), word (40 pt), split view; clear button bottom-left
iPad landscape .sideBySide: field (wide if the task area is ≥ 714 pt wide, otherwise compact) on the leading side; info panel on the trailing side with the same sizes as iPad portrait

Info panel content for count c:

c Numeral Written word Split view
0 Empty dashed rounded box none none
1–5 c lower-case number word from numbers.json (e.g. "drei") a single mini bead bar of c red beads with "c" underneath (no plus)
6–9 c e.g. "sieben" mini bars "5 + 2": 5 red beads, a plus, (c−5) blue Lochperlen, numerals under each bar
10 10 "zehn" "5 + 5"
11–19 c e.g. "siebzehn" "10 + (c−10)", e.g. "10 + 7": a ten bar (5 red + 5 blue Lochperlen) plus a bar of (c−10) beads, coloured five-first (up to 5 red, then blue Lochperlen), numerals under each bar
20 20 "zwanzig" "10 + 10"

The decomposition comes from numbers.json (Section 8). The written word is shown for the three-representations principle (Section 4); the child is not expected to read it.

Interactions:

Element Tap result
Dot Per 11.2.5, then speaks num.c
Numeral or written word in the info panel Speaks num.c again (c ≥ 1)
Split view Speaks the decomposition (template token {split c}): e.g. c = 7 → "fünf und zwei" (num.5, prompt.common.und, num.2); c = 17 → "zehn und sieben"; c ≤ 5 → num.c
Clear button (sweeping-hand pictogram, 60×60 pt) c := 0 (backward animation with sfx.dot_off)
Speaker button c ≥ 1: speaks num.c then the decomposition (c ≥ 6); c = 0: replays prompt.entdecken.explore_intro "Tipp auf einen Punkt!"

Rules:

  • On entry: field empty (c = 0), prompt.entdecken.explore_intro plays; with voice off, the demo hand taps the first dot as a ghost gesture.
  • No rounds, no tasks, no progress dots, no hint ladder, no GameEvent except debug logging. No mastery, no stars, no Zahlenfreund progress.
  • Inactivity: demo hand once at 6 s only if the child has not tapped any dot since entry; no re-prompts; pause overlay after 90 s of inactivity; after a further 300 s the app returns to the child home (same timings as Section 10.11).
  • Session time and the daily time limit apply normally (Section 15); when the limit is reached, free explore ends immediately at the next dot animation end (there is no "task" to finish).
  • Leaving: hold-to-exit home button → back to S-06 (or S-05 if entered from there).

11.2.7 Zähl mit: Screen Layout #

Device / orientation Layout
iPhone SE portrait .stacked: top bar (home, progress dots, speaker) → prompt area at the top of the task area → Zwanzigerfeld (compact) in the task area → answer area: clear button (60×60 pt) at the leading edge, check button ("Fertig": a round plate with a check mark, 72×72 pt) centred
iPhone SE landscape .sideBySide: field (compact) leading; trailing panel: prompt area on top, check button and clear button below
iPad .stacked in portrait, .sideBySide in landscape (Section 10.14.2); field wide if the task area is ≥ 714 pt wide; check button 96×96 pt

Prompt area content:

Voice Prompt area
On A speech-bubble pictogram (no numeral: the child must link the spoken word to the quantity). After a correct answer the numeral of the target fades in here (three representations).
Off The target numeral (80 pt iPhone / 110 pt iPad) — the task becomes numeral-to-quantity; credit per the voice-dependent rule (Section 10.4.3): recognize 0.5 only

The check button is disabled (50 % opacity) while c = 0 — Decision: a tap on it then counts as .notAnAttempt and replays the prompt (Section 10.6.2).

11.2.8 Zähl mit: Round Structure #

  • Tasks per round: vorschule 5, littleOnes 4 (Section 9). All tasks of a round use the round's step; "stretch" tasks may use step + 1 (Section 9).
  • Each task starts with the field set to its start state (empty, or pre-filled for "backward").
  • The answer is committed with the check button: Answer = count c. Correct iff c == target.
  • Round intro (Section 10.5.3 T04): prompt.entdecken.intro "Wir zählen zusammen!"; in the first task of the round at step1 the prompt additionally ends with prompt.entdecken.press_check "Wenn du fertig bist, tipp auf den Haken."

11.2.9 Zähl mit: Task Generation per Step #

Parameter step1 "Leicht" step2 "Mittel" step3 "Schwer"
Task kind(s) countAlong showMe littleOnes: backward (100 %); vorschule: backward (50 %) / withFive (50 %), alternating within a round, first kind chosen by the seed
Target range littleOnes 2–10 1–10 backward: 1–9
Target range vorschule 3–10 1–20 backward: 1–19; withFive: 6–20
Start state empty empty backward: c = target + d, d chosen (seeded) from {2, 3, 4} (littleOnes {1, 2}); offsets whose start would exceed the stage maximum are discarded; if no offset remains (target = stage maximum), the task becomes showMe for the same target. withFive: empty
Fill mode stepwise (one dot per tap) direct direct
Target marker A small flag pictogram above the target dot for the whole task none none
Voice per change Each added dot speaks its number (the app counts along: "eins, zwei, drei …") Speaks num.c after each change backward: each change speaks num.c (counting backwards); withFive: num.c after each change
Five-group emphasis in the task none none withFive: the five-group outlines (thin 2 pt #8A8FA3 rounded rectangles around each five) are visible from the start

Target ranges are intersected with the active range stage (11.1.2): e.g. littleOnes step1 at r5 = 2–5.

Target number repetition: the engine avoids the same number twice in a row (Section 9); the game adds no further rule.

11.2.10 Zähl mit: Prompts #

Kind Composition German (n = 7)
Round intro prompt.entdecken.intro "Wir zählen zusammen!"
countAlong prompt.entdecken.count_to + num.n + prompt.entdecken.tap_each "Wir zählen bis sieben. Tipp jeden Punkt an!"
showMe (n ≥ 2) prompt.entdecken.show_me + num.n + prompt.entdecken.dots "Zeig mir sieben Punkte."
showMe (n = 1) prompt.entdecken.show_me_one "Zeig mir einen Punkt."
backward prompt.entdecken.backward_to + num.n + prompt.entdecken.tap_last "Wir zählen rückwärts bis sieben. Tipp immer auf den letzten Punkt."
withFive prompt.entdecken.show_me + num.n + prompt.entdecken.dots + prompt.entdecken.five_tip "Zeig mir sieben Punkte. Mit der Fünf geht es ganz leicht!"
First step1 task only (appended) prompt.entdecken.press_check "Wenn du fertig bist, tipp auf den Haken."

Voice-off visual prompt: the target numeral in the prompt area; for backward additionally a small left-pointing arrow pictogram next to it.

11.2.11 Zähl mit: Hints and Solution #

Level Audio Visual
1 Section 10.7.2 sequence; hint line: hint.entdecken.you_have + num.c + hint.entdecken.we_need + num.n — "Du hast sechs. Wir brauchen sieben." The target marker flag (step1) or the check button (other steps) pulses once; the field keeps the child's dots so the child can correct them
2 hint.entdecken.up_to_here "Bis hierhin!" + (n ≥ 6: fb.solution.schau + decomposition, e.g. "Schau: fünf und zwei."; n = 17: "Schau: zehn und sieben.") A soft ring appears around the target dot and stays; dots 1…n show a faint ghost fill (25 % opacity of their bead colour); five-group outlines appear (if not already visible)
Solution prompt.common.das_sind + num.n (n = 1: prompt.common.das_ist_eins) + (n ≥ 6: fb.solution.schau + decomposition), i.e. shared template common.quantity_split (Section 10.8.3) — "Das sind sieben. Schau: fünf und zwei." The app animates the field to c = n (fill or empty, 60 ms per dot, with num.k per dot suppressed; only the solution line plays); dots become non-interactive; the check button gets the green solution ring; the child taps it to continue (Section 10.7.4). solutionAnswer = n

The hint's num.c uses the child's committed count (c ≥ 1 is guaranteed because c = 0 is not an attempt).

11.2.12 Zähl mit: Correct Feedback #

Section 10.8.1 sequence with num.n and a fb.correct line. Visual: the filled dots do a gentle wave (each dot scales 1.0→1.1→1.0, 30 ms stagger; Reduce Motion: a soft glow over the filled dots) and the target numeral fades into the prompt area (80 pt), beside it the split view mini-bars for n ≥ 6.

11.2.13 Parameters JSON Example (games/entdecken.json) #

Envelope fields follow Section 8.8 (envelope 8.8.1, step object 8.8.2). The step parameters block is the common block; parametersByLevel overlays it per level (Sections 8.8.3 and 10.3.3). Game-level configuration that is not tied to a step (free explore) lives in gameParameters. The utterances array declares the game's voice templates (Section 20.6.2).

{
  "schemaVersion": 1,
  "gameId": "entdecken",
  "tier": "free",
  "primarySkill": "name",
  "secondarySkill": "recognize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": { "littleOnes": 90, "vorschule": 100 },
  "abenteuerEligible": false,
  "promptIds": [
    "prompt.entdecken.intro",
    "prompt.entdecken.choose_mode",
    "prompt.entdecken.mode_explore",
    "prompt.entdecken.mode_zaehl_mit",
    "prompt.entdecken.explore_intro",
    "prompt.entdecken.count_to",
    "prompt.entdecken.tap_each",
    "prompt.entdecken.show_me",
    "prompt.entdecken.show_me_one",
    "prompt.entdecken.dots",
    "prompt.entdecken.backward_to",
    "prompt.entdecken.tap_last",
    "prompt.entdecken.five_tip",
    "prompt.entdecken.press_check"
  ],
  "hintIds": {
    "level1": ["hint.entdecken.you_have", "hint.entdecken.we_need"],
    "level2": ["hint.entdecken.up_to_here"]
  },
  "gameParameters": {
    "freeExplore": {
      "enabled": true,
      "fillStaggerMs": 40,
      "showWrittenWord": true,
      "showSplitView": true
    }
  },
  "utterances": [
    { "id": "entdecken.choose_mode", "segments": ["prompt.entdecken.choose_mode"], "variants": [] },
    { "id": "entdecken.mode_explore", "segments": ["prompt.entdecken.mode_explore"], "variants": [] },
    { "id": "entdecken.mode_zaehl_mit", "segments": ["prompt.entdecken.mode_zaehl_mit"], "variants": [] },
    { "id": "entdecken.explore_intro", "segments": ["prompt.entdecken.explore_intro"], "variants": [] },
    { "id": "entdecken.explore_number", "segments": ["{c}"], "variants": [] },
    { "id": "entdecken.explore_split", "segments": ["{split c}"], "variants": [] },
    { "id": "entdecken.explore_speaker", "segments": ["{c}", "_", "{split c}"], "variants": [] },
    { "id": "entdecken.intro", "segments": ["prompt.entdecken.intro"], "variants": [] },
    { "id": "entdecken.task_count_along", "segments": ["prompt.entdecken.count_to", "{n}", "_", "prompt.entdecken.tap_each"], "variants": [] },
    { "id": "entdecken.task_show_me", "segments": ["prompt.entdecken.show_me", "{n}", "prompt.entdecken.dots"],
      "variants": [ { "when": { "n": 1 }, "segments": ["prompt.entdecken.show_me_one"] } ] },
    { "id": "entdecken.task_backward", "segments": ["prompt.entdecken.backward_to", "{n}", "_", "prompt.entdecken.tap_last"], "variants": [] },
    { "id": "entdecken.task_with_five", "segments": ["prompt.entdecken.show_me", "{n}", "prompt.entdecken.dots", "_", "prompt.entdecken.five_tip"], "variants": [] },
    { "id": "entdecken.press_check", "segments": ["prompt.entdecken.press_check"], "variants": [] },
    { "id": "entdecken.count_step", "segments": ["{k}"], "variants": [] },
    { "id": "entdecken.hint_l1", "segments": ["hint.entdecken.you_have", "{c}", "_", "hint.entdecken.we_need", "{n}"], "variants": [] },
    { "id": "entdecken.hint_l2", "segments": ["hint.entdecken.up_to_here"], "variants": [] },
    { "id": "entdecken.hint_l2_split", "segments": ["hint.entdecken.up_to_here", "_", "fb.solution.schau", "{split n}"], "variants": [] }
  ],
  "steps": [
    {
      "id": "step1",
      "labelKey": "game.step.leicht",
      "numberMin": 2,
      "numberMax": 10,
      "numberRangeByLevel": { "littleOnes": { "min": 2, "max": 10 }, "vorschule": { "min": 3, "max": 10 } },
      "parameters": {
        "kinds": { "countAlong": 1.0 },
        "fillMode": "stepwise",
        "showTargetMarker": true,
        "speakEachDot": true,
        "showFiveOutlines": false
      },
      "parametersByLevel": {}
    },
    {
      "id": "step2",
      "labelKey": "game.step.mittel",
      "numberMin": 1,
      "numberMax": 20,
      "numberRangeByLevel": { "littleOnes": { "min": 1, "max": 10 } },
      "parameters": {
        "kinds": { "showMe": 1.0 },
        "fillMode": "direct",
        "showTargetMarker": false,
        "speakEachDot": false,
        "showFiveOutlines": false
      },
      "parametersByLevel": {}
    },
    {
      "id": "step3",
      "labelKey": "game.step.schwer",
      "numberMin": 1,
      "numberMax": 19,
      "numberRangeByLevel": { "littleOnes": { "min": 1, "max": 9 } },
      "parameters": {
        "kinds": { "backward": 0.5, "withFive": 0.5 },
        "fillMode": "direct",
        "showTargetMarker": false,
        "speakEachDot": false,
        "backwardStartOffsets": [2, 3, 4],
        "withFiveMinTarget": 6
      },
      "parametersByLevel": {
        "littleOnes": { "kinds": { "backward": 1.0 }, "backwardStartOffsets": [1, 2] }
      }
    }
  ]
}

The Zähl mit solution uses the shared template common.quantity_split and needs no game template (Section 10.8.3).

Parameter validation (EntdeckenStepParameters.validate(), run through the game's GameContentSchema, Section 8.8.3): kinds is a weights object (keys ⊆ {countAlong, showMe, backward, withFive}, weights > 0 summing to 1.0 ± 0.001); fillMode ∈ {stepwise, direct}; backwardStartOffsets non-empty, each 1–5; withFiveMinTarget 6–20. gameParameters.freeExplore: fillStaggerMs 20–100, the three flags Bool. The step number ranges are envelope fields validated by Section 8 (1 ≤ min ≤ max ≤ 20). For withFive, targets below withFiveMinTarget switch the task to backward.

11.2.14 Mastery Credit #

Mode / condition Credit
Free explore none
Zähl mit, voice on name 1.0 + recognize 0.5 for the target number
Zähl mit, voice off recognize 0.5 only (Section 10.4.3)

Stars: +1 per completed Zähl mit task, +2 per completed round (Section 14). Free explore: none.

11.2.15 Edge Cases #

Case Behaviour
Child taps the check button with c = 0 .notAnAttempt; check button wiggles (Reduce Motion: a short outline flash, one cycle), prompt replays
Step1 child overshoots (fills beyond the flag) Allowed; the child can tap back; evaluated only at the check button
backward start state would exceed the stage maximum Offset reduced to fit; if no offset ≥ 1 fits (target == stage max), the task becomes showMe for the same target
withFive planned with a target < 6 Task becomes backward (vorschule) for the same target
Child taps dots while the solution animation runs Ignored (dots disabled from the third wrong attempt)
Range stage changes between rounds (engine widened it) The field size changes at the next round start, never mid-round
Rotation mid-task Field switches arrangement if the 714 pt width threshold is crossed; the count c is preserved
Very fast tapping in free explore Every tap is applied in order (per-dot debounce 0.25 s for the same dot, Section 10.9.4); speech always reflects the last state
Voice turned off by a parent mid-session Takes effect at the next task (Section 10.3.4); prompt area switches to the numeral
Free explore left open with no interaction Pause overlay after 90 s, return to child home after further 300 s

11.2.16 Acceptance Criteria #

  1. Given a vorschule profile at r20 in free explore, when the child taps dot 17, then dots 1–17 fill in order (1–5 red, 6–10 blue Lochperlen, 11–15 red, 16–17 blue Lochperlen), the numeral "17", the word "siebzehn" and the split "10 + 7" appear, and "siebzehn" is spoken; tapping the split view then speaks "zehn und sieben".
  2. Given free explore with c = 17, when the child taps dot 12, then dots 13–17 empty and "zwölf" is spoken; when the child then taps dot 12 again, dot 12 empties and "elf" is spoken.
  3. Given free explore on an iPhone SE in portrait, when the field is shown at r20, then it uses the compact 4×5 arrangement with every dot hit region at least 60×60 pt and a larger gap between rows 2 and 3.
  4. Given any free-explore session, when the child leaves after tapping 30 dots, then no mastery record, star ledger entry or task attempt is written.
  5. Given a Zähl mit step1 task with target 4, when the child taps the tenth dot, then only dot 1 fills and "eins" is spoken (stepwise fill).
  6. Given a Zähl mit step2 task with target 7 (voice on), when the child fills 6 dots and taps the check button, then the neutral try-again sound plays, followed by a fb.tryagain line and "Du hast sechs. Wir brauchen sieben.", no red colour appears and no star is removed.
  7. Given a Zähl mit task with three wrong submissions, when the solution plays, then the field animates to the target count, "Das sind sieben. Schau: fünf und zwei." plays, the check button has the green ring, and tapping it completes the task with outcome shown and +1 star.
  8. Given a vorschule Zähl mit step3 backward task with target 5 and start count 8, when the child taps the last dot three times and then the check button, then "sieben", "sechs", "fünf" are spoken in order and the task completes as firstTry.
  9. Given voice is off, when a Zähl mit task is presented, then the prompt area shows the target numeral, the demo hand plays at the first task, and the completed task credits only recognize with weight 0.5.
  10. Given the content file games/entdecken.json, when content validation runs, then abenteuerEligible is false, the file passes all Section 8 checks, and Entdecken is visible in the game picker but never part of an Abenteuer composition.

11.3 Wie viele? #

11.3.1 Purpose and Skills #

The child counts a set of objects and chooses the matching numeral. The sets progress from structured (rows of five, Kraft der Fünf) to scattered (static) to moving (Section 4: structured before scattered). The child can tap objects to mark them as counted, which supports one-to-one correspondence.

GameID wie_viele, tier free. Primary skill count (1.0), secondary recognize (0.5).

11.3.2 Level Availability #

Both levels, all three steps. littleOnes uses fewer options and slower movement (11.3.5).

11.3.3 Screen Layout #

Template per layout class (Section 10.14.2); area shares and top-bar heights per Section 19.5.2.

Device / orientation Template Prompt area Task area (objects) Answer area
iPhone SE portrait (375×667) .stacked At the top of the task area: the object pictogram with a question-mark bubble Remaining task area 2–4 numeral tiles (size tier S, Section 19.7.6) in one row
iPhone SE landscape (667×375) .sideBySide Top of the trailing panel: the pictogram with the question mark Leading side Trailing panel below the prompt: tiles in 2 columns (Section 19.5.2)
iPad portrait .stacked At the top of the task area Remaining task area Tiles size tier L (104 pt) in one row
iPad landscape .sideBySide Top of the trailing panel Leading side Trailing panel: tiles size tier L

Object sizes:

Layout Visual size iPhone Visual size iPad Hit region
Structured 56 pt, shrinks to fit down to 44 pt 72 pt ≥ 60×60 pt; overlapping hit regions resolve to the nearest centre (Section 10.9.3)
Scattered 52 pt (min 44 pt) 68 pt same
Moving 52 pt 68 pt circle of radius 36 pt (72 pt diameter) around the object's centre

11.3.4 Round Structure #

  • Tasks per round: vorschule 5, littleOnes 4.
  • Each task: one object type, n objects, the question "Wie viele …?", 2–4 numeral options.
  • Object type: no type repeats within a round; moving tasks use only types marked "can move" (11.1.4); selection is seeded.
  • Marks: tapping an object toggles a mark (a warm-yellow #F5B82E ring with a small dot at the object's top-right; sfx.mark; no haptic, Section 10.15). Marks are never attempts (Section 10.6.2), persist across wrong attempts within the task, and are cleared at the next task. Marks never change the evaluation.
  • Answer: tap a numeral tile. Answer = tapped value; correct iff value == n.

11.3.5 Task Generation per Step #

Parameter step1 "Leicht" step2 "Mittel" step3 "Schwer"
Layout structured scattered moving
Target range littleOnes 1–20 1–10 2–6
Target range vorschule 1–20 1–15 3–10
Options littleOnes 2 3 3
Options vorschule 3 4 4
Distractor slots (in order; each slot lists strategies tried in order, Section 10.17.1) littleOnes: [near1]; vorschule: [near1], [near1, near2] littleOnes: [near1], [near1, near2]; vorschule: [near1], [near1], [near2, fivePartner] littleOnes: [near1], [near1, near2]; vorschule: [near1], [near1], [near2, far]
Speed (moving) — — littleOnes 0.035 task-area widths/s; vorschule 0.06 task-area widths/s
Five-group outline in the task spatial gaps only none none

Ranges intersect with the active range stage (e.g. littleOnes step1 at r5 = 1–5). Rationale for near distractors: counting errors are almost always ±1 (a skipped or double-counted object).

11.3.6 Object Layouts #

Structured (step1):

  • Compact (task-area width < 10 objects at the current size plus spacing): rows of five, left-aligned, filled row by row (e.g. 17 = 5, 5, 5, 2). Horizontal spacing 8 pt; vertical row spacing 10 pt; an extra 1.5× vertical gap after every second row (between the tens).
  • Wide (enough width for 10 objects with spacing and a 1.5× five-gap): rows of ten split 5 | 5, row 1 = objects 1–10, row 2 = 11–20 (the Zwanzigerfeld pattern).
  • The whole block is centred in the task area. All objects face the same way.

Scattered (step2):

  • ScatterLayout (Section 10.17.2) with minimum centre distance 1.3 × visual size, edge margin 12 pt, avoidRows = true (no four objects roughly in a line, so the layout does not accidentally look structured), all objects the same size, each object randomly mirrored horizontally (seeded).
  • If the task area is too small for n objects at 52 pt, the visual size shrinks in 4 pt steps down to 44 pt; if it still does not fit, the minimum distance factor drops to 1.15; this is only reachable on the smallest landscape heights with n = 15.

Moving (step3), SpriteKit via SpriteView:

Property Value
Scene Size = task area; scaleMode = .resizeFill; transparent background over the game background
Physics world Gravity (0, 0)
Walls Edge loop inset 8 pt from the task-area bounds
Object body Circle, radius 0.45 × visual size; restitution 1.0, friction 0, linearDamping 0, angularDamping 1, allowsRotation false; collides with walls and other objects
Overlap guarantee Bodies never overlap, so visual circles overlap at most ≈ 4 % of an object's area (well under the 20 % limit). Verification: a unit test simulates 60 s at 60 fps with n = 10 and asserts that the maximum pairwise visual overlap stays ≤ 20 %.
Initial positions ScatterLayout positions (min distance 1.3 ×), converted to scene coordinates
Speed Constant magnitude per step parameter (on iPhone SE portrait: littleOnes ≈ 12 pt/s, vorschule ≈ 20 pt/s); re-normalised every frame in update(_:) after collisions
Direction changes Every 3–6 s (seeded per object) the target heading changes by a random angle in ±90°; the heading turns toward it at most 60°/s (smooth, no jerks)
Facing Sprites mirror horizontally to face their horizontal direction; the mirror flip is a 0.2 s cross-fade, not an instant flip
Marks Mark ring is a child node of the object and moves with it
Tap hit-test Nearest object centre within 36 pt of the touch-down point
Pause scene.isPaused = true on pause (Section 10.12) and during level-2 scaffolding
Frame rate preferredFramesPerSecond = 60; if the device is in Low Power Mode, 30 (movement stays speed-correct because it uses delta time)

Reduce Motion (Section 10.16): step3 tasks are presented as static scattered layouts using step3's target range and options, with minimum centre distance 1.15 × (denser than step2). Decision: they are still recorded as step3, so difficulty stepping (Section 9) keeps working for children with Reduce Motion enabled.

11.3.7 Prompts #

Moment Composition German
Round intro prompt.wie_viele.intro "Wie viele sind es? Zähl ganz genau!"
Task prompt.wie_viele.how_many.<typeID> (one recorded line per type of 11.1.4) e.g. "Wie viele Äpfel sind das?", "Wie viele Enten schwimmen da?" (moving types use a movement verb where natural: Enten schwimmen, Fische schwimmen, Schmetterlinge fliegen, Vögel fliegen, Autos fahren, Bälle rollen, Marienkäfer krabbeln, Schnecken kriechen; all other types use "Wie viele … sind das?", e.g. "Wie viele Karotten sind das?")
First task of the first step2 or step3 round of the profile (appended once per profile) prompt.wie_viele.mark_tip "Tipp jedes an, das du gezählt hast."

Voice-off visual prompt: the object pictogram with a large question mark in the prompt area; the demo hand shows a ghost mark on one object and then a tap gesture over the answer area (Section 10.10.2).

11.3.8 Hints and Solution #

Level Audio Visual
1 Section 10.7.2 sequence; hint.wie_viele.count_again "Zähl nochmal ganz genau. Tipp jedes an, das du gezählt hast." Wrong tile fades; all objects gently lift once (scale 1.0→1.05→1.0 over 0.6 s); if the child has no marks yet, the demo hand shows one ghost mark
2 hint.wie_viele.count_with_me "Wir zählen zusammen." then the count-along: num.1 … num.n, one per object (template wie_viele.hint_l2) Moving objects decelerate to a stop within 0.3 s and stay stopped for the rest of the task. The child's marks fade out; the app places a small bead badge on each object in counting order (0.5 s per object for littleOnes and step1; 0.4 s otherwise): objects 1–5 get a red solid bead badge, 6–10 a blue Lochperle badge, 11–15 red, 16–20 blue (five-group coding). A running numeral (44 pt iPhone / 64 pt iPad) in the prompt area shows the current count and fades out 0.5 s after the last object. After every fifth object a 0.3 s pause and a soft outline around that group (structured: the row; scattered: a soft hull around the five objects). Counting order: structured = row by row, left to right; scattered/moving = nearest-neighbour path starting at the top-left-most object. Badges stay for the rest of the task.
Solution Shared template common.quantity_split (Section 10.8.3): n = 1: prompt.common.das_ist_eins ("Das ist eins."); n = 2–5: prompt.common.das_sind + num.n; n ≥ 6: + fb.solution.schau + decomposition — "Das sind siebzehn. Schau: zehn und sieben." Moving objects stop; all objects get their five-group badges instantly; the correct tile gets the green ring, others fade; the child taps it (Section 10.7.4)

11.3.9 Correct Feedback #

Section 10.8.1 sequence (num.n + fb.correct). Visual: the objects do a gentle ripple (30 ms stagger, scale 1.0→1.08→1.0; Reduce Motion: soft glow) and moving objects slow down to a stop during the feedback.

11.3.10 Parameters JSON Example (games/wie_viele.json) #

Envelope per Section 8.8; object types are game-level configuration in gameParameters. The task prompt is one recorded line per object type (prompt.wie_viele.how_many.<typeID>), played as a single clip; the count-along hint is the game template wie_viele.hint_l2; the solution uses the shared template common.quantity_split.

{
  "schemaVersion": 1,
  "gameId": "wie_viele",
  "tier": "free",
  "primarySkill": "count",
  "secondarySkill": "recognize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": { "littleOnes": 80, "vorschule": 100 },
  "promptIds": [
    "prompt.wie_viele.intro",
    "prompt.wie_viele.mark_tip",
    "prompt.wie_viele.how_many.apfel",
    "prompt.wie_viele.how_many.birne",
    "prompt.wie_viele.how_many.erdbeere",
    "prompt.wie_viele.how_many.karotte",
    "prompt.wie_viele.how_many.banane",
    "prompt.wie_viele.how_many.stern",
    "prompt.wie_viele.how_many.herz",
    "prompt.wie_viele.how_many.blume",
    "prompt.wie_viele.how_many.muschel",
    "prompt.wie_viele.how_many.knopf",
    "prompt.wie_viele.how_many.ball",
    "prompt.wie_viele.how_many.ente",
    "prompt.wie_viele.how_many.fisch",
    "prompt.wie_viele.how_many.schmetterling",
    "prompt.wie_viele.how_many.marienkaefer",
    "prompt.wie_viele.how_many.vogel",
    "prompt.wie_viele.how_many.auto",
    "prompt.wie_viele.how_many.schnecke"
  ],
  "hintIds": {
    "level1": ["hint.wie_viele.count_again"],
    "level2": ["hint.wie_viele.count_with_me"]
  },
  "gameParameters": {
    "objectTypes": [
      "apfel", "birne", "erdbeere", "karotte", "banane", "stern", "herz", "blume", "muschel",
      "knopf", "ball", "ente", "fisch", "schmetterling", "marienkaefer", "vogel", "auto", "schnecke"
    ],
    "movingObjectTypes": [
      "ball", "ente", "fisch", "schmetterling", "marienkaefer", "vogel", "auto", "schnecke"
    ]
  },
  "utterances": [
    { "id": "wie_viele.intro", "segments": ["prompt.wie_viele.intro"], "variants": [] },
    { "id": "wie_viele.mark_tip", "segments": ["prompt.wie_viele.mark_tip"], "variants": [] },
    { "id": "wie_viele.hint_l1", "segments": ["hint.wie_viele.count_again"], "variants": [] },
    { "id": "wie_viele.hint_l2", "segments": ["hint.wie_viele.count_with_me", "_", "{count s t}"], "variants": [] }
  ],
  "steps": [
    {
      "id": "step1",
      "labelKey": "game.step.leicht",
      "numberMin": 1,
      "numberMax": 20,
      "parameters": { "layout": "structured", "objectSizePt": 56, "minObjectSizePt": 44,
                      "optionCount": 3, "distractorSlots": [["near1"], ["near1", "near2"]] },
      "parametersByLevel": {
        "littleOnes": { "optionCount": 2, "distractorSlots": [["near1"]] }
      }
    },
    {
      "id": "step2",
      "labelKey": "game.step.mittel",
      "numberMin": 1,
      "numberMax": 15,
      "numberRangeByLevel": { "littleOnes": { "min": 1, "max": 10 } },
      "parameters": { "layout": "scattered", "objectSizePt": 52, "minObjectSizePt": 44,
                      "minCenterDistanceFactor": 1.3, "avoidRows": true,
                      "optionCount": 4, "distractorSlots": [["near1"], ["near1"], ["near2", "fivePartner"]] },
      "parametersByLevel": {
        "littleOnes": { "optionCount": 3, "distractorSlots": [["near1"], ["near1", "near2"]] }
      }
    },
    {
      "id": "step3",
      "labelKey": "game.step.schwer",
      "numberMin": 2,
      "numberMax": 10,
      "numberRangeByLevel": { "littleOnes": { "min": 2, "max": 6 }, "vorschule": { "min": 3, "max": 10 } },
      "parameters": { "layout": "moving", "objectSizePt": 52, "bodyRadiusFactor": 0.45,
                      "turnIntervalSeconds": [3, 6], "maxTurnDegreesPerSecond": 60,
                      "reduceMotionMinCenterDistanceFactor": 1.15,
                      "optionCount": 4, "speedWidthsPerSecond": 0.06,
                      "distractorSlots": [["near1"], ["near1"], ["near2", "far"]] },
      "parametersByLevel": {
        "littleOnes": { "optionCount": 3, "speedWidthsPerSecond": 0.035,
                        "distractorSlots": [["near1"], ["near1", "near2"]] }
      }
    }
  ]
}

Validation (WieVieleStepParameters.validate() through the game's GameContentSchema, Section 8.8.3): layout ∈ {structured, scattered, moving}; optionCount 2–4 and equal to distractorSlots.count + 1; every strategy name is a DistractorStrategy raw value (Section 10.17.1); objectSizePt 44–96 and ≥ minObjectSizePt ≥ 44; minCenterDistanceFactor 1.1–2.0; speedWidthsPerSecond 0.01–0.12 (required for moving); bodyRadiusFactor 0.40–0.50; turnIntervalSeconds two values, 1–10, ascending; a moving step has numberMax ≤ 12. gameParameters (WieVieleGameParameters): every objectTypes entry is a typeID of the catalogue in 11.1.4 with its asset obj_<typeID> and its line prompt.wie_viele.how_many.<typeID> listed in promptIds; movingObjectTypes ⊆ objectTypes and contains only types marked "can move" in 11.1.4.

11.3.11 Mastery Credit #

count 1.0 + recognize 0.5 for n. Voice on or off makes no difference (the skill does not depend on voice).

11.3.12 Edge Cases #

Case Behaviour
n = 1 Valid; prompt stays plural ("Wie viele Äpfel sind das?") which is natural German for the question; the solution uses "Das ist eins."
Distractor would be 0 (n = 1, near1) 0 is outside 1…20, so n+1/n+2 are used
littleOnes at r5, n = 5, 3 options Distractors 4 and 3 (near1 gives 4 or 6; 6 is outside the stage, so 4; then near1 again yields nothing new, fallback near2 → 3)
Child marks all objects then taps the wrong tile Normal attempt 1; marks stay
Child taps an object during the level-2 count-along Skips the rest of the animation (Section 10.7.3); all badges appear; marks cannot be toggled while badges are shown (Decision: badges replace marks for the rest of the task)
Rotation during a moving task Scene resizes; object positions are rescaled proportionally; walls rebuilt; speeds recomputed from the new width
Scattered layout cannot be generated (pathological container) Jittered-grid fallback (Section 10.17.2); never fails
App paused during movement Scene paused; on resume, movement continues from the same positions
Structured layout with 20 objects in iPhone SE landscape Wide 2×10 pattern if it fits at ≥ 44 pt visual size, otherwise compact rows of five with objects at 44 pt
Same object type requested more often than available types (future content with > 16 tasks) Types may repeat only after all eligible types were used

11.3.13 Acceptance Criteria #

  1. Given a vorschule step1 task with n = 7 on iPhone SE portrait, when the task appears, then 7 objects of one type show in rows of five (one row of 5, one row of 2), and exactly 3 numeral tiles are shown: 6, 7 and 8 in a seeded random order.
  2. Given any step2 task, when the objects are laid out, then no two object centres are closer than 1.3 × the object size (or 1.15 × on the documented fallback) and every object lies fully inside the task area.
  3. Given a step3 task, when 60 s of movement are simulated, then no object leaves the task area and no pair of objects overlaps by more than 20 % of an object's area.
  4. Given a task, when the child taps an object twice with at least 0.35 s between taps, then its mark appears and disappears, no attempt is counted, and the outcome is unaffected.
  5. Given a moving task and one wrong tile tap followed by a second wrong tile tap, when hint level 2 starts, then all objects stop, bead badges appear one by one with the numbers spoken, and the fifth object is followed by a short pause and a group outline.
  6. Given Reduce Motion is on, when a step3 task is presented, then the objects are static, the task is recorded with step step3, and its target lies within the step3 range.
  7. Given a correct tap on "7", then "sieben" is spoken followed by a fb.correct line, the progress dot fills, and the result credits count 1.0 and recognize 0.5 for 7.
  8. Given a littleOnes profile at r5, when any Wie viele? task is generated, then neither the target nor any option exceeds 5.
  9. Given voice off, when the first task of a round appears, then the prompt area shows the object pictogram with a question mark and the demo hand plays without pointing at any specific tile.

11.4 Hör hin #

11.4.1 Purpose and Skills #

The child hears a number word and taps the matching numeral. This links the spoken word to the written numeral.

GameID hoer_hin, tier free. Primary skill name (1.0), secondary recognize (0.5). The voice-dependent credit rule applies (Section 10.4.3).

11.4.2 Level Availability #

Both levels, all three steps.

11.4.3 Screen Layout #

Template per layout class (Section 10.14.2). Decision: the tiles sit large in the task area and the answer area stays empty (allowed by Section 10.14.2).

Device / orientation Template Prompt area Tiles
iPhone SE portrait .stacked Top of the task area: an ear pictogram inside a speech bubble; it "listens" (soft wave animation) while the number is spoken Large tiles 88×88 pt (numeral 56 pt), centred: 2 or 3 in one row; 4 as a 2×2 grid (16 pt spacing)
iPhone SE landscape .sideBySide Trailing panel: the ear pictogram Leading task area: 2–4 tiles 88×88 pt in one row, or 2×2 for 4 tiles if the row does not fit
iPad portrait .stacked Top of the task area, ear pictogram 128×128 pt tiles (numeral 84 pt), one row
iPad landscape .sideBySide Trailing panel, ear pictogram Leading task area: 128×128 pt tiles, one row

11.4.4 Round Structure #

  • Tasks per round: vorschule 5, littleOnes 4.
  • Each task: the prompt speaks the number; 2–4 numeral tiles; Answer = tapped value; correct iff value == n.
  • Prompt variant per task (seeded, 50/50, never the same variant three times in a row): "Wo ist die …?" or "Zeig mir die …".

11.4.5 Task Generation per Step #

Parameter step1 "Leicht" step2 "Mittel" step3 "Schwer"
Options 2 3 4
Target range littleOnes 1–20 1–20 1–20
Target range vorschule 1–10 1–20 6–20
Distractor slots [far] [near1], [confusableAuditory, confusableVisual, near2] [tensPartner, confusableAuditory], [confusableVisual, near1], [near1, near2]
Extra rule The far distractor must not be in a visual or auditory confusable pair with the target — At r20, the options include at least one number from 11–20 (satisfied automatically for targets 11–20; for targets 6–10 the tensPartner slot supplies n+10)

littleOnes ranges are capped by the active range stage (normally r5 or r10; 1–20 only with a parent override, Section 9). Examples of generated option sets: vorschule step3, n = 17: {17, 7, 11, 16} (tens partner 7, visual confusable 11, near 16). n = 12: {12, 2, 15, 13} or {12, 20, 15, 11}. littleOnes step1, n = 2 at r5: the only far candidate (distance ≥ 3) is 5, which is excluded because 2 and 5 are visually confusable; Decision: when far yields no candidate, the most distant non-confusable number in range is used, here 4, giving {2, 4}.

11.4.6 Prompts #

Moment Composition German (n = 7)
Round intro prompt.hoer_hin.intro "Hör gut zu!"
Task variant A prompt.hoer_hin.where_is + num.n.q (template hoer_hin.task_where_is) "Wo ist die Sieben?"
Task variant B prompt.hoer_hin.show_me + num.n (template hoer_hin.task_show_me) "Zeig mir die Sieben."

Voice-off visual prompt (Decision): the prompt area shows the target as a quantity card: a display-only mini Zwanzigerfeld (one row for n ≤ 10, 2 × 10 scaled for n ≥ 11; five-group coloured beads) with n filled beads. The task becomes quantity-to-numeral, and the credit follows Section 10.4.3 (recognize 0.5 only). The ear pictogram is not shown when voice is off. Hör hin stays fully playable with voice off. While voice is off, the engine deprioritises Hör hin in the Abenteuer composition (Section 9.16.3); in the game picker it is shown and opened like every other game.

11.4.7 Hints and Solution #

Level Audio Visual
1 Section 10.7.2 sequence; hint.hoer_hin.listen_again + num.n (template hoer_hin.hint_l1) — "Hör nochmal: sieben." (spoken with a slightly slower carrier; num.n is the normal recording) Wrong tile fades; the ear pictogram pulses once
2 hint.hoer_hin.so_many + num.n (template hoer_hin.hint_l2) — "Schau, so viele: sieben." A quantity card (mini Zwanzigerfeld with n beads in five-groups) appears in the prompt area and stays; one further distractor fades (if at least two enabled distractors remain)
Solution Shared template common.numeral_split (Section 10.8.3): n ≤ 5: prompt.common.das_ist_die + num.n ("Das ist die Drei."); n ≥ 6: + fb.solution.schau + decomposition ("Das ist die Sieben. Schau: fünf und zwei."; n = 17: "Das ist die Siebzehn. Schau: zehn und sieben.") Quantity card shown; correct tile green ring; others faded; the child taps it

11.4.8 Correct Feedback #

Section 10.8.1 (num.n + fb.correct). Visual: the tapped tile glows green and the quantity card for n appears for 0.8 s in the prompt area, linking all three representations.

11.4.9 Parameters JSON Example (games/hoer_hin.json) #

This is the authoritative Hör hin file; Section 8.8.5 shows the same envelope with placeholder parameters.

{
  "schemaVersion": 1,
  "gameId": "hoer_hin",
  "tier": "free",
  "primarySkill": "name",
  "secondarySkill": "recognize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": { "littleOnes": 50, "vorschule": 60 },
  "promptIds": ["prompt.hoer_hin.intro", "prompt.hoer_hin.where_is", "prompt.hoer_hin.show_me"],
  "hintIds": {
    "level1": ["hint.hoer_hin.listen_again"],
    "level2": ["hint.hoer_hin.so_many"]
  },
  "utterances": [
    { "id": "hoer_hin.intro", "segments": ["prompt.hoer_hin.intro"], "variants": [] },
    { "id": "hoer_hin.task_where_is", "segments": ["prompt.hoer_hin.where_is", "{n.q}"], "variants": [] },
    { "id": "hoer_hin.task_show_me", "segments": ["prompt.hoer_hin.show_me", "{n}"], "variants": [] },
    { "id": "hoer_hin.hint_l1", "segments": ["hint.hoer_hin.listen_again", "{n}"], "variants": [] },
    { "id": "hoer_hin.hint_l2", "segments": ["hint.hoer_hin.so_many", "{n}"], "variants": [] }
  ],
  "steps": [
    {
      "id": "step1",
      "labelKey": "game.step.leicht",
      "numberMin": 1,
      "numberMax": 20,
      "numberRangeByLevel": { "vorschule": { "min": 1, "max": 10 } },
      "parameters": { "optionCount": 2, "distractorSlots": [["far"]], "farExcludesConfusables": true,
                      "promptVariants": ["where_is", "show_me"] },
      "parametersByLevel": {}
    },
    {
      "id": "step2",
      "labelKey": "game.step.mittel",
      "numberMin": 1,
      "numberMax": 20,
      "parameters": { "optionCount": 3,
                      "distractorSlots": [["near1"], ["confusableAuditory", "confusableVisual", "near2"]],
                      "promptVariants": ["where_is", "show_me"] },
      "parametersByLevel": {}
    },
    {
      "id": "step3",
      "labelKey": "game.step.schwer",
      "numberMin": 1,
      "numberMax": 20,
      "numberRangeByLevel": { "vorschule": { "min": 6, "max": 20 } },
      "parameters": { "optionCount": 4,
                      "distractorSlots": [["tensPartner", "confusableAuditory"], ["confusableVisual", "near1"], ["near1", "near2"]],
                      "requireTeenOptionAtR20": true,
                      "promptVariants": ["where_is", "show_me"] },
      "parametersByLevel": {}
    }
  ]
}

Validation (HoerHinStepParameters.validate() through the game's GameContentSchema, Section 8.8.3): optionCount 2–4 and equal to distractorSlots.count + 1; every strategy name is a DistractorStrategy raw value (Section 10.17.1); promptVariants non-empty, each value v ∈ {where_is, show_me} with the template hoer_hin.task_<v> declared in utterances and the line prompt.hoer_hin.<v> listed in promptIds.

11.4.10 Mastery Credit #

Condition Credit
Voice on name 1.0 + recognize 0.5 for n
Voice off recognize 0.5 only

11.4.11 Edge Cases #

Case Behaviour
Step1 with 2 options and one wrong tap The wrong tile fades; only the correct tile remains; attempt 2 is necessarily correct → afterHint (by design)
Step1 wrong twice Impossible (only one option remains after the first wrong tap)
Step2 with 3 options: two wrong taps Only the correct tile remains after hint level 2; the level-2 "fade one further distractor" is skipped because none remain
Child taps a tile while the number is still being spoken (after 0.6 s) Allowed; the prompt stops; evaluation proceeds (Section 10.9.6)
Missing num.n.q recording ZKAudio falls back per Section 20 (statement recording or TTS in Debug); number words are validated as present for Release builds (Section 20)
Same number as the previous task Avoided by the engine (Section 9); the game does not re-check
Voice turned on again mid-round Next task uses the voice prompt and full credit

11.4.12 Acceptance Criteria #

  1. Given a vorschule step1 task with n = 3, when the task appears, then exactly 2 tiles are shown, one "3" and one number between 6 and 10 that is not 8 (visual confusable of 3), and "Wo ist die Drei?" or "Zeig mir die Drei." is spoken.
  2. Given a vorschule step3 task at r20 with n = 16, when the options are generated, then 4 distinct tiles are shown including 16, and the set contains 6 (tens partner) or another auditory confusable of 16, plus 19 or 15 or 17.
  3. Given a step2 task, when the child taps a wrong tile, then that tile fades to 35 % and cannot be tapped again, the neutral sound and "Hör nochmal: …" play, and input is enabled 0.6 s after the hint line starts.
  4. Given a second wrong tap, then a quantity card with n beads in five-groups appears in the prompt area and "Schau, so viele: …" is spoken.
  5. Given a third wrong tap (step3), then "Das ist die Siebzehn. Schau: zehn und sieben." (n = 17) plays, only the correct tile is enabled, and tapping it completes the task with outcome shown.
  6. Given voice is off, when a task is presented, then the prompt area shows a quantity card for n instead of the ear, the task can be played to completion, and the completed task credits recognize 0.5 only.
  7. Given a round of 5 step2 or step3 tasks (3 or 4 tiles), then the correct tile is never in the same position as in the previous task; given a round of step1 tasks (2 tiles), then it is never in the same position in more than 2 consecutive tasks (Section 10.17.3).
  8. Given a littleOnes profile at r5, when any step3 task is generated, then all 4 tiles are numbers from 1–5.

11.5 Was fehlt? #

11.5.1 Purpose and Skills #

A bead chain (Perlenkette) with numbered beads has one or two gaps; the child finds the missing number. Later steps count backwards. This builds the ordinal number sequence (Zahlenreihe) and its structure in five-groups.

GameID was_fehlt, tier free. Primary skill order (1.0), secondary recognize (0.5).

11.5.2 Level Availability #

Both levels, all three steps. littleOnes never gets two-gap tasks.

11.5.3 Chain Rendering #

  • The chain is a gently curved string (ink #2B2A33 at 60 %, 3 pt) with beads (Section 19 bead spec): numbers 1–5 and 11–15 are red solid beads, 6–10 and 16–20 blue Lochperlen. Bead colour follows the number's value.
  • The numeral of each bead is written below the bead (not on it, so the Lochperle ring stays visible): 44 pt on iPhone, 64 pt on iPad, ink colour.
  • Groups of five are separated by a gap of 1.5× the normal bead spacing (Section 19).
  • Direction (Section 4, DR-22): the chain always runs left to right in increasing order and is never mirrored. Counting backwards is shown by a right-to-left arrow pictogram above the chain (ink, 60 % opacity, spanning the segment), not by reversing the beads.
  • A gap is an empty dashed circle (2 pt #8A8FA3) in the bead's place with an empty dashed rounded box below it where the numeral would be. The gap column (circle + box) is the drop target: hit region ≥ 60×60 pt, expanded by 24 pt for drops (Section 10.9.5).
Measure iPhone iPad
Bead diameter 36 pt 48 pt
Column pitch (bead centre to centre) 60 pt 80 pt
Five-group extra gap +12 pt (edge spacing 24 → 36 pt) +16 pt
Beads per row 10 when the task-area width is ≥ 820 pt (wide), otherwise 5 (compact) same rule

Decision: an interactive chain wraps only at five-group boundaries (Section 4, DR-21) and uses the compact arrangement whenever the task-area width is < 820 pt, the width a 10-bead row with 64 pt numerals needs on iPad (10 × 48 + 8 × 32 + 48 + 20); this also keeps every 44 pt numeral and 60 pt gap target intact on every iPhone in both orientations. iPads show one row of 10 when the task area is ≥ 820 pt wide, otherwise two rows of 5. When wrapped, row 2 continues left-to-right under row 1 with a vertical gap of 1.5× the row spacing between the two rows (the ten boundary is visible), and the string is drawn as a smooth curve from the end of row 1 to the start of row 2 (reading direction always left to right).

Segments are always aligned to five-groups (Decision): every segment is displayed ascending, starts at 1, 6, 11 or 16 and has length 5 or 10 (1–5, 1–10, 6–10, 6–15, 11–15, 11–20, 16–20). A backward segment is the same display counted from its highest number down (e.g. 10…1 is shown as 1–10 with the right-to-left arrow). Each wrapped row is therefore exactly one five-group.

11.5.4 Screen Layout #

Template per layout class (Section 10.14.2); area shares per Section 19.5.2.

Device / orientation Template Prompt area Task area Answer area (tray)
iPhone SE portrait .stacked Top of the task area: chain pictogram with a question mark (and the right-to-left arrow for backward tasks) Chain in 1 or 2 rows of 5, centred 2–4 tray beads in one row
iPhone SE landscape .sideBySide Top of the trailing panel Leading side: chain in 1 or 2 rows of 5 Trailing panel: tray beads in 2 columns (Section 19.5.2)
iPad .stacked (portrait) / .sideBySide (landscape) As above Chain in one row of 10 when the task area is ≥ 820 pt wide, else rows of 5 Tray beads in one row (portrait) or 2 columns (landscape)

Tray beads: large white round beads (72 pt iPhone / 96 pt iPad) with an ink numeral (44 pt / 64 pt) centred on them. Decision: tray beads are neutral white so their colour cannot reveal the answer; when a bead is placed correctly it animates (0.3 s) into the chain bead's size and takes its value colour (red solid or blue Lochperle) with the numeral moving below it.

11.5.5 Round Structure #

  • Tasks per round: vorschule 5, littleOnes 4.
  • Answer by dragging a tray bead into a gap, or by tapping a tray bead (it flies into the active gap: the first open gap in reading order, left to right) (Section 10.9.5).
  • Answer = (beadValue, gapIndex). Evaluation:
    • Value equals the target gap's value → .correct (single gap) or .partial (another gap still open).
    • Value equals the value of a different open gap (two-gap tasks) → tolerant: the bead is redirected to its correct gap (0.3 s) and treated as a correct placement (.partial or .correct).
    • Value fits no open gap → .incorrect.
    • Drop outside every gap → .notAnAttempt.
  • Wrong attempts are counted per task across both gaps; the outcome follows Section 10.6.4.
  • Credited number = the target gap (the engine's planned number). The second gap of a two-gap task is an additionalNumber (not credited).

11.5.6 Task Generation per Step #

Parameter step1 "Leicht" step2 "Mittel" step3 "Schwer"
Kinds oneGap (ascending) littleOnes: oneGap; vorschule: oneGapLong or twoGaps (11.5.7) backward (counted downwards, one gap; displayed ascending with the right-to-left arrow)
Target range littleOnes 2–10 2–10 1–9
Target range vorschule 2–10 2–20 1–19
Segment littleOnes: 1–5 at r5, 1–10 at r10/r20 (targets ≤ 10); vorschule: 1–10 see 11.5.7 littleOnes: 1–5 (counted 5…1) at r5; 1–10 (counted 10…1) at r10/r20; vorschule: the length-10 segment containing the target at a position other than its highest number (11–20, 6–15 or 1–10, counted downwards), choosing the one where the target is closest to the middle
Gap position Never the first bead in counting direction (the leftmost bead of an ascending task) same Never the first bead in counting direction (the rightmost, highest bead)
Options littleOnes 2 3 3
Options vorschule 3 3 (oneGapLong) / 4 (twoGaps: both gap values + 2 distractors) 4
Distractor slots littleOnes [near1]; vorschule [near1], [near1, near2] oneGap/oneGapLong: [near1], [near2, near1]; twoGaps: [near1], [near1, near2] (relative to either gap value, excluding both gap values) littleOnes [near1], [near1, near2]; vorschule [near1], [near1], [near2, confusableVisual]

Distractors are allowed to be numbers visible elsewhere on the chain (a neighbour such as 5 or 7 for a gap at 6 is the classic ordering error and therefore the most informative distractor). Ranges intersect the active range stage.

11.5.7 Step2 Kind Selection (vorschule) #

Condition Kind and segment
Stage r10 twoGaps in segment 1–10
Stage r20, target 11–20 oneGapLong: segment 6–15 if the target is 11–15, else 11–20 (target never first: 11 is at position 6 of 6–15, 16 at position 6 of 11–20)
Stage r20, target 7–10 50 % oneGapLong in 6–15, 50 % twoGaps in 1–10 (seeded)
Stage r20, target 2–6 twoGaps in 1–10

twoGaps rules: the second gap is chosen uniformly among positions that are (a) not the first bead, (b) not adjacent to the target gap (at least one visible bead between the gaps), and (c) within the same segment. If no position satisfies all rules, the task becomes oneGap in 1–10.

littleOnes step2 is oneGap in the same segment as step1 with 3 options (harder through more, nearer distractors).

Target 1 is only valid in backward tasks (in an ascending segment 1 is always first). The content ranges above encode this; the engine never plans 1 for step1/step2 (Section 9 reads the ranges).

11.5.8 Prompts #

Kind Composition German
Round intro prompt.was_fehlt.intro "Oh, in der Perlenkette fehlen Perlen!"
oneGap, oneGapLong prompt.was_fehlt.which "Welche Zahl fehlt?"
twoGaps prompt.was_fehlt.two "Hier fehlen zwei Zahlen. Welche?"
backward prompt.was_fehlt.backward "Wir zählen rückwärts. Welche Zahl fehlt?"
After a partial success in twoGaps prompt.was_fehlt.one_more "Super, und welche fehlt noch?"

Voice-off visual prompt: the gap(s) glow softly (one breathing cycle of 1.6 s, repeated twice) and show a question mark inside the dashed circle; for backward the right-to-left arrow above the chain pulses together with the gap (2 pulses, Section 19.9).

11.5.9 Hints and Solution #

For twoGaps, hints and the solution always address the first open gap in reading order.

Level Audio Visual
1 Section 10.7.2 sequence; hint.was_fehlt.neighbours "Schau dir die Nachbarn an." The wrong bead floats back and fades; the two beads next to the addressed gap (left and right) glow once (0.8 s)
2 hint.was_fehlt.count_with_me "Zähl mit:" then the count-along of up to three beads before the gap in counting direction (ascending: e.g. num.3, num.4, num.5; backward: num.9, num.8, num.7), then hint.was_fehlt.and_then "… und dann?" (rising intonation); template was_fehlt.hint_l2 Each counted bead lights up in turn (0.5 s each); the gap pulses once at "und dann?"; one further distractor fades (if at least two enabled distractors remain)
Solution fb.solution.hier_fehlt_die + num.n + fb.solution.schau + num.(prev), num.n, num.(next) in counting direction (template was_fehlt.solution) — "Hier fehlt die Sechs. Schau: fünf, sechs, sieben."; backward: "Hier fehlt die Sechs. Schau: sieben, sechs, fünf." If the gap is the last bead in counting direction, only num.(prev), num.n The demo hand travels to the correct tray bead, which gets the green ring; the gap glows; the child taps (or drags) the bead, which flies into the gap. For twoGaps with both gaps still open, after the first gap is filled the second gap is demonstrated the same way (the task stays in awaitingSolutionTap until all gaps are filled; solutionAnswer is re-evaluated after each placement)

"num.(prev)" and "num.(next)" refer to the numbers adjacent to the gap in counting direction (ascending: n−1 and n+1; backward: n+1 and n−1); they are always inside the segment because the gap is never first in counting direction.

11.5.10 Correct Feedback #

Section 10.8.1 with num.n (the last filled gap's value) + fb.correct. Visual: the whole chain "sings": a soft light runs along the beads in counting direction (30 ms per bead; Reduce Motion: all beads glow together for 0.4 s). Partial success in twoGaps: sfx.drag_drop + num.v of the placed bead (template was_fehlt.partial), then prompt.was_fehlt.one_more; no fb.correct until the task is complete.

11.5.11 Parameters JSON Example (games/was_fehlt.json) #

{
  "schemaVersion": 1,
  "gameId": "was_fehlt",
  "tier": "free",
  "primarySkill": "order",
  "secondarySkill": "recognize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": { "littleOnes": 70, "vorschule": 80 },
  "promptIds": [
    "prompt.was_fehlt.intro",
    "prompt.was_fehlt.which",
    "prompt.was_fehlt.two",
    "prompt.was_fehlt.backward",
    "prompt.was_fehlt.one_more"
  ],
  "hintIds": {
    "level1": ["hint.was_fehlt.neighbours"],
    "level2": ["hint.was_fehlt.count_with_me", "hint.was_fehlt.and_then"]
  },
  "solutionIds": ["fb.solution.hier_fehlt_die"],
  "utterances": [
    { "id": "was_fehlt.intro", "segments": ["prompt.was_fehlt.intro"], "variants": [] },
    { "id": "was_fehlt.task", "segments": ["prompt.was_fehlt.which"], "variants": [] },
    { "id": "was_fehlt.task_two", "segments": ["prompt.was_fehlt.two"], "variants": [] },
    { "id": "was_fehlt.task_backward", "segments": ["prompt.was_fehlt.backward"], "variants": [] },
    { "id": "was_fehlt.partial", "segments": ["{v}", "_", "prompt.was_fehlt.one_more"], "variants": [] },
    { "id": "was_fehlt.hint_l1", "segments": ["hint.was_fehlt.neighbours"], "variants": [] },
    { "id": "was_fehlt.hint_l2", "segments": ["hint.was_fehlt.count_with_me", "{count s m}", "hint.was_fehlt.and_then"], "variants": [] },
    { "id": "was_fehlt.solution", "segments": ["fb.solution.hier_fehlt_die", "{n}", "_", "fb.solution.schau", "{count p q}"], "variants": [] }
  ],
  "steps": [
    {
      "id": "step1",
      "labelKey": "game.step.leicht",
      "numberMin": 2,
      "numberMax": 10,
      "parameters": { "kinds": { "oneGap": 1.0 }, "direction": "ascending",
                      "segmentLengths": [5, 10], "gapNeverFirst": true,
                      "optionCount": 3, "distractorSlots": [["near1"], ["near1", "near2"]] },
      "parametersByLevel": {
        "littleOnes": { "optionCount": 2, "distractorSlots": [["near1"]] }
      }
    },
    {
      "id": "step2",
      "labelKey": "game.step.mittel",
      "numberMin": 2,
      "numberMax": 20,
      "numberRangeByLevel": { "littleOnes": { "min": 2, "max": 10 } },
      "parameters": { "direction": "ascending", "gapNeverFirst": true, "twoGapsMinDistance": 2,
                      "kinds": { "oneGapLong": 0.5, "twoGaps": 0.5 },
                      "optionCount": 3, "twoGapsOptionCount": 4,
                      "distractorSlots": [["near1"], ["near2", "near1"]],
                      "twoGapsDistractorSlots": [["near1"], ["near1", "near2"]] },
      "parametersByLevel": {
        "littleOnes": { "kinds": { "oneGap": 1.0 }, "optionCount": 3,
                        "distractorSlots": [["near1"], ["near2", "near1"]] }
      }
    },
    {
      "id": "step3",
      "labelKey": "game.step.schwer",
      "numberMin": 1,
      "numberMax": 19,
      "numberRangeByLevel": { "littleOnes": { "min": 1, "max": 9 } },
      "parameters": { "kinds": { "backward": 1.0 }, "direction": "descending",
                      "segmentLengths": [5, 10], "gapNeverFirst": true,
                      "optionCount": 4, "distractorSlots": [["near1"], ["near1"], ["near2", "confusableVisual"]] },
      "parametersByLevel": {
        "littleOnes": { "optionCount": 3, "distractorSlots": [["near1"], ["near1", "near2"]] }
      }
    }
  ]
}

direction is the counting direction (descending = counted downwards); the display is always ascending (11.5.3). Template bindings: v = value of the placed bead; s…m = up to three beads before the gap in counting direction (m = the bead directly before the gap); p = the bead before the gap and q = the bead after it in counting direction (q = n when the gap is the last bead in counting direction). {count x y} counts upwards or downwards (Section 20.6.2).

The kind weights of vorschule step2 are the default split; the stage- and target-dependent rules in 11.5.7 take precedence over the weights (weights apply only in the "target 7–10 at r20" row). Validation (WasFehltStepParameters.validate() through the game's GameContentSchema, Section 8.8.3): kinds is a weights object with keys ⊆ {oneGap, oneGapLong, twoGaps, backward}; direction ∈ {ascending, descending}; segmentLengths ⊆ {5, 10}; optionCount 2–4 equal to distractorSlots.count + 1; twoGapsOptionCount = twoGapsDistractorSlots.count + 2; twoGapsMinDistance ≥ 2; an ascending step must have numberMin ≥ 2 (for every level range), a descending step numberMax ≤ 19.

11.5.12 Mastery Credit #

order 1.0 + recognize 0.5 for the target gap's number. The second gap in twoGaps gets no credit. Voice on or off makes no difference.

11.5.13 Edge Cases #

Case Behaviour
Child drags a correct bead onto the wrong gap (two gaps) Redirected to its correct gap, treated as correct placement (tolerant evaluation)
Child fills the second (uncredited) gap first, then makes a mistake on the target gap The wrong attempt counts; outcome follows the total wrong attempts in the task
Tap on a tray bead when two gaps are open The bead goes to the first open gap in reading order if its value fits that gap; if it fits the other open gap it goes there; if it fits neither it goes to the first open gap and is evaluated as wrong (floats back)
Drop between two gaps (both within 24 pt tolerance) Nearest gap centre wins (Section 10.9.5)
Wrap to two rows in portrait with a segment of 10 Row 1 = first five-group, row 2 = second; the gap may be in either row
Rotation mid-task Chain re-lays out (1 or 2 rows); placed beads and open gaps are preserved
Backward segment where the target is the last bead in counting direction (e.g. 1 when counting 5…1, the leftmost bead) Allowed; the solution line then says only "Schau: zwei, eins." (prev and n)
Parent override r20 for a littleOnes child littleOnes ranges cap targets at 10 (step1/2) and 9 (step3), so littleOnes chains never exceed 1–10 in this game. Decision: Was fehlt? stays within 1–10 for littleOnes even at r20 because the ordering of teens is taught at vorschule level.
Stage r5 and step2 twoGaps Impossible: littleOnes never gets twoGaps, and vorschule never has r5

11.5.14 Acceptance Criteria #

  1. Given a vorschule step1 task with target 6, when the task appears, then the chain 1–10 shows beads 1–5 red solid and 7–10 blue Lochperlen with numerals below, a dashed gap at position 6, a 1.5× gap between 5 and the gap, and 3 white tray beads including 6 and at least one of 5 or 7.
  2. Given iPhone SE in portrait, when a 10-bead chain is shown, then it wraps into two rows of five with the string curving from 5 to 6, and every numeral is at least 44 pt.
  3. Given a task, when the child taps the tray bead "6", then it flies into the gap, takes the blue Lochperle colour, "sechs" and a fb.correct line play, and the task completes as firstTry.
  4. Given a task, when the child drops a bead outside the chain, then it floats back to the tray, no attempt is counted and no sound other than sfx.return plays.
  5. Given a wrong bead dropped into the gap, then the bead floats back and fades, the two neighbouring beads glow, and "Schau dir die Nachbarn an." plays after a fb.tryagain line.
  6. Given a second wrong attempt on a gap at 6, then the beads 3, 4, 5 light up in turn with "drei, vier, fünf … und dann?" spoken.
  7. Given a third wrong attempt on a gap at 6, then "Hier fehlt die Sechs. Schau: fünf, sechs, sieben." plays, the demo hand points to the tray bead 6, and tapping it completes the task as shown.
  8. Given a vorschule step2 twoGaps task with gaps at 4 and 8 (target 4), when the child places 8 first and then 4, then the result is firstTry, and only 4 receives mastery credit.
  9. Given a vorschule step3 task with target 12 at r20, then the chain shows 6–15 or 11–20 ascending from left to right (colours by value) with a right-to-left arrow above it, "Wir zählen rückwärts. Welche Zahl fehlt?" is spoken, and the gap is never the highest (first counted) bead.
  10. Given a littleOnes profile at r5, when any task is generated, then the chain is 1–5 (counted 5…1 at step3, still displayed ascending) and all tray beads are within 1–5.
  11. Given an iPhone in landscape (task area narrower than 820 pt), when a 10-bead segment is shown, then it is displayed as two rows of five with a visible gap between the two tens, every tray bead and gap target is at least 60 × 60 pt, and every numeral is at least 44 pt.

12. Game Specifications: Premium Games I #

This section specifies the first four premium games: Blitzblick, Mehr oder weniger, Nachspuren and Schüttelbox. Section 13 specifies the other four premium games and uses the conventions in Section 12.1. All eight games are premium: they are playable only while the family holds an active Premium entitlement (Section 17). Their lock presentation for the child (lock badge, "Frag deine Eltern") is owned by Sections 17 and 18.

12.1 Conventions for Sections 12 and 13 #

12.1.1 Ownership boundaries #

Each game specification only defines what is game-specific. The following concerns are owned elsewhere and are referenced, never redefined:

Concern Owner
GameModule protocol, GameTask model, round lifecycle states and timings, answer evaluation contract, attempt counting, hint-ladder triggering (attempt 1 wrong → hint level 1, attempt 2 wrong → hint level 2, attempt 3 wrong → solution demonstration, child taps the highlighted answer to continue), inactivity handling (demo hand, re-prompt, pause overlay), input lock during the first part of a prompt, speaker (replay) button, home button, progress dots, pause and interruption semantics, events emitted Section 10
Which number n, which skill and which step each task gets; mastery math; difficulty stepping; range widening Section 9
Stars, stickers, celebrations, Zahlenfreunde Section 14
Generic envelope of games/<gameId>.json (including gameParameters, numberRangeByLevel, parametersByLevel and the per-step tasksPerRound, expectedRoundSeconds and minRange overrides), dot-picture file schema, content validation Section 8
Shared distractor selection (DistractorPicker, DistractorStrategy) and the option display-order rule Section 10.17
Counting-object catalogue (object types, food subset) Section 11.1.4
Audio inventory and recording; IDs and texts of fb.*, sfx.*, session.*, hint.common.* and the shared carrier fragments prompt.common.* Sections 20 and 21
Visual components (BeadView, ZwanzigerfeldView, DiceView, FingerPatternView, NumeralTile, BigButton), colors, typography tokens, touch-target minimums, layout grid, haptics map (Section 19.10), Reduce Motion rules Section 19
Premium entitlement and lock behaviour Section 17

What each game section owns: its task content generation, its parameter keys inside games/<gameId>.json (step parameters, parametersByLevel and the game-level gameParameters object), its game-specific prompt.<gameId>.*, hint.<gameId>.* and label.* lines (IDs and German text; Section 21 mirrors them verbatim), its hint and solution content (what is shown and said at each hint level, composed from its own lines and the Section 21 fb.solution.* and prompt.common.* fragments), its correct-feedback visuals, its mastery-credit mapping, its edge cases and its acceptance criteria.

12.1.2 Task inputs and determinism #

For every task the engine (Section 9) supplies a planned task consisting of the skill, the planned number n (1–20) and the step (step1, step2, step3). The game's task generator (the generation hook of the GameModule protocol, Section 10) turns this into concrete task content using only:

  • the planned number n and step,
  • the child's level (littleOnes or vorschule) and the active range stage (r5, r10, r20),
  • the effective step parameters from games/<gameId>.json (the step's parameters overlaid key by key with parametersByLevel.<level>, Section 8.8.3) and the game-level gameParameters object,
  • the injected seeded generator (SplitMix64, declared in ZKCore, Section 5.3.2) passed by the framework,
  • the game's per-profile state (Section 12.1.4), where a game uses one.

Decision: generation is a pure function of these inputs. The same inputs and the same RNG seed always yield identical content. Every game ships a unit test proving this (Section 25).

"Range bounds" below always means lo = 1 and hi = 5 / 10 / 20 for r5 / r10 / r20, intersected with the step's numberMin/numberMax.

12.1.3 Supported numbers, level caps and defensive clamping #

Each step in games/<gameId>.json declares numberMin and numberMax (the numbers this step can present as the planned number). Where one level has a narrower range, the step adds numberRangeByLevel.<level> (Section 8.8.2); no game uses a game-level range field. Each game also declares littleOnesMaxStep (the highest step a littleOnes child can reach in this game). The engine only plans numbers within the step's range for the child's level ∩ active range, and never plans a step above littleOnesMaxStep for a littleOnes child. Parent difficulty caps (Section 9) apply on top.

Three optional per-step envelope fields (Section 8.8.2) carry game-specific planning facts to the engine: tasksPerRound (an integer for both levels; replaces the game-level value for rounds at that step, used by Memory, Section 13.3.6), expectedRoundSeconds (replaces the game-level value for Abenteuer time budgeting at that step, used by Memory and Punkt zu Punkt) and minRange (r5, r10 or r20; the engine never plans the step while the active range is smaller, used by Punkt zu Punkt, Section 13.4.2). The engine reads them through GameCapabilities (Section 9.3.3).

Defensive rule: if a generator still receives an unsupported number, it clamps the number to the nearest supported one. In Debug builds it also calls assertionFailure, and in all builds it logs a .fault through os.Logger (Section 6). The task is then recorded under the clamped number. If the step itself is unsupported for the level, the generator uses the highest supported step instead and logs the same way.

12.1.4 Per-profile game state #

Two games need small amounts of persistent per-profile memory: Schüttelbox (split coverage, Section 12.5.6) and Punkt zu Punkt (picture rotation, Section 13.4.5). Game targets must not import ZKPersistence (Section 5), so they use the framework's GameStateStore (Section 10.4.5, the owner of the protocol and its rules). In short:

  1. Before the round starts, the app reads GameProgress.gameStateJSON for the (profile, game) pair (Section 7.5.6; default "", at most 8 KB) and creates the round's store. The store is available as GameEnvironment.stateStore and, inside task generation, as TaskGenerationContext.stateStore (Section 10.3 and 10.4).
  2. Access is synchronous and on the main actor (load and save are non-async), so task generation stays a pure function of its inputs (Section 12.1.2).
  3. save stages the new state. The round controller copies the staged value into TaskResult.updatedGameState of the task being completed, and RoundResultCoordinator writes it to GameProgress.gameStateJSON in that task's save point (Section 7.12.2). State staged for a task that never completes is discarded with it. A state larger than 8 KB is rejected and logged (Section 10.4.5).

Every game state struct carries an integer v (format version); its schema is owned by the game's section. If the stored JSON fails to decode, load returns nil, the game logs a .error and continues with empty state. It never crashes.

12.1.5 Numeral tiles, option counts and distractor selection #

Answer options are shown in the answer area (Section 10 layout slot) as one row of NumeralTiles or picture cards (Section 19), with at least 12 pt between targets. Every numeral that is task content (stimulus, answer tile, card numeral, pad or dot label) uses the child.numeral typography token: at least 44 pt on iPhone and 64 pt on iPad (Section 19.3.2). The smaller numeral tokens are never used for task content.

Distractors come from the framework's DistractorPicker (Section 10.17.1). Each game lists its distractor slots as ordered DistractorStrategy lists in its parameters (key distractorSlots: one list per slot; when more slots are needed than listed, the last list is repeated). "Nearest numbers first" is [nearest] (distance 1, 2, 3, … in seeded order). Game-specific preferences are expressed as strategies in the first slots, for example [fivePartner, nearest] (n ± 5) or [tensPartner, nearest] (n ± 10). Where a game needs a value no strategy describes (for example the swapped part in Schüttelbox, Section 12.5.7), the game computes these explicit candidates first; each valid one takes one option slot, and the rest are filled by DistractorPicker with the explicit candidates passed in excluded. All candidates stay within the child's active range ∩ the step's range. If fewer values exist than requested, the option count shrinks, never below 2 options in total (Section 10.17.1, rule 3).

Display order uses OptionOrder (Section 10.17.3), the single rule for every game: with 3 or more options, the correct option never sits in the same slot as the previous task's correct option; with 2 options, the correct option never sits in the same slot in more than 2 consecutive tasks; within these constraints the slot is seeded-random and the distractors fill the remaining slots in seeded random order. No game in Sections 12 and 13 defines its own position rule.

12.1.6 Audio and text notation #

Prompt tables list the audio ID, the German text and the string key. {n} marks where a number word is concatenated at runtime, using the concatenation and gap rules of Section 20. For example, prompt.froschsprung.jump_to + num.<n> plays "Spring zur" + "sieben". Number words are the recorded num.<n> files (statement intonation) or num.<n>.q (question intonation) as indicated. String keys follow the content key conventions of Section 8.3 (the text key of a voice line is its audio ID). The final inventory of every line is Section 21; the prompt.<gameId>.*, hint.<gameId>.* and label.* lines in Sections 12 and 13 are the source for those game rows, and Section 21 mirrors them verbatim. Solution sentences are composed from the Section 21 fragments (fb.solution.* and the carrier fragments prompt.common.*), with the IDs and texts given there; each game states which fragments it uses. Composed lines are declared as utterance templates in the game file's optional utterances array (Section 8.8.1, template shape in Section 20.6.2). Generic feedback lines (fb.correct.*, fb.tryagain.*) and the idle re-prompt (hint.common.listen_again) are chosen by the framework (Section 10).

hintIds.level1 and hintIds.level2 in each game file list every line the game may use at that rung. Which line plays is decided by the game's hint rules in this section; there is no rotation among hint lines (rotation applies only to fb.correct.* and fb.tryagain.*, Section 10.8.4).

The number word "null" (num.0) is declared by the optional zero entry of numbers.json (Section 8.6) and is spoken only by the Nachspuren digit prompt (Section 12.4.9). No other game line in Sections 12 and 13 speaks "null".

12.1.7 Voice-off path #

When the device setting "Sprache" is off (Section 16), every spoken prompt still has a visual equivalent: the framework demo hand (Section 10) plus the per-game visual prompt stated in each game's prompt table ("Visual equivalent" column). No task depends on hearing alone. The only exception is the number word itself. When voice is off, a game that would speak a target number shows the numeral in the prompt area instead.

12.1.8 Outcomes #

Outcomes are firstTry, afterHint and shown (Section 9). The framework derives them from attempt counts (Section 10). Games may only downgrade an outcome, and only where their section says so explicitly (the Blitzblick replay rule, Section 12.2.8). No game ever upgrades an outcome.

12.1.9 Layout reference #

The layout descriptions use three reference canvases:

  • iPhone SE portrait: 375 × 667 pt
  • iPhone SE landscape: 667 × 375 pt
  • iPad reference (11-inch class): 820 × 1180 pt portrait, 1180 × 820 pt landscape

Other devices scale according to the layout matrix of Section 19. The top bar with the home button (top-left), the speaker button (top-right) and the progress dots is the Section 10 slot. "Task area" means the space below the top bar. All child touch targets are at least 60 × 60 pt (Section 19).

12.1.10 Entitlement change during play #

If the Premium entitlement lapses or is revoked while a premium round is running (for example, Transaction.updates delivers an expiration), the round in progress finishes normally and records normally. The lock applies from the next game selection on. Inside an Abenteuer, the coordinator re-checks the entitlement at each Abenteuer round start and replaces a premium round that is no longer unlocked (Section 9.16.6); a running round is never interrupted. Decision: nothing is interrupted mid-round, because interrupting a child for a billing event is a pressure pattern.

12.1.11 Acceptance-criteria IDs #

Criteria are numbered AC-<CODE>-NN with the codes BB (Blitzblick), MW (Mehr oder weniger), NS (Nachspuren), SB (Schüttelbox), FS (Froschsprung), ZM (Fütter das Zahlenmonster), ME (Memory) and PP (Punkt zu Punkt). Section 25 turns them into tests.

12.2 Blitzblick #

12.2.1 Purpose and skills #

Blitzblick ("lightning look") trains simultaneous quantity recognition (Simultanerfassung). A structured quantity (dice pattern, Fünferfeld, finger pattern, Zwanzigerfeld) appears briefly, then hides. The child tells how many there were without counting one by one, which builds the "Kraft der Fünf" (seeing 7 as 5 + 2).

Field Value
GameID blitzblick
Display name Blitzblick
Tier premium
Primary skill subitize (weight 1.0)
Secondary skill count (weight 0.5)
Swift target GameBlitzblick

12.2.2 Level availability and limits #

Level Steps available Numbers per step (before active-range intersection)
littleOnes step1, step2 (littleOnesMaxStep = 2) step1: 1–6; step2: 1–10
vorschule step1, step2, step3 step1: 1–6; step2: 1–10; step3: 1–20

A littleOnes child in r5 therefore sees at most 5 at every step; the dice pattern for 6 appears only once the range is r10 or higher. Decision: step3 is not available to littleOnes, because a 1.0 s flash of 11–20 is not developmentally appropriate for ages 2–4.

Flash duration is a display time, never a response timer. The child always has unlimited time to answer. There is no visible or audible countdown anywhere in the game.

12.2.3 Representations #

Representation ID Numbers Rendering (component per Section 19) Layout rule
dice 1–6 DiceView, one die face Pip layout per Section 19.7.4 (the owner): 1 = centre; 2 = top-right, bottom-left; 3 = 2 + centre; 4 = four corners; 5 = 4 + centre; 6 = two columns of three. Pips are ink on a white face. Dice pips are never split red/blue.
fiveFrame 1–5 Fünferfeld: one row of 5 cells; filled cells show red solid beads, filled from the left Cell pitch equal; empty cells show an empty outline
fingers 1–10 FingerPatternView, two illustrated hands, back of hands facing the viewer (as the child sees its own raised hands), left hand on the left side of the screen German convention: counting starts with the thumb. 1 = left thumb; 2 = left thumb + index; 3 = + middle; 4 = + ring; 5 = whole left hand; 6 = whole left hand + right thumb; 7 = + right index; 8 = + right middle; 9 = + right ring; 10 = both whole hands. Folded fingers are drawn folded, not missing. For 1–5 the right hand is drawn as a closed fist (visible, so the layout does not jump between numbers).
twentyFrame 1–20 ZwanzigerfeldView, display-only, so always the wide arrangement scaled to the card (arrangement: .wide, Section 19.7.3) (2 rows × 10, each row split 5 and 5 with a gap of 1.5 × normal spacing; first five of each row solid red beads, second five blue "Lochperle" beads with a white center ring; row 1 = 1–10, row 2 = 11–20) Filled from the left of row 1; for 11–20, row 1 is completely full (10) and row 2 holds n − 10 (the 10 + n structure). Empty cells show a faint outline.

Decision: for twentyFrame tasks with n ≤ 10, only row 1 is rendered at step2. At step3, both rows are always rendered (row 2 empty when n ≤ 10), so the frame size never gives the answer away.

12.2.4 Screen layout #

The task area has three vertical zones: the flash card (center), the "Nochmal zeigen" button (next to the card) and the answer row.

Element iPhone SE portrait iPhone SE landscape iPad (portrait and landscape)
Flash card Centered, 300 × 220 pt (dice, fiveFrame: 220 × 220 pt; twentyFrame: 330 × 150 pt) Left 60% of the task area, card 360 × 220 pt max, vertically centered Centered in the upper 60% of the task area, 520 × 340 pt max (dice 340 × 340 pt)
"Nochmal zeigen" button 64 × 64 pt, eye pictogram, directly below the card's bottom-right corner 64 × 64 pt, right of the card 80 × 80 pt, right of the card
Answer row Bottom of the task area, 1 row, tiles 72 × 72 pt (4 tiles fit in 343 pt: 4 × 72 + 3 × 12 = 324 pt); numerals child.numeral 44 pt Right 40% of the task area, tiles in a 2 × 2 grid (72 pt) Bottom, 1 row, tile size per Section 19.7.6; numerals 64 pt
Quantity-card options (answer mode quantityCard) 2–4 cards, 100 × 100 pt each; one row for up to 3 cards, 2 × 2 grid for 4 2 × 2 grid of 100 pt cards 1 row, 160 × 160 pt cards

The card has a front (the pattern on white) and a back (a uniform cream pattern with a small eye motif; it never encodes a number). Before the flash the card shows its back.

12.2.5 Round structure #

Tasks per round follow Section 9: 5 for vorschule, 4 for littleOnes. Each task runs as follows:

  1. Ready. The card shows its back. The intro prompt plays on the first task of the round (prompt.blitzblick.intro); every task plays prompt.blitzblick.tap_card. The card gently pulses once (scale 1.0 → 1.04 → 1.0 over 0.8 s, not looping).
  2. Reveal on tap. The child taps the card. The child starts the flash, so the child is looking when it happens. If the child does not tap, the inactivity rules of Section 10 apply (demo hand taps the card after 6 s of inactivity). The flash never starts by itself.
  3. Flash. The card turns to its front (0.25 s flip), the pattern stays fully visible for the step's flashSeconds (measured from the end of the flip), then the card turns to its back (0.25 s flip). No sound plays during the flash except the very soft sfx.flash on each flip.
  4. Answer. The answer options slide in (0.2 s) and prompt.blitzblick.how_many plays (or prompt.blitzblick.find_same in quantityCard mode). The "Nochmal zeigen" button appears at the same time. The child has unlimited time.
  5. Evaluate and feed back per Section 10. Wrong attempts trigger the hints in Section 12.2.10, and the third wrong attempt triggers the solution demonstration in Section 12.2.11. On a correct answer, the correct feedback in Section 12.2.12 plays.

Reduce Motion variant: the flip is replaced by a 0.2 s cross-fade between back and front, the ready pulse is omitted, and options appear without sliding. Display durations are unchanged, because they are didactic parameters and not animations.

12.2.6 Task generation per step #

Given planned number n and step:

  1. Choose the representation. Pick from the step's representations weights, restricted to representations that support n (table in Section 12.2.3). If none of the weighted representations supports n, use the fallback order twentyFrame, then fingers, then dice.

  2. Choose the answer mode. Pick from answerModeWeights. quantityCard is only possible when n ≤ 10 and a different representation that supports n exists (rule 4).

  3. Build numeral options. Use DistractorPicker (Section 12.1.5) for optionCount − 1 distractors with the step's distractorSlots, bounds [max(1, lo), min(hi, stepNumberMax)]. The slots per step are in the table below.

  4. Build quantityCard options. Every option card uses the same answer representation, which must differ from the flashed representation:

    Flashed Answer representation
    dice fiveFrame if n ≤ 5, else twentyFrame
    fiveFrame dice
    fingers twentyFrame
    twentyFrame fingers

    Distractor quantities come from the same DistractorPicker call. Each distractor must be supported by the answer representation.

  5. No immediate repeats. The same (representation, n) pair is not generated twice in a row within a round. If the engine plans the same n twice in a row, the second task uses a different representation where one exists.

Parameter littleOnes step1 littleOnes step2 vorschule step1 vorschule step2 vorschule step3
numberMin–numberMax 1–6 1–10 1–6 1–10 1–20
flashSeconds 2.0 1.5 2.0 1.5 1.0
representations (weights) dice 0.6, fiveFrame 0.4 fingers 0.5, twentyFrame 0.5 dice 0.6, fiveFrame 0.4 fingers 0.5, twentyFrame 0.4, dice 0.1 twentyFrame 0.7 (all 11–20 use twentyFrame), fingers 0.3 (only n ≤ 10)
answerModeWeights quantityCard 0.7, numeral 0.3 quantityCard 0.5, numeral 0.5 numeral 0.7, quantityCard 0.3 numeral 0.8, quantityCard 0.2 numeral 1.0
optionCount 2 3 3 4 4
distractorSlots (DistractorStrategy, Section 10.17.1) [nearest] [nearest] [nearest] slot 1 [fivePartner, nearest] (five-group confusion), then [nearest] slot 1 [tensPartner, nearest] (n − 10 for 11–20, n + 10 for 1–10 when in range), then [nearest]
replayDowngrades false true false true true

Rule for step3 with n ≥ 11: the representation is always twentyFrame (10 + n). The weights above only apply to n ≤ 10.

12.2.7 Swift task content #

enum BlitzRepresentation: String, Codable, Sendable { case dice, fiveFrame, fingers, twentyFrame }
enum BlitzAnswerMode: String, Codable, Sendable { case numeral, quantityCard }

struct BlitzblickTaskContent: Sendable, Equatable {
    let number: Int                      // planned n, 1...20
    let representation: BlitzRepresentation
    let flashSeconds: Double             // display time only
    let answerMode: BlitzAnswerMode
    let answerRepresentation: BlitzRepresentation?   // non-nil only for .quantityCard
    let options: [Int]                   // displayed left-to-right; contains `number` exactly once
    let replayDowngrades: Bool
}

12.2.8 "Nochmal zeigen" (replay) rule #

  • The replay button (eye pictogram, BigButton, accessibility label "Nochmal zeigen") appears when the card turns to its back after the flash. It stays until either the child uses it or the child gives the first answer.

  • It can be used once per task. Using it replays the flip → flash (same flashSeconds) → flip sequence. The button then disappears for the rest of the task.

  • Outcome rule, decided and binding:

    Step Replay used, then first answer correct Replay used, then first answer wrong
    step1 firstTry (no downgrade) Normal hint ladder (hint level 1 next)
    step2 afterHint (downgraded) Normal hint ladder; final outcome at most afterHint
    step3 afterHint (downgraded) Normal hint ladder; final outcome at most afterHint
  • A replay is not an attempt and does not advance the hint ladder.

  • After the first answer (right or wrong), the replay button is gone. Re-showing the pattern after a wrong answer is part of hint level 1.

12.2.9 Prompts #

Audio ID German text When Visual equivalent (voice off)
prompt.blitzblick.intro "Schau genau hin! Die Punkte sind nur kurz zu sehen. Danach hast du ganz viel Zeit." First task of each round, before tap_card Demo hand taps the card once
prompt.blitzblick.tap_card "Tipp auf die Karte!" Every task, ready phase Card pulse + demo hand after 6 s (Section 10)
prompt.blitzblick.how_many "Wie viele waren es?" Answer phase, numeral mode Tiles slide in; a small closed-eye icon above the card
prompt.blitzblick.find_same "Wo sind genauso viele?" Answer phase, quantityCard mode Cards slide in; same icon
hint.blitzblick.look_again "Schau noch mal genau hin." Hint level 1 Card re-flashes (Section 12.2.10)
hint.blitzblick.five "Schau: Das sind fünf." Hint level 2, n ≥ 6, spoken while the full five-group glows Five-group outline glows
hint.blitzblick.count_together "Wir zählen zusammen." Hint level 2, n ≤ 5 Dots light up one by one
hint.blitzblick.ten "Schau: Das sind zehn." Hint level 2, n ≥ 11, spoken while the full first row glows First row outline glows
hint.blitzblick.and_more "… und noch {n}." Hint level 2, after hint.blitzblick.five, {n} = remaining count Remaining dots light up
(solution, composed) "Das sind {n}." (prompt.common.das_sind + num.<n>; n = 1: prompt.common.das_ist_eins "Das ist eins."), for n ≥ 6 followed by "Schau: fünf und {n−5}." or, for 11–20, "Schau: zehn und {n−10}." (fb.solution.schau + num.5 or num.10 + prompt.common.und + number) Solution demonstration Numeral shown large (child.numeral) under the pattern

12.2.10 Hints #

  • Hint level 1 (after the first wrong answer): the tapped wrong option fades and becomes non-interactive per Section 10.6.3 (35 % opacity, stays in place). hint.blitzblick.look_again plays. The card re-flashes for flashSeconds × 1.5 (step1: 3.0 s, step2: 2.25 s, step3: 1.5 s). While it is visible, a soft outline surrounds each five-group (and the full ten-row in 11–20). The outline is ink at 30% opacity with 2 pt stroke. For dice and fingers the outline surrounds the whole pattern.
  • Hint level 2 (after the second wrong answer): the card turns to the front and stays visible for the rest of the task. For n ≤ 5: hint.blitzblick.count_together plays, then the dots or fingers light up one at a time with num.1 … num.<n> spoken (0.6 s per item). For n ≥ 6: the first complete five-group lights up with hint.blitzblick.five; for 11–20 the full first row lights up with hint.blitzblick.ten instead, and then counting on continues across the remaining items ("sechs, sieben" or "elf, zwölf"), 0.6 s per item. The second wrong option fades too (Section 10.6.3).
  • Faded options cannot be chosen again (Section 10.6.3). With 2 options (littleOnes step1), the single remaining option after the first wrong answer is the answer; choosing it records afterHint.

12.2.11 Solution demonstration #

After the third wrong answer: the card shows the pattern (front) with the five-group structure outlined, the correct numeral (or correct quantity card) is highlighted with a soft green (#4FA36B) ring and a gentle pulse, and the line "Das sind {n}." plays. For n ≥ 6 the line continues with the structure "Schau: fünf und {n−5}." (for n ≥ 11: "Schau: zehn und {n−10}."), composed from the Section 21 fragments listed in Section 12.2.9. The child taps the highlighted option to continue (Section 10). The outcome is shown.

12.2.12 Correct feedback #

  • The chosen option gets the soft-green ring. The card turns to the front for 1.2 s, showing the pattern again with the numeral n fading in under it (the three representations linked: quantity, numeral, spoken word). num.<n> plays, followed by a generic correct line chosen by the framework (fb.correct.*, Section 10).
  • Haptic: the correct-answer haptic of the Section 19.10 map.
  • Reduce Motion: no flip; cross-fade to the front.

12.2.13 Parameters: games/blitzblick.json #

The envelope fields follow Section 8.8. Level differences use parametersByLevel (Section 8.8.3); the keys inside parameters and parametersByLevel are owned here.

{
  "schemaVersion": 1,
  "gameId": "blitzblick",
  "tier": "premium",
  "primarySkill": "subitize",
  "secondarySkill": "count",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": 75,
  "littleOnesMaxStep": 2,
  "promptIds": ["prompt.blitzblick.intro", "prompt.blitzblick.tap_card", "prompt.blitzblick.how_many", "prompt.blitzblick.find_same"],
  "hintIds": {
    "level1": ["hint.blitzblick.look_again"],
    "level2": ["hint.blitzblick.count_together", "hint.blitzblick.five", "hint.blitzblick.ten", "hint.blitzblick.and_more"]
  },
  "solutionIds": ["fb.solution.schau"],
  "steps": [
    {
      "id": "step1", "labelKey": "game.step.leicht", "numberMin": 1, "numberMax": 6,
      "parameters": {
        "flashSeconds": 2.0,
        "representations": { "dice": 0.6, "fiveFrame": 0.4 },
        "answerModeWeights": { "numeral": 0.7, "quantityCard": 0.3 },
        "optionCount": 3,
        "distractorSlots": [["nearest"]],
        "replayDowngrades": false,
        "hint1FlashFactor": 1.5
      },
      "parametersByLevel": {
        "littleOnes": { "answerModeWeights": { "quantityCard": 0.7, "numeral": 0.3 }, "optionCount": 2 }
      }
    },
    {
      "id": "step2", "labelKey": "game.step.mittel", "numberMin": 1, "numberMax": 10,
      "parameters": {
        "flashSeconds": 1.5,
        "representations": { "fingers": 0.5, "twentyFrame": 0.4, "dice": 0.1 },
        "answerModeWeights": { "numeral": 0.8, "quantityCard": 0.2 },
        "optionCount": 4,
        "distractorSlots": [["fivePartner", "nearest"], ["nearest"]],
        "replayDowngrades": true,
        "hint1FlashFactor": 1.5
      },
      "parametersByLevel": {
        "littleOnes": {
          "representations": { "fingers": 0.5, "twentyFrame": 0.5 },
          "answerModeWeights": { "quantityCard": 0.5, "numeral": 0.5 },
          "optionCount": 3,
          "distractorSlots": [["nearest"]]
        }
      }
    },
    {
      "id": "step3", "labelKey": "game.step.schwer", "numberMin": 1, "numberMax": 20,
      "parameters": {
        "flashSeconds": 1.0,
        "representations": { "twentyFrame": 0.7, "fingers": 0.3 },
        "teenRepresentation": "twentyFrame",
        "answerModeWeights": { "numeral": 1.0 },
        "optionCount": 4,
        "distractorSlots": [["tensPartner", "nearest"], ["nearest"]],
        "replayDowngrades": true,
        "hint1FlashFactor": 1.5
      }
    }
  ]
}

The solution sentence also uses the carrier fragments prompt.common.das_sind, prompt.common.das_ist_eins and prompt.common.und (Section 21.3.1); carriers are not listed in solutionIds.

Parameter validation (in addition to Section 8's generic checks): flashSeconds must be in 0.5–3.0; representation weights must be > 0, sum to 1.0 ± 0.001, and name only IDs from Section 12.2.3; optionCount must be 2–4; distractorSlots must be non-empty and name only DistractorStrategy raw values; hint1FlashFactor must be 1.0–2.0. An invalid step hides the game (Section 8 invalid-content rule).

12.2.14 Mastery credit #

Each task credits subitize (weight 1.0) and count (weight 0.5) for the planned number n, with the step's difficulty factor (Section 9). Distractor numbers are never credited.

12.2.15 Edge cases #

Case Behaviour
Child taps the card repeatedly during the flash Ignored; the flash runs its full duration once.
Child taps an answer tile during the closing flip Tiles are not yet present, so the tap has no effect.
App backgrounded or interrupted during the flash Section 10 pause. On resume, the task returns to the ready state (card back, tap to reveal). The interrupted flash does not count as the replay, and the outcome is not affected.
Device rotated during the flash The layout reflows. The flash timer continues and is not restarted. If the rotation animation hid more than 0.3 s of the flash, the task returns to the ready state as above.
Rotation after the flash Options and card reflow; state is kept.
Voice off Section 12.1.7; the numeral prompt is the closed-eye icon plus the tiles. Hint lines are replaced by the visual outlines and count-along highlighting; the running count appears as one numeral (child.numeral, 44 pt iPhone / 64 pt iPad) under the card, updated as each item lights up.
Reduce Motion Section 12.2.5 variant; durations unchanged.
Only one valid distractor exists (e.g. r5, n = 1, 3 options: candidates 2, 3) The algorithm still finds 2 distractors. The option count only shrinks when the bounded interval truly lacks values.
Engine plans n = 6 for a littleOnes child in r5 Cannot happen (active range intersection). The defensive clamp (Section 12.1.3) turns it into 5.
Replay used, then the app is interrupted The replay stays consumed; the downgrade rule still applies at step2/3.
VoiceOver running (adult assisting) The card's accessibility label is "Karte". During the flash the label is "{n} Punkte" so an assisting adult can read it aloud; the flash is extended to at least 3.0 s while VoiceOver runs.

12.2.16 Acceptance criteria #

  • AC-BB-01 Given a vorschule child at step1 and a planned number of 4, when the child taps the card, then a dice or Fünferfeld pattern of 4 is fully visible for 2.0 s (± 50 ms) and then hidden, and the answer options appear only after the pattern is hidden.
  • AC-BB-02 Given any step, when the pattern is hidden and the child waits 5 minutes without answering, then no answer is recorded, no countdown is ever shown, and only the Section 10 inactivity behaviours (demo hand, re-prompt, pause overlay) occur.
  • AC-BB-03 Given step2, when the child uses "Nochmal zeigen" and then answers correctly on the first attempt, then the recorded outcome is afterHint. Given step1 with the same actions, then the recorded outcome is firstTry.
  • AC-BB-04 Given the replay was used once in a task, when the pattern hides again, then the "Nochmal zeigen" button is no longer displayed for that task.
  • AC-BB-05 Given step3 and planned number 17, when the task is generated, then the representation is twentyFrame with row 1 full and 7 beads in row 2, and the options contain 17 and 7.
  • AC-BB-06 Given a fingers task for 7, when the pattern renders, then the left hand shows all five fingers and the right hand shows thumb and index finger extended.
  • AC-BB-07 Given the first answer was wrong, when hint level 1 runs, then the pattern re-flashes for 1.5 × flashSeconds with five-group outlines and hint.blitzblick.look_again plays.
  • AC-BB-08 Given three wrong answers, when the solution demonstration runs, then the pattern stays visible, the correct option is highlighted and the outcome recorded is shown after the child taps the highlighted option.
  • AC-BB-09 Given the same seed, planned number, step, level and parameters, when the generator runs twice, then both results are equal.
  • AC-BB-10 Given a littleOnes child, when the engine plans rounds for Blitzblick, then no task is at step3.

12.3 Mehr oder weniger #

12.3.1 Purpose and skills #

Mehr oder weniger ("more or less") trains comparing quantities and numbers. The child compares two sets, two numerals, or a numeral against a set, and decides which is more / bigger, which is fewer / smaller, or whether both are equal ("gleich").

Field Value
GameID mehr_weniger
Display name Mehr oder weniger
Tier premium
Primary skill compare (weight 1.0)
Secondary skill subitize (weight 0.5), credited only when n is shown as a quantity (Section 12.3.12)
Swift target GameMehrOderWeniger

12.3.2 Level availability and limits #

Level Steps available Numbers per step
littleOnes step1, step2 (littleOnesMaxStep = 2) step1: 1–10; step2: 1–10 (each intersected with the active range)
vorschule step1, step2, step3 step1: 1–10; step2: 1–10; step3: 1–20

Decisions:

  • The "weniger" / "kleiner" question is introduced at step2 and never appears at step1.
  • The "gleich" button exists at every step except littleOnes step1.
  • Numerals appear only from vorschule step2 and littleOnes step2. At step1 both sides are always quantities.

12.3.3 Task types #

Type ID Left / right content Question Answer
setsMore quantity vs quantity "Wo sind mehr?" Tap the side with more, or "gleich"
setsLess quantity vs quantity "Wo sind weniger?" Tap the side with fewer, or "gleich"
numeralsBigger numeral vs numeral "Welche Zahl ist größer?" Tap the bigger numeral, or "gleich"
numeralsSmaller numeral vs numeral "Welche Zahl ist kleiner?" Tap the smaller numeral, or "gleich"
mixedMore numeral vs quantity "Wo sind mehr?" Tap the side with more, or "gleich"
mixedLess numeral vs quantity "Wo sind weniger?" Tap the side with fewer, or "gleich"

Equality is not a separate type. Any type can be generated as an equality task (both sides represent the same number), and then the correct answer is "gleich". Whenever the gleich button is present, the prompt always ends with the equality clause ("Oder sind es gleich viele?" / "Oder sind beide gleich?"). This way the clause never gives away an equality task.

12.3.4 Screen layout #

Two big side cards plus the gleich button.

Element iPhone SE portrait iPhone SE landscape iPad portrait and landscape
Side cards Stacked top and bottom, each 343 × 190 pt, 84 pt apart Side by side, each 280 × 260 pt, 76 pt apart (room for the gleich button) Side by side, each up to 360 × 420 pt (portrait) or 460 × 440 pt (landscape)
Gleich button 72 × 72 pt circle centered in the 84 pt gap between the cards 72 × 72 pt, centered between the cards 96 × 96 pt, centered between the cards
Objects inside a card Structured: size chosen so ten objects plus gaps fit the inner card width, capped at 30 pt; scattered: 0.9–1.1 × that size Same rule (about 24 pt) Same rule, capped at 48 pt
Numerals inside a card 96 pt SF Pro Rounded, centered Same 160 pt

Decision: portrait iPhone stacks the sides vertically because a row of ten 30 pt objects needs ~330 pt of width. The spoken questions never say "links" or "rechts", so stacking needs no different audio.

The gleich button shows two identical small stacks of three dots with a large "=" sign between them (pictogram, no text; the "=" is allowed here as a pictogram, Section 12.5.2). Accessibility label: "Gleich viele".

Each side card is one touch target (the whole card). Tapping inside the card anywhere counts as choosing that side.

12.3.5 Quantity arrangements #

  • Structured (structured): objects in rows with a 1.5× gap between five-groups. 1–10 is one row of up to ten; 11–20 is two rows of ten (first row full, second row holds n − 10). The object size follows the layout table so that a row of ten always fits. Objects are bead-like counters rendered with BeadView (red solid first five, blue Lochperle second five, per Section 19). For structured quantities, bead counters are always used.
  • Scattered (scattered): picture objects of one type from the counting-object catalogue (Section 11.1.4, e.g. Äpfel, Enten, Sterne), placed at random positions inside the card. Constraints: minimum center distance 1.25 × object size, minimum 8 pt margin to the card edge, no overlap; up to 200 placement attempts per object with the RNG, after which the generator restarts the side with a new seed derived from the task seed (seed &+ 1) up to 5 times. After 5 failed restarts, the side falls back to structured and logs .error.
  • Size decoy (step2 and step3, share per the parameters table): the side with fewer objects uses objects 1.4× larger and spreads them over the full card; the side with more objects uses 0.8× size, clustered in 70% of the card area. This counters the "bigger area = more" heuristic. Size decoys are only used for sets* types and never in equality tasks.
  • Both sides of one task use the same object type (or both beads).

12.3.6 Round structure #

Tasks per round: 5 (vorschule) or 4 (littleOnes). Per task:

  1. The prompt plays (intro line before the first task of the round: prompt.mehr_weniger.intro). The cards appear with the content already visible. There is no hiding in this game.
  2. The child taps a side card or the gleich button.
  3. Evaluation and feedback per Section 10; hints per Section 12.3.9; correct feedback per Section 12.3.11.

Equality tasks per round: at steps where the gleich button exists, exactly one task per round is an equality task (20% of a 5-task round; 25% of a 4-task round). Its position is chosen by the RNG from positions 2…last (never the first task of a round). At littleOnes step1 there are no equality tasks.

Correct side balance: the correct side (top/left vs bottom/right) is chosen with OptionOrder (Section 10.17.3) over the two side cards, so the same side is never correct in more than 2 consecutive tasks. The gleich button is not a slot; equality tasks are placed by the equality-slot rule above.

12.3.7 Task generation per step #

Given planned number n:

  1. Pick the task type from typeWeights (table below). The less/smaller variants are only possible where lessShare > 0: the "more" family becomes the "less" family with probability lessShare.
  2. If this is the round's equality slot: m = n.
  3. Otherwise pick the partner m uniformly from all values in [lo, hi] with minDiff ≤ |n − m| ≤ maxDiff. With probability diffOneShare, restrict to |n − m| = 1 when minDiff ≤ 1. If no candidate exists, relax minDiff by 1 (not below 1), then widen maxDiff to hi − lo.
  4. Assign n and m to the two sides by RNG, respecting the side-balance rule.
  5. For mixed* types, the RNG decides which of n/m is the numeral side.
  6. For quantity sides, pick the arrangement per scatteredShare: each quantity side independently becomes scattered with that probability. At step1 both are structured. Apply the size decoy with probability sizeDecoyShare when eligible.
Parameter littleOnes step1 littleOnes step2 vorschule step1 vorschule step2 vorschule step3
numberMin–numberMax 1–10 1–10 1–10 1–10 1–20
typeWeights setsMore 1.0 sets 0.8, numerals 0.2 sets 1.0 sets 0.6, numerals 0.3, mixed 0.1 sets 0.3, numerals 0.3, mixed 0.4
lessShare 0.0 0.3 0.0 0.4 0.5
gleichButton false true true true true
Equality tasks per round 0 1 1 1 1
minDiff / maxDiff 2 / 4 1 / 4 2 / 5 1 / 5 1 / 8
diffOneShare 0.0 0.3 0.0 0.4 0.5
scatteredShare (per quantity side) 0.0 0.5 0.0 0.6 0.6
sizeDecoyShare 0.0 0.2 0.0 0.3 0.3
Quantities 11–20 n/a n/a n/a n/a structured only (two rows of ten)

"sets", "numerals" and "mixed" each mean the more/bigger variant, or the less/smaller variant with probability lessShare.

enum CompareTaskType: String, Codable, Sendable {
    case setsMore, setsLess, numeralsBigger, numeralsSmaller, mixedMore, mixedLess
}
enum SideContent: Sendable, Equatable {
    case quantity(count: Int, arrangement: Arrangement, objectID: String, sizeFactor: Double)
    case numeral(Int)
}
enum Arrangement: String, Codable, Sendable { case structured, scattered }
enum CompareAnswer: Sendable, Equatable { case first, second, equal }   // first = top (portrait) / left

struct MehrOderWenigerTaskContent: Sendable, Equatable {
    let number: Int                 // planned n (credited)
    let partner: Int                // m (never credited)
    let type: CompareTaskType
    let first: SideContent
    let second: SideContent
    let showsEqualButton: Bool
    let correct: CompareAnswer
    let scatterPositions: [[CGPoint]] // normalized 0...1 per side, empty for structured/numeral
}

12.3.8 Prompts #

Audio ID German text When Visual equivalent
prompt.mehr_weniger.intro "Schau dir beide Seiten an." First task of the round Both cards pulse once in turn
prompt.mehr_weniger.more "Wo sind mehr?" setsMore, mixedMore Pictogram above the cards: a tall stack beside a short stack, arrow pointing to the tall stack
prompt.mehr_weniger.less "Wo sind weniger?" setsLess, mixedLess Same pictogram, arrow pointing to the short stack
prompt.mehr_weniger.bigger "Welche Zahl ist größer?" numeralsBigger Pictogram: a large and a small numeral block, arrow to the large one
prompt.mehr_weniger.smaller "Welche Zahl ist kleiner?" numeralsSmaller Same, arrow to the small one
prompt.mehr_weniger.or_equal "Oder sind es gleich viele?" Appended to more/less when the gleich button exists Gleich button pulses once
prompt.mehr_weniger.or_equal_numbers "Oder sind beide gleich?" Appended to bigger/smaller when the gleich button exists Gleich button pulses once
hint.mehr_weniger.look_groups "Schau auf die Fünfer." Hint level 1 for quantity sides Five-group outlines
hint.mehr_weniger.show_amounts "So viele sind das." Hint level 1 for numeral sides Quantity strips appear under numerals
hint.mehr_weniger.pair_up "Wir legen sie nebeneinander." Hint level 2 Pairing animation
hint.mehr_weniger.number_line "Auf dem Zahlenstrahl: Weiter hinten ist mehr." Hint level 2, numeral-only tasks Number-line strip with both numbers marked and an arrow pictogram pointing towards the larger end

Solution and confirmation sentences use the Section 21 fragments (IDs and texts owned there):

Task Sentence Fragments
sets* and mixed*, "mehr" asked "{a} ist mehr als {b}." (a = larger, the correct side is highlighted) num.<a> + fb.solution.ist_mehr_als + num.<b>
sets* and mixed*, "weniger" asked "{b} ist weniger als {a}." (b = smaller, the correct side is highlighted) num.<b> + fb.solution.ist_weniger_als + num.<a>
numeralsBigger "{a} ist größer als {b}." (a = larger) num.<a> + fb.solution.ist_groesser_als + num.<b>
numeralsSmaller "{a} ist kleiner als {b}." (a = smaller) num.<a> + fb.solution.ist_kleiner_als + num.<b>
Equality, every task type "Gleich viele! {n} und {n}." fb.solution.gleich_viele + num.<n> + prompt.common.und + num.<n>

The spoken lines never say "links" or "rechts"; the side is always shown by highlighting.

12.3.9 Hints #

  • Hint level 1:
    • Quantity sides: hint.mehr_weniger.look_groups plays. Structured sides get a soft outline around each five-group. Scattered sides re-arrange into structured rows of five over 0.6 s (objects glide to row positions; Reduce Motion: cross-fade).
    • Numeral sides: hint.mehr_weniger.show_amounts plays and a structured bead strip showing the numeral's quantity appears under each numeral.
    • The wrongly chosen card or gleich button fades and becomes non-interactive per Section 10.6.3 (35 % opacity).
    • In a two-sided task without the gleich button (littleOnes step1), one wrong side leaves only the other side; choosing it records afterHint (the same rule as a two-option task in Section 11.4.11). Hint level 1 still plays before that choice.
  • Hint level 2: hint.mehr_weniger.pair_up plays and the two quantities (numeral sides use their bead strips) align into two parallel rows, one row per side, pairing items one to one. Pairs are joined by a thin ink line, and unpaired items on the larger side glow softly (warm yellow #F5B82E halo). For equality, every item is paired and nothing glows. For numeral-only tasks at step3 with any number > 10, hint.mehr_weniger.number_line plays instead, and a number-line strip (unlabeled start, stones 1–20 coded per Section 4.8) appears under the cards with both numbers marked by ink rings. The pairing and the strip stay visible until the task ends.

12.3.10 Solution demonstration #

After the third wrong answer: the hint level 2 visual stays, the correct target (side card or gleich button) gets the soft-green ring and gentle pulse, and the matching solution sentence of Section 12.3.8 plays. The child taps the highlighted target to continue. The outcome is shown.

12.3.11 Correct feedback #

On a correct answer the pairing animation from hint level 2 plays in 0.8 s (Reduce Motion: cross-fade into paired rows). The extra items glow, or for equality the "=" pictogram appears between the cards. Then the matching sentence of Section 12.3.8 is spoken as confirmation (e.g. "Sieben ist mehr als vier." or "Neun ist größer als sechs."), followed by the framework's fb.correct.* line (Section 10). The total feedback time stays within the Section 10 feedback budget. Where the sentence would exceed it, the sentence is spoken and the fb.correct.* line is skipped.

12.3.12 Mastery credit #

  • Credited number: only the planned number n. The partner m is never credited.
  • compare (1.0) is credited for n on every task.
  • subitize (0.5) is credited for n only when n is shown as a quantity (the sets* types, and mixed* types where n is the quantity side). When n is shown as a numeral, the task's secondarySkill is nil. Decision: numerals exercise no Simultanerfassung, so crediting it would inflate subitize mastery. The GameTask model (Section 10) must allow secondarySkill to be nil per task.

12.3.13 Parameters: games/mehr_weniger.json #

{
  "schemaVersion": 1,
  "gameId": "mehr_weniger",
  "tier": "premium",
  "primarySkill": "compare",
  "secondarySkill": "subitize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": 80,
  "littleOnesMaxStep": 2,
  "promptIds": ["prompt.mehr_weniger.intro", "prompt.mehr_weniger.more", "prompt.mehr_weniger.less", "prompt.mehr_weniger.bigger", "prompt.mehr_weniger.smaller", "prompt.mehr_weniger.or_equal", "prompt.mehr_weniger.or_equal_numbers"],
  "hintIds": {
    "level1": ["hint.mehr_weniger.look_groups", "hint.mehr_weniger.show_amounts"],
    "level2": ["hint.mehr_weniger.pair_up", "hint.mehr_weniger.number_line"]
  },
  "solutionIds": ["fb.solution.ist_mehr_als", "fb.solution.ist_weniger_als", "fb.solution.ist_groesser_als", "fb.solution.ist_kleiner_als", "fb.solution.gleich_viele"],
  "steps": [
    {
      "id": "step1", "labelKey": "game.step.leicht", "numberMin": 1, "numberMax": 10,
      "parameters": {
        "typeWeights": { "sets": 1.0 },
        "lessShare": 0.0,
        "gleichButton": true,
        "equalTasksPerRound": 1,
        "minDiff": 2,
        "maxDiff": 5,
        "diffOneShare": 0.0,
        "scatteredShare": 0.0,
        "sizeDecoyShare": 0.0
      },
      "parametersByLevel": {
        "littleOnes": { "gleichButton": false, "equalTasksPerRound": 0, "maxDiff": 4 }
      }
    },
    {
      "id": "step2", "labelKey": "game.step.mittel", "numberMin": 1, "numberMax": 10,
      "parameters": {
        "typeWeights": { "sets": 0.6, "numerals": 0.3, "mixed": 0.1 },
        "lessShare": 0.4,
        "gleichButton": true,
        "equalTasksPerRound": 1,
        "minDiff": 1,
        "maxDiff": 5,
        "diffOneShare": 0.4,
        "scatteredShare": 0.6,
        "sizeDecoyShare": 0.3
      },
      "parametersByLevel": {
        "littleOnes": {
          "typeWeights": { "sets": 0.8, "numerals": 0.2 },
          "lessShare": 0.3,
          "maxDiff": 4,
          "diffOneShare": 0.3,
          "scatteredShare": 0.5,
          "sizeDecoyShare": 0.2
        }
      }
    },
    {
      "id": "step3", "labelKey": "game.step.schwer", "numberMin": 1, "numberMax": 20,
      "parameters": {
        "typeWeights": { "sets": 0.3, "numerals": 0.3, "mixed": 0.4 },
        "lessShare": 0.5,
        "gleichButton": true,
        "equalTasksPerRound": 1,
        "minDiff": 1,
        "maxDiff": 8,
        "diffOneShare": 0.5,
        "scatteredShare": 0.6,
        "sizeDecoyShare": 0.3,
        "teenArrangement": "structured"
      }
    }
  ]
}

Level differences are expressed with parametersByLevel (Section 8.8.3); step3 is never reached by littleOnes (littleOnesMaxStep = 2). Validation: every share must be in 0…1; minDiff ≤ maxDiff in the effective parameters of each level; equalTasksPerRound ≤ 1; gleichButton must be true wherever equalTasksPerRound is 1.

12.3.14 Edge cases #

Case Behaviour
n = 1 and minDiff = 2 in r5 m ∈ {3, 4, 5}; valid.
n = 20 in step3 m ∈ [12, 19]; 20 is shown as two full rows.
Equality slot with a numeral type Both numerals are identical (e.g. 7 and 7); prompt ends with or_equal_numbers; the confirmation is "Gleich viele! Sieben und sieben."
Child taps both cards simultaneously (two fingers) Multi-touch rejection per Section 10: the first touch-down wins, the second is ignored.
Child taps the gleich button when it is not shown Impossible (not rendered). The area is inert card gap.
Scattered placement fails 5 times Side falls back to structured; logged; the task remains valid.
Rotation mid-task The cards re-layout. Scattered positions are normalized coordinates, so the same relative positions are kept; object size is recomputed; pairing lines are re-drawn.
Voice off The prompt pictogram (Section 12.3.8) is the only question carrier. The gleich button pulses once when present.
Reduce Motion Hint re-arrangements and pairing use cross-fades (0.3 s).
Size decoy in an equality task Never generated.

12.3.15 Acceptance criteria #

  • AC-MW-01 Given vorschule step1, when 20 rounds are generated with a fixed seed sequence, then no task uses the "weniger" or "kleiner" question and every quantity is structured.
  • AC-MW-02 Given a 5-task round at any step with the gleich button, when the round is generated, then exactly one task has both sides equal, and it is not the first task.
  • AC-MW-03 Given an equality task with two quantity sides, when the child taps the gleich button, then the answer is evaluated as correct, the "=" pictogram appears between the cards, and the equality sentence "Gleich viele! {n} und {n}." plays.
  • AC-MW-04 Given a non-equality task with the gleich button visible, when the child taps it, then it is a wrong attempt and hint level 1 runs.
  • AC-MW-05 Given littleOnes step1, when the task renders, then no gleich button exists and no equality task is generated in the round.
  • AC-MW-06 Given iPhone SE portrait, when a quantity of 10 is shown structured, then all objects fit in the card without overlap and the five-groups are separated by a 1.5× gap.
  • AC-MW-07 Given a numeralsBigger task where n is a numeral, when it completes firstTry, then compare for n is updated with weight 1.0 and no subitize update is recorded.
  • AC-MW-08 Given a second wrong attempt on a quantity task, when hint level 2 runs, then the quantities align into paired rows and the surplus items glow.
  • AC-MW-09 Given a size-decoy task, when it renders, then the side with fewer items uses objects 1.4× larger than the side with more items.
  • AC-MW-10 Given a first wrong answer on any side card or the gleich button, when hint level 1 runs, then the chosen target is at 35 % opacity and tapping it again has no effect and records no attempt.
  • AC-MW-11 Given littleOnes step1 (no gleich button) and a wrong first choice, when the child then taps the other side, then the task completes with outcome afterHint.

12.4 Nachspuren #

12.4.1 Purpose and skills #

Nachspuren ("trace along") teaches writing the numerals in the stroke order and direction taught in German primary schools (print numerals compatible with the Grundschrift and the common Druckschrift school sheets). The child traces with a finger. Apple Pencil is an optional enhancement on iPad.

Field Value
GameID nachspuren
Display name Nachspuren
Tier premium
Primary skill write (weight 1.0)
Secondary skill recognize (weight 0.5)
Swift target GameNachspuren

12.4.2 Level availability and limits #

Level Steps available Numbers
littleOnes step1 only (littleOnesMaxStep = 1) 1–5 only, whatever the active range
vorschule step1, step2, step3 step1: 1–10; step2: 1–20; step3: 1–20
  • write is active for littleOnes only through Nachspuren step1 with numbers 1–5 (Section 4).
  • write mastery is never required for a Zahlenfreund (Section 14).
  • Numbers 10–20 are traced as two digits, tens digit first ("1" then "4" for 14; "2" then "0" for 20). Only the ten digits 0–9 need stroke templates. Decision: 0 is traced only as part of 10 and 20. It is never a standalone task, because 0 is not a mastery number.

12.4.3 Glyph templates (German print numerals) #

Coordinates are normalized in a glyph box: x 0…1 left to right, y 0…1 top to bottom. The box has aspect ratio width : height = 0.6 : 1, with y = 0 at the cap line and y = 1 at the baseline. Each stroke is a polyline of key points. At runtime each stroke is converted to a centripetal Catmull-Rom spline through the key points (straight segments stay straight because collinear key points produce straight spline segments) and resampled to 64 points equally spaced by arc length. These 64 points are the template samples for the stroke.

Digit Strokes Stroke order and direction (German school convention)
1 1 Short up-stroke from the middle-left to the top, then straight down to the baseline, without lifting. No foot, no base bar.
2 1 Start upper left, arc up and over to the right (clockwise), straight diagonal down to the bottom left, then straight right along the baseline.
3 1 Start upper left, arc clockwise over the top to the middle, then a second, larger clockwise arc from the middle around the bottom right, ending lower left.
4 2 Stroke 1: from the top, slanted down to the left, then straight right (open top; the two strokes do not meet at the top). Stroke 2: lift, then from above the crossbar straight down through it to the baseline.
5 2 Stroke 1: short straight line down from the top left, then the round belly clockwise, ending lower left. Stroke 2: lift, then the top bar from left to right. (Down-stroke with belly first, top bar second.)
6 1 Start top right, sweep down to the left in a large curve to the bottom, then close the loop counter-clockwise, ending at the left middle.
7 1 Top bar from left to right, then without lifting the diagonal down to the bottom left-of-center. No crossbar.
8 1 One continuous stroke. Start top right, arc counter-clockwise over the top to the left, down diagonally through the center to the lower right, around the bottom (clockwise) to the lower left, back up through the center to the start.
9 1 Start at the right of the upper loop, go counter-clockwise around the loop (over the top, down the left, along the bottom), close at the right, then straight down to the baseline.
0 1 Start at the top center, counter-clockwise (first towards the left), all the way around, closing at the top.

The key points below are shipped in games/nachspuren.json under gameParameters.glyphs (shared by all steps), so stroke shapes can be tuned in data without code changes:

{
  "glyphAspect": 0.6,
  "samplesPerStroke": 64,
  "glyphs": {
    "0": [[[0.50,0.02],[0.33,0.06],[0.19,0.16],[0.09,0.32],[0.06,0.50],[0.09,0.68],[0.19,0.84],[0.33,0.94],[0.50,0.98],[0.67,0.94],[0.81,0.84],[0.91,0.68],[0.94,0.50],[0.91,0.32],[0.81,0.16],[0.67,0.06],[0.50,0.02]]],
    "1": [[[0.18,0.30],[0.40,0.15],[0.62,0.00],[0.62,0.50],[0.62,1.00]]],
    "2": [[[0.13,0.23],[0.20,0.13],[0.31,0.06],[0.46,0.02],[0.61,0.03],[0.74,0.08],[0.83,0.17],[0.88,0.27],[0.86,0.38],[0.79,0.48],[0.30,0.80],[0.08,1.00],[0.50,1.00],[0.92,1.00]]],
    "3": [[[0.18,0.12],[0.30,0.05],[0.45,0.02],[0.61,0.04],[0.74,0.10],[0.82,0.19],[0.82,0.30],[0.75,0.39],[0.63,0.46],[0.47,0.48],[0.64,0.48],[0.78,0.55],[0.86,0.65],[0.86,0.77],[0.80,0.87],[0.67,0.95],[0.50,0.99],[0.33,0.97],[0.19,0.91]]],
    "4": [[[0.48,0.00],[0.29,0.33],[0.10,0.66],[0.51,0.66],[0.92,0.66]],
          [[0.70,0.30],[0.70,0.65],[0.70,1.00]]],
    "5": [[[0.24,0.00],[0.23,0.25],[0.23,0.50],[0.39,0.43],[0.57,0.42],[0.73,0.48],[0.84,0.58],[0.88,0.71],[0.83,0.84],[0.70,0.94],[0.53,0.98],[0.35,0.96],[0.21,0.88]],
          [[0.24,0.00],[0.55,0.00],[0.85,0.00]]],
    "6": [[[0.66,0.06],[0.51,0.02],[0.35,0.08],[0.22,0.23],[0.13,0.46],[0.10,0.72],[0.18,0.87],[0.38,0.97],[0.63,0.98],[0.84,0.89],[0.94,0.74],[0.88,0.58],[0.70,0.48],[0.45,0.45],[0.22,0.53],[0.11,0.67]]],
    "7": [[[0.10,0.00],[0.50,0.00],[0.90,0.00],[0.64,0.50],[0.38,1.00]]],
    "8": [[[0.78,0.14],[0.62,0.03],[0.42,0.03],[0.25,0.12],[0.22,0.26],[0.32,0.38],[0.50,0.47],[0.70,0.57],[0.84,0.72],[0.80,0.88],[0.62,0.98],[0.40,0.98],[0.20,0.88],[0.16,0.72],[0.30,0.57],[0.50,0.47],[0.68,0.38],[0.78,0.26],[0.78,0.14]]],
    "9": [[[0.82,0.23],[0.75,0.12],[0.63,0.04],[0.47,0.02],[0.32,0.06],[0.22,0.16],[0.18,0.28],[0.22,0.40],[0.32,0.50],[0.47,0.54],[0.63,0.52],[0.75,0.44],[0.82,0.33],[0.82,0.65],[0.82,1.00]]]
  }
}

The first key point of each stroke is its start point (numbered start dot), and the last key point is its end. Where a key point repeats (the waist of 8 at [0.50, 0.47]; the closing points of 0 and 8), the validation algorithm's forward-window search (Section 12.4.7) resolves the ambiguity.

Verification step for the executor: before content lock (Section 27), compare the rendered templates side by side with a current German Grundschrift numeral sheet and a common Druckschrift school sheet. If a shape differs visibly, adjust only the key points in games/nachspuren.json. Stroke counts, start points and directions in the table above are binding and must not change.

Decorative rendering (the thick guide path, dotted outline, demo animation) uses the same resampled template, drawn with round caps and joins.

12.4.4 Screen layout #

Element iPhone SE portrait iPhone SE landscape iPad portrait iPad landscape
Single-digit glyph box Height 300 pt, width 180 pt, centered horizontally, top 24 pt below the top bar Height 260 pt, width 156 pt, centered in the left 65% of the task area Height 520 pt, width 312 pt, centered Height 480 pt, width 288 pt, centered in the left 65%
Two-digit glyph boxes (10–20) Height 240 pt each, width 144 pt, 24 pt gap, centered (total 312 pt) Height 250 pt, 150 pt wide, 24 pt gap Height 460 pt, width 276 pt, 40 pt gap Height 440 pt, 264 pt wide, 40 pt gap
Writing lines A baseline (y = 1) in ink at 25% opacity and a cap line (y = 0) at 15% opacity, extending 16 pt beyond the boxes on both sides Same Same Same
Quantity strip Below the boxes: BeadChainView of n beads, bead diameter 22 pt, shown after completion only Right 35% of the task area, vertical center, shown after completion Below the boxes, 36 pt beads Right 35%

Decision: there is no erase or restart button. A failed stroke erases itself (Section 12.4.7). An eraser adds a control without a clear meaning for pre-readers.

The ink canvas (PKCanvasView) covers the union of the glyph boxes plus a 40 pt margin on every side. Touches outside the canvas are ignored for tracing. The top bar remains usable.

12.4.5 Step presentation #

Aspect step1 "Leicht" step2 "Mittel" step3 "Schwer"
Guide path Thick grey-cream path (width = 0.16 × box height), round caps Thin path (width = 0.05 × box height), ink at 30% opacity Dotted outline (dots 0.03 × box height every 0.07 × box height along the path), ink 40%. Fades to 0% over 1.0 s starting 2.0 s after the prompt ends
Start dots Numbered start dot for every stroke ("1", "2"), 36 pt circle, warm yellow #F5B82E Start dot for the current stroke only, 28 pt, no number None (only after hint level 1)
Direction arrows Arrows along the path every 0.25 of stroke length, pointing in the writing direction None None
Demo at task start Animated demo: a glowing dot travels each stroke at 0.35 box-heights per second, strokes in order, ink appears behind it; then it fades Same demo, once No demo
Ink width (child's ink) 0.10 × box height 0.07 × box height 0.06 × box height
Start tolerance at 300 pt reference box height 44 pt 32 pt 24 pt
Corridor tolerance at 300 pt reference 36 pt 28 pt 22 pt
Coverage required ≥ 80% ≥ 80% ≥ 80%
Progress required ≥ 0.90 ≥ 0.90 ≥ 0.90

Tolerances scale linearly with the actual box height H (tolerance × H / 300), with floors: start tolerance never below 24 pt and corridor tolerance never below 18 pt. This keeps finger tracing fair on small boxes. The iPhone SE two-digit boxes (240 pt) therefore use step1 start tolerance 35.2 pt and corridor 28.8 pt.

Ink color: ink (#2B2A33) at 60 % opacity while drawing; an accepted stroke turns to ink at full opacity and sfx.trace_stroke plays. Decision: accepted strokes never turn blue, green or red. Bead red and bead blue mean only "first five / second five" (Section 4, DR-16), and green is reserved for the success ring.

12.4.6 Ink capture with PencilKit #

  • Component: a UIViewRepresentable wrapper around PKCanvasView, created once per task.
  • drawingPolicy = .anyInput (finger and Pencil). If the device setting "Nachspuren nur mit Apple Pencil" is on (Section 16.8; default off; delivered to the game in GameSettingsSnapshot, Section 10.3.4, because games never read settings storage), use .pencilOnly. Finger input is then ignored on the canvas, and a small pencil pictogram shows in the prompt area.
  • Tool: PKInkingTool(.monoline, color: ink, width: inkWidth). .monoline (available at the deployment target, Section 5) gives uniform width regardless of pressure or tilt. Pressure, azimuth and altitude are never used by validation. The tool picker is never shown. isRulerActive = false.
  • isScrollEnabled = false, minimumZoomScale = maximumZoomScale = 1, bouncesZoom = false. The canvas background is clear; the guide layers are SwiftUI views beneath it.
  • Stroke completion: the coordinator observes canvasViewDrawingDidChange(_:). When drawing.strokes.count increases, the newest PKStroke is validated. Its points are read with stroke.path.interpolatedPoints(by: .distance(4)) (location only), then converted from canvas coordinates to glyph-box coordinates.
  • Strokes whose total length is < 8 pt are discarded silently (taps, palm touches, accidental contact). They are removed from the drawing and are not attempts.
  • Palm rejection: when an Apple Pencil is used, PencilKit's system palm rejection applies. With .anyInput, a resting palm on iPad can create strokes. Mitigations: the 8 pt minimum length; strokes that start outside the canvas's glyph area plus margin are ignored; and a stroke touching more than 2 glyph boxes' worth of width in under 0.1 s is discarded as a palm swipe. Verification step for the executor: test with a resting palm on an iPad with and without Pencil and confirm that no attempt is recorded from palm contact.
  • Only one stroke at a time: PencilKit records a single inking stroke per touch sequence. A second simultaneous finger does not start a second validated stroke. If two strokes are appended in one change callback, only the first is validated and the second is removed.
  • Finger is the primary input. The game never asks for or requires a Pencil. No Pencil-specific audio exists.

12.4.7 Validation algorithm #

All geometry is in points within the glyph box of the digit being written. For a template stroke T with 64 samples T[0…63], a user stroke U (points U[0…u−1], spaced about 4 pt apart), start tolerance rs and corridor tolerance rc:

validateStroke(U, T, rs, rc):
  1. Start check:   d(U[0], T[0]) <= rs                                 else fail(.wrongStart)
  2. Progress walk: c = 0
                    for p in U:
                        window = T[max(0,c-4) ... min(63,c+12)]
                        j = index in window minimizing d(p, T[j])
                        if d(p, T[j]) <= rc: c = max(c, j)
                    progress = c / 63
                    progress >= 0.90                                    else fail(.incomplete or .wrongDirection)
                      (.wrongDirection if the user's end point is within rs of T[0] and
                       the start point is within rs of T[63]; otherwise .incomplete)
  3. Coverage:      covered = count of T[i] with min_k d(T[i], U[k]) <= rc
                    covered / 64 >= 0.80                                else fail(.incomplete)
  4. Off-path:      stray = count of U[k] with min_i d(U[k], T[i]) > 2*rc
                    stray / u <= 0.20                                   else fail(.offPath)
  5. pass

The forward window (−4 … +12 samples) is what enforces direction and resolves self-intersections (8's waist, 0's closing point): progress can only advance along the template near the current position, so tracing backwards never advances c.

Stroke order for multi-stroke digits (4 and 5):

  • The expected stroke is always the lowest-numbered stroke not yet accepted.
  • If a user stroke fails against the expected stroke but passes against a later stroke of the same digit, the result is fail(.wrongOrder). The later stroke is not accepted out of order. Decision: order is part of what is taught.
  • Accepted strokes stay on the canvas (full-opacity ink). A failed stroke fades out over 0.4 s and is removed from the drawing.

Digit and task completion: a digit is complete when all its strokes are accepted. For two-digit numbers, the second glyph box becomes active only after the first digit is complete (the inactive box's guides are drawn at 30% of their normal opacity). The task is complete when all digits are complete.

Attempt counting: every failed stroke (wrongStart, wrongDirection, incomplete, offPath, wrongOrder) is one wrong attempt for the task. Wrong attempts accumulate across all strokes and digits of the task. The hint ladder (Section 10) runs on the task's cumulative wrong-attempt count. The outcome is therefore firstTry only if every stroke of every digit passed on the first try.

enum StrokeFailure: String, Sendable { case wrongStart, wrongDirection, incomplete, offPath, wrongOrder }
enum StrokeResult: Sendable, Equatable { case accepted(strokeIndex: Int), failed(StrokeFailure) }

struct StrokeValidator: Sendable {
    let startTolerance: CGFloat      // already scaled to the box, floors applied
    let corridorTolerance: CGFloat
    let requiredCoverage: Double     // 0.80
    let requiredProgress: Double     // 0.90
    let maxStrayShare: Double        // 0.20
    func validate(user: [CGPoint], template: [CGPoint]) -> StrokeResult
}

StrokeValidator is pure and unit-tested with synthetic strokes (Section 25): exact template, template reversed (must fail wrongDirection), template shifted by rc/2 (pass), shifted by 2 rc (fail), half-length stroke (fail incomplete), stroke 2 of "4" drawn first (fail wrongOrder), a scribble (fail offPath).

Validation runs synchronously on the main actor. With at most about 200 user points × 64 samples it takes well under 1 ms.

12.4.8 Round structure #

Tasks per round: 5 (vorschule) or 4 (littleOnes). Per task:

  1. The glyph boxes and guides appear. The prompt plays (prompt.nachspuren.intro on the first task of a round; then prompt.nachspuren.trace or prompt.nachspuren.write per step). At step1 and step2 the numeral is also shown in the prompt area as a small 44 pt reference. At step3 it is not shown, because the child writes from memory.
  2. The demo animation plays (step1 and step2). The child may start tracing at any time after the Section 10 input-lock period. Starting to trace stops the demo immediately.
  3. Strokes are validated as they finish. After each accepted stroke, sfx.trace_stroke plays (a gentle rising tone) and the next stroke's start dot appears. For two-digit numbers, after the first digit: "Und jetzt die {d}." (prompt.nachspuren.next_digit + num.<d>).
  4. When all strokes are accepted: correct feedback (Section 12.4.11).

12.4.9 Prompts #

Audio ID German text When Visual equivalent
prompt.nachspuren.intro "Wir schreiben Zahlen. Fahr mit dem Finger nach." First task of a round Demo animation
prompt.nachspuren.trace "Schreib die {n}." (num.<n>) step1, step2 Reference numeral + demo
prompt.nachspuren.write "Schreib die {n} ganz allein." (recorded as prompt.nachspuren.write + num.<n> + prompt.nachspuren.ganz_allein) step3 Fading dotted outline
prompt.nachspuren.next_digit "Und jetzt die {d}." (num.<d>; d = 0 uses num.0, declared by the zero entry of numbers.json, Section 8.6) Second digit of 10–20 Second box brightens
hint.nachspuren.start_here_top "Fang hier oben an." Hint level 1 after wrongStart or wrongOrder when the expected start point has y ≤ 0.3 Start dot pulses
hint.nachspuren.start_here "Fang hier an." Same, when the expected start point has y > 0.3 Start dot pulses
hint.nachspuren.follow_arrow "Fahr in Pfeilrichtung." Hint level 1 after wrongDirection Arrows appear
hint.nachspuren.stay_on_path "Bleib schön auf dem Weg." Hint level 1 after incomplete or offPath Path thickens
hint.nachspuren.watch_me "Schau, ich zeig es dir. Dann du." Hint level 2 Demo with hand
(solution, composed) "So schreibt man die {n}." (fb.solution.so_schreibt_man + num.<n>, Section 21) Solution Demo, then full guides
prompt.nachspuren.done "Die {n}! Schön geschrieben." (recorded as prompt.nachspuren.done + num.<n> + prompt.nachspuren.schoen_geschrieben) Correct feedback Digit glows, quantity strip

With the V1 glyphs every stroke starts at y ≤ 0.3, so start_here_top is always the line used. start_here exists for future glyph data with lower start points.

12.4.10 Hints #

  • Hint level 1 (first failed stroke of the task): the failed stroke fades. The hint line matching the failure reason plays (table above). The current step's guides are temporarily upgraded by one level for the rest of the task: step3 gains the thin path and start dot; step2 gains the arrows; step1 gets a pulsing start dot (2 pulses at 1 Hz, within the Section 19.9 limit).
  • Hint level 2 (second failed stroke): hint.nachspuren.watch_me plays and a demo hand (the Section 10 demo-hand asset) traces the current stroke at 0.30 box-heights per second, leaving ink that fades after 1.0 s. For the rest of the task the guides are the full step1 set (thick path, numbered start dots, arrows).
  • A stroke accepted after a hint does not reset the ladder. The count is per task.

12.4.11 Solution demonstration and correct feedback #

  • Solution (third failed stroke): the app writes the whole remaining digit (and a following digit, if any), stroke by stroke, with the demo hand and full-opacity ink, while "So schreibt man die {n}." plays. Then the written number pulses with the soft-green ring, and the child taps it to continue (Section 10). The outcome is shown. Accepted strokes before the solution stay.
  • Correct: all ink is at full opacity, the digit(s) get a soft glow (0.6 s), prompt.nachspuren.done plays with num.<n>, and the quantity strip of n beads (five-structured, Section 19) slides in next to the numeral. This links numeral, spoken word and quantity. The framework's fb.correct.* line is skipped in this game, because prompt.nachspuren.done replaces it. Reduce Motion: the strip fades in; no glow pulse.

12.4.12 Task generation per step #

Given planned number n:

  1. The digits are the decimal digits of n ([n] for 1–9, [1, n − 10] for 11–19, [1, 0] for 10, [2, 0] for 20).
  2. The step determines the presentation (Section 12.4.5).
  3. There is nothing random except the choice of prompt variant. The task is fully determined by n and step.
Parameter littleOnes step1 vorschule step1 vorschule step2 vorschule step3
numberMin–numberMax 1–5 1–10 1–20 1–20
guide thickArrows thickArrows thinStartDot dottedFade
demoAtStart true true true false
startTolerancePt (at 300) 44 44 32 24
corridorTolerancePt (at 300) 36 36 28 22
coverage 0.80 0.80 0.80 0.80
progress 0.90 0.90 0.90 0.90
maxStrayShare 0.20 0.20 0.20 0.20
fadeDelaySeconds / fadeSeconds n/a n/a n/a 2.0 / 1.0

Decision for littleOnes: the corridor tolerance is multiplied by 1.25 (littleOnesCorridorFactor) because motor control at ages 2–4 is less precise. Coverage and order rules are unchanged.

12.4.13 Parameters: games/nachspuren.json #

{
  "schemaVersion": 1,
  "gameId": "nachspuren",
  "tier": "premium",
  "primarySkill": "write",
  "secondarySkill": "recognize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": 120,
  "littleOnesMaxStep": 1,
  "promptIds": ["prompt.nachspuren.intro", "prompt.nachspuren.trace", "prompt.nachspuren.write", "prompt.nachspuren.next_digit", "prompt.nachspuren.done"],
  "hintIds": {
    "level1": ["hint.nachspuren.start_here", "hint.nachspuren.start_here_top", "hint.nachspuren.follow_arrow", "hint.nachspuren.stay_on_path"],
    "level2": ["hint.nachspuren.watch_me"]
  },
  "solutionIds": ["fb.solution.so_schreibt_man"],
  "gameParameters": {
    "glyphAspect": 0.6,
    "samplesPerStroke": 64,
    "referenceBoxHeightPt": 300,
    "minStartTolerancePt": 24,
    "minCorridorTolerancePt": 18,
    "minStrokeLengthPt": 8,
    "littleOnesCorridorFactor": 1.25,
    "glyphs": { "1": [[[0.18,0.30],[0.40,0.15],[0.62,0.00],[0.62,0.50],[0.62,1.00]]] }
  },
  "steps": [
    { "id": "step1", "labelKey": "game.step.leicht", "numberMin": 1, "numberMax": 10,
      "numberRangeByLevel": { "littleOnes": { "min": 1, "max": 5 } },
      "parameters": { "guide": "thickArrows", "demoAtStart": true, "startTolerancePt": 44, "corridorTolerancePt": 36, "coverage": 0.80, "progress": 0.90, "maxStrayShare": 0.20 } },
    { "id": "step2", "labelKey": "game.step.mittel", "numberMin": 1, "numberMax": 20,
      "parameters": { "guide": "thinStartDot", "demoAtStart": true, "startTolerancePt": 32, "corridorTolerancePt": 28, "coverage": 0.80, "progress": 0.90, "maxStrayShare": 0.20 } },
    { "id": "step3", "labelKey": "game.step.schwer", "numberMin": 1, "numberMax": 20,
      "parameters": { "guide": "dottedFade", "demoAtStart": false, "startTolerancePt": 24, "corridorTolerancePt": 22, "coverage": 0.80, "progress": 0.90, "maxStrayShare": 0.20, "fadeDelaySeconds": 2.0, "fadeSeconds": 1.0 } }
  ]
}

gameParameters is the optional game-level object of the envelope (Section 8.8.1); its keys are owned here and validated by this game's content schema. The glyphs object in the example is abbreviated to digit 1. The shipped file contains all ten digits exactly as listed in Section 12.4.3. Validation: every digit 0–9 is present; every stroke has ≥ 2 key points with coordinates in 0…1; tolerances > 0; 0 < coverage ≤ 1; 0 < progress ≤ 1.

12.4.14 Mastery credit #

Each task credits write (1.0) and recognize (0.5) for n. Digits of teen numbers are not credited separately (tracing 14 credits 14, not 1 and 4). For littleOnes, write records exist only for 1–5, and Section 14's Zahlenfreund rule never requires write.

12.4.15 Edge cases #

Case Behaviour
Child lifts the finger mid-stroke and continues The lifted part is validated as a complete stroke and usually fails (incomplete), counting as one wrong attempt. Decision: to stay forgiving, a stroke that fails only incomplete with progress ≥ 0.4 and is followed within 0.6 s by a new stroke starting within rc of where the previous one ended is merged with it. The merged stroke is validated as one. Only if the merged result fails does one wrong attempt count.
Child draws outside the glyph box Stroke points outside the box still count for off-path. The stroke is discarded if it starts outside the canvas area.
Child taps the start dot A stroke < 8 pt is discarded, not an attempt.
Pencil and finger alternate Both accepted under .anyInput; no difference in validation.
"Nachspuren nur mit Apple Pencil" on, iPhone The setting is shown only on iPad (Section 16.8). On iPhone it is ignored (.anyInput).
"Nachspuren nur mit Apple Pencil" on, iPad, no Pencil paired The child cannot trace. Decision: if no Pencil touch has been received in the first 10 s of a task while pencil-only is on, the canvas switches to .anyInput for the rest of the session and logs .info. The setting is not changed.
Rotation mid-stroke The in-progress stroke is cancelled and removed (not an attempt). Accepted strokes are re-drawn from their stored glyph-normalized points in the new box geometry.
Backgrounding mid-task Section 10 pause. Accepted strokes are kept, and the in-progress stroke is discarded.
Reduce Motion The demo dot still moves, because it carries the instruction. The glow and slide-ins are replaced by fades.
Voice off Hint reasons are shown only through the guide upgrades (pulsing start dot, arrows, thickened path).
Left-handed child No special handling needed: templates are direction-based, and the tolerances are symmetric.
Digit 0 as the second digit of 10/20 Uses the 0 template. The prompt next_digit speaks "Und jetzt die Null." (num.0, declared by the zero entry of numbers.json, Section 8.6, and listed in Section 21.2).
Very fast scribble covering the whole box Coverage may pass but off-path fails (stray > 20%) and the start check usually fails. It is not accepted.

12.4.16 Acceptance criteria #

  • AC-NS-01 Given a synthetic stroke equal to the template of "7" at step2, when validated, then it is accepted. Given the same stroke reversed, then it fails with wrongDirection.
  • AC-NS-02 Given digit "4" at step1, when the child first draws stroke 2 (the vertical), then the result is wrongOrder, the stroke fades, and hint.nachspuren.start_here_top plays with stroke 1's start dot pulsing.
  • AC-NS-03 Given a task for 14, when the task starts, then two glyph boxes appear, only the "1" box is active, and the "4" box activates after the "1" is complete.
  • AC-NS-04 Given a stroke whose start is 30 pt from the template start at step3 on a 300 pt box, when validated, then it fails wrongStart. Given step1, then the start check passes.
  • AC-NS-05 Given a stroke that covers 75% of the template samples within the corridor, when validated, then it fails incomplete. At 85% (and passing the other checks), it is accepted.
  • AC-NS-06 Given a littleOnes child, when the engine plans Nachspuren tasks, then only step1 and numbers 1–5 occur, whatever the active range.
  • AC-NS-07 Given a touch shorter than 8 pt on the canvas, when it ends, then no attempt is recorded and no hint plays.
  • AC-NS-08 Given the task's third failed stroke, when the solution runs, then the app writes the remaining strokes, the child taps the number to continue, and the outcome is shown.
  • AC-NS-09 Given an Apple Pencil stroke with varying pressure along the template path, when validated, then the result is identical to a finger stroke along the same points.
  • AC-NS-10 Given step3, when the task starts, then the dotted outline is visible, starts fading 2.0 s after the prompt ends, and is invisible 1.0 s later, and no start dot is shown.

12.5 Schüttelbox #

12.5.1 Purpose and skills #

The Schüttelbox ("shaker box") is the digital version of a classroom shaker box used in German preschool and first-grade maths. A box with a divider holds N beads. The child shakes it (or taps the shake button), opens the lid, and the beads have fallen into two halves. The child finds the split, for example 7 as 3 and 4, which builds Zahlzerlegung (number decomposition) and part–whole understanding.

Field Value
GameID schuettelbox
Display name Schüttelbox
Tier premium
Primary skill decompose (weight 1.0)
Secondary skill subitize (weight 0.5)
Swift target GameSchuettelbox

12.5.2 Level availability and limits #

Level Steps available Numbers N
littleOnes step1, step2 (littleOnesMaxStep = 2) 2–5 only (decompose is active for littleOnes only for 2–5, Section 4); declared as numberRangeByLevel.littleOnes on step1 and step2
vorschule step1, step2, step3 step1: 2–10; step2: 2–10; step3: 5–10

Decisions:

  • Schüttelbox covers N = 2–10 in V1. N = 1 cannot be split into two non-empty parts, and the game never plans it.
  • V1 trains decomposition for 2–10 (vorschule) and 2–5 (littleOnes), as Section 4 (DR-30) states. Decomposition of 11–20 (e.g. 14 = 10 + 4) is not a Schüttelbox task in V1: a box of 20 beads is too crowded on iPhone SE. decompose for 11–20 therefore has no V1 task source. The engine's candidate filter (Section 9.4) excludes decompose × 11–20, and Section 14's Zahlenfreund rule for 11–20 relies on the other skills. The ten split of 11–20 follows in the content drop "Schüttelbox bis 20" (Section 17.12.1).
  • Schüttelbox shows no "+" and no "=" symbol; every split is shown as a Zerlegungshaus (a small house: the roof holds the whole N, the two rooms below hold the parts), the standard German Grundschule representation of a decomposition. Elsewhere in the app the split labels of Section 4 (DR-09, e.g. "5 + 2") and the gleich pictogram of Mehr oder weniger (Section 12.3.4) are allowed. A split is spoken as "Sieben ist drei und vier." (Section 12.5.9).

12.5.3 Objects and geometry #

  • Box: a rounded rectangle (wooden look, illustration per Section 21 asset list). The interior is split into two equal compartments by a vertical divider of width 12 pt, full interior height. The lid is a separate layer covering the whole box. At step3 the lid consists of two flaps (left and right) that open independently.
  • Beads: BeadView beads (Section 19). Within each compartment, the first five beads are solid red and beads 6–10 are blue Lochperlen, counted in the compartment's structured order after settling (Section 12.5.8). While falling, each bead keeps the style it will have in its final slot, so beads do not change color after landing.
  • Tray: before the beads go into the box, they are shown in a tray above the box as one structured row (five-groups), so the child sees the whole quantity N first.
Element iPhone SE portrait iPhone SE landscape iPad portrait iPad landscape
Box outer size 343 × 230 pt, horizontally centered, in the middle of the task area 400 × 220 pt, left part of the task area 640 × 400 pt 720 × 440 pt, left part
Compartment interior 157 × 200 pt each 186 × 200 pt each 300 × 360 pt 340 × 400 pt
Bead diameter 26 pt 28 pt 46 pt 50 pt
Tray (above the box) 343 × 48 pt Above the box, 400 × 32 pt, tray beads 22 pt 640 × 72 pt 720 × 80 pt
Answer area Below the box (cards or tiles) Right 35% of the task area (about 223 pt): tiles in a 2 × 2 grid of 72 pt; house cards in a 2 × 2 grid of 105 × 145 pt Below the box Right 35%
Shake button 88 × 88 pt BigButton, centered below the box, pictogram: a box with motion lines 88 pt, centred in the answer area (empty during the shake phase) 112 pt 112 pt
Lid tap target The whole box (≥ 230 pt tall) Whole box Whole box Whole box

The fullest possible compartment holds 9 beads (split 9 and 1 of N = 10), arranged as a row of five and a row of four: 5 × 26 pt + 4 × 4 pt gaps = 146 pt ≤ 157 pt interior width. Two rows need 2 × 26 pt + 4 pt = 56 pt of the 200 pt interior height.

iPhone SE landscape height check: the task area below the 64 pt top bar is 311 pt; tray 32 pt + 8 pt gap + box 220 pt + 2 × 16 pt margins (Section 19.4) = 292 pt. Width check: 16 + 400 + 12 + 223 + 16 ≈ 667 pt. Each house card shows the roof numeral and the two room numerals in child.numeral (44 pt); room dot groups use 8 pt dots.

12.5.4 Round structure and task flow #

Tasks per round: 5 (vorschule) or 4 (littleOnes). Per task:

  1. Show the whole. N beads lie in the tray as a structured row. prompt.schuettelbox.here_are + num.<N> + prompt.schuettelbox.perlen "Hier sind {N} Perlen." plays, and the numeral N is displayed on the box's front label (the roof numeral of the later Zerlegungshaus).
  2. Fill. The beads roll from the tray into the open box (0.8 s, staggered 50 ms). The lid closes (0.3 s) with sfx.lid_close.
  3. Shake. prompt.schuettelbox.shake "Schüttle die Box!" plays. Shake detection (Section 12.5.5) starts, and the shake button is visible according to Section 12.5.5. While a shake is detected, the closed box wobbles (rotation ±4° at 2.5 Hz, below the 3 Hz limit of Section 19) and sfx.shake plays, retriggered while shaking continues (Section 20.9). The shake phase ends when shaking stops (Section 12.5.5) or after one shake-button press (2.0 s of automatic wobble and rattle).
  4. Open. prompt.schuettelbox.open "Mach die Box auf!" plays and the lid pulses once. The child taps the box. The lid lifts (0.4 s, sfx.lid_open) and the beads drop into their compartments and settle (Section 12.5.8). At step3, only the left flap opens.
  5. Ask. The step's question (Section 12.5.7) plays and the answer options appear.
  6. Evaluate and feed back (Sections 10, 12.5.10–12.5.12).

The split is decided at task generation (Section 12.5.6), before the child shakes. Shaking only triggers the reveal. Shaking harder, longer or several times does not change the result. Decision: this makes tasks deterministic and testable, and lets the engine guarantee coverage of all splits. The child experiences it as the natural result of shaking, because the box is closed while shaking, exactly like a real shaker box.

12.5.5 Shake detection and button fallback #

Motion input (threading per Section 5.6.1; one shared motion manager per Section 24.13):

  • The app target owns the single CMMotionManager instance (Section 24.13) and passes it to the game when it registers the Schüttelbox module in the GameRegistry (Section 5.4.3), e.g. SchuettelboxModule(motionManager:). The game never creates its own instance. Updates run only during the shake phase and stop immediately after (to save battery), and also when the pause overlay appears, the scene leaves .active, or the game closes.
  • Samples are delivered to a private serial OperationQueue (maxConcurrentOperationCount = 1). The ShakeDetector is a final class confined to that queue (documented @unchecked Sendable confinement). Only the discrete events "shake started" and "shake phase ended" are forwarded to the main actor.
  • If isDeviceMotionAvailable: deviceMotionUpdateInterval = 1/60, startDeviceMotionUpdates(to: motionQueue). Use userAcceleration (gravity already removed), in g.
  • Otherwise, if isAccelerometerAvailable: accelerometerUpdateInterval = 1/60, startAccelerometerUpdates(to: motionQueue), raw acceleration with a first-order high-pass filter (α = 0.9) to remove gravity.
  • Neither available: motion is unavailable and the button path is used.
  • No permission prompt is needed for accelerometer or device-motion data. Nothing is recorded or stored; samples are evaluated and discarded.

Detection rule:

Parameter iPhone iPad
Sample rate 60 Hz 60 Hz
Threshold (magnitude of user acceleration) ≥ 1.3 g ≥ 1.0 g
Required above-threshold samples ≥ 2 within a sliding 0.3 s window same
Debounce after a detected shake 1.0 s (further detections ignored) same
Shake phase ends 0.8 s after the last above-threshold sample, or 3.0 s after the first detection, whichever is first same

Decision: the thresholds are lower than a typical 1.6 g phone-shake threshold. Small children shake with little force, and a heavy iPad should not have to be swung hard. Harder shaking must never be necessary. Verification step for the executor: test with 5 children aged 3–6 (Section 25 usability protocol). If more than 1 in 5 cannot trigger a shake within 5 s, lower the iPhone threshold in steps of 0.1 g (floor 0.9 g) in games/schuettelbox.json (shakeThresholdG).

Button fallback ("Schütteln" button, pictogram only, accessibility label "Schütteln"). The button is visible from the start of the shake phase when any of these hold:

  • motion is unavailable;
  • the device setting "Schüttelbox mit Bewegung" is off (Section 16.8; default on; delivered to the game in GameSettingsSnapshot, Section 10.3.4);
  • the device is an iPad (safety: children should not be encouraged to swing a heavy tablet; shaking still works);
  • the child's level is littleOnes (drop-safety for ages 2–4; shaking still works);
  • UIAccessibility.isReduceMotionEnabled, isSwitchControlRunning, isVoiceOverRunning or isAssistiveTouchRunning is true.

Otherwise (vorschule, iPhone, motion available and enabled), the button fades in 5.0 s after the shake prompt ends if no shake has been detected. Pressing the button counts as the shake. Button and motion are never both needed. Neither path affects the outcome.

When the "Schüttelbox mit Bewegung" setting is off, motion updates are never started, and prompt.schuettelbox.shake_button ("Drück auf den Knopf und schüttle die Box!") replaces the shake prompt.

Orientation during shaking: shaking can rotate the interface. Decision: from lid-close until the lid opens, the game requests an orientation lock at the current interface orientation through the app's OrientationLock service. The service is the app delegate's application(_:supportedInterfaceOrientationsFor:) returning a dynamic mask, plus setNeedsUpdateOfSupportedInterfaceOrientations() on the root view controller. The lock is released when the lid opens, when the task ends, or when the game exits. On iPad in Split View or Stage Manager, where the system may ignore the lock, a rotation during the shake phase only re-lays out the closed box and changes nothing else. Verification step for the executor: confirm on an iPhone SE and one iPad that a vigorous shake with the lock active does not rotate the interface.

/// Confined to its private serial OperationQueue (Section 5.6.1). Never touched directly from the main actor;
/// the two callbacks are the only way events reach the UI.
final class ShakeDetector: @unchecked Sendable {
    struct Config: Sendable { var thresholdG: Double; var windowSeconds: Double; var minSamples: Int; var debounceSeconds: Double; var quietEndSeconds: Double; var maxPhaseSeconds: Double }
    init(motionManager: CMMotionManager,          // the app-wide instance (Section 24.13)
         queue: OperationQueue,                   // serial, maxConcurrentOperationCount = 1
         onShakeStarted: @escaping @MainActor @Sendable () -> Void,
         onShakePhaseEnded: @escaping @MainActor @Sendable () -> Void)
    var isMotionAvailable: Bool { get }           // reads the manager's availability flags
    func start(config: Config)                    // enqueues on the detector queue
    func stop()                                   // enqueues on the detector queue; stops updates
    /// Test hook: feeds synthetic samples (g) with timestamps; runs the same detection code
    /// synchronously on the caller. Used by unit tests instead of CMMotionManager.
    func ingest(sample magnitude: Double, at time: TimeInterval)
}

12.5.6 Split generation and coverage #

For a task with total N, the split is (left, right) with left + right = N, left ≥ 1, right ≥ 1. Ordered splits are distinct: (3, 4) and (4, 3) are different outcomes. Zero parts are excluded at all steps in V1. Decision: in a real shaker box all beads can land on one side, but "0" parts need the numeral 0 as an answer and a concept of the empty set, and 0 is never a required answer in V1 (Section 4).

Coverage rule: the game keeps per-profile state (Section 12.1.4):

struct SchuettelboxState: Codable, Sendable {
    var v: Int = 1
    /// key: N (2...10) as String; value: count of how often each left-part (index = left) was shown
    var seenLeft: [String: [Int]] = [:]
}

Selection for N:

  1. Candidates: left ∈ 1…N−1, further restricted by the step (step3: left ∈ 1…N−1 but the hidden right must be ≤ 5 when N ≤ 7, so the inference stays within reach; for N = 8–10, right ∈ 2…6).
  2. Take the candidates with the minimum seen-count. Among them, exclude the split used in the immediately preceding Schüttelbox task for the same N (if another candidate remains). Choose uniformly with the RNG.
  3. After the task completes (any outcome), increment seenLeft[N][left] and save it through the GameStateStore (Section 12.1.4).

Result: every split of every N appears before any split repeats (least-seen-first). For N = 7 the six splits 1+6 … 6+1 all appear within 6 tasks for 7. The engine requests only N; coverage of splits is the game's responsibility.

12.5.7 Task types per step #

Step Type Question (audio) Answer input Correct when
step1 pickHouse "Wie sind die Perlen gefallen?" (prompt.schuettelbox.which_house) Choose one of the Zerlegungshaus cards. Each card shows the roof numeral N and two rooms, each with a numeral and a small dot group of that quantity. The chosen card's (left, right) equals the actual split
step2 countSides First "Wie viele Perlen sind hier?" (prompt.schuettelbox.how_many_here) with the left compartment outlined, after a correct answer "Und wie viele sind auf der anderen Seite?" (prompt.schuettelbox.how_many_other) with the right compartment outlined Numeral tiles, first for the left compartment, then for the right; the compartment being asked about is outlined Both answers correct
step3 findHidden "{N} Perlen. Hier sind {l}. Wie viele sind versteckt?" (composed from num.<N>, prompt.schuettelbox.perlen, prompt.schuettelbox.here_are, num.<l> and prompt.schuettelbox.hidden, per Section 12.5.9; for l = 1 "Hier ist eine." (prompt.schuettelbox.here_is_one) replaces "Hier sind {l}."; the visible compartment is outlined while "Hier sind" plays) Numeral tiles Tile = right

Decision: no Schüttelbox line says "links" or "rechts", because left-right discrimination is unreliable before school age. The outline shows which compartment is meant.

Details:

  • step1 pickHouse: littleOnes get 2 cards, vorschule 3 cards. Distractor cards are other splits of the same N: first (left ± 1), then (left ± 2), never the swapped split (right, left) (too confusing at this step), and never duplicates. If N = 2, the only split is (1, 1), so the task falls back to countSides with a single question for the left side (tiles 1 and 2). Beads settle and are then arranged into structured rows (Section 12.5.8).
  • step2 countSides: tiles are 4 for vorschule and 3 for littleOnes, bounds [1, N]; the swapped part N − correct is an explicit candidate when it differs, and the remaining tiles come from DistractorPicker with slots [nearest] (Section 12.1.5). Beads stay where they settled (scattered, not re-arranged), so the child counts or subitizes a scattered group. The left question counts as sub-answer 1 and the right as sub-answer 2. Each sub-answer runs its own attempt sequence, but wrong attempts accumulate for the task's hint ladder (Section 10). After both are correct, the Zerlegungshaus with N, left and right appears above the box.
  • step3 findHidden: only the left flap opens, and the right flap stays closed with a small question-mark pictogram on it. Tiles: 4, bounds [1, N], explicit candidates l (the visible part, a common confusion) and N (the whole), the rest from DistractorPicker with slots [nearest]. After a correct answer or the solution, the right flap opens and shows the hidden beads settling, and the Zerlegungshaus appears. Left beads are arranged structured.

12.5.8 Physics (SpriteKit) #

The box interior is an SKScene hosted in SpriteView (Section 5), sized to the box interior in points, with scaleMode = .resizeFill. The scene exists from task start. During the tray and shake phases the beads are sprites without physics (animated by actions), because the lid hides them while shaking.

On lid opening (drop phase):

Property Value
physicsWorld.gravity CGVector(dx: 0, dy: −9.8) (SpriteKit default scale)
Walls Edge-loop body on the interior rect; divider as a static rectangle body, full height
Bead body SKPhysicsBody(circleOfRadius: r) with r = bead diameter / 2
restitution 0.35
friction 0.4
linearDamping 0.6
angularDamping 0.8
density 1.0
allowsRotation true (the Lochperle ring is rotation-invariant)
Initial positions Each bead at y = top − r − random(0…0.25 × interior height), x uniform inside its assigned compartment with margin r + 2 pt
Initial velocity dx random −40…40 pt/s, dy 0
Release Staggered by 40 ms per bead in random order
Settle detection Every frame: all beads' velocity magnitude < 5 pt/s continuously for 0.3 s → settled. Hard timeout 2.5 s after the last release → settled
After settle All bodies set isDynamic = false (frozen)
Overlap guard If two beads overlap by more than 20% of the diameter after settle (rare stacking artifacts), the upper bead is moved to the nearest free position in its compartment
Structured arrangement (step1; step3 visible left side) After settle, beads glide over 0.4 s to five-structured slots: rows of five from the bottom, left-aligned, 1.5× gap not needed within a row. First five solid red, next five Lochperle blue
Scattered (step2) Beads stay at their settled positions. Their color style is assigned by settled order: the five lowest beads in a compartment (by y, then x) are red, the rest blue. This is fixed before release, so colors do not change on landing.

Beads in each compartment are assigned at generation time: the generator emits left bead IDs for the left compartment and right for the right. A bead can never cross the divider, because the divider reaches the top of the interior and the beads start inside their compartment.

Sound: sfx.beads_settle (beads settling with small clicks) plays once when the first bead is released; there are no per-contact sounds. collisionBitMask covers bead–bead and bead–wall. Haptics: the bead-landing impact of the Section 19.10 map, triggered by contact events (contactTestBitMask) with an impulse above a threshold and throttled as that map states.

Reduce Motion: no wobble while shaking (the box shows three motion lines that fade in and out, rattle sound unchanged). On opening, the lid cross-fades away and the beads appear directly in their final positions (structured or settled-equivalent positions precomputed by running the same simulation off-screen for up to 2.5 s of simulated time), with a 0.3 s fade.

Performance: at most 10 beads and one static divider. Target 60 fps on iPhone SE (2nd generation). The physics world runs only during the drop phase and is paused (isPaused = true) otherwise.

12.5.9 Prompts #

Audio ID German text When Visual equivalent
prompt.schuettelbox.intro "Das ist die Schüttelbox." First task of a round, before here_are Box bounces once
prompt.schuettelbox.here_are "Hier sind …" (fragment; composed as here_are + num.<N> + perlen) Tray phase; step3 before the visible part l ≥ 2 Numeral N on the box label
prompt.schuettelbox.perlen "… Perlen." (fragment, follows a number) Tray phase after here_are + num.<N>; step3 after num.<N> Numeral N on the box label
prompt.schuettelbox.here_is_one "Hier ist eine." Singular variant of "Hier sind {l}.": whenever l = 1, "Hier ist eine." replaces here_are + num.<l> (step3 prompt and step3 hint level 1) Numeral 1 above the open compartment (outlined)
prompt.schuettelbox.shake "Schüttle die Box!" Shake phase Demo hand shaking a phone pictogram; shake button (where visible) pulses
prompt.schuettelbox.shake_button "Drück auf den Knopf und schüttle die Box!" Shake phase when motion is off or unavailable Button pulses
prompt.schuettelbox.open "Mach die Box auf!" Open phase Lid pulses, demo hand taps the lid
prompt.schuettelbox.which_house "Wie sind die Perlen gefallen?" step1 Cards slide in
prompt.schuettelbox.how_many_here "Wie viele Perlen sind hier?" step2, first sub-answer Left compartment outlined, pointing-hand pictogram on it
prompt.schuettelbox.how_many_other "Und wie viele sind auf der anderen Seite?" step2, second sub-answer Right compartment outlined
prompt.schuettelbox.hidden "Wie viele sind versteckt?" (composed with the fragments, Section 21.4 template schuettelbox.hidden: num.<N> + perlen + here_are + num.<l> + hidden → "{N} Perlen. Hier sind {l}. Wie viele sind versteckt?"; l = 1: num.<N> + perlen + here_is_one + hidden) step3 Box label N, numeral l above the open compartment (outlined), "?" on the closed flap
hint.schuettelbox.look_rows "Schau, wie die Perlen liegen." Hint level 1, step1/step2 Five-group outlines in both compartments; step2 beads glide into structured rows
hint.schuettelbox.count_side "Wir zählen die Perlen auf dieser Seite." Hint level 2, step1/step2 The outlined compartment's beads light up one by one with count-along
hint.schuettelbox.all_together "Zusammen sind es {N}. Hier sind {l}." (l = 1: "Zusammen sind es {N}. Hier ist eine.", using here_is_one) Hint level 1, step3 Tray outline with N empty slots appears above; l slots fill
hint.schuettelbox.count_on "Zähl weiter bis {N}." Hint level 2, step3 Count-on from l to N along the remaining tray slots, each slot lighting up
(solution, composed) "{N} ist {l} und {r}." (num.<N> + fb.solution.ist + num.<l> + prompt.common.und + num.<r>, Section 21) Solution and correct confirmation Zerlegungshaus with N, l, r

Hint level 2 (step1/step2): for the side currently asked (step1: first the left, then the right compartment, each outlined in turn; step2: the current sub-answer's side), the beads light up one at a time with num.1 … num.<k> at 0.6 s each. For step1 both sides are counted in turn.

12.5.10 Hints #

  • Level 1: step1/step2 hint.schuettelbox.look_rows; step3 hint.schuettelbox.all_together (table above). The wrong card or tile fades and becomes non-interactive per Section 10.6.3 (35 %). In step2 this applies within the current sub-answer's tile set; the second sub-answer starts with a fresh tile set.
  • Level 2: step1/step2 hint.schuettelbox.count_side (the voice counts the side being asked about; at step1 both sides); step3 hint.schuettelbox.count_on, where the tray shows N slots, the first l filled, and the voice counts on "vier, fünf, sechs, sieben" while the remaining slots light up. The number of lit slots is the answer, but it is not stated.

12.5.11 Solution demonstration #

After the third wrong attempt (cumulative within the task): all hidden parts are revealed (step3 right flap opens), both compartments show structured beads, the Zerlegungshaus appears with the correct parts, the correct card or tile is highlighted with the soft-green ring, and the composed solution sentence "{N} ist {l} und {r}." plays. The child taps the highlighted answer to continue. The outcome is shown.

12.5.12 Correct feedback #

The Zerlegungshaus (roof N, rooms l and r) slides down over the box (0.5 s; Reduce Motion: fade) and the composed sentence plays as confirmation ("Sieben ist drei und vier."). The framework fb.correct.* line follows only if the combined duration stays within the Section 10 feedback budget. Otherwise it is skipped.

12.5.13 Task content #

enum SchuettelTaskType: String, Codable, Sendable { case pickHouse, countSides, findHidden }

struct SchuettelboxTaskContent: Sendable, Equatable {
    let total: Int                  // N, credited
    let left: Int
    let right: Int
    let type: SchuettelTaskType
    let houseOptions: [SplitOption] // pickHouse only; displayed order
    let leftTiles: [Int]            // countSides (first sub-answer) and findHidden (unused, empty)
    let rightTiles: [Int]           // countSides (second sub-answer) / findHidden options
    let beadSeed: UInt64            // drives drop positions and velocities deterministically
}
struct SplitOption: Sendable, Equatable { let left: Int; let right: Int }

12.5.14 Parameters: games/schuettelbox.json #

{
  "schemaVersion": 1,
  "gameId": "schuettelbox",
  "tier": "premium",
  "primarySkill": "decompose",
  "secondarySkill": "subitize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": 110,
  "littleOnesMaxStep": 2,
  "promptIds": ["prompt.schuettelbox.intro", "prompt.schuettelbox.here_are", "prompt.schuettelbox.perlen", "prompt.schuettelbox.here_is_one", "prompt.schuettelbox.shake", "prompt.schuettelbox.shake_button", "prompt.schuettelbox.open", "prompt.schuettelbox.which_house", "prompt.schuettelbox.how_many_here", "prompt.schuettelbox.how_many_other", "prompt.schuettelbox.hidden"],
  "hintIds": {
    "level1": ["hint.schuettelbox.look_rows", "hint.schuettelbox.all_together"],
    "level2": ["hint.schuettelbox.count_side", "hint.schuettelbox.count_on"]
  },
  "solutionIds": ["fb.solution.ist"],
  "gameParameters": {
    "shake": {
      "sampleHz": 60,
      "shakeThresholdG": { "iPhone": 1.3, "iPad": 1.0 },
      "windowSeconds": 0.3,
      "minSamples": 2,
      "debounceSeconds": 1.0,
      "quietEndSeconds": 0.8,
      "maxPhaseSeconds": 3.0,
      "buttonDelaySeconds": 5.0,
      "buttonPressWobbleSeconds": 2.0
    },
    "physics": {
      "restitution": 0.35, "friction": 0.4, "linearDamping": 0.6, "angularDamping": 0.8, "density": 1.0,
      "releaseStaggerMs": 40, "initialDxRange": [-40, 40],
      "settleSpeedPtPerSec": 5, "settleHoldSeconds": 0.3, "settleTimeoutSeconds": 2.5
    },
    "allowZeroParts": false
  },
  "steps": [
    { "id": "step1", "labelKey": "game.step.leicht", "numberMin": 2, "numberMax": 10,
      "numberRangeByLevel": { "littleOnes": { "min": 2, "max": 5 } },
      "parameters": { "type": "pickHouse", "cardCount": 3, "arrangeAfterSettle": true, "excludeSwappedDistractor": true },
      "parametersByLevel": { "littleOnes": { "cardCount": 2 } } },
    { "id": "step2", "labelKey": "game.step.mittel", "numberMin": 2, "numberMax": 10,
      "numberRangeByLevel": { "littleOnes": { "min": 2, "max": 5 } },
      "parameters": { "type": "countSides", "tileCount": 4, "arrangeAfterSettle": false, "preferSwappedDistractor": true, "distractorSlots": [["nearest"]] },
      "parametersByLevel": { "littleOnes": { "tileCount": 3 } } },
    { "id": "step3", "labelKey": "game.step.schwer", "numberMin": 5, "numberMax": 10,
      "parameters": { "type": "findHidden", "tileCount": 4, "arrangeVisibleSide": true, "hiddenPartMaxWhenNUpTo7": 5, "hiddenPartRangeWhenN8To10": [2, 6], "explicitDistractors": ["visiblePart", "total"], "distractorSlots": [["nearest"]] } }
  ]
}

The solution sentence uses fb.solution.ist and the carrier prompt.common.und. Validation: shakeThresholdG values must be in 0.5–3.0; numberMin ≥ 2 and numberMax ≤ 10; allowZeroParts must be false (true is rejected by the V1 validator).

12.5.15 Mastery credit #

Each task credits decompose (1.0) and subitize (0.5) for the total N. The parts l and r are not credited. Decision: the engine plans the whole; the parts serve decomposition of N.

12.5.16 Edge cases #

Case Behaviour
Child shakes before the shake prompt (during tray or fill) Motion is not being sampled yet, so nothing happens.
Child shakes again after the lid is open Motion updates are stopped; nothing happens.
Child presses the shake button and shakes at the same time The first trigger wins; the other is ignored (the phase is already running).
Child taps the lid before shaking The lid does not open. The shake prompt repeats at most once per 5 s.
Motion permission or availability changes mid-session (e.g. Low Power Mode) Motion updates keep working in Low Power Mode. If startDeviceMotionUpdates delivers an error, the detector stops and the button appears immediately.
Device lying flat on a table, child only taps The button path is always available at the latest 5 s after the prompt.
Backgrounding during the shake phase Motion updates stop. On resume the task returns to the start of the shake phase (lid closed). The split is unchanged.
Backgrounding during the drop phase The physics scene pauses. On resume, beads are placed directly at their settled positions (simulation completed off-screen).
Rotation during settle (lock not honoured, e.g. iPad multitasking) The scene is resized. Beads are re-placed at their final positions, scaled to the new interior.
Split coverage state lost (decode error) Starts fresh. Coverage restarts; no crash.
N = 2 at step1 Only split (1, 1); falls back to a single countSides question (Section 12.5.7).
Engine plans N = 1 or N > 10 Defensive clamp (Section 12.1.3) to 2 or 10.
Haptics on Only the events of the Section 19.10 map apply (in this game: the bead-landing impact during the drop phase and the correct-answer feedback). The game adds no haptic events of its own.

12.5.17 Acceptance criteria #

  • AC-SB-01 Given a vorschule child on an iPhone with motion available and enabled, when the shake prompt ends and no shake occurs for 5.0 s, then the "Schütteln" button fades in, and pressing it reveals the result exactly like a detected shake.
  • AC-SB-02 Given synthetic motion samples of 1.35 g at two consecutive 60 Hz samples on iPhone, when ingested, then one shake is detected. Given a single sample of 2.0 g followed only by samples < 1.3 g, then no shake is detected.
  • AC-SB-03 Given a detected shake, when further above-threshold samples arrive within 1.0 s, then no additional shake event is emitted.
  • AC-SB-04 Given 6 consecutive step1 tasks for N = 7 with fresh state, when generated, then the left parts are exactly {1, 2, 3, 4, 5, 6} with no repeat.
  • AC-SB-05 Given a littleOnes child or an iPad, when the shake phase starts, then the "Schütteln" button is visible immediately.
  • AC-SB-06 Given the device setting "Schüttelbox mit Bewegung" is off, when a Schüttelbox task runs, then CMMotionManager updates are never started and the button is visible immediately.
  • AC-SB-07 Given step3 with N = 7 and l = 3, when the lid opens, then only the left compartment is visible, the prompt "Sieben Perlen. Hier sind drei. Wie viele sind versteckt?" plays, and tapping 4 opens the right flap, shows the Zerlegungshaus 7 / 3 / 4 and plays "Sieben ist drei und vier."
  • AC-SB-08 Given any task, when the drop phase ends, then every bead rests inside its assigned compartment, no bead crosses the divider, and settle is reached within 2.5 s of the last release.
  • AC-SB-09 Given step2 and a correct left answer followed by one wrong right answer, when the child then answers the right side correctly, then the outcome is afterHint.
  • AC-SB-10 Given the shake phase is active on an iPhone, when the device is shaken vigorously, then the interface orientation does not change until the lid opens.
  • AC-SB-11 Given the engine's candidate filter, when a vorschule child's active range is r20, then no Schüttelbox task is planned for N > 10.
  • AC-SB-12 Given any Schüttelbox task, when every prompt, hint and solution line is listed, then none contains the words "links" or "rechts".
  • AC-SB-13 Given iPhone SE landscape (667 × 375 pt), when a step1 task renders, then tray, box, answer cards and top bar fit without overlap and every child target is at least 60 × 60 pt.
  • AC-SB-14 Given a Schüttelbox task is on screen, when shake detection runs, then motion samples are processed off the main actor on the detector's serial queue, only the two discrete events reach the main actor, and the app-wide CMMotionManager instance is used.

13. Game Specifications: Premium Games II #

This section specifies the remaining four premium games: Froschsprung, Fütter das Zahlenmonster, Memory and Punkt zu Punkt. All conventions of Section 12.1 apply unchanged: ownership boundaries, deterministic generation, supported numbers, numberRangeByLevel, littleOnesMaxStep and the per-step tasksPerRound, expectedRoundSeconds and minRange overrides, the per-profile GameStateStore, numeral tiles and the child.numeral size for task numerals, distractor selection with DistractorPicker (Section 10.17), audio notation and the ownership of line IDs, the voice-off path, outcome downgrade rules, layout reference canvases, entitlement change during play, and acceptance-criteria IDs.

13.1 Froschsprung #

13.1.1 Purpose and skills #

Froschsprung ("frog jump") builds the ordinal number line: where a number lives, what comes one or two after or before it, and how landmarks (5, 10, 15, 20) help to find numbers without counting from the start. A frog starts on the grassy bank and hops along lily pads numbered from 1 (the "stones" of the number line, Section 4.8), counting aloud as it lands.

Field Value
GameID froschsprung
Display name Froschsprung
Tier premium
Primary skill order (weight 1.0)
Secondary skill compare (weight 0.5), credited only for relative tasks (Section 13.1.11)
Swift target GameFroschsprung

13.1.2 Level availability and limits #

Level Steps available Target numbers per step
littleOnes step1, step2 (littleOnesMaxStep = 2) step1: 1–10; step2: 1–10 (forward relations only)
vorschule step1, step2, step3 step1: 1–10; step2: 1–20; step3: 1–20

All values are intersected with the active range.

The number line length follows the active range: r5 → bank + pads 1–5, r10 → bank + pads 1–10, r20 → bank + pads 1–20. The bank is always present at the leading end. It represents zero but carries no label, and no line ever speaks "null" in this game (Section 4.8, DR-36). The bank is the start position for jumpTo and landmarkJump; it is never a start position for relative tasks and never a target. 0 is never a target number, because it is not a mastery number.

Pad coding (Section 4.8, DR-37): pads 1–5 and 11–15 are red solid lily pads, pads 6–10 and 16–20 are blue lily pads with a white centre ring (the Lochperle shape), so the five-structure is visible without colour vision. Between two five-groups the spacing is 1.5 × the normal pad spacing (DR-15). The landmark pads 5, 10, 15 and 20 are drawn 1.15 × larger. The line runs left to right in increasing order in every orientation and never wraps.

13.1.3 Task types #

Type Step Setup Question Input Correct when
jumpTo step1 Frog on the bank; all pads labeled with numerals "Spring zur {n}!" Tap pad or drag the frog Frog lands on pad n
relative step2 Frog on start pad s (s ≥ 1); all pads labeled "Der Frosch sitzt auf der {s}. Spring {k} weiter!" or "… Spring {k} zurück!" (k ∈ {1, 2}) Tap pad or drag the frog Frog lands on s + k (weiter) or s − k (zurück)
landmarkJump step3 Frog on the bank; only the landmark pads 5, 10, 15, 20 within the line are labeled, except that the target pad's own label is hidden (the step-3 target exception of Section 4.8, DR-39) "Spring zur {n}!" Tap pad or drag the frog Frog lands on pad n
whereIsFrog step3 Frog already sits on pad n; only landmarks labeled, the frog's pad (the target) label hidden "Auf welcher Zahl sitzt der Frosch?" Numeral tiles (4) Tile = n

Step3 mix: landmarkJump 0.5, whereIsFrog 0.5.

13.1.4 Screen layout #

Element iPhone SE portrait iPhone SE landscape iPad portrait iPad landscape
Pond (task area background) Full task area Full task area Full task area Full task area
Number line Horizontal, pad centers at 55% of task-area height Pad centers at 58% 55% 58%
Pad pitch (center to center inside a five-group) 72 pt 72 pt 80 pt (narrower iPads: see below) 96 pt
Extra gap at each five-group boundary (after pads 5, 10, 15) +6 pt (1.5 × the 12 pt spacing) +6 pt +8 pt +10 pt
Pad visual diameter 60 pt; landmarks 69 pt 60 pt; landmarks 69 pt 64 pt; landmarks 74 pt 76 pt; landmarks 87 pt
Pad hit area Column (pitch − 12 pt) wide × 140 pt tall, from 50 pt above to 90 pt below the pad center, covering pad and label (≥ 60 × 60 pt, ≥ 12 pt apart) Same Same Column 84 × 170 pt
Pad numeral child.numeral 44 pt, centered directly below the pad 44 pt 64 pt, below the pad 64 pt, below the pad
Bank Grassy bank 80 pt wide at the leading end, no label; frog start position 40 pt from the leading edge; first pad center at 40 pt + pitch Same Same Bank 96 pt wide
Frog 64 pt, sits on the pad 64 pt 72 pt 88 pt
Visible pads without scrolling about 5.2 (one full five-group) about 9.3 about 10.2 about 12.3 (bank + 1–10 fits; 1–20 scrolls)
Mini-map Full width − 32 pt, 16 pt tall, top of the task area Same Same Same
Prompt visual (target numeral or arrows) Prompt area of the top bar region, 56 pt numeral Same 72 pt 72 pt
Answer tiles (whereIsFrog) Bottom, one row of 4 × 72 pt Bottom, one row Bottom, one row, tile size per Section 19.7.6 Bottom, one row

Visible window (Section 4.8, DR-38 as amended there): at compact width the view always shows at least 5 consecutive pads (one five-group), at regular width at least 10, and the mini-map shows the whole line. On iPads whose portrait width is below 820 pt (for example 744 pt), the pitch becomes floor(viewportWidth / 10.25) pt, minimum 72 pt, so at least 10 pads stay visible; whenever the pitch is below 80 pt, the two-digit labels of even pads are placed directly above the pad instead of below it, so neighbouring 64 pt labels never overlap. Horizontal margin: the last pad is centered 48 pt from the trailing edge of the scrollable content.

Scrolling (all devices, whenever the line is wider than the viewport):

  • The line is a horizontal ScrollView whose position is driven with scrollPosition(id:) / ScrollViewReader.scrollTo(_:anchor:).
  • Task start: the camera places the frog's position at 25% of the viewport width from the leading edge (forward tasks) or at 75% (relative zurück tasks). For whereIsFrog, the frog is placed at 50%. Five-group alignment: if moving the leading edge of the window to the nearest five-group boundary (the bank, or the gap after pad 5, 10 or 15) keeps the frog and the next pad in the task's direction fully visible, the aligned position is used; otherwise the unaligned one.
  • During hops: user scrolling is disabled (scrollDisabled(true)), and the camera follows the frog so that it stays inside the middle 50% of the viewport (animated with the hop, 0.45 s ease-in-out per hop).
  • While waiting for input: the child may swipe the line freely. A pan that starts on the frog drags the frog (below). Any other pan scrolls. The camera never snaps back on its own. Decision: the child keeps control of the view, and a snapping camera fights small hands.
  • Mini-map: shown only when the line does not fit. It displays the whole line as ticks (landmarks taller), the frog's position as a green dot, and the visible window as a rounded outline. It is display-only (no touch handling), so it cannot cause mis-taps. Accessibility: hidden from VoiceOver.

Frog dragging: a pan starting within the frog's 64 pt frame moves the frog with the finger. When the finger is within 40 pt of the viewport's leading or trailing edge, the line auto-scrolls at 300 pt/s. On release, the chosen pad is the pad whose center is horizontally nearest to the frog's center, if that distance is ≤ 0.6 × pitch and the vertical distance to the line is ≤ 80 pt. Otherwise the frog returns to its position (0.3 s) and nothing is evaluated. A release onto the frog's current pad or onto the bank is not an attempt (the bank is never a target); the frog returns. On a valid release, the frog lands on the chosen pad immediately. The pads between the old and new position then light up in order with the count-along words (0.3 s per pad), so dragging produces the same counting experience as hopping.

13.1.5 Hopping and counting #

  • A tap on a pad (other than the frog's pad) makes the frog hop pad by pad towards it: each hop is 0.45 s, following a parabolic arc 40 pt high. sfx.frog_hop plays on take-off and num.<k> on landing on pad k (count-along; descending numbers when hopping backwards).
  • From the bank: the first hop lands on pad 1 and says "eins"; nothing is spoken on the bank.
  • landmarkJump movement: from the bank the frog first makes one big jump (0.9 s, arc 90 pt, sfx.frog_big_jump) to the largest landmark ≤ the chosen pad, saying the landmark number on landing, then hops one pad at a time for the rest. For example, target 14: jump to 10 "zehn", then "elf, zwölf, dreizehn, vierzehn". For a chosen pad below 5, it hops from the bank.
  • Taps during movement are ignored (input lock until the frog has landed and the evaluation is done).
  • Reduce Motion: hops become 0.2 s cross-fades of the frog between pads. The count-along timing stays at 0.45 s per pad (the counting rhythm carries the instruction). Big jumps become a single cross-fade with the landmark word.

13.1.6 Round structure #

Tasks per round: 5 (vorschule) or 4 (littleOnes). At the start of each task the frog appears on the task's start pad with a 0.3 s "plop" (Reduce Motion: fade) and the camera positions itself.

  • jumpTo / landmarkJump: the frog starts on the bank.
  • relative: the frog starts on s.
  • whereIsFrog: the frog starts on n.

Wrong-attempt behaviour (decided per type):

Type After a wrong attempt
jumpTo The frog stays on the pad it reached (it has counted there). The next attempt starts from that pad. For example, the frog is on 5 and the target is 7: the child taps 7 and the frog hops "sechs, sieben".
relative The frog lands on the chosen pad, and the pad's number is spoken. Then the frog returns to s with one quick jump (0.5 s, no counting), because the question is relative to s.
landmarkJump The frog lands on the chosen pad and the pad's label becomes visible for the rest of the task. Then the frog returns to the bank with one quick jump (0.5 s).
whereIsFrog The frog stays. The wrong tile fades and becomes non-interactive per Section 10.6.3 (35 %).

Pads are positions on one continuous number line, not a set of discrete answer options, so they are never faded: the line must stay intact (Section 4.8). A pad chosen wrongly may be chosen again and then counts as a new attempt, as Section 10.6.3 states for answers without discrete options.

13.1.7 Task generation per step #

Given planned number n (the target pad, or the frog's pad for whereIsFrog), with line maximum L (5, 10 or 20):

  • step1 jumpTo: the target is n. Nothing else is random.
  • step2 relative:
    1. Build all valid combinations (k, direction) with k ∈ {1, 2} and direction ∈ {weiter, zurück}, allowed by the level (littleOnes: weiter only). For weiter the start is s = n − k and must satisfy s ≥ 1 (the bank is never a relative start, because it has no number to speak). For zurück the start is s = n + k and must satisfy s ≤ L.
    2. Choose with weights: weiter 0.6, zurück 0.4 (vorschule), and k = 1 or 2 with 0.5 each, renormalised over the valid combinations.
    3. If no combination is valid (only n = 1 for a littleOnes child, where weiter would have to start on the bank), fall back to jumpTo for that task.
  • step3: choose landmarkJump or whereIsFrog by weight. For whereIsFrog, if n is a landmark, its label is hidden like any other target label (rule in Section 13.1.3). Tiles for whereIsFrog: 3 distractors from DistractorPicker (Section 12.1.5), bounds [1, L], distractorSlots [fivePartner, nearest] (landmark confusion n ± 5), then [nearest].
Parameter littleOnes step1 littleOnes step2 vorschule step1 vorschule step2 vorschule step3
numberMin–numberMax 1–10 1–10 1–10 1–20 1–20
types jumpTo relative jumpTo relative landmarkJump 0.5, whereIsFrog 0.5
relativeK n/a [1, 2] n/a [1, 2] n/a
directions (weights) n/a weiter 1.0 n/a weiter 0.6, zurück 0.4 n/a
labels all all all all landmarks except the target / frog pad
landmarks n/a n/a n/a n/a [5, 10, 15, 20] ∩ line (the bank is never labeled)
tileCount n/a n/a n/a n/a 4 (whereIsFrog)
enum FrogTaskType: String, Codable, Sendable { case jumpTo, relative, landmarkJump, whereIsFrog }
enum FrogDirection: String, Codable, Sendable { case weiter, zurueck }

struct FroschsprungTaskContent: Sendable, Equatable {
    let number: Int                 // credited n (target pad or frog pad)
    let type: FrogTaskType
    let lineMax: Int                // 5, 10 or 20
    let startPad: Int               // 0 = the unlabeled bank (jumpTo, landmarkJump only)
    let relativeK: Int?             // relative only
    let direction: FrogDirection?   // relative only
    let labeledPads: Set<Int>
    let tiles: [Int]                // whereIsFrog only
}

13.1.8 Prompts #

Audio ID German text When Visual equivalent
prompt.froschsprung.intro "Der Frosch will hüpfen. Hilf ihm!" First task of a round Frog bounces once
prompt.froschsprung.jump_to "Spring zur {n}!" (num.<n>) jumpTo, landmarkJump Numeral n in the prompt area
prompt.froschsprung.sits_on "Der Frosch sitzt auf der {s}." (num.<s>) relative, before the instruction Frog's pad pulses once
prompt.froschsprung.forward_1 "Spring eins weiter!" relative, weiter, k = 1 One right-pointing arrow in the prompt area
prompt.froschsprung.forward_2 "Spring zwei weiter!" relative, weiter, k = 2 Two right-pointing arrows
prompt.froschsprung.back_1 "Spring eins zurück!" relative, zurück, k = 1 One left-pointing arrow
prompt.froschsprung.back_2 "Spring zwei zurück!" relative, zurück, k = 2 Two left-pointing arrows
prompt.froschsprung.where_frog "Auf welcher Zahl sitzt der Frosch?" whereIsFrog Question-mark bubble above the frog
hint.froschsprung.look_numbers "Wo ist die {n}? Schau auf die Zahlen." (recorded as hint.froschsprung.look_numbers + num.<n>.q + hint.froschsprung.schau_auf_die_zahlen) Hint level 1, jumpTo Camera scrolls so that pad n is in view (not highlighted)
hint.froschsprung.direction_forward "Weiter geht es zu den größeren Zahlen." Hint level 1, relative weiter A big arrow on the frog pointing forward
hint.froschsprung.direction_back "Zurück geht es zu den kleineren Zahlen." Hint level 1, relative zurück Arrow pointing back
hint.froschsprung.landmark "Schau auf die {m}." (num.<m>, m = nearest landmark ≤ target, or the next landmark if the target is below 5) Hint level 1, step3 Landmark m pulses (2 pulses at 1 Hz, within the Section 19.9 limit)
hint.froschsprung.count_steps "Wir zählen die Sprünge." Hint level 2, relative k footprints appear on the next k pads in the direction, counted "eins, zwei"
hint.froschsprung.from_the "Von der …" (fragment; composed as from_the + num.<m> + count_from_landmark → "Von der {m} zählen wir weiter.") Hint level 2, step3, before num.<m> Pads from m to the target light up with count-along
hint.froschsprung.count_from_landmark "… zählen wir weiter." (fragment, follows from_the + num.<m>) Hint level 2, step3 (m = nearest landmark ≤ target; for a target below 5, hint.froschsprung.count_path is used instead, lighting pads from pad 1) Pads from m to the target light up with count-along
hint.froschsprung.count_path "Wir zählen zusammen." Hint level 2, jumpTo Pads from the frog to n light up with count-along (the frog does not move)
(solution, composed) "Hier ist die {t}." (fb.solution.hier_ist_die + num.<t>, Section 21) Solution for jumpTo, landmarkJump and whereIsFrog (t = target pad; for whereIsFrog the frog's pad) Target pad with green ring (whereIsFrog: correct tile with green ring; the frog's pad label appears)
(solution, composed) "{k} weiter von der {s} ist die {t}." (num.<k> + fb.solution.weiter_von_der + num.<s> + fb.solution.ist_die + num.<t>, Section 21) Solution for relative, weiter (s = start pad, t = target pad) Target pad with green ring + footprints
(solution, composed) "{k} zurück von der {s} ist die {t}." (num.<k> + fb.solution.zurueck_von_der + num.<s> + fb.solution.ist_die + num.<t>, Section 21) Solution for relative, zurück (s = start pad, t = target pad) Target pad with green ring + footprints

13.1.9 Hints and solution demonstration #

  • Hint level 1: per the table (look_numbers / direction / landmark). The wrongly chosen pad shows its numeral for the rest of the task (step3), or keeps it (step1 and step2, labels already visible).
  • Hint level 2: count_path / count_steps / count_from_landmark. The lit pads keep a soft glow until the task ends. In whereIsFrog, hint level 2 lights the pads from the nearest landmark ≤ n (for n < 5: from pad 1) up to the frog with count-along, stopping at the pad before the frog: the voice counts "zehn, elf, zwölf" and then pauses on the frog's pad without saying it.
  • Solution (third wrong attempt): the target pad (or tile) gets the soft-green ring and gentle pulse, and the solution line plays. The child taps the ringed pad or tile. For pad tasks the frog then hops there with count-along. The outcome is shown.

13.1.10 Correct feedback #

The frog lands on the target pad (or the tile is chosen in whereIsFrog) and does a happy hop in place (0.4 s; Reduce Motion: none). The target pad's numeral appears if it was hidden, and a ripple ring spreads on the water once (0.6 s; Reduce Motion: none). For every type, num.<n> is repeated as confirmation. Then the framework's fb.correct.* line plays if within the Section 10 feedback budget.

13.1.11 Mastery credit #

  • The credited number is the target n: the pad for jumpTo, relative and landmarkJump, and the frog's pad for whereIsFrog. The start pad s of a relative task is not credited.
  • order (1.0) is credited on every task.
  • compare (0.5) is credited only for relative tasks, where the child reasons about "more/less by 1 or 2". For all other types secondarySkill is nil (same rule as Section 12.3.12).

13.1.12 Parameters: games/froschsprung.json #

{
  "schemaVersion": 1,
  "gameId": "froschsprung",
  "tier": "premium",
  "primarySkill": "order",
  "secondarySkill": "compare",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": 100,
  "littleOnesMaxStep": 2,
  "promptIds": ["prompt.froschsprung.intro", "prompt.froschsprung.jump_to", "prompt.froschsprung.sits_on", "prompt.froschsprung.forward_1", "prompt.froschsprung.forward_2", "prompt.froschsprung.back_1", "prompt.froschsprung.back_2", "prompt.froschsprung.where_frog"],
  "hintIds": {
    "level1": ["hint.froschsprung.look_numbers", "hint.froschsprung.direction_forward", "hint.froschsprung.direction_back", "hint.froschsprung.landmark"],
    "level2": ["hint.froschsprung.count_path", "hint.froschsprung.count_steps", "hint.froschsprung.from_the", "hint.froschsprung.count_from_landmark"]
  },
  "solutionIds": ["fb.solution.hier_ist_die", "fb.solution.weiter_von_der", "fb.solution.zurueck_von_der", "fb.solution.ist_die"],
  "gameParameters": {
    "lineMaxByRange": { "r5": 5, "r10": 10, "r20": 20 },
    "hopSeconds": 0.45,
    "bigJumpSeconds": 0.9,
    "returnJumpSeconds": 0.5,
    "dragCountSecondsPerPad": 0.3,
    "dropMaxHorizontalPitchFactor": 0.6,
    "dropMaxVerticalPt": 80,
    "edgeAutoScrollPtPerSec": 300,
    "landmarkScale": 1.15,
    "groupGapFactor": 1.5
  },
  "steps": [
    { "id": "step1", "labelKey": "game.step.leicht", "numberMin": 1, "numberMax": 10,
      "parameters": { "types": { "jumpTo": 1.0 }, "labels": "all", "wrongAttempt": "stay" } },
    { "id": "step2", "labelKey": "game.step.mittel", "numberMin": 1, "numberMax": 20,
      "parameters": { "types": { "relative": 1.0 }, "labels": "all", "relativeK": [1, 2], "kWeights": [0.5, 0.5],
                      "directions": { "weiter": 0.6, "zurueck": 0.4 }, "wrongAttempt": "returnToStart" },
      "parametersByLevel": { "littleOnes": { "directions": { "weiter": 1.0 } } } },
    { "id": "step3", "labelKey": "game.step.schwer", "numberMin": 1, "numberMax": 20,
      "parameters": { "types": { "landmarkJump": 0.5, "whereIsFrog": 0.5 }, "labels": "landmarksExceptTarget",
                      "landmarks": [5, 10, 15, 20], "tileCount": 4,
                      "distractorSlots": [["fivePartner", "nearest"], ["nearest"]],
                      "wrongAttempt": { "landmarkJump": "returnToStart", "whereIsFrog": "stay" } } }
  ]
}

Validation: landmarks must be a subset of {5, 10, 15, 20} (0 is rejected, because the bank is never labeled).

littleOnes step2 uses numberMax 10 regardless of the step's 20 (engine intersection with r5/r10; littleOnes never auto-widens to r20). If a parent sets r20 for a littleOnes child, step2 targets up to 20 are allowed.

13.1.13 Edge cases #

Case Behaviour
Child taps the frog's own pad Not an attempt; the frog does a small bounce.
Child taps the mini-map No effect (display only).
Child scrolls away so that the frog is off-screen, then taps a pad Works normally; the camera follows the frog during the hops.
Rotation mid-task Pitch and viewport change; the camera re-applies the task-start placement rule relative to the frog. The frog's pad and all state are kept.
Rotation during a hop The hop completes in the new layout; the counting continues.
Backgrounding during hops Section 10 pause. On resume, the frog is placed on the destination pad, and the remaining count-along words are skipped. The evaluation proceeds.
relative zurück onto the bank (e.g. s = 2, k = 2) Cannot occur: the target n ≥ 1, so s − k ≥ 1.
relative weiter from the bank Never generated: s ≥ 1 always (Section 13.1.7). For n = 1 a vorschule child gets zurück from pad 2 or 3; a littleOnes child gets a jumpTo task.
Child drops the frog on the bank Not an attempt; the frog returns to its position.
Line bank + 1–5 on iPhone SE portrait Width 40 + 5 × 72 + 48 = 448 pt; scrolls slightly; mini-map shown.
Voice off Target numeral / arrows / question bubble in the prompt area carry the task. Count-along is replaced by each landed pad's numeral briefly enlarging (1.3× for 0.3 s).
Two fingers: one drags the frog, another taps a pad First touch wins (Section 10); the second is ignored.

13.1.14 Acceptance criteria #

  • AC-FS-01 Given vorschule step1 with target 7 on a line of bank + pads 1–10, when the child taps pad 7, then the frog hops 7 times from the bank (0.45 s each) with "eins" … "sieben" spoken on landing, and the task is correct.
  • AC-FS-02 Given step1 with target 7 where the child first taps 5, when the frog reaches 5, then it stays on 5, hint level 1 plays, and a following tap on 7 makes it hop "sechs, sieben", giving the outcome afterHint.
  • AC-FS-03 Given step2 relative with s = 4, k = 2, weiter, when the task starts, then "Der Frosch sitzt auf der Vier. Spring zwei weiter!" plays and two forward arrows show. Tapping 6 is correct and credits order and compare for 6.
  • AC-FS-04 Given a littleOnes child at step2, when 50 tasks are generated, then every task uses direction weiter.
  • AC-FS-05 Given iPhone SE portrait and a line of bank + pads 1–20, when the task starts, then the mini-map is visible, at least 5 consecutive pads are fully visible, and the child can scroll the line by swiping outside the frog.
  • AC-FS-06 Given step3 landmarkJump with target 14 on a line of bank + pads 1–20, when the child taps pad 14, then the frog makes one big jump to 10 saying "zehn" and then hops "elf, zwölf, dreizehn, vierzehn", only pads 5, 10, 15 and 20 were labeled before the jump, and the bank never showed a label.
  • AC-FS-07 Given step3 whereIsFrog with the frog on 13, when tiles are generated, then they include 13, contain 4 distinct values within 1–20, and pad 13 shows no label.
  • AC-FS-08 Given the child drags the frog and releases it more than 0.6 × pitch away from every pad center, when released, then the frog returns to its pad and no attempt is recorded.
  • AC-FS-09 Given Reduce Motion, when the frog moves 3 pads, then there is no arc motion, and the count-along still plays at 0.45 s per pad.
  • AC-FS-10 Given any Froschsprung task on any level and step, when all spoken output of the task is recorded, then num.0 ("null") never plays and no relative task starts on the bank.
  • AC-FS-11 Given any line, when it renders, then pads 1–5 and 11–15 use the red solid style and pads 6–10 and 16–20 the blue ring style, the spacing at each five-group boundary is 1.5 × the normal spacing, and pads 5, 10, 15, 20 are 1.15 × larger.
  • AC-FS-12 Given iPhone SE and iPad, when pad labels render, then every pad numeral is at least 44 pt (iPhone) or 64 pt (iPad), and no two labels overlap; at regular width at least 10 consecutive pads are visible.
  • AC-FS-13 Given step3 whereIsFrog and a wrong tile, when hint level 1 runs, then that tile is at 35 % opacity and cannot be chosen again.

13.2 Fütter das Zahlenmonster #

13.2.1 Purpose and skills #

In "Fütter das Zahlenmonster" ("feed the number monster") a friendly monster asks for exactly N items. The child drags items from a pile into its mouth (or taps an item to send it there), hears the count grow with each item, and decides when to stop by pressing the "Fertig" plate. This trains the cardinality principle (the last number counted is how many) and producing a set of a given size. At step3 the Kraft der Fünf and Zehn apply through packs of five and ten.

Field Value
GameID zahlenmonster
Display name Fütter das Zahlenmonster
Tier premium
Primary skill count (weight 1.0)
Secondary skill recognize (weight 0.5)
Swift target GameZahlenmonster

13.2.2 Level availability and limits #

Level Steps available N per step
littleOnes step1, step2 (littleOnesMaxStep = 2) step1: 1–5; step2: 1–10
vorschule step1, step2, step3 step1: 1–5; step2: 1–10; step3: 6–20

All values are intersected with the active range. Step3 needs N ≥ 6, because packs are pointless below 6. In r10, step3 uses N 6–10.

13.2.3 Screen layout #

Element iPhone SE portrait iPhone SE landscape iPad portrait iPad landscape
Monster Top of the task area, 180 × 180 pt, centered in the width left of the Fertig column Top of the right part of the task area (right 232 pt), 210 × 210 pt Upper half, 380 × 380 pt Right half, 420 × 420 pt
Bib (on the monster's chest, may overhang the body) 150 × 72 pt: numeral N in child.numeral (44 pt) left, dot pattern of N right (five-structured BeadView dots, 8 pt; 11–20 as a mini Zwanzigerfeld with 6 pt dots) Same 190 × 120 pt, numeral 64 pt Same
Mouth drop zone The mouth's ellipse expanded by 40 pt on every side Same Expanded by 56 pt Same
Pile area Below the monster, 343 × 360 pt Left of the monster, 400 × 290 pt Lower half, 700 × 380 pt Left half, 520 × 600 pt
Item size 56 pt visual, 60 pt touch frame 52 pt visual, 60 pt touch frame 80 pt 84 pt
Five-pack / ten-pack (step3) Items drawn at 44 pt inside packs; packs in one column at the leading edge of the pile: ten-pack 240 × 96 pt (2 rows of 5), below it the two five-packs, 240 × 56 pt each, 8 pt apart (column 240 × 224 pt) Same column Items 64 pt inside packs Same
"Fertig" plate button 80 × 80 pt, below the monster's right side: right-aligned in the task area, its bottom edge level with the monster's bottom edge 80 pt, below the monster, bottom-right of the task area 104 pt, right of the monster 104 pt, bottom-right

iPhone SE portrait check (task area 375 × 583 pt below the 64 pt top bar): monster 180 pt + 12 pt + pile 360 pt + margins fit; the plate column (80 pt) and the monster (180 pt) fit side by side in 343 pt. iPhone SE landscape check (task area 667 × 311 pt): monster 210 pt + 8 pt + plate 80 pt = 298 pt tall; pile 400 pt + 12 pt + monster column 232 pt ≤ 635 pt.

The Fertig button shows a plate with a big checkmark (pictogram only; accessibility label "Fertig"). It is disabled (40% opacity, no action) until at least one item has been fed in the current attempt. Tapping it while disabled makes it wiggle once, and nothing is recorded.

13.2.4 Item supply per step #

Step Pile content Arrangement
step1 max(N + 3, 6) single items, capped at 10 Structured: rows of five in a tray (row 1 up to 5 items, row 2 the rest), five-group gap 1.5×
step2 min(N + 4, 14) single items Scattered: random non-overlapping positions in the pile area, min center distance 64 pt (iPad 88 pt), 8 pt edge margin, up to 200 attempts per item. On failure, fall back to a tidy grid of 5 columns and log .error
step3 2 five-packs + 1 ten-pack (only if N ≥ 10) + singles = min(9, number of single positions that fit the scatter rules in the space beside and below the pack column) Packs in one column at the leading edge of the pile area (ten-pack top, five-packs below it), singles scattered in the remaining area (same scatter rules)

Decision: supply always exceeds N, so "take everything" never solves a task and over-feeding is possible. With one ten-pack and two five-packs, no N in 6–20 needs more than 4 singles (for example 19 = 10 + 5 + 4, 9 = 5 + 4), and every supported layout fits at least 6 singles (iPhone SE portrait 9, iPhone SE landscape 8, iPad 9), so every N can be composed and over-feeding stays possible.

One food type per task, chosen by the RNG from the food subset of the counting-object catalogue (Section 11.1.4): Äpfel (apfel), Birnen (birne), Erdbeeren (erdbeere), Karotten (karotte), Bananen (banane). No sweets are used (Section 23.12). The same food is never used in two consecutive tasks. Packs show the same food.

13.2.5 Feeding interaction #

  • Drag: a drag starts on an item or pack after the Section 10 touch slop. The item follows the finger with its grab offset preserved, scaled to 1.1×. The monster's mouth opens when the dragged item's center comes within 120 pt of the mouth zone, and closes when it moves away.
  • Drop accepted: on release, if the item's center or the finger location is inside the mouth drop zone, the item is eaten. It shrinks into the mouth (0.25 s), sfx.monster_munch plays, the drop haptic of the Section 19.10 map fires, and the running count increases by 1 (item) or 5 / 10 (pack).
  • Drop rejected: otherwise the item glides back to its pile position (0.3 s). Nothing is recorded.
  • One drag at a time: a second simultaneous drag is ignored (first touch wins, Section 10).
  • No taking back: eaten items cannot be dragged out again.
  • Tap-to-feed: for every child at every step, a single tap on an item or pack feeds it directly (it flies into the mouth in 0.4 s). This is the framework's tap alternative for drags (Section 10.9.5). Each tap feeds exactly one item or one pack, so one-to-one correspondence is kept: every item still needs its own deliberate action. Taps during the 0.4 s flight of a previous item are queued in order, and each is counted aloud on arrival.
  • Count-along: after each eaten item, the running total is spoken (num.<count>). After a pack, the new total is spoken once (e.g. after a ten-pack: "zehn"; after a following five-pack: "fünfzehn"). If an item is eaten while the previous count word is still playing, the previous word is cut and the new total is spoken, so words never queue up. The spoken count is the only running count shown. There is no visible counter, because producing the count is the task.
  • Bib support at step1: at step1 only, each eaten item fills one bib dot in order (one-to-one correspondence made visible). Items eaten beyond N show as small crumbs next to the bib (no extra dots). At step2 and step3 the bib dots stay unfilled until hint level 2.
  • Fertig: tapping the enabled Fertig plate submits the running count for evaluation (Section 13.2.7).

13.2.6 Round structure #

Tasks per round: 5 (vorschule) or 4 (littleOnes). Per task:

  1. A new pile appears (0.4 s). The monster's tummy is empty.
  2. The prompt plays (intro and the Fertig explanation before the first task of each round): "Ich habe Hunger! Gib mir {N}!" The bib shows N as numeral and dots.
  3. The child feeds and taps Fertig.
  4. Evaluation, hints and feedback.
  5. Between tasks the monster rubs its tummy and the eaten items disappear (the tummy is empty again).

13.2.7 Evaluation, over-feeding and under-feeding #

Let c be the running count at the moment Fertig is tapped.

Result Treatment
c == N Correct (outcome per attempt number, Section 10).
c < N (too few) Wrong attempt. The eaten items stay eaten. The monster reacts with the hint for this attempt level (Section 13.2.9), and the child continues feeding and taps Fertig again.
c > N (too many) Wrong attempt. Only after submission (never during feeding) the monster says it was too much and spits back exactly c − N items: they fly back to free pile positions (0.6 s; if a pack caused the excess, the excess returns as single items), with sfx.monster_spit, and the monster recounts its tummy aloud. Now c == N and the Fertig plate pulses. The child must tap Fertig again to confirm, which is evaluated as the next attempt (correct, so afterHint at best).

The monster never reacts to over-feeding while the child is still feeding. Decision: stopping at N is the skill being practised, so feedback only comes when the child declares being done.

13.2.8 Prompts #

Audio ID German text When Visual equivalent
prompt.zahlenmonster.intro "Das ist das Zahlenmonster. Es hat großen Hunger!" First task of a round Monster waves
prompt.zahlenmonster.give_me "Gib mir {N}!" (num.<N>) Every task Bib with numeral N and dots pulses once; demo hand drags one item into the mouth (voice-off or inactivity, Section 10)
prompt.zahlenmonster.press_done "Wenn du fertig bist, drück auf den Teller." After give_me on the first task of a round Fertig plate pulses once
hint.zahlenmonster.more "Mmh! Das waren …" (fragment; composed as more + num.<c> + i_want + num.<N> → "Mmh! Das waren {c}. Ich möchte {N}.") Too few, hint level 1 (c ≥ 2) Monster rubs its tummy; bib numeral pulses
hint.zahlenmonster.more_one "Mmh! Das war eins." Too few, hint level 1, c = 1 (replaces more + num.<c>; followed by i_want + num.<N>) Monster rubs its tummy; bib numeral pulses
hint.zahlenmonster.i_want "… Ich möchte …" (fragment, between c and N) Too few, hint level 1 Bib numeral pulses
hint.zahlenmonster.too_many "Uff, das waren …" (fragment; composed as too_many + num.<c> + too_much → "Uff, das waren {c}. Das ist zu viel!") Too many (any level), before spitting back Monster puffs cheeks
hint.zahlenmonster.too_much "… Das ist zu viel!" (fragment, follows too_many + num.<c>) Too many (any level), before spitting back Monster puffs cheeks
hint.zahlenmonster.now_have "Jetzt habe ich {N}." After spitting back and recounting Bib dots fill to N during the recount
hint.zahlenmonster.look_bib "Schau auf meinen Latz. Wie viele Punkte sind noch leer?" Too few, hint level 2 Bib dots fill for the eaten items; empty dots remain outlined
(solution, composed) "Schau: {N}. So viele wollte ich." (fb.solution.schau + num.<N> + fb.solution.so_viele_wollte_ich, Section 21) Solution demonstration Monster takes or returns items itself
prompt.zahlenmonster.yum "Mmh, genau …" (fragment; composed as yum + num.<N> + thanks → "Mmh, genau {N}! Danke!") Correct confirmation Monster happy animation
prompt.zahlenmonster.thanks "Danke!" Correct confirmation, after yum + num.<N> Monster happy animation

During hint.zahlenmonster.now_have and the recount, the count-along words num.1 … num.<N> play (0.4 s per item) while the tummy's items briefly appear as dots on the bib.

13.2.9 Hints #

  • Hint level 1 (first wrong attempt):
    • Too few: hint.zahlenmonster.more.
    • Too many: hint.zahlenmonster.too_many → spit back → recount → hint.zahlenmonster.now_have.
  • Hint level 2 (second wrong attempt):
    • Too few: hint.zahlenmonster.look_bib. From now on the bib shows filled dots for eaten items and outlined empty dots for the rest, and it keeps updating as the child feeds.
    • Too many: the same spit-back sequence as level 1, then the bib fill is shown.
  • After a first or second wrong submission that was too many, the spit-back leaves the tummy at exactly N, so the following Fertig tap is correct. The solution demonstration runs on the third wrong submission, whatever kind it is. If that third submission is too many, the spit-back becomes part of the solution demonstration (Section 13.2.10).

13.2.10 Solution demonstration #

After the third wrong submission: if the count was too low, the monster fetches the missing items itself (they fly from the pile one by one, 0.4 s each, with count-along) until c == N. If it was too high, the monster spits back the extra items and recounts its tummy from 1 to N (0.4 s per item). The plate gets the soft-green ring and "Schau: {N}. So viele wollte ich." plays. The child taps the plate to continue. The outcome is shown.

13.2.11 Correct feedback #

The monster closes its mouth, smiles, and does a happy wiggle (0.8 s; Reduce Motion: a static smile swap). The bib dots all fill (if not already filled), and prompt.zahlenmonster.yum + num.<N> + prompt.zahlenmonster.thanks plays. The framework fb.correct.* line is skipped in this game, because yum replaces it. Haptic: the correct-answer haptic of the Section 19.10 map.

13.2.12 Task generation #

Given N: food type (RNG, no repeat), supply per Section 13.2.4, scattered positions (normalized 0…1 within the pile area), and prompt variant. Nothing else.

enum FeedItemKind: Sendable, Equatable { case single, fivePack, tenPack }
struct FeedItem: Sendable, Equatable, Identifiable {
    let id: Int
    let kind: FeedItemKind
    let position: CGPoint          // normalized 0...1 within the pile area
}
struct ZahlenmonsterTaskContent: Sendable, Equatable {
    let target: Int                // N, credited
    let foodID: String             // counting-object catalogue typeID (Section 11.1.4), e.g. "apfel"
    let supply: [FeedItem]
    let bibFillsDuringFeeding: Bool // true at step1
}
Parameter littleOnes step1 littleOnes step2 vorschule step1 vorschule step2 vorschule step3
numberMin–numberMax 1–5 1–10 1–5 1–10 6–20
Supply max(N+3, 6), cap 10 min(N+4, 14) max(N+3, 6), cap 10 min(N+4, 14) 2 × five-pack, ten-pack if N ≥ 10, min(9, fitting) singles
Arrangement structured tray scattered structured tray scattered packs + scattered singles
bibFillsDuringFeeding true false true false false
Tap-to-feed on on on on on

13.2.13 Parameters: games/zahlenmonster.json #

{
  "schemaVersion": 1,
  "gameId": "zahlenmonster",
  "tier": "premium",
  "primarySkill": "count",
  "secondarySkill": "recognize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "expectedRoundSeconds": 110,
  "littleOnesMaxStep": 2,
  "promptIds": ["prompt.zahlenmonster.intro", "prompt.zahlenmonster.give_me", "prompt.zahlenmonster.press_done", "prompt.zahlenmonster.yum", "prompt.zahlenmonster.thanks"],
  "hintIds": {
    "level1": ["hint.zahlenmonster.more", "hint.zahlenmonster.too_many", "hint.zahlenmonster.now_have", "hint.zahlenmonster.more_one", "hint.zahlenmonster.i_want", "hint.zahlenmonster.too_much"],
    "level2": ["hint.zahlenmonster.look_bib", "hint.zahlenmonster.too_many", "hint.zahlenmonster.now_have", "hint.zahlenmonster.too_much"]
  },
  "solutionIds": ["fb.solution.schau", "fb.solution.so_viele_wollte_ich"],
  "gameParameters": {
    "foods": ["apfel", "birne", "erdbeere", "karotte", "banane"],
    "mouthZoneExpandPt": { "iPhone": 40, "iPad": 56 },
    "mouthOpenDistancePt": 120,
    "eatSeconds": 0.25,
    "tapFlySeconds": 0.4,
    "returnSeconds": 0.3,
    "spitBackSeconds": 0.6,
    "recountSecondsPerItem": 0.4,
    "solutionSecondsPerItem": 0.4,
    "scatterMinDistancePt": { "iPhone": 64, "iPad": 88 },
    "tapToFeed": true
  },
  "steps": [
    { "id": "step1", "labelKey": "game.step.leicht", "numberMin": 1, "numberMax": 5,
      "parameters": { "supply": { "rule": "plusThreeMinSixCapTen" }, "arrangement": "structuredTray", "bibFillsDuringFeeding": true } },
    { "id": "step2", "labelKey": "game.step.mittel", "numberMin": 1, "numberMax": 10,
      "parameters": { "supply": { "rule": "plusFourCapFourteen" }, "arrangement": "scattered", "bibFillsDuringFeeding": false } },
    { "id": "step3", "labelKey": "game.step.schwer", "numberMin": 6, "numberMax": 20,
      "parameters": { "supply": { "rule": "packs", "fivePacks": 2, "tenPacks": 1, "tenPackMinN": 10, "maxSingles": 9, "minSingles": 6 },
                      "arrangement": "packsAndScattered", "bibFillsDuringFeeding": false } }
  ]
}

Every ID in foods must be an object of the counting-object catalogue (Section 11.1.4) flagged as food; content validation rejects any other ID (Section 8). tapToFeed must be true in V1 (every drag has its tap alternative, Section 10.9.5). If fewer than minSingles singles fit on a device, the layout test fails (Section 25); this never happens on the supported sizes listed in Section 13.2.4.

13.2.14 Mastery credit #

Each task credits count (1.0) and recognize (0.5) for N. Intermediate counts are not credited.

13.2.15 Edge cases #

Case Behaviour
Child taps Fertig with 0 items Disabled; wiggle only; no attempt.
Child drops an item on the Fertig plate Not the mouth: the item returns to the pile. The plate is not triggered by drops.
Child feeds 20 singles quickly at step2 Only min(N+4, 14) items exist. Counting words are cut to the latest total.
Child drags two items with two fingers The second drag is ignored.
Over-feeding with a ten-pack when N = 12 and c = 25 Spit back 13 singles to free pile positions (positions recomputed with the scatter rules; if no room, items stack in a tidy grid at the pile's bottom edge).
App backgrounded mid-drag The drag is cancelled and the item returns to the pile. Eaten items stay.
Rotation mid-task Monster and pile re-layout. Pile items keep their normalized positions. Eaten items stay eaten.
Voice off Count-along is replaced by a numeral bubble near the mouth (child.numeral, 44 pt iPhone / 64 pt iPad) showing the running count for 0.8 s after each eaten item. Decision: without voice the child needs some count feedback, and the numeral bubble adds the recognize link. At step2/3 with voice off, the bubble is the only count carrier.
VoiceOver running Items are accessibility elements labeled with the food name. A double-tap feeds (tap-to-feed, which is always on). The plate is labeled "Fertig".
Reduce Motion Items cross-fade into the mouth and back to the pile; the spit-back is a fade-out at the mouth and a fade-in in the pile.

13.2.16 Acceptance criteria #

  • AC-ZM-01 Given step1 with N = 4, when the task starts, then the pile holds 7 singles in a structured tray (5 + 2), the bib shows "4" and four dots, and "Gib mir vier!" plays.
  • AC-ZM-02 Given any step, when the child drops an item whose center is inside the mouth zone, then the item is eaten and the new running total is spoken. When dropped outside, the item returns and nothing is spoken or recorded.
  • AC-ZM-03 Given N = 5 and the child feeds 7 items and taps Fertig, then the monster says "Uff, das waren sieben. Das ist zu viel!", spits back exactly 2 items, says "Jetzt habe ich fünf.", and a following Fertig tap completes the task with outcome afterHint.
  • AC-ZM-04 Given the child feeds 6 items with N = 8, when feeding, then the monster gives no feedback about the count until Fertig is tapped.
  • AC-ZM-05 Given step3 with N = 17 on iPhone SE portrait, when the pile is generated, then it contains one ten-pack, two five-packs (in one column, items at 44 pt) and min(9, fitting) singles, at least 6, all inside the 343 × 360 pt pile area without overlap; and feeding the ten-pack, then a five-pack, then two singles yields spoken totals "zehn", "fünfzehn", "sechzehn", "siebzehn".
  • AC-ZM-06 Given three too-few submissions, when the solution runs, then the monster fetches the missing items with count-along, the plate is ringed, and after the child taps it the outcome is shown. Given two too-few submissions followed by a too-many submission, then the monster spits back the extras as part of the solution, and the outcome is shown.
  • AC-ZM-07 Given step2 or step3, when the child feeds, then the bib dots do not fill until hint level 2 has been reached in that task.
  • AC-ZM-08 Given a child of either level at any step, when the child taps a single item, then it flies into the mouth and the running count increases by exactly 1; when the child taps a five-pack or ten-pack, then the count increases by exactly 5 or 10.
  • AC-ZM-09 Given two consecutive tasks, when generated, then they use different food types, both from apfel, birne, erdbeere, karotte, banane.
  • AC-ZM-10 Given iPhone SE portrait and landscape, when any step renders, then the monster, the Fertig plate and the pile do not overlap, every item has a 60 pt touch frame, and the bib numeral is at least 44 pt.

13.3 Memory #

13.3.1 Purpose and skills #

Memory is the classic pairs game with one twist: a pair is never two identical pictures, but the same number in two different representations (numeral and quantity, numeral and dice pattern, quantity and finger pattern). Finding a pair means recognizing that both cards show the same number.

Field Value
GameID memory
Display name Memory
Tier premium
Primary skill recognize (weight 1.0)
Secondary skill subitize (weight 0.5)
Swift target GameMemory

The display name is read only from the string key game.memory.title in Content.xcstrings (displayNameKey, Section 8.8.1). No voice line, asset name or code literal contains the word 'Memory'. If the launch trademark check (Section 26.9.1) does not clear it, the value of game.memory.title changes to 'Paare finden' without any code change; GameID.memory, the target GameMemory, the file games/memory.json and all audio IDs stay unchanged.

13.3.2 Level availability and limits #

Level Steps available Numbers per step
littleOnes step1, step2 (littleOnesMaxStep = 2) step1: 1–10; step2: 1–10
vorschule step1, step2, step3 step1: 1–10; step2: 1–10; step3: 1–20

All values are intersected with the active range. There is no move counter, no timer, no score and no "best result" anywhere in this game.

13.3.3 Board, pairs and representations #

Step Cards / pairs Pair types (weights)
step1 6 cards / 3 pairs numeralQuantity 1.0
step2 8 cards / 4 pairs numeralQuantity 0.5, numeralDice 0.5 (dice only for numbers 1–6)
step3 12 cards / 6 pairs numeralQuantity 0.35, numeralDice 0.25 (1–6), quantityFinger 0.40 (1–10)

Card faces:

Face Rendering
Numeral NumeralTile style, numeral centered at 44 pt (SE) / 64 pt (iPad) minimum, larger when the card allows
Quantity 1–10: five-structured bead row (BeadChainView: solid red first five, blue Lochperle second five, 1.5× gap), wrapped as two rows of five on narrow cards. 11–20: mini ZwanzigerfeldView
Dice DiceView pip layout per Section 19.7.4
Finger FingerPatternView (German convention, thumb first; Section 12.2.3)

Card back: identical for all cards (a cream card with the app's bead-chain motif). It never encodes anything.

Pair-type fallback per number: if the chosen type does not support the number (dice for n > 6, fingers for n > 10), the next supported type in the order numeralQuantity, numeralDice, quantityFinger is used. Numbers 11–20 always use numeralQuantity.

Each board has distinct numbers only, so no two pairs share a number. The resulting cards are visually unique on the board.

13.3.4 Screen layout #

The grid is laid out once at board creation. Card positions are shuffled once with the RNG and never change during the game: no reshuffling, and matched cards stay in place.

Board iPhone SE portrait iPhone SE landscape iPad portrait iPad landscape
6 cards 2 columns × 3 rows, 150 × 150 pt 3 × 2, 130 × 130 pt 2 × 3, 220 × 220 pt 3 × 2, 240 × 240 pt
8 cards 2 × 4, 125 × 125 pt 4 × 2, 130 × 130 pt 2 × 4, 200 × 200 pt 4 × 2, 220 × 220 pt
12 cards 3 × 4, 106 × 106 pt 6 × 2, 94 × 94 pt (6 × 94 + 5 × 12 = 624 pt ≤ 635 pt available) 3 × 4, 180 × 180 pt 4 × 3, 180 × 180 pt

Gap between cards: 12 pt (iPhone) / 20 pt (iPad). The grid is centered in the task area. On rotation the grid switches between the portrait and landscape shapes, and each card keeps its logical index. Decision: the logical order (index 0…k−1, row-major in the portrait shape) is preserved, so a card's neighbours stay stable within one orientation and the positions are reproducible from the seed.

13.3.5 Turn mechanics #

  1. The child taps a face-down card. It flips face-up (0.3 s; Reduce Motion: cross-fade) with sfx.card_flip. No number is spoken on flip. Decision: speaking would turn the game into an audio-matching game and bypass recognition.
  2. The child taps a second face-down card. It flips face-up. Input is now locked until the turn resolves.
  3. Match (same number): after 0.4 s both cards get a soft-green border, scale to 0.92, and become inert. sfx.card_match plays, then num.<n>, followed by the match line (Section 13.3.8). The task for n completes (Section 13.3.6).
  4. Mismatch: both cards stay face-up for 1.5 s, then flip back (0.3 s). No sound other than the flip. No wrong-sound, because a mismatch in Memory is normal play.
  5. Taps on face-up cards, matched cards, or during the 1.5 s mismatch display are ignored. They are not attempts.
  6. The board is complete when all pairs are matched. The round then ends (Section 10 round completion).

13.3.6 Tasks, attempts and outcomes #

A Memory round is one board. Each matched pair is one task for that pair's number n. The round therefore has 3, 4 or 6 tasks (step1, step2, step3). games/memory.json declares this with the per-step tasksPerRound override (Section 8.8.2), which the engine uses as the round's task count N (Section 9.9.2) and the framework uses as the plan's task count (Section 10.4.2). Decision: this overrides the general 5 / 4 tasks per round for Memory only. Section 14's stars follow the actual task count (one star per matched pair plus the round bonus). The progress dots (Section 10) show one dot per pair.

Not every mismatch is a mistake, because the first sighting of a card cannot be matched from memory. Attempts are therefore counted precisely:

  • A card is seen once it has been face-up at least once, in any turn.
  • A mismatch turn is a knowable miss for pair P when the first card flipped in that turn belongs to P and the partner card of P had already been seen before this turn started.
  • Only knowable misses count as wrong attempts, and only for pair P. The second card of a mismatch turn is a guess and is never counted. Mismatches where the partner was never seen are free.
  • When pair P is matched, its outcome is:
    • firstTry if P had 0 knowable misses;
    • afterHint if P had 1 or 2;
    • shown if the solution demonstration ran for P (3rd knowable miss, below).

A match found by luck (the partner was never seen) is firstTry.

13.3.7 Hints and solution demonstration per pair #

Hints are delivered when the child flips the first card of a turn and that card's pair P has at least one knowable miss:

Knowable misses of P When the child next flips a card of P as the first card of a turn
1 Hint level 1: hint.memory.where_other "Wo war noch eine {n}?" (num.<n>.q) plays, and the row containing the partner card gets a soft highlight band (cream-yellow at 40% opacity) for 2.0 s.
2 Hint level 2: hint.memory.look_here "Schau mal hier!" plays, and the partner card wiggles (±3°, 2 cycles in 0.6 s; Reduce Motion: 2 soft glow pulses instead).
3 or more Solution demonstration: the partner card gets the soft-green ring and gentle pulse, and fb.solution.hier_ist_die_andere + num.<n> "Hier ist die andere {n}." (Section 21) plays. The child taps the ringed card, which flips and matches. Outcome shown.

The framework's generic fb.tryagain.* lines are not used in Memory (Section 13.3.5, rule 4). The hint ladder mechanics (Section 10) are applied per pair, using knowable misses as the attempt count. This is the one game where attempts are attributed to tasks that are not presented one at a time.

13.3.8 Prompts #

Audio ID German text When Visual equivalent
prompt.memory.intro "Finde die Paare! Zwei Karten mit der gleichen Zahl." Start of the board Demo: two example cards (not from this board) flip, show 3 and three dots, and move together (1.5 s), then disappear
prompt.memory.continue "Such weiter!" The board's task prompt, replayed by the Section 10 inactivity re-prompt after hint.common.listen_again Demo hand taps a face-down card
prompt.memory.match "Ein Paar!" After num.<n> on a match Green borders
prompt.memory.all_found "Alle Paare gefunden!" Board complete, before the round celebration All cards gently bounce once
hint.memory.where_other "Wo war noch eine {n}?" Hint level 1 Row band
hint.memory.look_here "Schau mal hier!" Hint level 2 Partner card wiggles
fb.solution.hier_ist_die_andere + num.<n> (Section 21) "Hier ist die andere {n}." Solution Partner card ringed

13.3.9 Correct feedback #

A match gets the green borders, the scale to 0.92, num.<n>, then prompt.memory.match. The framework fb.correct.* line is played only for the board's final pair (then prompt.memory.all_found). Decision: in Memory, a celebration line after every pair would slow the game down, and matches come in quick succession.

13.3.10 Task generation #

The engine plans k numbers for the round (k = the step's tasksPerRound: 3, 4 or 6). The game declares distinctNumbersPerRound: true, which the engine honours (Section 9). Defensive fallback: a duplicate planned number is replaced by the nearest unused number in the supported range (lower first), and the task is recorded under the replacement.

Generation:

  1. For each number, choose the pair type by the step's weights, restricted to supported types (Section 13.3.3).
  2. Create two cards per pair and shuffle all positions once with the RNG.
  3. Constraint: the two cards of a pair are never orthogonally adjacent at step1 and step2 (re-shuffle up to 50 times; then accept). Decision: adjacent partners would make the first boards trivially guessable.
enum MemoryPairType: String, Codable, Sendable { case numeralQuantity, numeralDice, quantityFinger }
enum MemoryFace: Sendable, Equatable { case numeral(Int), quantity(Int), dice(Int), fingers(Int) }
struct MemoryCard: Sendable, Equatable, Identifiable {
    let id: Int              // logical grid index 0..<cardCount
    let number: Int
    let face: MemoryFace
}
struct MemoryBoard: Sendable, Equatable {
    let cards: [MemoryCard]  // index = logical position
    let pairTypes: [Int: MemoryPairType] // number -> type
}
struct MemoryPairState: Sendable, Equatable {
    var knowableMisses = 0
    var solutionShown = false
    var matched = false
}
Parameter littleOnes step1 littleOnes step2 vorschule step1 vorschule step2 vorschule step3
numberMin–numberMax 1–10 1–10 1–10 1–10 1–20
tasksPerRound (step field; pairs) 3 4 3 4 6
pairTypes numeralQuantity numeralQuantity 0.5, numeralDice 0.5 numeralQuantity numeralQuantity 0.5, numeralDice 0.5 numeralQuantity 0.35, numeralDice 0.25, quantityFinger 0.40
mismatchShowSeconds 1.5 1.5 1.5 1.5 1.5
avoidAdjacentPartners true true true true false

13.3.11 Parameters: games/memory.json #

{
  "schemaVersion": 1,
  "gameId": "memory",
  "tier": "premium",
  "primarySkill": "recognize",
  "secondarySkill": "subitize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "distinctNumbersPerRound": true,
  "expectedRoundSeconds": 120,
  "littleOnesMaxStep": 2,
  "promptIds": ["prompt.memory.intro", "prompt.memory.continue", "prompt.memory.match", "prompt.memory.all_found"],
  "hintIds": { "level1": ["hint.memory.where_other"], "level2": ["hint.memory.look_here"] },
  "solutionIds": ["fb.solution.hier_ist_die_andere"],
  "gameParameters": { "flipSeconds": 0.3, "matchDelaySeconds": 0.4, "mismatchShowSeconds": 1.5, "matchedScale": 0.92 },
  "steps": [
    { "id": "step1", "labelKey": "game.step.leicht", "numberMin": 1, "numberMax": 10,
      "tasksPerRound": 3, "expectedRoundSeconds": 70,
      "parameters": { "pairTypes": { "numeralQuantity": 1.0 }, "avoidAdjacentPartners": true } },
    { "id": "step2", "labelKey": "game.step.mittel", "numberMin": 1, "numberMax": 10,
      "tasksPerRound": 4, "expectedRoundSeconds": 100,
      "parameters": { "pairTypes": { "numeralQuantity": 0.5, "numeralDice": 0.5 }, "avoidAdjacentPartners": true } },
    { "id": "step3", "labelKey": "game.step.schwer", "numberMin": 1, "numberMax": 20,
      "tasksPerRound": 6, "expectedRoundSeconds": 170,
      "parameters": { "pairTypes": { "numeralQuantity": 0.35, "numeralDice": 0.25, "quantityFinger": 0.40 }, "avoidAdjacentPartners": false } }
  ]
}

The step-level tasksPerRound is the number of pairs on the board; it replaces the game-level tasksPerRound for rounds at that step, for both levels (Section 12.1.3). The game-level value stays present because the envelope requires it and it is the fallback for any step without an override. The step-level expectedRoundSeconds replaces the game-level value for Abenteuer time budgeting (Section 9.16.5). The game derives the card count as 2 × tasksPerRound.

13.3.12 Mastery credit #

Each matched pair credits recognize (1.0) and subitize (0.5) for the pair's number n. Every pair type contains a non-numeral quantity card, so the secondary credit always applies.

13.3.13 Edge cases #

Case Behaviour
Child taps two cards simultaneously The first touch-down flips; the second is ignored (Section 10).
Child taps the same card twice The second tap is ignored (already face-up).
Backgrounding during the 1.5 s mismatch display On resume, both cards are face-down (the flip-back is applied). The miss is already counted.
Rotation The grid reshapes. Face-up/face-down and matched states are kept per logical index.
Engine plans fewer distinct numbers than pairs because the range is small (e.g. r5 and 6 pairs) Cannot occur for littleOnes (max 4 pairs; r5 has 5 numbers). For vorschule step3 (6 pairs) the range is at least r10. Defensive: if the supported range has fewer numbers than pairs, the board is reduced to as many pairs as distinct numbers exist (minimum 2).
A pair's solution ran while the child had flipped another card first The solution only triggers when a card of P is the first card of a turn. There is never a third open card.
Voice off Hints are the row band and the wiggle. The match is confirmed by the green border.
VoiceOver Face-down cards are labeled "Karte {index}". Face-up cards are labeled with their content ("Zahl 3", "3 Punkte", "Würfel 3", "3 Finger"). This is for adult assistance only (Section 19).

13.3.14 Acceptance criteria #

  • AC-ME-01 Given step1, when a board is generated, then it has 6 cards forming 3 pairs of distinct numbers, each pair a numeral card and a five-structured quantity card, and no pair's cards are orthogonally adjacent.
  • AC-ME-02 Given two face-up cards that do not match, when 1.5 s pass, then both flip face-down, and no card on the board has changed position.
  • AC-ME-03 Given a pair P whose cards were both never seen, when the child finds the match by chance, then the outcome for P's number is firstTry.
  • AC-ME-04 Given the child has seen the numeral-3 card, when the child later flips the quantity-3 card first and then a wrong card, then P(3) has 1 knowable miss. When the child later flips a 3-card first again, hint level 1 plays ("Wo war noch eine Drei?") with the partner's row band.
  • AC-ME-05 Given a mismatch turn where the first card's partner was never seen, when it resolves, then no knowable miss is counted for any pair.
  • AC-ME-06 Given P has 3 knowable misses, when the child flips a card of P first, then the partner card is ringed, and tapping it matches the pair with outcome shown.
  • AC-ME-07 Given step3, when the board completes, then exactly 6 task completions were emitted and the round shows 6 progress dots.
  • AC-ME-08 Given any board, then no move counter, timer or score is displayed at any time.
  • AC-ME-09 Given a numeralDice pair type chosen for number 8, when generated, then the pair falls back to numeralQuantity.

13.4 Punkt zu Punkt #

13.4.1 Purpose and skills #

Punkt zu Punkt ("dot to dot"): the child connects numbered dots in order, and a picture appears. Each finished picture becomes a sticker in the sticker album (Section 14). This trains the number sequence (order) and numeral recognition.

Field Value
GameID punkt_zu_punkt
Display name Punkt zu Punkt
Tier premium
Primary skill order (weight 1.0)
Secondary skill recognize (weight 0.5)
Swift target GamePunktZuPunkt

13.4.2 Level availability and limits #

Level Steps available Picture sizes
littleOnes step1, step2 (littleOnesMaxStep = 2) step1: 5- or 10-point pictures (5-point only in r5); step2: 10-point pictures (requires r10 or wider)
vorschule step1, step2, step3 step1: 5 or 10; step2: 10; step3: 20 (requires r20)

Decisions:

  • The number of points P of a chosen picture never exceeds the active range maximum.
  • Step2 requires at least r10, and step3 requires r20. The game declares these with the per-step minRange envelope field (Section 8.8.2); the engine reads it as stepMinRange (Section 9.3.3) and never plans a step whose minRange exceeds the active range (Section 9.9.3). For example, a vorschule child in r10 plays Punkt zu Punkt at step1 or step2 only.

13.4.3 Picture catalog (24 pictures, 8 per step) #

Each picture is one file Resources/Content/dotpictures/<pictureId>.json using the dot-picture schema of Section 8.12, listed in manifest.dotPictures (Section 8.5). Each picture has exactly one sticker (the 24 Punkt-zu-Punkt stickers of Section 14.7.2, which owns the album). Picture IDs are plain slugs (^[a-z][a-z0-9_]*$), sticker IDs are sticker.dot.<pictureId>, and the reveal name line is label.dot.<pictureId> (declared in the picture file as nameAudioId, Section 8.12).

# pictureId German name Step Points P closed Sticker ID Reveal line (label.dot.<pictureId>)
1 stern Stern step1 5 true (pentagram) sticker.dot.stern "Ein Stern!"
2 haus Haus step1 5 true sticker.dot.haus "Ein Haus!"
3 fisch Fisch step1 5 true sticker.dot.fisch "Ein Fisch!"
4 sonne Sonne step1 5 true sticker.dot.sonne "Eine Sonne!"
5 herz Herz step1 10 true sticker.dot.herz "Ein Herz!"
6 boot Boot step1 10 true sticker.dot.boot "Ein Boot!"
7 blume Blume step1 10 true sticker.dot.blume "Eine Blume!"
8 luftballon Luftballon step1 10 true sticker.dot.luftballon "Ein Luftballon!"
9 schmetterling Schmetterling step2 10 true sticker.dot.schmetterling "Ein Schmetterling!"
10 schnecke Schnecke step2 10 false sticker.dot.schnecke "Eine Schnecke!"
11 katze Katze step2 10 true sticker.dot.katze "Eine Katze!"
12 hase Hase step2 10 true sticker.dot.hase "Ein Hase!"
13 auto Auto step2 10 true sticker.dot.auto "Ein Auto!"
14 rakete Rakete step2 10 true sticker.dot.rakete "Eine Rakete!"
15 tannenbaum Tannenbaum step2 10 true sticker.dot.tannenbaum "Ein Tannenbaum!"
16 vogel Vogel step2 10 true sticker.dot.vogel "Ein Vogel!"
17 elefant Elefant step3 20 true sticker.dot.elefant "Ein Elefant!"
18 giraffe Giraffe step3 20 true sticker.dot.giraffe "Eine Giraffe!"
19 dinosaurier Dinosaurier step3 20 true sticker.dot.dinosaurier "Ein Dinosaurier!"
20 schiff Schiff step3 20 true sticker.dot.schiff "Ein Schiff!"
21 eisenbahn Eisenbahn step3 20 true sticker.dot.eisenbahn "Eine Eisenbahn!"
22 burg Burg step3 20 true sticker.dot.burg "Eine Burg!"
23 schildkroete Schildkröte step3 20 true sticker.dot.schildkroete "Eine Schildkröte!"
24 einhorn Einhorn step3 20 true sticker.dot.einhorn "Ein Einhorn!"

The sortIndex of each picture (Section 8.12) is its position within its step in this table (0–7); it is the catalog order used by Section 13.4.5. The reveal assets are named dotpic_<pictureId>_reveal (flat asset naming, Section 6.4.3).

Step design rules (content authoring; validated in Section 13.4.12):

  • step1: simple convex or near-convex outlines. Consecutive lines never cross each other, except the pentagram of stern.
  • step2: more complex shapes: concave outlines, a line may cross an earlier line, and consecutive dots may be far apart or change direction sharply.
  • step3: 20 points, complex outlines. Dot 11 must not be adjacent (within 0.25) to dot 1, so the tens are not guessable from position.

13.4.4 Fields used from the dot-picture file #

The schema is owned by Section 8.12. This game uses the following fields (names as in Section 8.12; listed here with the game's additional constraints):

Field Use in this game Constraint enforced by this game's validation
pictureId Identity, rotation state Slug ^[a-z][a-z0-9_]*$; one of the 24 IDs of Section 13.4.3 in V1
step Step membership step1 / step2 / step3
points[] (number, x, y) Dots; x, y normalized 0…1 in the square canvas, y down Numbers exactly 1…P, contiguous; P ∈ {5, 10} for step1, 10 for step2, 20 for step3; 0.05 ≤ x, y ≤ 0.95; minimum pairwise distance ≥ 0.17 (the same minimum as the Section 8.12 geometry rule C082); each label box at its placed position (Section 13.4.7) overlaps no other dot and no other label at the iPhone SE canvas size
closed Whether the line from P back to 1 is drawn automatically at completion Boolean
revealAssetName Colored illustration shown at completion, drawn to the same normalized square dotpic_<pictureId>_reveal; asset exists
nameAudioId Reveal line spoken at completion Required by this game: label.dot.<pictureId>, declared in the voice-line registry (Section 8.7)
stickerId Sticker awarded on first completion sticker.dot.<pictureId>; exists in stickers.json
sortIndex Catalog order within the step 0–7, unique within the step
outlineAssetName Not used by this game in V1 Must be absent

Optional per-point labelOffset ({ "dx": Double, "dy": Double }, normalized) overrides the automatic label placement of Section 13.4.7 where a picture needs it.

Example file (haus, step1, 5 points):

{
  "schemaVersion": 1,
  "pictureId": "haus",
  "step": "step1",
  "nameKey": "dotpicture.haus.name",
  "nameAudioId": "label.dot.haus",
  "closed": true,
  "points": [
    { "number": 1, "x": 0.20, "y": 0.90 },
    { "number": 2, "x": 0.80, "y": 0.90 },
    { "number": 3, "x": 0.80, "y": 0.48 },
    { "number": 4, "x": 0.50, "y": 0.12 },
    { "number": 5, "x": 0.20, "y": 0.48 }
  ],
  "revealAssetName": "dotpic_haus_reveal",
  "stickerId": "sticker.dot.haus",
  "sortIndex": 1,
  "retired": false
}

13.4.5 Picture selection and rotation #

The game keeps per-profile state (Section 12.1.4):

struct PunktZuPunktState: Codable, Sendable {
    var v: Int = 1
    var completedAt: [String: Date] = [:]   // pictureId -> last completion time
    var lastPictureId: String? = nil
}

Selection for a round at step s with active range maximum hi and planned checkpoint numbers C:

  1. Eligible pictures are those with step == s, P ≤ hi and P ≥ max(C).
  2. Prefer pictures never completed (not in completedAt), in catalog order. The catalog order is the table order in Section 13.4.3, so a new child meets the 5-point pictures first in r5.
  3. Otherwise choose the least recently completed, excluding lastPictureId if another eligible picture exists. Ties are broken by the RNG.
  4. If no picture is eligible (only possible through a planning inconsistency), clamp all C to ≤ P of the largest picture with step == s and P ≤ hi, and log .error.

Decision: completion of a picture is recorded in the game state (for rotation) and emitted as an event for the sticker album (Section 13.4.10). The sticker album itself is owned by Section 14 and never read by the game.

13.4.6 Checkpoints: how a picture maps to mastery tasks #

One picture is one round. A picture has 5, 10 or 20 dots, but mastery is recorded only at checkpoint dots, so that a 20-dot picture does not flood the mastery records and round lengths stay comparable with other games.

  • The engine plans the usual number of tasks (5 for vorschule, 4 for littleOnes), each with a number c. These numbers are the checkpoints.
  • The game declares distinctNumbersPerRound: true. If a duplicate still arrives, it is replaced by the nearest unused number in 1…P and recorded under the replacement.
  • A checkpoint task for c covers the moment when dot c is the next expected dot. Its attempts are the wrong dot taps made while c was expected. Its outcome is firstTry (0 wrong taps), afterHint (1–2) or shown (solution demonstration ran, after the 3rd wrong tap).
  • Non-checkpoint dots use exactly the same interaction and hint ladder (the child is always helped), but record no mastery and no task.
  • The task completion event for checkpoint c is emitted when dot c is connected. Stars follow Section 14: one per checkpoint task plus the round bonus. The picture's sticker is additional (Section 13.4.10).
  • The progress dots of Section 10 are hidden in this game. The picture itself shows progress. Decision: dots for checkpoints hidden among the drawing's dots would confuse the child.

13.4.7 Screen layout #

Element iPhone SE portrait iPhone SE landscape iPad portrait iPad landscape
Canvas Square, 359 × 359 pt, horizontally centered, top 16 pt below the top bar Square, full screen height minus 8 pt top and bottom (359 × 359 pt), horizontally centered. The top-bar buttons stay in the screen corners, outside the canvas (154 pt free on each side) Square 720 × 720 pt, centered Square 720 × 720 pt, centered; top-bar buttons in the corners
Dot visual 14 pt ink circle (outline until connected, filled with ink once connected) 14 pt 20 pt 20 pt
Dot numeral label child.numeral 44 pt, label center placed 36 pt from the dot center in the direction away from the polygon's centroid (content may override with labelOffset); labels of connected dots fade to 40 % opacity 44 pt 64 pt, 52 pt away 64 pt
Dot hit target Circle of radius 30 pt around the dot center (60 pt target) Same Radius 36 pt Radius 36 pt
Line Ink 4 pt, round caps (iPad 6 pt) Same 6 pt 6 pt

Hit resolution: a tap selects the dot whose center is nearest to the touch location, if that distance is ≤ the hit radius. Taps outside every hit target are ignored (not attempts). The minimum pairwise dot distance of 0.17 × 359 pt = 61 pt on iPhone SE keeps hit targets from overlapping. Labels are not tap targets; a tap on a label counts for the dot whose center is nearest, if within the hit radius.

Dots are positions in a picture, not a set of discrete answer options, so a wrongly tapped dot is never faded (the picture must stay complete); tapping it again counts as a new attempt, as Section 10.6.3 states for answers without discrete options.

Portrait below the canvas (iPhone SE: about 200 pt free) remains empty pond or paper background. Decision: nothing interactive is placed there, to keep one task per screen.

13.4.8 Round structure and interaction #

  1. The canvas shows all dots with numeral labels. prompt.punkt_zu_punkt.intro "Verbinde die Punkte! Fang bei der Eins an." plays. Dot 1 does not pulse (pulsing is reserved for hint level 1).
  2. Expected dot e starts at 1. The "current dot" is the last connected dot (none before dot 1 is tapped).
  3. Tap on dot e: it becomes connected. If e > 1, a line animates from the current dot to e (0.25 s; Reduce Motion: appears instantly) with sfx.line_connect. num.<e> plays (count-along), the dot fills with ink (never bead red or bead blue, Section 4 DR-16), its label fades to 40 % opacity so the remaining labels stand out, and e increments.
  4. Tap on any other unconnected dot: wrong attempt for the current expected dot. The neutral sfx.try_again plays, no line is drawn, and the tapped dot does a small 0.2 s shrink-and-return. The hint ladder advances for dot e (Section 13.4.9).
  5. Tap on an already connected dot (including the current one): ignored, not an attempt.
  6. When dot P is connected: if closed, the line from P to 1 draws automatically (0.4 s). Then the reveal (Section 13.4.10) runs.

Attempts per dot: wrong taps are counted per expected dot and reset when the dot is connected.

13.4.9 Hints and solution demonstration (per expected dot) #

Wrong taps while e is expected Hint
1 Hint level 1: dot e pulses gently (scale 1.0 → 1.3 → 1.0 at 1 Hz, 2 cycles, within the Section 19.9 limit) and hint.punkt_zu_punkt.where "Wo ist die {e}?" (num.<e>.q) plays. This is the only situation in which a dot pulses.
2 Hint level 2: hint.punkt_zu_punkt.after "Nach der {e−1} kommt die {e}." plays (for e = 1: hint.punkt_zu_punkt.start "Die Eins ist der Anfang."). A dotted preview line (ink 30%, 3 pt) draws from the current dot halfway towards dot e, and dot e pulses as in level 1.
3 Solution: dot e gets the soft-green ring, the dotted preview line extends fully to it, and "Nach der {e−1} kommt die {e}." plays, composed from the Section 21 carriers prompt.common.nach_der and prompt.common.kommt_die (for e = 1: hint.punkt_zu_punkt.start "Die Eins ist der Anfang."). The child taps dot e to continue (Section 10). The outcome is shown if e is a checkpoint.

Hints do not carry over: the next expected dot starts again without a hint.

13.4.10 Completion, reveal and sticker #

When the picture completes:

  1. The colored reveal illustration fades in over the line drawing (0.8 s) with sfx.picture_reveal, and the lines fade to 30%.
  2. The reveal line label.dot.<pictureId> plays ("Ein Haus!").
  3. The game emits the pictureCompleted game event with pictureID and stickerID (below). The sticker award, its animation into the album and all album logic are owned by Section 14. The sticker is awarded only on first completion; repeated completions award no second copy (duplicates are impossible, Section 14).
  4. The game updates PunktZuPunktState.completedAt[pictureId] and lastPictureId.
  5. The round completes (Section 10: round celebration, stars).

The reveal and sticker animation together stay within the Section 14 celebration limit of 2.5 s. The reveal line may overlap the sticker animation.

// ZKGameKit, declared in Section 10.13.2; the event is `GameEvent.pictureCompleted(PictureCompleted)` (Section 10.13.1). Shown here for reference.
public struct PictureCompleted: Sendable, Equatable, Codable {
    public let roundID: UUID        // RoundPlan.id
    public let pictureID: String    // e.g. "haus"
    public let stickerID: String    // "sticker.dot.<pictureId>"
    public let completedAt: Date
}

The event carries no profile ID; games do not know profiles. It is emitted before the round's roundCompleted. The app's RoundResultCoordinator attaches the active profile and, in the round save point (Sections 10.13.3 and 7.12.2), has the reward service award the sticker (Section 14.12). ZKRewards never sees ZKGameKit events.

Decision: the event is emitted on every completion. The reward service decides whether a sticker is new, which keeps "duplicates impossible" in one place (Section 14). The game's own "never completed" check (from PunktZuPunktState.completedAt) only decides whether the sticker animation cue is requested.

13.4.11 Prompts #

Audio ID German text When Visual equivalent
prompt.punkt_zu_punkt.intro "Verbinde die Punkte! Fang bei der Eins an." Round start Demo hand taps dot 1 then dot 2 of a ghost example in a corner (1.5 s), only when voice is off or on inactivity (Section 10)
hint.punkt_zu_punkt.where "Wo ist die {e}?" Hint level 1 Dot e pulses
hint.punkt_zu_punkt.after "Nach der {e−1} kommt die {e}." Hint level 2 Half preview line + pulse
hint.punkt_zu_punkt.start "Die Eins ist der Anfang." Hint level 2 when e = 1 Pulse on dot 1
(solution, composed) "Nach der {e−1} kommt die {e}." (prompt.common.nach_der + num.<e−1> + prompt.common.kommt_die + num.<e>, Section 21) Solution Green ring + full preview line
label.dot.<pictureId> "Ein Stern!" etc. (Section 13.4.3) Completion Reveal illustration

Count-along on each correct tap uses num.<e>. The framework's fb.correct.* line plays once after the reveal line, as part of the round completion.

13.4.12 Parameters: games/punkt_zu_punkt.json #

{
  "schemaVersion": 1,
  "gameId": "punkt_zu_punkt",
  "tier": "premium",
  "primarySkill": "order",
  "secondarySkill": "recognize",
  "tasksPerRound": { "littleOnes": 4, "vorschule": 5 },
  "distinctNumbersPerRound": true,
  "expectedRoundSeconds": 90,
  "littleOnesMaxStep": 2,
  "promptIds": ["prompt.punkt_zu_punkt.intro"],
  "hintIds": { "level1": ["hint.punkt_zu_punkt.where"], "level2": ["hint.punkt_zu_punkt.after", "hint.punkt_zu_punkt.start"] },
  "solutionIds": [],
  "gameParameters": {
    "minDotDistance": 0.17,
    "coordinateMargin": 0.05,
    "lineSeconds": 0.25,
    "closeLineSeconds": 0.4,
    "revealSeconds": 0.8,
    "hint1PulseScale": 1.3,
    "hint1PulseHz": 1.0,
    "hint1PulseCycles": 2,
    "connectedLabelOpacity": 0.4,
    "hideProgressDots": true
  },
  "steps": [
    { "id": "step1", "labelKey": "game.step.leicht", "numberMin": 1, "numberMax": 10, "minRange": "r5", "expectedRoundSeconds": 75,
      "parameters": { "pointCounts": [5, 10] } },
    { "id": "step2", "labelKey": "game.step.mittel", "numberMin": 1, "numberMax": 10, "minRange": "r10", "expectedRoundSeconds": 85,
      "parameters": { "pointCounts": [10] } },
    { "id": "step3", "labelKey": "game.step.schwer", "numberMin": 1, "numberMax": 20, "minRange": "r20", "expectedRoundSeconds": 150,
      "parameters": { "pointCounts": [20] } }
  ]
}

minRange and expectedRoundSeconds are per-step envelope fields (Section 8.8.2) read by the engine (Section 9.3.3). The step1 value 75 s is the budget of a 10-point picture, so a 5-point round never overruns the Abenteuer budget. The solution sentence uses only the carrier fragments prompt.common.nach_der and prompt.common.kommt_die, so solutionIds is empty; the reveal lines label.dot.<pictureId> are declared through the picture files' nameAudioId.

Game-specific validation (in addition to Section 8): at least one picture per step and per allowed point count; every picture satisfies the constraints of Section 13.4.4; step3 pictures satisfy the dot-11 rule of Section 13.4.3; each stickerId is unique across the 24 pictures and equals sticker.dot.<pictureId>; hint1PulseCycles ≤ 2 and hint1PulseHz ≤ 1.5 (Section 19.9).

13.4.13 Mastery credit #

Each checkpoint task credits order (1.0) and recognize (0.5) for its checkpoint number c. Non-checkpoint dots record nothing.

13.4.14 Edge cases #

Case Behaviour
Child drags a finger across dots instead of tapping Only discrete taps count (tap gesture with Section 10 touch slop). A drag is ignored. Decision: dragging across several dots would connect them accidentally and bypass the ordering skill.
Two dots within one hit radius of the touch The nearest dot center wins.
Tap exactly between two dots, outside both hit targets Ignored.
Rotation mid-picture The canvas resizes. Connected lines and dots are redrawn from normalized coordinates. The expected dot and hint state are kept.
Backgrounding mid-picture Section 10 pause. On resume the picture continues where it was. If the round is abandoned (home button), the partially drawn picture is discarded, the completed checkpoint tasks remain recorded (Section 10 mid-round exit rules), and no sticker is awarded.
All 8 pictures of a step completed Rotation by least recently completed (Section 13.4.5). No sticker, but stars and mastery as usual.
Picture file invalid in Release Skipped by the Section 8 loader. If a step then has no valid picture for a point count, that count is removed from eligibility. If a step has no valid pictures at all, the engine treats the step as unsupported for this game.
Voice off The pulse at hint level 1, the preview line at hint level 2 and the ring at solution carry the hints. Count-along is replaced by the connected dot's numeral enlarging 1.3× for 0.3 s.
Reduce Motion Lines appear without animation. The reveal is a 0.3 s cross-fade. The hint pulse is replaced by a static warm-yellow halo (#F5B82E, 3 s).
Checkpoint c = 1 Valid. The task covers finding dot 1 at the start.

13.4.15 Acceptance criteria #

  • AC-PP-01 Given a littleOnes child in r5, when a Punkt zu Punkt round starts, then the picture is one of the four 5-point step1 pictures, and all planned checkpoints are within 1–5.
  • AC-PP-02 Given dot 3 is expected, when the child taps dot 5, then a soft neutral sound plays, no line is drawn, and dot 3 pulses (hint level 1) with "Wo ist die Drei?". No dot pulses at any other time before a wrong tap.
  • AC-PP-03 Given dot 3 is expected, when the child taps dot 3, then a line is drawn from dot 2 to dot 3 and "drei" is spoken.
  • AC-PP-04 Given a checkpoint 7 and two wrong taps while 7 was expected, when dot 7 is connected, then a task completion for 7 with outcome afterHint is emitted, crediting order 1.0 and recognize 0.5.
  • AC-PP-05 Given a 20-point picture with checkpoints {4, 9, 12, 16, 19}, when the picture completes, then exactly 5 task completions were emitted, and none were emitted for the other 15 dots.
  • AC-PP-06 Given a profile completes haus for the first time, when the reveal runs, then a GameEvent.pictureCompleted with stickerID sticker.dot.haus is emitted, "Ein Haus!" (label.dot.haus) plays, and the round save point awards the sticker. Completing haus again later emits the event again but does not produce a second sticker (Section 14).
  • AC-PP-07 Given a vorschule child in r10, when the engine plans Punkt zu Punkt, then no step3 round is planned.
  • AC-PP-08 Given every shipped picture file, when content validation runs, then all dots satisfy 0.05 ≤ x, y ≤ 0.95 and have a minimum pairwise distance ≥ 0.17, and the numbers are contiguous from 1.
  • AC-PP-09 Given iPhone SE landscape, when a picture is shown, then the canvas is 359 × 359 pt, and no dot hit target overlaps the home or speaker button.
  • AC-PP-10 Given the child taps an already connected dot, when handled, then nothing happens and no attempt is counted.
  • AC-PP-11 Given any picture on iPhone SE and iPad, when it renders, then every dot label is at least 44 pt (iPhone) or 64 pt (iPad), no label overlaps another label or dot, and each connected dot is filled with ink and its label shown at 40 % opacity; no dot or line uses bead red or bead blue.
  • AC-PP-12 Given the shipped content, when validated, then the picture set equals the 24 IDs of Section 13.4.3 and every picture declares nameAudioId label.dot.<pictureId> and revealAssetName dotpic_<pictureId>_reveal.

14. Reward Economy and Gamification #

This section is the single source of truth for every reward in the product: stars, the Zahlengarten (the child's garden), the decoration catalog and its prices, the Zahlenfreunde (number friends), the sticker album, celebrations, and the list of forbidden engagement patterns. The learning engine (Section 9) decides when a number counts as mastered and whether a friend is eligible; this section decides what the child receives and how it is presented. Persistence of every reward entity is owned by Section 7; screen layouts in general are owned by Section 18; visual tokens, animation principles and the haptics style vocabulary are owned by Section 19; audio lines are inventoried in Section 21.

14.1 Design Principles of the Reward Economy #

# Principle Consequence in this section
R1 Rewards follow effort, not performance. Every completed task earns exactly one star, whatever the outcome (firstTry, afterHint, shown). Accuracy is never paid extra. A child who needed the solution demonstration earns the same star as a child who answered at once.
R2 Rewards are deterministic and predictable. No random drops, no chance-based items, no variable ratios. The same action always yields the same reward.
R3 Nothing earned is ever taken away. Stars never expire or decay. Friends, stickers and decorations stay forever, including after a subscription lapse. Only a parent can delete them, and only through the explicit "Alles zurücksetzen" option (Section 14.11, displayed by Section 16).
R4 Real learning is the visible collection. Zahlenfreunde are earned only through mastery (Section 9). Their collection is the child-visible picture of progress.
R5 No money in the child's world. Stars cannot be bought, no item has a money price, no reward is tied to the subscription as a sales lever.
R6 Calm. Every celebration lasts at most 2.5 seconds of animation; no flashing above 3 Hz; one ambient loop per screen (Section 19).

14.2 Stars #

14.2.1 Star Earning Table (complete) #

Stars are the only currency. The table below is exhaustive: no other event in the product creates or removes stars.

# Source Amount Condition When the ledger entry is written Ledger reason
E1 Completed task +1 A task in any game round reached a final outcome (firstTry, afterHint or shown, see Section 10). Applies to all 12 games, including Entdecken in "Zähl mit" mode. Immediately when the outcome is recorded (same save point as the task outcome, Section 7.12.2). taskSolved
E2 Round completion bonus +2 Every task of the round reached a final outcome (the round was played to the end; S-08 is shown). When the round controller enters the round-complete state (Section 10). roundCompleted
E3 Abenteuer completion bonus +5 All 3 rounds of the daily Abenteuer were completed and the profile has not yet received this bonus on the current local day. When S-10 starts. abenteuerCompleted
S1 Decoration purchase −price The child bought a decoration in S-12 (Section 14.4.4). At the moment of purchase, in the same save as the new owned decoration. decorationPurchased

Explicit non-sources (these never create stars): Entdecken free-explore mode; opening the app; returning on consecutive days; befriending a Zahlenfreund; receiving a sticker; reaching a milestone; watching a celebration; tapping friends or decorations; the parent granting extra time; the subscription starting or renewing; restoring purchases.

Derived values:

Round type Tasks Stars from tasks Round bonus Total per completed round
Vorschule round 5 5 2 7
Die Kleinen round 4 4 2 6
Round of a step with a per-step task count (for example Memory boards of 3, 4 or 6 pairs) the step's tasksPerRound (Section 8.8.2) 1 per recorded task 2 recorded tasks + 2

Tasks per round (5 for vorschule, 4 for littleOnes) are owned by Section 9. A game step may override the count with the optional per-step tasksPerRound of its content file (Section 8.8.2); the engine then plans that many tasks (Section 9.9.2) and the game specification in Sections 11–13 states the values. The star rule applies unchanged: one star per recorded task, +2 per completed round.

14.2.2 Star Rules and Edge Cases #

Situation Rule
Child leaves a round early (home button, Section 10) Stars already written for completed tasks stay. No round bonus. The task in progress when the child left earns nothing (it has no outcome).
Daily time limit reached mid-round (Section 15.8) The current task always completes and earns its star (Section 15.8.3). The round is then closed as interrupted: no round bonus, no S-08.
App terminated or crash mid-round Stars for tasks whose outcomes were saved stay. Nothing else is granted retroactively.
Abenteuer abandoned after round 1 or 2 Task stars and round bonuses of completed rounds stay. No Abenteuer bonus. Tapping the Abenteuer button later the same local day resumes the unfinished Abenteuer at its next unplayed round (Section 9.16.6, Section 15.10.5); a new local day discards it. The +5 is paid on the first completion of the local day only.
Second completed Abenteuer on the same local day Not possible from the child home (the Abenteuer button shows the garden after completion, Section 15.10.4). Defensive rule: if the engine ever reports a second completion for the same local date, E3 is not written again.
Abenteuer that started before local midnight and completes after it Counts for the local date on which it started (Section 15.13).
Balance display The star counter shows the current balance as a numeral next to a star icon. Balances above 9999 display as "9999" (the true balance is kept).
Balance can never go negative A purchase is only possible when balance >= price; the check and the ledger write happen in one MainActor transaction (Section 14.4.4).
Parent area activity Never creates or removes stars. The only parent actions that affect stars are "Alles zurücksetzen" for a child (balance becomes 0 because the ledger is deleted) and deleting the child or all data (Section 16.10).
Subscription lapse No effect on stars (Section 14.10).
"Fortschritt zurücksetzen" No effect on stars (Section 14.11).

14.2.3 Ledger Semantics #

  • Stars are stored as an append-only ledger per profile (entity StarLedgerEntry, fields and dedup rules in Section 7). The ledger reason is the ZKCore enum StarReason (declared once, Section 7.3.3); its persisted raw values are exactly taskSolved, roundCompleted, abenteuerCompleted, decorationPurchased.
  • sourceID references the originating record: the task attempt ID for taskSolved, the round ID for roundCompleted, the Abenteuer record ID for abenteuerCompleted, the owned-decoration ID for decorationPurchased. A given (reason, sourceID) pair is written at most once; the reward service checks for an existing entry before writing (idempotency against double events).
  • Balance = sum of all ledger amounts for the profile. The cached balance on the profile is recomputed at launch (Section 7).
  • Lifetime stars earned (used by the milestone stickers in Section 14.7.4) = sum of all positive ledger amounts (reasons taskSolved, roundCompleted, abenteuerCompleted). Spending never reduces the lifetime total.

14.2.4 Anti-Farming Reasoning #

Because every task pays one star whatever the outcome, a child could in principle tap randomly. The design accepts this and removes the incentive: a random-tapping child needs three wrong attempts plus the solution demonstration and a final tap on the highlighted answer (Section 10), which takes clearly longer than answering; stars per minute are therefore highest for genuine answering. The engine sees the shown outcomes and lowers difficulty (Section 9), which makes correct answers easier. Stars are bounded by the daily time limit (Section 15.8). No per-day star cap exists.

14.3 Worked Earnings (Sanity Check) #

The economy must satisfy two pacing goals for a child who plays about 10 minutes per day:

  • G1: the child can buy at least one small item (10 stars) every day;
  • G2: the child can additionally buy a large item (50 stars) roughly every week.

14.3.1 Assumptions #

Parameter Value Source
Average duration of one round including intro prompt and S-08 about 95 s (typical child), about 150 s (slow child) Derived from Section 9's Abenteuer target of 5 minutes for 3 rounds plus greeting and soft end; per-game expectedRoundSeconds in Section 8.
Daily Abenteuer 3 rounds + 5 bonus stars, about 5 minutes Section 9, Section 15.10
Tasks per round 5 (vorschule), 4 (littleOnes) Section 9
10-minute day Abenteuer (5 min) + free play (5 min) assumption for this check

14.3.2 Stars per Day #

Scenario Vorschule Die Kleinen
A. Typical 10-minute day: Abenteuer (3 × 7 + 5 = 26 / 3 × 6 + 5 = 23) + 3 free-play rounds (3 × 7 = 21 / 3 × 6 = 18) 47 41
B. Slow child, 10 minutes: Abenteuer takes about 7.5 min, then 1 free-play round 26 + 7 = 33 23 + 6 = 29
C. Abenteuer only (about 5 minutes) 26 23

14.3.3 Stars per Week and What They Buy #

Scenario Days played Vorschule per week Die Kleinen per week After buying 1 small item per played day Large items (50) affordable from the rest
A (typical) 7 329 287 259 / 217 5 / 4
B (slow) 7 231 203 161 / 133 3 / 2
C (Abenteuer only) 7 182 161 112 / 91 2 / 1
C (Abenteuer only) 4 104 92 64 / 52 1 / 1

Result: G1 holds in every scenario (the lowest daily income, 23 stars, exceeds 10). G2 holds in every scenario, including the most conservative one (a child in "Die Kleinen" who plays only the Abenteuer on 4 days a week buys a small item on each of those days and still has 52 stars for one large item that week).

14.3.4 Catalog Pacing #

Buying one copy of every catalog item costs 24 × 10 + 20 × 25 + 12 × 50 + 4 × 100 = 240 + 500 + 600 + 400 = 1,740 stars. That is about 5.3 weeks in scenario A (Vorschule, 329/week), about 7.5 weeks in scenario B and about 10–11 weeks in scenario C. Three mechanisms keep stars meaningful beyond that point:

  1. Twelve items require a number of Zahlenfreunde (Section 14.4.5), so the last part of the catalog opens in step with real learning, not with play time.
  2. Every item can be owned more than once (up to 24 copies, Section 14.4.4), so stars always have a use and the child can build a garden full of sunflowers if they want to.
  3. Quarterly content drops add decorations and seasonal garden themes (Section 17 roadmap), delivered as content without code changes (Section 8).

Decision: the catalog prices are generous on purpose; the stars are a joyful side-effect of practice, not a scarce resource. Price review based on real usage is tracked in Section 28.

14.4 The Zahlengarten #

14.4.1 Concept #

Every profile owns exactly one garden, the Zahlengarten, shown on screen S-11. It consists of:

  • a placement grid of 24 slots ("Beete", garden beds) into which decorations are placed;
  • the Freundeshügel (friends' hill), a band below the grid (a column beside it in iPhone landscape) where befriended Zahlenfreunde sit;
  • the inventory tray ("Korb", basket), an always-visible strip holding owned decorations that are not placed (there is no separate basket button that opens a tray);
  • a top bar with the home button, the star counter, the shop button (opens S-12), the album button (opens S-14) and the friends-house button (opens S-13).

Decision: V1 ships one garden theme, "Sommerwiese" (summer meadow): cream-green meadow, soft path, light-blue sky with one slowly drifting cloud as the screen's single ambient loop (static under Reduce Motion). Seasonal themes (for example "Herbstgarten", "Wintergarten") are content drops (Section 17): a theme changes only background art and the ambient loop, never the slot layout, so placements survive a theme change. V1 has no theme picker; when themes ship, the theme picker is a child-facing row of picture tiles on S-11 and themes are free (never bought with stars or money).

14.4.2 Slot Grid and Layout #

Slots are addressed logically as (slotX 0–5, slotY 0–3) — a 6 × 4 grid, 24 slots — and persisted in that form (Section 7). The linear slot index is slotIndex = slotY × 6 + slotX (0–23, reading order).

Layout class Grid shown Mapping from slotIndex Freundeshügel Inventory tray
iPad, regular width, any orientation 6 columns × 4 rows column = index mod 6, row = index div 6 band below the grid, two rows of five places (5 | 5), up to 10 friends horizontal strip at the bottom
iPhone portrait and any compact-width window (including narrow iPad windows) 6 columns × 4 rows, contiguous slots column = index mod 6, row = index div 6 band below the grid, one row, up to 5 friends horizontal strip at the bottom
iPhone landscape (compact height) 6 columns × 4 rows, contiguous slots column = index mod 6, row = index div 6 vertical column at the right edge, up to 4 friends vertical strip at the left edge

Decision: the grid is 6 × 4 in every layout class and never re-flows, so every decoration keeps the same place on every device and in every orientation; rotation moves only the Freundeshügel and the tray. The number of friends shown is the maximum for friends 1–10; friends 11–20 are drawn larger (Section 14.5.5), so fewer places fit when they are among the visitors.

Size budget (iPhone SE, 375 × 667 pt, the tightest case; Section 19.5.2 owns the global layout geometry and Section 19.6 the touch-target rules):

Element Portrait Landscape
Top bar 60 pt 56 pt
Slot grid 6 × 4 slots of 60 × 60 pt, contiguous (no spacing between slots) → 360 × 240 pt, 7.5 pt side margins 6 × 4 slots of 60 × 60 pt, contiguous → 360 × 240 pt
Freundeshügel 108 pt band below the grid; friends 1–10 at 60 pt, friends 11–20 at 96 pt; 12 pt spacing, 12 pt side margins 108 pt column at the right edge; same friend sizes and spacing
Inventory tray 84 pt band, items 60 pt 88 pt column at the left edge, items 60 pt

Portrait uses 60 + 240 + 108 + 84 = 492 pt of the 667 pt height (the rest is sky and scenery); landscape uses 88 + 360 + 108 = 556 pt of the 667 pt width and 56 + 240 = 296 pt of the 375 pt height. Every slot is a full 60 × 60 pt target; adjacent slots touch but never overlap, and a touch belongs to exactly one slot. On larger screens slots grow proportionally to fill the available area (maximum slot size 140 pt on iPad Pro 13"), and a 12 pt spacing between slots is used whenever the width allows it (6 slots of at least 60 pt plus 5 × 12 pt spacing plus margins). Slots are never smaller than 60 × 60 pt.

Visual slot states: empty slot = a softly outlined patch of darker grass; occupied slot = the decoration standing on the patch; drop-target highlight while dragging = a warm yellow glow ring around the nearest valid slot (#F5B82E at 40 % opacity, no pulsing).

Decision: every decoration occupies exactly one slot, whatever its size. Size determines how large the item is drawn: small items fill 60 % of the slot, medium 80 %, large 100 % and may extend up to 50 % of a slot height above the slot, special items 100 % and may extend up to 100 % of a slot height above and 25 % to each side. Items are drawn in row order (back row first) so taller items overlap the row behind them, never the row in front. Rationale: one-slot placement keeps drag-and-drop trivial for 2-year-olds (there is no "does it fit" failure) and removes all multi-slot collision logic.

14.4.3 Garden Interactions #

All garden interactions are single-finger; a second simultaneous touch is ignored while a drag is in progress (Section 10 input rules apply). Every drag has a tap alternative (the tap-then-tap placement below), so a child who cannot yet drag can furnish the garden too (Section 10.9.5). Haptics follow the map of Section 19.10 (drop into a slot: .impact(weight: .light); no haptic when an item flies back).

Interaction Gesture Result Audio / haptic
Place from tray Touch an item in the tray and move it upward out of the tray (movement mostly perpendicular to the tray's scroll direction), or hold it 0.3 s and move in any direction; drop onto a slot. Item snaps into the slot that contains the drop point; in layouts with slot spacing, the nearest slot whose frame, expanded by 12 pt on each side, contains the drop point. If that slot is empty, the item is placed. If it is occupied, the two items swap: the dropped item takes the slot, the previous occupant flies back into the tray (0.35 s). sfx.drag_pickup on pick-up; sfx.garden_place on snap; drop haptic.
Place by tapping (tap alternative) Tap an item in the tray: it lifts (scale 1.1, shadow) and stays lifted; then tap a slot. Same result as a drop on that slot (place, or swap with the occupant). Tapping the lifted item again, or tapping anywhere outside the slots, puts it back down in the tray. Only one item can be lifted at a time; lifting another item puts the first one down. sfx.drag_pickup on lift; sfx.garden_place on placement; drop haptic.
Drop outside any slot Release outside every expanded slot frame. The item flies back to where the drag started (tray position or previous slot), 0.3 s. No sound.
Scroll the tray Swipe along the tray. Tray scrolls; no drag starts. None.
Move a placed item Long-press a placed item for 0.5 s; it lifts (scale 1.1, shadow); drag to another slot and release. Empty target: move. Occupied target: swap the two items. Released outside all slots and outside the tray: returns to its original slot. Lift: sfx.drag_pickup + pick-up haptic (Section 19.10); drop: sfx.garden_place + drop haptic.
Move by tapping (tap alternative) Long-press a placed item for 0.5 s and release it without moving: it stays lifted; then tap a slot or the tray. Tap on a slot: move or swap as above. Tap on the tray: the item returns to the inventory. Tapping the lifted item again puts it back in its slot. As for moving by drag.
Put an item back Long-press a placed item, drag it onto the tray, release. The item returns to the inventory (placement removed). sfx.drag_drop.
Tap a placed item Single tap (released before 0.5 s). The item plays a short idle animation (wiggle, 0.6 s; Reduce Motion: brief brightness lift) and its item sound if the catalogue entry has a soundId (sfx.deco_<slug>; V1 ships none, Section 21.11). No other effect. Item SFX sfx.deco_<slug> if present, else sfx.garden_tap.
Tap a friend on the Freundeshügel Single tap. Friend interaction (Section 14.5.6). Friend audio.
Selling or deleting Not available. Decision: decorations cannot be sold, destroyed or given back for stars. The tray is the only "storage". —

Additional rules:

  • Placement changes are saved when a drop completes (one save per completed gesture).
  • A long-press on a placed item does not start if the finger moves more than 10 pt within the 0.5 s; that movement is ignored (it is not a scroll gesture because the garden does not scroll).
  • A lifted item (tap alternative) is put back where it came from when the child leaves S-11, when the app goes to the background, or after 20 s without a touch; nothing is lost.
  • The tray shows owned-but-unplaced items, one tile per owned instance, ordered by purchase time (newest first). A newly bought item is marked for its first garden visit by a gentle one-time bounce (not a badge). When the tray is empty it shows an outline of a basket and a shop icon that opens S-12.
  • The garden full case (24 occupied slots) needs no special message: any drop onto an occupied slot swaps. Decision: no "garden full" message exists.
  • Placing a decoration for the very first time (any decoration, first placement ever for the profile) awards the milestone sticker "Erste Deko" (Section 14.7.4).

14.4.4 Decoration Shop (S-12) #

Layout: four shelves, one per size (small, medium, large, special), shown as a vertically scrolling list of horizontal rows on iPhone and as a grid on iPad. Shelves are identified by picture (a small, a medium, a large and a special sample item), never by text. Item tiles are at least 88 × 88 pt and show the item picture, a star icon and the price numeral.

Item states in the shop:

State Condition Tile appearance Tap result
Affordable unlock requirement met, balance >= price, owned copies < 24 normal Opens the item card.
Not affordable unlock requirement met, balance < price normal picture; price numeral; below it a small star jar filled to balance / price Opens the item card with the buy button dimmed (it stays tappable but never buys).
Friend-locked profile has fewer befriended Zahlenfreunde than the item requires picture shown at 60 % saturation with a friend badge showing the required count as a numeral and that many small friend dots (in five-groups) Opens the item card; buy button replaced by the friend requirement (see below).
Maximum reached 24 copies owned normal picture with a small green check Opens the item card without buy button.

Item card: an overlay card with the item picture (large), the price (star icon + numeral), a big buy button (picture: star moving into a basket, at least 88 × 88 pt), and a close button (X, 60 × 60 pt). No text.

Purchase flow:

  1. Child taps an affordable item → card opens and plays the item's name if a voice line exists (Section 21; decorations without a recorded name play no voice).
  2. Child taps the buy button.
  3. The reward service, on the MainActor, re-reads the balance and the owned count, checks balance >= price and ownedCopies < 24, writes the purchase ledger entry (−price) and creates the owned decoration instance in one save. If any check fails, nothing is written and the card shows the not-affordable state.
  4. Celebration C5 (Section 14.8): stars fly from the counter to the item, the item drops into a basket icon. The counter animates down to the new balance.
  5. The card then shows two picture buttons: "back to the shop" (shop shelf icon) and "to the garden" (garden icon). "To the garden" opens S-11 with the new item at the front of the tray.

Rules:

  • Two taps are always required to buy (item, then buy button), which prevents accidental single-tap purchases by toddlers. There is no undo; stars are plentiful (Section 14.3), and a purchased item is never wasted because it can always be placed.
  • Tapping the dimmed buy button of a not-affordable item plays session.shop_need_more ("Dafür sammeln wir noch ein paar Sterne.") once per card opening; the line never asks the child to play more; the star jar gently fills to its current level (Reduce Motion: shown filled without animation).
  • Tapping a friend-locked item's requirement plays session.shop_need_friends ("Das bekommst du, wenn du mehr Zahlenfreunde hast.") and highlights the required number of friend dots, with the already-befriended count filled in.
  • Nothing in the shop mentions money, time, "new", "only today", discounts or rarity. All items are always visible. The shop never changes its assortment by date.

14.4.5 Decoration Catalog V1 (60 items) #

Prices by size are fixed: small 10 stars, medium 25 stars, large 50 stars, special 100 stars (the size enum DecorationSize is declared once in ZKCore, Section 5.3.2). IDs follow the pattern deco.<slug> with lowercase ASCII slugs (umlauts transliterated); the illustration asset of each item is named deco_<slug> (for example deco_tulpe, flat asset naming per Section 6.4.3); the file schema of decorations.json is owned by Section 8, and content validation rejects any item whose price does not match its size (Section 8). "Unlock" is the minimum number of befriended Zahlenfreunde; "–" means available from the start. No item ever has a money price.

Small (24 items, 10 stars each, no unlock requirements):

# ID German name Size Price Unlock
1 deco.gaensebluemchen Gänseblümchen small 10 –
2 deco.tulpe Tulpe small 10 –
3 deco.sonnenblume Sonnenblume small 10 –
4 deco.loewenzahn Löwenzahn small 10 –
5 deco.kleeblatt Kleeblatt small 10 –
6 deco.pilz Pilz small 10 –
7 deco.marienkaefer Marienkäfer small 10 –
8 deco.schmetterling Schmetterling small 10 –
9 deco.schnecke Schnecke small 10 –
10 deco.grasbueschel Grasbüschel small 10 –
11 deco.erdbeere Erdbeere small 10 –
12 deco.karotte Karotte small 10 –
13 deco.kuerbis Kürbis small 10 –
14 deco.giesskanne Gießkanne small 10 –
15 deco.schaufel Schaufel small 10 –
16 deco.eimer Eimer small 10 –
17 deco.ball Ball small 10 –
18 deco.windrad Windrad small 10 –
19 deco.laterne Laterne small 10 –
20 deco.kieselsteine Kieselsteine small 10 –
21 deco.tannenzapfen Tannenzapfen small 10 –
22 deco.apfel Apfel small 10 –
23 deco.blumentopf Blumentopf small 10 –
24 deco.muschel Muschel small 10 –

Medium (20 items, 25 stars each):

# ID German name Size Price Unlock
25 deco.rosenbusch Rosenbusch medium 25 –
26 deco.beerenstrauch Beerenstrauch medium 25 –
27 deco.hecke Hecke medium 25 –
28 deco.gartenzaun Gartenzaun medium 25 –
29 deco.vogelhaeuschen Vogelhäuschen medium 25 1 Zahlenfreund
30 deco.vogeltraenke Vogeltränke medium 25 –
31 deco.gartenbank Gartenbank medium 25 –
32 deco.schubkarre Schubkarre medium 25 –
33 deco.igel Igel medium 25 2 Zahlenfreunde
34 deco.hase Hase medium 25 3 Zahlenfreunde
35 deco.ente Ente medium 25 –
36 deco.katze Katze medium 25 –
37 deco.bienenhaus Bienenhaus medium 25 4 Zahlenfreunde
38 deco.gemuesebeet Gemüsebeet medium 25 –
39 deco.blumenbeet Blumenbeet medium 25 –
40 deco.flugdrachen Flugdrachen medium 25 –
41 deco.picknickdecke Picknickdecke medium 25 –
42 deco.lichterkette Lichterkette medium 25 –
43 deco.frosch Frosch medium 25 –
44 deco.gartenzwerg Gartenzwerg medium 25 –

Large (12 items, 50 stars each):

# ID German name Size Price Unlock
45 deco.apfelbaum Apfelbaum large 50 –
46 deco.kirschbaum Kirschbaum large 50 –
47 deco.teich Teich large 50 2 Zahlenfreunde
48 deco.schaukel Schaukel large 50 –
49 deco.rutsche Rutsche large 50 4 Zahlenfreunde
50 deco.wippe Wippe large 50 –
51 deco.zelt Zelt large 50 –
52 deco.sandkasten Sandkasten large 50 –
53 deco.gewaechshaus Gewächshaus large 50 –
54 deco.windmuehle Windmühle large 50 6 Zahlenfreunde
55 deco.pony Pony large 50 7 Zahlenfreunde
56 deco.spielhaus Spielhaus large 50 –

Special (4 items, 100 stars each, all friend-gated):

# ID German name Size Price Unlock Note
57 deco.baumhaus Baumhaus special 100 3 Zahlenfreunde
58 deco.regenbogenbruecke Regenbogenbrücke special 100 5 Zahlenfreunde
59 deco.perlenbrunnen Perlenbrunnen special 100 8 Zahlenfreunde Fountain whose water drops are red and blue beads in groups of five (Section 19 bead rules).
60 deco.zahlenkarussell Zahlenkarussell special 100 10 Zahlenfreunde Carousel with seats; befriended friends are drawn sitting in the seats (up to 10, lowest numbers first).

Totals: 24 small + 20 medium + 12 large + 4 special = 60 items; 12 items are friend-gated (4 medium, 4 large, 4 special).

Decision: the highest unlock requirement is 10 Zahlenfreunde. Rationale: a profile at level "Die Kleinen" works in the range 1–10 unless a parent overrides the range (Section 9), so it can befriend at most 10 friends; with a maximum of 10, every item is reachable at every level. Unlock requirements count befriended friends only; they are never tied to money, the subscription, time, dates or play streaks. Befriending is reachable with the four free games alone (Section 14.5.2), so free users can unlock every item.

Unlock evaluation: the shop evaluates befriendedCount >= requirement each time S-12 appears and after each befriending. Because friends are never lost (Section 14.5.3), an unlocked item never re-locks. Items bought remain owned and placeable forever even if the friend count were ever reduced by a parent action ("Alles zurücksetzen" removes the owned items too, Section 14.11).

14.5 Zahlenfreunde #

14.5.1 The 20 Characters #

Each number 1–20 is a small character. Its German name is the number word, capitalised as a noun ("Eins", "Zwei" … "Zwanzig"). In spoken lines the numeral noun is feminine ("die Sieben"), matching the didactic convention of Section 4; the collective noun is "der Zahlenfreund" / "die Zahlenfreunde". The friend ID is its number n (1–20); friends.json (schema in Section 8) maps n to its name string key, asset names and audio IDs.

n Name Body shape and look (illustration guidance) Personality one-liner
1 Eins Tiny round drop with one leaf on its head. Brave little one who always says hello first.
2 Zwei Two-lobed bean with two big eyes and two shoes. Loves everything that comes in pairs.
3 Drei Rounded triangle with three hair tufts. A cheerful climber who hops in threes.
4 Vier Soft square on four stubby legs. Calm builder who stacks blocks all day.
5 Fünf Mitten-shaped friend whose five fingers wave. The "Kraft der Fünf" hero: proud, helpful, shows that five is one hand.
6 Sechs Rounded cube, like a friendly die. Rolls around and giggles when it lands.
7 Sieben Round beetle-like friend with a spotted shell. Dreamy and loves rainbows.
8 Acht Two stacked circles like the numeral 8, with a little scarf. Likes to twirl and skate in loops.
9 Neun Tall, slender friend who stretches upward. Always curious, peeks over fences.
10 Zehn Caterpillar with ten little feet, body in two halves. Counts on its fingers and toes and loves "full tens".
11 Elf Long snake-like friend with a bow tie. Polite and a bit of a joker.
12 Zwölf Round clock-faced friend with twelve short rays. Punctual and loves to tick-tock.
13 Dreizehn Small dragon with round wings. Friendly dragon who puffs soap bubbles, never fire.
14 Vierzehn Tortoise with a patterned shell. Slow, steady and always finishes.
15 Fünfzehn Kangaroo-like friend with a pouch. Bouncy, carries little things for others.
16 Sechzehn Round owl-like friend with big glasses. Thoughtful and reads picture books.
17 Siebzehn Fluffy cloud-sheep. Soft and sleepy, loves hugs.
18 Achtzehn Little train engine with a smiling face. Always on the move, says "tschu-tschu".
19 Neunzehn Giraffe-like friend with a long neck. Sees far and shares what it spots.
20 Zwanzig Big, gentle whale-shaped friend. The friendly giant who carries all the others.

Visual design rules (binding for the illustrator; bead rendering per Section 19):

  1. Quantity on the body. Every friend carries a "bead belly" (a rounded panel on its tummy or back) showing exactly n beads in the Zwanzigerfeld structure: row 1 holds beads 1–10, row 2 holds beads 11–20; within each row, beads 1–5 are solid red round beads, beads 6–10 are blue "Lochperlen" (round beads with a white center ring); the two five-groups in a row are separated by a gap of 1.5 × the normal bead spacing. Example: 7 = one row: 5 solid red | 2 blue Lochperlen. Example: 17 = row 1: 5 red | 5 blue; row 2: 5 red | 2 blue. Partially filled rows show only the beads present (no empty placeholders on the friend). Legibility minimum: in every rendering (garden, gallery, S-27, stickers, parent-area avatars of friends) a belly bead has a diameter of at least 8 pt and its Lochperle ring hole at least 3 pt, so the solid/ring shape distinction survives for color-blind children (Section 19.7.1). Where a friend is drawn too small for rows of ten at that bead size, the belly uses the compact arrangement: each row holds exactly one five-group, with a 1.5× gap between the first ten and the second ten (the same rule as the compact Zwanzigerfeld, Section 4.4.2). Example: 17 in the compact arrangement = rows 5 red | 5 blue | 5 red | 2 blue, stacked.
  2. Numeral on the character. Every friend shows its numeral once, on a badge, hat, scarf or sign, in SF Pro Rounded numerals (Section 19). Together with the spoken name this gives the three linked representations: numeral, word, quantity (Section 4).
  3. Body colors never use the bead red #D8453A or bead blue #2D6BD4 as large body areas, so beads always stand out; bodies use warm pastels. Maximum 5 colors per character (Section 19).
  4. Shapes are round and friendly; no teeth, claws, weapons or scary expressions. Minimum drawing sizes: 60 pt for every friend anywhere; friends 11–20 at least 96 pt on the Freundeshügel and on S-27, where they carry the two-row belly; in 60–72 pt renderings (S-13 places, sticker art) friends 11–20 use the compact belly arrangement.
  5. Each friend has exactly four illustration states: idle, happy (used for tap and celebrations), sleepy (used on S-10, S-16 and S-26; yawning is an animation of this state, not a separate asset) and silhouette (used for not-yet-befriended places in S-13, a soft single-color outline without beads or numeral). Asset names follow the flat pattern friend_<nn>_<state> with a two-digit number, for example friend_07_idle (Section 6.4.3).

14.5.2 Befriending Rule #

A profile befriends the friend for number n when all of the following are true:

  1. n is mastered (score ≥ 0.80 and attempts ≥ 6 for that skill × number, Section 9) in at least 3 distinct skills that are active for the child's level (Section 4 / Section 9 list the active skills per level);
  2. at least one of those mastered skills is count or subitize;
  3. n had a firstTry correct outcome in at least 3 distinct games.

The eligibility function lives in the learning engine (eligibleFriends, Section 9). The app's task save point (Section 5.4.5 step 5, Section 7.12.2) calls it and passes the resulting numbers to the reward service (befriend(profileID:numbers:), Section 14.12); ZKRewards never evaluates mastery itself and does not import the engine (module rules, Section 5.3.2). write never counts toward the rule for "Die Kleinen" because write is not required for friends at that level (Section 9); for "Vorschule" it may count as one of the three skills but is never required.

Reachability check (free version): the four free games cover recognize, name, count and order (Entdecken "Zähl mit": name/recognize; Wie viele?: count/recognize; Hör hin: name/recognize; Was fehlt?: order/recognize). A free user can therefore satisfy all three conditions for every number in the active range. Decision: this reachability is a release requirement and is covered by an engine test (Section 25).

14.5.3 When Befriending Is Evaluated #

Moment Evaluation
Round complete (after the last task outcome of a round, before S-08 finishes) For every number that appeared in the round, the app asks the engine for eligibility and passes the eligible numbers to befriend. New friends are queued for S-27.
Round closed as interrupted (time limit or home button) Evaluate the same way; queued friends are shown at the next S-05 entry of this profile.
Profile enters S-05 (session start) Evaluate all 20 numbers once (catch-up for changed levels, parent overrides, or data restored in V1.1 sync). Queued friends are shown on S-05 before any other interaction.

Once a FriendState exists for (profile, n), the friend is permanent: later drops in mastery scores, a level change, a parent range override, a subscription lapse and "Fortschritt zurücksetzen" never remove it. Only "Alles zurücksetzen" for that child, deleting the child or deleting all data removes it (Section 14.11). A friend is never celebrated twice: the S-27 queue skips any n for which a FriendState already exists (guards against double events and V1.1 sync duplicates, whose dedup is defined in Section 7).

14.5.4 Befriending Celebration Flow (S-27) #

S-27 is a full-screen overlay. When several friends are queued at once they are shown one after another in ascending number order, each with the full flow below.

Placement in the flow:

  • After a normal round: S-08 (round-end celebration) finishes, then S-27 for each queued friend, then the navigation that would have followed S-08.
  • Inside an Abenteuer: after the round's S-08 and before the path transition to the next round (or before S-10 after round 3). Decision: befriending is not postponed to after the soft end, because the soft end puts the friends to sleep and must remain the calm final moment.
  • Queued from an interrupted round or session-start catch-up: on S-05 before anything else is interactive.
  • Never during a task, never on top of S-16 or S-26. If the time limit is reached while friends are queued, they are shown at the next S-05 entry (tomorrow or after a parent extension).

Sequence (times from overlay start). All motion ends at 2.5 s (celebration limit, Section 14.8); nothing moves after that.

Time Visual Audio Haptic
0.0–0.4 s Background dims to 60 %; the friend's silhouette fades in at the center. sfx.friend_new (one jingle, at most 2.5 s) —
0.4–1.4 s The silhouette pops into the full-color happy friend (scale 0.8 → 1.0 with a soft overshoot); a ring of 8 slow star sparkles appears around it (no strobing). — .success
1.0 s onward — session.new_friend ("Du hast einen neuen Zahlenfreund!"), then friend.<n>.hello (the friend introduces itself; the line always contains its number word, Section 21.10). —
1.4–2.3 s The friend's bead belly lights up group by group: first the red five-group together, then the blue group, then the second row groups (never bead by bead), with the numeral badge glowing at the end. — —
2.3–2.5 s A small garden icon and a small album icon at the bottom edge each show a static "added" check: the friend has moved into the garden and its sticker is in the album (Section 14.7.3). There is no walk-off and no sticker flight. — —
2.5 s Animation is complete. A large "continue" button (check-mark picture, 88 × 88 pt) appears bottom-center. — —
end The overlay closes on a tap of the continue button, or automatically 3 s after the last audio line ended. This is the only auto-close rule. — —

Reduce Motion variant: no scaling; the friend crossfades in (0.3 s), bead groups highlight in sequence by brightness only, the garden and album "added" checks appear without motion; audio unchanged. Voice off: the numeral badge and a large numeral next to the friend are shown for the full duration so the number is still communicated; sfx.* still plays if SFX are on. All sound off: fully visual.

Input during S-27: taps before 2.5 s are ignored (so a child's leftover taps from the game do not skip it); after that only the continue button is active. The home button is not shown on S-27; the overlay always ends by itself.

14.5.5 Where Friends Live #

  • In the garden (S-11): befriended friends sit on the Freundeshügel. Friends 1–10 are drawn at 60 pt and friends 11–20 at 96 pt there (bead-belly legibility, Section 14.5.1 rules 1 and 4), with 12 pt spacing. The maximum number of visitors depends on the layout class (Section 14.4.2: 10 on iPad regular width, 5 in iPhone portrait and compact width, 4 in iPhone landscape); on iPhone fewer fit when friends 11–20 are among them. The visitors are chosen deterministically: (1) the most recently befriended friend always has a place; (2) the other befriended friends follow in ascending number order, starting at the day's rotation offset and wrapping around, and each is added while it still fits into the remaining length of the band (or column) and the maximum is not reached; the first friend that does not fit ends the selection. The rotation offset for a local day is (number of local days since 2026-01-01 × 3) modulo the number of befriended friends other than the most recent one, so all friends visit over successive days. No randomness is involved. Visible friends are ordered by number from left to right (top to bottom in the landscape column).
  • In the friends house (S-13, Zahlenfreunde gallery): all 20 places are always shown in Zwanzigerfeld order. iPad regular width: 2 rows of 10 (1–10 on top, 11–20 below), each row split 5 | 5 with a 1.5× gap. Compact layouts: 4 rows of 5 (rows: 1–5, 6–10, then a larger gap, 11–15, 16–20). Befriended places show the friend in its idle state; not-yet-befriended places show the silhouette state with no numeral, so the gallery does not pressure the child with "missing" numbers. Friend places are at least 60 × 60 pt.
  • On the Zahlenkarussell decoration (Section 14.4.5, item 60) when placed.
  • On S-10 and S-16 in the sleepy state (the friends shown are the up-to-5 visitors chosen by the rule above; with no friends, the child's avatar animal sleeps alone).

14.5.6 Friend Interactions #

Trigger Response
Tap a befriended friend (S-11 or S-13) The friend switches to happy, bounces once (0.5 s; Reduce Motion: brightness lift), and plays num.<n> followed after 250 ms by friend.<n>.hello. While the number word plays, the bead belly highlights group by group (five-groups light together), linking word and quantity.
Tap the same friend again within 10 s after its line ended The friend plays friend.<n>.fact (its structure, for example "Ich bin fünf und zwei."; for 11–19 always "zehn und (n−10)", Section 21.10) while the matching bead groups highlight.
Tap the same friend while it is speaking Ignored (no restart, no stacking).
Tap another friend while one is speaking The current audio stops, the new friend starts.
Tap a silhouette in S-13 The silhouette wiggles gently and session.friend_waiting plays ("Dieser Zahlenfreund wartet noch auf dich.") at most once per S-13 visit; later taps only wiggle. No number is revealed.
Voice off The friend's numeral enlarges next to it for 1.5 s instead of the audio.
Friend as speaker in session lines The "guide friend" is the most recently befriended friend; with no friends it is the child's avatar animal. The guide friend speaks the time-limit warning, the break nudge and the Abenteuer lines (Section 15).

14.6 Friend and Reward Audio IDs Used in This Section #

Section 21 is the master inventory of every audio line and lists each line below with the same ID and German text. Session and reward IDs follow the convention session.<snake_case> (no further dots).

Audio ID German line Used by
friend.<n>.hello (n = 1–20) Short self-introduction containing the number word; texts per friend in Section 21.10 (for example "Ich bin die Sieben! Eine Woche hat sieben Tage.") S-27, friend tap
friend.<n>.fact (n = 1–20) The friend's structure, for example "Ich bin fünf und zwei." (Section 21.10) Second tap on a friend within 10 s
session.new_friend "Du hast einen neuen Zahlenfreund!" S-27
session.friend_waiting "Dieser Zahlenfreund wartet noch auf dich." S-13 silhouette tap
session.new_sticker "Ein neuer Sticker für dein Album!" C4 sticker celebration (milestone stickers only; picture and friend stickers are covered by their own flows)
session.round_done_01 … session.round_done_04 Rotated round praise, for example "Geschafft! Das war eine tolle Runde!" (Section 21.9) S-08 (C2)
session.stars_bonus "Und noch Extra-Sterne für dich!" S-10 (C6)
session.shop_need_more "Dafür sammeln wir noch ein paar Sterne." S-12
session.shop_need_friends "Das bekommst du, wenn du mehr Zahlenfreunde hast." S-12
session.shop_bought "Wie schön! Wo soll es hin?" S-12 after purchase

No line in this section mentions money, the app name, a game name, "schnell" or asks the child to keep playing.

SFX IDs required (all in Section 21.11): sfx.friend_new, sfx.sticker, sfx.garden_place, sfx.drag_pickup, sfx.drag_drop, sfx.tap, sfx.shop_spend, sfx.star, sfx.page_turn (album page change), sfx.round_complete (shared with Section 10's S-08). Item-specific decoration sounds are optional content (sfx.deco_<slug>, for example sfx.deco_ente; V1 ships none) and fall back to sfx.garden_tap.

14.7 Sticker Album #

14.7.1 Structure #

The album (S-14) has 6 pages and exactly 52 sticker places in V1. Pages are turned with two large arrow buttons (at least 72 × 72 pt) and a row of 6 page dots; each page also has a picture tab (friend face, dot picture with 1, 2 or 3 dots, trophy) so a pre-reader can jump directly.

Page Content Places Arrangement
1 Friend stickers 1–10 10 2 rows of 5 (a five-group per row)
2 Friend stickers 11–20 10 2 rows of 5
3 Punkt-zu-Punkt pictures, step 1 ("Leicht") 8 2 rows of 4
4 Punkt-zu-Punkt pictures, step 2 ("Mittel") 8 2 rows of 4
5 Punkt-zu-Punkt pictures, step 3 ("Schwer") 8 2 rows of 4
6 Milestone stickers 8 2 rows of 4
Total 52

Decision: friend pages come first because every child, free or premium, can fill them; the dot-picture pages follow. Empty places show a light dotted outline frame only — no silhouette, no lock badge, no price, no "?" — so the album never advertises premium content to the child. Sticker places are at least 72 × 72 pt on iPhone and 120 × 120 pt on iPad; each page fits without scrolling in every layout class (in iPhone landscape, 2 rows of 5 or 2 rows of 4 fit at 72 pt).

Tapping an earned sticker enlarges it (0.3 s) and plays its sound: friend stickers play num.<n> then friend.<n>.hello; dot-picture stickers play the picture's name line label.dot.<pictureId> (for example "Ein Stern!", declared in the picture file, Section 8.12); milestone stickers play sfx.sticker_tap (Section 14.7.4). Tapping an empty place does nothing.

Album button indicator: after a new sticker is awarded, the album button (on S-05 and S-11) shows a small warm-yellow dot until the child opens the album. It never shows a count. Opening the album on the page of the newest sticker and letting that sticker do a single gentle shine (0.8 s; Reduce Motion: none) clears the indicator.

14.7.2 Punkt-zu-Punkt Picture Stickers (24) #

One sticker per dot picture of the game Punkt zu Punkt (Section 13), 8 per difficulty step. Sticker ID pattern: sticker.dot.<pictureId>. The picture IDs and themes below are the V1 picture set; Section 13 defines each picture's dot layout, and dotpictures/<pictureId>.json (Section 8) links each picture to its sticker ID.

# Sticker ID Picture (German) Theme Step / page
1 sticker.dot.stern Stern Himmel step1 / page 3
2 sticker.dot.haus Haus Zuhause step1 / page 3
3 sticker.dot.fisch Fisch Wasser step1 / page 3
4 sticker.dot.sonne Sonne Himmel step1 / page 3
5 sticker.dot.herz Herz Formen step1 / page 3
6 sticker.dot.boot Boot Wasser step1 / page 3
7 sticker.dot.blume Blume Garten step1 / page 3
8 sticker.dot.luftballon Luftballon Fest step1 / page 3
9 sticker.dot.schmetterling Schmetterling Garten step2 / page 4
10 sticker.dot.schnecke Schnecke Garten step2 / page 4
11 sticker.dot.katze Katze Tiere step2 / page 4
12 sticker.dot.hase Hase Tiere step2 / page 4
13 sticker.dot.auto Auto Fahrzeuge step2 / page 4
14 sticker.dot.rakete Rakete Himmel step2 / page 4
15 sticker.dot.tannenbaum Tannenbaum Wald step2 / page 4
16 sticker.dot.vogel Vogel Tiere step2 / page 4
17 sticker.dot.elefant Elefant Tiere step3 / page 5
18 sticker.dot.giraffe Giraffe Tiere step3 / page 5
19 sticker.dot.dinosaurier Dinosaurier Tiere step3 / page 5
20 sticker.dot.schiff Schiff Wasser step3 / page 5
21 sticker.dot.eisenbahn Eisenbahn Fahrzeuge step3 / page 5
22 sticker.dot.burg Burg Zuhause step3 / page 5
23 sticker.dot.schildkroete Schildkröte Wasser step3 / page 5
24 sticker.dot.einhorn Einhorn Fantasie step3 / page 5

Award trigger: the first time the profile completes that picture in Punkt zu Punkt (all dots connected and the picture revealed, Section 13.4). The game emits the pictureCompleted game event (Section 10.13.1, payload without a profile ID); the app handles it in the round save point by calling the reward service's awardPictureSticker(profileID:stickerID:) (Section 14.12). Completing the same picture again reveals it again but awards nothing new; the reveal screen then shows the sticker with a small check and plays no "new sticker" line.

14.7.3 Friend Stickers (20) #

Sticker ID pattern: sticker.friend.<n> (n = 1–20); artwork = the friend in its happy state with its bead belly and numeral. Award trigger: awarded in the same save as the FriendState when the friend is befriended (Section 14.5.4). A friend sticker can therefore never exist without its friend and vice versa.

14.7.4 Milestone Stickers (8) #

# Sticker ID Parent-facing name Artwork Award trigger
1 sticker.milestone.first_round Erste Runde Small flag with a "1" First round of any game completed to the end (E2 written for the first time).
2 sticker.milestone.first_abenteuer Erstes Abenteuer Treasure map with a path First Abenteuer completed (E3 written for the first time).
3 sticker.milestone.friends_5 5 Zahlenfreunde One open hand with 5 red beads Fifth friend befriended.
4 sticker.milestone.friends_10 10 Zahlenfreunde Two hands, 5 red + 5 blue beads Tenth friend befriended.
5 sticker.milestone.friends_20 20 Zahlenfreunde Full Zwanzigerfeld with all friends Twentieth friend befriended.
6 sticker.milestone.stars_100 100 Sterne Star jar with "100" Lifetime stars earned reach 100 (Section 14.2.3).
7 sticker.milestone.stars_500 500 Sterne Big star with "500" Lifetime stars earned reach 500.
8 sticker.milestone.first_decoration Erste Deko Flower pot with a sparkle First decoration placed into a garden slot.

C4 plays session.new_sticker only; tapping a milestone sticker plays sfx.sticker_tap.

Friend-count milestones count befriended friends at any level; "20 Zahlenfreunde" is reachable only in the range 1–20 (level "Vorschule" or a parent override, Section 9). Lifetime-star milestones use lifetime earned stars, so spending never delays them.

14.7.5 Award Rules and Duplicate Prevention #

  • A sticker award is a StickerAward record keyed logically by (profile, stickerID) (Section 7). Every sticker award inside the reward service (from recordTaskCompleted, recordRoundCompleted, recordAbenteuerCompleted, befriend, awardPictureSticker and place) is idempotent: it reads existing awards for the profile on the MainActor and writes only if none exists, inside the same save as the triggering change. Duplicates are therefore impossible in V1; V1.1 sync duplicates are merged per Section 7.
  • Milestone checks run after every ledger write with a positive amount (stars milestones), after every befriending (friend milestones), after every E2/E3 (first round / first Abenteuer) and after every first placement (first decoration).
  • If several milestones trigger at once (for example the first completed round also crosses 100 lifetime stars), all are awarded; their celebrations are queued in sticker ID order after any S-27 overlays.
  • Stickers are never removed except by "Alles zurücksetzen", deleting the child, or deleting all data.

14.8 Celebrations #

All celebrations obey: animation at most 2.5 s; no flashing or strobing above 3 Hz; motion eased (Section 19 animation principles); sound only if SFX (or voice, for spoken lines) are on; haptics only if the haptics toggle is on and the device supports haptics (Section 16.8). The haptic column uses SwiftUI .sensoryFeedback styles; Section 19.10 owns the haptics map and the values below are identical to it. No celebration and no error ever uses an error or warning haptic.

ID Celebration Trigger Animation (max 2.5 s) Sound Haptic Reduce Motion variant
C1 Correct-answer feedback Task outcome firstTry or afterHint Owned by Section 10: visual at most 0.8 s (Section 19.9), audio sequence capped at 2.5 s (Section 10.5.2) fb.correct.* (Section 10/21) .success Section 10
C2 Round end (S-08) Round completed Star counter fills with 1 star per task plus the 2 bonus stars flying in as one group of "+2" (1.6 s); the guide friend cheers (0.9 s) sfx.round_complete + one rotated session.round_done_01…04 line .success Stars appear in place with a crossfade; no flight path
C3 Zahlenfreund befriended (S-27) Section 14.5.4 Section 14.5.4 (all motion within 2.5 s) sfx.friend_new, voice lines of Section 14.5.4 .success Section 14.5.4
C4 Sticker awarded Dot picture completed first time (inside Punkt zu Punkt's reveal), milestone Sticker pops (scale 0 → 1, 0.4 s), shines once (0.6 s), flies to the album icon (0.8 s) sfx.sticker; milestones also session.new_sticker (Section 14.7.4) .impact(weight: .light) Sticker crossfades in, stays 1 s, crossfades out; album icon shows its dot
C5 Decoration bought Section 14.4.4 Stars fly from counter to item (≤ 1.2 s), item drops into basket (0.5 s) sfx.shop_spend + session.shop_bought .success Counter number changes, item shows a check for 1 s
C6 Abenteuer bonus S-10 start 5 stars fly one after another into the star jar (1.5 s) session.stars_bonus; sfx.star per star, 5 × at 0.25 s spacing .selection per arriving star (5 ×) Jar crossfades to the new value
C7 First placement / friend tap / decoration tap Section 14.4.3, 14.5.6 ≤ 0.6 s idle animation item or friend sound none (a placement drop keeps its drop haptic, Section 14.4.3) brightness lift

Queueing: at most one celebration is on screen at a time. Order after a round: C2 (S-08) → C3 for each queued friend (ascending n) → C4 for each queued milestone sticker (ascending sticker ID). Celebrations never overlap a spoken prompt; any queued celebration waits until the current voice line ends (Section 20 audio priority rules). While a celebration runs, all child input except its own continue control is ignored.

Sound-off path: every celebration is complete without audio (the visual shows what happened: stars counted into the jar, the friend's numeral, the sticker flying to the album).

14.9 Forbidden Patterns #

The following patterns are forbidden in every build and every future content drop. Each row states how the design avoids it; Section 25 contains the corresponding review checks.

# Forbidden pattern How the design avoids it
F1 Streaks and loss of streaks No day counter, no "X Tage in Folge", no flame or calendar markers, no reward for consecutive days. The Abenteuer bonus is per day but missing a day changes nothing; the next Abenteuer is exactly the same.
F2 Pressure timers No response timer anywhere in the child area. Blitzblick's flash duration is a display time, not a response timer; the child always has unlimited time to answer (Section 12). The daily time limit is a parent tool, never shown as a countdown to the child; the child hears one calm warning (Section 15.8).
F3 Leaderboards and social comparison No rankings, no comparison between siblings or with other children, no sharing of results. Profile picker shows avatars only, never scores.
F4 Loot boxes and surprise packs Every item's content and price are visible before the purchase. No mystery items, no chests, no wheels.
F5 Random rewards and variable-ratio schedules Stars follow the fixed table in Section 14.2.1. No random bonus stars, no "lucky" multipliers, no random drops. The only rotation in the reward system (which friends visit the Freundeshügel) is deterministic and grants nothing.
F6 Paid rewards and purchasable currency Stars cannot be bought; no item has a money price; the subscription grants games, never stars, items, friends or stickers.
F7 Child-facing purchases No price, buy button or paywall is ever shown in child mode. Locked games show only the lock animation and the calm line "Dieses Spiel ist noch zu. Frag deine Eltern." (session.locked_game, S-15, Section 17.8); purchases happen only in the parent area behind the gate (Section 16, Section 17).
F8 Notifications V1 sends no notifications at all: no push, no local notifications, no badges on the app icon. The app does not request notification permission and does not link UserNotifications.
F9 Fake scarcity and FOMO No "nur heute", no limited-time items, no countdowns, no "new" banners, no seasonal items that disappear. Seasonal themes, once shipped, stay available forever.
F10 Loss aversion and decay Stars never expire; gardens never wilt; friends never leave or get sad; nothing is lost when the child does not play. A lapse of the subscription removes nothing earned (Section 14.10).
F11 Guilt or emotional pressure Characters never express sadness about absence, never beg the child to play, never say "don't go". The soft end and time-limit lines encourage rest.
F12 Ads and cross-promotion No ads, no promotion of other apps, no external links in child mode (Section 23).
F13 Endless-play loops Rounds end; the Abenteuer ends softly (S-10); the break nudge suggests a pause (S-26); the daily time limit (default 20 minutes) ends play for the day (Section 15). No autoplay of the next round outside the Abenteuer.
F14 Performance-based pressure on rewards Stars do not depend on accuracy or speed (Section 14.2.1, R1). Wrong answers never cost stars.

Any proposal that introduces one of these patterns requires a new decision recorded in DECISIONS.md and a revision of this section; the default answer is no.

14.10 Subscription Lapse #

When a premium subscription ends (expiration, cancellation, refund, loss of Family Sharing access; entitlement logic in Section 17):

Item Effect
Stars (balance and ledger) Unchanged.
Decorations owned or placed, including those bought with stars earned in premium games Unchanged; still placeable and movable.
Zahlenfreunde All kept. New friends can still be befriended through the free games (Section 14.5.2).
Stickers, including Punkt-zu-Punkt picture stickers All kept and visible in the album. New picture stickers cannot be earned while Punkt zu Punkt is locked; empty places show the neutral outline only.
Mastery and game progress in premium games Kept (Section 9); used again when premium returns.
Daily Abenteuer Continues, composed only from the Abenteuer-eligible free games Wie viele?, Hör hin and Was fehlt? (Entdecken is never part of an Abenteuer, Section 9.16.2). The Abenteuer bonus is unchanged.
Premium games Re-lock with the lock badge; tapping them shows S-15 (Section 17).

The child is never told that anything changed beyond seeing the lock badges return; no line in the child area mentions the subscription.

14.11 Reset and Deletion Scope for Rewards #

Section 16 displays these options in the parent area (S-23) and owns the confirmation UI. The scope is decided here.

Parent action (German label) Learning-engine state (Section 9): mastery records, task attempts, game steps, range stage and range counters Stars (ledger) Decorations (owned + placements) Zahlenfreunde Stickers Abenteuer records Time usage (daily usage, sessions) Profile identity and settings
"Fortschritt zurücksetzen" deleted; range stage returns to the level's start stage (Section 9) kept kept kept kept kept kept kept (a parent range override or difficulty cap stays as set)
"Alles zurücksetzen" (for one child) deleted deleted (balance 0) deleted (garden empty) deleted deleted deleted historical entries deleted; today's usage entry kept so a reset never grants extra play time kept
"Kind löschen" (not available for the last remaining profile, Section 16.10.1) deleted deleted deleted deleted deleted deleted deleted deleted
"Alle Daten löschen" (device) everything on the device deleted; the app returns to the first-launch welcome S-02 (Section 16.10.2)

The per-child action "Alles zurücksetzen" and the device-wide "Alle Daten löschen" are deliberately named differently so a parent cannot confuse them. Deleting the last remaining profile is impossible except through "Alle Daten löschen".

Decision: after "Fortschritt zurücksetzen", friends and stickers stay even though the mastery that earned them is gone. Rationale: rewards are never taken away from the child (R3); the parent resets learning state, typically to let the engine re-assess, not to punish. Already befriended friends are not celebrated again when the child re-masters their numbers (Section 14.5.3).

14.12 Reward Service Contract #

The reward system is implemented in module ZKRewards (depends only on ZKCore and ZKPersistence, Section 5.3.2). This section owns the single reward protocol RewardService; Section 5.4.2 cites it. The shared enums StarReason (persisted raw values taskSolved, roundCompleted, abenteuerCompleted, decorationPurchased) and DecorationSize (small, medium, large, special) are declared once in ZKCore (Section 5.3.2, Section 7.3.3) and are not redeclared here. ZKRewards cannot import ZKContent or the learning engine, so the app passes every content value and every eligibility result in as resolved inputs. Value types for the UI and the app:

import Foundation
import ZKCore

/// Built by the app from the ContentCatalog entry of the decoration (Section 8.9).
public struct DecorationOffer: Hashable, Sendable {
    public let id: String            // "deco.<slug>"
    public let size: DecorationSize  // ZKCore
    public let price: Int            // stars; content validation guarantees it matches `size`
    public let requiredFriends: Int  // 0 = available from the start
    public init(id: String, size: DecorationSize, price: Int, requiredFriends: Int)
}

public struct GardenSlot: Hashable, Codable, Sendable {
    public let x: Int  // 0...5
    public let y: Int  // 0...3
    public var index: Int { y * 6 + x }
    public static let columns = 6
    public static let rows = 4
    public static let count = 24
}

public enum PurchaseResult: Sendable, Equatable {
    case purchased(ownedDecorationID: UUID, newBalance: Int)
    case notAffordable(balance: Int, price: Int)
    case friendLocked(required: Int, current: Int)
    case maximumOwned
}

public enum PlacementResult: Sendable, Equatable {
    case placed(GardenSlot)
    case swapped(GardenSlot, displacedToInventory: UUID?)   // tray → occupied slot
    case moved(from: GardenSlot, to: GardenSlot)
    case swappedPlaced(GardenSlot, GardenSlot)              // slot ↔ slot
    case returnedToInventory
    case rejected
}

public enum RewardEvent: Sendable, Equatable {
    case starsEarned(amount: Int, reason: StarReason)
    case friendBefriended(number: Int)
    case stickerAwarded(stickerID: String)
}

public enum RewardError: Error, Sendable, Equatable {
    case profileNotFound
    case invalidNumber(Int)          // befriend outside 1...20
    case unknownSticker(String)
    case persistence                 // the unit of work was rolled back (Section 7.12.2)
}

@MainActor
public protocol RewardService: AnyObject {
    func balance(profileID: UUID) throws(RewardError) -> Int
    func lifetimeStarsEarned(profileID: UUID) throws(RewardError) -> Int

    /// Idempotent per (reason, sourceID). Returns events for celebrations (milestones included).
    func recordTaskCompleted(profileID: UUID, taskAttemptID: UUID) throws(RewardError) -> [RewardEvent]
    func recordRoundCompleted(profileID: UUID, roundID: UUID) throws(RewardError) -> [RewardEvent]
    func recordAbenteuerCompleted(profileID: UUID, abenteuerID: UUID, localDate: LocalDate) throws(RewardError) -> [RewardEvent]

    /// `numbers` are the eligible numbers the app obtained from the engine (Section 9, `eligibleFriends`).
    /// Creates FriendState + friend sticker for each number not yet befriended; skips existing ones.
    func befriend(profileID: UUID, numbers: Set<Int>) throws(RewardError) -> [RewardEvent]

    /// Called by the app for the `pictureCompleted` game event (Section 10.13.1). Idempotent.
    func awardPictureSticker(profileID: UUID, stickerID: String) throws(RewardError) -> [RewardEvent]

    func purchase(profileID: UUID, item: DecorationOffer) throws(RewardError) -> PurchaseResult
    func place(profileID: UUID, ownedDecorationID: UUID, at slot: GardenSlot) throws(RewardError) -> PlacementResult
    func returnToInventory(profileID: UUID, ownedDecorationID: UUID) throws(RewardError) -> PlacementResult

    func resetProgressKeepingRewards(profileID: UUID) throws(RewardError)   // "Fortschritt zurücksetzen" (reward side: no-op for rewards)
    func deleteAllRewards(profileID: UUID) throws(RewardError)              // part of "Alles zurücksetzen"
}

LocalDate is the calendar-day value type of ZKCore (Section 5). Production class: LiveRewardService (ZKRewards); test double: FakeRewardService (naming per Section 6.2.1). The app's task save point (Section 5.4.5, Section 7.12.2) calls the engine for friend eligibility and then befriend; the round save point routes pictureCompleted to awardPictureSticker; the shop screen builds each DecorationOffer from the content catalog before calling purchase.

Unit tests (Section 25) must cover at least: star table E1–E3 and S1 amounts with the four StarReason raw values; idempotency of every write; purchase boundary balance == price; purchase with balance == price − 1; the 24-copy maximum; friend-lock boundary befriended == requiredFriends − 1 and == requiredFriends; all placement outcomes including swaps (by drag and by the tap alternative, which call the same place); milestone thresholds 99 → 100 and 499 → 500 lifetime stars (spending in between must not matter); no duplicate sticker after a double event (including a repeated awardPictureSticker); befriend with an already befriended number returns no event (friend never celebrated twice); "Fortschritt zurücksetzen" leaves stars, decorations, friends and stickers untouched; "Alles zurücksetzen" leaves today's usage entry.

15. Child Profiles and Session Management #

This section owns child profiles as a product concept (fields and their rules, avatars, color themes, the profile limit, creation and switching), the two levels as seen by the parent and child, the definition of a session, active-time accounting, the daily time limit, the break nudge, the daily Abenteuer as the child experiences it, and interruption handling. Persistence of every entity mentioned here (profile, per-child settings, sessions, daily usage, Abenteuer records) is owned by Section 7. Task selection, range and difficulty logic are owned by Section 9. Game-screen pause behaviour is owned by Section 10. Screen layouts and navigation mechanics are owned by Section 18. Parent-facing wording is owned by Section 22; this section quotes it where needed.

15.1 Principles #

# Principle Consequence
P1 No child accounts, no login, no personal data. A profile is an avatar with optional nickname. No birthdate, photo, real name, email or password.
P2 A pre-reader can pick their own profile. The profile picker shows large avatar tiles; captions are for parents only.
P3 Siblings share a device fairly. Switching profiles needs no gate; time limits are counted per profile.
P4 Time limits protect, they never pressure. The child never sees a countdown; one calm spoken warning, then a calm goodbye screen.
P5 The app is predictable across interruptions. Short interruptions resume exactly where the child was; long ones end the session cleanly.

15.2 Profile Model #

15.2.1 Fields #

The ChildProfile entity and its companion per-child settings are defined in Section 7. This table fixes the product meaning and validation of every child-facing and parent-facing field.

Field Type (logical) Default Rules
nickname String "" (empty) Optional. 0–20 characters, counted as Swift Characters (grapheme clusters) after trimming. Leading and trailing whitespace is trimmed; runs of internal whitespace collapse to one space; newlines and control characters are removed. Any other characters are allowed, including umlauts and emoji. Not unique (two children may share a nickname). Shown only in the parent area and as the caption under the avatar on the profile picker (S-04). Never spoken, never shown inside child screens other than S-04.
avatarID String, one of 12 IDs (Section 15.3.1) first avatar not used by another profile Required. Unique per device: no two profiles may share an avatar, so the picker is unambiguous for a pre-reader.
colorThemeID String, one of 6 IDs (Section 15.3.2) theme.sonne Required. Not unique.
level Level raw value: littleOnes or vorschule none (parent must choose) Required at creation; the finish button stays disabled until a level is chosen. Changeable later in S-20 (Section 15.6.3).
createdAt Date creation time Set once; never shown. Used to order profiles on S-04 and in the parent area.

Per-child settings (defaults at creation; edited in S-20, Section 16.7):

Setting Values Default Owner of the behaviour
Number range Auto / 1–5 / 1–10 / 1–20 Auto Section 9
Difficulty cap Auto / Leicht / Mittel / Schwer Auto Section 9
Daily time limit Aus / 10 / 15 / 20 / 30 / 45 / 60 minutes 20 minutes this section (15.8)

Display name rule: wherever the parent area names a child, it uses the nickname if non-empty, otherwise the German avatar animal name (for example "Fuchs"). This fallback is never shown as if it were typed text (no placeholder styling).

15.2.2 Profile Limit #

The maximum is 5 profiles per device, defined once as ProfileLimits.maxProfiles = 5 in ZKCore and referenced everywhere (UI, validation, tests). When 5 profiles exist, the "Weiteres Profil" button in the parent dashboard (S-18) is disabled and the notice "Maximal 5 Profile pro Gerät." (Section 22.5.2) is shown beneath it; the parent must delete a profile (Section 16.10) before creating a new one. The repository also rejects a sixth insert defensively (returns a typed error; Release builds never crash). V1.1 sync behaviour when the union of two devices exceeds 5 profiles is defined in Section 7.20.5.

15.3 Avatars and Color Themes #

15.3.1 Avatars (12) #

Avatars are friendly animal portraits, drawn in the illustration style of Section 19 (asset names follow the flat pattern avatar_<slug>[_<state>] by animal slug, never by ordinal, for example avatar_fuchs and avatar_fuchs_sleepy, Section 6.4.3), deliberately different from every Zahlenfreund (Section 14.5.1) and every animal decoration (Section 14.4.5) so a child never confuses "me" with a friend or an item. Each avatar has two states: awake and sleepy (used on S-04 for a profile that has reached today's time limit, and on S-16).

# ID German name (parent-facing fallback name)
1 avatar.baer Bär
2 avatar.fuchs Fuchs
3 avatar.otter Otter
4 avatar.pinguin Pinguin
5 avatar.loewe Löwe
6 avatar.elefant Elefant
7 avatar.hund Hund
8 avatar.maus Maus
9 avatar.waschbaer Waschbär
10 avatar.koala Koala
11 avatar.zebra Zebra
12 avatar.panda Panda

The avatar list is content (avatars.json, schema in Section 8); adding avatars later needs no code change. The app never speaks the avatar name and never speaks the nickname.

15.3.2 Color Themes (6) #

A color theme tints only the profile's personal accents: the circle behind the avatar on S-04, the avatar button on S-05, and the child's card accent in the parent area. It never tints game screens, beads, the Zwanzigerfeld, feedback colors or the garden, so the didactic color system (Section 19) stays constant for every child.

# ID German name Accent color
1 theme.sonne Sonne #F6C85F
2 theme.meer Meer #4FB3BF
3 theme.wiese Wiese #8CC084
4 theme.beere Beere #A77BCA
5 theme.pfirsich Pfirsich #F4A38C
6 theme.himmel Himmel #8FB8E8

Decision: these six IDs, names and hex values are the profile theme tokens; Section 19.2.4 carries them under exactly these IDs and values, and the fallback for an unknown colorThemeID is theme.sonne. None of them equals the bead red, bead blue, success green, star yellow or try-again neutral (Section 19), and all are used only as large fills behind dark ink artwork, never as text color.

15.4 Creating Profiles #

15.4.1 First Launch (the first profile) #

Decision: first-launch setup is parent-facing and nothing can be configured before the parental gate is passed. The flow:

  1. S-01 Splash/Loading → the app finds zero profiles.
  2. S-02 First-launch welcome: static parent-facing content and one button "Profil einrichten" (copy in Section 22.5.1). No settings, no links, no purchase elements on S-02. S-02 plays no audio (the audio session is not yet active, Section 20.3.2).
  3. Tapping "Profil einrichten" opens S-17, the parental gate (Section 16.2). The gate always precedes S-03; there is no ungated setup path. Cancel returns to S-02.
  4. S-03 Parent setup (copy in Section 22.5.2), one scrolling form:
    • Nickname text field (optional, rules in 15.2.1, live counter "n/20").
    • Avatar grid: 12 tiles, 72 pt minimum; the first avatar is preselected.
    • Color row: 6 swatches, 60 pt minimum; theme.sonne preselected.
    • Level: two large option cards, "Die Kleinen (2–4 Jahre)" and "Vorschule (4–6 Jahre)"; none preselected.
    • Sound check button "Ton testen" with a speaker icon: plays num.5 through the voice channel so the parent can confirm the volume and that the silent switch does not mute the app. It is the one exception to "no audio before child mode": the tap activates the audio session for that line and deactivates it afterwards (Section 20.3.2). Decision: the sound check is part of S-03, not a separate step.
    • Finish button "Fertig", enabled once a level is chosen.
  5. On "Fertig" the app creates the profile and its per-child settings with the defaults of Section 15.2.1 in one save.
  6. Hand-off screen (inside S-03): "Fertig! Jetzt darf Ihr Kind übernehmen." / "Geben Sie das Gerät einfach weiter." shown for 3 s, with the new avatar animating in; any tap after 1 s skips the remaining time.
  7. S-05 Child home for the new profile; a session starts (Section 15.7.1).

S-03 contains nothing else: no "add another child" control (profiles 2–5 are created only in the parent area, Section 15.4.2), no premium or paywall link (the paywall is reachable only through S-18 or the S-15 parent icon, both behind the gate, Section 17), and no external link.

Edge cases: if the app is terminated anywhere before step 5 completes, nothing is stored and the next launch starts again at S-02 (and the gate again). If the gate cooldown is active when the parent taps "Profil einrichten", the gate shows the cooldown (Section 16.2.4). There is no "skip setup" path and no default profile created without a parent.

15.4.2 Additional Profiles #

Profiles 2–5 are created only in the parent area: S-18 → "Weiteres Profil" → S-25 Profile editor in creation mode, with the same fields and defaults as S-03 (without the hand-off screen). Avatars already used by other profiles are shown dimmed with a small check and cannot be selected. Saving returns to S-18, which then shows the new child's card. The child-facing profile picker (S-04) never contains an "add profile" control.

15.5 Profile Picker (S-04) and Switching #

15.5.1 When S-04 Appears #

Situation Destination
Launch with exactly 1 profile S-05 of that profile directly (S-04 is skipped)
Launch with 2–5 profiles S-04
Child taps the avatar button on S-05 S-04 (only shown when 2–5 profiles exist)
Return from background after more than 5 minutes, 2–5 profiles S-04 (a different sibling may be holding the device)
Return from background after more than 5 minutes, 1 profile S-05
Parent area closed and the previously active profile no longer exists S-04 if 2–5 profiles remain, S-05 if 1 remains
Parent area closed, previously active profile exists the screen the gate was opened from (Section 16.4.3)

15.5.2 Layout and Behaviour #

  • One tile per profile, ordered by createdAt ascending. Tile = avatar illustration on a circle filled with the profile's theme color; tile size 120 pt on iPhone, 180 pt on iPad; spacing 16 pt minimum. Layouts: iPhone portrait 2 columns (up to 3 rows), iPhone landscape one row (up to 5 tiles at 110 pt), iPad one or two rows centered. Section 19 governs exact sizes per layout class.
  • Caption under each tile: the nickname, or the avatar's German animal name if the nickname is empty, 15 pt secondary text. It exists for parents; the child identifies the avatar picture.
  • On appearance the voice asks session.picker ("Wer spielt jetzt?", Section 21.8) once per appearance.
  • Tap on a tile: the avatar bounces (0.3 s; Reduce Motion: brightness lift), sfx.tile_select plays, then S-05 of that profile opens and a session starts.
  • A profile that has reached today's time limit shows its avatar in the sleepy state with a small moon. Tapping it opens S-16 for that profile (so the child sees the calm "resting" message instead of an unexplained refusal).
  • The grown-up (parent entry) icon is present on S-04 as on S-05 and S-16 (press-and-hold 2 s, then S-17; element specification in Section 18.1.3).
  • No other controls. No time, stars or progress are shown on S-04, so siblings are never compared (Section 14.9, F3).

15.5.3 Switching Profiles #

Decision: switching needs no parental gate. Siblings switch freely by tapping the avatar button on S-05, which leads to S-04. The avatar button exists only on S-05 (inside games the child first returns home). Switching ends the current session (reason profileSwitch) and starts a new one for the chosen profile. Because time limits, stars, gardens, friends and stickers are all per profile, free switching cannot be used to gain anything: a child who switches to a sibling's profile plays within that sibling's limit and fills that sibling's garden. The accepted risk that a child plays on a sibling's profile is documented in Section 23's threat model.

15.6 Levels #

15.6.1 What a Level Determines #

Aspect littleOnes ("Die Kleinen", 2–4 Jahre) vorschule ("Vorschule", 4–6 Jahre) Owner
Starting number range 1–5 1–10 Section 9
Automatic widening up to 1–10; never to 1–20 automatically up to 1–20 Section 9
Tasks per round 4 5 Section 9
Active skills recognize, name, count, subitize, order, compare, decompose (numbers 2–5 only), write (Nachspuren step 1 only) all 8 skills Section 4, Section 9
Zahlenfreunde reachable without parent override up to 10 up to 20 Section 14.5
Stars per completed round 6 7 Section 14.2

15.6.2 Choosing the Level #

The parent chooses the level at profile creation (S-03 or S-25) from the two option cards; the age ranges are guidance, not rules, and the app never asks for the child's age or birthdate.

15.6.3 Changing the Level #

The level is changed in S-20 (Section 16.7). A confirmation sheet explains the effect in one sentence (functional copy in Section 16.12) with the buttons "Stufe ändern" and "Abbrechen". Effects of a change (Decision; the engine implements the range and step parts, Section 9):

Effect littleOnes → vorschule vorschule → littleOnes
Mastery records, attempts, game progress kept kept
Active skill set becomes all 8 reduced to the littleOnes set; records of inactive skill × number pairs are kept but not scheduled
Range stage (when range is Auto) becomes the larger of the current stage and 1–10 becomes the smaller of the current stage and 1–10
Range stage (when a parent override is set) override stays in force override stays in force
Game steps unchanged steps above what the level allows (for example Nachspuren above step 1) are capped (Section 9)
Tasks per round 5 from the next round 4 from the next round
Zahlenfreunde, stars, garden, stickers unchanged unchanged (friends are never lost)

The change takes effect at the start of the next round. Because a level change happens in the parent area, the child's session is paused at that moment (Section 15.7.1), so no round is in progress when it applies.

Level-up hint: the dashboard card of a littleOnes profile shows an informational hint "Bereit für die Vorschule-Stufe?" with a link to S-20 when the profile's automatic range has reached 1–10 and it has at least 8 Zahlenfreunde. Decision: the app never changes the level automatically; the hint is dismissible and does not reappear for that profile for 30 days after dismissal (the dismissal date is a device-level UserDefaults entry per profile; key name and reset behaviour in the key registry of Section 7.9.2).

15.7 Sessions and Active Time #

15.7.1 Session Definition #

A session is one continuous stretch of use of the child mode by one profile.

Start: a session starts when a child profile enters S-05 without an open session: after picking a tile on S-04, at launch with one profile, after the first-launch hand-off, after S-16 is lifted (midnight or parent extension), and after returning from the parent area when the previous session has ended.

End (the first that occurs):

End reason (SessionEndReason) Condition Session end timestamp
profileSwitch Child taps the avatar button on S-05. moment of the tap
background App was in the background (or the scene inactive) for more than 5 minutes. moment the app left the foreground
idle Foreground, but no counted active time for 5 minutes (no touch; see 15.7.2). moment counting stopped
parentArea The parent area stayed open for more than 5 minutes. moment the gate was opened
timeLimit The daily time limit ended play (S-16 shown). moment S-16 appears
terminated The app process ended while the session was open (found open at the next launch). last persisted flush time (15.7.2)
dataDeleted The parent deleted the profile or all data. moment of deletion

SessionEndReason is declared once in ZKCore with exactly these seven cases and raw values (Section 7.3.3); this table defines their meaning.

Gaps of 5 minutes or less (background, parent area, idle) pause the session; it continues when counting resumes. Decision: one uniform 5-minute rule for every kind of gap keeps the model predictable and matches the gate validity rule (Section 16.2.5).

Session start effects: the guide friend (Section 14.5.6) greets the child on S-05. Every session start plays the time-of-day greeting: session.greeting_morning ("Guten Morgen! Schön, dass du da bist.", 05:00–10:59), session.greeting_day ("Hallo! Schön, dass du da bist.", 11:00–17:59) or session.greeting_evening ("Guten Abend! Schön, dass du da bist.", 18:00–04:59). The app never speaks the child's name. These bands are the only greeting bands (Section 18.3.5 and Section 21.8 use them). Pending befriending celebrations are shown first (Section 14.5.3). A session that starts silently after an idle end (the child touches the screen again after more than 5 minutes) plays no greeting.

Session record: each session is persisted (SessionRecord, Section 7) with start, end, end reason, counted active seconds and the number of completed tasks. Decision: sessions with zero completed tasks are stored (they appear in time usage) but the engine does not count them as "sessions played" for range decisions (Section 9).

15.7.2 Active-Time Accounting #

Active time is the time that counts toward the daily limit, the break nudge and the parent time chart. A second is counted only when all of these hold:

  1. the scene phase is .active (the app is in the foreground and not covered by a system overlay);
  2. a child profile is active and the current screen is a counted child-mode screen: S-05 to S-15, S-26 in its nudge state, S-27 (S-08, S-09 and S-10 included);
  3. the last touch anywhere in the app was at most 90 seconds ago.

Never counted: S-01 to S-04, S-16, S-17, the whole parent area (S-18 to S-25), S-26 in its resting state, the game pause overlay shown after 90 seconds without a touch (Section 10), and any time while the scene is .inactive or .background.

Inactivity: when 90 seconds pass without a touch, counting stops (the 90 seconds themselves are counted). On game screens Section 10 shows its pause overlay at the same moment. The next touch resumes counting.

Measurement: elapsed time is measured with a monotonic clock (ContinuousClock, injected for tests) while the conditions hold; the wall clock is used only to decide which local date the seconds belong to, via the trusted day clock (Section 15.12). Seconds are attributed to the trusted local date at the moment they are counted; play across local midnight is split between the two dates.

Persistence: counted seconds accumulate in memory and are flushed to the profile's DailyUsage entry for the current trusted local date (Section 7) every 15 seconds of counted time, at every task-outcome save, at every scene-phase change and at session end (save points in Section 7.12.2). A crash loses at most 15 seconds of counted time.

DailyUsage fields this section relies on (Section 7 defines the entity): localDate, activeSeconds, bonusSeconds (parent extensions granted for that date), limitMinutes (the limit in force, updated whenever it changes that day; used by the time chart), warningAllowanceSeconds (the allowance value for which the time warning has already been spoken, or none).

15.8 Daily Time Limit #

15.8.1 Values #

Value Label (Section 22.6.5)
Off Aus
10 10 Minuten
15 15 Minuten
20 (default) 20 Minuten (Standard)
30 30 Minuten
45 45 Minuten
60 60 Minuten

Stored per profile as whole minutes, 0 meaning Off. The limit is per profile and per device in V1.

Definitions for the current trusted local date:

  • allowance = limit × 60 + bonusSeconds
  • remaining = allowance − activeSeconds
  • the profile is locked when the limit is on and remaining ≤ 0.

15.8.2 Warning Two Minutes Before the Limit #

When remaining first drops to 120 seconds or less for the current allowance, the guide friend says session.time_warning ("Gleich ist Zeit zum Ausruhen.") exactly once, and warningAllowanceSeconds is set to the current allowance so the warning is not repeated after a relaunch.

Situation when the threshold is crossed When the line plays
A voice prompt, hint or feedback line is playing Queued; plays as soon as that line ends and before the next prompt (Section 20 audio priority).
Between tasks, on S-05, S-06, S-11 to S-14 Immediately.
During a celebration (S-08, S-27, C4/C5) After the celebration ends.
Profile enters S-05 with remaining already ≤ 120 s On S-05 after the greeting.

Presentation: the guide friend's small portrait slides in at the screen edge for the duration of the line, yawning (Reduce Motion: fades in). No countdown, clock, progress bar or number is ever shown to the child. Voice off: the portrait appears for 3 seconds with a small moon icon. After a parent extension (15.8.5) the warning can play once more when the new remaining drops to 120 seconds. With a limit of 10 minutes the warning plays at 8 minutes of active time.

15.8.3 Reaching the Limit #

When remaining reaches 0:

Where the child is What happens
A task is presented and has no outcome yet The current task always completes; there is no time cap. Hints and the solution demonstration continue normally (Section 10), and the hint ladder bounds the task: after the third wrong attempt the solution is shown and the task ends with outcome shown. The outcome and its star are recorded, the round is closed as interrupted (no round bonus, no S-08; Section 14.2.2) and S-16 appears. If the child stops touching the screen, the inactivity rules of Sections 10.11 and 15.7.2 apply (pause overlay and no counting after 90 s); if the session then ends as idle (5 more minutes), the task is closed without an outcome and the next touch shows S-16.
Between tasks, or in task feedback The current feedback line finishes (at most 2 s), the round is closed as interrupted, S-16 appears.
S-08 or S-27 is on screen It finishes; queued S-27 overlays not yet shown are postponed to the next S-05 entry; S-16 appears.
S-09 (Abenteuer intro) or S-10 (soft end) S-09: stops, S-16. S-10: the soft end finishes (it is a goodbye already), then S-16.
S-05, S-06, S-11 to S-14 A drag in progress completes where the finger is released; a purchase whose buy button was already tapped completes; then S-16 within 1 s.
S-15 or S-26 The overlay closes; S-16 appears.

Seconds spent finishing the last task are counted normally, so today's usage may exceed the limit by the time that task takes (in practice under a minute, because the hint ladder ends every task after at most three wrong attempts) plus at most 2 seconds of feedback. An Abenteuer interrupted by the limit is not completed (Section 15.10.5).

15.8.4 S-16 "Zeit zum Ausruhen" #

  • Calm evening version of the garden: the guide friend and the visiting friends (Section 14.5.5) in their sleepy state; with no friends, the child's avatar sleeps alone. The only ambient loop is the friends' slow breathing (Reduce Motion: static).
  • Music fades out over 2 seconds and stays off on S-16 (no music track plays on S-16, Section 20.8.2). The voice says session.times_up ("Jetzt ist Zeit zum Ausruhen. Bis morgen!") once on appearance; tapping the scene does nothing (no replay, no reactions), so the screen offers nothing to keep playing with.
  • Controls: the avatar button (only if 2–5 profiles exist) leads to S-04 so a sibling with remaining time can play; the grown-up (parent entry) icon (press-and-hold 2 s → S-17). No home button, no games, no garden access.
  • S-16 is not counted as active time.
  • The lock lasts until the next trusted local midnight (Section 15.12). Only the trusted local date decides the lock; a day rollover of AppClock (which drives the Abenteuer and engine due dates, Section 5.7.3) never unlocks a locked profile. If S-16 is on screen at midnight, the next touch after midnight replaces it with S-05 and starts a new session with a greeting. If the app is in the background at midnight, the lock is re-evaluated on return.

15.8.5 Parent Extension (+10 Minuten) #

  • From S-16: parent entry icon → S-17 gate → a parent sheet with three choices: "+10 Minuten für heute" (Section 22.6.5), "Zum Elternbereich", "Abbrechen".
  • From S-20 (Section 16.7): when the profile is locked today, the time-limit row shows the same "+10 Minuten für heute" button.
  • Effect: bonusSeconds for today increases by 600, remaining becomes positive, S-16 closes to S-05 and a new session starts (the old one ended with timeLimit). On S-05 the guide friend says session.time_extended ("Du darfst noch ein bisschen spielen!") instead of the greeting. The time warning may play once more.
  • There is no maximum number of extensions; every extension from S-16 requires a fresh gate (the gate is never cached across the child mode).
  • Extensions are valid only for the trusted local date on which they were granted; they do not carry over.

15.8.6 Changing the Limit During the Day #

A new limit applies immediately to today's allowance; granted bonusSeconds stay. Raising the limit or setting it to Off unlocks a locked profile at once (on return to child mode the child lands on S-05). Lowering it below today's usage locks the profile: on return from the parent area the child lands on S-16. With the limit Off, no warning is spoken and no lock applies, but active time is still recorded for the parent time chart and the break nudge still works.

15.9 Break Nudge (S-26) #

Rule: after 8 minutes of continuous play the guide friend yawns and suggests a break once; the child may continue.

Continuous play time:

  • accumulates counted active seconds (Section 15.7.2);
  • is frozen while an Abenteuer is in progress (from S-09 until S-10 or abandonment); Decision: Abenteuer time does not count toward the 8 minutes, and a completed Abenteuer resets continuous play time to 0 because its soft end already is a rest suggestion;
  • resets to 0 after any gap of 3 minutes or more without counted time (background, idle, parent area), on profile switch, at session start, and when the child chooses "Pause" on S-26.

Trigger: continuous play time reaches 480 seconds and no nudge has been shown in this session. The nudge appears at the next safe point, never during a task:

Where the child is When S-26 appears
In a game round After the round's S-08 and any S-27 overlays, before the next navigation.
S-05, S-06, S-11 to S-14 Immediately (after a drag or purchase in progress completes).

Suppression: if the daily limit is on and remaining is 180 seconds or less, the nudge is skipped for this session (the time warning or S-16 follows soon). At most one nudge per session.

S-26 has two states:

  1. nudge: the guide friend yawns (1.5 s, with sfx.friend_yawn; Reduce Motion: crossfade to the sleepy pose) and says session.break_nudge ("Puh, ich bin ein bisschen müde. Magst du eine Pause machen?"). Two large picture buttons (96 × 96 pt): "Pause" (moon icon) and "Weiterspielen" (play-arrow icon). No text on the buttons for the child; accessibility labels carry the words. Counted as active time.
    • "Weiterspielen": session.break_continue ("Na gut, dann spielen wir noch ein bisschen!") plays, the overlay closes; play continues; no further nudge this session.
    • "Pause": switches to the resting state.
  2. resting: friends sleep; session.break_bye ("Bis später!") plays once; the screen shows only a large sun button (96 × 96 pt) to wake up. Not counted as active time; continuous play time is reset. Tapping the sun returns to S-05 (the session continues if less than 5 minutes passed, otherwise a new session starts per 15.7.1).

If the child does not respond to the nudge state for 90 seconds, counting stops as usual (inactivity) and the overlay stays.

15.10 Daily Abenteuer from the Child's View #

The engine composes the Abenteuer (3 rounds, entitled Abenteuer-eligible games only, never the same game twice, at least one free game, target about 5 minutes; Entdecken is never part of an Abenteuer; Section 9.16). This section defines how the child experiences it.

15.10.1 Entry #

S-05 shows the Abenteuer button (a large treasure-map picture, at least 120 × 120 pt on iPhone). While today's Abenteuer is not yet completed, the button gently glows (this is S-05's single ambient loop; Reduce Motion: static highlight ring). Tapping it opens S-09. If an unfinished Abenteuer of the same local day exists, S-09 shows it and play resumes at its next unplayed round (Section 15.10.5); otherwise the engine composes the three rounds at this moment and the composition is fixed for this Abenteuer.

15.10.2 S-09 Abenteuer Intro #

Time Visual Audio
0.0–0.5 s A path scene fades in with three stepping stones, each showing the icon of one chosen game in order (completed rounds of a resumed Abenteuer show a check); the guide friend stands at the start of the path (or on the last completed stone). music crossfades to music.abenteuer (S-09 is the only screen with this track, Section 20.8.2)
0.5 s — session.abenteuer_intro ("Heute gibt es ein neues Abenteuer! Komm mit!")
1.0–2.2 s The stones light up one after another (0.4 s each). sfx.path_step per stone
2.0 s A large play button (triangle picture, 120 pt on iPhone, 160 pt on iPad) appears next to the next stone and gently glows; the next stone is also tappable. —
20 s after the intro line ended without a tap The intro line repeats once; nothing else happens. session.abenteuer_intro

Decision: S-09 never starts a round automatically; the child starts it with the play button or the stone (child agency, calm design). The home button is present; leaving S-09 returns to S-05 and nothing is recorded. An Abenteuer record (Section 7) is created only when round 1 starts.

15.10.3 Rounds and Transitions #

  1. Round 1 starts in S-07 with the game's own intro prompt (Section 10); tasks per round follow the level (Section 15.6.1).
  2. At round end: S-08 (at most 2.5 s, Section 14.8 C2) → queued S-27 overlays (Section 14.5.4) → path transition (1.5 s: the guide friend hops to the next stone, which lights up; sfx.path_step; Reduce Motion: crossfade) → round 2 starts with its intro prompt.
  3. Same after round 2 → round 3.
  4. After round 3: S-08 → queued S-27 overlays → S-10.

No choices are asked between rounds; the child is carried through. The break nudge never appears during an Abenteuer (Section 15.9).

15.10.4 S-10 Abenteuer Soft End #

Time Visual Audio
0.0–1.5 s Celebration C6: the 5 bonus stars fly one by one into the star jar (Section 14.8). session.stars_bonus ("Und noch Extra-Sterne für dich!"); sfx.star × 5; music fades to silence over 2 s (no music plays on S-10)
1.5 s, or when session.stars_bonus ends if later — session.abenteuer_end ("Das war ein schönes Abenteuer! Die Zahlenfreunde ruhen sich jetzt aus.")
1.5–4.0 s The visiting friends (or the avatar) yawn and lie down in the garden; the sky shifts slowly to evening colors (no flashing). Reduce Motion: crossfade to the sleeping picture. sfx.friend_yawn once
after the line — session.abenteuer_bye ("Bis bald!")
about 7 s A home button (house picture, 96 × 96 pt) appears and gently glows. —
15 s after the last line without input Automatic return to S-05. music resumes on S-05

After S-10:

  • The Abenteuer bonus (+5) and the "Erstes Abenteuer" milestone (first time only) are recorded at S-10 start (Section 14.2.1, 14.7.4); a milestone sticker celebration is shown on S-05 right after S-10 closes.
  • The Abenteuer button on S-05 changes for the rest of the local day to a picture of the sleeping friends in the garden. Tapping it opens S-11 (the garden) and plays session.abenteuer_done ("Dein Abenteuer für heute ist geschafft. Morgen gibt es ein neues!"). There is no counter or reminder of any kind.
  • The child may keep playing single games from S-06; the soft end is a suggestion, not a lock. Continuous play time for the break nudge is reset.

15.10.5 Abandoned or Interrupted Abenteuer #

  • The home button in a round exits the Abenteuer (Section 10 mid-round exit rules). Completed rounds keep their stars and round bonuses; the +5 is not paid.
  • The same happens when the daily limit is reached, when the app is in the background for more than 5 minutes, or on profile switch.
  • The Abenteuer button stays in its "not yet completed" state. Tapping it later the same local day resumes the unfinished Abenteuer at its next unplayed round with the same composition (Section 9.16.6; the engine re-checks entitlements at each round start). A new local day discards the unfinished Abenteuer and composes a new one. The +5 is paid on the first completion of the local day only.

15.11 Interruptions and Resume #

Game-screen pause and resume presentation is owned by Section 10; audio-session interruption and route-change handling is owned by Section 20. This table fixes the session-level behaviour.

Event Typical scene phase Session-level behaviour Resume
Incoming call or FaceTime (iPhone), alarm or timer from the Clock app .inactive (banner) or .background (full screen) Counting stops. Voice stops, music pauses (Section 20). A game task freezes without recording anything (Section 10). Back to .active within 5 minutes: same screen and task; counting resumes as soon as the conditions of Section 15.7.2 hold again. After more than 5 minutes in the background: session ends (background); the open round is closed as interrupted (completed tasks keep their stars); the app shows S-04 (2–5 profiles) or S-05 (1 profile).
Control Center, Notification Center, app switcher gesture started but cancelled .inactive Counting stops; audio pauses. On .active: resumes silently.
Home gesture, lock button, Siri full screen .background As for a call. As for a call.
Low-battery alert .inactive Counting stops; audio pauses. On dismissal: resumes.
Low Power Mode switched on none No behaviour change in V1 (performance budgets in Section 24 already hold). —
Headphones or Bluetooth speaker disconnected none The playing voice line stops (Section 20); the task's speaker button pulses once so the line can be replayed. The game does not pause. —
Screen Time downtime or app limit takes effect .background As for backgrounding. As for backgrounding.
Guided Access active none Fully supported; recommended for young children in the parent help (Section 16.11). —
System terminates the app in the background (memory pressure) process ends At next launch: the open session is closed with terminated; the app starts at S-04 or S-05, never inside a round. Saved task outcomes and stars are kept. —
Device rotation during play none Layout adapts (Section 19); no pause. —
iPad Split View / Stage Manager, app visible but not frontmost .active Counted as normal (the app is visible). —

Screen auto-lock: Decision: UIApplication.shared.isIdleTimerDisabled is true only while a game task is on screen (S-07) and not paused, so the device does not lock while a child is thinking; it is false on every other screen, including the game pause overlay, so the device can sleep normally when the child walks away.

Single window: Decision: the app supports exactly one scene (UIApplicationSupportsMultipleScenes = NO, Section 5), so two child sessions can never run at the same time on one iPad.

15.12 Trusted Day Clock (Clock-Change Protection) #

Changing the device clock must not reset the daily limit early. The time-limit subsystem therefore determines "today" through a trusted day clock instead of reading the wall clock directly. Engine due dates (Section 9) continue to use AppClock; only time-limit and usage attribution use the trusted day clock.

Implementation home: TrustedDayClock lives in ZKCore/Time/TrustedDayClock.swift (the only folder allowed to touch clocks and calendars, Section 6.6). It reads the wall clock and calendar through the injected AppClock (clock.now, clock.calendar), never through Date() or Calendar.current directly. The continuous time source is mach_continuous_time() converted to seconds with mach_timebase_info: it includes time the device spends asleep and restarts at 0 after a reboot. (ContinuousClock exposes no persistable since-boot value, and ProcessInfo.systemUptime excludes sleep, so every device sleep would look like a clock change; neither is used for the anchor.) The live implementation of ContinuousTimeSource is the only code in the app that calls mach_continuous_time(); the network and API policy check allowlists exactly this file (Section 23.10.2).

State (device-level, stored in UserDefaults under the key zk.clock.trustedAnchor, registered in Section 7.9.2, never synced, kept by "Alle Daten löschen"): anchorWall (Date), anchorContinuous (continuous seconds since boot at the anchor), driftSince (continuous seconds when a drift was first detected, or none).

Algorithm trustedNow():

  1. Read wall = clock.now and cont = continuous seconds since boot (mach_continuous_time()).
  2. No anchor stored, or cont < anchorContinuous (the device rebooted): trust the wall clock; re-anchor (anchorWall = wall, anchorContinuous = cont, driftSince = none); return wall.
  3. Otherwise compute expected = anchorWall + (cont − anchorContinuous) and drift = wall − expected.
  4. If |drift| ≤ 300 s: the clock is plausible. Re-anchor, clear driftSince, return wall.
  5. If |drift| > 300 s: the clock was changed during this boot. Return expected (ignore the change) and do not re-anchor. Set driftSince = cont if not set. If the drift has persisted for 24 hours of continuous time (cont − driftSince ≥ 86,400), accept the wall clock (re-anchor), because waiting 24 hours grants nothing a real day would not.

The trusted local date is clock.calendar (the device's current calendar and time zone) applied to trustedNow() (a time-zone change is not a clock change: Date is absolute, only the calendar interpretation changes).

Consequences:

  • Setting the clock forward to "tomorrow" does not unlock a locked profile; setting it back does not create a fresh day with zero usage.
  • Legitimate automatic time corrections are usually far below 5 minutes and pass step 4.
  • Residual risk (accepted, documented in Section 23): a child who changes the clock and then restarts the device defeats the check. This requires the device passcode flow and a restart and is unrealistic for ages 2–6.

Privacy manifest: mach_continuous_time() is a required-reason API of the category "system boot time". Decision: the privacy manifest declares NSPrivacyAccessedAPICategorySystemBootTime with reason 35F9.1 (measuring elapsed time between events that occurred within the app); the manifest entry itself is owned by Section 23.6.2.

15.13 Edge Cases #

# Case Behaviour
1 Two children alternate on one device Each switch ends one session and starts another; usage, limits, warnings and the break nudge are tracked per profile. A locked sibling shows as sleeping on S-04 while the other can play.
2 Local midnight passes during play (not locked) Seconds after midnight count for the new date; the new date's allowance applies at once; the time warning state resets for the new date. The session continues.
3 Local midnight passes while S-16 is shown The next touch after midnight replaces S-16 with S-05 and starts a new session.
4 Abenteuer starts before and completes after local midnight It counts for the date on which round 1 started (bonus, "done today" state). The new date's Abenteuer is available right after.
5 The last task before the limit is still being finished at midnight The task finishes; the lock evaluation after it uses the new trusted date (usually unlocked), so S-16 is not shown and play continues on S-05 (the round was closed as interrupted).
6 Daylight-saving change Local dates come from Calendar; a 23- or 25-hour day simply has more or fewer hours; limits are in active minutes and are unaffected.
7 Travel across time zones "Today" follows the device's current time zone. Flying east can shorten a day; flying west can lengthen it. Accepted.
8 Device clock set backward or forward Handled by the trusted day clock (15.12).
9 Profile deleted while a child is using it Impossible from child mode: deletion exists only in the parent area, and the child session is paused while the parent area is open. On closing the parent area, the app routes as in 15.5.1.
10 Last remaining profile "Kind löschen" is disabled when only one profile exists; the only way to remove it is "Alle Daten löschen" (Section 16.10.2), which returns the app to S-02. No other flow ever leads to zero profiles.
11 Parent changes level, range or difficulty cap while the child's session is paused in the parent area Applies at the next round start (Section 15.6.3).
12 Parent lowers the limit below today's usage On closing the parent area the child lands on S-16.
13 Parent raises the limit or sets it Off while the profile is locked On closing the parent area the child lands on S-05; a new session starts.
14 App launched for the first time at 23:59 Setup completes normally; the first session's seconds split at midnight.
15 App reinstalled iOS deletes all local data with the app; the reinstall starts at S-02. The subscription is restored from Apple (Section 17).
16 Child idles on S-05 for an hour Counting stops after 90 seconds; the session ends (idle) after 5 more minutes; the device auto-locks normally because the idle timer is only disabled during tasks.
17 The limit is reached during the first-launch hand-off Cannot happen: a new profile starts with 0 seconds and a minimum limit of 10 minutes.
18 Limit reached while a befriending is queued The friend's S-27 is shown at the next S-05 entry (Section 14.5.4).

15.14 Session Management Contract #

Session and time-limit logic is split into pure, unit-testable value types in ZKCore and a MainActor coordinator in the app target (App/Session/), which uses the repositories of ZKPersistence (Section 5 module rules). SessionEndReason (cases profileSwitch, background, idle, parentArea, timeLimit, terminated, dataDeleted) is declared once in ZKCore by Section 7.3.3 and is used here, not redeclared.

import Foundation

public enum ProfileLimits {
    public static let maxProfiles = 5
    public static let nicknameMaxCharacters = 20
}

public enum DailyTimeLimit: Int, CaseIterable, Codable, Sendable {
    case off = 0, min10 = 10, min15 = 15, min20 = 20, min30 = 30, min45 = 45, min60 = 60
    public static let `default`: DailyTimeLimit = .min20
}

public struct SessionTimingRules: Sendable {
    public static let inactivityStopSeconds: TimeInterval = 90
    public static let sessionGapSeconds: TimeInterval = 300        // background / idle / parent area
    public static let warningBeforeLimitSeconds: TimeInterval = 120
    public static let extensionSeconds: Int = 600
    public static let breakNudgeAfterSeconds: TimeInterval = 480
    public static let breakResetGapSeconds: TimeInterval = 180
    public static let breakSuppressIfRemainingBelowSeconds: TimeInterval = 180
    public static let usageFlushIntervalSeconds: TimeInterval = 15
}

/// Pure decision logic; no clocks, no persistence.
public struct TimeLimitPolicy: Sendable {
    public struct Input: Sendable {
        public var limit: DailyTimeLimit
        public var activeSeconds: Int
        public var bonusSeconds: Int
        public var warningAllowanceSeconds: Int?
    }
    public enum Decision: Sendable, Equatable {
        case unrestricted                 // limit off
        case playing(remainingSeconds: Int)
        case speakWarning(remainingSeconds: Int, allowanceSeconds: Int)
        case locked
    }
    public static func evaluate(_ input: Input) -> Decision
}

public protocol ContinuousTimeSource: Sendable {
    /// Seconds since boot, including time asleep. Restarts at 0 after a reboot.
    /// Live implementation: mach_continuous_time() scaled by mach_timebase_info (ZKCore/Time/TrustedDayClock.swift).
    func continuousSeconds() -> TimeInterval
}

public struct TrustedDayClock: Sendable {
    public struct Anchor: Codable, Sendable {
        public var wall: Date
        public var continuous: TimeInterval
        public var driftSince: TimeInterval?
    }
    public static let plausibleDriftSeconds: TimeInterval = 300
    public static let acceptDriftAfterSeconds: TimeInterval = 86_400
    /// Returns the trusted instant and the anchor to persist.
    public static func trustedNow(wall: Date, continuous: TimeInterval, anchor: Anchor?) -> (now: Date, anchor: Anchor)
}

@MainActor
public protocol SessionManaging: AnyObject {
    var activeProfileID: UUID? { get }
    func enterChildHome(profileID: UUID)            // starts a session if none is open
    func switchProfile()                            // ends the session with .profileSwitch
    func noteTouch()                                // resets the 90 s inactivity window
    func scenePhaseChanged(to phase: ScenePhaseValue)
    func parentAreaOpened()
    func parentAreaClosed()
    func grantExtension(profileID: UUID)            // +600 s for the trusted local date
    func abenteuerStarted()
    func abenteuerEnded(completed: Bool)
    func breakNudgeAnswered(takePause: Bool)
}

public enum ScenePhaseValue: Sendable { case active, inactive, background }

Required unit tests (Section 25): TimeLimitPolicy at remaining 121/120/1/0/−1 seconds and with extensions; the task in progress at the limit always completes (no time cap) and the round closes as interrupted; warning spoken once per allowance; TrustedDayClock for no anchor, reboot, device sleep of several hours without a clock change (no drift), drift of ±299 s and ±301 s, persistent drift below and above 24 hours; an AppClock day rollover alone never unlocks a locked profile; midnight split of counted seconds; session end after exactly 300 s versus 301 s of gap; break nudge at 479/480 s, frozen during an Abenteuer, reset after a completed Abenteuer and after a 180-second gap, suppressed when remaining ≤ 180 s, never shown before the round's S-08; an abandoned Abenteuer resumes at its next unplayed round on the same local day and is discarded on the next day; nickname normalisation (trimming, whitespace collapse, 20-character limit with emoji and combining marks); avatar uniqueness; sixth-profile rejection.

16. Parent Area and Parental Gate #

This section owns the parental gate (S-17), the list of everything behind it, and the parent area: its information architecture, each parent screen (S-18 to S-25), the data actions (export, reset, delete), help and legal links, parent-area accessibility and edge cases. The paywall and all subscription logic are owned by Section 17; reward scope of reset and delete is decided in Section 14.11; profile rules and the daily time limit are owned by Section 15; the export file format is owned by Section 7; the mastery, band and "Gerade schwierig" computations are owned by Section 9; final German wording is owned by Section 22, which this section quotes; compliance traceability is owned by Section 23.

16.1 Principles #

# Principle Consequence
G1 Everything that leaves the child's world is behind the gate. Settings, purchases, links, data actions and time extensions are reachable only after S-17.
G2 The gate is easy for adults and practically impossible for 2–6-year-olds. Written number words plus multiplication facts from 36 to 81, never spoken.
G3 The parent area is honest and calm. Progress is shown as it is, in plain German; no scores out of context, no rankings, no nagging.
G4 Parent screens follow platform conventions. Standard SwiftUI navigation, forms, Dynamic Type, VoiceOver, dark mode (Section 19). Parent copy uses formal "Sie" (Section 22.1).
G5 Destructive actions are explicit and scoped. Every reset and delete states exactly what is removed and what stays; the device-wide delete requires typing a word.

16.2 Parental Gate (S-17) #

16.2.1 Challenge #

  • Two factors a and b are drawn independently and uniformly from {6, 7, 8, 9} using SystemRandomNumberGenerator (never seeded, never deterministic in Release). All 16 ordered pairs are possible, including squares (for example "acht mal acht").
  • A new challenge must differ from the previous challenge as an ordered pair (the same question never appears twice in a row).
  • The question is written with German number words, lowercase inside the sentence: "Was ist sieben mal acht?". Word map: 6 = "sechs", 7 = "sieben", 8 = "acht", 9 = "neun". The words come from the German number-word strings of the String Catalog (Section 20), not from a separate list.
  • The correct answer is a × b, always in the range 36–81 and therefore always two digits.
  • The question is never spoken: S-17 plays no audio at all. When S-17 opens, the child-mode voice stops and music pauses (Section 20); they resume only when child mode is visible again.

16.2.2 Layout and Input #

S-17 is presented full screen over the child mode, with parent styling (system background, system fonts, no illustrations, no characters, no animation) so it is neither inviting nor interesting for a child. It cannot be dismissed by swiping (interactiveDismissDisabled).

Element Content Notes
Cancel button (top leading) "Abbrechen" Returns to the screen the gate was opened from.
Title "Für Erwachsene" Section 22.4
Instruction "Bitte lösen Sie diese Aufgabe, um fortzufahren." Section 22.4
Question "Was ist sieben mal acht?" .title2, Dynamic Type, wraps.
Answer display Two boxes showing the entered digits Empty boxes show a thin underline.
Keypad Rows 1 2 3 / 4 5 6 / 7 8 9 / delete, 0 Standard phone order, never shuffled. Keys at least 64 × 56 pt. Delete key uses the SF Symbol delete.left.
Confirm button "Bestätigen" Full width below the keypad; enabled only when exactly 2 digits are entered.
Message line error, cooldown or "try again" text Section 22.4 strings; announced to VoiceOver.

Input rules:

  • The app uses its own keypad, never the system keyboard (no text field), so no autofill, dictation, predictive text or clipboard paste applies.
  • A third digit is ignored; the delete key removes the last digit.
  • With a hardware keyboard attached, the digit keys, Delete (Backspace) and Return are accepted (onKeyPress, available from iOS 17); Return equals "Bestätigen" when enabled.
  • The layout works in portrait and landscape on every device down to iPhone SE; in iPhone landscape the question and answer are shown to the left of the keypad.

16.2.3 Evaluation #

  • Correct: the gate closes and the destination opens within 0.2 s. The consecutive-wrong counter resets to 0.
  • Wrong: the answer boxes clear, the message "Das war leider nicht richtig. Bitte versuchen Sie es noch einmal." appears, the consecutive-wrong counter increases by 1, and a new challenge replaces the question. A 0.3 s horizontal shake of the answer boxes accompanies the error (Reduce Motion: no shake, the boxes briefly show the neutral color #8A8FA3 outline instead). No red, no sound.

16.2.4 Cooldown #

  • After the third consecutive wrong answer, the keypad and confirm button are disabled for 30 seconds and the message "Zu viele Versuche. Bitte warten Sie %1$d Sekunden und versuchen Sie es dann erneut." shows a countdown from 30 to 0, updated every second (Section 22.4 explains why this security countdown is exempt from the no-countdown rule).
  • When the cooldown ends: "Sie können es jetzt erneut versuchen.", a new challenge appears, the counter resets to 0.
  • Cancelling the gate does not reset the counter or the cooldown. Reopening the gate during a cooldown shows the remaining cooldown.
  • Persistence: the counter and the cooldown end time are stored in UserDefaults (zk.gate.consecutiveWrong, zk.gate.cooldownUntil, registered in the key registry of Section 7.9.2) so a relaunch does not reset them. On relaunch, the remaining cooldown is min(cooldownUntil − now, 30 s), so a clock change can never produce a cooldown longer than 30 seconds, and a negative value means the cooldown is over.

16.2.5 Gate Validity (the Parent Session) #

A correct answer opens a parent session. The parent session ends when the first of these occurs:

End condition Result
The parent leaves the parent area ("Fertig" on S-18, or closing the single destination opened from S-15 or S-16) Child mode; the next entry requires the gate again.
The app is in the background for more than 5 minutes On return, child mode is shown (routing in Section 15.5.1); the parent area is closed.
No touch in the parent area for 10 minutes while in the foreground Decision: the parent area closes itself and child mode appears (protection against a parent who walks away with the parent area open).
The profile set becomes empty ("Alle Daten löschen") S-02 first-launch welcome.

While the parent session is valid, no further gate is shown for any action inside the parent area (purchase, restore, manage subscription, settings, data actions, external links). External links additionally show the leave-app confirmation sheet (Section 16.11.2). Background for 5 minutes or less returns to the same parent screen without the gate.

A gate opened from child mode for a single purpose (S-15 → paywall, S-16 → extension sheet) opens a parent session limited to that destination plus the parent area if the parent chooses "Zum Elternbereich" from there; closing it ends the parent session.

16.2.6 Accessibility of the Gate #

The gate is designed for adults, including adults who use assistive technologies:

  • Every element has a VoiceOver label: the question is read as written text ("Was ist sieben mal acht?") by VoiceOver when an adult uses VoiceOver; the app itself never plays or synthesizes it. Keypad keys are labeled with the digit word ("Sieben"), the delete key "Löschen", the answer display "Eingabe: 5, 6" or "Eingabe leer".
  • Error and cooldown messages are posted as VoiceOver announcements.
  • Dynamic Type applies to title, instruction, question and messages (up to the accessibility sizes); the keypad keys keep a minimum size and grow with the text size.
  • Switch Control, Voice Control ("Tippen Sie auf Sieben") and hardware keyboards work because all controls are standard buttons with visible labels.

16.2.7 Rationale and Compliance #

  • App Store Review Guideline 1.3 (Kids Category) requires that apps in the Kids Category do not include links out of the app, purchasing opportunities or other distractions to children unless they are reserved for a designated area behind a parental gate. Guideline 5.1.4 adds the privacy rules for kids apps (Section 23 owns the full traceability).
  • Why this gate works for ages 2–6: the child would have to read German number words (pre-readers cannot; even a child who knows digits cannot map "sieben" to 7 without reading) and know multiplication facts between 36 and 81 (not part of preschool mathematics). A new random question after each wrong answer defeats memorising one answer; the 30-second cooldown after three wrong answers defeats guessing (a random two-digit guess is right with a probability of 1 in 90, and every wrong try brings a new question).
  • Why it is never spoken: speaking the question would remove the reading barrier, which is the main protection; an older sibling or a voice assistant could then answer it for the child. The child-mode rule that every instruction is spoken deliberately does not extend to the gate.
  • Why the keypad is not shuffled: shuffling adds no protection against a child who cannot read the question and harms adults and VoiceOver users.
  • Verification step for the executor: before submission, re-read the current text of Guideline 1.3 and the Kids Category section of Apple's Human Interface Guidelines and confirm that nothing in them requires a different gate form (Section 23, Section 26 launch checklist).

16.3 Everything That Requires the Gate #

# Action or screen Entry point Gate
1 First-launch setup (S-03) S-02 "Profil einrichten" S-17 before S-03 (Section 15.4.1)
2 Parent area (S-18 to S-25) Grown-up (parent entry) icon on S-04, S-05 and S-16 (press-and-hold 2 s, Section 18.1.3) S-17
3 Paywall, purchase, trial start (S-22) S-15 parent icon; S-18 Premium row S-17 (from S-15); covered by the parent session (from S-18)
4 Restore purchases ("Käufe wiederherstellen"), manage subscription, offer-code redemption, refund request S-22, S-24 (Section 17) covered by the parent session
5 Daily time extension "+10 Minuten für heute" S-16 parent icon; S-20 S-17 (from S-16); parent session (from S-20)
6 Every per-child and device setting S-20, S-21, S-25 parent session
7 Creating, editing, resetting, deleting profiles; "Alle Daten löschen" S-18, S-23, S-25 parent session
8 Data export and the system share sheet S-23 parent session
9 Every external link: privacy policy on the web, imprint, help website, support e-mail, Apple's standard EULA S-22, S-24 parent session + leave-app confirmation sheet (16.11.2)
10 App Store rating request S-18 only (16.14) parent session
11 Developer menu (compiled only under #if DEBUG, Section 5.10.1; absent from the Profile and Release configurations) single entry row "Entwicklermenü" at the bottom of S-18, Debug builds only parent session

Never gated: profile switching (Section 15.5.3), all child-mode screens, the "Frag deine Eltern" screen S-15 itself.

16.4 Information Architecture and Navigation #

16.4.1 Structure #

The parent area is one NavigationStack presented full screen over child mode (navigation mechanics per Section 18). On iPad in regular width the content column is limited to a readable width of 720 pt and centered, except the progress grid, which may use the full width.

S-18  Elternbereich (dashboard)                    [Fertig]
 ├─ Kinder
 │   ├─ Child card "Übersicht" (one per profile) ─────▶ S-19 Kind-Details
 │   │                                                  ├─ Segment "Fortschritt"
 │   │                                                  ├─ Segment "Gerade schwierig"
 │   │                                                  └─ Segment "Zeit"
 │   ├─ Card button "Einstellungen" ───────────────────▶ S-20 Einstellungen pro Kind
 │   │                                                  └─ "Profil bearbeiten" ─▶ S-25 (edit mode)
 │   └─ "Weiteres Profil" ─────────────────────────────▶ S-25 (create mode)
 ├─ Geräteeinstellungen ───────────────────────────────▶ S-21
 ├─ Premium (status line) ─────────────────────────────▶ S-22 (Section 17)
 ├─ Daten ─────────────────────────────────────────────▶ S-23
 └─ Hilfe & Rechtliches ───────────────────────────────▶ S-24
                                                        ├─ Datenschutzerklärung (in-app)
                                                        ├─ Tipps für Eltern (in-app)
                                                        └─ external links (confirmation sheet)

16.4.2 Entry #

  • From S-04, S-05 or S-16: the grown-up (parent entry) icon (press-and-hold 2 s, Section 18.1.3) → S-17 → S-18.
  • From S-15: parent icon → S-17 → S-22 directly (Section 17), with a "Zum Elternbereich" button leading to S-18.
  • From S-16 after the gate: the extension sheet (Section 15.8.5) with "Zum Elternbereich".
  • Opening the parent area pauses the child's session (Section 15.7.1).

16.4.3 Leaving #

"Fertig" (top trailing on S-18) closes the parent area and ends the parent session. Child mode then shows:

Situation Child-mode screen
The previously active profile still exists and is not locked The screen the gate was opened from (S-04, S-05); from S-15, the game picker S-06 with the game now unlocked if premium became active
The previously active profile is now locked (limit lowered) S-16
The previously active profile was unlocked (limit raised, Off, or extension) S-05
The previously active profile was deleted S-04 (2–5 profiles remain) or S-05 (1 remains)
The parent area stayed open for more than 5 minutes The session had ended (Section 15.7.1); S-05 of the previous profile (new session), or S-04 if it was deleted
All data deleted S-02

16.5 Parent Dashboard (S-18) #

Title "Elternbereich". Content from top to bottom:

  1. Section "Kinder": one card per profile, ordered by createdAt. Each card is the "Übersicht" for that child:
Row Content Example
Header Avatar (48 pt, theme-colored circle), display name (Section 15.2.1), level label "Mila · Die Kleinen"
Subtitle Section 22.6.1 "Übersicht" subtitle "So läuft es gerade bei Mila."
Zahlenraum current range stage and whether it is automatic "Zahlenraum 1–10 (automatisch)" / "Zahlenraum 1–20 (fest eingestellt)"
Zahlenfreunde count out of 20 plus a compact 20-dot bar in five-groups (filled dots = befriended) "7 von 20 Zahlenfreunden"
Sterne current star balance "128 Sterne"
Zeit today and the last 7 days (including today) "Heute: 12 von 20 Min. · Letzte 7 Tage: 74 Min." (limit Off: "Heute: 12 Min.")
Gerade schwierig the first item from Section 9's list, rendered with the Section 22.6.4 template, or the empty state "Zählen: Die Zahl 7 ist gerade noch schwierig."
Status shown only when locked today "Heute ist Zeit zum Ausruhen."
Hint level-up hint (Section 15.6.3), dismissible "Bereit für die Vorschule-Stufe?"
Actions the whole card opens S-19; trailing button "Einstellungen" opens S-20

Empty state for a child who has never completed a round: the card shows header, Zahlenraum and the Section 22.6.2 text "Noch keine Daten – nach der ersten Runde sehen Sie hier den Fortschritt." instead of the other rows.

  1. Button "Weiteres Profil" (disabled with the notice "Maximal 5 Profile pro Gerät." when 5 exist, Section 15.2.2).
  2. Row "Geräteeinstellungen" with the Section 22.6.1 subtitle.
  3. Row "Premium" with the current subscription status line (Section 22.6.8, state logic in Section 17).
  4. Row "Daten" with subtitle.
  5. Row "Hilfe & Rechtliches" with subtitle.
  6. Debug builds only: row "Entwicklermenü" (Section 5.10.1). It does not exist in Profile or Release builds.

All values refresh each time S-18 appears (the parent area may stay open across midnight).

16.6 Child Progress Detail (S-19) #

Title: the child's display name. Below the title, a header shows avatar, level and the range stage indicator; then a segmented control with three segments: "Fortschritt", "Gerade schwierig", "Zeit". The segment selection is remembered while the parent area stays open.

16.6.1 Range Stage Indicator #

Three connected capsules "1–5", "1–10", "1–20". The current stage is filled; earlier stages show a check. A caption states the mode: "automatisch" (the engine widens and narrows, Section 9) or "fest eingestellt – ändern in den Einstellungen" with a link to S-20. For littleOnes with automatic range, the "1–20" capsule carries the caption "nur per Einstellung", because automatic widening stops at 1–10.

16.6.2 Segment "Fortschritt": the Number × Skill Grid #

Grid: 20 rows (numbers 1–20) × 8 columns (skills in this order: Ziffer erkennen, Zahlwort zuordnen, Zählen, Simultanerfassung, Ordnen, Vergleichen, Zerlegen, Schreiben — display names from the skill taxonomy, Section 4).

  • Row header: the numeral, bold. Thin separators after rows 5, 10 and 15 and a stronger one after row 10, mirroring the five-group and Zwanzigerfeld structure.
  • Column header: an SF Symbol per skill plus the skill name in .caption2, wrapping to at most 3 lines. Tapping a column header shows a short popover with the skill's one-sentence explanation (Section 4).
  • Rows beyond the current range are dimmed to 50 % and preceded by a divider labelled "Ende des aktuellen Zahlenraums".
  • Cell size 44 × 44 pt minimum (column width 64 pt). In compact width the grid scrolls horizontally with the number column pinned; in regular width it fits without scrolling.

Cell states (never color alone: every state has a distinct symbol and a text label, and the symbol is drawn in the ink color on the band fill):

Band (MasteryBand, ZKCore; computed by Section 9) Label (Section 22.6.3) SF Symbol Fill token (values for light, dark and increased contrast are owned by Section 19.2.3)
notStarted (attempts 0) Noch nicht geübt minus band.notStarted
practicing (score < 0.50) Wird geübt circle.bottomhalf.filled band.practicing
almost (0.50–0.79) Fast sicher circle.fill band.almost
mastered (score ≥ 0.80 and attempts ≥ 6) Sicher checkmark.circle.fill band.mastered
skill × number not active for the child's level (for example Schreiben above 5 for "Die Kleinen") Nicht Teil dieser Stufe slash.circle diagonal hatch on the surface color
skill × number never scheduled at any level (per the engine's applicability rules, Section 9) Nicht anwendbar none (empty) surface color with 1 pt border

A legend with all six states (symbol, fill, label) sits above the grid.

Below the grid, the block "Neu sicher (letzte 30 Tage)":

  • Headline: "In den letzten 30 Tagen neu sicher: %d" where %d is the number of skill × number pairs whose first transition to mastered happened within the last 30 local days.
  • Per skill with at least one entry: the skill name followed by number chips in ascending order, for example "Zählen: 4 · 5 · 7".
  • Data source: the first time each pair reached mastered (a firstMasteredAt timestamp maintained by the engine on MasteryRecord, Section 7 and Section 9). A pair that later drops below the threshold keeps its timestamp; the list shows historical gains, not the current state.
  • Empty state: "In den letzten 30 Tagen ist noch keine Zahl neu sicher geworden."

16.6.3 Cell Detail #

Tapping a cell opens a sheet (medium detent on iPhone, popover on iPad):

Field Source Example
Title number and skill "7 · Zählen"
Stand band symbol and label "Fast sicher"
Versuche attempts "Versuche: 9"
Beim ersten Versuch richtig firstTryCorrect of attempts "6 von 9"
Zuletzt geübt lastPracticedAt, relative "heute", "gestern", "vor 4 Tagen", or a date ("12.08.2026") when older than 30 days; "noch nie" when attempts is 0
Nächste Wiederholung nextDueAt "heute fällig", "in 3 Tagen"
Sicher seit firstMasteredAt, only if set "seit 14.09.2026"

Decision: the raw mastery score is not shown to parents; the band and the attempt counts are more honest than a percentage with false precision. Cells in "Nicht Teil dieser Stufe" or "Nicht anwendbar" state open the sheet with only the title and one explanatory sentence.

16.6.4 Segment "Gerade schwierig" #

  • Up to 3 items computed by the engine (the pairs with attempts ≥ 3 and the lowest scores, Section 9), in the engine's order.
  • Each item: the sentence from the Section 22.6.4 template ("Zählen: Die Zahl 7 ist gerade noch schwierig."), the band symbol, and one everyday tip for that skill (table below). Tapping an item opens the cell detail (16.6.3).
  • Footer line: "Die App übt diese Zahlen automatisch häufiger."
  • Empty state (no qualifying item): Section 22.6.2 "Gerade läuft es gut – nichts Auffälliges."

Everyday tips per skill (functional copy; Section 22 adopts it):

Skill Tip
Ziffer erkennen Suchen Sie die Zahl gemeinsam auf Hausnummern, Uhren oder Preisschildern.
Zahlwort zuordnen Sagen Sie beim Tischdecken laut, wie viele Teller es sind, und zeigen Sie die Zahl mit den Fingern.
Zählen Zählen Sie gemeinsam Treppenstufen, Knöpfe oder Äpfel und tippen Sie dabei jedes Ding an.
Simultanerfassung Würfeln Sie zusammen und fragen Sie, wie viele Punkte es sind, ohne zu zählen.
Ordnen Zählen Sie beim Treppensteigen vorwärts und beim Hinuntergehen rückwärts.
Vergleichen Fragen Sie beim Essen: Wer hat mehr Nudeln, wer hat weniger?
Zerlegen Verteilen Sie Gummibärchen auf zwei Hände und fragen Sie, wie viele in jeder Hand sind.
Schreiben Malen Sie die Zahl gemeinsam mit dem Finger in die Luft oder in Sand.

16.6.5 Segment "Zeit" #

  • Subtitle (Section 22.6.1): "Spielzeit der letzten 7 Tage."
  • Chart (Swift Charts): one BarMark per local day for the last 7 days including today (today rightmost). Y value: active minutes (activeSeconds / 60, one decimal internally; the bar annotation shows whole minutes rounded half up, and "< 1" for 1–29 seconds). X labels: two-letter weekday ("Mo", "Di", …), today labeled "Heute". Days without usage show a zero-height bar with a "0" annotation.
  • Limit: for each day with a limit in force, a short dashed RuleMark segment across that day's bar at the day's allowance (limit plus extensions, from DailyUsage.limitMinutes and bonusSeconds). Days with the limit Off show no rule. A legend explains "gestrichelt = Zeitlimit".
  • Summary rows below the chart: "Durchschnitt: %d Min. pro Tag" (average over the 7 days, including zero days), "Heute: %d von %d Min." (or "Heute: %d Min." when Off), and "Heute verlängert: +%d Min." when extensions were granted today.
  • Empty state: Section 22.6.2 "In den letzten 7 Tagen wurde nicht gespielt." (the chart is still drawn with zero bars).
  • Accessibility: each bar has the label "Montag" and the value "14 Minuten, Zeitlimit 20 Minuten"; the chart has a summary label containing the average. Swift Charts' built-in accessibility (including Audio Graphs) stays enabled; verification step: check with VoiceOver on device.
  • Colors: bars in the neutral ink tint; the chart never colors days by "good" or "bad".

16.7 Child Settings (S-20) and Profile Editor (S-25) #

16.7.1 S-20 "Einstellungen pro Kind" #

A grouped Form. Changes are saved immediately when a value changes; there is no separate save button.

Group Control Values and default Footer / behaviour
Lernen Zahlenraum (Picker) Automatisch (default), 1–5, 1–10, 1–20 "Automatisch: Die App erweitert den Zahlenraum, wenn Ihr Kind so weit ist." A fixed value switches off automatic widening and narrowing (Section 9). 1–20 is selectable at both levels. Applies from the next round.
Lernen Schwierigkeit (Picker) Automatisch (default), Leicht, Mittel, Schwer "Leicht" allows only step 1, "Mittel" steps 1–2, "Schwer" all steps (Section 9). Applies from the next round.
Lernen Stufe (Picker) Die Kleinen (2–4 Jahre), Vorschule (4–6 Jahre) Changing it opens the confirmation (Section 16.12) with effects per Section 15.6.3.
Zeit Tägliches Zeitlimit (Picker) Aus, 10, 15, 20 (Standard), 30, 45, 60 Minuten (Section 22.6.5) Applies immediately (Section 15.8.6).
Zeit Status row "Heute gespielt: %d von %d Minuten" Read-only.
Zeit Button "+10 Minuten für heute" visible when the limit is on Grants +600 s for today (Section 15.8.5); a confirmation toast "Heute 10 Minuten mehr." appears.
Profil Row "Profil bearbeiten" Opens S-25 in edit mode.
Daten Row "Daten dieses Kindes" Opens S-23 scrolled to this child.

16.7.2 S-25 Profile Editor #

Two modes with the same form (copy per Section 22.5.2):

Field Create mode Edit mode
Spitzname empty, optional, 0–20 characters, live counter current value
Lieblingstier (avatar grid, 12) first unused avatar preselected; avatars used by other profiles dimmed with a small check and not selectable current avatar selected; other profiles' avatars dimmed
Lieblingsfarbe (6 swatches) theme.sonne preselected current
Stufe two option cards, none preselected, required not shown (the level is changed in S-20)
Buttons "Abbrechen", "Fertig" (enabled when valid) "Abbrechen", "Fertig"

Validation per Section 15.2.1. The nickname field enforces the limit while typing (input beyond 20 characters is not accepted; a paste that would exceed it is truncated to 20 and shows the Section 22.5.2 length message). Cancelling with unsaved changes asks "Änderungen verwerfen?" with "Verwerfen" and "Weiter bearbeiten".

16.8 Device Settings (S-21) #

Settings for this device only, stored in UserDefaults and never synced (DeviceSettingsStore, Section 7.9); defaults apply on first launch and after "Alle Daten löschen". Changes take effect immediately. Hardware capabilities are read from the two read-only flags supportsHaptics and hasAccelerometer of DeviceSettingsStore (Section 7.9.3), which the app target fills at bootstrap; ZKParentArea itself never imports Core Haptics or Core Motion (module rules, Section 5.3.2).

Setting (label) Default Visibility Effect
Sprache (spoken voice) on always Off: no voice lines; every instruction is shown visually through the demo hand, highlighted targets and numerals (Section 20, Section 10). Footer: "Ohne Sprache zeigen Bilder und Animationen, was zu tun ist."
Musik on always Background music loops on or off (Section 20).
Soundeffekte on always SFX on or off.
Haptik on only when DeviceSettingsStore.supportsHaptics is true Enables .sensoryFeedback in child mode (map in Section 19.10).
Schüttelbox mit Bewegung on always; disabled with the note "Dieses Gerät hat keinen Bewegungssensor." when DeviceSettingsStore.hasAccelerometer is false Off: Schüttelbox uses only its on-screen shake button (Section 12.5). These labels are the only names of the two game settings; Section 12 uses them.
Nachspuren nur mit Apple Pencil off only on iPad On: the Nachspuren canvas accepts only Apple Pencil input, finger touches are ignored (Section 12). Footer: "Nur einschalten, wenn Ihr Kind mit einem Apple Pencil spielt."

Volume is controlled only by the device's system volume; the app has no volume sliders.

V1.1 addition (Section 7.15.2, Section 26.4): S-21 gains the toggle "Mit iCloud synchronisieren", default off. It is enabled only while premium is active; without premium it is disabled with an explanatory footer. When premium lapses, synchronisation stops and all local data stays on the device. Switching the toggle reopens the data store with the other store configuration (Section 7.15.2). V1 contains no sync setting.

16.9 Premium (S-22) #

The S-18 row "Premium" shows the status line from Section 22.6.8 and opens S-22. Paywall content, purchase, restore, manage subscription, offer codes, refund request, entitlement states and lapse behaviour are owned entirely by Section 17. Within the parent area no additional gate is shown for these actions (the parent session covers them, Section 16.2.5).

16.10 Data Management (S-23) #

Title "Daten", subtitle per Section 22.6.1. One group per child (avatar and display name as group header) and one device group.

16.10.1 Per-Child Actions #

Action Flow Result
Daten exportieren Builds the export for this child using the JSON format defined in Section 7 (the profiles array contains exactly this child), writes it to the app's temporary directory under the file name defined in Section 7.17.1 (<AppName>-Export-<DisplayName>-<yyyy-MM-dd>.json; AppName from Brand.appName, DisplayName per the display-name rule of Section 15.2.1, so an empty nickname falls back to the avatar name, sanitized as defined in Section 7.17.1), and presents the system share sheet with the file. Share sheet completed with an activity: success banner (Section 22.6.7). Share sheet cancelled: no message. Write or encoding failure: failure alert (Section 22.6.7). The temporary file is deleted when the share sheet closes.
Fortschritt zurücksetzen Confirmation alert (16.12). Learning state reset; stars, garden, friends and stickers kept (scope in Section 14.11).
Alles zurücksetzen Confirmation alert (16.12), destructive style. The label is deliberately different from the device-wide "Alle Daten löschen". All progress, rewards and usage history of this child deleted; the profile, its avatar, nickname, color theme, level and settings, and today's usage entry remain, so a reset never grants extra play time (scope in Section 14.11, procedure in Section 7.18.2).
Kind löschen Confirmation alert "Profil löschen?" (Section 22.6.6), destructive. Disabled with the footer "Mindestens ein Profil muss bestehen bleiben. Um alles zu entfernen, nutzen Sie „Alle Daten löschen“." when only one profile exists; deleting the last remaining profile is possible only through "Alle Daten löschen". The profile and all its data are hard-deleted (cascade, Section 7.18.3). Any store recovery folder (Section 7.12.3) is deleted too, because a recovery copy contains the data of all profiles, including this child. S-23 and S-18 update; if S-19 or S-20 of this child was on the navigation stack, the stack returns to S-18.

Export rules: the export contains no data of other children and no device settings; it is generated on demand and never stored permanently by the app. Encoding runs off the main actor on a Sendable snapshot produced by the repository (Section 7). Export of a child with no data produces a valid file with empty collections.

16.10.2 Device Actions #

The device group of S-23 contains, in this order:

Action Visibility Flow and result
Alle Daten exportieren always Builds one export file with scope allProfiles (format and file name <AppName>-Export-<yyyy-MM-dd>.json in Section 7.17.1) and presents the system share sheet; banners, failure alert and temporary-file handling as for the per-child export (16.10.1).
Wiederherstellungsdaten löschen only while a store recovery folder exists (Section 7.12.3) Confirmation alert with "Abbrechen" / "Löschen" (wording per Section 22.6.6). Deletes the recovery folder; the one-time recovery notice is cleared. Otherwise the folder is deleted automatically after 30 days (Section 7.12.3).
Alle Daten löschen always The two-step flow below.

"Alle Daten löschen":

  1. First step: alert "Alle Daten löschen?" with the Section 22.6.6 body and buttons "Abbrechen" / "Weiter".
  2. Second step: a sheet with the instruction "Geben Sie zur Bestätigung LÖSCHEN in das Feld ein.", a text field (placeholder "LÖSCHEN", autocapitalization characters, autocorrection off), and the button "Endgültig löschen", enabled only when the trimmed field text equals exactly "LÖSCHEN" (case-sensitive, Section 22.6.6). The sheet also states "Ihr Abonnement bleibt bestehen. Sie können es in den Einstellungen Ihres Geräts verwalten."
  3. On confirm: all SwiftData data is deleted (procedure in Section 7.18.4); every UserDefaults key marked "reset" in the key registry of Section 7.9.2 is removed (device settings, gate counters, dismissed hints and other per-device state); temporary export files and store recovery folders are removed. Kept on purpose: the entitlement cache (zk.entitlement.*, so the device does not show a wrong "free" state while offline), zk.device.installationID, zk.clock.highWaterMark and the trusted-clock anchor zk.clock.trustedAnchor (deleting data must never reset clock-change protection, Section 15.12). The StoreKit entitlement is re-read from Apple immediately afterwards (Section 17); the subscription itself is not affected by deleting app data.
  4. The parent area closes and the app shows S-02 (first launch). Nothing can be undone.

16.11.1 Contents #

Row Type Target
Tipps für Eltern in-app page Short static guidance: how the app teaches (three representations, groups of five, mistakes are never punished), the recommendation to play together with children under 3 (Section 2), and how to use Guided Access (Geführter Zugriff) to keep a young child inside the app. Copy owned by Section 22.
Datenschutzerklärung in-app page The privacy policy text bundled with the app (outline in Section 22.8), readable offline. At the bottom, the row "Im Browser öffnen" (external link to PRIVACY_POLICY_URL).
Impressum external link IMPRINT_URL (Section 1: static page on the product domain).
Hilfe-Website external link SUPPORT_URL.
Support kontaktieren external (Mail) mailto: SUPPORT_EMAIL with the subject and body template of Section 22.9, which includes app version, device model family and iOS version, and no child data. Decision: always the mailto: flow with the leave-app sheet (16.11.2); the app never uses MFMailComposeViewController and does not link MessageUI (Section 23.10.2).
Rückerstattung anfordern system sheet Owned by Section 17.
Version read-only "Version 1.0 (Build 42)" from the bundle.

URLs and the support address are read through Brand (Section 20.19.2); their values come from Config/Brand.xcconfig via the checked-in Info.plist (build settings in Section 5.8.2 and Section 5.12, decision in Section 1). The app contains no web view: Decision: no WKWebView and no SFSafariViewController; external pages always open in the system browser, so leaving the app is always explicit.

16.11.2 Leave-App Confirmation Sheet #

Every external link (including the EULA link on the paywall, Section 17, and the support e-mail) first shows a confirmation sheet:

Element Web link E-mail
Title "Sie verlassen jetzt die App" "Sie verlassen jetzt die App"
Body "Die Seite wird in Safari geöffnet." "Ihre E-Mail-App wird geöffnet."
Buttons "Öffnen", "Abbrechen" "Öffnen", "Abbrechen"

"Öffnen" calls SwiftUI openURL. If the system reports that the URL could not be opened (for example no mail account is configured), a fallback sheet shows the address as selectable text with a button "Adresse kopieren" (copies to the pasteboard) and "Schließen". Returning to the app later lands in the parent area if the parent session is still valid (background 5 minutes or less), otherwise in child mode.

16.12 Functional Microcopy #

Section 22 is the owner of parent-facing wording and uses formal "Sie". Strings that Section 22 already defines are used exactly as defined there (gate, setup, section headers, empty states, band labels, "Gerade schwierig" template, time-limit labels, profile deletion, device-wide deletion, export, subscription status). The following strings are required by this section's flows; their meaning is binding, and Section 22 carries the final wording:

Context String
Reset one child's learning progress: title Fortschritt zurücksetzen?
Reset: body Der Lernstand von %1$@ wird zurückgesetzt: Die App beginnt wieder mit leichten Aufgaben im Start-Zahlenraum. Sterne, Garten, Zahlenfreunde und Sticker bleiben erhalten.
Reset: buttons Abbrechen / Zurücksetzen
Reset everything of one child: title Alles zurücksetzen?
Everything: body Alle Lernfortschritte, Sterne, Gartendekorationen, Zahlenfreunde und Sticker von %1$@ werden unwiderruflich gelöscht. Das Profil selbst und die heutige Spielzeit bleiben erhalten.
Everything: buttons Abbrechen / Alles zurücksetzen
Level change: title Stufe ändern?
Level change: body Die Aufgaben passen sich ab der nächsten Runde an die neue Stufe an. Fortschritt, Sterne und Zahlenfreunde bleiben erhalten.
Level change: buttons Abbrechen / Stufe ändern
Level-up hint Bereit für die Vorschule-Stufe? %1$@ kennt den Zahlenraum bis 10 schon gut.
Extension toast Heute 10 Minuten mehr.
Extension sheet (after gate from S-16) +10 Minuten für heute / Zum Elternbereich / Abbrechen
Locked status on dashboard Heute ist Zeit zum Ausruhen.
Delete-all subscription note Ihr Abonnement bleibt bestehen. Sie können es in den Einstellungen Ihres Geräts verwalten.
Device export row Alle Daten exportieren
Recovery data row and alert Wiederherstellungsdaten löschen / Wiederherstellungsdaten löschen? / Die Sicherungskopie der früheren Daten wird endgültig gelöscht. / Abbrechen / Löschen
iCloud sync (V1.1) Mit iCloud synchronisieren / Die Synchronisierung ist mit Premium verfügbar.
Last profile cannot be deleted Mindestens ein Profil muss bestehen bleiben. Um alles zu entfernen, nutzen Sie „Alle Daten löschen“.
Leave-app sheet Sie verlassen jetzt die App / Die Seite wird in Safari geöffnet. / Ihre E-Mail-App wird geöffnet. / Öffnen / Abbrechen
Mail fallback Es ist keine E-Mail-App eingerichtet. Schreiben Sie uns an: %1$@ / Adresse kopieren / Schließen
New-mastery block Neu sicher (letzte 30 Tage) / In den letzten 30 Tagen neu sicher: %d / In den letzten 30 Tagen ist noch keine Zahl neu sicher geworden.
Grid legend extras Nicht Teil dieser Stufe / Nicht anwendbar / Ende des aktuellen Zahlenraums
"Gerade schwierig" footer Die App übt diese Zahlen automatisch häufiger.
Unsaved changes Änderungen verwerfen? / Verwerfen / Weiter bearbeiten

Tone rules for any further parent string: factual, warm, short sentences, formal "Sie", no exclamation marks except in the child-facing voice lines, no pressure, no judgement of the child ("schwierig" describes a task, never the child).

16.13 Accessibility of the Parent Area #

Area Requirement
Dynamic Type All parent text uses text styles and scales up to the largest accessibility size. At accessibility sizes (.accessibility1 and larger) the progress grid switches to a list layout: one disclosure row per number, expanding to 8 rows (skill name, symbol, band label).
VoiceOver Every control has a label; decorative images are hidden. Grid cells read as "7, Zählen, Fast sicher, 9 Versuche"; row and column headers are marked as headers. Child cards read as one combined element with a custom action "Einstellungen". Charts per 16.6.5. The parent entry icon in child mode offers the accessibility action "Elternbereich öffnen" (equivalent to the press-and-hold, still followed by the gate).
Color Band states always carry symbol and label (16.6.2). With "Kontrast erhöhen" enabled, band fills switch to their high-contrast variants (Section 19) and cell borders become 1 pt ink.
Touch targets At least 44 × 44 pt (Apple HIG), gate keys at least 64 × 56 pt.
Dark mode The parent area supports light and dark appearance (child mode always uses the light warm appearance, Section 19).
Reduce Motion No custom animations in the parent area beyond system navigation; the gate shake is replaced (16.2.3).
Voice Control and keyboards Visible labels equal accessibility labels; hardware keyboard navigation uses standard SwiftUI focus.

16.14 App Store Rating Request #

Decision: the app requests an App Store rating only inside the parent area, using SwiftUI's requestReview environment action, and only when all of these hold: the parent returns from S-19 to S-18; at least 14 days have passed since the first profile was created; at least 10 sessions with completed tasks exist across all profiles; no request was made for the current app version; and at least 120 days have passed since the last request. The system decides whether a prompt is actually shown. The request is never made in child mode, never after a purchase, and never tied to rewards.

16.15 Edge Cases #

# Case Behaviour
1 Child reaches S-17 by long-pressing the parent icon The child sees an unreadable question; cancel returns to child mode; three wrong answers start the cooldown. No audio, no reward, nothing happens.
2 Gate is open and the app goes to the background for more than 5 minutes On return, S-17 is dismissed and the origin child screen is shown (or routing per Section 15.5.1).
3 Device rotates during the gate Layout switches; entered digits and the current challenge are kept.
4 Cooldown active, app relaunched, clock changed Remaining cooldown is at most 30 s (16.2.4).
5 VoiceOver user in child mode The parent icon's accessibility action opens the gate; the gate is fully accessible.
6 Parent area open across local midnight Values refresh when screens appear; the time chart shifts by one day.
7 Parent deletes the child whose S-19 is open (from another path) The navigation stack returns to S-18.
8 Parent deletes the currently active child Allowed (not the last one); child mode routing per Section 16.4.3.
9 Export while storage is almost full Write fails → failure alert; no partial file remains.
10 Export for a child with an empty nickname File name uses the avatar name, for example Zahlenkette-Export-Fuchs-2026-09-29.json (Section 7.17.1).
11 Parent types "löschen" or "LÖSCHEN " in the delete-all field "löschen": button stays disabled (case-sensitive). "LÖSCHEN " with trailing space: accepted after trimming.
12 Parent opens an external link while a StoreKit purchase sheet is showing Not possible: the system purchase sheet is modal.
13 Ten minutes without touch in the parent area during a StoreKit purchase sheet The inactivity timer pauses while a system sheet (purchase, manage subscription, share sheet, refund) is presented and restarts when it closes.
14 Setting "Nachspuren nur mit Apple Pencil" on an iPad without a Pencil The child cannot trace; the footer warns before enabling. The parent can switch it off at any time.
15 Parent changes device settings while child mode audio is paused Takes effect when child mode resumes.
16 5 profiles exist and V1.1 sync later brings more Behaviour per Section 7.20.5 (no automatic deletion, creation blocked).
17 Parent sets range 1–20 for "Die Kleinen" Allowed; the Zahlenfreunde 11–20 become reachable; the indicator shows "fest eingestellt".

16.16 Gate and Parent Session Contract #

import Foundation

public struct GateChallenge: Equatable, Sendable {
    public let a: Int            // 6...9
    public let b: Int            // 6...9
    public var answer: Int { a * b }
    public static let factorRange = 6...9

    /// Returns a challenge whose (a, b) differs from `previous`.
    public static func make<R: RandomNumberGenerator>(previous: GateChallenge?, using rng: inout R) -> GateChallenge
}

public enum GateInputResult: Equatable, Sendable {
    case accepted
    case wrong(remainingBeforeCooldown: Int, next: GateChallenge)
    case cooldownStarted(until: Date, next: GateChallenge)
}

public struct GateRules: Sendable {
    public static let maxConsecutiveWrong = 3
    public static let cooldownSeconds: TimeInterval = 30
    public static let answerDigits = 2
}

public enum GatePurpose: Sendable, Equatable {
    case firstLaunchSetup
    case parentArea
    case paywallFromLockedGame
    case timeExtension(profileID: UUID)
}

@MainActor
public protocol ParentSessionManaging: AnyObject {
    var isValid: Bool { get }
    func open(for purpose: GatePurpose)
    func noteTouch()                                  // resets the 10-minute inactivity timer
    func systemSheetPresented(_ presented: Bool)      // pauses the inactivity timer
    func scenePhaseChanged(to phase: ScenePhaseValue) // background > 5 min ends the session
    func close()
}

GateChallenge.make is tested with a seeded generator in unit tests (never in Release). Required tests (Section 25): all answers are two digits; no immediate repetition over 10,000 draws; the cooldown starts exactly at the third consecutive wrong answer and not at the second; a correct answer resets the counter; cancel does not reset it; relaunch cap of 30 s; parent session ends at 300 s + 1 s in the background but not at 300 s; inactivity auto-close at 10 minutes and pause during system sheets; the delete-all confirmation accepts only "LÖSCHEN" after trimming.

17. Monetization and Subscriptions #

This section is the single owner of the business model, the subscription products, prices, the free trial, Family Sharing, entitlement logic, the child-facing lock UI, the functional structure of the paywall, lapse behaviour, the content roadmap that justifies the subscription, and the rejected alternatives. Final German paywall copy is owned by Section 22. The parental gate is owned by Section 16. Compliance traceability is owned by Section 23.

17.1 Model and Rationale #

The app is monetized with exactly one mechanism: an auto-renewable subscription sold through StoreKit 2, with a monthly and a yearly plan in one subscription group, a 7-day free trial for new subscribers, and Family Sharing enabled on both plans.

Principle Consequence
The four games that grew out of the original prototype stay free forever. Entdecken, Wie viele?, Hör hin and Was fehlt? are fully playable (all 3 difficulty steps, full mastery tracking, stars, garden, Zahlenfreunde, sticker album, Abenteuer) without paying. A family can use the free tier indefinitely and get real learning value.
The subscription pays for ongoing work, not for a one-time unlock. Premium unlocks the other 8 games now and every content drop and platform feature listed in Section 17.12 later. The roadmap is part of the product promise.
The child never sees commerce. No price, no "kaufen", no "Premium" wording, no store sheet and no paywall ever appears in child mode. Locked games show a lock badge and a friendly "ask a grown-up" moment (Section 17.8). All purchase, restore, redeem, manage and refund actions live exclusively in the parent area behind the parental gate (Section 16).
One purchase covers the whole family. Family Sharing is enabled; one subscription covers all 5 profiles on a device and all devices of all members of the purchaser's Apple Family Sharing group.
No dark patterns. No countdowns, no "only today", no fake strike-through prices, no pre-checked upsells, no guilt copy, no repeated paywall pop-ups. The paywall is never shown automatically; a parent reaches it only by choice (Section 17.9.1).
No backend. Entitlement is determined entirely on device from StoreKit 2 signed transactions. There is no receipt server, no App Store Server Notifications endpoint, no account system.

17.2 Products #

17.2.1 Product table #

Field Monthly Yearly
Reference name (App Store Connect, internal) Premium Monatlich Premium Jährlich
Product ID <bundleID>.premium.monthly → de.zahlenkette.app.premium.monthly <bundleID>.premium.yearly → de.zahlenkette.app.premium.yearly
Type Auto-renewable subscription Auto-renewable subscription
Subscription group (reference name) Zahlenkette Premium Zahlenkette Premium
Duration 1 month 1 year
Price (base storefront Germany; same price point in Austria) 3,99 € 29,99 €
Savings shown on paywall none badge "ca. 37 % günstiger" (computed, see Section 17.9.3)
Introductory offer Free trial, 1 week (7 days), new subscribers Free trial, 1 week (7 days), new subscribers
Family Sharing On On
Group level (ranking) Level 2 Level 1 (highest)
Preselected on paywall no yes

Notes:

  • The bundle ID default is de.zahlenkette.app (Section 1). Product IDs are derived from the bundle ID at build time, never hardcoded as literals in Swift: ProductIDs.monthly = Brand.bundleIdentifier + ".premium.monthly" and ProductIDs.yearly = Brand.bundleIdentifier + ".premium.yearly" (Brand.bundleIdentifier is the non-optional accessor of Section 20.19.2; no force unwrap, per the coding rules of Section 6.8.2); in unit tests the base is injected. If the executor changes the bundle ID (Section 1), the product IDs change with it; product IDs in App Store Connect are permanent and cannot be reused after deletion.
  • The subscription group reference name Zahlenkette Premium is internal and survives an app rename. The group display name shown to customers (in Settings → Apple ID → Subscriptions) is localized separately (Section 17.3) and must be updated if the app is renamed (rename checklist, Section 20).
  • Group level: level 1 = yearly, level 2 = monthly. Consequences under Apple's rules: switching monthly → yearly is an upgrade and takes effect immediately (Apple refunds the unused monthly portion pro rata); switching yearly → monthly is a downgrade and takes effect at the next renewal date (Section 17.6.9).
  • The 37 % figure: 12 × 3,99 € = 47,88 €; 1 − 29,99 / 47,88 = 0,3736 → "ca. 37 %".
  • Trial eligibility is per subscription group per Apple ID: a customer who already used the trial on either plan is not eligible again on the other plan. Apple determines eligibility; the app only reads it (Section 17.6.11).

17.2.2 Storefront availability #

Decision: V1 launches in the storefronts Germany, Austria and Switzerland only, because all V1 content, voice and copy are German (launch storefronts owned by Section 26.9.3). No other storefront is enabled at launch; English-language storefronts open with V1.2 (English text and voice in British English, en-GB, Section 26).

17.2.3 Prices in CHF #

Prices are set with Germany as the base storefront; Apple's automatic price equalization derives prices for Austria (same euro price) and Switzerland (CHF). Decision: accept Apple's equalized CHF prices unless they end in an uneven value; in that case the executor manually selects the nearest CHF price point at or just below the equalized value for Switzerland in App Store Connect and records the chosen CHF prices in the repository file DECISIONS.md. The app never displays a hardcoded price in any currency: all prices come from Product.displayPrice and Product.priceFormatStyle (Section 17.9.3).

17.3 App Store Connect Setup Checklist #

The executor completes every item before the first TestFlight build that contains the paywall. Items marked "(once)" cannot be undone.

# Item Details
1 Paid Applications Agreement Accept in App Store Connect → Business. Required before any in-app purchase can be tested in sandbox or sold.
2 Banking information Add the payout bank account for the seller entity.
3 Tax forms Complete the tax forms App Store Connect requests for the seller's country (for a non-US seller this includes the US tax form declaring treaty status).
4 EU trader status Declare trader status under the EU Digital Services Act in App Store Connect (required for distribution in the EU). A company seller is a trader; contact details declared there are displayed on the product page.
5 Primary language and category App primary language German. Primary category Education; in the age-rating section "Made for Kids" is selected with the age band "5 and Under", which places the app in the Kids category (procedure owned by Section 26.9.3; compliance in Section 23).
6 Subscription group Create group with reference name Zahlenkette Premium. Add group localization de-DE: display name {{APP_NAME}} Premium rendered as "Zahlenkette Premium" (Section 22 owns the final string).
7 Products Create the two products per Section 17.2.1 with exactly those product IDs, durations and group levels (yearly level 1, monthly level 2).
8 Product localizations de-DE display name and description per product (Section 22 owns the strings; keep within the character limits App Store Connect enforces). Decision: only de-DE in V1; en-GB is added with V1.2.
9 Prices Base storefront Germany: monthly 3,99 €, yearly 29,99 €. Review Austria and Switzerland per Section 17.2.3. Availability: Germany, Austria and Switzerland only (Section 17.2.2).
10 Introductory offer For each product: type Free, duration 1 week, eligibility new subscribers, all available storefronts, no end date.
11 Family Sharing (once) Turn on for both products. Apple does not allow turning Family Sharing off again once enabled for a subscription.
12 Billing Grace Period Turn on in the subscription settings. Decision: 16 days; eligible subscribers "paid-to-paid renewals only" (a failed first charge after the free trial does not receive grace; the trial already gave 7 free days). Environment: sandbox and production.
13 Offer codes Offer codes may be created later for press and promotions (Section 17.6.8). No offer codes are required at launch.
14 Promoted in-app purchases Decision: none. Do not promote the subscriptions on the App Store product page, because a promoted purchase would start the purchase flow without the parental gate. The app does not observe PurchaseIntent.
15 App Store Server Notifications Decision: not configured (no backend).
16 Review information per product Attach the review screenshot: a screenshot of the paywall S-22 on an iPhone, in the size App Store Connect requests. Review notes: "The subscription is offered only in the parent area. Hold the grown-up icon at the top right of the child home screen for 2 seconds, then answer the multiplication question (answer shown in German number words, e.g. 'sieben mal acht' = 56). Open 'Premium'."
17 Submit with the app The two subscriptions must be submitted for review together with the first app version that contains them.
18 Kids category restrictions Verify: purchase, restore, redeem, manage and refund are reachable only behind the parental gate; no external link, price or purchase control appears in child mode; the app contains no third-party SDK (Section 23).
19 Terms and privacy links in metadata App Store description includes the subscription terms paragraph (Section 22). The standard Apple EULA applies (Section 17.9.5); the privacy policy URL field is filled (Section 22 and Section 23).
20 Sandbox testers Create at least 3 sandbox Apple Accounts (fresh, trial-used, family organizer). For Family Sharing tests, set up a sandbox family per Apple's current sandbox documentation (verification step: confirm the current sandbox family-sharing support in Apple's documentation at build time; if not supported, family-shared behaviour is verified by unit tests on the resolver, Section 17.14.3).

17.4 Free vs Premium Feature Matrix #

Feature Free Premium
Entdecken, Wie viele?, Hör hin, Was fehlt? (all 3 steps each) yes yes
Blitzblick, Mehr oder weniger, Nachspuren, Schüttelbox, Froschsprung, Fütter das Zahlenmonster, Memory, Punkt zu Punkt visible with lock badge; not playable yes
Adaptive learning engine, mastery per skill × number, spaced repetition, range widening yes (fed only by free games) yes (fed by all games)
Daily Abenteuer yes; the engine composes it only from free games (Section 9) yes; composed from all games
Stars, Zahlengarten, decorations shop (stars only) yes yes
Zahlenfreunde (befriending) yes. Note: befriending needs a firstTry correct answer in ≥ 3 distinct games and mastery in ≥ 3 skills (Section 14); with 4 free games this is reachable for skills recognize, name, count, order yes
Sticker album: friend and milestone stickers yes yes
Sticker album: Punkt-zu-Punkt picture stickers page visible, slots empty (earned only by playing Punkt zu Punkt) yes
Up to 5 child profiles yes yes
Parent area: progress, "Gerade schwierig", time chart, all settings, time limits, export/reset/delete yes yes
Future content drops (Section 17.12) new free-tier improvements only when stated in the drop yes
V1.1 iCloud sync (device toggle "Mit iCloud synchronisieren" on S-21, default off) no: the toggle is disabled with an explanation yes: opt-in; available only while premium (Section 7.15.2, Section 26.4)
V1.2 English text and voice yes for the free games yes

Decision rationale for sync in premium: sync is a recurring-value platform feature that the subscription funds; free users keep full local functionality. Sync is opt-in: the parent turns it on with the toggle "Mit iCloud synchronisieren" in S-21 (default off), which is available only while EntitlementService.isPremium is true. When premium lapses, sync stops and all local data stays on the device (Section 17.10). This default is revisited through decision RV-16 in Section 28.2.

Rule: a game that is free is never moved into premium in a later version. New games may be free or premium; default premium.

17.5 Module and Type Design (ZKStore) #

ZKStore depends only on ZKCore and Apple's StoreKit (Section 5). It contains the StoreKit adapter, the pure entitlement resolver, the entitlement cache, and value types used by the parent area. It imports neither UIKit nor SwiftUI: every piece of StoreKit UI (the purchase confirmation and StoreKit messages) is triggered from ZKParentArea through SwiftUI environment actions, and the results are passed into EntitlementService (Sections 17.6.3 and 17.6.13). No game target imports ZKStore (Section 5). Child-mode screens read entitlement only through the composition root.

17.5.1 Core value types #

// Module ZKCore
public enum GameTier: String, Codable, Sendable { case free, premium }

extension GameID {
    /// Authoritative tier. The `tier` field in Resources/Content/games/<gameId>.json
    /// must equal this value; content validation (Section 8) fails on mismatch,
    /// so editing content can never unlock a premium game.
    public var tier: GameTier {
        switch self {
        case .entdecken, .wieViele, .hoerHin, .wasFehlt: return .free
        default: return .premium
        }
    }
}

public enum GameAccess {
    public static func isPlayable(_ game: GameID, isPremium: Bool) -> Bool {
        game.tier == .free || isPremium
    }
}

(Case names of GameID follow Section 6 naming; raw values are those of Section 5 and Section 8, e.g. wie_viele.)

// Module ZKStore
public enum SubscriptionPlan: String, Codable, Sendable, CaseIterable {
    case monthly, yearly
}

public enum EntitlementSource: String, Codable, Sendable {
    case purchased       // Transaction.ownershipType == .purchased
    case familyShared    // Transaction.ownershipType == .familyShared
}

public enum RenewalCondition: String, Codable, Sendable {
    case willRenew              // auto-renew on, subscribed
    case willNotRenew           // auto-renew turned off; access until expirationDate
    case inGracePeriod          // billing problem, access kept (Section 17.6.6)
    case billingRetry           // billing problem, no grace; not entitled
    case unknown                // renewal info unavailable (offline, family-shared)
}

public struct PremiumInfo: Codable, Sendable, Equatable {
    public var plan: SubscriptionPlan
    public var productID: String
    public var source: EntitlementSource
    public var expirationDate: Date?          // nil only if StoreKit supplies none
    public var gracePeriodEndDate: Date?      // set while inGracePeriod
    public var renewal: RenewalCondition
    public var isInTrial: Bool                // latest transaction is an introductory free-trial transaction
    public var pendingPlanChange: SubscriptionPlan?  // downgrade scheduled at next renewal
    public var isFromCache: Bool              // true when granted by the offline cache rule (Section 17.7)
    // No transaction identifier is stored here or in the cache (data inventory, Section 23.7.3).
    // The refund sheet reads the transaction ID from StoreKit when it is opened (Section 17.6.14).
}

public enum EntitlementState: Sendable, Equatable {
    case resolving                // only before the cache is read; never visible (cache read is synchronous at init)
    case free(FreeReason)
    case premium(PremiumInfo)

    public var isPremium: Bool { if case .premium = self { return true } else { return false } }
}

public enum FreeReason: String, Codable, Sendable {
    case neverSubscribed
    case expired
    case revoked                  // refund or Family Sharing ended
    case billingRetryNoGrace
    case cacheExpired             // offline tolerance exceeded
}

17.5.2 Paywall offer model #

public struct PlanOffer: Sendable, Identifiable, Equatable {
    public var id: SubscriptionPlan { plan }
    public var plan: SubscriptionPlan
    public var productID: String
    public var displayPrice: String           // Product.displayPrice, e.g. "29,99 €"
    public var price: Decimal                 // Product.price
    public var priceFormat: Decimal.FormatStyle.Currency  // Product.priceFormatStyle
    public var trialDays: Int?                // 7 when an introductory free trial is configured, else nil
}

public struct PaywallOffer: Sendable, Equatable {
    public var monthly: PlanOffer
    public var yearly: PlanOffer
    public var isEligibleForTrial: Bool       // Section 17.6.11
    public var yearlySavingsPercent: Int?     // Section 17.9.3; nil if < 10
    public var yearlyMonthlyEquivalent: String // yearly price / 12 formatted with priceFormat
    public var canMakePayments: Bool          // AppStore.canMakePayments
}

17.5.3 Outcomes and errors #

public enum PurchaseOutcome: Sendable, Equatable {
    case success(SubscriptionPlan)
    case pending            // Ask to Buy or Strong Customer Authentication pending
    case cancelled          // user dismissed the system sheet
    case unverified         // StoreKit returned .unverified; access NOT granted
    case failed(StoreFailure)
}

public enum RestoreOutcome: Sendable, Equatable {
    case restoredPremium(SubscriptionPlan)
    case nothingToRestore
    case failed(StoreFailure)
}

public enum StoreFailure: String, Sendable, Equatable, Error {
    case productsUnavailable     // Product.products returned fewer than 2 products
    case network                 // StoreKitError.networkError
    case purchasesNotAllowed     // AppStore.canMakePayments == false or Product.PurchaseError.purchaseNotAllowed (Screen Time)
    case notAvailableInStorefront
    case ineligibleForOffer
    case system                  // StoreKitError.systemError, .unknown, any other error
}

17.5.4 The EntitlementService protocol #

This is the authoritative signature; Section 5 registers the conforming instance in the composition root (AppEnvironment).

import Observation
import StoreKit

@MainActor
public protocol EntitlementService: AnyObject, Observable {
    /// Current state. Observed by the child router (lock badges) and the parent area.
    var state: EntitlementState { get }
    var isPremium: Bool { get }

    /// Tier check used by the child router and the game picker:
    /// `.free` → always true; `.premium` → `isPremium`.
    func isUnlocked(_ tier: GameTier) -> Bool

    /// Registers the Transaction.updates listener and the StoreKit Message listener
    /// (critical path, no awaiting), then schedules processing of Transaction.unfinished
    /// and the first refresh as a deferred task.
    /// Called exactly once, from the App initializer, before the first scene appears.
    func start()

    /// Re-reads Transaction.currentEntitlements and subscription statuses and re-resolves.
    /// Throttled: calls within 60 s of a completed refresh return immediately unless `force` is true.
    func refresh(force: Bool) async

    /// Loads both products and trial eligibility for the paywall.
    func loadOffer() async throws(StoreFailure) -> PaywallOffer

    /// The loaded StoreKit product for a plan, handed to SwiftUI's `PurchaseAction`
    /// (`@Environment(\.purchase)`) in ZKParentArea. Loads products if needed.
    func product(for plan: SubscriptionPlan) async throws(StoreFailure) -> Product

    /// Receives the result of SwiftUI's `PurchaseAction` (value or thrown error),
    /// verifies, refreshes, finishes the transaction and maps it to an outcome (Section 17.6.3).
    func completePurchase(_ result: Result<Product.PurchaseResult, any Error>,
                          plan: SubscriptionPlan) async -> PurchaseOutcome

    /// Calls AppStore.sync(); only ever invoked from an explicit parent tap on "Käufe wiederherstellen".
    func restore() async -> RestoreOutcome

    /// Queued StoreKit messages (billing issue, price increase consent) waiting for the parent area.
    var pendingStoreMessageCount: Int { get }
    /// Returns the queued messages in arrival order and clears the queue. ZKParentArea displays
    /// each one with SwiftUI's `@Environment(\.displayStoreKitMessage)` (Section 17.6.13).
    func takePendingStoreMessages() -> [Message]
}

Decision: the protocol is @MainActor because its only consumers are SwiftUI views and the router; StoreKit calls are async and do not block the main actor. It does not refine Sendable (main-actor isolation already makes it safe to share within the UI). The production implementation is StoreKitEntitlementService (@MainActor @Observable final class). Further implementations, all in ZKStore and performing no StoreKit calls:

  • LockedEntitlementService: the default value of the SwiftUI environment key (Section 5); state is always .free(.neverSubscribed), so an unconfigured view can never unlock a premium game.
  • FakeEntitlementService: settable state for previews and unit tests (compiled in all configurations because previews and unit tests need it).

The DEBUG-only developer menu (Section 5.10, #if DEBUG) can force free or premium via FakeEntitlementService. UI tests select a fixed state with the launch argument -uiTestEntitlement notSubscribed | subscribed | expired from the launch-argument registry of Section 5.9, honoured only under DEBUG || UITEST_HOOKS. Neither mechanism exists in Release builds.

17.5.5 Transaction snapshot and pure resolver #

All decision logic is in a pure function so it can be tested with test vectors without StoreKit.

public struct TransactionSnapshot: Sendable, Equatable {
    public var id: UInt64
    public var originalID: UInt64
    public var productID: String
    public var purchaseDate: Date
    public var expirationDate: Date?
    public var revocationDate: Date?
    public var isUpgraded: Bool
    public var ownership: EntitlementSource
    public var isIntroductoryOffer: Bool
}

public struct StatusSnapshot: Sendable, Equatable {
    public enum State: Sendable { case subscribed, expired, inBillingRetryPeriod, inGracePeriod, revoked, unknown }
    public var state: State
    public var productID: String                   // transaction product
    public var willAutoRenew: Bool?
    public var autoRenewProductID: String?          // renewal preference
    public var gracePeriodExpirationDate: Date?
}

public struct EntitlementCache: Codable, Sendable, Equatable {
    public var schema: Int = 1
    public var isPremium: Bool
    public var info: PremiumInfo?        // contains product ID, dates and flags; no transaction identifier
    public var expirationDate: Date?     // copy of info.expirationDate or gracePeriodEndDate, whichever is later
    public var lastVerifiedAt: Date      // time of the refresh that produced this cache
}

public enum EntitlementResolver {
    public static let offlineToleranceDays = 3

    public static func resolve(
        verifiedTransactions: [TransactionSnapshot],   // from Transaction.currentEntitlements, verified only
        statuses: [StatusSnapshot]?,                    // nil if status fetch failed
        cache: EntitlementCache?,
        knownProductIDs: [SubscriptionPlan: String],
        now: Date,
        calendar: Calendar
    ) -> EntitlementState
}

Mapping from StoreKit: isIntroductoryOffer is true when the transaction's offer is an introductory offer (transaction.offer?.type == .introductory where the SDK of Section 5 provides offer; on OS releases within the deployment target that predate that property, transaction.offerType == .introductory — branch with #available exactly as the SDK's availability annotations require). ownership maps .purchased → .purchased, .familyShared → .familyShared. StatusSnapshot is built from Product.SubscriptionInfo.Status: state maps one-to-one; willAutoRenew, autoRenewProductID (autoRenewPreference) and gracePeriodExpirationDate come from the verified renewalInfo; an unverified renewalInfo leaves these fields nil.

Resolution algorithm (implement exactly in this order):

  1. Keep only transactions whose productID is one of the two known product IDs, whose revocationDate == nil, and whose isUpgraded == false.
  2. A kept transaction is active if expirationDate == nil or expirationDate > now, or if a status for its product has state inGracePeriod and gracePeriodExpirationDate > now.
  3. If at least one transaction is active → .premium. Choose the active transaction with the latest expirationDate (ties: prefer purchased over familyShared, then yearly over monthly). Fill PremiumInfo: plan from product ID; source from ownership; isInTrial from isIntroductoryOffer; renewal from the status (subscribed + willAutoRenew == true → willRenew; subscribed + false → willNotRenew; inGracePeriod → inGracePeriod; no status or family-shared without renewal info → unknown); pendingPlanChange = plan of autoRenewProductID if it differs from the current plan; isFromCache = false.
  4. Otherwise, if statuses contains any status with state revoked for a known product, or StoreKit reported a revoked transaction during this run → .free(.revoked).
  5. Otherwise, if statuses contains inBillingRetryPeriod and no inGracePeriod → .free(.billingRetryNoGrace).
  6. Otherwise, if statuses contains expired for every known product that has a status → .free(.expired).
  7. Otherwise (StoreKit gave no active entitlement and no explicit negative status — typical when offline and a renewal has not yet been delivered to the device), apply the offline cache rule: if cache?.isPremium == true and cache.expirationDate != nil and now < cache.expirationDate + 3 days (calendar days added with calendar) and NOT now < cache.lastVerifiedAt − 1 day (clock-backwards heuristic, Section 17.7.2) → .premium(cache.info with isFromCache = true).
  8. Otherwise, if cache?.isPremium == true → .free(.cacheExpired); else → .free(.neverSubscribed).

Unverified transactions (VerificationResult.unverified) are never passed to the resolver: they never grant access, are logged with os.Logger (category store, privacy .private for IDs), and are not finished.

17.6 StoreKit 2 Implementation #

17.6.1 Launch sequence #

  1. AppEnvironment (composition root, Section 5) constructs StoreKitEntitlementService during app initialization. The initializer synchronously reads EntitlementCache from UserDefaults and sets state via EntitlementResolver.resolve(verifiedTransactions: [], statuses: nil, cache: cache, …). Result: the first frame of child mode already shows correct lock badges (no flicker from locked to unlocked).
  2. start() is called from the App initializer (not from a view's .task) so that the Transaction.updates listener exists before any purchase or before StoreKit delivers transactions that completed outside the app (Ask to Buy approval, renewals, refunds, offer-code redemptions in the App Store app). Listener registration is on the launch critical path and must not await anything (budget ≤ 5 ms, Section 24.6.1); only the refresh is deferred. Calling start() twice is a no-op (guarded by a flag).
  3. start() spawns, in this order:
    • updatesTask = Task.detached(priority: .background) { for await result in Transaction.updates { await self.handle(result) } }
    • messagesTask = Task { for await message in StoreKit.Message.messages { self.enqueue(message) } } (Section 17.6.13)
    • Task { await self.processUnfinished(); await self.refresh(force: true) } (deferred work; it never blocks the first frame)
  4. processUnfinished() iterates Transaction.unfinished; each .verified transaction is finished after refresh has incorporated it; .unverified ones are logged and left unfinished.
  5. refresh(force:):
    • iterate Transaction.currentEntitlements; map .verified(t) to TransactionSnapshot; drop .unverified;
    • fetch statuses: try? await Product.SubscriptionInfo.status(for: groupID) where groupID is read from any loaded product's subscription?.subscriptionGroupID; if no product has been loaded yet, the service loads products first (Product.products(for:)); if that fails, statuses = nil;
    • state = EntitlementResolver.resolve(…, now: clock.now, calendar: clock.calendar) (the injected AppClock; direct use of Date() or Calendar.current is forbidden outside ZKCore/Time/, Section 6.6);
    • write EntitlementCache when the result was not produced from the cache (isFromCache == false) or when the result is .free (then isPremium = false, info = nil).
  6. Refresh triggers: launch (step 3); scenePhase becomes .active (throttled per refresh(force:), 60 s); S-22 appears (force: true); after every purchase, restore, offer-code redemption, refund sheet dismissal, manage-subscriptions sheet dismissal (force: true); every element from Transaction.updates.

The clock (AppClock, ZKCore) is injected so tests can move time.

17.6.2 Loading products #

let products = try await Product.products(for: [ids.monthly, ids.yearly])
  • Loaded lazily when S-22 opens and during refresh if needed for the group ID; cached in memory for the process lifetime; reloaded on S-22 appear if the previous load failed.
  • If the call throws or returns fewer than 2 products → StoreFailure.productsUnavailable (or .network for StoreKitError.networkError). S-22 shows its "not available" state (Section 17.9.6) with a "Erneut versuchen" button. Nothing is hardcoded as a fallback price.
  • AppStore.canMakePayments is read at the same time. If false (Screen Time: In-App Purchases not allowed), the plan buttons are disabled and S-22 explains that purchases are restricted on this device (Section 22 owns the text).

17.6.3 Purchase flow #

  1. Precondition: parent area open (gate passed, Section 16); S-22 visible; offer loaded; canMakePayments == true.
  2. Parent selects a plan (yearly preselected) and taps the primary button. The button shows a progress indicator and all plan and restore controls are disabled until the outcome arrives (prevents double purchase).
  3. The S-22 view in ZKParentArea obtains the product with entitlements.product(for: plan) and calls SwiftUI's purchase action, @Environment(\.purchase) private var purchase, as try await purchase(product, options: []). The action presents the system confirmation sheet in the correct scene, so neither ZKStore nor ZKParentArea touches UIKit. No appAccountToken (there are no accounts). The view passes the result, or the thrown error, unchanged into entitlements.completePurchase(_:plan:):
// ZKParentArea, S-22 primary button action
let result: Result<Product.PurchaseResult, any Error>
do { result = .success(try await purchase(product, options: [])) }
catch { result = .failure(error) }
let outcome = await entitlements.completePurchase(result, plan: selectedPlan)
  1. completePurchase handles Product.PurchaseResult:
    • .success(.verified(transaction)) → await refresh(force: true) (which reads currentEntitlements and includes the new transaction) → await transaction.finish() → outcome .success(plan).
    • .success(.unverified(transaction, error)) → log; do not finish; do not grant; outcome .unverified. S-22 shows an error with "Käufe wiederherstellen" as the suggested action.
    • .pending → outcome .pending. S-22 shows "Kauf wartet auf Bestätigung" (Ask to Buy or bank authentication). When approved later, the transaction arrives through Transaction.updates, the state changes, and locks disappear without further user action.
    • .userCancelled → outcome .cancelled; S-22 returns to its idle state with no message.
    • .failure with Product.PurchaseError.purchaseNotAllowed → .failed(.purchasesNotAllowed); .ineligibleForOffer → .failed(.ineligibleForOffer) then S-22 reloads the offer (eligibility changed) and shows the non-trial wording; StoreKitError.networkError → .failed(.network); StoreKitError.notAvailableInStorefront → .failed(.notAvailableInStorefront); StoreKitError.userCancelled → .cancelled; anything else → .failed(.system).
  2. On .success, S-22 switches to its "Premium aktiv" status state and shows a single confirmation line (Section 22). When the parent closes the parent area, child mode shows the previously locked tiles unlocked; the first time each previously locked tile appears unlocked, the lock badge plays the 0.6 s unlock animation (Section 19) once.

17.6.4 Transaction.updates handling #

For each element:

  • .verified(t): await refresh(force: true), then await t.finish(). If t.revocationDate != nil, the refresh yields .free(.revoked) (unless another active transaction exists, e.g. the family member also bought their own).
  • .unverified: log, ignore, do not finish.

The listener runs for the whole process lifetime; the task handle is kept by the service and cancelled only in deinit (which in practice never runs).

17.6.5 Verification #

Only VerificationResult.verified transactions and renewal infos grant access. StoreKit 2 verifies the JWS signature on device. The app performs no additional receipt validation and has no server. Unverified results are expected only on tampered or broken devices and in Xcode StoreKit testing when the test option to fail verification is enabled.

17.6.6 Grace period and billing retry #

  • Billing Grace Period is enabled in App Store Connect (Section 17.3, item 12: 16 days, paid-to-paid renewals).
  • During grace, the status state is inGracePeriod and the subscriber keeps premium (resolver step 2). PremiumInfo.renewal = .inGracePeriod, gracePeriodEndDate set. The parent area shows a non-blocking notice on S-18 and S-22: payment problem, please check payment details, with a button "Zahlungsdaten prüfen" that opens the manage-subscriptions sheet (Section 17.6.7). No notice ever appears in child mode.
  • After grace ends without payment, Apple moves the subscription to billing retry; state inBillingRetryPeriod without grace → .free(.billingRetryNoGrace). The parent area shows the same notice with the "Abgelaufen" status line. If Apple recovers the payment during retry, a renewal transaction arrives via Transaction.updates and premium returns automatically.
  • The cache expirationDate during grace is the later of expirationDate and gracePeriodEndDate.

17.6.7 Manage subscription #

  • Location: S-22 status state, button "Abo verwalten".
  • Implementation: SwiftUI .manageSubscriptionsSheet(isPresented:) in ZKParentArea. On dismissal: refresh(force: true).
  • Hidden when source == .familyShared (a family member cannot manage the organizer's subscription). Instead S-22 shows the family-sharing explanation (Section 22).
  • Downgrade yearly → monthly and cancellation are done by the parent in this Apple sheet; the app does not build its own cancel flow.

17.6.8 Offer codes #

Decision: included for press, reviewers and promotions.

  • Location: S-22, a secondary text button "Code einlösen" below "Käufe wiederherstellen", visible in both the paywall and the status state.
  • Implementation: SwiftUI .offerCodeRedemption(isPresented:onCompletion:) in ZKParentArea. On completion: refresh(force: true). A redemption completed in the App Store app outside the app arrives via Transaction.updates.
  • No codes are required for launch; they are created in App Store Connect when needed (one-time-use or custom codes). The app requires no change to support new codes.

17.6.9 Upgrades, downgrades, crossgrades #

Change How the parent does it Apple behaviour App behaviour
Monthly → yearly S-22 status state shows "Zum Jahresabo wechseln" (only when plan == .monthly, source == .purchased). Tapping calls purchase(.yearly). Upgrade (higher group level): effective immediately, unused monthly time refunded pro rata. The monthly transaction gets isUpgraded = true. Resolver ignores upgraded transactions; PremiumInfo.plan becomes .yearly. Premium never lapses during the switch.
Yearly → monthly Only via "Abo verwalten" (Apple sheet). Downgrade: effective at next renewal; renewal info autoRenewProductID = monthly. pendingPlanChange = .monthly; S-22 shows "Wechsel zu Monatlich am ".
Same plan re-purchase while active Not offered; the purchase button for the active plan is replaced by the status state. n/a n/a
Family member with shared access buys own plan Allowed via paywall? Decision: S-22 for a family-shared user shows status only, no purchase buttons, to avoid double payment. n/a If the member nevertheless owns a purchase (bought on another device), resolver prefers purchased on ties.

17.6.10 Family Sharing #

  • Both products are family shareable. A family member's device receives a transaction with ownershipType == .familyShared; access is identical to a purchaser's.
  • When the organizer stops sharing, leaves the family, or the subscription is refunded, StoreKit delivers a transaction with revocationDate set via Transaction.updates → .free(.revoked).
  • Renewal info for family-shared transactions may be unavailable; the resolver then uses renewal = .unknown and S-22 shows the family-sharing status line without a renewal date claim.
  • Refund request and manage buttons are hidden for family-shared access.

17.6.11 Trial eligibility #

  • isEligibleForTrial = await yearlyProduct.subscription?.isEligibleForIntroOffer ?? false, and only if yearlyProduct.subscription?.introductoryOffer?.paymentMode == .freeTrial. Eligibility is per group, so one check suffices; the monthly product uses the same value.
  • If the introductory offer is not configured in the storefront (introductoryOffer == nil) → treated as not eligible.
  • Paywall wording depends on eligibility. This section fixes the functional rule; the final German button labels and lines are the Section 22.3.3 and 22.3.4 strings, and this table does not restate them. {trialDays} is the day count read from introductoryOffer.period and passed as a format argument; {price} is Product.displayPrice of the selected plan.
Condition Primary button (string owner) Line below plans (string owner)
Eligible, yearly selected trial-start label with {trialDays} (Section 22.3.4) trial line with {trialDays} and the yearly {price} per year, plus "cancel any time in Settings" (Section 22.3.3)
Eligible, monthly selected trial-start label with {trialDays} (Section 22.3.4) trial line with {trialDays} and the monthly {price} per month, plus "cancel any time in Settings" (Section 22.3.3)
Not eligible, yearly selected subscribe label without any trial wording (Section 22.3.4) yearly {price} per year, renews automatically, cancel any time (Section 22.3.3)
Not eligible, monthly selected subscribe label without any trial wording (Section 22.3.4) monthly {price} per month, renews automatically, cancel any time (Section 22.3.3)

The word "kostenlos" and the trial day count never appear on the paywall when the customer is not eligible. The trial length is never hardcoded in a string: the string keys take the day count as an argument, and prices are never literals in copy.

17.6.12 Restore #

  • Location: S-22 (paywall and status state): text button "Käufe wiederherstellen". Also listed in S-24 Help as a row that navigates to S-22.
  • Implementation: try await AppStore.sync(), then refresh(force: true). AppStore.sync() may prompt for the Apple Account password; it is called only on an explicit parent tap, never automatically.
  • Outcome: premium found → .restoredPremium(plan) and a confirmation line; none → .nothingToRestore and a neutral message ("Es wurde kein aktives Abo gefunden" — Section 22 owns copy); StoreKitError.userCancelled → silent; other errors → .failed.
  • Decision: no automatic restore on first launch or reinstall is needed because StoreKit 2 currentEntitlements already contains synced transactions for the signed-in Apple Account.

17.6.13 StoreKit messages #

StoreKit may present system messages (for example billing-issue or price-increase consent sheets). Such a sheet must never appear over child mode.

  • The service subscribes to StoreKit.Message.messages at start(); subscribing defers the system's automatic display, so messages are queued in memory.
  • When S-18 or S-22 becomes visible, the view in ZKParentArea calls entitlements.takePendingStoreMessages() and displays each returned message, in order, with SwiftUI's @Environment(\.displayStoreKitMessage) action (try? displayStoreKitMessage(message)). ZKStore never presents UI and never imports UIKit.
  • Queued messages are not persisted; if the app terminates, StoreKit re-delivers pending messages on a later launch.
  • Verification step for the executor: confirm in a sandbox price-increase or billing-issue test that no system message appears in child mode and that the message appears when S-18 opens.

17.6.14 Refund request #

Decision: offered, in S-24 Help, as the row "Erstattung für einen Kauf anfordern".

  • Visible only when the current or cached state is premium with source == .purchased.
  • Implementation: when the parent taps the row, the view reads the transaction ID at that moment from StoreKit (the newest verified transaction in Transaction.currentEntitlements for a known product ID with ownership .purchased; this identifier is never persisted, Section 17.5.1) and presents SwiftUI .refundRequestSheet(for: transactionID, isPresented:, onDismiss:) in ZKParentArea. The UIKit alternative Transaction.beginRefundRequest(in:) is not used. On dismiss: refresh(force: true).
  • If Apple approves a refund, a revoked transaction arrives via Transaction.updates → .free(.revoked); premium games re-lock per Section 17.10.
  • Rationale: an easy, honest refund path reduces App Store refund disputes and 1-star reviews, and matches the product principle of no dark patterns.

17.6.15 Revocation and expiration #

Event Signal Resulting state Effect
Normal expiration after cancel No active entitlement; status expired .free(.expired) Lapse per Section 17.10
Refund granted Transaction with revocationDate .free(.revoked) Lapse per Section 17.10, immediately
Family Sharing ended Family-shared transaction revoked .free(.revoked) Lapse per Section 17.10, immediately
Billing retry without grace Status inBillingRetryPeriod .free(.billingRetryNoGrace) Lapse; auto-return if payment recovers

Timing rule for the child: an entitlement change never interrupts a running round. A premium round in progress completes normally, and its results are recorded normally. Outside a play session, the child router applies the new state the next time S-05 or S-06 is shown, or immediately on a visible S-06 (Section 18.5.5). Inside an Abenteuer, the Abenteuer coordinator re-checks the entitlement at each round start; a premium round that has not started yet is replaced by a free game or dropped (Section 9.16.6).

17.7 Offline Behaviour and Clock Tampering #

17.7.1 Offline rules #

  • The app never checks connectivity and never references the Network framework (Section 23). Offline behaviour follows only from what StoreKit returns.
  • StoreKit 2 transactions are stored and verified on device, so an active subscription whose current period has not ended works offline with no special handling.
  • The only offline gap is a renewal that happened on Apple's servers while the device was offline: the local transaction's expirationDate is in the past and no new transaction has arrived. Decision: the app keeps premium for up to 3 calendar days after the cached expirationDate (resolver step 7), as long as StoreKit reports no revocation and no explicit expired status. After that the premium games lock until StoreKit delivers the renewal (next online refresh), which then unlocks automatically.
  • Cache storage: UserDefaults.standard, key zk.entitlement.cacheV1 (registered in the key registry of Section 7.9.2), value = JSON-encoded EntitlementCache. Decoding failure → treated as no cache (nil) and overwritten at the next refresh. The cache contains no personal data and no transaction identifier (product ID, dates, booleans only).
  • Neither the per-child actions in S-23 nor the device action "Alle Daten löschen" (Section 16.10.2) delete the entitlement cache, because the subscription belongs to the Apple Account, not to child data; StoreKit restores it anyway (kept keys listed in Section 7.18.4).

17.7.2 Clock tampering stance #

Decision: accepted risk. A person who sets the device clock backwards could extend the offline tolerance or an expired transaction's apparent validity. Rationale:

  • The actor is a parent, not the child (changing the clock requires device Settings, which a parent can restrict via Screen Time).
  • The maximum gain is small (premium games offline until the device is next online with correct time; setting the clock back also disrupts the parent's other apps).
  • Mitigations would require a server time source, which conflicts with the no-backend, no-network design and the "Data Not Collected" privacy label (Section 23).
  • A cheap heuristic is still applied: if now < cache.lastVerifiedAt − 1 day (clock went backwards more than a day since the last successful verification), the cache rule (resolver step 7) is not applied. This does not affect StoreKit-verified active transactions.

17.8 Lock UI in Child Mode #

Rules:

  1. Locked games are visible in S-06 at their normal position with full-colour artwork and a lock badge (component spec in Section 19). They are never hidden, greyed out, or shown with a price.
  2. Child mode never contains: a price, a currency symbol, the words "kaufen", "Premium", "Abo", "gratis", "kostenlos", a store sheet, a paywall, or any control that leads to purchasing without passing the parental gate.
  3. Tapping a locked tile opens S-15 Ask-a-parent (Section 18) as an overlay on S-06:
    • The game's mascot illustration appears next to a large closed padlock; sfx.lock plays and the padlock wiggles gently (0.6 s, once; Reduce Motion: no wiggle, padlock fades in).
    • The spoken line session.locked_game plays: "Dieses Spiel ist noch zu. Frag deine Eltern." Then the follow-up session.locked_other plays: "Schau mal, diese Spiele kannst du jetzt spielen!", while the unlocked game tiles of S-06 behind the scrim are highlighted (a star.yellow ring that pulses twice, Section 19.9; Reduce Motion: static ring for 1.2 s). Audio IDs and texts are mirrored in Section 21.8. Neither line contains the app name, a game name, or any money or purchase word ("kaufen", "Abo", "Premium", "freischalten", "gratis", "kostenlos"), so a rename never needs a re-recording and the child is never asked to pester for a purchase.
    • Two picture buttons: a parent icon (grown-up silhouette with a key, 72 pt) and a back button (house or arrow-back icon, 72 pt, returns to S-06).
    • The parent icon opens the parental gate S-17 with a single tap (no press-and-hold here, because the child has been asked to fetch an adult). If the gate is passed, the parent area opens directly at S-22 with a back button to S-18. If the gate is cancelled, the app returns to S-06.
    • Repeat damping: the spoken pair plays at most once per session per profile (session as defined in Section 15.7.1). Every later tap on a locked tile in the same session opens S-15 with the padlock wiggle and the highlight of the open tiles only, without any voice line. This prevents a child from turning the lock into a nagging tool.
    • Voice off (Section 20): no line plays; the padlock wiggle and the highlighted open tiles carry the meaning.
  4. The Abenteuer never schedules a locked game (Section 9), so S-15 cannot appear inside an Abenteuer.
  5. Entdecken and the other free games never show any lock.

17.9 Paywall and Premium Screen (S-22) — Functional Structure #

S-22 lives only inside the parent area (Section 18). The primary button labels, trial lines, status lines and all other copy are the Section 22.3 strings; prices and trial length are always inserted from StoreKit at runtime (Product.displayPrice, offer period), never hardcoded in copy. It has two modes: paywall mode when state is .free, and status mode when state is .premium. Section 22 owns all final copy; the strings below are functional placeholders that define meaning, and Section 22's versions replace them.

17.9.1 How the parent reaches S-22 #

  • S-18 dashboard row "Premium" (always present; shows "Aktiv" or "4 von 12 Spielen frei").
  • S-15 parent icon → gate → S-22 directly.
  • S-24 row "Käufe wiederherstellen" → S-22 (Section 17.6.12).

There is no other entry: first-launch setup (S-02, S-03) contains no Premium link, so the paywall is reachable only after the gate and only from inside the parent area. The paywall is never presented automatically, never on a timer, never after a round, and never more than once per explicit tap.

17.9.2 Paywall mode layout (top to bottom) #

Order Element Rules
1 Navigation bar Title "Premium"; back button to S-18 (or close when opened from S-15 path, which also returns to S-18).
2 Headline and sub-headline Section 22 copy. Neutral, no urgency.
3 Illustration A small row of 4 premium game icons (static).
4 Benefits list 3 or 4 items, each an SF Symbol + one line, listing only what the subscription actually adds. Content (functional): 8 more games (list names); all future games and content drops (Section 17.12); one subscription for the whole family via Family Sharing; from V1.1 on, iCloud sync (Section 17.4). Never listed as a benefit, because the free tier already has them: Zahlenfreunde, stars, garden, stickers, child profiles, all difficulty steps, absence of ads and tracking, on-device data storage. Below the list, one neutral footer line states that the app stays free of ads and tracking in both tiers (Section 22 copy).
5 Plan selector Two selectable cards, vertically stacked on compact widths, side by side on regular widths (Section 19). Yearly card first and preselected: label "Jährlich", price {yearly.displayPrice} pro Jahr as the most prominent price text, secondary line "entspricht {yearlyMonthlyEquivalent} pro Monat", badge "ca. {savings} % günstiger". Monthly card: "Monatlich", {monthly.displayPrice} pro Monat. Selection is shown by a 3 pt accent border AND a checkmark symbol (not colour alone).
6 Trial / terms line Per Section 17.6.11 table.
7 Primary button Full-width, 56 pt tall, label per Section 17.6.11 (final strings in Section 22.3.4). Disabled with spinner during purchase.
8 Auto-renew disclosure Small text (footnote style, still ≥ 4.5:1 contrast): subscription name, length of each period, price per period, that payment is charged to the Apple Account at confirmation of purchase (after the trial if eligible), that it renews automatically unless cancelled at least 24 hours before the end of the current period, and that it can be managed and cancelled in the Apple Account settings (Einstellungen). Section 22 owns the German text.
9 Secondary actions "Käufe wiederherstellen", "Code einlösen".
10 Legal links "Datenschutzerklärung" (privacy policy URL, Section 22) and "Nutzungsbedingungen (EULA)" (Apple standard EULA, Section 17.9.5). Both open only after the confirmation sheet that the parent is leaving the app (Section 16).

17.9.3 Price display rules #

  • All prices come from Product.displayPrice; per-month equivalent = yearly.price / 12 formatted with yearly.priceFormatStyle (rounded to 2 decimals by the format style; for 29,99 € this shows "2,50 €").
  • Savings percent = floor((1 − yearly.price / (12 × monthly.price)) × 100); displayed as "ca. N % günstiger" only if N ≥ 10; with the default prices N = 37. Computed at runtime so CHF or future price changes stay truthful.
  • The billed amount (per year) is always visually more prominent than the per-month equivalent (larger type, primary ink).
  • No strike-through prices, no "statt", no invented reference prices.

17.9.4 Forbidden paywall patterns #

No countdown or timer; no "Angebot endet"; no scarcity; no pre-selected add-ons; no confirm-shaming dismissal text ("Nein, ich will nicht, dass mein Kind lernt"); no auto-presentation; no delayed or hidden close button (the back button is visible immediately); no fake discount; no child imagery pressuring the parent (for example a sad child).

17.9.5 Terms of use #

Decision: the app uses Apple's standard Licensed Application End User License Agreement (EULA) and does not write custom terms in V1. The paywall link "Nutzungsbedingungen (EULA)" points to Apple's standard EULA page (https://www.apple.com/legal/internet-services/itunes/dev/stdeula/). Verification step: before submission, open the URL and confirm it still resolves to Apple's standard EULA; if Apple has moved it, use the current URL. The App Store description also mentions that Apple's standard EULA applies (Section 22).

17.9.6 S-22 states #

State Trigger Display
Loading Offer not yet loaded Plan cards show placeholder shimmer-free grey blocks (static, no animation) and a small ProgressView; primary button disabled.
Paywall .free, offer loaded, canMakePayments As Section 17.9.2.
Purchases restricted canMakePayments == false Plans shown with prices, buttons disabled, info text about Screen Time restrictions. Restore stays enabled.
Not available productsUnavailable, network, notAvailableInStorefront Message "Die Abos können gerade nicht geladen werden." + "Erneut versuchen". Restore and Code einlösen stay enabled.
Purchasing Purchase in flight Spinner in primary button; everything else disabled.
Pending .pending Info card "Kauf wartet auf Bestätigung"; buttons re-enabled.
Error .failed, .unverified Inline error card (neutral colour, no red alarm styling), action "Erneut versuchen" / "Käufe wiederherstellen".
Status: active .premium, renewal == .willRenew Plan, "verlängert sich am {Datum}"; trial: "Testzeitraum bis {Datum}"; buttons "Abo verwalten", "Zum Jahresabo wechseln" (monthly only), "Code einlösen".
Status: will not renew .willNotRenew "Endet am {Datum}. Danach bleiben alle Fortschritte erhalten." + "Abo verwalten".
Status: pending downgrade pendingPlanChange != nil Additional line "Wechsel zu Monatlich am {Datum}".
Status: grace .inGracePeriod Notice "Problem mit der Zahlung" + "Zahlungsdaten prüfen" (manage sheet). Premium still active.
Status: family-shared source == .familyShared "Premium ist über die Familienfreigabe aktiv." No manage, no refund, no purchase buttons.
Status: from cache isFromCache == true Additional footnote "Status wird bei der nächsten Verbindung bestätigt."
Free after lapse .free(.expired/.revoked/.billingRetryNoGrace/.cacheExpired) Paywall mode, with an info line above the plans (e.g. "Ihr Abo ist abgelaufen. Alle Fortschritte sind erhalten.").

Dynamic Type: S-22 is a ScrollView; at accessibility sizes the plan cards stack vertically and the primary button text wraps (Section 19).

17.10 Lapse Behaviour #

When the state changes from .premium to .free for any reason:

Data / feature Behaviour
Stars (balance and lifetime) Kept unchanged
Garden decorations placed and in inventory Kept
Zahlenfreunde befriended Kept (never lost, Section 14)
Stickers (including Punkt-zu-Punkt stickers earned while premium) Kept
Mastery records from premium games Kept and still used by the engine for task selection in free games and for parent reports
GameProgress (current step per premium game) Kept; resumes exactly on resubscription
Premium games Re-lock at the next display of S-05/S-06, or immediately on a visible S-06; never mid-round (Section 18.5.5)
Abenteuer From the next Abenteuer on, composed only from free games (Section 9). In an Abenteuer in progress the running round completes; at each later round start the coordinator re-checks the entitlement and replaces a premium round that has not started with a free game, or drops it if none is eligible (Section 9.16.6)
Friends befriending progress Continues via free games; criteria unchanged
Parent area Full access; S-22 shows paywall mode with the lapse info line
iCloud sync (V1.1) Sync stops; the toggle "Mit iCloud synchronisieren" in S-21 becomes disabled with an explanation; all data stays on the device and nothing is deleted locally or in the parent's iCloud (Section 7.15.2)
Child-facing message about the lapse None. The child only sees lock badges again.

On resubscription, locks disappear on the next S-05/S-06 display; no data migration is needed because nothing was deleted.

17.11 Pricing Rationale #

  • Price band. Subscription kids-learning apps in the German App Store are commonly positioned in the low single-digit euro range per month, with yearly plans discounted substantially relative to twelve monthly payments. 3,99 € per month and 29,99 € per year place the app inside that band, below premium "all-subjects" learning platforms and above one-topic utility apps. No competitor prices are cited here; before launch, the executor checks the current German storefront (Education and Kids categories) and confirms or adjusts the prices as a Section 1 decision, recording the result in DECISIONS.md.
  • Yearly as default. Learning 1–20 takes a child many months; the yearly plan matches the learning horizon, reduces churn, and is the better value for families (about 2,50 € per month).
  • Monthly as option. Lets sceptical parents buy one month after the trial without a 29,99 € commitment.
  • Trial 7 days. Long enough for a parent to watch several short sessions and see the adaptive behaviour; short enough to keep the decision fresh.
  • Family Sharing. Siblings and grandparents' iPads are covered by one subscription, which fits the up-to-5-profiles design and removes any incentive to share Apple Accounts.
  • Approximate proceeds (for planning only). Prices include VAT (Germany 19 %, Austria 20 %, Switzerland 8,1 %); Apple's commission is 15 % for developers in the App Store Small Business Program (and for subscriptions after one year of paid service), otherwise 30 % in the first year. For Germany with 15 %: yearly ≈ 29,99 / 1,19 × 0,85 ≈ 21,42 €; monthly ≈ 3,99 / 1,19 × 0,85 ≈ 2,85 €. Decision: the seller enrolls in the Small Business Program before launch (Section 26 launch checklist).

17.12 Content Roadmap Justifying the Subscription #

The subscription is justified by continuous, visible additions. All content ships in App Store updates (no downloads, no backend); data-only additions follow Section 8, new games follow Section 10 as new game targets.

17.12.1 Year-1 schedule (quarters counted from the V1 launch date) #

Quarter Platform / release New games or major extensions (≥ 2 per quarter) Garden and album content
Q1 (months 1–3) V1.1: iCloud sync across devices signed in with the same Apple Account (private CloudKit database; opt-in toggle "Mit iCloud synchronisieren" in S-21, default off, available only while premium; Section 7.15.2 and Section 26.4) New game "Zahlenhaus" (Zahlzerlegung in the classic German number-house format: fill the missing part so both floors make N, numbers 2–10); extension "Froschsprung Schritt 4": jumps backwards along the number line ("1 weniger", "2 weniger") Garden theme "Winter"; 8 new Punkt-zu-Punkt pictures (album page 7)
Q2 (months 4–6) V1.2: English text and voice in British English, en-GB (Section 20 and Section 26); English-language storefronts New game "Perlen fädeln" (build a quantity on an empty bead string using the red/blue five-groups); extension "Blitzblick mit dem Zwanzigerfeld" (flash quantities 11–20 structured as 10 + n) Garden theme "Frühling"; 12 new decorations
Q3 (months 7–9) Maintenance release for the new iOS major version New game "Rechenrahmen" (slide beads on a 20-bead abacus to show N; the Rechenrahmen colour and grouping rules of Section 4); extension "Schüttelbox bis 20" (the ten split of 11–20, spoken "zehn und …" and labelled "10 + n" per Section 4, plus splits with an empty compartment such as 7 + 0; V1 trains decomposition of 2–10 for Vorschule and 2–5 for Die Kleinen) Garden theme "Sommer"; 8 new Punkt-zu-Punkt pictures (album page 8)
Q4 (months 10–12) Preparation release for V2 (engine range generalisation, Section 26) New game "Zahlenzug" (order numbered wagons, gaps and predecessors/successors up to 20); extension "Memory mit Fingerbildern" (pairs numeral ↔ finger pattern) Garden theme "Herbst"; 12 new decorations; 4 new milestone stickers

Rules for the roadmap:

  • Every quarter delivers at least 2 new games or major game extensions, at least 1 new garden theme and new album or decoration content.
  • New games are premium by default; free games may receive extensions for free users.
  • If a quarterly item slips, the quarter still ships at least 2 items by pulling the next quarter's items forward; the order within the year may change, the minimum per quarter may not.
  • The in-app parent area has no "coming soon" teasers for the child; S-24 Help shows a short "Neu in dieser Version" list for parents (Section 22 copy).

17.12.2 Year 2 and later (V2 direction) #

Numbers to 100 (Hunderterfeld, tens and ones), adding and subtracting within 10 and 20, Kaufladen with coins, clock reading, CKShare-based progress sharing between a parent's and a child's Apple Account, and an optional Kita edition. Scope and preparation are owned by Section 26. The rejections in Section 17.13 apply to the family app; the business model of a Kita edition is decided at V2 planning (decision RV-35 in Section 28.2; default: an auto-renewable subscription, no paid-upfront or one-time sale).

17.13 Rejected Alternatives #

Alternative Pros Cons Decision
Paid upfront (for example 7,99 € once for all 12 games) Simplest for parents; no trial logic; no lock UI; common for classic kids apps App Store search conversion for paid kids apps is low; no free tier for parents to evaluate learning value; revenue ends at first sale while content and OS updates are ongoing; roadmap (sync, English, new games, V2 numbers to 100) cannot be funded; refunds hurt more Rejected: incompatible with the ongoing content roadmap and the free-forever tier
Freemium + one-time family unlock (free 4 games, 1 non-consumable unlocks 8) Clear value, one decision, no renewal anxiety; Family Sharing possible for non-consumables Same funding problem as paid upfront; future content would either be given away (no revenue) or require more unlock purchases (fragmented, confusing, "nickel-and-diming" for parents in a kids app) Rejected: does not fund continuous content; multiple unlocks would recreate the pay-per-item pattern the product avoids
Lifetime purchase (non-consumable alongside the subscription) Attractive to subscription-averse parents; one-time cash Cannibalises yearly plan; a lifetime promise for an app whose content grows yearly is a long unfunded liability; adds a third product and a second entitlement path; out of scope for V1 Rejected for V1; revisited only through decision RV-28 in Section 28.2 (default: no)

17.14 Testing #

17.14.1 StoreKit configuration file #

  • File Products.storekit (Section 5) in the app project, selected in the scheme's StoreKit Configuration option for the Debug configuration (Run and Test actions) and for the Profile configuration used by UI and performance tests (Section 5.8.1).
  • Contents: subscription group "Zahlenkette Premium" with the two products, IDs exactly as in Section 17.2.1 with the default bundle ID, prices 3,99 € / 29,99 €, group levels, introductory offer free 1 week on both, Family Shareable on, storefront Germany, locale de_DE.
  • The Release configuration, and therefore every TestFlight and App Store build, uses no StoreKit configuration file.
  • Xcode StoreKit testing options used by the test matrix: Ask to Buy, interrupted purchases, billing retry on renewal, billing grace period, failed verification, accelerated time rate, transaction manager (refund, expire, delete transactions). Verification step: option names follow the Xcode version of Section 5; map each matrix row to the matching option.

17.14.2 Test levels #

Level Tool Scope
Unit Swift Testing EntitlementResolver vector table (Section 17.14.3); savings percent and monthly-equivalent formatting; GameAccess.isPlayable; cache encode/decode and corrupt-cache handling; clock-backwards heuristic; paywall wording selection per eligibility.
Integration Swift Testing + StoreKitTest (SKTestSession with Products.storekit) Purchase success, cancel, Ask to Buy pending → approve, expire, refund (revocation), renewal, billing retry with and without grace, restore, unverified result; assert state transitions and that transactions are finished.
UI XCUITest S-15 never shows price/purchase; the S-15 voice pair plays once per session per profile and later taps are silent; gate → S-22 flow; yearly preselected; not-eligible wording; restore button present; lapse re-locks tiles on next S-06 display, not mid-round. The entitlement is fixed with -uiTestEntitlement (Section 5.9).
Sandbox Physical device with sandbox Apple Account Real App Store sheets, manage-subscriptions sheet, refund sheet, offer-code sheet, StoreKit message deferral.
TestFlight External testers (test families, Section 1) End-to-end trial start, renewal (accelerated in TestFlight), cancel, Family Sharing with a real family group, reinstall + automatic entitlement.

17.14.3 Entitlement state test matrix #

Every row is a resolver unit test vector and, where the "Env" column says so, also an integration or sandbox test. now = 2026-03-15 12:00 local; "exp" = expirationDate.

# Scenario Inputs Expected state Env
1 Never subscribed no transactions, no cache .free(.neverSubscribed) Unit, SKTest
2 Trial active (yearly) tx yearly intro, exp 2026-03-18 .premium(yearly, isInTrial: true) Unit, SKTest
3 Trial active, auto-renew off + status subscribed, willAutoRenew false .premium(renewal: .willNotRenew) Unit, SKTest
4 Active monthly tx monthly exp 2026-04-01, status subscribed .premium(monthly, .willRenew) Unit, SKTest
5 Active yearly tx yearly exp 2027-01-10 .premium(yearly) Unit, SKTest
6 Upgraded monthly → yearly monthly tx isUpgraded, yearly tx active .premium(yearly) Unit, SKTest
7 Downgrade pending yearly active, autoRenewProductID monthly .premium(yearly, pendingPlanChange: monthly) Unit, Sandbox
8 Grace period yearly exp 2026-03-10, status inGracePeriod, grace end 2026-03-26 .premium(renewal: .inGracePeriod) Unit, SKTest
9 Grace ended, billing retry exp past, status inBillingRetryPeriod .free(.billingRetryNoGrace) Unit, SKTest
10 Expired after cancel exp 2026-03-01, status expired .free(.expired) Unit, SKTest
11 Refunded tx with revocationDate .free(.revoked) Unit, SKTest
12 Family-shared active tx familyShared exp future, no renewal info .premium(source: .familyShared, renewal: .unknown) Unit, TestFlight
13 Family sharing stopped familyShared tx revoked .free(.revoked) Unit, TestFlight
14 Own purchase + family-shared both active, same exp .premium(source: .purchased) Unit
15 Offline, renewal not delivered, within tolerance no active tx, statuses nil, cache premium exp 2026-03-13 .premium(isFromCache: true) Unit
16 Offline beyond tolerance cache exp 2026-03-11 (now > exp + 3 days) .free(.cacheExpired) Unit
17 Offline but status says revoked cache premium, status revoked .free(.revoked) Unit
18 Clock set back > 1 day cache lastVerifiedAt 2026-03-20 cache rule not applied → .free(.cacheExpired) Unit
19 Unverified transaction only verification failed .free(.neverSubscribed), tx not finished SKTest (failed verification option)
20 Ask to Buy pending → approved purchase returns pending; approve in session .free then .premium via updates SKTest
21 Interrupted purchase purchase interrupted, then completed premium only after completion arrives via updates SKTest
22 Products fail to load no network / empty products S-22 "Not available" state; state unchanged SKTest, Sandbox
23 Purchases restricted canMakePayments false S-22 "Purchases restricted" state Sandbox (Screen Time)
24 Not eligible for trial trial previously used not-eligible wording; no "kostenlos" SKTest, Sandbox
25 Reinstall delete app, reinstall, same Apple Account .premium after first refresh without tapping restore Sandbox, TestFlight
26 Restore with nothing fresh sandbox account, tap restore .nothingToRestore message Sandbox
27 Lapse during a premium round expire while S-07 running round completes; tile locked on next S-06 UI (fake service)
28 Corrupt cache invalid JSON under cache key treated as nil, no crash Unit
29 StoreKit message during child mode billing-issue message not shown in child mode; shown on S-18 Sandbox
30 Offer code redeemed redeem in sheet .premium; S-22 status Sandbox
31 Lapse during an Abenteuer expire while round 1 of an Abenteuer with a premium round 2 runs round 1 completes; round 2 is replaced by a free game (Section 9.16.6) UI (fake service)
32 Tier check isUnlocked(.free) / isUnlocked(.premium) in states .free and .premium free → true/false; premium → true/true Unit
33 Cache contents encode a premium cache JSON contains product ID, dates, flags and no transaction identifier Unit

Acceptance: all unit vectors pass on every build (Section 25); SKTest integration rows pass before milestone sign-off; sandbox and TestFlight rows are executed and recorded in the release QA checklist before submission (Section 26).

18. Screen Inventory and Navigation #

This section is the single owner of the list of screens (S-01 to S-27), what each screen contains at the structural level, how screens connect, and how navigation is implemented. Behaviour inside a screen is owned by the section named in each screen's "Owner" row; visual tokens, component sizes and layout classes are owned by Section 19; final copy by Section 22; audio line texts by Section 21.

18.1 Conventions #

18.1.1 Audiences #

Audience Screens Rules
Child S-04 to S-16, S-26, S-27 No readable text is required; every control is a picture or a numeral; every instruction is spoken; touch targets ≥ 60×60 pt with ≥ 12 pt spacing (Section 19); light "warm" appearance only; status bar hidden; no price, store, link or settings.
Parent S-02, S-03, S-17 to S-25 Standard iOS patterns (navigation bars, lists, forms); German text with formal "Sie"; Dynamic Type; VoiceOver; dark mode supported (Section 19); HIG touch targets ≥ 44 pt.
System S-01 Neither; shown only while the app loads.

18.1.2 Layout classes #

Every screen specification refers to the four layout classes defined in Section 19: CP compact portrait (for example iPhone SE portrait, 375×667 pt), CL compact landscape (iPhone SE landscape, 667×375 pt), RP regular portrait (iPad portrait), RL regular landscape (iPad landscape). The class is computed from the window size, not from the device model, so iPad Split View and Stage Manager windows get the matching layout.

All screens support all orientations the app supports (Section 5): on iPhone portrait, landscape left and landscape right; on iPad all four. No screen locks orientation.

18.1.3 Global child chrome #

Element Position Size (Section 19 tokens) Present on
Home button (house pictogram) Top-left, inside the safe area, 12 pt inset 60 pt circle (72 pt in RP/RL) S-06, S-07, S-09, S-11, S-12, S-13, S-14, S-15
Star counter (star + numeral) Top-centre capsule 48 pt tall (56 pt RP/RL) S-05, S-06, S-08, S-11, S-12
Speaker button (replay the current spoken instruction) Top-right 60 pt circle (72 pt RP/RL) S-07 and every child screen that plays an entry line (replays it)
Grown-up icon (adult silhouette; press-and-hold 2 s, Section 18.6) Top-right 40 pt visual in a 60 pt hit area S-04, S-05, S-16 only (S-15 has its own single-tap parent icon, Section 17.8)
Avatar button (current child's avatar) Top-left 60 pt circle (72 pt RP/RL) S-05 and S-16, only when ≥ 2 profiles exist

On S-04, S-05 and S-16 the speaker button is not shown (the top-right corner holds the grown-up icon). On S-04 the question line can be heard again by leaving and re-entering the picker; on S-05 the greeting can be replayed by tapping the Abenteuer friend character; S-16 intentionally has no replay (Section 15.8.4).

18.1.4 "No dead ends" rule (child mode) #

Every child screen offers at least one picture-only control that leads, directly or in one more step, back to S-05, without any text, timing trick or adult help. Verification: the UI test testEveryChildScreenReachesHome (Section 25) visits every child screen and returns to S-05 using only accessibility identifiers of picture buttons.

Screen Way back
S-05 is home
S-06, S-11, S-13, S-14 home button → S-05
S-12 home-style back button → S-11 → home button
S-07 home button (exit behaviour per Section 10) → S-06 (free play) or S-05 (Abenteuer)
S-08 picker button → S-06, or automatic continuation in an Abenteuer
S-09 home button → S-05
S-10 house button → S-05, or automatic return 15 s after the last line (Section 15.10.4)
S-15 back button → S-06
S-26 state nudge: "Weiterspielen" (play arrow) continues, "Pause" (moon) → state resting; state resting: sun button → S-05 (Section 15.9)
S-27 continue button (check mark) or automatic close 3 s after the last audio line → the navigation that follows S-08 (Section 14.5.4)
S-04 tapping any avatar → S-05 of that child (or S-16 if that child's limit is reached)
S-16 only exception: intentionally terminal until local midnight or a parent extension (Section 15). The child can switch to another profile via the avatar button when ≥ 2 profiles exist.

18.2 Screen Summary #

ID Name Audience Presentation Owner (behaviour)
S-01 Splash/Loading System Root phase .launching Section 18 (routing), Section 24 (startup budget)
S-02 First-launch welcome (parent-facing) Parent Root phase .onboarding Section 15 (first launch, 15.4.1), copy Section 22
S-03 Parent setup: create first profile Parent Root phase .onboarding (always after S-17) Section 15 (15.4.1), copy Section 22
S-04 Profile picker Child Root phase .profilePicker Section 15
S-05 Child home (world) Child Child router screen .home Section 15, Section 14
S-06 Game picker Child Child router screen .gamePicker Section 18, Section 17 (locks)
S-07 Game screen (generic container) Child Full-screen cover (play session) Section 10; games Sections 11–13
S-08 Round end celebration Child Overlay inside the play session Section 14, Section 10
S-09 Abenteuer intro Child First state of the Abenteuer play session Section 15, Section 9
S-10 Abenteuer soft end Child Last state of the Abenteuer play session Section 15, Section 14
S-11 Garden (Zahlengarten) Child Child router screen .garden Section 14
S-12 Decoration shop Child Child router screen .shop Section 14
S-13 Zahlenfreunde gallery Child Child router screen .friends Section 14
S-14 Sticker album Child Child router screen .album Section 14
S-15 Ask-a-parent (locked game) Child Overlay on S-06 Section 17
S-16 Time's up (Zeit zum Ausruhen) Child Root phase .timesUp Section 15
S-17 Parental gate Parent First state of the parent-area cover; embedded in onboarding Section 16
S-18 Parent dashboard Parent Parent-area cover, NavigationStack root Section 16
S-19 Child progress detail Parent Pushed Section 16
S-20 Child settings Parent Pushed Section 16
S-21 Device settings Parent Pushed Section 16
S-22 Premium/paywall Parent Pushed Section 17
S-23 Data management Parent Pushed Section 16
S-24 Help and legal Parent Pushed Section 16
S-25 Profile editor Parent Pushed (parent area) or onboarding step Section 15
S-26 Break nudge overlay Child Overlay inside the play session Section 15
S-27 Friend befriended celebration Child Overlay inside the play session Section 14

18.3 Screen Specifications #

Each screen lists: audience, purpose, entry points, exits, key elements, spoken audio on entry (audio IDs; texts in Section 21), states, orientation behaviour, accessibility notes, owner. Sizes use Section 19 tokens; values in parentheses are CP / RP defaults.

18.3.1 S-01 Splash/Loading #

Field Specification
Audience System
Purpose Cover the time needed to open the SwiftData container (with the recovery procedure of Section 7.12.3 if needed), load and validate content (Section 8), read the entitlement cache and register the StoreKit listener (Section 17.6.1), then route. The audio engine starts after the first frame and does not block routing (Section 24.6.1, Section 20).
Entry App cold start. The static launch screen (Info.plist UILaunchScreen: background colour bg.cream, centred image LaunchMark = a short red/blue bead chain, no text, no app name) is replaced by S-01, which renders the identical image so the hand-over is invisible.
Exits (routing, evaluated in order) 1. No profile exists → S-02. 2. Exactly 1 profile → if that profile's daily limit is reached today → S-16, else S-05. 3. ≥ 2 profiles → S-04.
Key elements Centred bead-chain mark (200 pt wide CP, 280 pt RP). If loading takes longer than 400 ms, the beads fill in one by one (the screen's single ambient loop, 1 bead per 150 ms, Reduce Motion: static mark plus a small ProgressView). No text for children.
Audio on entry none; no music on S-01
States Loading (default). A store that cannot be opened never blocks S-01: the procedure of Section 7.12.3 (retry once, recovery copy, fresh store or in-memory fallback) runs and routing continues; the one-time parent notice appears later in the parent area. Start failure (core content numbers.json invalid, Section 8.17): parent-facing card "Die App konnte nicht gestartet werden." with "Erneut versuchen" and the support e-mail address as plain text (no link, because no gate exists here); copy Section 22. There is no empty state.
Orientation Mark centred in all classes.
Accessibility VoiceOver label on the mark: app name via Brand.appName + "wird geladen".
Owner Section 18 (routing), Section 24 (startup time budget and integrity)

18.3.2 S-02 First-launch welcome (parent-facing) #

Field Specification
Audience Parent
Purpose Explain in 20 seconds what the app is, who sets it up, and the privacy promise; hand the parent into setup behind the parental gate.
Entry S-01 when no profile exists (first launch, or after "Alle Daten löschen", Section 16.10.2).
Exits "Profil einrichten" → S-17 (embedded gate; first-launch setup always starts with the parental gate, Section 15.4.1) → S-03. Gate cancelled → S-02. There is no skip and no other control.
Key elements Illustration (friends 1–5 on a bead chain), headline, short body text, privacy promise row with an SF Symbol, primary button "Profil einrichten" (full width, 56 pt). All copy is the Section 22.5.1 text. No settings, no links, no price, no Premium mention or link, no purchase element (Section 15.4.1).
Audio on entry none: S-02 plays no voice, no sound effect and no music (the audio session stays inactive before child mode, Section 20.3.2)
States Single state.
Orientation CP: vertical stack in a ScrollView. CL: illustration left 40 %, text and button right 60 %. RP/RL: content column max width 560 pt, centred.
Accessibility Full VoiceOver order: headline, body, promise, button. Dynamic Type up to the largest accessibility size (scrolls).
Owner Section 15 (flow); copy Section 22

18.3.3 S-03 Parent setup: create first profile #

Field Specification
Audience Parent
Purpose Create the first child profile and hand the device to the child.
Entry S-02 "Profil einrichten" → S-17 passed. This is the only entry: S-03 is never shown again once a profile exists; profiles 2–5 are created in the parent area (S-18 → "Weiteres Profil" → S-25, Section 15.4.2).
Exits "Fertig" → the profile is saved → hand-off state → S-05 of the new profile (a session starts, Section 15.7.1). There is no back to S-02 after the gate, no second profile, and no Premium link.
Key elements One scrolling form (Section 15.4.1, copy Section 22.5.2): nickname text field (optional, 0–20 characters, live counter "n/20"); avatar grid of 12 tiles, 72 pt minimum, first avatar preselected; colour row of 6 swatches, 60 pt minimum, theme.sonne preselected; level as two large option cards "Die Kleinen (2–4 Jahre)" and "Vorschule (4–6 Jahre)", none preselected; sound-check button "Ton testen" with a speaker icon; finish button "Fertig", enabled once a level is chosen. Hand-off state (inside S-03): "Fertig! Jetzt darf Ihr Kind übernehmen." / "Geben Sie das Gerät einfach weiter." for 3 s with the new avatar animating in; any tap after 1 s skips the rest. The form view is the same component as S-25 in create mode plus the sound check and the hand-off.
Audio on entry none. "Ton testen" plays num.5 on the voice channel when tapped (the audio session is activated for that line only, Section 20.3.2).
States Form; hand-off. Validation per Section 15.2.1. Save failure (SwiftData error): inline error with "Erneut versuchen" (Section 7.12). If the app terminates before "Fertig" completes, nothing is stored and the next launch starts at S-02 again (Section 15.4.1).
Orientation Form in a ScrollView; content column max 560 pt on RP/RL; avatar grid 4 columns (CP), 6 columns (CL, RP, RL).
Accessibility Standard form accessibility (as S-25); avatars labelled by German animal names; swatches by colour names.
Owner Section 15; copy Section 22

18.3.4 S-04 Profile picker #

Field Specification
Audience Child (siblings)
Purpose Let a pre-reader pick "who plays" by avatar.
Entry S-01 with ≥ 2 profiles; avatar button on S-05 or S-16; return from background after more than 5 minutes with ≥ 2 profiles; closing the parent area when it was opened from S-04 (Section 15.5.1, Section 16.4.3).
Exits Tap an avatar → S-05 of that profile, or S-16 if that profile's limit is reached today. Profile switching needs no gate (Section 15.5.3). Grown-up icon (press-and-hold 2 s) → S-17 → S-18.
Key elements One avatar tile per profile (2–5), ordered by createdAt: avatar illustration on a circle filled with the profile's theme colour, 120 pt on iPhone / 180 pt on iPad, spacing ≥ 16 pt (Section 15.5.2); under each tile a small caption (child.caption) with the nickname or, when it is empty, the avatar's German animal name (for adults). A profile whose limit is reached today shows its avatar in the sleepy state with a small moon. Tap on a tile: the avatar bounces (0.3 s; Reduce Motion: brightness lift) and sfx.tile_select plays, then the destination opens. Grown-up icon top-right (Section 18.6). No add-profile control (adding is parent-only, Section 15.4.2); no time, stars or progress (siblings are never compared).
Audio on entry session.picker ("Wer spielt jetzt?") once per appearance of S-04 (Section 15.5.2)
States 2–5 profiles. Fewer than 2 cannot occur (routing skips S-04).
Orientation CP: 2 columns (up to 3 rows); CL: one row of up to 5 tiles at 110 pt; RP/RL: one or two rows, centred (Section 15.5.2).
Accessibility Each tile: label = nickname if set, else the avatar animal name (Section 15), trait button. Grown-up icon: custom action "Elternbereich öffnen" (Section 18.6).
Owner Section 15

18.3.5 S-05 Child home (world) #

Field Specification
Audience Child
Purpose The child's home base: start the daily Abenteuer, choose a game, visit the garden, friends and album.
Entry S-01, S-04, home buttons on child screens, end of play sessions, parent area closed ("Fertig" in S-18 returns here unless the parent area was opened from S-15, see Section 18.5.6).
Exits Abenteuer button → S-09 (a new Abenteuer, or the unfinished Abenteuer of the same local day, which resumes at its next unplayed round, Section 9.16.6 and Section 15.10.5); when today's Abenteuer is already completed → S-11 with the line session.abenteuer_done; Spiele → S-06; Garten → S-11; Freunde → S-13; Album → S-14; avatar button → S-04; grown-up icon (press-and-hold 2 s) → S-17 → S-18.
Key elements Background: the child's own garden rendered read-only by the S-11 renderer (decorations and friends in place, not interactive). Hero button "Abenteuer": a friend character holding a map on a round button, 160 pt (CP) / 120 pt (CL) / 220 pt (RP/RL); while today's Abenteuer is not completed, the hero glows gently (S-05's single ambient loop; Reduce Motion: static highlight ring, Section 15.10.1); when done today, the hero shows the friends asleep in the garden with a moon. Navigation row: 4 picture buttons, 72 pt (CP/CL) / 96 pt (RP/RL): Spiele (grid of four tiles), Garten (flower), Freunde (two friend faces), Album (book with star). Top bar: avatar button (only if ≥ 2 profiles), star counter, grown-up icon.
Audio on entry Session start: the greeting of Section 15.7.1 (session.greeting_morning, session.greeting_day, session.greeting_evening at every session start (not after an idle end); time bands owned by Section 15.7.1). Returning within the same session: none. Background music music.home per Section 20.8.2.
States Normal. Abenteuer done today (hero variant). Premium state change is applied here (Section 17.6.15). No empty state (a new profile has an empty garden, which is still a scene).
Orientation CP: top bar; hero centred at 38 % of height; navigation row in one line near the bottom safe area. CL: hero left half, navigation buttons as a 2×2 grid in the right half. RP: hero centred, navigation row below. RL: hero left third, navigation row right two-thirds in one line.
Accessibility Labels: "Abenteuer", "Spiele", "Garten", "Zahlenfreunde", "Stickeralbum", "Profil wechseln", "Elternbereich, zwei Sekunden gedrückt halten". The grown-up icon exposes a VoiceOver custom action "Elternbereich öffnen" that skips the hold (VoiceOver users are adults, Section 19).
Owner Section 15 (session start, greeting), Section 14 (garden rendering, stars)

18.3.6 S-06 Game picker #

Field Specification
Audience Child
Purpose Choose one of the 12 games in free play.
Entry S-05 "Spiele"; S-08 picker button; S-15 back; parent area closed after a purchase started from S-15.
Exits Unlocked tile → S-07 of that game (play session). Locked tile → sfx.lock and the S-15 overlay. Home button → S-05.
Key elements 12 game tiles in the fixed order of GameID.allCases (Entdecken, Wie viele?, Hör hin, Was fehlt?, Blitzblick, Mehr oder weniger, Nachspuren, Schüttelbox, Froschsprung, Fütter das Zahlenmonster, Memory, Punkt zu Punkt). Tile: rounded square with the game's illustration, no text; locked tiles add the lock badge (Section 19) at the bottom-right. Tile size: CP 3 columns (≈ 106 pt), CL 6 columns (≈ 95 pt), RP 4 columns, RL 6 columns; tile size capped at 180 pt; spacing 12 pt (CP/CL) / 20 pt (RP/RL). Vertical scrolling only if the grid does not fit. Top bar: home button, star counter.
Audio on entry session.game_pick on the first entry per session only
States All unlocked (premium). 8 locked (free). Just unlocked: after entitlement becomes premium, each previously locked tile plays the lock-open animation once (0.6 s; Reduce Motion: badge fades out). A game hidden because its content failed validation (Section 8) leaves no gap: the grid simply has 11 tiles.
Orientation Per column counts above; the grid is centred; tiles never smaller than 88 pt.
Accessibility Tile label: German game name; locked tiles add ", gesperrt". Trait button. Identifiers S06.gameTile.<gameId> and S06.gameTile.<gameId>.lockBadge (Section 6.10).
Owner Section 18; locks Section 17

18.3.7 S-07 Game screen (generic container) #

Field Specification
Audience Child
Purpose Host exactly one game round (free play) or the current round of an Abenteuer. One task on screen at a time.
Entry S-06 tile; S-08 replay; inside an Abenteuer: S-09 play button and automatic continuation after S-08.
Exits Round complete → S-08 overlay. Home button → exit per Section 10 (free play → S-06; Abenteuer → S-05). Time limit reached → the current task always completes (the hint ladder bounds it; the inactivity rules of Section 10 apply) → S-16 (Section 15.8.3). S-26 never appears inside S-07; a due break nudge waits for the round's S-08 (Section 15.9).
Key elements Layout slots owned by Section 10: home button (top-left), progress dots (top-centre, one dot per task, Section 19), speaker button (top-right), prompt area, task area, answer area. The game module fills the task and answer areas (Sections 11–13).
Audio on entry prompt.<gameId>.intro then the first task prompt (Section 10, Section 21)
States Loading (round plan being built, < 300 ms, no spinner shown unless > 300 ms, then a static bead chain); running; paused (inactivity overlay after 90 s, app backgrounded, interruption — Section 10); error (game fails to build a round: the container shows the friend with a shrug for 1.5 s, logs, and returns to S-06; the tile is hidden until next launch).
Orientation Rotation mid-round keeps the round state; an in-progress drag is cancelled and the dragged item returns to its origin. Slot geometry per layout class in Section 19. Game-specific orientation notes (e.g. Schüttelbox) in Section 12.
Accessibility Home/speaker buttons labeled; task elements labeled per game (Section 19 VoiceOver scope). System edge gestures deferred (.defersSystemGestures(on: .all)) so an accidental swipe does not open Control Center.
Owner Section 10; game content Sections 11–13

18.3.8 S-08 Round end celebration #

Field Specification
Audience Child
Purpose Close the round warmly, show stars earned.
Entry Last task of a round completed in S-07.
Exits Free play: replay button → new round of the same game (S-07); picker button → S-06. If a friend was befriended in this round → S-27 first, then back to S-08's buttons. Abenteuer: no buttons; after the celebration and any S-27 overlays, the path transition (1.5 s, Section 15.10.3) leads to the next round's S-07; after the 3rd round → S-10. If the time limit was reached during the round → S-16 instead of the buttons (Section 15.8.3). Free play only: if a break nudge is due → S-26 after the celebration and any S-27 overlays (Section 15.9).
Key elements Celebration animation ≤ 2.5 s (Section 14 and Section 19): the friend of a number practised in the round cheers; stars fly one by one into the star counter; the earned amount is shown as star icon + numeral (e.g. "+7"). Then (free play) two buttons: replay (circular arrow) 96 pt (CP) / 120 pt (RP), picker (grid of four tiles) 72 pt / 96 pt.
Audio on entry sfx.round_complete, then one of session.round_done_01 … session.round_done_04 (random without immediate repetition); sfx.star_count ticks while the counter counts up (Section 21)
States Free play; Abenteuer; with befriending; with time limit; Reduce Motion (stars appear in the counter with a cross-fade, counter numeral updates).
Orientation CP/RP: stack (celebration above, buttons below); CL/RL: celebration left, buttons right.
Accessibility Buttons "Nochmal spielen", "Andere Spiele". VoiceOver announcement "Plus sieben Sterne" when the counter updates.
Owner Section 14 (stars, celebration), Section 10 (round lifecycle)

18.3.9 S-09 Abenteuer intro #

Field Specification
Audience Child
Purpose Start (or resume) the daily 5-minute Abenteuer with a greeting and a clear start action.
Entry S-05 hero button when today's Abenteuer is not yet completed. If an Abenteuer of the same local day was left unfinished, S-09 shows that Abenteuer with its completed stones checked and the play button starts the next unplayed round (Section 9.16.6, Section 15.10.5); an unfinished Abenteuer of an earlier day is discarded.
Exits Play button → the next round (round 1 for a new Abenteuer) in S-07. Home button → S-05 (nothing recorded if round 1 has not started; the Abenteuer record is created when round 1 starts, Section 15.10.2).
Key elements The guide friend at the start of a path with 3 stepping stones, each showing the icon of one chosen game in order (Section 9 composition), and a finish flag; the stones light up one after another (0.4 s each, sfx.path_step per stone). Big play button (triangle) 120 pt (CP) / 160 pt (RP), tappable from 2.0 s. No auto-start: if the child does not tap within 20 s, the intro line is repeated once and the play button pulses once (Reduce Motion: no pulse); after that S-09 waits silently.
Audio on entry session.abenteuer_intro at 0.5 s; music music.abenteuer (played only on S-09, Section 20.8.2)
States New; resume (completed stones checked). Abenteuer plan could not be built (no playable game, impossible with the free games; defensive): return to S-05 without audio.
Orientation CP: stacked (friend, path, button). CL/RL: friend left, path and button right.
Accessibility Play button "Abenteuer starten"; stations labelled with game names.
Owner Section 15 (flow and timing, 15.10.2), Section 9 (composition)

18.3.10 S-10 Abenteuer soft end #

Field Specification
Audience Child
Purpose End the Abenteuer calmly: friends get sleepy, the +5 bonus is shown, the child is guided back home.
Entry S-08 (and any S-27 overlays) of the 3rd Abenteuer round.
Exits House button → S-05 (hero now in "done today" variant). Automatic return to S-05 15 s after the last line without input (Section 15.10.4). If the time limit was reached, the soft end finishes first, then S-16 (Section 15.8.3).
Key elements Soft end sequence per Section 15.10.4: the 5 bonus stars fly one by one into the star jar (celebration C6, ≤ 2.5 s); the visiting friends (or the avatar) yawn and lie down while the sky shifts slowly to evening colours (no flashing; Reduce Motion: cross-fade to the sleeping picture); after about 7 s the house button (96 pt CP / 120 pt RP) appears and glows gently. No replay button (one Abenteuer per day earns the bonus).
Audio on entry session.stars_bonus with sfx.star per bonus star; then session.abenteuer_end and session.abenteuer_bye (Section 15.10.4). Music fades out over 2 s at entry; S-10 has no music (Section 20.8.2).
States Normal; Reduce Motion (static scene with cross-fade).
Orientation Centred composition in all classes; button bottom-centre (CP/RP) or right-centre (CL/RL).
Accessibility Button "Nach Hause".
Owner Section 15; stars Section 14

18.3.11 S-11 Garden (Zahlengarten) #

Field Specification
Audience Child
Purpose Place and move decorations bought with stars; visit befriended Zahlenfreunde.
Entry S-05 "Garten"; S-05 hero when the Abenteuer is done today; S-12 via back or "to the garden" after buying.
Exits Home button → S-05; shop button → S-12; album button → S-14; friends-house button → S-13 (top bar per Section 14.4.1).
Key elements Garden scene with the placement grid of 24 slots (6 columns × 4 rows, Section 14.4.2), each slot ≥ 60 × 60 pt, contiguous; the Freundeshügel where befriended friends sit (Section 14.5.5); the always-visible inventory tray ("Korb") strip with the owned, unplaced decorations (Section 14.4.1); top bar with home button, star counter, shop button, album button and friends-house button (72 pt picture buttons). Placement: drag from the tray to a slot, long-press (0.5 s) and drag to move, drag back onto the tray to store; in addition, tap-then-tap: tap a tray item (it lifts), then tap a slot to place it; long-press a placed item until it lifts (0.5 s), then tap a slot to move it (Section 14.4.3). A single tap on a placed item only plays its idle animation. Tapping a friend makes it bounce and plays num.<n> then friend.<n>.hello (Section 14.5.6). When the tray is empty it shows a basket outline and a shop icon that opens S-12.
Audio on entry session.garden_intro on the first visit per profile; session.garden_hint the first time the tray holds an item
States Empty garden (new profile). Tray empty (basket outline plus shop icon). Garden full (24 slots used): dropping onto an occupied slot swaps the two items and the replaced item returns to the tray; there is no "garden full" message (Section 14.4.3).
Orientation The grid is 6 × 4 in every layout class and never re-flows (Section 14.4.2). CP: top bar 60 pt; grid of contiguous 60 pt slots, 360 × 240 pt with 7.5 pt side margins; Freundeshügel band (108 pt) below the grid, one row of up to 5 friends; tray strip (84 pt) at the bottom. CL: tray as a vertical strip (88 pt) at the left edge; grid of contiguous 60 pt slots (360 × 240 pt) in the centre; Freundeshügel as a column (108 pt) at the right edge, up to 4 friends. RP/RL: grid scaled (slots up to 140 pt, 12 pt spacing between slots whenever the width allows it), Freundeshügel band below the grid with two rows of five places (5 | 5, up to 10 friends), tray strip at the bottom. Friends are drawn at 60 pt (1–10) and 96 pt (11–20) on the Freundeshügel (Section 14.5.5). Where slots are contiguous they are exempt only from the 12 pt spacing rule (Section 19.6), never from the 60 pt size.
Accessibility Slots labelled "Platz " plus the item name; identifiers S11.decorationSlot.<x>_<y> (Section 6.10); VoiceOver custom actions "In den Korb legen" and "Verschieben nach …" for adult-assisted use. Edge gestures deferred.
Owner Section 14

18.3.12 S-12 Decoration shop #

Field Specification
Audience Child
Purpose Spend stars (never money) on garden decorations.
Entry S-11 shop button; empty-tray shop icon on S-11.
Exits Back button (arrow-back pictogram in the home-button position, 60 pt) → S-11. After a purchase the item card offers "back to the shop" (shelf pictogram) and "to the garden" (garden pictogram); "to the garden" → S-11 with the new item at the front of the tray (Section 14.4.4).
Key elements Four shelves, one per size (small, medium, large, special), identified by a sample-item picture, never by text; a vertically scrolling list of horizontal rows on iPhone, a grid on iPad. Item tiles ≥ 88 × 88 pt with the item picture, a star icon and the price numeral (child.numeralSmall). Tile states per Section 14.4.4: affordable; not affordable (small star jar filled to balance / price); friend-locked (60 % saturation, friend badge with the required count as a numeral and friend dots in five-groups); maximum reached (24 copies owned, small green check). Tap a tile → item card overlay: large item picture, price, buy button (star moving into a basket, ≥ 88 × 88 pt), close button (X, 60 pt); no text. Two taps are always required to buy.
Audio on entry session.shop_intro on entry; session.shop_need_more when the dimmed buy button of an unaffordable item is tapped (once per card opening); session.shop_need_friends when a friend requirement is tapped; sfx.shop_spend and session.shop_bought after a purchase
States Affordable / not affordable (buy button dimmed but tappable; it never buys) / friend-locked (buy button replaced by the friend requirement) / maximum reached (card without buy button). Buying repeats are allowed up to 24 copies per item (Section 14.4.4).
Orientation CP and CL: rows scroll vertically, each shelf scrolls horizontally; RP/RL: grid, 4 (RP) or 6 (RL) columns.
Accessibility Card label: item name, "kostet 25 Sterne"; unavailable reason appended.
Owner Section 14
Field Specification
Audience Child
Purpose See all 20 Zahlenfreunde; befriended ones are shown in full, the others as silhouettes.
Entry S-05 "Freunde"; S-11 friends-house button.
Exits Home button → S-05.
Key elements 20 friend places in Zwanzigerfeld order (Section 14.5.5): CP and CL: 4 rows × 5 (1–5, 6–10, a larger gap, 11–15, 16–20), place ≥ 60 pt, 12 pt spacing, 12 pt side margins; RP and RL: 2 rows × 10 (1–10 on top, 11–20 below), each row split 5 | 5 with a 1.5× gap. Befriended place: the friend in its idle state (its numeral is part of the character, Section 14.5.1). Not yet befriended: the silhouette state with no numeral and no dots, so the gallery never shows "missing" numbers (Section 14.5.5). Tap a befriended friend → the card enlarges to a 240 pt detail and the friend plays num.<n> then friend.<n>.hello (Section 14.5.6); tap outside or the X (60 pt) closes. Tap a silhouette → it wiggles gently and session.friend_waiting plays at most once per S-13 visit; later taps only wiggle. No number is revealed.
Audio on entry session.friends_intro on the first entry per session with ≥ 1 friend; session.friends_empty with 0 friends
States 0 friends (all silhouettes), some, all 20.
Orientation As above; vertical scrolling allowed in CL if needed.
Accessibility Befriended place: "Zahlenfreund ". Silhouette: "Zahlenfreund, noch nicht gefunden" (no number).
Owner Section 14

18.3.14 S-14 Sticker album #

Field Specification
Audience Child
Purpose Browse the 6 album pages and 52 stickers.
Entry S-05 "Album"; S-11 album button.
Exits Home button → S-05.
Key elements One page at a time (page layouts per Section 14.7.1); page arrows left/right (72 pt) at the vertical centre of the screen edges; horizontal swipe also turns pages; a row of 6 page dots (not interactive) and a picture tab per page (friend face, dot picture with 1, 2 or 3 dots, trophy) for direct jumps. Earned sticker in full colour; empty place as a light dotted outline only (no silhouette, no lock, no price, no "?"). Tap an earned sticker → it enlarges (0.3 s) and plays its sound: friend stickers num.<n> then friend.<n>.hello; dot-picture stickers the picture's name line label.dot.<pictureId>; milestone stickers sfx.sticker_tap (Section 14.7.4). Tapping an empty place does nothing.
Audio on entry session.album_intro on the first entry per session with ≥ 1 sticker; session.album_empty with 0 stickers; sfx.page_turn on page change
States Empty album (all outlines), partial, full. The album opens on the page of the newest sticker, which shines once (0.8 s; Reduce Motion: none) and clears the album-button dot (Section 14.7.1).
Orientation Every page fits without scrolling in every layout class; in CP the page is shown in portrait composition (Section 19 layout matrix).
Accessibility Arrows "Vorherige Seite", "Nächste Seite"; stickers labelled by name or "leerer Platz".
Owner Section 14

18.3.15 S-15 Ask-a-parent (locked game) #

Field Specification
Audience Child (then parent)
Purpose Friendly explanation that a grown-up is needed; never a purchase screen.
Entry Tap on a locked tile in S-06.
Exits Back button → S-06. Parent icon (single tap) → S-17 → on success the parent area opens at S-22 (Section 17.8); on cancel → S-06.
Key elements Dimmed S-06 behind (scrim scrim.child), centred card with the game's illustration, a large padlock that wiggles once, parent icon button (adult silhouette with key) 72 pt (CP) / 96 pt (RP), back button (arrow-back pictogram) 72 pt / 96 pt. While session.locked_other plays, the unlocked tiles of S-06 are highlighted above the scrim (Section 17.8). No price, no text, no store wording (Section 17.8).
Audio on entry sfx.lock; then, at most once per session per profile, session.locked_game ("Dieses Spiel ist noch zu. Frag deine Eltern.") followed by session.locked_other ("Schau mal, diese Spiele kannst du jetzt spielen!") (Section 17.8)
States First time in the session (voice pair plays); damped (every later opening in the same session: padlock wiggle and highlighted open tiles only, no voice).
Orientation Card centred; buttons side by side below the illustration in all classes (CL: illustration left, buttons right).
Accessibility Card label "Dieses Spiel ist noch zu. Frag deine Eltern." Parent button "Elternbereich".
Owner Section 17

18.3.16 S-16 Time's up (Zeit zum Ausruhen) #

Field Specification
Audience Child (parent may act)
Purpose Calmly end play for the day when the daily limit is reached.
Entry Time limit reached (Section 15.8.3) from any child screen after the current task completes; S-01/S-04 selecting a profile whose limit is reached today; closing the parent area after the limit was lowered below today's usage (Section 15.8.6).
Exits The next touch after the trusted local midnight → S-05 of the same profile with a new session (Section 15.8.4, Section 15.12). Grown-up icon (press-and-hold 2 s) → S-17 → the extension sheet of Section 15.8.5 ("+10 Minuten für heute" → S-05; "Zum Elternbereich" → S-18; "Abbrechen" → S-16). Avatar button (≥ 2 profiles) → S-04.
Key elements Calm evening version of the garden: the guide friend and the visiting friends in their sleepy state; with no friends, the child's avatar sleeps alone (Section 15.8.4). The only ambient loop is the friends' slow breathing (4 s period; Reduce Motion: static). Grown-up icon top-right; avatar button top-left (if ≥ 2 profiles). No home button, no games, no garden access; tapping the scene does nothing.
Audio on entry session.times_up once on appearance, no replay; music fades out over 2 s and stays off (S-16 has no music, Section 20.8.2)
States Normal; after the parent grants +10 minutes the phase changes to child mode.
Orientation Scene fills the window in all classes; controls in the corners.
Accessibility Scene label "Zeit zum Ausruhen. Morgen geht es weiter."
Owner Section 15

18.3.17 S-17 Parental gate #

Field Specification
Audience Parent
Purpose Keep children out of the parent area, purchases, external links and settings (Section 16 owns the gate rules).
Entry Grown-up icon hold on S-04, S-05 or S-16; parent icon on S-15; S-02 "Profil einrichten"; re-validation when the gate grant expired (Section 16.2.5).
Exits Correct answer → the requested destination (S-18, S-22, the extension sheet, or S-03). "Abbrechen" → the screen it was opened from.
Key elements Per Section 16.2.2: full-screen parent styling (system background, no illustrations, no animation), not dismissible by swiping; cancel button "Abbrechen" top leading; title "Für Erwachsene"; instruction line; written question in German number words (never spoken); answer display of two boxes; own numeric keypad in standard phone order (1 2 3 / 4 5 6 / 7 8 9 / delete, 0), keys ≥ 64 × 56 pt; full-width button "Bestätigen" below the keypad, enabled only when exactly 2 digits are entered; message line for errors and the 30 s cooldown after 3 wrong answers. Copy Section 22.4.
Audio on entry none (never spoken)
States Input; wrong answer (display shakes once, cleared; Reduce Motion: no shake, message only); cooldown 30 s (keypad disabled); success.
Orientation CP/RP: question above keypad. CL/RL: question and answer left, keypad right.
Accessibility Full VoiceOver support (keypad keys labelled as digits, question readable), Dynamic Type for the question text; it remains appropriate because VoiceOver users are adults (Section 19). Identifiers S17.question, S17.keypadKey.<d>, S17.confirm (Section 6.10).
Owner Section 16

18.3.18 S-18 Parent dashboard #

Field Specification
Audience Parent
Purpose Overview per child and entry to all parent functions.
Entry S-17 success from S-04, S-05 or S-16 ("Zum Elternbereich"); "Zum Elternbereich" on S-22 when opened from S-15 (Section 16.4.2); back navigation from pushed screens.
Exits "Fertig" (toolbar, top trailing) → closes the parent area (Section 18.5.6, Section 16.4.3). Each child card → S-19; the card's trailing button "Einstellungen" → S-20; "Weiteres Profil" → S-25 (create) when fewer than 5 profiles exist; rows push S-21, S-22, S-23, S-24. In Debug builds only, the row "Entwicklermenü" opens the developer menu (Section 5.10, compiled only under #if DEBUG).
Key elements Title "Elternbereich". Section "Kinder": one "Übersicht" card per profile, ordered by createdAt (header with avatar, display name and level; Zahlenraum; Zahlenfreunde count with a 20-dot bar in five-groups; stars; time today and last 7 days; first "Gerade schwierig" item; locked-today status; level-up hint), exactly as Section 16.5 defines. Button "Weiteres Profil" (disabled with "Maximal 5 Profile pro Gerät." at 5 profiles). Rows "Geräteeinstellungen", "Premium" (status line, Section 22.6.8), "Daten", "Hilfe & Rechtliches". Payment-problem notice when in grace or billing retry (Section 17.6.6). Queued StoreKit messages are displayed on appear (Section 17.6.13). One-time store-recovery notice when applicable (Section 7.12.3).
Audio on entry none; music paused while the parent area is open (Section 20)
States Loaded; child without play data (card shows header, Zahlenraum and the empty-state text of Section 22.6.2). Values refresh each time S-18 appears.
Orientation CP/CL: single column list. RP/RL: content column max 720 pt, centred (Section 16.4.1); two columns (cards left, rows right) when width ≥ 700 pt.
Accessibility Full VoiceOver and Dynamic Type; charts have accessibilityChartDescriptor descriptions (Section 16).
Owner Section 16

18.3.19 S-19 Child progress detail #

Field Specification
Audience Parent
Purpose Number × skill mastery grid, range stage, 30-day gains.
Entry S-18 "Fortschritt".
Exits Back → S-18. Tap a cell → detail sheet (attempts, last practiced, band) (Section 16.6.3).
Key elements Header with avatar, level and the range stage indicator (Section 16.6.1); segmented control "Fortschritt", "Gerade schwierig", "Zeit" (Section 16.6). Segment "Fortschritt": grid with rows = numbers 1–20 and columns = the 8 skills; band shown by colour AND symbol (Section 19 band tokens, Section 16 symbols and labels); sticky number column; block "Neu sicher (letzte 30 Tage)" below the grid.
Audio on entry none
States No data yet (all "Noch nicht geübt", explanatory empty text); inactive skills for the child's level shown with a hatched pattern and "nicht aktiv" in the cell's accessibility label.
Orientation If the computed cell width is < 44 pt (CP on narrow windows), the grid scrolls horizontally with the number column pinned; otherwise it fits.
Accessibility Each cell label: ", <Fähigkeit>: ". Rotor-friendly row headers.
Owner Section 16

18.3.20 S-20 Child settings #

Field Specification
Audience Parent
Purpose Per-child settings: level, range override, difficulty cap, daily time limit, avatar/nickname (via S-25).
Entry S-18.
Exits Back → S-18; "Profil bearbeiten" → S-25 (edit).
Key elements Form with pickers per Section 16 (values from Section 9 and Section 15). Changes save immediately.
Audio on entry none
States Normal; parent override active indicators.
Orientation Standard form; content width max 700 pt on RP/RL.
Accessibility Standard form controls.
Owner Section 16

18.3.21 S-21 Device settings #

Field Specification
Audience Parent
Purpose Device-wide toggles (Section 16.8).
Entry S-18.
Exits Back → S-18.
Key elements Form with the toggles of Section 16.8: Sprache, Musik, Soundeffekte, Haptik (only on devices with haptics), Schüttelbox mit Bewegung, Nachspuren nur mit Apple Pencil (iPad only); footers explain effects (e.g. voice off → visual demo path, Section 20). From V1.1: toggle "Mit iCloud synchronisieren" (default off), enabled only while premium, disabled with an explanation otherwise (Section 17.4, Section 7.15.2).
Audio on entry none
States Normal.
Orientation Standard form.
Accessibility Standard.
Owner Section 16

18.3.22 S-22 Premium/paywall #

Field Specification
Audience Parent
Purpose Subscribe, restore, redeem, manage; see status.
Entry S-18 row "Premium"; S-15 → S-17; S-24 "Käufe wiederherstellen" row. There is no entry from first-launch setup.
Exits Back → S-18 (or S-24, whichever pushed it); when opened from S-15, a button "Zum Elternbereich" leads to S-18 (Section 16.4.2). Legal links → leave-app confirmation sheet → Safari (Section 16.11.2). System sheets (purchase, manage, offer code, refund) return to S-22.
Key elements Paywall mode and status mode per Section 17.9.
Audio on entry none
States Per Section 17.9.6.
Orientation ScrollView; plan cards stacked in CP/CL, side by side in RP/RL when width ≥ 600 pt and Dynamic Type is not an accessibility size.
Accessibility Plan cards are single accessibility elements with selected trait; price, period and trial terms read in full.
Owner Section 17; copy Section 22

18.3.23 S-23 Data management #

Field Specification
Audience Parent
Purpose Per child: "Daten exportieren", "Fortschritt zurücksetzen", "Alles zurücksetzen", "Kind löschen"; device: "Alle Daten löschen" (Section 16.10; scopes in Section 14.11).
Entry S-18.
Exits Back → S-18; after "Alle Daten löschen" → the parent area closes and the app routes to S-02. "Kind löschen" is disabled while only one profile exists, so deleting a profile never leaves zero profiles; the only path to zero profiles is "Alle Daten löschen" (Section 16.10.1).
Key elements One group per child and one device group; rows and destructive confirmations per Section 16.10 and Section 16.12 (typed word "LÖSCHEN" for "Alle Daten löschen").
Audio on entry none
States Export in progress; export ready; confirmation dialogs; completion messages.
Orientation Standard form.
Accessibility Destructive buttons have role .destructive.
Owner Section 16
Field Specification
Audience Parent
Purpose Help texts (how to reach the parent area, how the app teaches, Guided Access tip), privacy policy, imprint, support e-mail, "Neu in dieser Version", restore shortcut, refund request (Section 17.6.14), app version.
Entry S-18.
Exits Back → S-18; external links and the support e-mail (mailto:) → leave-app confirmation sheet → Safari/Mail (Section 16.11.2); "Käufe wiederherstellen" → S-22.
Key elements List sections: Hilfe, Rechtliches, Kontakt, Käufe, Über die App (version and build from Info.plist, Brand.appName).
Audio on entry none
States Refund row hidden unless eligible (Section 17.6.14). Mail not configured: the mailto link still opens (system handles); the e-mail address is also shown as selectable text.
Orientation Standard list.
Accessibility Standard.
Owner Section 16

18.3.25 S-25 Profile editor #

Field Specification
Audience Parent
Purpose Create an additional child profile or edit an existing one (Section 16.7.2).
Entry S-18 "Weiteres Profil" (create, only while fewer than 5 profiles exist); S-20 "Profil bearbeiten" (edit). The first profile is created on S-03, which reuses this form (Section 15.4.1).
Exits "Fertig" (enabled when valid) → saves and returns to the caller (S-18 shows the new child's card); "Abbrechen" → back without changes; with unsaved changes it asks "Änderungen verwerfen?" with "Verwerfen" and "Weiter bearbeiten".
Key elements Nickname ("Spitzname") TextField, optional, 0–20 characters, live counter; avatar grid ("Lieblingstier", 12 avatars, 72 pt each, selected with ring + checkmark; avatars used by other profiles dimmed with a small check and not selectable; create mode preselects the first unused avatar); colour row ("Lieblingsfarbe", 6 swatches, 60 pt, selected with checkmark; theme.sonne preselected in create mode); level ("Stufe") as two option cards "Die Kleinen (2–4 Jahre)" / "Vorschule (4–6 Jahre)", none preselected, required, create mode only (in edit mode the level is changed in S-20). Rules and defaults per Section 15.2.1 and Section 16.7.2; copy Section 22.5.2.
Audio on entry none
States Create, edit, validation (input beyond 20 characters is not accepted; an over-long paste is truncated and shows the length message), save error (Section 7.12). Sixth profile impossible: "Weiteres Profil" is disabled at 5 profiles with the notice of Section 16.5.
Orientation Form; avatar grid 4 columns (CP), 6 columns (CL, RP, RL).
Accessibility Avatars labelled by German animal names; swatches by colour names.
Owner Section 16 (editor), Section 15 (profile rules)

18.3.26 S-26 Break nudge overlay #

Field Specification
Audience Child
Purpose One gentle break suggestion after 8 minutes of continuous play (Section 15.9); the child may continue.
Entry At the next safe point after the trigger of Section 15.9: after a free-play round's S-08 and any S-27 overlays, or immediately on S-05, S-06, S-11 to S-14. Never during a task, never during an Abenteuer, at most once per session.
Exits State nudge: "Weiterspielen" (play arrow) → the overlay closes and play continues where the child was (after S-08: its replay/picker buttons); "Pause" (moon) → state resting. State resting: sun button → S-05 (the session continues if less than 5 minutes passed, otherwise a new session starts, Section 15.7.1).
Key elements Scrim over the current screen. State nudge: the guide friend yawns (1.5 s; Reduce Motion: cross-fade to the sleepy pose) and two equal-weight picture buttons, 96 × 96 pt: "Pause" (moon) and "Weiterspielen" (play arrow); no text on the buttons. State resting: the friends sleep; only a large sun button (96 × 96 pt) to wake up.
Audio on entry State nudge: sfx.friend_yawn and session.break_nudge; "Weiterspielen" plays session.break_continue; "Pause" plays session.break_bye once when resting begins (Section 15.9, IDs per Section 21.8)
States nudge (counted as active time; after 90 s without response counting stops as usual and the overlay stays); resting (not counted; continuous play time reset).
Orientation Card centred; buttons side by side in all classes.
Accessibility "Pause machen" (moon), "Weiterspielen" (play arrow), "Aufwachen" (sun).
Owner Section 15

18.3.27 S-27 Friend befriended celebration #

Field Specification
Audience Child
Purpose Celebrate a newly befriended Zahlenfreund and show it moving into the garden.
Entry After S-08 of the round in which the befriending rule (Section 14.5.2) became true; inside an Abenteuer after the round's S-08 and before the path transition (or before S-10 after round 3); queued celebrations from an interrupted round on the next S-05 entry. Several friends are shown one after another in ascending number order (Section 14.5.4). Never during a task, never on top of S-16 or S-26.
Exits Continue button (check-mark picture, 88 × 88 pt, appears at 2.5 s) or automatic close 3 s after the last audio line → the navigation that would have followed S-08. Taps before 2.5 s are ignored; there is no home button on S-27.
Key elements Full-screen overlay per Section 14.5.4: background dims, the friend's silhouette pops into the full-colour happy friend, its bead belly lights up five-group by five-group and its numeral badge glows; animated part ≤ 2.5 s; afterwards the friend is shown moving into the garden and its sticker into the album (Reduce Motion: static "added" checks). Voice off: a large numeral next to the friend for the whole overlay.
Audio on entry sfx.friend_new, then session.new_friend, friend.<n>.hello (the friend's own line contains its number word) (Section 14.5.4)
States Single; repeated for multiple friends.
Orientation Centred in all classes.
Accessibility Announcement "Neuer Zahlenfreund: ".
Owner Section 14

18.4 Navigation Map #

flowchart TD
    S01[S-01 Splash] -->|no profile| S02[S-02 Welcome]
    S01 -->|1 profile| S05[S-05 Child home]
    S01 -->|1 profile, limit reached| S16[S-16 Zeit zum Ausruhen]
    S01 -->|2+ profiles| S04[S-04 Profile picker]
    S02 -->|Profil einrichten| G1[S-17 Gate]
    G1 -->|success| S03[S-03 Setup form]
    G1 -->|Abbrechen| S02
    S03 -->|Fertig, hand-off| S05
    S04 -->|avatar| S05
    S04 -->|avatar, limit reached| S16
    S04 -->|hold 2 s| G2[S-17 Gate]
    S05 -->|Abenteuer new or resume| S09[S-09 Abenteuer intro]
    S05 -->|Abenteuer done today| S11[S-11 Garden]
    S05 -->|Spiele| S06[S-06 Game picker]
    S05 -->|Garten| S11
    S05 -->|Freunde| S13[S-13 Friends gallery]
    S05 -->|Album| S14[S-14 Sticker album]
    S05 -->|avatar button| S04
    S05 -->|hold 2 s| G2
    S06 -->|unlocked tile| S07[S-07 Game]
    S06 -->|locked tile| S15[S-15 Ask a parent]
    S15 -->|back| S06
    S15 -->|parent icon| G3[S-17 Gate] -->|success| S22[S-22 Premium]
    S07 -->|round done| S08[S-08 Round end]
    S08 -->|friend befriended| S27[S-27 Friend celebration] --> S08
    S08 -->|replay| S07
    S08 -->|picker| S06
    S08 -->|break due, free play| S26[S-26 Break nudge]
    S26 -->|Weiterspielen| S08
    S26 -->|Pause, then sun| S05
    S09 -->|play| S07A[S-07 Abenteuer round 1..3]
    S07A --> S08A[S-08 Abenteuer round end] -->|rounds 1-2, path transition| S07A
    S08A -->|round 3| S10[S-10 Soft end] --> S05
    S11 -->|shop| S12[S-12 Shop] --> S11
    S11 -->|album| S14
    S11 -->|friends house| S13
    S07 -. time limit .-> S16
    S07A -. time limit .-> S16
    S16 -->|hold 2 s| G4[S-17 Gate] --> X16[Extension sheet]
    X16 -->|+10 Minuten für heute| S05
    X16 -->|Zum Elternbereich| S18
    X16 -->|Abbrechen| S16
    S16 -->|avatar button| S04
    G2 -->|success| S18[S-18 Dashboard]
    S18 --> S19[S-19 Progress]
    S18 --> S20[S-20 Child settings] --> S25[S-25 Profile editor]
    S18 --> S21[S-21 Device settings]
    S18 --> S22
    S18 --> S23[S-23 Data]
    S18 --> S24[S-24 Help and legal]
    S18 -->|Weiteres Profil| S25
    S24 -->|restore| S22
    S23 -->|Alle Daten löschen| S02
    S18 -->|Fertig| S05
    S22 -->|back or Zum Elternbereich| S18

Home-button edges from S-06, S-07, S-09, S-11, S-13, S-14 back to S-05 exist on every child screen and are omitted from the diagram for readability (Section 18.1.4 lists them). "Fertig" on S-18 returns to the screen the gate was opened from (S-04, S-05, S-06 after S-15) or to the destination of Section 16.4.3; the diagram shows only the S-05 case. The S-26 edge "Weiterspielen" returns to where the nudge appeared.

18.5 Navigation Implementation #

Decision: the app uses two navigation systems: a custom state-driven router for child mode (no NavigationStack, no system back gesture, no navigation bar), and a NavigationStack for the parent area. Games and the Abenteuer run in a fullScreenCover owned by the child container. The parent area (including its gate) runs in a second fullScreenCover attached to the root view.

Rationale: child mode must never show a navigation bar, a back swipe or any system chrome that a toddler can trigger accidentally; transitions must be calm and fully controlled. The parent area benefits from standard iOS navigation, accessibility and Dynamic Type behaviour.

18.5.1 Router types #

// App target, folder App/Routing/ (folder layout, Section 5.13)
import SwiftUI
import ZKCore

@MainActor
@Observable
final class AppRouter {
    enum Phase: Equatable {
        case launching                       // S-01
        case onboarding                      // S-02 -> S-17 -> S-03 (single form + hand-off)
        case profilePicker                   // S-04
        case child                           // S-05, S-06, S-11..S-14 via childScreen
        case timesUp                         // S-16 for activeProfileID
    }

    private(set) var phase: Phase = .launching
    private(set) var activeProfileID: UUID?          // profile `id` (Section 7)
    var childScreen: ChildScreen = .home
    var childOverlay: ChildOverlay?                   // S-15 over S-06
    var playSession: PlaySession?                     // drives the play-session fullScreenCover
    var parentArea: ParentAreaRequest?                // drives the parent-area fullScreenCover

    func route(afterLaunch profiles: [ProfileSummary], today: Date)
    func selectProfile(_ id: UUID)
    func go(_ screen: ChildScreen)                    // cross-fade transition
    func startGame(_ game: GameID)
    func startAbenteuer(_ plan: AbenteuerPlan)
    func endPlaySession(returnTo: ChildScreen)
    func showAskParent(for game: GameID)
    func openParentArea(_ intent: ParentIntent)
    func closeParentArea()
    func timeLimitReached()                           // called by the session manager (Section 15)
    func dayRolledOver()                              // local midnight (Section 15)
    func dataWiped()                                  // after "Alle Daten löschen" -> onboarding (S-02)
}

enum ChildScreen: Hashable { case home, gamePicker, garden, shop, friends, album }

enum ChildOverlay: Identifiable, Equatable {
    case askParent(GameID)                             // S-15
    var id: String { switch self { case .askParent(let g): return "ask-\(g.rawValue)" } }
}

enum PlaySession: Identifiable, Equatable {
    case game(GameID, sessionID: UUID)                 // S-07 (+ S-08, S-26, S-27 inside)
    case abenteuer(AbenteuerPlan, sessionID: UUID)     // S-09, S-07 x3, S-08 x3, S-10 inside
    var id: UUID {
        switch self {
        case .game(_, let sid), .abenteuer(_, let sid): return sid
        }
    }
}

enum ParentIntent: Equatable {
    case dashboard                                     // from the S-04, S-05 or S-16 grown-up icon
    case premium(fromLockedGame: GameID)               // from S-15
    case grantExtraTime(profileID: UUID)               // from the S-16 grown-up icon (extension sheet)
}

struct ParentAreaRequest: Identifiable, Equatable {
    let id = UUID()
    let intent: ParentIntent
}

enum ParentRoute: Hashable {
    case progress(profileID: UUID)                     // S-19
    case childSettings(profileID: UUID)                // S-20
    case deviceSettings                                // S-21
    case premium                                       // S-22
    case data                                          // S-23
    case help                                          // S-24
    case profileEditor(ProfileEditorMode)              // S-25
}

enum ProfileEditorMode: Hashable { case create, edit(profileID: UUID) }

AbenteuerPlan is produced by the learning engine (Section 9) and must be Equatable; startGame and startAbenteuer create a fresh sessionID for every start so that a replay is a new presentation identity. ProfileSummary is a lightweight value read from the persistence layer (Section 7).

18.5.2 View hierarchy #

struct RootView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        @Bindable var router = router
        ZStack {
            switch router.phase {
            case .launching:     SplashView()                 // S-01
            case .onboarding:    OnboardingFlowView()         // S-02, S-17 embedded, S-03 form and hand-off
            case .profilePicker: ProfilePickerView()          // S-04
            case .child:         ChildContainerView()         // S-05, S-06, S-11..S-14, S-15 overlay
            case .timesUp:       TimesUpView()                // S-16
            }
        }
        .fullScreenCover(item: $router.parentArea) { request in
            ParentAreaContainerView(request: request)          // S-17 first, then NavigationStack S-18..S-25
        }
    }
}

struct ChildContainerView: View {
    @Environment(AppRouter.self) private var router
    var body: some View {
        @Bindable var router = router
        ZStack {
            childScreenView(router.childScreen)
                .transition(.opacity)
                .id(router.childScreen)
            if let overlay = router.childOverlay { AskParentOverlay(overlay: overlay) } // S-15
        }
        .animation(.easeInOut(duration: 0.3), value: router.childScreen)
        .environment(\.colorScheme, .light)        // child area always light (Section 19)
        .statusBarHidden(true)
        .persistentSystemOverlays(.hidden)
        .fullScreenCover(item: $router.playSession) { session in
            PlaySessionContainerView(session: session) // S-07 with S-08, S-26, S-27; Abenteuer S-09/S-10
                .environment(\.colorScheme, .light)
        }
    }
}

Rules:

  • The light-appearance override is applied on the child containers (ChildContainerView, ProfilePickerView, TimesUpView, PlaySessionContainerView) and not on RootView, so the parent-area cover follows the system appearance (Section 19). S-01 uses fixed design-system colours and is unaffected.
  • The parent-area cover is attached to RootView, above the child container. It is never presented while a play session cover is up (no parent entry exists inside S-07); openParentArea asserts playSession == nil in Debug and is a no-op in Release.
  • fullScreenCover presentations for play sessions and the parent area are made without the system slide-up animation: the router sets them inside withTransaction with disablesAnimations = true, and the presented container fades its content in over 0.3 s. Verification step: confirm on the deployment target and on the SDK of Section 5 that disablesAnimations suppresses the cover animation; if it does not, accept the system animation for the parent area and keep the fade for play sessions by presenting the play session as a full-window ZStack layer inside ChildContainerView instead of a cover.
  • Child transitions between ChildScreens: cross-fade 0.3 s ease-in-out. Reduce Motion: identical (cross-fades are permitted, Section 19).
  • There is exactly one NavigationStack in the app: the parent area's (ParentAreaContainerView). OnboardingFlowView switches between S-02, the embedded S-17 and S-03 with cross-fades and needs no stack, because S-03 is a single form with no pushed screens.

18.5.3 Parent-area container #

ParentAreaContainerView(request:) has two internal states:

  1. gate: shows S-17. Cancel → router.closeParentArea() (returns to exactly the child state that was visible). Success → state 2.
  2. unlocked: depending on request.intent:
    • .dashboard → NavigationStack(path:) with root S-18 and an empty path.
    • .premium → root S-18, path [.premium] (S-22 visible, back leads to S-18).
    • .premium also shows the button "Zum Elternbereich" on S-22, which pops to S-18 (Section 16.4.2).
    • .grantExtraTime(profileID) → the extension sheet of Section 15.8.5 with three choices: "+10 Minuten für heute" applies the extension, closes the cover, and the router moves from .timesUp to .child at S-05 (new session); "Zum Elternbereich" shows S-18 with an empty path; "Abbrechen" closes the cover and S-16 stays.

Gate grant validity (Section 16 owns the rule): while the parent area is open, a background period longer than 5 minutes resets the container to state 1 (S-17) on return to the foreground; after a new success it shows S-18 with an empty path.

18.5.4 Play-session container #

PlaySessionContainerView owns a local state machine:

  • .game(g): S-07 for game g → S-08 → (S-27 per befriended friend) → S-08 buttons → replay (new S-07 round) or picker (router.endPlaySession(returnTo: .gamePicker)). Home button → Section 10 exit rules → endPlaySession(returnTo: .gamePicker).
  • .abenteuer(plan): S-09 → S-07 (next unplayed round; round 1 for a new plan) → S-08 → (S-27) → path transition → S-07 (next round) → … → S-08 (round 3) → (S-27) → S-10 → endPlaySession(returnTo: .home). Home button in any state → Section 15.10.5 abandon rules (the unfinished Abenteuer can be resumed the same local day) → endPlaySession(returnTo: .home). At each round start the Abenteuer coordinator re-checks the entitlement (Section 9.16.6).
  • S-26 is presented by this container only in .game sessions, after S-08 and any S-27 overlays, when the session manager signals a due break nudge (Section 15.9).
  • On timeLimitReached, the container lets the current task complete (it always completes; the hint ladder bounds it, Section 15.8.3), then the router dismisses the cover and sets phase = .timesUp. No S-08 buttons are shown in that case.

18.5.5 Applying entitlement changes #

The child router reads EntitlementService.isPremium (Section 17) but never reacts while a play session is active. S-06 renders lock badges from the value at the moment it is (re)displayed; a change while S-06 is visible updates the tiles immediately with the lock-open animation (premium gained) or the badge fading in (premium lost). A running round is never interrupted (Section 17.6.15); inside an Abenteuer the coordinator re-checks the entitlement at each round start and replaces a premium round that has not started (Section 9.16.6). The router uses EntitlementService.isUnlocked(_:) with the game's tier for every tile.

18.5.6 Closing the parent area #

Decision: only S-18 has the "Fertig" button (top trailing, Section 16.4.3); pushed parent screens use the standard Back button. "Fertig" calls router.closeParentArea(), which applies the destination table of Section 16.4.3:

  • Intent .dashboard → the screen the grown-up icon was on (S-04 or S-05; from S-16 the rules below apply).
  • Intent .premium(fromLockedGame:) → child mode S-06; the S-15 overlay is not re-shown. If the game is now playable, its tile plays the lock-open animation.
  • Settings changes that affect the active child (level, range, time limit) take effect immediately; if the new time limit is already exceeded today, the router goes to S-16 (Section 15).
  • If the active profile was deleted in S-23, the router goes to S-04 when 2–5 profiles remain, or to S-05 of the only remaining profile. Zero profiles cannot occur here ("Kind löschen" is disabled for the last profile; "Alle Daten löschen" routes to S-02 via dataWiped()).
  • If the parent area stayed open for more than 5 minutes, the session had ended (Section 15.7.1): S-05 of the previous profile with a new session, or S-04 if it was deleted.

18.5.7 Scene phase and interruptions #

  • Background → foreground: the router keeps its state. The session manager (Section 15) decides whether a new session starts (background > 5 minutes) and whether the greeting plays again.
  • The app declares a single scene. Decision: UIApplicationSupportsMultipleScenes = false (no second window on iPad), so exactly one router exists (Section 5 owns Info.plist).
  • Local midnight while S-16 is shown → dayRolledOver() → S-05. Midnight during play is handled by Section 15.
  • Memory warning: no navigation change (Section 5).

18.6 Parent Entry Point on the Child Home #

Decision: a small grown-up icon (adult silhouette pictogram, ink.secondary on a white disc) sits top-right on S-04, S-05 and S-16. It is visually small (40 pt) so it does not attract children, but its hit area is 60 pt. Accessibility identifiers S04.parentEntry, S05.parentEntry, S16.parentEntry (Section 6.10).

Aspect Rule
Gesture Press and hold for 2.0 s. A circular progress ring fills around the icon during the hold (Reduce Motion: the ring fills without scaling effects). Release before 2.0 s → ring resets over 0.2 s, nothing else happens (no sound, no voice, no haptic).
Completion At 2.0 s: light haptic (Section 19), then S-17 opens (parent-area cover with the gate).
Movement tolerance The hold is cancelled if the finger moves more than 20 pt from the start point.
VoiceOver Custom accessibility action "Elternbereich öffnen" opens S-17 immediately (VoiceOver users are adults).
Why a hold and a gate The hold prevents accidental entry during play; the gate (Section 16) is the actual protection required for Kids category apps.
Other entries S-15 parent icon (single tap, Section 17.8). There is no parent entry inside S-06 to S-14, S-26 or S-27.

18.7 Deep Links and External Entry Points #

Decision: none in V1. The app declares no URL scheme, no universal links (no associated domains), no Handoff activities, no Spotlight indexing, no App Intents or App Shortcuts, no widgets, no Home Screen quick actions, no notifications (Section 14 forbids notifications) and no PurchaseIntent handling (Section 17.3). The app is always entered through S-01. Any future entry point must route through the parental gate if it targets the parent area.

18.8 Audio-on-Entry Registry #

Audio IDs referenced by this section. The IDs and texts are owned as follows: session.*, sfx.* and music.* IDs follow the inventory of Section 21 (convention session.<snake_case>, no extra dots); game lines are owned by Sections 11–13; friend and reward behaviour by Section 14; session and time behaviour by Section 15. Section 21 contains every ID listed here with its German text; the content validation (Section 8) fails if any is missing.

Audio ID Used on Played when
session.picker S-04 each appearance of S-04
session.greeting_morning, session.greeting_day, session.greeting_evening S-05 every session start (not after an idle end), by time band (Section 15.7.1)
session.abenteuer_done S-05 → S-11 Abenteuer button tapped after today's Abenteuer is completed
session.game_pick S-06 first entry per session
sfx.lock S-06 locked tile tapped
prompt.<gameId>.intro S-07 round start (Section 10; IDs per Sections 11–13)
sfx.round_complete, sfx.star_count S-08 round end jingle / counter counting up
session.round_done_01 … session.round_done_04 S-08 round end (random without immediate repetition)
session.abenteuer_intro S-09 at 0.5 s; repeated once after 20 s without a tap
sfx.path_step S-09, path transition each stone lights up / the guide friend hops
music.abenteuer S-09 background music (S-09 only)
session.stars_bonus, sfx.star S-10 bonus line / each of the 5 bonus stars
session.abenteuer_end, session.abenteuer_bye S-10 soft end, in this order
session.garden_intro, session.garden_hint S-11 first visit per profile / first time the tray holds an item
sfx.garden_place S-11 a decoration snaps into a slot
session.shop_intro S-12 entry
session.shop_need_more, session.shop_need_friends S-12 dimmed buy button tapped / friend requirement tapped
sfx.shop_spend, session.shop_bought S-12 after a purchase
session.friends_intro, session.friends_empty S-13 first entry per session with ≥ 1 friend / with 0 friends
session.friend_waiting S-13 silhouette tapped (at most once per visit)
session.album_intro, session.album_empty S-14 first entry per session with ≥ 1 sticker / with 0 stickers
sfx.page_turn S-14 page change
sfx.sticker_tap S-14 milestone sticker tapped (Section 14.7.4)
label.dot.<pictureId> S-14 dot-picture sticker tapped (Section 14.7.1)
session.locked_game, session.locked_other S-15 first opening per session per profile, in this order
session.times_up S-16 once on appearance
sfx.friend_yawn, session.break_nudge S-26 state nudge
session.break_continue, session.break_bye S-26 "Weiterspielen" / "Pause"
sfx.friend_new, session.new_friend S-27 entry, followed by friend.<n>.hello
friend.<n>.hello S-11, S-13, S-14, S-27 friend tapped / friend sticker tapped / celebration
num.<n> S-11, S-13, S-14 before friend.<n>.hello when a friend or friend sticker is tapped
num.5 S-03 "Ton testen" tapped
sfx.tile_select S-04 avatar tile tapped

"First entry per session" lines are replayable with the screen's speaker button when present; they never repeat automatically on the same screen within the same session. When the voice channel is off (Section 20), no line plays and the screen's visual path applies (Section 20). Parent screens (S-02, S-17 to S-25) play no audio, except the S-03 sound check.

18.9 Orientation Summary #

Screen group CP (iPhone SE portrait 375×667) CL (iPhone SE landscape 667×375) RP / RL (iPad)
Parent screens (S-02, S-03, S-17 to S-25) Single column, scrolls Single column, scrolls; S-17 splits question/keypad Content column max 560–700 pt centred; S-18 two columns when width ≥ 700 pt
Child hub screens (S-04, S-05) Vertical composition Hero left, controls right RP vertical, RL horizontal, scaled tokens
Child grids (S-06, S-12, S-13) 3 / 3 / 5 columns 6 / 5 / 5 columns 4–6 / 4–6 / 10 columns
Garden (S-11) 6 × 4 grid, Freundeshügel below, tray strip at the bottom Tray left, 6 × 4 grid centre, Freundeshügel right 6 × 4 grid scaled; Freundeshügel below; tray strip at the bottom
Game container (S-07) Stacked (task above answer), geometry Section 19.5.2 Side by side (task left, answer right), geometry Section 19.5.2 RP stacked, RL side by side, geometry Section 19.5.2
Overlays (S-08, S-15, S-26, S-27) Stacked Side by side Centred card, max 640 pt wide
Terminal and system (S-01, S-16) Full-window scene Full-window scene Full-window scene

The detailed layout matrix and the class thresholds are owned by Section 19.

19. Design System and Accessibility #

This section is the single owner of colours, bead and number-representation graphics, typography, spacing, layout classes, touch targets, reusable components, illustration style, animation, haptics, Reduce Motion, VoiceOver scope, Dynamic Type, colour-blind rules and the calm-design checklist. All tokens and components live in the module ZKDesignSystem (Section 5). No view outside ZKDesignSystem may use a literal colour, font size or animation duration; it uses the tokens defined here.

19.1 Design Principles #

  1. One task per screen. A child screen shows one question and its answer options. Nothing else competes for attention.
  2. Calm. At most one looping ambient animation per screen, no flashing, celebrations ≤ 2.5 s, soft sounds (Section 20), no visual noise behind the task area.
  3. Meaningful colour only. Red and blue are reserved for the beads and counters of the five-structure. Green means "correct". Warm yellow means stars and hints. Errors are never red; "try again" uses a gentle neutral grey.
  4. Never colour alone. Every meaning carried by colour is also carried by shape, symbol, position or motion (Section 19.14).
  5. Big, round, friendly. SF Pro Rounded, large numerals, rounded shapes, ≥ 60 pt child touch targets.
  6. The child area is always light and warm; the parent area is a standard, accessible iOS interface with dark mode.
  7. No text a child must read. Text appears in the child area only as numerals and as optional captions for adults (for example nicknames).

19.2 Colour Tokens #

19.2.1 Core palette #

Token Hex Used for Forbidden uses
bead.red #D8453A Solid beads, counters and friend-body dots in the 1st and 3rd group of five (1–5, 11–15) Errors, warnings, "wrong", destructive UI in the child area, decoration of any non-bead element in the task area
bead.redOutline #A8322A 1 pt outline of red beads —
bead.blue #2D6BD4 "Lochperle" beads and counters in the 2nd and 4th group of five (6–10, 16–20) Buttons or other UI in the child task area (so blue always means "second five")
bead.blueOutline #1F4E9E 1 pt outline and inner rim of blue beads —
bead.hole #FFFFFF Centre hole of the Lochperle —
bg.cream #FFF7EC Background of all child screens and S-01 —
surface.white #FFFFFF Tiles, buttons, cards, dice faces in the child area —
ink #2B2A33 Numerals, pictograms, primary text, dice pips —
ink.secondary #5E5C6B Secondary text, grown-up icon, captions —
success #4FA36B Correct-answer ring and glow, mastered band swatch Text on white (fails AA for small text; use parent.successText)
star.yellow #F5B82E Stars, star counter glyph, completed progress dots, hint highlight ring Text
neutral.tryAgain #8A8FA3 Try-again wiggle ring, empty-cell outlines, upcoming progress dots, ghost (gap) beads Text
line.field #C9C3D6 Inner grid lines of the Zwanzigerfeld, dividers in the child area (decorative) Any line that must be perceived to operate a control
silhouette #D9D4E3 Not-yet-befriended friend silhouettes, empty sticker-slot fill —
scrim.child #2B2A33 at 40 % opacity Dimming behind S-15, S-26 overlays —

19.2.2 Parent-area tokens (light and dark) #

The parent area uses SwiftUI semantic colours for backgrounds and text (Color(.systemGroupedBackground), Color(.secondarySystemGroupedBackground), .primary, .secondary) so it follows the system appearance, plus these tokens with explicit light and dark values:

Token Light Dark Used for
parent.accent #2D6BD4 #8FB4F5 Tint (buttons, links, toggles, selection ring, chart bars)
parent.textPrimary #2B2A33 #EDEBF3 Custom text drawn outside system components
parent.textSecondary #5E5C6B #A8A6B3 Secondary custom text
parent.successText #2E7D4F #6BC08A "Aktiv", positive status lines
parent.destructive #B3362C #FF8A80 Destructive buttons and confirmations (red is allowed here because the parent area contains no beads)
parent.notice #FFE9B8 #4A3B12 Background of the payment-problem notice card (text uses primary text colour)

Decision: the parent accent reuses the bead blue hex in light mode because the parent area never shows beads next to controls except in S-19 legends, where beads are drawn with their mandatory hole shape.

19.2.3 Mastery band swatches (S-18, S-19) #

Section 16 owns the band labels and symbols; this section owns their colours. Swatches are identical in light and dark mode; the symbol inside each swatch is drawn in ink.

Band Token Hex Ink-on-swatch contrast
notStarted band.notStarted #ECEAF1 11.9:1
practicing band.practicing #FFE9B8 11.9:1
almost band.almost #BFE3CB 10.2:1
mastered band.mastered #4FA36B 4.6:1

19.2.4 Profile colour themes #

Six themes. Section 15.3.2 owns the IDs, German names and accent values; this table carries them under the same IDs and adds the contrast check. Theme colours tint only the circle behind the avatar on S-04, the avatar button on S-05 and the child's card accent in the parent area; they never appear in the task area, on beads, the Zwanzigerfeld, feedback colours or the garden. They are used only as large fills behind dark ink artwork, never as text colour.

colorThemeID German name Hex Ink-on-theme contrast (non-text, ≥ 3:1)
theme.sonne Sonne #F6C85F 9.0:1
theme.meer Meer #4FB3BF 5.8:1
theme.wiese Wiese #8CC084 6.7:1
theme.beere Beere #A77BCA 4.3:1
theme.pfirsich Pfirsich #F4A38C 7.1:1
theme.himmel Himmel #8FB8E8 6.9:1

None of the six equals bead.red, bead.blue, success, star.yellow or neutral.tryAgain. The default theme (preselected in S-03 and S-25, and the fallback for an unknown ID) is theme.sonne.

19.2.5 Appearance decision #

Decision: the child area is always rendered in the light "warm" appearance; the parent area supports light and dark mode.

  • Implementation: the child containers apply .environment(\.colorScheme, .light) (Section 18.5.2); all child tokens are defined with a single ("Any") appearance.
  • The parent-area cover is attached above the child container and therefore follows the system appearance.
  • S-01 and the launch screen use fixed bg.cream.
  • Illustrations are single-appearance assets.

19.2.6 Contrast checks #

Computed with the WCAG 2.x relative-luminance formula. Requirement: parent-area text ≥ 4.5:1 (normal) and ≥ 3:1 (large text ≥ 18 pt regular or 14 pt bold); non-text UI components and meaningful graphics ≥ 3:1 against their background.

Foreground Background Ratio Use Result
ink #2B2A33 bg.cream #FFF7EC 13.3:1 numerals, text pass AAA
ink surface.white 14.2:1 tile numerals pass AAA
ink.secondary #5E5C6B bg.cream 6.1:1 captions pass AA
ink.secondary #F2F2F7 (grouped background) 5.9:1 parent secondary pass AA
parent.accent #2D6BD4 white 5.0:1 links, tint pass AA
parent.accent #F2F2F7 4.5:1 tint on grouped bg pass AA (at the limit; do not lighten)
white parent.accent 5.0:1 filled button label pass AA
parent.successText #2E7D4F white / #F2F2F7 5.1:1 / 4.5:1 status text pass AA
parent.destructive #B3362C white / #F2F2F7 6.0:1 / 5.4:1 destructive pass AA
parent.accent dark #8FB4F5 #000000 / #1C1C1E / #2C2C2E 10.0 / 8.1 / 6.7:1 dark tint pass AA
parent.textSecondary dark #A8A6B3 #000000 / #1C1C1E 8.8 / 7.1:1 dark secondary pass AA
parent.destructive dark #FF8A80 #000000 / #1C1C1E 9.2 / 7.5:1 dark destructive pass AA
bead.red bg.cream 4.1:1 bead graphic pass (non-text ≥ 3:1)
bead.blue bg.cream 4.8:1 bead graphic pass (non-text)
neutral.tryAgain #8A8FA3 bg.cream 3.0:1 outlines, try-again ring pass (non-text, at the limit; do not lighten)
white bead.red 4.35:1 numeral printed on a red bead (large text only) pass large text
ink star.yellow 8.0:1 numeral on a star badge pass
ink success 4.6:1 check symbol on green pass
bead.red bead.blue 1.16:1 — luminance nearly equal: red and blue are indistinguishable in greyscale, which is why the shape and position rules of Section 19.14 are mandatory

Verification: a unit test in ZKDesignSystem (ContrastTests) recomputes every "pass" row from the asset-catalog values and fails if any ratio falls below its threshold.

19.2.7 Implementation #

// ZKDesignSystem — colours are colour sets in Resources/Media.xcassets inside the package
// (processed resource; catalog location and naming per Section 6.4.3: the colour-set name is the
// token name in lowerCamelCase, e.g. token `bead.red` → colour set `beadRed`).
import ZKCore   // MasteryBand is declared in ZKCore (Section 5.3.2)

public enum ZKColor {
    public static let beadRed        = Color("beadRed", bundle: .module)
    public static let beadRedOutline = Color("beadRedOutline", bundle: .module)
    public static let beadBlue       = Color("beadBlue", bundle: .module)
    public static let beadBlueOutline = Color("beadBlueOutline", bundle: .module)
    public static let bgCream        = Color("bgCream", bundle: .module)
    public static let surface        = Color("surfaceWhite", bundle: .module)
    public static let ink            = Color("ink", bundle: .module)
    public static let inkSecondary   = Color("inkSecondary", bundle: .module)
    public static let success        = Color("success", bundle: .module)
    public static let star           = Color("starYellow", bundle: .module)
    public static let tryAgain       = Color("neutralTryAgain", bundle: .module)
    public static let lineField      = Color("lineField", bundle: .module)
    public static let silhouette     = Color("silhouette", bundle: .module)
    public static let parentAccent   = Color("parentAccent", bundle: .module)
    // … every token of Sections 19.2.1–19.2.4 has exactly one static property
    public static func theme(_ id: String) -> Color   // e.g. "theme.meer" → colour set `themeMeer`; unknown id → theme.sonne
    public static func band(_ band: MasteryBand) -> Color
}

The app target's AccentColor asset equals parent.accent.

19.3 Typography #

Font family: SF Pro Rounded, used through Font.system(size:weight:design: .rounded) (child) and text styles with .fontDesign(.rounded) (parent). No custom font files are bundled.

19.3.1 Size tiers #

Child sizes depend on the size tier, computed from the window's shorter side: S < 400 pt (iPhone SE, standard-size iPhones), M 400–599 pt (Plus/Pro Max iPhones, narrow iPad windows), L ≥ 600 pt (iPad full screen and wide iPad windows).

19.3.2 Child type scale (fixed sizes, no Dynamic Type) #

Token S M L Weight Use
child.numeralHero 96 pt 112 pt 160 pt bold Entdecken numeral display, S-27 friend numeral
child.numeral 44 pt 48 pt 64 pt bold Every numeral that is task content: stimulus numerals, numeral tiles and answer cards, Froschsprung pad and landmark labels, Punkt-zu-Punkt dot labels (all steps), numerals on friend and sticker detail views
child.numeralSmall 28 pt 30 pt 40 pt bold Star counter, prices in S-12, "+7" star gains only; never task content
child.numeralBadge 17 pt 17 pt 22 pt bold Friend-requirement badges in S-12 only; never task content
child.caption 15 pt 15 pt 17 pt medium Adult-facing captions (nickname under avatar)

All numerals use .monospacedDigit() so two-digit numbers keep stable widths during count-up animations. Minimum size of every task-content numeral: 44 pt on iPhone (tiers S and M; tier M uses 48 pt), 64 pt on iPad (tier L); child.numeralSmall and child.numeralBadge exist only for star counts, prices and badges, which the child never has to read to solve a task. Where a game fades a numeral (for example the labels of already-connected Punkt-zu-Punkt dots at 40 % opacity, Section 13.4), the size stays child.numeral.

Decision: display numerals use SF Pro Rounded glyphs; Nachspuren shows its own German print model glyphs (Section 12). Small glyph differences (for example the open or closed top of the 4) are accepted; child testing (Section 25) checks recognition across both, and Section 28.2 lists this as decision RV-29 to revisit.

19.3.3 Parent type scale (Dynamic Type) #

Role Text style Weight Notes
Screen title .largeTitle (navigation large title) bold S-18 only; pushed screens use inline titles
Section header .title3 semibold card headers
Card metric (e.g. "12 / 20") .title2 + .monospacedDigit() bold
Body .body regular
Secondary .subheadline regular .secondary colour
Fine print (paywall disclosure) .footnote regular never smaller; contrast ≥ 4.5:1
Grid cell labels (S-19) .caption + .monospacedDigit() semibold

The parent area supports all Dynamic Type sizes up to AX5 (Section 19.13).

19.4 Spacing, Radii and Grid #

Token Value Use
space.xs 4 pt icon-to-label
space.s 8 pt inside components
space.m 12 pt minimum spacing between child touch targets
space.l 16 pt screen margin S and M tiers; card padding
space.xl 24 pt between groups
space.xxl 32 pt screen margin L tier
space.xxxl 48 pt hero separation on L
radius.tile 16 / 18 / 24 pt (S/M/L) numeral tiles, game tiles
radius.card 24 pt child overlay cards
radius.parentCard 12 pt parent cards
Screen margins S: 16 pt, M: 16 pt, L: 32 pt, always added to the safe-area insets
Content max width child overlay cards 640 pt; parent content column 560 pt (forms) / 700 pt (S-18 two-column breakpoint)

Spacing is a 4-pt grid. All child layout values are multiples of 4 pt except computed component sizes (tiles, beads), which are rounded to whole points.

19.5 Layout Classes and Layout Matrix #

19.5.1 Layout class definition #

public enum LayoutClass: Sendable, Equatable {
    case compactPortrait, compactLandscape, regularPortrait, regularLandscape

    public init(windowSize s: CGSize) {
        let regular = min(s.width, s.height) >= 600
        let portrait = s.height >= s.width
        switch (regular, portrait) {
        case (false, true):  self = .compactPortrait
        case (false, false): self = .compactLandscape
        case (true, true):   self = .regularPortrait
        case (true, false):  self = .regularLandscape
        }
    }
}

public enum SizeTier: Sendable { case s, m, l
    public init(windowSize s: CGSize) {
        let shortSide = min(s.width, s.height)
        self = shortSide < 400 ? .s : (shortSide < 600 ? .m : .l)
    }
}

The root view measures the window with a GeometryReader and injects LayoutClass and SizeTier into the environment (\.zkLayoutClass, \.zkSizeTier). Horizontal and vertical size classes are not used for child layouts because iPad multitasking windows need size-based decisions.

Reference window Size (pt) Class Tier
iPhone SE portrait 375 × 667 CP S
iPhone SE landscape 667 × 375 CL S
Standard iPhone portrait (e.g. 393 × 852) 393 × 852 CP S
Pro Max iPhone landscape (e.g. 932 × 430) 932 × 430 CL M
iPad mini portrait 744 × 1133 RP L
iPad 11" landscape 1180 × 820 RL L
iPad Pro 13" landscape 1376 × 1032 RL L
iPad Split View half (portrait-shaped window) ≈ 500–700 × 1024 CP or RP M or L

Minimum window: the app is designed for windows ≥ 375 pt wide (Section 5). If an iPad multitasking configuration produces a narrower window, the child area renders the CP layout scaled uniformly by width / 375; this is the only case in which child touch targets may fall below 60 pt, and it is accepted because children do not normally use narrow multitasking windows.

19.5.2 Layout matrix per screen type #

Screen type CP (iPhone SE portrait) CL (iPhone SE landscape) RP (iPad portrait) RL (iPad landscape)
Child hub (S-05) Top bar 72 pt; hero at 38 % height; 4-button row at bottom Top bar 64 pt; hero left half; 2×2 buttons right half Top bar 96 pt; hero centre; button row below Hero left third; row of 4 in right two-thirds
Child grids (S-06, S-12, S-13) 3 / 3 / 5 columns 6 / 5 / 5 columns 4 / 4 / 10 columns 6 / 6 / 10 columns
Game container (S-07) Top bar 72 pt; task area 55 % of the remaining height; answer area 45 % below Top bar 64 pt; task area left 60 % width; answer area right 40 % Top bar 96 pt; task 60 % height; answer 40 % Top bar 96 pt; task left 62 %; answer right 38 %
Answer options (inside answer area) ≤ 4 options in one row; 5–6 options in 2 rows of 3 2 columns, up to 3 rows ≤ 5 in one row; 6 in 2 rows 2 columns up to 3 rows; ≤ 3 options as one column
Garden (S-11) Top bar 60 pt; 6 × 4 grid of contiguous 60 pt slots (360 × 240 pt, 7.5 pt side margins); Freundeshügel band (108 pt) below the grid; inventory tray strip (84 pt, items 60 pt) at the bottom Top bar 56 pt; tray as a vertical strip (88 pt) at the left edge; 6 × 4 grid of contiguous 60 pt slots (360 × 240 pt) in the centre; Freundeshügel column (108 pt) at the right edge 6 × 4 grid scaled (slots up to 140 pt, 12 pt spacing when the width allows), Freundeshügel band below (two rows of five), tray strip at the bottom Same as RP
Album (S-14) Page fitted to width, page arrows overlap page edges Page fitted to height, arrows outside page Page fitted, arrows outside Page fitted, arrows outside
Child overlays (S-08, S-15, S-26, S-27) Card full width minus margins; buttons below illustration Illustration left, buttons right Card max 640 pt Card max 640 pt, side-by-side
Parent lists/forms (S-18 to S-25) Single column Single column Column max 560–700 pt centred; S-18 two columns at ≥ 700 pt width Same
Gate (S-17) Question above keypad Question left, keypad right Question above keypad, keypad 80 pt keys Question left, keypad right

Section 10 owns the meaning of the game-container slots; this matrix is the only owner of their geometry (Section 10 states no pixel heights). The container's two layout modes map to the matrix: .sideBySide (task left, answer right) in CL, in any window whose height is < 480 pt (every phone in landscape), and in RL; .stacked (task above answer) in CP and RP. There is no stacked layout on phone landscape, because the remaining task height would be too small for a Zwanzigerfeld or counting objects.

19.6 Touch Targets #

Area Rule
Child area Every interactive element ≥ 60 × 60 pt hit area (typical 72 pt on tier L); ≥ 12 pt spacing between the hit areas of different targets. Visual size may be smaller only for the grown-up icon (40 pt visual in a 60 pt hit area, intentionally inconspicuous). There is no exception to the 60 pt size in the child area.
Parent area Apple HIG minimum 44 × 44 pt.
Contiguous-grid exception: garden slots (S-11) On iPhone the 6 × 4 grid uses contiguous slots ≥ 60 × 60 pt without spacing (drag-and-drop grid, Section 14.4.2); on larger windows a 12 pt spacing is used whenever the width allows it. Only the 12 pt spacing rule is waived; the 60 pt size is not.
Interactive Zwanzigerfeld and bead chains Must meet the 60 pt rule in every window: in a window narrower than 714 pt an interactive Zwanzigerfeld uses the compact arrangement, and in a window narrower than 820 pt an interactive bead chain uses the compact arrangement (rows of five, 1.5× gap between the tens; Section 19.7.2, Section 19.7.3, Section 4). Display-only fields and chains keep the wide arrangement and scale down.
Parent-area exception: S-19 grid Cells ≥ 44 pt tall; when width would be < 44 pt the grid scrolls horizontally (Section 18.3.19).

Hit areas are implemented with .contentShape(Rectangle()) on a frame of the required size; visual padding never reduces the hit area.

Verification: UI test testChildTouchTargetsAtLeast60pt (Section 25) iterates every hittable element whose accessibility identifier belongs to a child screen (prefixes S04. to S16., S26. and S27., format of Section 6.10), on every child screen in CP and CL (iPhone SE simulator), and asserts frame.width ≥ 60 && frame.height ≥ 60 without exception. A second assertion checks that no two such frames are closer than 12 pt, except pairs of S11.decorationSlot.* (contiguous garden grid).

19.7 Components #

All components are SwiftUI views in ZKDesignSystem, public, @MainActor, with previews and a DEBUG-only catalog screen (DesignSystemCatalogView) reachable from the developer menu (Section 5).

19.7.1 Beads #

public enum BeadStyle: Sendable, Equatable {
    case solid      // red, first five of each ten
    case hole       // blue "Lochperle", second five of each ten

    /// Style is derived from the ABSOLUTE number (1...), never from the index in a visible window.
    public static func forNumber(_ n: Int) -> BeadStyle { ((n - 1) / 5) % 2 == 0 ? .solid : .hole }
}

public enum BeadState: Sendable, Equatable { case normal, highlighted, dimmed, ghost }

public struct BeadView: View {
    public init(style: BeadStyle, diameter: CGFloat, state: BeadState = .normal)
}

Rendering (diameter d):

Part Solid (red) Lochperle (blue)
Body circle, fill bead.red circle, fill bead.blue
Outline 1 pt bead.redOutline 1 pt bead.blueOutline
Centre nothing (no highlight, no gloss — the centre of a red bead is always plain) white hole: circle of diameter 0.36 d, fill bead.hole, with an inner rim stroke 0.04 d in bead.blueOutline
Shadow drop shadow: ink 18 % opacity, y offset 0.06 d, blur radius 0.08 d same
Minimum d 12 pt (display-only chains of 20 on narrow windows); 8 pt for belly beads on Zahlenfreunde and friend stickers 12 pt, the hole always ≥ 4.5 pt; belly beads on friends and friend stickers 8 pt with a hole ≥ 3 pt
Maximum d 56 pt 56 pt

States: highlighted = 3 pt star.yellow ring outside the outline (hint); dimmed = 35 % opacity (beads not part of the current quantity); ghost = gap placeholder: dashed 2 pt neutral.tryAgain circle, no fill, no shadow (Was fehlt?).

Decision: beads have no specular highlight, because a highlight near the centre could be confused with the Lochperle hole by colour-blind children.

Friend bodies (Section 14.5.1 owns the bead-belly rules): in every rendering (garden, gallery, S-27, stickers) a belly bead has a diameter ≥ 8 pt and a Lochperle hole ≥ 3 pt, so the solid/hole distinction survives at every size; these are the only bead sizes below the 12 pt minimum above. Where a friend is drawn too small for rows of ten at that bead size, the belly uses the compact arrangement: each row holds exactly one five-group, with a 1.5× gap between the first ten and the second ten (the same rule as the compact Zwanzigerfeld, Section 19.7.3). Every friend is drawn at ≥ 60 pt; friends 11–20 are drawn at ≥ 96 pt on the Freundeshügel and on S-27 (two-row belly), and use the compact belly in 60–72 pt renderings (S-13 places, sticker art).

19.7.2 Bead chain #

/// Shared by BeadChainView and ZwanzigerfeldView.
public enum GroupArrangement: Sendable, Equatable {
    case wide      // chain: one row (wraps only at a five-group boundary when the row does not fit);
                   // field: 2 rows × 10 (one row for r5/r10)
    case compact   // rows of exactly one five-group each; 1.5× gap between the two tens

    /// Interactive fields use .compact below 714 pt window width, interactive chains below 820 pt;
    /// display-only fields and chains are always .wide and scale down.
    public static func forField(windowWidth: CGFloat, interactive: Bool) -> GroupArrangement {
        interactive && windowWidth < 714 ? .compact : .wide
    }
    public static func forChain(windowWidth: CGFloat, interactive: Bool) -> GroupArrangement {
        interactive && windowWidth < 820 ? .compact : .wide
    }
}

public struct BeadChainView: View {
    public init(numbers: ClosedRange<Int>,              // absolute numbers shown, e.g. 8...14
                gaps: Set<Int> = [],                     // numbers rendered as ghost beads
                highlighted: Set<Int> = [],
                dimmed: Set<Int> = [],
                showsString: Bool = true,
                axis: Axis = .horizontal,
                arrangement: GroupArrangement = .wide,
                countingDirection: CountingDirection = .forward)  // .backward adds a right-to-left arrow
}

public enum CountingDirection: Sendable, Equatable { case forward, backward }

Geometry: "bead spacing" means the centre-to-centre pitch. Within a group of five the pitch is p = 1.2 d (edge gap 0.2 d). Between two groups of five (between 5|6, 10|11, 15|16) the pitch is 1.5 × p = 1.8 d (edge gap 0.8 d). Total length for n beads with k group boundaries: L = d × (1 + 1.2 (n − 1) + 0.6 k); for 20 beads L = 25.6 d, for 10 beads L = 12.4 d. The view computes d = min(56, availableLength / (L/d)), clamped at 12 pt. The string is a 2 pt line in ink at 30 % opacity through the bead centres, drawn behind the beads and not visible through the hole. Vertical axis is used only when a game requires it (Sections 11–13); grouping rules are identical.

Direction: a chain is always drawn in increasing order from left to right and is never mirrored (Section 4, DR-22). Counting backwards is shown by an arrow pictogram pointing right to left above the chain (countingDirection: .backward); a backward segment such as 15 down to 6 is displayed as 6 … 15 with the arrow. Compact arrangement: each row holds exactly one five-group (rows start at 1, 6, 11, 16 in absolute numbers), rows are separated by 0.4 d, and the boundary between the two tens (after 10) gets a 1.5× gap; bead diameters in interactive compact chains keep a hit area ≥ 60 pt per bead.

19.7.3 Zwanzigerfeld #

public struct ZwanzigerfeldView: View {
    public init(filled: Set<Int>,                       // cell numbers 1...20 that hold a counter
                highlighted: Set<Int> = [],
                cellCount: Int = 20,                     // 5, 10 or 20 (r5, r10, r20)
                arrangement: GroupArrangement = .wide,   // Section 19.7.2; .compact for interactive fields < 714 pt
                interactive: Bool = false,
                gameID: GameID? = nil,                   // for accessibility identifiers of interactive cells
                onCellTap: ((Int) -> Void)? = nil)
}
Aspect Specification
Structure (.wide) 2 rows × 10 cells (one row for cellCount 5 or 10). Row 1 = cells 1–10, row 2 = cells 11–20, left to right. Each row is split 5 | 5 by a vertical gap of 0.5 c (c = cell side). Rows touch (no gap between rows). Used for every display-only field in every window and for interactive fields in windows ≥ 714 pt wide.
Structure (.compact) Rows of five: cells 1–5, 6–10 (r10: 2 rows), then a vertical gap of 1.5× the row pitch, then 11–15, 16–20 (r20: 4 rows). Fill order and colours are unchanged (1–5 and 11–15 solid red, 6–10 and 16–20 Lochperle). Used for interactive fields in windows < 714 pt wide (every iPhone portrait window, Section 11.2.4). Cells ≥ 60 pt.
Size .wide: field width = 10.5 c; c = availableWidth / 10.5, max 96 pt. .compact: field width = 5 c, c = min(availableWidth / 5, availableHeight / (rows + 0.5)), at least 60 pt.
Cells Squares with 1.5 pt line.field inner lines; outer border 2.5 pt ink at 60 %; cell fill surface.white.
Counters Disc of diameter 0.8 c centred in the cell. Cells 1–5 and 11–15: solid red bead style; cells 6–10 and 16–20: Lochperle style (BeadView rules, same hole ratio).
Empty cell Outline circle 0.8 c, 2 pt neutral.tryAgain.
Highlight 3 pt star.yellow ring around the counter or empty circle.
Fill order Always row 1 left to right, then row 2 left to right (.compact: row by row, top to bottom, each row left to right). A quantity of 7 is 5 red + 2 blue in row 1 (Kraft der Fünf, Section 4); 17 is a full row 1 plus 5 red + 2 blue in row 2.
Interaction Every interactive cell has a hit area ≥ 60 × 60 pt (Section 19.6; no exception). Accessibility identifier S07.<gameId>.fieldCell.<n> (Section 6.10).

19.7.4 Dice patterns #

public struct DiceView: View { public init(value: Int /* 1...6 */, side: CGFloat) }

Face: square, corner radius 0.2 × side, fill surface.white, 2 pt ink 70 % border, shadow as beads. Pips: diameter 0.18 × side, fill ink (never red or blue, so dice never compete with the five-structure colours). Pip centres on a 3 × 3 grid at 0.25, 0.5, 0.75 of the side: 1 = centre; 2 = top-right, bottom-left; 3 = 2 + centre; 4 = four corners; 5 = 4 + centre; 6 = two columns of three (left and right columns). Quantities above 6 use two dice (Section 12 owns which splits are used). Minimum side 56 pt.

19.7.5 Finger patterns #

public enum SkinTone: Int, CaseIterable, Sendable { case tone1, tone2, tone3, tone4, tone5 }
public struct FingerPatternView: View {
    public init(count: Int /* 0...10 */, skinTone: SkinTone, height: CGFloat)
}
Aspect Specification
Convention German counting convention: counting starts with the thumb. 1 = thumb; 2 = thumb + index; 3 = + middle; 4 = + ring; 5 = whole hand. 6–10 = one whole hand plus the second hand counted from its thumb.
Hand orientation The hands are drawn as the child sees their own raised hands: backs of the hands facing the viewer, fingers up. The first (full) hand is the left hand, drawn on the viewer's left; the second hand is the right hand, drawn on the right, separated by a gap of 0.25 × hand width.
Folded fingers Drawn folded (visible knuckle shapes), never missing.
Skin tones Five tones: #F6D7C3, #E8B894, #C68E63, #8D5A3B, #5C3A24; outline = the same hue 25 % darker; no nails emphasis, no jewellery, no sleeves beyond the wrist. Both hands in one task use the same tone.
Tone selection Deterministic per round: SkinTone.allCases[roundSeed % 5], so tones rotate across rounds and all children see all tones over time.
Minimum size Hand height ≥ 96 pt (S), 140 pt (L).
Assets 11 vector assets per hand side and tone are not needed: each hand is composed from a palm shape plus 5 finger shapes (extended or folded) tinted by tone.

19.7.6 Numeral tile #

public enum TileState: Sendable, Equatable { case normal, pressed, selected, correct, tryAgain, hint, disabled }

public struct NumeralTile: View {
    public init(number: Int, state: TileState = .normal, accessibilityID: String, action: @escaping () -> Void)
}
Property S M L
Size 72 × 72 pt 80 × 80 pt 104 × 104 pt
Numeral child.numeral (44 pt) 48 pt 64 pt
Radius 16 pt 18 pt 24 pt

Fill surface.white; border 2 pt ink at 12 %; shadow ink 12 %, y 2 pt, blur 4 pt. States: pressed = scale 0.96 for 80 ms; selected = 4 pt ink border; correct = 6 pt success ring + soft green glow (Section 19.9); tryAgain = 4 pt neutral.tryAgain ring + wiggle; hint = 4 pt star.yellow ring, pulses twice; disabled = 40 % opacity. Two-digit numerals fit at the listed sizes (verified in previews for 18, 20).

19.7.7 Buttons and chrome #

Component Token S / M / L size Visual
BigButton hero button.hero 160 (CP) / 120 (CL) / 220 pt circle, illustration inside
BigButton large button.large 120 / 120 / 160 pt circle, surface.white, pictogram ink at 45 % of size
BigButton primary button.primary 96 / 96 / 120 pt same; also the equal-weight pair "Pause"/"Weiterspielen" and the sun button on S-26 (Section 15.9)
BigButton choice button.choice 88 / 88 / 112 pt same; S-27 continue (check mark) and the S-12 buy button
BigButton secondary button.secondary 72 / 72 / 96 pt same
HomeButton button.chrome 60 / 60 / 72 pt circle surface.white, house.fill in ink
SpeakerButton button.chrome 60 / 60 / 72 pt speaker.wave.2.fill; while a line plays, the waves animate with the variable-colour symbol effect (not counted as an ambient loop; stops with the line)
GrownUpButton 40 pt visual, 60 pt hit 40 / 40 / 48 pt visual adult-silhouette pictogram in ink.secondary on white disc; 3 pt ink progress ring during the 2 s hold (Section 18.6)
LockBadge — 28 / 28 / 36 pt white circle, lock.fill in ink at 16 / 16 / 20 pt, shadow; bottom-right of the tile, inset 6 pt
StarCounter — height 48 / 48 / 56 pt capsule surface.white 90 %, star glyph star.yellow 28 / 28 / 32 pt, numeral child.numeralSmall; not interactive; grows horizontally for up to 5 digits
ProgressDots — dot 12 / 12 / 16 pt, spacing 8 / 8 / 10 pt done = star.yellow filled; current = 16 / 16 / 20 pt ink outline 2.5 pt; upcoming = neutral.tryAgain outline 2 pt; not interactive; no numbers
public enum Pictogram: String, Sendable { case home, speaker, play, replay, grid, garden, friends, album, basket, shop, back, close, check, parent, next, moon, sun }

public struct BigButton: View {
    public enum Size: Sendable { case hero, large, primary, choice, secondary, chrome }
    public init(_ pictogram: Pictogram, size: Size, accessibilityLabel: LocalizedStringResource,
                accessibilityID: String, action: @escaping () -> Void)
}
public struct LockBadge: View { public init(isLocked: Bool) }            // animates the unlock when it changes to false
public struct StarCounter: View { public init(count: Int) }
public struct ProgressDots: View { public init(total: Int, completed: Int) }
public struct GrownUpButton: View { public init(holdDuration: Duration = .seconds(2), onUnlock: @escaping () -> Void) }

Pictograms: SF Symbols where suitable (house.fill, speaker.wave.2.fill, play.fill, arrow.counterclockwise, square.grid.2x2.fill, lock.fill, checkmark, xmark, arrow.left, arrow.right, moon.fill, sun.max.fill) rendered at a fixed point size; custom vector pictograms (garden, friends, album, basket, shop stall, adult silhouette) follow the illustration guide. Custom pictograms are drawn on a 24 × 24 grid with 2 pt rounded strokes and exported as template vector assets.

19.8 Illustration Style Guide #

Rule Specification
Style Flat, warm vector illustration; rounded forms; no gradients except one very soft vertical background gradient per scene (max 6 % lightness difference); no photorealism, no 3D rendering.
Outlines Soft outlines 2–3 pt at 1× in a darker shade of the fill colour (never pure black).
Colours per character Maximum 5 fill colours per character (outline shades and eye white/pupil not counted). Characters never use bead.red or bead.blue except for the quantity dots on Zahlenfreunde bodies.
Zahlenfreunde Each friend's body shows its quantity as dots in five-groups using the bead rules (solid red for 1–5 and 11–15, Lochperle blue for 6–10 and 16–20; groups separated; layout on the body follows the Zwanzigerfeld fill order compressed to the body shape). The friend shows its numeral once on the character itself, on a badge, hat, scarf or sign (Section 14.5.1); in task-content use the numeral follows child.numeral sizes (Section 19.3.2). The belly-dot minimum sizes of Section 19.7.1 apply to every rendering.
Friendliness No scary content: no sharp teeth, no weapons, no aggressive expressions, no darkness scenes except the calm S-16 evening. The Zahlenmonster is round, soft, big-eyed, and its mouth is a friendly oval.
Representation Hands for finger patterns in five skin tones (Section 19.7.5). Human figures (only in the grown-up pictogram and help illustrations) are neutral silhouettes. Avatars are animals. No gender stereotypes in colour or activity.
No text in illustrations Illustrations contain no letters or words (pre-readers; localization; app rename safety). Numerals appear only where the content requires them (dot-picture numbers, the friend's numeral badge).
Backgrounds Low saturation and low detail behind the task area; decorative details only at screen edges; no busy patterns.
Assets Delivered as vector PDF or SVG, imported into asset catalogs with "Preserve Vector Data" enabled, single scale. Naming follows Section 6.4.3: flat <category>_<slug>[_<state>], lowercase snake_case, asset-catalog namespaces off (pattern ^[a-z][a-z0-9_]*$, validated per Section 8), e.g. avatar_fuchs, friend_07_idle, deco_tulpe, dotpic_stern_reveal, obj_apfel, game_hoer_hin_icon. Avatars are named by animal slug, never by ordinal. Full asset list: Section 21.13.
Motion-ready Characters that animate (friends, monster, frog) are delivered as layered vectors (body, eyes, limbs separate) so SwiftUI can animate parts without sprite sheets.

19.9 Animation Principles #

Rule Value
Ambient loops Max 1 looping ambient animation per screen (e.g. one friend breathing). Period ≥ 3 s. Stops when a modal overlay appears.
Flashing Forbidden above 3 Hz; additionally, no full-screen or large-area luminance flashes at all. Highlight pulses ≤ 1.5 Hz and ≤ 2 repetitions.
Celebrations ≤ 2.5 s total (round end, befriending, Abenteuer end).
Screen transitions Cross-fade 0.3 s, ease-in-out. No slides, zooms or page curls in child mode.
Tap response Visual response ≤ 100 ms (pressed state 80 ms).
Correct feedback ≤ 0.8 s: green ring grows in (0.25 s spring), soft glow fades (0.55 s).
Try-again feedback 0.4 s horizontal wiggle, amplitude 6 pt, 2 oscillations (2.5 Hz), neutral ring; never red, never a shake of the whole screen.
Hint highlight yellow ring pulse, 2 × 0.6 s.
Star flight Each star 0.6 s along a gentle arc, stars staggered by 80 ms, max 8 stars animated (larger gains animate 8 stars and count the numeral up).
Lock open 0.6 s: symbol replace lock.fill → lock.open.fill, then badge scales to 0 and fades.
Friend hop 0.4 s.
Easing UI motion: .spring(response: 0.35, dampingFraction: 0.8); fades: .easeInOut; overshoot never > 10 %.
Forbidden Camera shake, parallax, auto-scrolling content, particle confetti covering the task area, spinning or strobing elements, animated backgrounds during tasks.

Tokens:

public enum ZKMotion {
    public static let crossFade: Animation = .easeInOut(duration: 0.3)
    public static let press: Animation = .easeOut(duration: 0.08)
    public static let ui: Animation = .spring(response: 0.35, dampingFraction: 0.8)
    public static let correctGrow: Animation = .spring(response: 0.25, dampingFraction: 0.8)
    public static let celebrationMaxSeconds: Double = 2.5
    public static let ambientMinPeriodSeconds: Double = 3.0
}

19.10 Haptics #

Haptics use SwiftUI .sensoryFeedback(_:trigger:) wrapped in the modifier .zkHaptic(_:trigger:), which does nothing when the device setting "Haptik" (Section 16, default on) is off. Devices without haptic hardware (most iPads) ignore the call. Child mode never uses .error or .warning.

Event Feedback Area
Tap on an answer tile, numeral or bead none (no per-tap haptic; the visual pressed state and sfx.tile_select confirm the tap) child
Correct answer (celebration C1) .success child
Try again (wrong attempt) none. Decision: no haptic for errors, so a mistake never feels like a punishment (Section 10.15 applies this map) child
Drag pick-up .impact(weight: .light, intensity: 0.4) child
Drop snaps into a target (games, garden placement) .impact(weight: .light) child
Garden item lifted for moving .impact(weight: .light) child
Bead lands (Schüttelbox) .impact(flexibility: .rigid, intensity: 0.4), throttled to max one per 60 ms child
Round complete (C2), friend befriended (C3), decoration bought (C5) .success once child
Sticker awarded (C4) .impact(weight: .light) once child
Star arrives in the counter or star jar (S-08 star flight, Abenteuer bonus C6) .selection per arriving star, max 8 per celebration child
Lock opens .impact(weight: .medium) child
Grown-up hold completes .impact(weight: .light) child → parent
Gate wrong answer .warning parent
Purchase or restore success .success parent
Destructive action confirmed .warning parent

This table is the single haptics map; Section 10.15 and Section 14.8 use exactly these assignments. Child mode never uses .error or .warning, and never plays a haptic for a wrong answer.

19.11 Reduce Motion #

When accessibilityReduceMotion is true:

Normal Reduced alternative
Star flight along an arc Stars fade into the counter (0.3 s), numeral counts up
Correct: ring spring and glow Ring fades in (0.2 s), no glow
Try-again wiggle Neutral ring fades in and out (0.4 s), no movement
Hint pulse Static yellow ring for 1.2 s
Celebrations (S-08, S-10, S-27) Static composition with cross-fades; duration unchanged or shorter
Friend hop, padlock wiggle, S-26 yawn Pose change with cross-fade
Ambient loops (S-01 beads filling, S-16 breathing, S-05 friend idle) Off (static)
Lock open Badge fades out (0.3 s)
Frog jumps, bead roll, moving counting objects Objects fade out and in at the new position; moving-objects step of Wie viele? uses the static-scattered variant (Section 11)
Screen cross-fades Kept (fades are acceptable under Reduce Motion)

Physics-based motion owned by games (Schüttelbox) defines its own reduced variant in Section 12; the rule is: no continuous large motion, outcome shown with fades.

19.12 VoiceOver Scope #

Decision: the parent area (S-02, S-03, S-17 to S-25) is fully accessible with VoiceOver, Voice Control and Switch Control using standard controls. The child area is designed for sighted pre-readers; it carries complete accessibility labels so that an adult using VoiceOver can operate it for or with a child, but the child flows are not redesigned for non-visual play in V1.

Rules for the child area:

  • Every interactive element has an accessibility label in German, an accessibility identifier in the format of Section 6.10 (<screen>.<element>[.<qualifier>], for example S05.abenteuer, S07.home, S07.<gameId>.fieldCell.7, S11.decorationSlot.3_1), and the button trait. Pure decorations are hidden (.accessibilityHidden(true)).
  • Task visuals expose their meaning: a quantity displays as "sieben Punkte", a bead chain as "Perlenkette von 8 bis 14, Lücke bei 11", a numeral tile as "Sieben", a finger pattern as "sieben Finger", a Zwanzigerfeld as "Zwanzigerfeld mit sieben Plättchen".
  • Spoken instructions still play; when VoiceOver is running, the app's voice line plays first and VoiceOver focus moves to the answer area after the line ends (post .layoutChanged with the first answer element).
  • Press-and-hold gestures have VoiceOver custom actions (grown-up icon: "Elternbereich öffnen").
  • Drag-and-drop tasks expose custom actions (e.g. "In den Mund legen") so a VoiceOver user can complete them.
  • Celebrations post an announcement (e.g. "Plus sieben Sterne").

19.13 Dynamic Type and Other System Settings #

Setting Parent area Child area
Dynamic Type Supported up to AX5. At accessibility sizes, horizontal layouts (plan cards, two-column S-18, metric rows) switch to vertical stacks via dynamicTypeSize.isAccessibilitySize; all screens scroll. Fixed sizes (Section 19.3.2). Child containers apply .dynamicTypeSize(.large) so that system components inside them do not scale.
Bold Text Respected by system text styles. Numerals are bold already; no change.
Increase Contrast Semantic colours adapt automatically; custom tokens provide high-contrast variants equal to their normal values or darker (ink.secondary → ink). neutral.tryAgain outlines become ink at 70 %; line.field becomes ink at 40 %.
Differentiate Without Color Always satisfied (symbols in bands and selection states). Always satisfied by design (Section 19.14).
Reduce Transparency Materials replaced by solid system backgrounds. scrim.child becomes bg.cream at 95 % opacity.
Smart Invert Illustrations, avatars and beads use .accessibilityIgnoresInvertColors(). Same.
Button Shapes System buttons comply. All child buttons are already shaped discs or tiles.
Mono Audio, hearing settings No effect on layout; voice-off visual path (Section 20) applies when sound is off. Same.
Guided Access S-24 Help explains how parents can use Guided Access to keep a child inside the app. Works without special handling.

19.14 Colour-Blind Rules and Verification #

Rules (mandatory everywhere the five-structure appears: beads, chains, Zwanzigerfeld counters, friend bodies, stickers, icons):

  1. The first group of five of each ten is a solid red bead; the second group of five is a blue Lochperle with a white centre hole. Shape differs: plain centre versus white hole.
  2. The two groups also differ by position: they are always separated by the 1.5× group gap (chains) or the 5 | 5 vertical gap (Zwanzigerfeld), and the solid group always comes first (left or top).
  3. Style is derived from the absolute number (Section 19.7.1), so the same number always looks the same everywhere.
  4. Red and blue are never used alone for any other meaning in the child area.
  5. "Correct" is shown by green ring + check-style growth + sound + haptic; "try again" by neutral ring + wiggle + sound. Neither relies on hue alone.
  6. Parent mastery bands use colour + symbol + label (Section 16).

Simulated appearance of the bead colours (Machado et al. 2009 model, full severity), for reference when reviewing assets:

Vision type bead.red appears as bead.blue appears as Distinguishable by hue?
Protanopia olive brown #6F6538 blue #2B77D8 yes (yellow-brown vs blue)
Deuteranopia olive #938535 blue #0068D2 yes
Tritanopia red-pink #ED1644 teal #008495 yes, but equal luminance
Achromatopsia (greyscale) mid grey mid grey (1.16:1) no — shape and position carry the meaning

Verification procedure (executed at milestone reviews and before release, Section 25):

  1. On a device, enable Settings → Accessibility → Display & Text Size → Color Filters, and check every game's step 1–3 screens and S-13, S-11 with each filter: Grayscale, Red/Green (Protanopia), Green/Red (Deuteranopia), Blue/Yellow (Tritanopia).
  2. For each screen, a reviewer answers: "Can I tell the first five from the second five without colour?" Every answer must be yes.
  3. Record screenshots with filters for the release QA checklist.
  4. The DEBUG catalog screen renders all bead components in a greyscale preview (.grayscale(1)) for quick inspection.

19.15 Left-Handed Considerations #

Rule Implementation
Chrome away from the hands Home, speaker, star counter and grown-up icon sit in the top corners/top edge; no controls at the bottom edge corners where either hand rests.
Symmetric answer areas Answer options are centred in their area, never aligned to one side.
Drag visibility A dragged item is rendered 24 pt above the touch point so neither a left nor a right finger covers it; drop targets highlight when the item is within 40 pt.
Landscape answer column In CL and RL the answer area is on the right. A left-handed child's arm may cross the task area while answering; the task area keeps the key visual in its upper two-thirds so it stays visible. Decision: no handedness setting in V1; child testing (Section 25) checks occlusion with left-handed children, and Section 28.2 lists a left-hand layout mirror as decision RV-30 to revisit (default: no).
Tracing Nachspuren guides do not assume a hand; stroke-order arrows and the next-stroke preview are placed per Section 12 so they are not covered for either hand.
Finger patterns Shown in the German convention regardless of the child's handedness (Section 19.7.5).

19.16 Calm-Design Checklist #

Every child screen must pass all items before a milestone is accepted (Section 27):

  • Exactly one task or one clear choice is shown.
  • At most one ambient loop; period ≥ 3 s; stops under overlays and under Reduce Motion.
  • No element flashes; no pulse faster than 1.5 Hz; no more than 2 pulse repetitions.
  • Celebration ≤ 2.5 s; no confetti over the task area.
  • No red used for errors; try-again is neutral and friendly.
  • No timer, countdown, progress bar that implies speed, or "hurry" cue (Blitzblick's flash is a display time, not a response timer, Section 12).
  • No streak counter, no leaderboard, no random reward, no scarcity cue, no price, no store wording.
  • Every control is a picture or a numeral, ≥ 60 pt, ≥ 12 pt apart (exceptions per Section 19.6 only).
  • A picture-only way back to S-05 exists (Section 18.1.4).
  • The screen works with sound off (visual path, Section 20) and with Reduce Motion on.
  • Red and blue beads differ by shape and position (Section 19.14) and survive the greyscale filter.
  • Background behind the task area is low-detail; decorations stay at the edges.
  • Voice lines do not overlap; music is ducked under voice (Section 20).
  • Layout verified in CP (iPhone SE 375 × 667), CL (667 × 375), RP and RL (iPad Pro 13" landscape) with no clipping or overlap.

19.17 Design-System Testing #

Test Type Assertion
ContrastTests Swift Testing All contrast rows of Section 19.2.6 meet their thresholds using asset-catalog values.
BeadStyleTests Swift Testing BeadStyle.forNumber returns solid for 1–5, 11–15 and hole for 6–10, 16–20; chain length formula yields 25.6 d for 20 beads and 12.4 d for 10 beads.
LayoutClassTests Swift Testing Reference windows of Section 19.5.1 map to the listed class and tier.
TypographyTokenTests Swift Testing child.numeral is ≥ 44 pt in tiers S and M and ≥ 64 pt in tier L; child.numeralSmall and child.numeralBadge are not used by any task-content component (checked on the component catalog).
GroupArrangementTests Swift Testing forField returns .compact for interactive fields below 714 pt and .wide otherwise; forChain switches at 820 pt; display-only always .wide; compact cells ≥ 60 pt at 375 pt window width.
testChildTouchTargetsAtLeast60pt XCUITest, iPhone SE simulator, portrait and landscape Section 19.6 (identifiers per Section 6.10).
testParentAreaAccessibilityAX5 XCUITest with the largest accessibility text size S-18, S-22, S-17 have no truncated primary controls and are scrollable.
Manual colour-filter review Manual Section 19.14 procedure.
Manual Reduce Motion review Manual Every row of Section 19.11 observed on device.

20. Audio, Voice and Localization #

This section covers audio behaviour (module ZKAudio), voice recording and delivery, the sound-off visual path, the TTS fallback, the localization architecture, English readiness and the central app-name mechanism. The actual list of lines, sounds, music loops and assets is in Section 21. The content file schemas that reference audio IDs (prompts.json, games/<gameId>.json, numbers.json) are owned by Section 8. When each line is spoken during a round (prompt, hint ladder, feedback, solution) is owned by Section 10. This section owns how a line is resolved, composed, scheduled, mixed, interrupted and localized.

20.1 Principles #

# Principle Consequence
A1 The child cannot read. The voice is the main instruction channel. Voice plays even when the Ring/Silent switch is set to silent (Section 20.3). Every spoken instruction also has a visual equivalent (Section 20.13).
A2 All voice is professionally recorded German. TTS is only a fallback. TTS never speaks a number word in a Release build (Section 20.14). Content validation fails if any number-word file is missing.
A3 Audio is calm. Only one voice line plays at a time. Music is low (30 %) and ducks under the voice. There are no buzzers and no alarm-like sounds.
A4 Audio is fast. A tap produces sound in under 100 ms (Section 20.11).
A5 Audio never identifies anyone. No line speaks the child's name, a nickname or the app name (Section 20.19).
A6 Audio is data-driven. Games reference audio IDs and utterance templates, never file names or literal text. Adding a line needs no code (Section 8).

20.2 Channels, settings and gains #

20.2.1 Channels #

Channel Content Audio ID prefixes Playback technology Locale-dependent
Voice Number words, carrier fragments, prompts, hints, feedback, solutions, picture names, session lines, friend lines, TTS fallback num., prompt., hint., fb., label., session., friend. AVAudioEngine + one AVAudioPlayerNode Yes: Resources/Audio/<locale>/
SFX Short non-verbal sounds sfx. AVAudioEngine + pool of 4 AVAudioPlayerNodes No: Resources/Audio/common/
Music Calm background loops music. AVAudioPlayer with numberOfLoops = -1 No: Resources/Audio/common/

20.2.2 Settings #

The three channel toggles are device-wide, not per child profile. They are shown in the parent area under "Geräteeinstellungen" (Section 16). They are persisted as described in Section 7. ZKAudio never reads the settings store itself: the app target passes the current values to the service with update(settings:) at child-mode activation and on every change (Section 5 dependency rules).

Setting German label (functional; final copy in Section 22) Default Effect when off
voiceEnabled "Sprache" with the subtitle "Gesprochene Anleitungen und Zahlen" On No voice line of any kind is played. TTS is not used either. Games switch to the sound-off visual path (Section 20.13).
sfxEnabled "Soundeffekte" On No SFX is played. Visual feedback is unchanged.
musicEnabled "Musik" On No music is played.

Rules:

  • The subtitle is required because "Sprache" also means "language" in German. The toggle does not change the app language.
  • A change takes effect immediately:
    • Voice off: a playing line fades out over 40 ms and the voice queue is cleared.
    • SFX off: all SFX nodes stop.
    • Music off: music fades out over 500 ms and then stops.
    • Voice/SFX/music on: nothing is replayed retroactively. The next request plays normally. Music starts with a 1.5 s fade-in if the current screen has a music assignment (Section 20.8.2).
  • The haptics toggle is independent and owned by Section 19.
  • There is no volume slider in V1. The system volume is the only volume control. Decision: fixed channel gains as in the table below.

20.2.3 Gains and mix #

Parameter Value
Voice mixer gain 1.0
SFX mixer gain 0.6
Music baseline gain (AVAudioPlayer.volume) 0.30
Music gain while voice is active (ducked) 0.10
Voice stop fade (any interruption of a voice line) 40 ms linear
Music crossfade between loops 800 ms
Music fade-in on screen entry 1.5 s
Music fade-out on leaving child mode or on toggle off 500 ms

The source files are mastered as specified in Sections 20.15 and 20.16. With those masters and the gains above, the voice sits roughly 12 LU above the music and 4–6 LU above SFX.

20.3 Audio session configuration #

20.3.1 Decision: category .playback, mode .default, no options #

try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setPreferredIOBufferDuration(0.010)

Rationale:

  • The Ring/Silent switch (and the Silent mode of the Action button) silences only apps whose category is .ambient or .soloAmbient. It does not silence .playback. Many parents keep their phone on silent. With .ambient the app would be silent and unusable for a pre-reader, and a 3-year-old cannot diagnose why. Principle A1 decides this.
  • No .mixWithOthers. A child session is a focused activity. Other apps' audio (for example the parent's podcast) would compete with spoken number words that the child must hear clearly. Starting the voice therefore interrupts other audio, as in any media app.
  • No .duckOthers. It is not needed because the session is non-mixable.
  • The mode is .default, not .spokenAudio. .spokenAudio changes how the system treats the app relative to other spoken-audio apps (it pauses rather than ducks). This is not relevant for short instructional lines.
  • No UIBackgroundModes audio entry. The app never plays audio in the background.
  • The system volume buttons control the app volume as usual. The app never changes the system volume.

Executor verification: on a physical iPhone with the silent switch on, confirm that voice, SFX and music are audible. With a podcast playing in another app, confirm that it stops when child mode starts and can resume after the app goes to the background (Section 20.3.2).

20.3.2 Session lifecycle #

Moment Action
App launch Configure the category (20.3.1). Do not activate yet. Launching into the parent-facing first-launch flow (S-02, S-03) or the parental gate must not interrupt the parent's other audio. S-02 plays no audio.
"Ton testen" on S-03 (Section 15.4.1) Exception: setActive(true), start the engine, play num.5 on the voice channel, then stop the engine and setActive(false, options: .notifyOthersOnDeactivation). This is the only audio before child mode.
First entry into any child-mode screen (S-04 and later) setActive(true), start AVAudioEngine, start music per screen (20.8.2).
Child mode → parent area (S-17 to S-25) Music fades out over 500 ms. Voice is stopped. The session stays active and the engine keeps running (reactivating it on return would cause audible churn). No audio plays in the parent area.
Parent area → child mode Music resumes with a fade-in.
Scene phase .background Stop all channels immediately (no fades, the process may suspend). Stop the engine. setActive(false, options: .notifyOthersOnDeactivation) so other apps may resume.
Scene phase .active again, child-mode screen on top setActive(true), restart the engine, restart music. The current task prompt is not replayed automatically. Section 10.12.3 decides whether a pause overlay is shown (an absence shorter than 3 s resumes automatically) and replays the prompt when the child continues.
setActive(true) throws (for example, another app holds a non-mixable session during a call) Log an error (category audio). Mark the service .unavailable. Every speak returns .failed immediately. Games continue on the visual path. Retry activation on the next scene activation and on interruptionEnded.

20.4 Engine architecture #

20.4.1 Graph #

voicePlayer (AVAudioPlayerNode) ──► voiceMixer (AVAudioMixerNode, gain 1.0) ──┐
sfxPlayer[0..3] (AVAudioPlayerNode) ─► sfxMixer (AVAudioMixerNode, gain 0.6) ──┼──► mainMixerNode ──► outputNode
                                                                               │
musicPlayer (AVAudioPlayer, separate from the engine, same session) ───────────┘ (mixed by the system)
  • Players are connected with the clip processing format: 44.1 kHz, mono, Float32, non-interleaved (AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)). The mixer converts to the hardware rate. The app does not set a preferred sample rate.
  • The engine runs continuously while child mode is in the foreground. This avoids start-up latency on the first tap.

20.4.2 Concurrency (Swift 6 strict mode) #

  • The public API is the @MainActor protocol AudioService; the production implementation is LiveAudioService, a @MainActor final class. Games and the framework call it from the main actor (Section 5 concurrency model).
  • All AVFoundation objects (AVAudioEngine, nodes, AVAudioPCMBuffer, AVAudioPlayer, AVSpeechSynthesizer) are confined to one internal actor AudioEngineCore. None of these non-Sendable objects ever crosses an isolation boundary. The main actor sends only Sendable value commands (Utterance, AudioID, AudioSettings) and receives Sendable results (VoiceCompletion, AudioEvent).
  • Completion handlers of scheduleBuffer(_:completionCallbackType:completionHandler:) run on an internal audio thread. They capture only the ticket ID (a UUID) and hop back into the actor with Task { await core.segmentFinished(ticketID) }. The actor ignores callbacks for tickets that are no longer current.
  • Notification observers (AVAudioSession.interruptionNotification, .routeChangeNotification, .mediaServicesWereResetNotification, AVAudioEngineConfigurationChange) are registered with NotificationCenter.addObserver(forName:object:queue:using:). The closure extracts the Sendable raw values it needs (interruption type UInt, route change reason UInt, shouldResume flag) and forwards only those values to the actor.
  • Decoding (AVAudioFile → AVAudioPCMBuffer) runs inside the actor, so it never blocks the main thread. Decoding a 3 s clip takes a few milliseconds on an iPhone SE (2nd gen), which is acceptable inside the actor. The latency budget in Section 20.11 must still be verified on that device.
  • Tap path without await: playSFX(_:) and the scheduling of a resident clip (number words, fb.*, SFX) go through a small player facade inside LiveAudioService. The facade holds the already-running player nodes and the resident buffers behind an OSAllocatedUnfairLock and calls scheduleBuffer/play synchronously from the main actor. Only the facade touches the nodes for these calls; the actor still owns decoding, engine start and stop, graph changes and all non-resident clips. The lock is held only for the scheduling call (no allocation, no I/O inside it). Section 24.6.2 relies on this: one synchronous call from the gesture to scheduleBuffer, no await.

20.5 Swift API #

AudioID lives in module ZKCore, because ZKCore domain types (NumberFact) and ZKContent reference it and neither may import ZKAudio (Section 5.3.2). Every other type below lives in module ZKAudio, which depends on ZKCore and ZKContent (Section 5).

// ZKCore/Audio/AudioID.swift
import Foundation

/// Audio ID per the convention in Section 8.3 (lowercase, dot-separated), e.g. "num.7", "prompt.hoer_hin.intro".
public struct AudioID: RawRepresentable, Hashable, Sendable, Codable, ExpressibleByStringLiteral, CustomStringConvertible {
    public let rawValue: String
    public init(rawValue: String)
    public init(stringLiteral value: String)
    public var description: String { rawValue }

    /// `num.<n>` or `num.<n>.q`. Returns nil outside 0...20, and for `.question` outside 1...20.
    public static func number(_ n: Int, _ intonation: NumberIntonation = .statement) -> AudioID?

    public var category: AudioCategory { get }            // derived from the prefix
    public var isLocaleIndependent: Bool { get }          // true for sfx.* and music.*
}

public enum NumberIntonation: Sendable, Hashable { case statement, question }

/// Mirrors the voice-line categories of `prompts.json` (Section 8.7) plus the two non-voice channels.
public enum AudioCategory: String, Sendable, CaseIterable {
    case number              // num.
    case carrier             // prompt.common.
    case prompt              // prompt.<gameId>.
    case hint                // hint.
    case feedbackCorrect     // fb.correct.
    case feedbackTryAgain    // fb.tryagain.
    case feedbackSolution    // fb.solution.
    case label               // label.
    case session             // session.
    case friend              // friend.
    case sfx                 // sfx.
    case music               // music.
}
// ZKAudio
import Foundation
import os
import ZKCore
import ZKContent

public enum AudioChannel: String, Sendable, CaseIterable { case voice, sfx, music }

public struct AudioSettings: Equatable, Sendable, Codable {
    public var voiceEnabled: Bool = true
    public var sfxEnabled: Bool = true
    public var musicEnabled: Bool = true
    public init(voiceEnabled: Bool = true, sfxEnabled: Bool = true, musicEnabled: Bool = true)
}

// MARK: - Utterances

/// One element of a voice utterance after template expansion.
public enum UtteranceSegment: Sendable, Hashable {
    case clip(AudioID)              // a recorded voice file (number words included)
    case pause(Duration)            // explicit silence
}

/// A fully resolved sequence of voice segments. Built by `UtteranceBuilder`.
public struct Utterance: Sendable, Hashable {
    public var segments: [UtteranceSegment]
    public var sourceTemplateID: String?        // for logging and developer tools
    public init(segments: [UtteranceSegment], sourceTemplateID: String? = nil)
    public static func clip(_ id: AudioID) -> Utterance
}

public struct UtteranceBindings: Sendable, Hashable {
    public var numbers: [String: Int]           // slot name -> value, e.g. ["n": 7] or ["a": 3, "b": 4, "n": 7]
    public init(numbers: [String: Int] = [:])
}

public enum TemplateError: Error, Sendable, Equatable {
    case unknownTemplate(String)
    case unboundSlot(String)
    case numberOutOfRange(slot: String, value: Int)
    case malformedToken(String)
}

/// Expands templates (Section 20.6) into utterances. Pure and deterministic.
public struct UtteranceBuilder: Sendable {
    public init(templates: [String: UtteranceTemplate], numbers: NumberFactsTable, timing: UtteranceTiming = .default)
    public func build(_ templateID: String, _ bindings: UtteranceBindings) throws(TemplateError) -> Utterance
    /// Every AudioID the template can reference for the given bindings (used for preloading).
    public func referencedIDs(_ templateID: String, _ bindings: UtteranceBindings) throws(TemplateError) -> Set<AudioID>
}

public struct UtteranceTiming: Sendable, Hashable {
    public var joinGap: Duration            // default .zero
    public var sentenceGap: Duration        // default .milliseconds(250)
    public var countStepVorschule: Duration // default .milliseconds(700), onset to onset
    public var countStepLittleOnes: Duration// default .milliseconds(800), onset to onset
    public static let `default`: UtteranceTiming
}

// MARK: - Arbitration

public enum VoicePriority: Int, Comparable, Sendable {
    case ambient = 0      // friend taps, hub chatter: plays only when idle
    case feedback = 1     // correct / try-again / count-along triggered by the child's action
    case instruction = 2  // intro, task prompt, replay, hint, solution, re-prompt
    case system = 3       // time warning, time's up, break nudge
    public static func < (l: Self, r: Self) -> Bool { l.rawValue < r.rawValue }
}

public enum VoiceCompletion: Sendable, Equatable {
    case finished        // all segments played back
    case interrupted     // stopped by a newer request, stopVoice(), an interruption or a route loss
    case dropped         // never started (ambient while busy, or superseded while queued)
    case suppressed      // voice is off in the settings; returned immediately
    case failed          // session unavailable or every segment unresolvable
}

public struct VoiceTicket: Sendable, Hashable {
    public let id: UUID
    public let priority: VoicePriority
}

public enum AudioEvent: Sendable, Equatable {
    case voiceStarted(VoiceTicket)
    case voiceEnded(VoiceTicket, VoiceCompletion)
    case interruptionBegan
    case interruptionEnded(shouldResume: Bool)
    case outputRouteLost                      // headphones unplugged / Bluetooth disconnected
    case engineRestarted
    case lowSystemVolume                      // output volume < 0.05 when a round starts (Section 20.13.4)
    case ttsFallbackUsed(AudioID)
    case clipMissing(AudioID)
}

// MARK: - Service

@MainActor
public protocol AudioService: AnyObject {
    var settings: AudioSettings { get }
    var isVoiceBusy: Bool { get }                       // playing or queued
    var audioLocale: String { get }                     // "de" in V1

    func activate() async                               // 20.3.2
    func deactivate() async
    func update(settings: AudioSettings)

    func preload(_ ids: Set<AudioID>) async             // resolves when decoded or timed out (20.10)
    func releaseRoundCache()
    func purgeCaches(keeping ids: Set<AudioID>)         // drops every non-resident buffer except `ids` (memory warning, Section 20.10)

    @discardableResult
    func speak(_ utterance: Utterance, priority: VoicePriority) -> VoiceTicket
    func completion(of ticket: VoiceTicket) async -> VoiceCompletion
    func stopVoice()                                    // 40 ms fade, clears the queue

    func playSFX(_ id: AudioID)                         // ids of another category are ignored and logged at `error`
    func setMusic(_ id: AudioID?)                       // nil = no music; crossfade 800 ms

    func estimatedDuration(of utterance: Utterance) -> Duration  // sum of clip durations + pauses
    func makeEventStream() -> AsyncStream<AudioEvent>   // one stream per subscriber
}

@MainActor
public final class LiveAudioService: AudioService {
    public init(bundle: Bundle = .main,
                content: ContentStore,
                settings: AudioSettings,
                localeResolver: AudioLocaleResolver = .init(),
                logger: Logger = Logger(subsystem: Brand.bundleIdentifier, category: "audio"))
}

/// Test double: records every call, completes utterances after `estimatedDuration` on an injected clock or immediately.
@MainActor
public final class FakeAudioService: AudioService { /* ... */ }

Notes:

  • Brand.bundleIdentifier returns Bundle.main.bundleIdentifier ?? "de.zahlenkette.app" and sits next to Brand.appName in ZKCore (Section 20.19). Logging follows Section 6.
  • speak never throws and never blocks. Callers that must wait (Section 10: "feedback, then next prompt") call await completion(of:).
  • When settings.voiceEnabled == false, speak returns a ticket whose completion is .suppressed immediately. The framework then uses the visual timings of Section 10 instead of waiting for audio.
  • FakeAudioService is what the Section 10 deterministic round tests and the SwiftUI previews use. The framework's GameAudio (Section 10.18.1) is a thin adapter over AudioService whose play takes an Utterance.
  • The app injects one LiveAudioService from the composition root (Section 5.4).

20.6 Utterances, templates and concatenation #

20.6.1 Why compose #

Number words are recorded once (41 files: num.0, 20 statement and 20 question files, Section 21.2). Carrier and solution fragments such as "Wo ist die …" or "Das sind …" are recorded once. The app joins them at runtime ("Wo ist die" + "Sieben?"). A line whose number slot sits inside the sentence is recorded as consecutive fragments: each fragment has its own audio ID, named after its words (for example prompt.schuettelbox.perlen), and the game section lists the fragments in speaking order (Section 21.1.1). Recording every carrier with every number would multiply the studio time by 20 and would make new content drops expensive.

20.6.2 Template format #

Templates are data. Shared templates live in Resources/Content/prompts.json under the key templates (Section 8.7). Game-specific templates live in Resources/Content/games/<gameId>.json under the key utterances (Section 8.8). Section 8 owns both file schemas and embeds the template object defined here. Section 21.4 and the per-game tables in Section 21.5 list every template the V1 content uses. Shared carrier fragments use the audio ID prefix prompt.common. and the voice-line category carrier (Section 8.7), so every game may reference them; a game never references another game's prompt.<gameId>.* or hint.<gameId>.* lines.

{
  "id": "hoer_hin.task_where_is",
  "segments": ["prompt.hoer_hin.where_is", "{n.q}"],
  "variants": []
}
{
  "id": "common.quantity",
  "segments": ["prompt.common.das_sind", "{n}"],
  "variants": [
    { "when": { "n": 1 }, "segments": ["prompt.common.das_ist_eins"] }
  ]
}

Template ID format: <scope>.<name>, where the scope is common or a GameID raw value and the name matches [a-z0-9_]+. Template IDs are not audio IDs and have no files.

Segment tokens:

Token Meaning Expands to
"prompt.hoer_hin.where_is" (any audio ID) A recorded clip .clip(id)
"{x}" The number word for binding x, statement intonation .clip(num.<value>)
"{x.q}" The number word for binding x, question intonation .clip(num.<value>.q); the value must be 1…20
"{split x}" The spoken decomposition of binding x (Section 4, DR-09 and DR-43): 6–9 → "fünf und (x−5)"; 10 → "fünf und fünf"; 11–19 → "zehn und (x−10)"; 20 → "zehn und zehn". Example: 7 → "fünf und zwei"; 17 → "zehn und sieben". The structure field of numbers.json (Section 8.6) drives only the rendering, not this spoken form. The parts as number words, joined by prompt.common.und. Empty for 1–5.
"{count x y}" Counting from binding x to binding y inclusive, ascending or descending Number words separated by pauses so that onsets are countStep apart (20.6.4)
"_" Sentence boundary .pause(sentenceGap)
"~<ms>" Explicit pause, e.g. "~500" .pause(.milliseconds(500)), where 0 < ms ≤ 2000

Binding names match [a-z]+[0-9]*. There is no token for a game title: no voice line speaks a game name (Section 21.1.4). Common names: n (the task number), a and b (decomposition parts or comparison operands), s (start), t (target), g (gap), k (current dot or count), c (the child's count), e (expected dot), l and r (the two parts), m (the number before, or a landmark). The caller computes every value. Templates contain no arithmetic.

Variants: variants is an ordered list. The first variant whose when object matches all given binding values replaces segments. when supports only equality on integer bindings. Variants exist only for German grammar exceptions. Sections 21.4 and 21.5 list every one (for example "Das sind sieben." / "Das ist eins.").

Validation (runs in the content validation test, Section 8):

  1. Every literal audio ID exists for every shipped audio locale, or has a TTS fallback key (number words: no fallback allowed; see Section 20.14).
  2. Every slot token has a binding name, and every {x.q} is only used where the game supplies values in 1…20.
  3. No template references another template (templates are flat).
  4. No template contains two adjacent "_" tokens and none starts or ends with "_".

20.6.3 Full-sentence overrides #

If a specific join sounds unnatural in QA (for example "Wo ist die" + "Elf?"), a whole sentence can be recorded. It is stored under the carrier's audio ID plus .full_<n>, for example prompt.hoer_hin.where_is.full_11. (Fragments of one spoken line each have their own audio ID, Section 21.1.1.) UtteranceBuilder checks for an override file before composing any template whose first two segments are a carrier followed by {x} or {x.q}. V1 ships without overrides unless the QA join test in Section 20.15.9 fails for a pair. Overrides are optional content and are not listed in Section 21.

20.6.4 Gap timing #

The delivered files keep 30 ms of silence at the head and the tail (Section 20.15.6). A join with no added gap therefore has about 60 ms of silence, which sounds like connected speech.

Join Added pause Resulting silence (approx.) Example
Carrier fragment → number word (same sentence) 0 ms 60 ms "Wo ist die ▸ Sieben?"
Number word → fragment (same sentence) 0 ms 60 ms "Drei ▸ und ▸ vier ▸ sind zusammen ▸ sieben."
Sentence boundary "_" 250 ms 310 ms "Das ist die Sieben. ▸ Fünf und zwei."
Try-again line → hint level 1 (Section 10 ladder) 250 ms ("_") 310 ms "Macht nichts! Nochmal! ▸ Hör nochmal: sieben."
Automatic counting {count}, Vorschule Onsets every 700 ms depends on the word "eins … zwei … drei"
Automatic counting {count}, Die Kleinen Onsets every 800 ms depends on the word same
Child-driven counting (one number word per tap) none; each tap is its own utterance n/a Section 20.7 rule R4

For {count}, the builder pads each number word with a pause of max(0, countStep − clipDuration). If a clip is longer than countStep, no pause is added.

20.6.5 German grammar rules that templates must respect #

  • The numeral noun is feminine: "die Eins" … "die Zwanzig". Carriers that name a numeral end in "die …", "der …" (dative: "bei der …", "nach der …", "auf der …") or "zur …". These work for all numbers 0–20 without variants.
  • A quantity uses the counting form without an article: "Das sind sieben." For n = 1 German needs "eins" in a different sentence frame: "Das ist eins." Every quantity template therefore has an n == 1 variant.
  • Object nouns are not composed with number words ("sieben Äpfel"). Plural forms, gender and case would need a recording per object and per number. Quantity lines use neutral frames ("Das sind sieben."). Exceptions: the fixed noun "Punkte" after a number ("Zeig mir sieben Punkte.", fragment prompt.entdecken.dots, with the whole-line n = 1 variant "Zeig mir einen Punkt.") and "Perlen" after a number ("Hier sind sieben Perlen."); both nouns are the same for every n ≥ 2. Object names otherwise appear only in whole recorded lines without a number (the Wie viele? prompts "Wie viele Äpfel sind das?", Section 21.5.2) and in accessibility labels (Section 21.13.7).
  • A number word in the middle of a sentence uses the statement file. A question-intonation file is used only where the number word ends a question.
  • The separable-verb problem ("Fang bei der Eins an") is avoided. No template puts a slot before a separable particle. Such lines are recorded whole.

20.7 Voice arbitration: priorities, queueing and interruption #

Only one voice utterance is audible at any time (one voice player node). A new request is arbitrated as follows.

Rule Condition Behaviour
R1 Voice off in the settings Return .suppressed immediately. Nothing plays.
R2 New request is .system Stop the current utterance (40 ms fade) → .interrupted. Drop every queued item → .dropped. Play the new request.
R3 New request is .instruction If the current utterance is .system: queue the new request right after it. Otherwise stop the current utterance (40 ms fade) → .interrupted, drop every queued item with priority < .system → .dropped, and play the new request.
R4 New request is .feedback If the current utterance is .system: queue after it. Otherwise stop the current .instruction, .feedback or .ambient utterance → .interrupted, drop queued items with priority ≤ .instruction → .dropped, and play. Reason: feedback is always caused by a child action. The child has acted, so the running prompt has served its purpose.
R5 New request is .ambient If anything is playing or queued → .dropped. Otherwise play.
R6 Queue length At most 1 queued item (behind a .system line). A newer queued request replaces the older one → .dropped.

Guarantees that follow from these rules:

  • A new prompt cancels every pending lower-priority line (R3).
  • Feedback never overlaps a prompt. There is one channel, so they cannot overlap. In addition, the framework (Section 10) awaits completion(of:) of the feedback before it requests the next task prompt. The next prompt therefore never cuts off praise.
  • A child who taps quickly while counting hears each new number word. The previous word is cut with the 40 ms fade (R4). The child never hears a backlog of number words.
  • System lines (time warning, time's up, break nudge) are never cut by game audio. They are only played between tasks (Section 15), so R2 rarely interrupts anything.

Replay button (speaker icon, Section 10.10.1): it re-requests the current task prompt as .instruction. If the same prompt is already playing, the request is ignored (no restart, no stutter). Debouncing and the ignore window at the start of a line are owned by Section 10.10.1 (constant speakerDebounce, Section 10.5.2); the audio service adds no debounce of its own.

Worked sequence (Hör hin, child answers wrong and then right):

t (ms) Event Voice action
0 Task shown speak(hoer_hin.task_where_is, .instruction) → "Wo ist die Sieben?"
900 Child taps "1" during the prompt (input allowed after 600 ms, Section 10) sfx.try_again; speak([fb.tryagain.03, "_", hint.hoer_hin.listen_again + num.7], .feedback) interrupts the prompt (R4)
4100 Child taps "7" sfx.correct; speak(common.correct_numeral, .feedback) ("Richtig, das ist die Sieben!")
5600 Feedback finished; the framework awaited completion speak(next task prompt, .instruction)

20.8 Music and ducking #

20.8.1 Ducking #

  • When a voice utterance starts, music ramps from 0.30 to 0.10 in 120 ms (AVAudioPlayer.setVolume(_:fadeDuration:)).
  • When the voice has been idle for 300 ms (nothing playing, nothing queued), music ramps back to 0.30 in 600 ms. A new voice line during the release ramp ducks again from the current volume.
  • SFX never duck music and never duck voice.
  • TTS fallback audio is played through the voice node (Section 20.14.3), so ducking applies to it as well.

20.8.2 Music per screen #

Decision: music plays only on hub screens. There is no music during game rounds. Listening tasks such as Hör hin need a clean audio field, and early-maths tasks put a load on working memory.

Screens (Section 18) Loop
S-04 Profile picker, S-05 Child home, S-06 Game picker, S-13 Zahlenfreunde gallery, S-14 Sticker album music.home
S-11 Garden, S-12 Decoration shop music.garden
S-09 Abenteuer intro music.abenteuer
S-07 Game screen, S-08 Round end celebration, S-15 Ask-a-parent, S-26 Break nudge overlay, S-27 Friend befriended celebration none (the music of the previous screen fades out over 500 ms)
S-10 Abenteuer soft end, S-16 Time's up none; the music of the previous screen fades out over 2 s and stays off (Sections 15.8.4 and 15.10.4)
S-01 to S-03, S-17 to S-25 none

Switching between two screens with the same loop continues playback without a restart. Switching to a different loop crossfades over 800 ms: two AVAudioPlayer instances are used during the crossfade and the old one is released afterwards.

Loop seamlessness is verified on device (see Section 20.16.1).

20.9 SFX playback #

  • All SFX (Section 21.11) are decoded to PCM at child-mode activation and stay resident.
  • Pool of 4 player nodes, used round-robin. When all 4 are busy, the node that started earliest is stopped and reused. SFX are short, so stealing is inaudible in practice.
  • The same SFX ID requested again within 50 ms is ignored (double-tap and multi-touch protection; input rules are in Section 10).
  • sfx.shake may be retriggered while it plays (every 300 ms at most) to follow continued shaking in Schüttelbox.
  • SFX never wait for or block voice.
  • The parent area plays no SFX.

20.10 Preloading and caching #

20.10.1 Cache tiers #

Tier Contents When loaded Evicted
Resident All num.* (41), all prompt.common.* carriers, fb.* (correct, try-again, solution), hint.common.*, all sfx.* Child-mode activation, decoded in the background Never while child mode runs. On memory warning: kept.
Round All prompt.<gameId>.* and hint.<gameId>.* of the game being started, plus session.round_done_* RoundController loading state (Section 10) When the round ends (releaseRoundCache()); LRU afterwards
On demand session.*, friend.*, label.*, everything else Decoded at first speak LRU
  • The total PCM cache is capped at 48 MB. Mono Float32 at 44.1 kHz uses about 176 KB per second of audio. The resident tier is about 110 s of audio, about 19 MB. When the cap is reached, the least recently used non-resident buffers are evicted.
  • On UIApplication.didReceiveMemoryWarningNotification, the app calls purgeCaches(keeping:) with the current round tier; all other non-resident buffers are evicted. The resident tier is kept.
  • Section 20 owns these cache numbers; Sections 5.7.5 and 24.5 reference them.
  • The memory figures count toward the budget in Section 24.

20.10.2 Loading rules #

  • preload(_:) completes when every requested clip is decoded, or after 500 ms, whichever comes first. A round never waits longer than that for audio.
  • A clip that is not decoded when speak needs it is decoded synchronously inside the audio actor. Its playback may then start late, but it still plays.
  • Resolving a clip URL: bundle.url(forResource: id.rawValue, withExtension: "m4a", subdirectory: "Audio/\(folder)"). folder is common for sfx.* and music.* and the audio locale (de) otherwise. The repository folder Resources/Audio/ is added to the app target as a folder reference named Audio, so the subfolders survive in the bundle. Audio is deliberately not placed in .lproj folders. The app controls locale resolution and fallback itself (Section 20.17.6).
  • Decoding: AVAudioFile(forReading:commonFormat: .pcmFormatFloat32, interleaved: false) and a read of the full length into one buffer.
  • AAC priming: afconvert writes encoder-delay information into .m4a files. Executor verification: for five clips, compare the decoded frame count with the source WAV (at 44.1 kHz). If the decoded buffer is longer by the AAC priming amount (typically 2112 frames), trim that many leading frames in the decoder and add a unit test for it.

20.11 Latency budget #

Path Budget (built-in speaker, iPhone SE 2nd gen) How it is met
Touch-down → SFX audible < 100 ms (target ≤ 50 ms) Resident PCM, running engine, 10 ms I/O buffer, one synchronous call through the locked player facade (20.4.2) between the gesture callback and scheduleBuffer; no await
Tap on a dot or item → number word audible (counting) < 100 ms Resident num.*
Answer evaluated → feedback audible < 150 ms Resident fb.*
Replay tap → prompt audible < 100 ms Round tier preloaded
Task shown (transition end) → prompt audible ≤ 200 ms Round tier preloaded; the framework requests the prompt at transition end
Synchronous decode fallback (≤ 3 s clip) < 60 ms Measured on iPhone SE 2nd gen

Bluetooth output adds route latency that the app cannot control (often 150–250 ms). Decision: no compensation. The budget applies to the built-in speaker and wired headphones.

Measurement (executor): add os_signpost intervals named audio.sfx and audio.voice from gesture receipt to scheduleBuffer. Add AVAudioSession.outputLatency + ioBufferDuration to that. Confirm end to end once with a 240 fps slow-motion video of a finger tapping next to a second device recording audio. The acceptance tests in Section 25 reference these numbers.

20.12 Interruptions, route changes and resets #

Event Handling
interruptionNotification, type .began (phone call, FaceTime, Siri, alarm) The engine has been stopped by the system. Mark the state interrupted. Complete the current voice ticket with .interrupted and drop the queue. Pause music. Emit .interruptionBegan. Section 10.12.3 decides whether the round pauses (an interruption shorter than 3 s resumes automatically without the overlay); Section 15 pauses the active-time accounting.
.ended (with or without .shouldResume) Emit .interruptionEnded(shouldResume:). Do not auto-play voice. When the scene is active: setActive(true), restart the engine, resume music with a fade-in. The prompt of the current task is replayed only when the child resumes, or automatically after an interruption shorter than 3 s (Section 10.12.3).
routeChangeNotification, reason .oldDeviceUnavailable (headphones unplugged, Bluetooth disconnected) Stop voice immediately (fade 40 ms) → .interrupted. Pause music. Emit .outputRouteLost. The round does not pause and no pause overlay appears (Section 15.11); the speaker button pulses once and stays available. Do not replay automatically. Music resumes on the next child interaction (any tap).
Other route change reasons (new device available, category change) No action.
AVAudioEngineConfigurationChange (output format changed, for example a new Bluetooth device) Reconnect the nodes with the current output format and restart the engine. The running voice ticket completes with .interrupted. Emit .engineRestarted.
mediaServicesWereResetNotification Discard the engine, the nodes, the AVAudioPlayer, the synthesizer and all cached buffers. Recreate the engine graph, reconfigure the session (20.3.1), reload the resident tier and emit .engineRestarted.
App to background See Section 20.3.2.
Low Power Mode No change. Audio is not a significant power consumer here.
Guided Access, Screen Time limits System-managed. No special handling.
VoiceOver running App voice keeps playing because it is the instruction channel. VoiceOver announcements may overlap. The child area's VoiceOver scope is owned by Section 19.

20.13 Sound-off visual path #

20.13.1 Rule #

Every spoken instruction has a visual equivalent that is always shown, whether voice is on or off:

  1. The target numeral or quantity is shown in the prompt area (Section 10 layout slot) whenever the spoken prompt names a number.
  2. The task type is shown as a pictogram (Section 21.13.10, glyph_prompt_* and glyph_arrow_*) whenever the prompt names an operation ("mehr", "weniger", "größer", "kleiner", "weiter", "zurück").
  3. The animated demo hand (Section 10.10.2) shows the expected gesture.
  4. Hint levels 1 and 2 always have their visual scaffold (highlight, pulse, count-along highlighting, five-group emphasis) in addition to the hint line.
  5. Feedback is always visual as well: a soft green glow and a star for correct, a gentle neutral shake for try-again (Section 19).

When voiceEnabled == false, the framework additionally does the following:

  • It plays the demo hand at every task start, not only after 6 s of inactivity (the inactivity timings are in Section 10).
  • It keeps each prompt state on screen for at least the estimatedDuration of the suppressed utterance, so the pacing matches the voiced version.
  • During counting it shows the counted number as a numeral above each counted item or dot for 1 s ("counting numerals").

When the system output volume is below 0.05 at round start and voice is enabled, the service emits .lowSystemVolume. The game shows an animated "volume up" icon next to the speaker button for 4 s, at most once per session. This hint contains no text.

20.13.2 Per-game visual equivalents #

The game mechanics and their visual prompts are owned by Sections 11–13. This table summarises what replaces each spoken instruction. Every game is playable with voice off.

Game Spoken content Visual equivalent (always present) Playable with voice off
Entdecken, free explore Number word and decomposition after a tap Large numeral, written word and split view (for example "5 + 2", "10 + 7") on tap Yes
Entdecken, Zähl mit "Wir zählen bis …", "Zeig mir … Punkte." With voice off the target numeral replaces the speech bubble in the prompt area; counting numerals above each dot; demo hand; a left-pointing arrow for backward tasks Yes (credit per 20.13.3)
Wie viele? "Wie viele Äpfel sind das?" Object pictogram with a large "?" in the prompt area; demo hand marks one object, then taps over the answer area Yes
Hör hin The number to find (the stimulus) Quantity card in the prompt area: a mini Zwanzigerfeld row with n filled beads; the task becomes quantity-to-numeral (Section 11.4.6) Yes (credit per 20.13.3)
Was fehlt? "Welche Zahl fehlt?" The gap glows and shows a "?"; backward chains show a left-pointing arrow pictogram Yes
Blitzblick "Tipp auf die Karte!", "Wie viele waren es?" Card pulse and demo hand; after the flash a closed-eye glyph above the card; tiles slide in Yes
Mehr oder weniger "Wo sind mehr/weniger?", "Welche Zahl ist größer/kleiner?" Glyphs glyph_prompt_more, glyph_prompt_less, glyph_prompt_bigger, glyph_prompt_smaller in the prompt area; the gleich button pulses once when it first appears Yes
Nachspuren "Schreib die …", stroke hints Reference numeral, guide arrows, numbered start dots, animated demo stroke Yes
Schüttelbox "Schüttle die Box!", questions per compartment Demo hand shaking a phone pictogram, pulsing shake button, the compartment asked about outlined, "?" on the hidden flap Yes
Froschsprung "Spring zur …", "Spring zwei weiter!" Target numeral in the prompt area; one or two direction arrows for relative tasks Yes
Fütter das Zahlenmonster "Gib mir …!" Numeral and dot pattern on the monster's bib (always shown) Yes
Memory No number is spoken on flip; hints Fully visual; hints are the row band and the partner wiggle Yes
Punkt zu Punkt "Verbinde die Punkte! Fang bei der Eins an." Numerals printed at every dot; demo hand taps dot 1 then dot 2 of a ghost example Yes

20.13.3 Decision: voice-dependent content when voice is off #

  • Linking a spoken word (name skill). Tasks completed while voice is off report voiceOn = false in TaskResult (Section 10.4.3). The framework already removes name from the credited skills of such a task and applies the credit rule of Section 10.4.3 (for Entdecken Zähl mit and Hör hin: recognize with weight 0.5 only). The engine does no extra filtering.
  • Hör hin stays playable and openable with voice off, using the quantity-card prompt (Section 11.4.6). The engine deprioritises it in Abenteuer composition while voice is off (score penalty, Section 9.16.3); it is not excluded and its tile is not dimmed.
  • Low system volume does not change credit. Only the setting does.

20.13.4 System volume at zero #

This case is not the setting. AVAudioSession.outputVolume is read at round start. A value below 0.05 triggers the .lowSystemVolume visual hint (20.13.1, glyph glyph_volume_up) and nothing else.

20.14 Missing audio and the TTS fallback #

20.14.1 When TTS is used #

TTS (AVSpeechSynthesizer) is used only when a voice clip is missing from the bundle for the current audio locale and the line has a ttsFallbackKey in prompts.json (Section 8). It exists so that new content (for example a quarterly content drop, Section 17) can be tested and, if necessary, shipped before its studio session. For the V1 launch, 100 % recorded coverage is required (Section 26 launch checklist).

Missing clip Debug build Release build
num.* (any of the 41 number-word files) The content validation test fails (Section 8, Section 25), so the build is red. At runtime: segment skipped, fault logged. Must not happen: the Release build-phase check scripts/validate-audio.sh fails the archive. Defensive runtime behaviour: segment skipped silently, fault logged. TTS is never used for a number word.
Other voice line with a ttsFallbackKey TTS speaks the line. A warning is logged. The developer content report (Section 8) lists it with a use counter. TTS speaks the line. A warning is logged.
Other voice line without a text key Validation fails. Segment skipped, error logged.
sfx.* Validation warning; silent at runtime Silent
music.* Validation warning; no music No music

Within a composed utterance, only the missing segment is replaced by TTS. Recorded number words still play from their files, in sequence. Mixing voices is audibly imperfect, but this rule keeps the number words authentic, and the situation is temporary by definition.

TTS text for a fragment: the fragment's value from Content.xcstrings (key = audio ID) with every %@ slot marker and the punctuation directly attached to a marker removed (Section 20.17.3). Examples: "Wo ist die %@?" → TTS speaks "Wo ist die"; "%@ sind zusammen %@." → "sind zusammen".

20.14.2 Voice settings #

Parameter Value
Voice selection From AVSpeechSynthesisVoice.speechVoices(), all voices with language == "de-DE" (V1.2: "en-GB" for en). Prefer quality .premium, then .enhanced, then .default. Within the same quality prefer a female voice (gender == .female) to match the recorded speaker. If none is found: AVSpeechSynthesisVoice(language: "de-DE").
rate 0.44 (AVSpeechUtteranceDefaultSpeechRate is 0.5; slightly slower for young children)
pitchMultiplier 1.05
volume 0.9
preUtteranceDelay / postUtteranceDelay 0 / 0
usesApplicationAudioSession true (default)
mixToTelephonyUplink false

20.14.3 Integration #

  • Decision: render TTS into buffers with AVSpeechSynthesizer.write(_:toBufferCallback:), convert them with AVAudioConverter to 44.1 kHz mono Float32, and schedule them on the voice node like any clip. This keeps arbitration (20.7), ducking (20.8) and completion tracking uniform.
  • Executor verification: on a physical iPhone with iOS 17 and with the current iOS release, confirm that write delivers non-empty buffers for the selected de-DE voice. If it does not, fall back to speak(_:) directly and treat the synthesizer as the voice channel: stop the voice node, use AVSpeechSynthesizerDelegate for start and finish, and use stopSpeaking(at: .immediate) for interruption. Document the choice in DECISIONS.md.
  • The synthesizer is created lazily on first use and kept for the process lifetime.
  • Every TTS use logs Logger.warning("TTS fallback used for \(id, privacy: .public)") and emits .ttsFallbackUsed(id).

20.14.4 Build-time check #

scripts/validate-audio.sh runs as a build phase in the Release configuration and in CI:

  1. It reads every audio ID referenced by prompts.json, games/*.json, friends.json and numbers.json.
  2. It checks for Resources/Audio/<locale>/<id>.m4a for each shipped locale (from the content manifest, Section 8) and Resources/Audio/common/<id>.m4a for sfx.* and music.*.
  3. It exits non-zero if any num.<n> (n = 0…20) or num.<n>.q (n = 1…20) is missing. num.0 is declared by the optional zero entry of numbers.json (Section 8.6). It prints a warning for every other missing ID.
  4. It exits non-zero if any file name in Resources/Audio/ does not match the audio ID grammar (^[a-z0-9_]+(\.[a-z0-9_]+)*\.m4a$).

The same logic runs as a Swift Testing test in the content validation suite (Section 25), so Debug builds catch problems early.

20.15 Recording specification (voice) #

Casting, budget and booking are executor decisions with defaults in Section 1. This section defines the technical and artistic requirements.

20.15.1 Speaker profile #

Attribute Requirement
Number of speakers Decision: one adult speaker for every voice line, including friend lines and the Zahlenmonster. Character lines are performed in a lighter, more playful manner by the same person, without pitch processing. Reasons: consistency for pre-readers, one pickup contact, lower cost, and no child-performer regulations.
Voice type Decision: female adult. Warm, mid-range pitch, friendly and calm. Not sing-song, no baby talk, not over-excited.
Accent Standard German (Hochdeutsch) without regional colouring, acceptable in Germany, Austria and Switzerland. Trained speaker (professional speech training or equivalent experience). Native German.
Experience Children's audio (audiobooks, educational apps, children's TV). Able to hold a consistent character across sessions.
Tempo Instructions about 130–150 words per minute. Number words clearly articulated, not stretched.

20.15.2 Pronunciation rules #

Word Rule
zwei Always "zwei", never "zwo"
eins Full final "s": "eins", not "ein"
sechs, sechzehn "sechs" with /ks/; "sechzehn" /ˈzɛçt͡seːn/ (no /ks/)
sieben, siebzehn "siebzehn" (not "siebenzehn")
fünfzehn Full "fünfzehn" (not "fuffzehn")
zwanzig Decision: standard ending /ɪç/ ("zwanzich"), as in the pronunciation dictionaries used by German broadcasting. Used consistently.
Null /nʊl/
Numeral nouns "die Sieben" is spoken identically to "sieben". Capitalisation affects only text.
Imperatives "Schüttle", "Füttere" (written standard forms), "Tipp", "Zähl", "Hüpf", "Mal", "Zieh", "Hör", "Schau"
Greetings Only "Hallo", "Guten Morgen", "Guten Abend", "Bis bald". No regional greetings ("Servus", "Grüezi", "Moin", "Tschüss").

20.15.3 Intonation rules #

  • num.<n>: statement. A calm, falling, complete word ("Sieben."). It must also work in the middle of a sentence ("Drei und vier sind zusammen sieben"). The speaker records it neither as the end of a list nor as a question.
  • num.<n>.q: question. A clearly rising end ("Sieben?"). It is only used at the end of a question.
  • Carrier fragments: recorded as a full sentence with a dummy number, then cut at the onset of the number (20.15.5). The dummy is "Sieben" for all carriers except those whose example in Section 21 uses a different number.
  • Feedback lines: warm and positive, never shrill. Try-again lines are warm and encouraging, with no disappointed tone and no sigh.
  • Friend lines: playful, a little higher energy, same voice.
  • Session end lines (Abenteuer soft end, time's up): slower and softer, bedtime-story tone.

20.15.4 Studio specification #

Item Requirement
Room Treated voice booth. Noise floor ≤ −60 dBFS (A-weighted) on the recorded channel. No audible room reverb.
Microphone Large-diaphragm condenser, cardioid, 20–30 cm distance with a pop filter. Same microphone, distance and preamp for all sessions including pickups. Setup photographed and documented.
Recording format (masters) WAV, 48 kHz, 24-bit, mono
Takes At least 2 takes per line; 3 for number words and carriers. The director marks the selected take in the session log.
Direction The producer or executor-appointed director attends (remotely is acceptable) with the script and context notes.
Session length Blocks of at most 3 hours with a break every 45–60 minutes, to keep the voice consistent

20.15.5 Editing and processing #

  1. Select the take. Remove mouth clicks and breaths within lines. No time-stretching, no pitch-shifting.
  2. Carrier fragments: cut at the last zero crossing before the onset of the dummy number. Keep the natural release of the carrier's last syllable. A 5 ms fade-out is applied at the cut.
  3. Processing chain (identical for all lines): high-pass 80 Hz, gentle EQ, de-esser, light compression (ratio ≤ 3:1), no reverb.
  4. Loudness:
    • Lines ≥ 1.0 s: −16 LUFS integrated (±0.5 LU).
    • Lines < 1.0 s (number words, short fragments; the integrated measurement is unreliable on very short material): match the maximum momentary loudness (400 ms window) to the median maximum momentary loudness of the ≥ 1.0 s lines (±1 LU).
    • True peak ≤ −1 dBTP for all lines.
  5. Head and tail: masters keep at least 150 ms of room tone at the head and tail. Delivered files are trimmed to 30 ms (±5 ms) of silence at the head and tail, with 5 ms fades.

20.15.6 Delivery #

Deliverable Format Naming
Edited masters WAV 48 kHz / 24-bit mono, processed, trimmed <audioId>.wav, e.g. num.7.q.wav
App files AAC-LC in .m4a, 44.1 kHz, mono, 96 kbps (constant or average bitrate) <audioId>.m4a, placed in Resources/Audio/de/
Unprocessed selected takes WAV 48 kHz / 24-bit mono, with 150 ms room tone <audioId>__raw.wav (archive only, not in the repo)
Session log CSV: audioId, take, timestamp, notes session-<date>.csv

Reference encode command (macOS; afconvert ships with macOS):

afconvert -f m4af -d aac -b 96000 -c 1 --src-complexity bats -r 127 \
  "masters/num.7.q.wav" "Resources/Audio/de/num.7.q.m4a"

afconvert resamples from 48 kHz to 44.1 kHz with the high-quality sample rate converter (--src-complexity bats). The file name is the audio ID exactly, lowercase, with the .m4a extension. No spaces and no version suffixes. Masters and raw takes are archived outside the app repository (they are too large for it). Only .m4a files are committed.

20.15.7 Script format #

The recording script is generated from the content files, never written by hand. It is produced by swift scripts/ExportVoiceScript.swift <locale> (a Swift script using only Foundation) into Recording/<locale>/script.csv (UTF-8, comma-separated, RFC 4180 quoting). The columns are:

Column Content
audioId e.g. prompt.common.das_ist_die
category number, carrier, prompt, hint, feedback, solution, label, session, celebration, friend
text Exactly the spoken text. For carriers, the fragment only ("Wo ist die").
recordAs For carriers: the full sentence with the dummy number ("Wo ist die Sieben?"), marked with the cut position "Wo ist die
intonation statement / question / exclamation
direction Emotion and delivery note (from the translator comment in Content.xcstrings)
context What the child sees at that moment (screen ID and situation)
maxSeconds Soft maximum duration: number words 1.2, fragments 2.0, prompts 4.0, friend lines 5.0

The script is ordered for efficient recording: number words, then carriers, then lines grouped by game, then feedback, session and friend lines.

20.15.8 Session plan and pickups #

Session Content Estimated duration
Main session, block 1 Number words (41), carriers and solution fragments, common hints, feedback, 6 games 3 h
Main session, block 2 Remaining 6 games, session, celebration and friend lines 3 h
Pickup session Corrections from in-app QA, any full-sentence overrides (20.6.3) 1–2 h, booked 2–6 weeks after the main session

Pickup process:

  1. After integration, run the in-app QA (20.15.9) on a device build.
  2. Record each rejected line in Recording/de/pickups.csv with the audio ID, the reason and the reference file of a good neighbouring line.
  3. Pickups use the same speaker, booth, microphone setup and processing chain. The speaker listens to 3–5 approved lines before starting, to match the tone.
  4. Pickup files replace the originals under the same audio ID. Loudness is matched to the rules in 20.15.5.

Section 21.14 totals the lines and estimates the studio time.

20.15.9 QA checklist (every line) #

  • The text matches Content.xcstrings exactly (no ad-libs, no missing words).
  • Pronunciation follows 20.15.2. Intonation follows 20.15.3 (statement vs question files checked pairwise for all 20 numbers).
  • No clicks, pops, mouth noise, breaths within the line, clipping, distortion or background noise.
  • Loudness within tolerance. True peak ≤ −1 dBTP. Head and tail silence 30 ms ±5 ms.
  • File name equals the audio ID. The file is in the correct locale folder. AAC 44.1 kHz mono.
  • Join test: every carrier template has been rendered with all numbers it can take (the developer "Audio-Prüfung" screen in Debug builds renders any template × numbers 0/1–20 in sequence). No join sounds broken.
  • Duration within maxSeconds (a soft limit; any line over it is reviewed).
  • The line never speaks the app name, a child's name, prices, "kaufen" or any term from the forbidden list in Section 21.1.4.
  • Consistent voice character across both blocks and the pickups (A/B listening against three reference lines).

20.15.10 Rights #

The speaker contract grants a worldwide, perpetual buyout: use in the app, all updates and versions, App Store previews and marketing. It also permits edited, cut and composed use of the recordings. It excludes any use for training synthetic voices. The commercial terms are in Section 1.

20.16 Music and SFX production specification #

20.16.1 Music #

Attribute Requirement
Count 3 loops (Section 21.12)
Character Calm, warm, acoustic (glockenspiel, ukulele, soft piano, light strings). Major key. 70–90 BPM (music.abenteuer 60–70 BPM). No vocals, no lyrics, no sudden dynamic changes, no percussion hits above the pad level.
Length 60–90 s, seamless loop at a bar boundary, no fade at the loop point
Master WAV 48 kHz / 24-bit stereo, −18 LUFS integrated, true peak ≤ −1 dBTP
App file AAC-LC .m4a, 44.1 kHz, stereo, 128 kbps; Resources/Audio/common/music.<key>.m4a
Licence Commissioned or royalty-free with a licence allowing in-app use without attribution in the app UI; licence file stored in Recording/licences/

Loop verification (executor): on a device, listen to 5 consecutive loop transitions of each file. If a gap or click is audible with AAC and AVAudioPlayer, re-encode that loop as Apple Lossless (afconvert -d alac, .m4a, same file name). Lossless files have no encoder priming. Record the change in DECISIONS.md.

20.16.2 SFX #

Attribute Requirement
Count and list Section 21.11 (42 items, plus optional sfx.deco_<slug> item sounds)
Character Soft, rounded, wooden, marimba, glass-bell or natural sounds. Never a buzzer, alarm, siren, horn or harsh synthesised sound. The try-again sound is neutral (a soft low wooden "tock-tock" or a gentle "hmm" tone) and must not sound negative.
Duration Per-item maximum in Section 21.11. Celebration sounds ≤ 2.5 s (the celebration limit is in Section 19).
Master WAV 48 kHz / 24-bit mono, maximum momentary loudness −18 LUFS, true peak ≤ −3 dBTP, 5 ms fades, no leading silence (≤ 5 ms), since leading silence adds latency
App file AAC-LC .m4a, 44.1 kHz, mono, 96 kbps; Resources/Audio/common/sfx.<key>.m4a
Licence Commissioned or royalty-free, with the licence stored as for music

20.17 Localization architecture #

20.17.1 Languages #

Item V1 V1.2
Development language (CFBundleDevelopmentRegion, project "Development Language", defaultLocalization of ZahlenketteKit) de de
Shipped localizations (CFBundleLocalizations) de de, en
Audio locales (Resources/Audio/<locale>/) de de, en
Regional variants None. One de localization serves Germany, Austria and Switzerland. Same. One en localization written in British English (en-GB conventions).

Decision: there is no de-CH variant in V1. Swiss users see "ß" in the parent-area text (the Swiss convention would be "ss"). The child area has no text, and the audio is unaffected. This is recorded as a decision to revisit in Section 28.

20.17.2 String Catalogs #

Catalog Contents Location
Localizable.xcstrings All app UI text: parent area, parental gate, paywall, first-launch flow, accessibility labels and hints (child area included), error messages App/Localization/Localizable.xcstrings (app target, Section 5.13)
Content.xcstrings All content text: the spoken text of every voice line (key = audio ID), number words, game names, difficulty-step labels, friend names, decoration names, sticker names, dot-picture names, counting-object names (singular and plural), avatar names App/Localization/Content.xcstrings (app target, Section 5.13)
  • Decision: both catalogs live in the app target and all modules look up strings in Bundle.main. SwiftUI Text(_ key: LocalizedStringKey) defaults to the main bundle. Non-SwiftUI lookups go through the helper L10n in ZKCore:
public enum L10n {
    /// App UI string from Localizable.xcstrings.
    public static func ui(_ key: String.LocalizationValue) -> String {
        String(localized: key, table: "Localizable", bundle: .main)
    }
    /// Content string from Content.xcstrings (key = audio ID or content key).
    public static func content(_ key: String) -> String {
        String(localized: String.LocalizationValue(key), table: "Content", bundle: .main)
    }
}

Rationale: one place for translators, one place for the key-coverage test, and no duplicated catalogs across a dozen package targets. Consequence: automatic string extraction from package sources does not add keys to the app catalogs. Keys are added to the catalogs deliberately, which the explicit key naming below requires anyway. The coverage test (20.17.8) catches omissions.

  • There is no InfoPlist.xcstrings in V1. The display name is not localized (20.19), and the app requests no permission that needs a usage-description string (raw accelerometer access via CMMotionManager needs no permission prompt).
  • Every entry has a translator comment. Content entries state where the line is heard and its intonation. That comment becomes the direction column of the recording script (20.15.7).
  • Catalog entries are never marked "Don't translate", except number-free technical tokens (none exist in V1).

20.17.3 Keys #

The general key naming rules are in Section 6.5; the content key conventions are owned by Section 8.3. The patterns below are the ones the audio and content systems use:

Pattern Catalog Example Value (de)
<audioId> for every voice line Content prompt.hoer_hin.intro "Hör gut zu! Ich sage eine Zahl. Tipp sie an."
<audioId> for carrier and other fragments, with %@ marking the slot side Content prompt.common.das_ist_die "Das ist die %@."
num.<n> / num.<n>.q (spoken text of the number files) Content num.7, num.7.q "sieben", "Sieben?"
number.<n>.word (written number word) Content number.7.word "sieben"
game.<gameId>.title Content game.wie_viele.title "Wie viele?"
game.step.leicht, game.step.mittel, game.step.schwer Content game.step.leicht "Leicht"
friend.<n>.name Content friend.7.name as listed in Section 14
<decorationId>.name, <stickerId>.name, dotpicture.<pictureId>.name, <avatarId>.name, <themeId>.name Content dotpicture.stern.name "Stern"
object.<id>.one / object.<id>.other Content object.apfel.other "Äpfel"
<area>.<screen>.<element> Localizable parent.settings.voice.title "Sprache"

Carrier values contain one %@ for each adjacent number slot: none for a whole line, one for a fragment at the start or end of a sentence ("Wo ist die %@?"), and two for a fragment between two slots ("%@ und %@", "%@ sind zusammen %@.", "%@ ist größer als %@."). This makes the value readable for translators ("Wo ist die %@?" → "Where is number %@?"). The spoken fragment text is the value with every %@ marker, and the punctuation attached to a marker, removed ("%@ sind zusammen %@." → "sind zusammen"). Section 21 shows slots as "…" for readability. The catalog uses %@.

20.17.4 Plurals #

  • The child area shows no pluralised text. Audio avoids pluralised nouns (Section 20.6.5).
  • The parent area uses String Catalog plural variations (German and English: one, other) for every string with a count. Examples: parent.dashboard.stars_count %lld → "1 Stern" / "%lld Sterne"; parent.dashboard.friends_count %lld → "1 Zahlenfreund" / "%lld Zahlenfreunde"; parent.time.minutes %lld → "1 Minute" / "%lld Minuten".
  • Counting-object names have object.<id>.one and object.<id>.other entries. VoiceOver labels in the child area use them via a plural-variation string a11y.objects.count %lld %@.

20.17.5 Numbers, dates and durations #

Context Rule
Numerals in the child area Always Western Arabic digits 0–9, rendered with Text(verbatim: String(n)). Never passed through a locale-aware formatter, so no locale can change the glyphs the child is learning.
Numbers in the parent area n.formatted() with the current locale (grouping is irrelevant below 1000, but the rule is uniform)
Durations Minutes via the plural strings above. Charts use Date.FormatStyle().weekday(.abbreviated) ("Mo.", "Di.", …).
Dates date.formatted(date: .abbreviated, time: .omitted) with the current locale. de_AT users automatically see "Jänner" where the system formats it that way.
Prices Only Product.displayPrice from StoreKit (Section 17). Never formatted by the app.
Parental gate question Written number words from Localizable.xcstrings (Section 16), never digits

20.17.6 Language resolution #

  • The app language follows the device language: iOS picks the first entry of the user's preferred languages that the bundle supports. If none is supported, it falls back to CFBundleDevelopmentRegion (de). In V1 the app is therefore German on every device.
  • Audio locale = the resolved UI language:
public struct AudioLocaleResolver: Sendable {
    public init() {}
    /// Returns an audio folder name ("de", later "en"). Falls back to "de".
    public func resolve(bundle: Bundle = .main, available: Set<String>) -> String {
        let first = bundle.preferredLocalizations.first ?? "de"
        let language = Locale(identifier: first).language.languageCode?.identifier ?? "de"
        return available.contains(language) ? language : "de"
    }
}

available is the list of audio locales declared in the content manifest (Section 8). UI language and voice language are always the same. The app never mixes a German voice into English UI or the reverse.

  • Decision: there is no in-app language override in V1 or V1.2. From V1.2 (two localizations), iOS automatically offers a per-app "Language" setting in the Settings app. The app honours it without code, because it resolves through Bundle.preferredLocalizations. iOS relaunches the app when this setting changes, so no live language switching is needed.
  • Content JSON never contains display text, only string keys (Section 8). A new language therefore needs no JSON change, except for optional per-locale template overrides (20.18.3).

20.17.7 Right-to-left and text expansion #

No right-to-left language is planned. The parent-area layouts use leading and trailing alignment anyway (SwiftUI default) and must tolerate 30 % text expansion (English is usually shorter than German, so German is the worst case).

20.17.8 Localization tests #

  • Every string key referenced from Swift code through L10n, from content JSON or from audio IDs exists in the matching catalog for every shipped language and has state "translated". This is a Swift Testing test in the app test target, where the app bundle is available.
  • Every key in Content.xcstrings that looks like an audio ID has a clip or is listed as TTS-only content (none in V1).
  • Pseudo-localization run (Xcode scheme option "Double-Length Pseudolanguage") for the parent area before each release. There is no clipped or truncated text at the largest Dynamic Type size (the Dynamic Type scope is in Section 19).

20.18 English readiness (V1.2) #

20.18.1 What V1 already provides #

  • Every user-visible string is in a String Catalog. There are no string literals in UI code (enforced by review and by the Section 6 lint rule).
  • Audio is resolved per locale folder, with the same audio IDs.
  • Templates are designed so that English fits the same slot order (20.18.3).
  • The content JSON is language-neutral.

20.18.2 Steps to add English #

  1. Add en to the project localizations. Both catalogs gain an English column.
  2. Translate Localizable.xcstrings (parent area, gate, paywall, accessibility) and Content.xcstrings (all voice lines and names) with a professional translator. An early-years educator who is a native English speaker then reviews the content lines for didactic wording (20.18.4).
  3. Decide English display names for the games. Proposed defaults: Entdecken "Explore", Wie viele? "How Many?", Hör hin "Listen", Was fehlt? "What's Missing?", Blitzblick "Quick Look", Mehr oder weniger "More or Fewer", Nachspuren "Trace", Schüttelbox "Shake Box", Froschsprung "Frog Jump", Fütter das Zahlenmonster "Feed the Number Monster", Memory "Pairs", Punkt zu Punkt "Dot to Dot".
  4. Check each template's English word order against the table in 20.18.3. Add per-locale overrides only where needed.
  5. Record English audio for every audio ID in Section 21 (the same inventory, the same IDs, the same technical specification as in 20.15). Decision: one British English speaker (standard southern British accent), female, same profile. en is a single localization using British spelling and vocabulary (Sections 26.5 and 28.2); devices set to any English variant use it.
  6. Deliver the files to Resources/Audio/en/. Add en to audioLocales in the content manifest. Run scripts/validate-audio.sh.
  7. TTS fallback voice: en-GB (20.14.2).
  8. Store listing in English (Section 22 owns only the German listing; the English listing follows the same structure). English screenshots.
  9. QA: full English playthrough of all 12 games and all screens. Join test for all English carriers. The child-usability protocol (Section 25) runs with English-speaking children.

20.18.3 Template compatibility #

Each German template was checked for an English equivalent with the same slot order:

Template (German) English (proposed) Same slot order
"Wo ist die ▸{n.q}" "Where is number ▸{n.q}" Yes
"Zeig mir die ▸{n}." "Show me ▸{n}." Yes
"Das ist die ▸{n}." "This is ▸{n}." Yes
"Das sind ▸{n}." / "Das ist eins." "That's ▸{n}." / "That's one." Yes (the English n = 1 variant is optional)
"{a} und {b} sind zusammen {n}." "{a} and {b} make {n}." Yes
"Nach der ▸{m} kommt die ▸{g}." "After ▸{m} comes ▸{g}." Yes
"Der Frosch sitzt auf der ▸{s}." "The frog is sitting on ▸{s}." Yes
"Hier sind ▸{n}▸ Perlen." "Here are ▸{n}▸ beads." Yes
"Gib mir ▸{n}!" "Give me ▸{n}!" Yes
"{a} ist größer als {b}." "{a} is bigger than {b}." Yes

Future languages with a different word order use a per-locale override. A template may carry "localeOverrides": { "<locale>": { "segments": [...], "variants": [...] } }. Section 8 includes this optional field in the template object. V1 content uses no overrides.

20.18.4 Didactic differences between German and English #

Topic German English Decision for V1.2
Teen words "dreizehn" … "neunzehn": the unit is spoken first, then "zehn", while the numeral is written "1" then "3" "thirteen" … "nineteen": the same unit-first order, with the "-teen" suffix Same hint logic. The Hör hin level-1 hint repeats the word slowly (hint.hoer_hin.listen_again, "Listen again: …").
Eleven, twelve "elf", "zwölf" are irregular (no "zehn") "eleven", "twelve" are irregular Same handling. Hints explain them as "ten and one", "ten and two".
Numeral noun Feminine noun: "die Sieben" No gender: "seven", "number seven" Carriers use "number …" where German uses "die …"
Quantity n = 1 "Das ist eins." (different frame) "That's one." English needs no variant, but it keeps the variant slot.
Comparison "mehr / weniger" "more / fewer" Decision: "fewer" for countable sets (the correct form in early-years curricula). "less" is not used.
Finger counting Starts with the thumb (thumb = 1) Varies by culture. Commonly starts with the index finger in the US and UK. Decision: the finger convention is a per-locale content setting fingerCountingConvention in numbers.json (Section 8), with values thumbFirst or indexFirst. de = thumbFirst. en also ships thumbFirst in V1.2: the German convention is kept, and the code-drawn hands of Section 19.7.5 implement thumb-first only. An indexFirst drawing mode would be a later content drop.
Five and ten structure, colours Kraft der Fünf, red/blue groups of five Same didactic model ("power of five", ten frames) Unchanged
Numeral shapes and stroke order German print numerals (Section 12) English school numerals are similar. The "1" flag and the uncrossed "7" match common English print. V1.2 keeps the German stroke definitions. Revisit in Section 28 if English usability tests show confusion.

20.19 Central app-name mechanism #

"Zahlenkette" is a working title. The name is defined in one place and can be changed without touching functionality or re-recording audio.

20.19.1 Single source #

Config/Brand.xcconfig:

// The one and only place where the app's user-visible name is defined.
// Keep the value on one line and put no comment on the value line.
APP_DISPLAY_NAME = Zahlenkette
// Support and legal links (placeholder domain until the name is final, Section 1). "//" starts a comment
// in .xcconfig files, so the scheme separator is written with the $() escape.
PRIVACY_POLICY_URL = https:/$()/zahlenkette.de/datenschutz
IMPRINT_URL = https:/$()/zahlenkette.de/impressum
SUPPORT_URL = https:/$()/zahlenkette.de/hilfe
SUPPORT_EMAIL = hilfe@zahlenkette.de
  • Config/Brand.xcconfig is included by Config/Base.xcconfig (#include "Brand.xcconfig"), which every build configuration includes. All configurations therefore resolve the same name. The xcconfig file layout and build settings are owned by Section 5.8.2.
  • The app uses the checked-in App/Info.plist (GENERATE_INFOPLIST_FILE = NO, Section 5.12). It contains <key>CFBundleDisplayName</key><string>$(APP_DISPLAY_NAME)</string> and the four keys PRIVACY_POLICY_URL, IMPRINT_URL, SUPPORT_URL and SUPPORT_EMAIL with the values $(PRIVACY_POLICY_URL) and so on. No INFOPLIST_KEY_… build settings are used.
  • CFBundleName stays $(PRODUCT_NAME) (Zahlenkette). It is an internal identifier that the user never sees on the Home Screen. PRODUCT_NAME, the Xcode project name, module and package names (ZahlenketteKit, ZK*), the bundle ID de.zahlenkette.app, the StoreKit product IDs and the subscription group reference name are internal. They do not change on a rename.
  • The display name is not localized (there is no InfoPlist.xcstrings entry). All locales show the same name.

20.19.2 Swift access #

// ZKCore/Brand.swift
import Foundation

public enum Brand {
    /// Used only if CFBundleDisplayName is missing (e.g. in unit-test hosts).
    public static let fallbackName = "Zahlenkette"

    /// The user-visible app name. Always use this; never hardcode the name.
    public static var appName: String {
        if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String,
           !name.trimmingCharacters(in: .whitespaces).isEmpty {
            return name
        }
        return fallbackName
    }

    public static var bundleIdentifier: String {
        Bundle.main.bundleIdentifier ?? "de.zahlenkette.app"
    }

    /// Support and legal links from Config/Brand.xcconfig via Info.plist. Nil if a key is missing or malformed;
    /// the parent area then hides the corresponding row.
    public static var privacyPolicyURL: URL? { url(forInfoKey: "PRIVACY_POLICY_URL") }
    public static var imprintURL: URL? { url(forInfoKey: "IMPRINT_URL") }
    public static var supportURL: URL? { url(forInfoKey: "SUPPORT_URL") }
    public static var supportEmail: String? {
        Bundle.main.object(forInfoDictionaryKey: "SUPPORT_EMAIL") as? String
    }

    private static func url(forInfoKey key: String) -> URL? {
        guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String else { return nil }
        return URL(string: value)
    }
}

20.19.3 Rules for localized strings #

  • Catalog values never contain the literal app name. They interpolate it:
    • parent.help.about %@ → "Über %@", used as L10n.ui("parent.help.about \(Brand.appName)")
    • paywall.title %@ → "%@ Premium"
  • The name is only placed where German grammar does not depend on its gender, case or article: titles, "Über %@", "%@ Premium", quoted names. Constructions such as "die %@-App" or "in der %@" are forbidden, because a future name may have a different gender.
  • Child-area screens show no text and therefore no name.

20.19.4 Marketing texts #

  • Source texts live in Marketing/de/ (Markdown or plain text: App Store description, subtitle, promotional text, keywords, press text; the content is owned by Section 22). They use the token {{APP_NAME}} wherever the name appears.
  • scripts/render-marketing.sh renders them into Marketing/rendered/<locale>/. The rendered folder is git-ignored (Section 6.11) and is what gets pasted into App Store Connect.
#!/usr/bin/env bash
# scripts/render-marketing.sh
# Replaces {{APP_NAME}} in Marketing/<locale>/ sources with APP_DISPLAY_NAME from Config/Brand.xcconfig.
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
xcconfig="$repo_root/Config/Brand.xcconfig"
name="$(sed -nE 's/^[[:space:]]*APP_DISPLAY_NAME[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$xcconfig" | head -n 1)"
if [[ -z "$name" ]]; then
  echo "error: APP_DISPLAY_NAME not found in Config/Brand.xcconfig" >&2
  exit 1
fi

# Escape characters that are special in a sed replacement (backslash, ampersand, delimiter).
escaped="$(printf '%s' "$name" | sed -e 's/[\\&|]/\\&/g')"

out_root="$repo_root/Marketing/rendered"
rm -rf "$out_root"

find "$repo_root/Marketing" -type f \( -name '*.md' -o -name '*.txt' \) -not -path "$out_root/*" -print0 |
while IFS= read -r -d '' src; do
  rel="${src#"$repo_root/Marketing/"}"
  dst="$out_root/$rel"
  mkdir -p "$(dirname "$dst")"
  sed "s|{{APP_NAME}}|$escaped|g" "$src" > "$dst"
  if grep -q '{{[A-Z_]*}}' "$dst"; then
    echo "error: unresolved token in Marketing/$rel" >&2
    exit 1
  fi
done

echo "Rendered marketing texts with APP_DISPLAY_NAME=\"$name\" into Marketing/rendered/"

20.19.5 Audio never speaks the app name #

  • No voice line contains the app name or the name of a game (Section 21.1.4). The word "Zahlenkette" is also not used as a common noun in audio (for example for a bead chain), so that the audio has no connection to the brand. Bead chains are called "Perlenkette" in all lines (Section 21).
  • The QA checklist (20.15.9) and the forbidden-word list (Section 21.1.4) enforce this.
  • The app icon and the launch screen contain no wordmark or text (Section 19), so they are rename-proof.

20.19.6 Automated guard #

scripts/check-brand.sh runs in CI and as a test-phase script. It fails if the literal current APP_DISPLAY_NAME value (read from the xcconfig), or the literal "Zahlenkette" as a user-visible string, appears in:

  • any value in Localizable.xcstrings or Content.xcstrings,
  • any .swift file except ZKCore/Brand.swift, as a string literal (module and type names are not string literals and are not matched),
  • any file in Marketing/<locale>/ outside {{APP_NAME}} tokens.

It ignores identifiers such as ZahlenketteKit, Zahlenkette.xcodeproj, de.zahlenkette.app and the xcconfig itself.

20.19.7 Rename checklist #

The launch checklist in Section 26 covers App Store name availability and DPMA/EUIPO and domain checks for a new name. The technical steps of a rename are:

  1. Change APP_DISPLAY_NAME in Config/Brand.xcconfig. Keep it at 12 characters or fewer so it is not truncated on the Home Screen; verify on an iPhone SE.
  2. Change Brand.fallbackName in ZKCore/Brand.swift.
  3. Run scripts/check-brand.sh (it now checks for the new name; also search manually for the old name in catalog values).
  4. Run scripts/render-marketing.sh and paste the output into App Store Connect: app name (up to 30 characters), subtitle, description, promotional text, keywords.
  5. App Store Connect, subscriptions: update the subscription group display name and the localized display names and descriptions of both products if they contain the name. The reference name "Zahlenkette Premium" is internal and stays.
  6. Re-render App Store screenshots and app previews that show the name (screenshots of the child area show none).
  7. Update the privacy policy, imprint and support pages, the support e-mail signature and the domain (Section 23, Section 26).
  8. Audio: no action. Confirm with the checklist in 20.15.9 that no line contains the name.
  9. App icon and launch screen: no action (no wordmark).
  10. Ship as a normal update. The bundle ID, product IDs, data and subscriptions are unaffected, and users keep everything.

20.20 Acceptance criteria #

# Given When Then
AC-20.1 A physical iPhone with the Ring/Silent switch set to silent, voice on The child enters S-05 The greeting is audible
AC-20.2 A podcast playing in another app The app launches into S-02 and the parental gate (first launch) The podcast keeps playing until the parent taps "Ton testen" on S-03 or the first child-mode screen appears; after the sound check it may resume
AC-20.3 Hör hin task prompt "Wo ist die Sieben?" playing The child taps a tile 900 ms after the prompt start The prompt stops within 40 ms and the feedback or hint utterance starts; the two never overlap
AC-20.4 Correct feedback playing The next task is presented Its prompt starts only after the feedback has finished (a 150 ms tolerance is measured between feedback end and prompt start)
AC-20.5 Resident cache loaded on an iPhone SE (2nd gen) The child taps a Zwanzigerfeld dot sfx.dot_on and the number word are audible in under 100 ms (signpost measurement)
AC-20.6 Music playing at 0.30 on S-05 A voice line starts Music is at 0.10 within 150 ms and returns to 0.30 within 1 s after the line ends
AC-20.7 Voice setting off A round of Wie viele? is played No voice is heard. The demo hand appears at every task start. Counting numerals appear above tapped objects. All tasks remain solvable.
AC-20.8 Voice setting off The child opens Hör hin, and separately the engine composes an Abenteuer Hör hin is playable with the quantity-card prompt and solvable; in Abenteuer composition it is deprioritised by the score penalty of Section 9.16.3, not excluded; its S-06 tile is not dimmed
AC-20.9 Voice setting off An Entdecken Zähl mit task is completed No mastery update is applied to the name skill. The recognize update is applied.
AC-20.10 A Release build Resources/Audio/de/num.13.q.m4a is removed The archive fails at scripts/validate-audio.sh
AC-20.11 A Debug build without prompt.memory.intro.m4a Memory starts TTS speaks the intro with a de-DE voice, a warning is logged and the developer content report lists the ID
AC-20.12 A voice line playing through wired headphones during a task The headphones are unplugged The voice stops, music pauses, no pause overlay appears, the speaker button pulses once, the round continues, and nothing plays through the speaker until the child taps again
AC-20.13 A round in progress A phone call arrives and ends after more than 3 s No audio plays automatically. After the child resumes, the current prompt is replayed (Section 10.12.3).
AC-20.14 APP_DISPLAY_NAME changed to "Zahlino" The app is built and scripts/render-marketing.sh is run The Home Screen shows "Zahlino", "Über Zahlino" appears in Hilfe & Rechtliches, Marketing/rendered/de/ contains "Zahlino" and no {{APP_NAME}}, and scripts/check-brand.sh passes
AC-20.15 A device language of French The app launches The UI and the voice are German
AC-20.16 The template common.quantity with n = 1 It is built The utterance is [prompt.common.das_ist_eins]. With n = 7 it is [prompt.common.das_sind, num.7].
AC-20.17 The QA "Audio-Prüfung" screen (Debug) A QA tester plays hoer_hin.task_where_is for n = 1…20 All 20 joins are rendered in sequence with the 250 ms sentence gap between them
AC-20.18 music.abenteuer playing on S-09 The Abenteuer ends and S-10 appears; later the daily limit shows S-16 The music fades out over 2 s on S-10 and no music plays on S-10 or S-16
AC-20.19 The template common.quantity_split with n = 17 It is built The utterance is [prompt.common.das_sind, num.17, pause, fb.solution.schau, num.10, prompt.common.und, num.7] ("Das sind siebzehn. Schau: zehn und sieben.")

21. Content and Audio Inventory #

This section is the complete list of every voice line, every utterance template, every sound effect, every music loop and every illustration the V1 app needs. Section 20 owns the audio behaviour, template syntax, recording and delivery specification. Section 8 owns the content file schemas that reference these IDs. Sections 11–13 own the game mechanics these lines accompany. Section 10 owns when the framework plays which line (hint ladder, feedback selection, inactivity).

21.1 How to read this inventory #

21.1.1 Table formats #

  • Recorded lines use the columns Audio ID | German text | Used by | Notes. Every row is exactly one studio recording and one file, Resources/Audio/de/<audioId>.m4a.
  • Templates use the columns Template ID | Segments | Example | Used by. A template is a runtime composition of recorded lines (Section 20.6) and has no file of its own. Template IDs are not audio IDs. Shared templates live in prompts.json under templates; game templates live in games/<gameId>.json under utterances (Section 8).
  • "…" in a German text marks a number slot. In Content.xcstrings the slot is written %@ (Section 20.17.3). The recorded fragment contains only the words, never the number. A line whose number slot sits inside the sentence is recorded as consecutive fragments, one file each: every fragment has its own audio ID, named by the owning game section after its words or its role, with no numbered-suffix convention (for example hint.zahlenmonster.more "Mmh! Das waren …", hint.zahlenmonster.i_want "… Ich möchte …" and prompt.schuettelbox.perlen "… Perlen."). The game section and this inventory list the fragments in speaking order. The game's template joins them with the numbers (Section 20.6).
  • Binding names in templates ({n}, {a}, {s}, …) are defined in Section 20.6.2. The caller (game or framework) supplies their values.
  • "Used by" names screens by their Section 18 IDs (S-01 … S-27) and games by their GameID raw value.

21.1.2 Language rules for every line #

  1. Standard German spelling (current official orthography). Neutral vocabulary accepted in Germany, Austria and Switzerland.
  2. A numeral is a feminine noun: "die Sieben", "bei der Eins", "zur Vier". A quantity uses the counting word without an article: "sieben Äpfel", "Das sind sieben."
  3. Lines are short: prompts at most 12 words, feedback at most 6 words, hints at most 14 words.
  4. Address the child with "du". The tone is warm, calm and encouraging.
  5. Imperatives use standard forms: "Schüttle", "Füttere", "Tipp", "Zähl", "Hüpf", "Mal", "Zieh", "Dreh", "Schau", "Hör".
  6. Bead chains are always called "Perlenkette". The word "Zahlenkette" never appears in audio (Section 20.19.5).

21.1.3 Performance styles #

Style Used for Delivery
Narrator All prompts, hints, solutions, session lines Warm, clear, calm, medium tempo
Praise fb.correct.*, celebration lines Bright and happy, never shrill
Encourage fb.tryagain.* Warm, light, no disappointment
Monster prompt.zahlenmonster.give_me, yum, thanks, hint.zahlenmonster.*, fb.solution.so_viele_wollte_ich Playful, slightly deeper and rounder, friendly, never scary (same speaker, no processing)
Friend friend.*, session.break_nudge Playful, a little higher energy (same speaker, no processing)
Soft Abenteuer end, time's up, break nudge Slow, soft, bedtime-story

21.1.4 Forbidden in any voice line #

The following never appear in a voice line:

  • The app name.
  • The word "Zahlenkette".
  • The name of a game. Game names are shown only in the parent area; the child identifies games by their tile artwork.
  • The child's name or nickname.
  • "falsch".
  • "Nein" as a correction.
  • "leider", "schade", "Fehler", "verloren".
  • "schnell", "beeil dich", "die Zeit läuft".
  • "kaufen", "Geld", "Preis", "gratis", "kostenlos", "Abo", "freischalten".
  • "links" and "rechts" (left–right discrimination is unreliable before school age; lines say "hier" and "auf der anderen Seite", or "weiter" and "zurück").
  • Any comparison with other children.
  • Any threat of losing something.
  • Any mention of streaks or "jeden Tag".
  • Any nudge to keep playing ("Spiel noch ein bisschen!") or to come back ("… freuen sich schon auf morgen").

21.1.5 Ownership of IDs and texts #

Audio ID family Owner of the ID and the German text Role of this section
prompt.<gameId>.*, hint.<gameId>.*, label.* The game sections: Section 11 (free games), Section 12 (premium games I), Section 13 (premium games II); label.dot.* names follow the picture set of Section 14.7.2 Mirrors them verbatim in Section 21.5 and adds the templates that compose them
num.*, prompt.common.* (carriers), fb.*, hint.common.*, session.*, friend.*, sfx.*, music.* This section Defines the ID and the text. Behaviour sections (10, 14, 15, 17, 18) reference these IDs exactly.

Session IDs use the form session.<snake_case> with no further dots. Every audio ID that a behaviour section (Sections 10–18) plays is listed here; the content validation checks this through CodeReferencedVoiceLines.all (Section 8, validation code C014).

21.2 Number words #

41 recordings: num.0 plus 20 statement files and 20 question files. All 20 statement files and all 20 question files are recorded in one sitting, in pairs, to keep the voice consistent (Section 20.15.3).

Audio ID German text Used by Notes
num.0 null nachspuren only: prompt.nachspuren.next_digit for the second digit of 10 and 20 ("Und jetzt die Null.") Statement only; there is no num.0.q. Declared through the optional zero entry of numbers.json (Section 8.6); it carries no mastery. No other game speaks "null": the Froschsprung start bank is unlabeled (Section 13.1).
num.1 eins All counting, all {n} slots Full final "s". Never "ein" alone.
num.2 zwei same Never "zwo"
num.3 drei same
num.4 vier same
num.5 fünf same, including every {split} for 6–10
num.6 sechs same /ks/ ending
num.7 sieben same Dummy number for carrier recordings
num.8 acht same
num.9 neun same
num.10 zehn same, including every {split} for 11–20
num.11 elf same
num.12 zwölf same
num.13 dreizehn same
num.14 vierzehn same
num.15 fünfzehn same Not "fuffzehn"
num.16 sechzehn same /ç/, not /ks/
num.17 siebzehn same Not "siebenzehn"
num.18 achtzehn same
num.19 neunzehn same
num.20 zwanzig same Ending /ɪç/ (Section 20.15.2)
num.1.q Eins? {n.q} slots: prompt.hoer_hin.where_is, hint.froschsprung.look_numbers, hint.punkt_zu_punkt.where, hint.memory.where_other Rising
num.2.q Zwei? same Rising
num.3.q Drei? same Rising
num.4.q Vier? same Rising
num.5.q Fünf? same Rising
num.6.q Sechs? same Rising
num.7.q Sieben? same Rising
num.8.q Acht? same Rising
num.9.q Neun? same Rising
num.10.q Zehn? same Rising
num.11.q Elf? same Rising
num.12.q Zwölf? same Rising
num.13.q Dreizehn? same Rising on "-zehn"
num.14.q Vierzehn? same Rising on "-zehn"
num.15.q Fünfzehn? same Rising on "-zehn"
num.16.q Sechzehn? same Rising on "-zehn"
num.17.q Siebzehn? same Rising on "-zehn"
num.18.q Achtzehn? same Rising on "-zehn"
num.19.q Neunzehn? same Rising on "-zehn"
num.20.q Zwanzig? same Rising

The written number words shown in Entdecken and in the parent area come from the Content.xcstrings keys number.<n>.word (lowercase counting form, e.g. "sieben"; key convention in Section 8.3). The spoken text of each recording is stored under its audio ID (num.7, num.7.q). Section 11 decides the capitalisation used on screen.

21.3 Shared carrier and solution fragments #

21.3.1 Carrier fragments (prompt.common.*) #

Carrier fragments are declared in prompts.json with the category carrier (Section 8.7), so any game and the framework may use them. Each fragment is recorded as a whole sentence with the dummy shown in "Record as" and cut before or after the number (Section 20.15.5).

Audio ID German text Used by Notes (record as)
prompt.common.das_ist_die Das ist die … common.numeral, common.numeral_split (numeral solutions, Section 10.8.3) "Das ist die | Sieben."
prompt.common.das_sind Das sind … common.quantity, common.quantity_split (quantity solutions, Section 10.8.3) "Das sind | sieben." Only for n ≥ 2.
prompt.common.das_ist_eins Das ist eins. n = 1 variant of the quantity templates Whole line
prompt.common.und … und … {split} expansion, schuettelbox.solution, mehr_weniger.solution_equal Recorded as "fünf | und | zwei", keeping the middle word only
prompt.common.nach_der Nach der … punkt_zu_punkt.solution "Nach der | Sechs kommt die Sieben."
prompt.common.kommt_die … kommt die … punkt_zu_punkt.solution, punkt_zu_punkt.hint_l2 "Nach der Sechs | kommt die | Sieben."

21.3.2 Solution fragments (fb.solution.*) #

Audio ID German text Used by Notes (record as)
fb.solution.schau Schau: common.numeral_split, common.quantity_split, entdecken.hint_l2_split, was_fehlt.solution, zahlenmonster.solution "Schau: | fünf und zwei." Warm and explanatory.
fb.solution.hier_fehlt_die Hier fehlt die … was_fehlt "Hier fehlt die | Sechs."
fb.solution.ist_mehr_als … ist mehr als … mehr_weniger, sets and mixed, "mehr" asked "Neun | ist mehr als | sechs." The correct side is highlighted
fb.solution.ist_weniger_als … ist weniger als … mehr_weniger, sets and mixed, "weniger" asked "Sechs | ist weniger als | neun."
fb.solution.gleich_viele Gleich viele! mehr_weniger, equality in every task type "Gleich viele! | Sieben und sieben."
fb.solution.ist_groesser_als … ist größer als … mehr_weniger, numeralsBigger "Neun | ist größer als | sechs."
fb.solution.ist_kleiner_als … ist kleiner als … mehr_weniger, numeralsSmaller "Sechs | ist kleiner als | neun."
fb.solution.so_schreibt_man So schreibt man die … nachspuren "So schreibt man die | Sieben."
fb.solution.ist … ist … schuettelbox "Sieben | ist | drei und vier."
fb.solution.hier_ist_die Hier ist die … froschsprung jumpTo, landmarkJump, whereIsFrog "Hier ist die | Sechs."
fb.solution.weiter_von_der … weiter von der … froschsprung relative, weiter "Zwei | weiter von der | Vier ist die Sechs."
fb.solution.zurueck_von_der … zurück von der … froschsprung relative, zurück "Zwei | zurück von der | Sechs ist die Vier."
fb.solution.ist_die … ist die … froschsprung relative "Zwei weiter von der Vier | ist die | Sechs."
fb.solution.so_viele_wollte_ich … So viele wollte ich. zahlenmonster Monster style. "Schau: sieben. | So viele wollte ich."
fb.solution.hier_ist_die_andere Hier ist die andere … memory "Hier ist die andere | Sieben."

21.4 Shared templates #

These templates live in prompts.json under templates (Section 8.7). The spoken decomposition {split n} is: 1–5 nothing; 6–9 "fünf und (n−5)"; 10 "fünf und fünf"; 11–19 "zehn und (n−10)"; 20 "zehn und zehn" (Section 4, DR-09 and DR-43).

Template ID Segments Example Used by
common.numeral prompt.common.das_ist_die, {n} "Das ist die Drei." Numeral solutions (Section 10.8.3)
common.numeral_split prompt.common.das_ist_die, {n}, _, fb.solution.schau, {split n}. Variants n = 1…5: prompt.common.das_ist_die, {n} "Das ist die Sieben. Schau: fünf und zwei." / "Das ist die Siebzehn. Schau: zehn und sieben." Numeral solutions (Section 10.8.3): hoer_hin
common.quantity prompt.common.das_sind, {n}. Variant n = 1: prompt.common.das_ist_eins "Das sind vier." / "Das ist eins." Quantity solutions (Section 10.8.3)
common.quantity_split prompt.common.das_sind, {n}, _, fb.solution.schau, {split n}. Variant n = 1: prompt.common.das_ist_eins. Variants n = 2…5: prompt.common.das_sind, {n} "Das sind siebzehn. Schau: zehn und sieben." Quantity solutions (Section 10.8.3): entdecken, wie_viele, blitzblick

Composition done by the framework, not by a template (Section 10):

  • Correct feedback: num.<n> (when the answer is a number), then the fb.correct line 0.2 s later (Section 10.8.1). Every fb.correct line is a standalone sentence without a number slot, so no template is needed.
  • Try-again before hint level 1: fb.tryagain.<nn>, _, then the hint's segments (Section 10.7.2).
  • After every solution line: hint.common.tap_glowing (Section 10.7.4).
  • Inactivity re-prompt after 20 s: hint.common.listen_again, _, then the current task prompt's segments (Section 10.11).

21.5 Game lines #

The game sections own every prompt.<gameId>.*, hint.<gameId>.* and label.* line: their IDs, their German text and when they play (Sections 11.2–11.5, 12.2–12.5, 13.1–13.4). The tables below mirror them. The hint ladder follows Section 10.7: level 1 after the first wrong attempt (preceded by a fb.tryagain line), level 2 after the second, and the solution demonstration after the third; the child then taps the highlighted answer. No game line speaks the game's name (Section 21.1.4). The game templates live in each games/<gameId>.json under utterances (Section 8.8).

21.5.1 Entdecken (entdecken) #

Section 11.2. Free explore records nothing and is never part of an Abenteuer; "Zähl mit" is the mastery mode.

Audio ID German text Used by Notes
prompt.entdecken.choose_mode Was möchtest du machen? Mode chooser entry (Section 11.2.3)
prompt.entdecken.mode_explore Hier kannst du die Punkte entdecken. Mode chooser, while the "Entdecken" tile is highlighted
prompt.entdecken.mode_zaehl_mit Hier zählen wir zusammen. Mode chooser, while the "Zähl mit" tile is highlighted
prompt.entdecken.explore_intro Tipp auf einen Punkt! Free explore entry; speaker button while the field is empty
prompt.entdecken.intro Wir zählen zusammen! Zähl mit round intro
prompt.entdecken.count_to Wir zählen bis … countAlong task (step 1) "Wir zählen bis | sieben."
prompt.entdecken.tap_each Tipp jeden Punkt an! countAlong task, after count_to
prompt.entdecken.show_me Zeig mir … showMe (n ≥ 2) and withFive tasks "Zeig mir | sieben Punkte."
prompt.entdecken.dots … Punkte. After show_me + number "Zeig mir sieben | Punkte." The only noun composed with a number word (Section 20.6.5).
prompt.entdecken.show_me_one Zeig mir einen Punkt. showMe, n = 1 Whole line
prompt.entdecken.backward_to Wir zählen rückwärts bis … backward task (step 3) "Wir zählen rückwärts bis | sieben."
prompt.entdecken.tap_last Tipp immer auf den letzten Punkt. backward task, after backward_to
prompt.entdecken.five_tip Mit der Fünf geht es ganz leicht! withFive task (step 3, Vorschule), appended
prompt.entdecken.press_check Wenn du fertig bist, tipp auf den Haken. Appended to the first task of a step 1 round
hint.entdecken.you_have Du hast … Hint level 1 "Du hast | sechs."
hint.entdecken.we_need … Wir brauchen … Hint level 1, between the child's count and the target "Du hast sechs. | Wir brauchen | sieben."
hint.entdecken.up_to_here Bis hierhin! Hint level 2 The target dot gets a ring
Template ID Segments Example Used by
entdecken.choose_mode, entdecken.mode_explore, entdecken.mode_zaehl_mit, entdecken.explore_intro, entdecken.intro, entdecken.press_check the matching single line Mode chooser, free explore entry, round intro, check-button tip
entdecken.explore_number {c} "sieben" Free explore: dot tap, numeral or word tap
entdecken.explore_split {split c} (for c ≤ 5 the game speaks {c} instead, Section 11.2.6) "zehn und sieben" Free explore: tap on the split view
entdecken.explore_speaker {c}, _, {split c} "Siebzehn. Zehn und sieben." Free explore: speaker button with c ≥ 1
entdecken.count_step {k} "drei" Every dot change in Zähl mit (priority .feedback)
entdecken.task_count_along prompt.entdecken.count_to, {n}, _, prompt.entdecken.tap_each "Wir zählen bis sieben. Tipp jeden Punkt an!" Step 1
entdecken.task_show_me prompt.entdecken.show_me, {n}, prompt.entdecken.dots. Variant n = 1: prompt.entdecken.show_me_one "Zeig mir sieben Punkte." Step 2
entdecken.task_backward prompt.entdecken.backward_to, {n}, _, prompt.entdecken.tap_last "Wir zählen rückwärts bis sieben. Tipp immer auf den letzten Punkt." Step 3
entdecken.task_with_five prompt.entdecken.show_me, {n}, prompt.entdecken.dots, _, prompt.entdecken.five_tip "Zeig mir sieben Punkte. Mit der Fünf geht es ganz leicht!" Step 3, Vorschule
entdecken.hint_l1 hint.entdecken.you_have, {c}, _, hint.entdecken.we_need, {n} "Du hast sechs. Wir brauchen sieben." Level 1 (c = the child's committed count)
entdecken.hint_l2 hint.entdecken.up_to_here "Bis hierhin!" Level 2, n ≤ 5
entdecken.hint_l2_split hint.entdecken.up_to_here, _, fb.solution.schau, {split n} "Bis hierhin! Schau: fünf und zwei." Level 2, n ≥ 6
common.quantity_split (shared) Section 21.4 "Das sind sieben. Schau: fünf und zwei." Solution

21.5.2 Wie viele? (wie_viele) #

Section 11.3. One task prompt per counting-object type of the catalogue in Section 11.1.4 (the prompt names the object, so each is a whole recorded line).

Audio ID German text Used by Notes
prompt.wie_viele.intro Wie viele sind es? Zähl ganz genau! Round intro
prompt.wie_viele.mark_tip Tipp jedes an, das du gezählt hast. Appended once per profile to the first task of the first step 2 or step 3 round Teaches one-to-one marking
prompt.wie_viele.how_many.apfel Wie viele Äpfel sind das? Task prompt, object type apfel
prompt.wie_viele.how_many.birne Wie viele Birnen sind das? Task prompt, object type birne
prompt.wie_viele.how_many.erdbeere Wie viele Erdbeeren sind das? Task prompt, object type erdbeere
prompt.wie_viele.how_many.karotte Wie viele Karotten sind das? Task prompt, object type karotte
prompt.wie_viele.how_many.banane Wie viele Bananen sind das? Task prompt, object type banane
prompt.wie_viele.how_many.stern Wie viele Sterne sind das? Task prompt, object type stern
prompt.wie_viele.how_many.herz Wie viele Herzen sind das? Task prompt, object type herz
prompt.wie_viele.how_many.blume Wie viele Blumen sind das? Task prompt, object type blume
prompt.wie_viele.how_many.muschel Wie viele Muscheln sind das? Task prompt, object type muschel
prompt.wie_viele.how_many.knopf Wie viele Knöpfe sind das? Task prompt, object type knopf
prompt.wie_viele.how_many.ball Wie viele Bälle rollen da? Task prompt, object type ball Movable type: movement verb
prompt.wie_viele.how_many.ente Wie viele Enten schwimmen da? Task prompt, object type ente Movable type: movement verb
prompt.wie_viele.how_many.fisch Wie viele Fische schwimmen da? Task prompt, object type fisch Movable type: movement verb
prompt.wie_viele.how_many.schmetterling Wie viele Schmetterlinge fliegen da? Task prompt, object type schmetterling Movable type: movement verb
prompt.wie_viele.how_many.marienkaefer Wie viele Marienkäfer krabbeln da? Task prompt, object type marienkaefer Movable type: movement verb
prompt.wie_viele.how_many.vogel Wie viele Vögel fliegen da? Task prompt, object type vogel Movable type: movement verb
prompt.wie_viele.how_many.auto Wie viele Autos fahren da? Task prompt, object type auto Movable type: movement verb
prompt.wie_viele.how_many.schnecke Wie viele Schnecken kriechen da? Task prompt, object type schnecke Movable type: movement verb
hint.wie_viele.count_again Zähl nochmal ganz genau. Tipp jedes an, das du gezählt hast. Hint level 1
hint.wie_viele.count_with_me Wir zählen zusammen. Hint level 2, before the count-along
Template ID Segments Example Used by
wie_viele.intro, wie_viele.mark_tip the matching single line Round intro, marking tip
wie_viele.hint_l1 hint.wie_viele.count_again Level 1
wie_viele.hint_l2 hint.wie_viele.count_with_me, _, {count s t} "Wir zählen zusammen. Eins … zwei … drei." Level 2 (s = 1, t = n; the game adds a 0.3 s pause after every fifth object)
common.quantity_split (shared) Section 21.4 "Das sind siebzehn. Schau: zehn und sieben." Solution

The task prompt is the single line prompt.wie_viele.how_many.<typeID> of the task's object type.

21.5.3 Hör hin (hoer_hin) #

Section 11.4. The task prompt is the stimulus. With voice off the game stays playable: a quantity card replaces the spoken number (Section 11.4.6).

Audio ID German text Used by Notes
prompt.hoer_hin.intro Hör gut zu! Round intro
prompt.hoer_hin.where_is Wo ist die …? Task variant A "Wo ist die | Sieben?" The number uses num.<n>.q.
prompt.hoer_hin.show_me Zeig mir die … Task variant B "Zeig mir die | Sieben."
hint.hoer_hin.listen_again Hör nochmal: … Hint level 1 "Hör nochmal: | sieben." Spoken slightly slower
hint.hoer_hin.so_many Schau, so viele: … Hint level 2, with the quantity card "Schau, so viele: | sieben."
Template ID Segments Example Used by
hoer_hin.intro prompt.hoer_hin.intro "Hör gut zu!" Round intro
hoer_hin.task_where_is prompt.hoer_hin.where_is, {n.q} "Wo ist die Sieben?" Task variant A
hoer_hin.task_show_me prompt.hoer_hin.show_me, {n} "Zeig mir die Sieben." Task variant B (variant choice per Section 11.4.4)
hoer_hin.hint_l1 hint.hoer_hin.listen_again, {n} "Hör nochmal: sieben." Level 1
hoer_hin.hint_l2 hint.hoer_hin.so_many, {n} "Schau, so viele: sieben." Level 2
common.numeral_split (shared) Section 21.4 "Das ist die Sieben. Schau: fünf und zwei." Solution

21.5.4 Was fehlt? (was_fehlt) #

Section 11.5. Bead chains are always called "Perlenkette".

Audio ID German text Used by Notes
prompt.was_fehlt.intro Oh, in der Perlenkette fehlen Perlen! Round intro
prompt.was_fehlt.which Welche Zahl fehlt? oneGap, oneGapLong
prompt.was_fehlt.two Hier fehlen zwei Zahlen. Welche? twoGaps
prompt.was_fehlt.backward Wir zählen rückwärts. Welche Zahl fehlt? backward
prompt.was_fehlt.one_more Super, und welche fehlt noch? twoGaps, after the first gap is filled
hint.was_fehlt.neighbours Schau dir die Nachbarn an. Hint level 1
hint.was_fehlt.count_with_me Zähl mit: Hint level 2, before the count-along
hint.was_fehlt.and_then … und dann? Hint level 2, directly after the count-along Rising intonation
Template ID Segments Example Used by
was_fehlt.intro, was_fehlt.task, was_fehlt.task_two, was_fehlt.task_backward the matching single line (intro, which, two, backward) Round intro and task prompts
was_fehlt.partial {v}, _, prompt.was_fehlt.one_more "Sechs. Super, und welche fehlt noch?" twoGaps partial success (v = placed bead)
was_fehlt.hint_l1 hint.was_fehlt.neighbours Level 1
was_fehlt.hint_l2 hint.was_fehlt.count_with_me, {count s m}, hint.was_fehlt.and_then "Zähl mit: drei … vier … fünf … und dann?" Level 2 (s, m = first and last of up to three beads before the gap in counting direction; descending for backward)
was_fehlt.solution fb.solution.hier_fehlt_die, {n}, _, fb.solution.schau, {count p q} "Hier fehlt die Sechs. Schau: fünf … sechs … sieben." Solution (p = bead before the gap, q = bead after it in counting direction; q = n when the gap is the last bead)

21.5.5 Blitzblick (blitzblick) #

Section 12.2. The flash duration is a display time, not a response timer; the intro says so.

Audio ID German text Used by Notes
prompt.blitzblick.intro Schau genau hin! Die Punkte sind nur kurz zu sehen. Danach hast du ganz viel Zeit. First task of each round, before tap_card Stresses the unlimited answer time
prompt.blitzblick.tap_card Tipp auf die Karte! Every task, ready phase
prompt.blitzblick.how_many Wie viele waren es? Answer phase, numeral answers
prompt.blitzblick.find_same Wo sind genauso viele? Answer phase, quantity-card answers
hint.blitzblick.look_again Schau noch mal genau hin. Hint level 1 (the card re-flashes)
hint.blitzblick.count_together Wir zählen zusammen. Hint level 2, n ≤ 5
hint.blitzblick.five Schau: Das sind fünf. Hint level 2, n = 6–10, while the five-group glows
hint.blitzblick.ten Schau: Das sind zehn. Hint level 2, n ≥ 11, while the first row glows
hint.blitzblick.and_more … und noch … Hint level 2, after five or ten, naming the remaining count "Das sind fünf | und noch | zwei."
Template ID Segments Example Used by
blitzblick.hint_l2_small hint.blitzblick.count_together, _, {count s t} "Wir zählen zusammen. Eins … zwei … drei." Level 2, n ≤ 5 (s = 1, t = n)
blitzblick.hint_l2_five hint.blitzblick.five, hint.blitzblick.and_more, {r}, _, {count a n} "Schau: Das sind fünf und noch zwei. Sechs … sieben." Level 2, n = 6–10 (r = n − 5, a = 6)
blitzblick.hint_l2_ten hint.blitzblick.ten, hint.blitzblick.and_more, {r}, _, {count a n} "Schau: Das sind zehn und noch zwei. Elf … zwölf." Level 2, n = 11–20 (r = n − 10, a = 11); for n = 20: "… zehn und noch zehn."
blitzblick.solution common.quantity_split "Das sind sieben. Schau: fünf und zwei." Solution (Section 12.2.11)

21.5.6 Mehr oder weniger (mehr_weniger) #

Section 12.3. The same sentence confirms a correct answer and demonstrates the solution (Sections 12.3.10 and 12.3.11). The side is always shown by highlighting; no line says "links" or "rechts".

Audio ID German text Used by Notes
prompt.mehr_weniger.intro Schau dir beide Seiten an. First task of the round
prompt.mehr_weniger.more Wo sind mehr? setsMore, mixedMore
prompt.mehr_weniger.less Wo sind weniger? setsLess, mixedLess
prompt.mehr_weniger.bigger Welche Zahl ist größer? numeralsBigger
prompt.mehr_weniger.smaller Welche Zahl ist kleiner? numeralsSmaller
prompt.mehr_weniger.or_equal Oder sind es gleich viele? Appended to more/less when the gleich button exists
prompt.mehr_weniger.or_equal_numbers Oder sind beide gleich? Appended to bigger/smaller when the gleich button exists
hint.mehr_weniger.look_groups Schau auf die Fünfer. Hint level 1, quantity sides
hint.mehr_weniger.show_amounts So viele sind das. Hint level 1, numeral sides
hint.mehr_weniger.pair_up Wir legen sie nebeneinander. Hint level 2 (pairing animation)
hint.mehr_weniger.number_line Auf dem Zahlenstrahl: Weiter hinten ist mehr. Hint level 2, numeral-only tasks at step 3 with a number > 10 Shown with an arrow pictogram
Template ID Segments Example Used by
mehr_weniger.task_more_eq prompt.mehr_weniger.more, _, prompt.mehr_weniger.or_equal "Wo sind mehr? Oder sind es gleich viele?" With the gleich button (same pattern for less)
mehr_weniger.task_bigger_eq prompt.mehr_weniger.bigger, _, prompt.mehr_weniger.or_equal_numbers "Welche Zahl ist größer? Oder sind beide gleich?" With the gleich button (same pattern for smaller)
mehr_weniger.solution_more {a}, fb.solution.ist_mehr_als, {b} "Neun ist mehr als sechs." Sets and mixed, "mehr" asked (a = larger)
mehr_weniger.solution_less {b}, fb.solution.ist_weniger_als, {a} "Sechs ist weniger als neun." Sets and mixed, "weniger" asked (b = smaller)
mehr_weniger.solution_equal fb.solution.gleich_viele, {n}, prompt.common.und, {n} "Gleich viele! Sieben und sieben." Equality, every task type
mehr_weniger.solution_bigger {a}, fb.solution.ist_groesser_als, {b} "Neun ist größer als sechs." numeralsBigger (a = larger)
mehr_weniger.solution_smaller {a}, fb.solution.ist_kleiner_als, {b} "Sechs ist kleiner als neun." numeralsSmaller (a = smaller)

The plain task templates without the gleich button are the single prompt lines.

21.5.7 Nachspuren (nachspuren) #

Section 12.4. "Finger" also covers Apple Pencil use; there is no separate line.

Audio ID German text Used by Notes
prompt.nachspuren.intro Wir schreiben Zahlen. Fahr mit dem Finger nach. First task of a round
prompt.nachspuren.trace Schreib die … Every task "Schreib die | Sieben."
prompt.nachspuren.write Schreib die … Step 3, first fragment "Schreib die | Sieben ganz allein."
prompt.nachspuren.ganz_allein … ganz allein. Step 3, second fragment, after prompt.nachspuren.write + num.<n> "Schreib die Sieben | ganz allein."
prompt.nachspuren.next_digit Und jetzt die … Second digit of 10–20 "Und jetzt die | Sieben." For 10 and 20 the digit is num.0 ("Und jetzt die Null.")
prompt.nachspuren.done Die …! Correct feedback, first fragment (replaces fb.correct in this game) "Die | Sieben! Schön geschrieben." Praise style
prompt.nachspuren.schoen_geschrieben … Schön geschrieben. Correct feedback, second fragment, after prompt.nachspuren.done + num.<n> "Die Sieben! | Schön geschrieben."
hint.nachspuren.start_here_top Fang hier oben an. Hint level 1 after a wrong start or wrong order, start point in the upper part Always used with the V1 glyphs
hint.nachspuren.start_here Fang hier an. Same, start point lower For future glyph data
hint.nachspuren.follow_arrow Fahr in Pfeilrichtung. Hint level 1 after a wrong direction
hint.nachspuren.stay_on_path Bleib schön auf dem Weg. Hint level 1 after an incomplete or off-path stroke
hint.nachspuren.watch_me Schau, ich zeig es dir. Dann du. Hint level 2 (demo hand traces)
Template ID Segments Example Used by
nachspuren.task_trace prompt.nachspuren.trace, {n} "Schreib die Sieben." Steps 1–2
nachspuren.task_write prompt.nachspuren.write, {n}, prompt.nachspuren.ganz_allein "Schreib die Sieben ganz allein." Step 3
nachspuren.next_digit prompt.nachspuren.next_digit, {d} "Und jetzt die Null." After the first digit of 10–20 (d = second digit, 0…9)
nachspuren.done prompt.nachspuren.done, {n}, prompt.nachspuren.schoen_geschrieben "Die Sieben! Schön geschrieben." Correct feedback
nachspuren.solution fb.solution.so_schreibt_man, {n} "So schreibt man die Sieben." Solution demonstration

21.5.8 Schüttelbox (schuettelbox) #

Section 12.5. Lines never say "links" or "rechts": the compartment asked about is outlined and named "hier" or "auf der anderen Seite". Parts are never 0 (Section 12.5).

Audio ID German text Used by Notes
prompt.schuettelbox.intro Das ist die Schüttelbox. First task of a round, before here_are
prompt.schuettelbox.here_are Hier sind … Tray phase, before num.<N>; step 3 and step 3 hint level 1, before the visible part l ≥ 2 "Hier sind | sieben Perlen."
prompt.schuettelbox.perlen … Perlen. Tray phase after here_are + num.<N>; step 3 after num.<N> "Hier sind sieben | Perlen."
prompt.schuettelbox.here_is_one Hier ist eine. Replaces here_are + num.<l> whenever l = 1 (step 3 prompt and step 3 hint level 1) Whole line
prompt.schuettelbox.shake Schüttle die Box! Shake phase
prompt.schuettelbox.shake_button Drück auf den Knopf und schüttle die Box! Shake phase when motion is off or unavailable
prompt.schuettelbox.open Mach die Box auf! Open phase
prompt.schuettelbox.which_house Wie sind die Perlen gefallen? Step 1
prompt.schuettelbox.how_many_here Wie viele Perlen sind hier? Step 2, first sub-answer (compartment outlined)
prompt.schuettelbox.how_many_other Und wie viele sind auf der anderen Seite? Step 2, second sub-answer
prompt.schuettelbox.hidden Wie viele sind versteckt? Step 3, final fragment, after num.<l> or here_is_one "Sieben Perlen. Hier sind drei. | Wie viele sind versteckt?"
hint.schuettelbox.look_rows Schau, wie die Perlen liegen. Hint level 1, steps 1–2
hint.schuettelbox.count_side Wir zählen die Perlen auf dieser Seite. Hint level 2, steps 1–2 (the outlined compartment)
hint.schuettelbox.all_together Zusammen sind es … Hint level 1, step 3, first fragment; the visible part follows as here_are + num.<l> (or here_is_one) "Zusammen sind es | sieben. Hier sind drei."
hint.schuettelbox.count_on Zähl weiter bis … Hint level 2, step 3 "Zähl weiter bis | sieben."
Template ID Segments Example Used by
schuettelbox.box prompt.schuettelbox.here_are, {n}, prompt.schuettelbox.perlen "Hier sind sieben Perlen." Tray phase
schuettelbox.hidden {n}, prompt.schuettelbox.perlen, prompt.schuettelbox.here_are, {l}, prompt.schuettelbox.hidden. Variant l = 1: {n}, prompt.schuettelbox.perlen, prompt.schuettelbox.here_is_one, prompt.schuettelbox.hidden "Sieben Perlen. Hier sind drei. Wie viele sind versteckt?" Step 3
schuettelbox.hint_l1_hidden hint.schuettelbox.all_together, {n}, prompt.schuettelbox.here_are, {l}. Variant l = 1: hint.schuettelbox.all_together, {n}, prompt.schuettelbox.here_is_one "Zusammen sind es sieben. Hier sind drei." Step 3 level 1
schuettelbox.hint_l2 hint.schuettelbox.count_side, _, {count s t} "Wir zählen die Perlen auf dieser Seite. Eins … zwei … drei." Steps 1–2 level 2 (s = 1, t = the count of the outlined side; step 1 counts both sides in turn)
schuettelbox.hint_l2_hidden hint.schuettelbox.count_on, {n}, _, {count c n} "Zähl weiter bis sieben. Vier … fünf … sechs … sieben." Step 3 level 2 (c = l + 1)
schuettelbox.solution {n}, fb.solution.ist, {l}, prompt.common.und, {r} "Sieben ist drei und vier." Solution and correct confirmation

21.5.9 Froschsprung (froschsprung) #

Section 13.1. The start bank is unlabeled and never spoken; "null" does not occur in this game. k ∈ {1, 2}.

Audio ID German text Used by Notes
prompt.froschsprung.intro Der Frosch will hüpfen. Hilf ihm! First task of a round
prompt.froschsprung.jump_to Spring zur …! jumpTo, landmarkJump "Spring zur | Sieben!"
prompt.froschsprung.sits_on Der Frosch sitzt auf der … relative, before the instruction "Der Frosch sitzt auf der | Vier."
prompt.froschsprung.forward_1 Spring eins weiter! relative, weiter, k = 1
prompt.froschsprung.forward_2 Spring zwei weiter! relative, weiter, k = 2
prompt.froschsprung.back_1 Spring eins zurück! relative, zurück, k = 1
prompt.froschsprung.back_2 Spring zwei zurück! relative, zurück, k = 2
prompt.froschsprung.where_frog Auf welcher Zahl sitzt der Frosch? whereIsFrog
hint.froschsprung.look_numbers Wo ist die …? Hint level 1, jumpTo, first fragment "Wo ist die | Sieben? Schau auf die Zahlen." The number uses num.<n>.q.
hint.froschsprung.schau_auf_die_zahlen … Schau auf die Zahlen. Hint level 1, jumpTo, second fragment, after look_numbers + num.<n>.q "Wo ist die Sieben? | Schau auf die Zahlen."
hint.froschsprung.direction_forward Weiter geht es zu den größeren Zahlen. Hint level 1, relative weiter
hint.froschsprung.direction_back Zurück geht es zu den kleineren Zahlen. Hint level 1, relative zurück
hint.froschsprung.landmark Schau auf die … Hint level 1, step 3 "Schau auf die | Zehn."
hint.froschsprung.count_steps Wir zählen die Sprünge. Hint level 2, relative
hint.froschsprung.from_the Von der … Hint level 2, step 3, first fragment "Von der | Zehn zählen wir weiter."
hint.froschsprung.count_from_landmark … zählen wir weiter. Hint level 2, step 3, second fragment, after from_the + num.<m> "Von der Zehn | zählen wir weiter."
hint.froschsprung.count_path Wir zählen zusammen. Hint level 2, jumpTo
Template ID Segments Example Used by
froschsprung.task_jump prompt.froschsprung.jump_to, {n} "Spring zur Sieben!" jumpTo, landmarkJump
froschsprung.task_relative prompt.froschsprung.sits_on, {s}, _, one of prompt.froschsprung.forward_1, forward_2, back_1, back_2 "Der Frosch sitzt auf der Vier. Spring zwei weiter!" relative (one template per relation in games/froschsprung.json)
froschsprung.hop_count {k} "fünf" Each landing during a frog movement (count step per Section 20.6.4); the big landmark jump says the landmark on landing
froschsprung.hint_l1_jump hint.froschsprung.look_numbers, {n.q}, hint.froschsprung.schau_auf_die_zahlen "Wo ist die Sieben? Schau auf die Zahlen." Level 1, jumpTo
froschsprung.hint_l1_landmark hint.froschsprung.landmark, {m} "Schau auf die Zehn." Level 1, step 3 (m per Section 13.1.8)
froschsprung.hint_l2_relative hint.froschsprung.count_steps, _, {count s t} "Wir zählen die Sprünge. Eins … zwei." Level 2, relative (s = 1, t = k)
froschsprung.hint_l2_landmark hint.froschsprung.from_the, {m}, hint.froschsprung.count_from_landmark, _, {count a b} "Von der Zehn zählen wir weiter. Zehn … elf … zwölf." Level 2, step 3 (a = m; b = target, or for whereIsFrog the pad before the frog)
froschsprung.hint_l2_path hint.froschsprung.count_path, _, {count a b} "Wir zählen zusammen. Eins … zwei … drei." Level 2, jumpTo (from the pad after the frog to the target)
froschsprung.solution fb.solution.hier_ist_die, {t} "Hier ist die Sechs." Solution, jumpTo, landmarkJump and whereIsFrog (t = target pad; for whereIsFrog the frog's pad)
froschsprung.solution_forward {k}, fb.solution.weiter_von_der, {s}, fb.solution.ist_die, {t} "Zwei weiter von der Vier ist die Sechs." Solution, relative weiter (s = start pad, t = target pad)
froschsprung.solution_back {k}, fb.solution.zurueck_von_der, {s}, fb.solution.ist_die, {t} "Zwei zurück von der Sechs ist die Vier." Solution, relative zurück (s = start pad, t = target pad)

Correct feedback in every task type repeats num.<n> as confirmation, followed by the framework's fb.correct line (Section 13.1.10).

21.5.10 Fütter das Zahlenmonster (zahlenmonster) #

Section 13.2. Monster style unless noted. c is the number of items the monster has eaten, N the wanted number.

Audio ID German text Used by Notes
prompt.zahlenmonster.intro Das ist das Zahlenmonster. Es hat großen Hunger! First task of a round Narrator
prompt.zahlenmonster.give_me Gib mir …! Every task "Gib mir | sieben!" "Gib mir eins!" is correct German, so no variant is needed.
prompt.zahlenmonster.press_done Wenn du fertig bist, drück auf den Teller. After give_me on the first task of a round
prompt.zahlenmonster.yum Mmh, genau … Correct confirmation, first fragment (replaces fb.correct in this game) "Mmh, genau | sieben! Danke!"
prompt.zahlenmonster.thanks Danke! Correct confirmation, after yum + num.<N> "Mmh, genau sieben! | Danke!"
hint.zahlenmonster.more Mmh! Das waren … Too few, hint level 1 (c ≥ 2), first fragment "Mmh! Das waren | drei. Ich möchte sieben."
hint.zahlenmonster.more_one Mmh! Das war eins. Too few, hint level 1, c = 1; replaces more + num.<c> Whole line
hint.zahlenmonster.i_want … Ich möchte … Too few, hint level 1, between c and N "Mmh! Das waren drei. | Ich möchte | sieben."
hint.zahlenmonster.too_many Uff, das waren … Too many, before spitting back, first fragment "Uff, das waren | neun. Das ist zu viel!"
hint.zahlenmonster.too_much … Das ist zu viel! Too many, after too_many + num.<c> "Uff, das waren neun. | Das ist zu viel!"
hint.zahlenmonster.now_have Jetzt habe ich … After spitting back and recounting "Jetzt habe ich | sieben."
hint.zahlenmonster.look_bib Schau auf meinen Latz. Wie viele Punkte sind noch leer? Too few, hint level 2
Template ID Segments Example Used by
zahlenmonster.task prompt.zahlenmonster.give_me, {n} "Gib mir sieben!" Every task
zahlenmonster.feed_count {k} "vier" Each item eaten (child-driven; packs count as the new total, e.g. "fünf", "zehn")
zahlenmonster.hint_more hint.zahlenmonster.more, {c}, hint.zahlenmonster.i_want, {n}. Variant c = 1: hint.zahlenmonster.more_one, hint.zahlenmonster.i_want, {n} "Mmh! Das waren drei. Ich möchte sieben." Too few, level 1
zahlenmonster.hint_too_many hint.zahlenmonster.too_many, {c}, hint.zahlenmonster.too_much "Uff, das waren neun. Das ist zu viel!" Too many, any level
zahlenmonster.hint_now_have {count s n}, _, hint.zahlenmonster.now_have, {n} "Eins … zwei … sieben. Jetzt habe ich sieben." After the recount (s = 1; 0.4 s per item)
zahlenmonster.solution fb.solution.schau, {n}, fb.solution.so_viele_wollte_ich "Schau: sieben. So viele wollte ich." Solution demonstration
zahlenmonster.correct prompt.zahlenmonster.yum, {n}, prompt.zahlenmonster.thanks "Mmh, genau sieben! Danke!" Correct feedback

21.5.11 Memory (memory) #

Section 13.3. No number is spoken when a card is flipped; a mismatch has no line and no try-again sound. Hints follow the knowable-miss rule of Section 13.3.7.

Audio ID German text Used by Notes
prompt.memory.intro Finde die Paare! Zwei Karten mit der gleichen Zahl. Start of the board
prompt.memory.continue Such weiter! Inactivity re-prompt (Section 10.11)
prompt.memory.match Ein Paar! After the number word on a match
prompt.memory.all_found Alle Paare gefunden! Board complete, before the round celebration Praise style
hint.memory.where_other Wo war noch eine …? Hint level 1 for a pair "Wo war noch eine | Sieben?" The number uses num.<n>.q.
hint.memory.look_here Schau mal hier! Hint level 2 for a pair (partner card wiggles)
Template ID Segments Example Used by
memory.match {n}, _, prompt.memory.match "Sieben. Ein Paar!" Every match
memory.hint_l1 hint.memory.where_other, {n.q} "Wo war noch eine Sieben?" Level 1
memory.solution fb.solution.hier_ist_die_andere, {n} "Hier ist die andere Sieben." Solution demonstration

21.5.12 Punkt zu Punkt (punkt_zu_punkt) #

Section 13.4. Each correctly connected dot speaks its number word. The reveal line is the picture's label.dot.<pictureId> line, referenced by nameAudioId in dotpictures/<pictureId>.json (Section 8.12).

Audio ID German text Used by Notes
prompt.punkt_zu_punkt.intro Verbinde die Punkte! Fang bei der Eins an. Round start Whole line (separable verb)
hint.punkt_zu_punkt.where Wo ist die …? Hint level 1 (the expected dot pulses) "Wo ist die | Sieben?" The number uses num.<e>.q.
hint.punkt_zu_punkt.after Nach der … Hint level 2, first fragment; the carrier prompt.common.kommt_die follows "Nach der | Sechs kommt die Sieben."
hint.punkt_zu_punkt.start Die Eins ist der Anfang. Hint level 2 when the expected dot is 1 Whole line
label.dot.stern Ein Stern! Reveal of picture stern (step1); sticker album tap on sticker.dot.stern
label.dot.haus Ein Haus! Reveal of picture haus (step1); sticker album tap on sticker.dot.haus
label.dot.fisch Ein Fisch! Reveal of picture fisch (step1); sticker album tap on sticker.dot.fisch
label.dot.sonne Eine Sonne! Reveal of picture sonne (step1); sticker album tap on sticker.dot.sonne
label.dot.herz Ein Herz! Reveal of picture herz (step1); sticker album tap on sticker.dot.herz
label.dot.boot Ein Boot! Reveal of picture boot (step1); sticker album tap on sticker.dot.boot
label.dot.blume Eine Blume! Reveal of picture blume (step1); sticker album tap on sticker.dot.blume
label.dot.luftballon Ein Luftballon! Reveal of picture luftballon (step1); sticker album tap on sticker.dot.luftballon
label.dot.schmetterling Ein Schmetterling! Reveal of picture schmetterling (step2); sticker album tap on sticker.dot.schmetterling
label.dot.schnecke Eine Schnecke! Reveal of picture schnecke (step2); sticker album tap on sticker.dot.schnecke
label.dot.katze Eine Katze! Reveal of picture katze (step2); sticker album tap on sticker.dot.katze
label.dot.hase Ein Hase! Reveal of picture hase (step2); sticker album tap on sticker.dot.hase
label.dot.auto Ein Auto! Reveal of picture auto (step2); sticker album tap on sticker.dot.auto
label.dot.rakete Eine Rakete! Reveal of picture rakete (step2); sticker album tap on sticker.dot.rakete
label.dot.tannenbaum Ein Tannenbaum! Reveal of picture tannenbaum (step2); sticker album tap on sticker.dot.tannenbaum
label.dot.vogel Ein Vogel! Reveal of picture vogel (step2); sticker album tap on sticker.dot.vogel
label.dot.elefant Ein Elefant! Reveal of picture elefant (step3); sticker album tap on sticker.dot.elefant
label.dot.giraffe Eine Giraffe! Reveal of picture giraffe (step3); sticker album tap on sticker.dot.giraffe
label.dot.dinosaurier Ein Dinosaurier! Reveal of picture dinosaurier (step3); sticker album tap on sticker.dot.dinosaurier Friendly long-neck dinosaur
label.dot.schiff Ein Schiff! Reveal of picture schiff (step3); sticker album tap on sticker.dot.schiff
label.dot.eisenbahn Eine Eisenbahn! Reveal of picture eisenbahn (step3); sticker album tap on sticker.dot.eisenbahn
label.dot.burg Eine Burg! Reveal of picture burg (step3); sticker album tap on sticker.dot.burg
label.dot.schildkroete Eine Schildkröte! Reveal of picture schildkroete (step3); sticker album tap on sticker.dot.schildkroete
label.dot.einhorn Ein Einhorn! Reveal of picture einhorn (step3); sticker album tap on sticker.dot.einhorn
Template ID Segments Example Used by
punkt_zu_punkt.dot {k} "acht" Each correctly connected dot (child-driven)
punkt_zu_punkt.hint_l1 hint.punkt_zu_punkt.where, {e.q} "Wo ist die Sieben?" Level 1 (e = expected dot)
punkt_zu_punkt.hint_l2 hint.punkt_zu_punkt.after, {m}, prompt.common.kommt_die, {e}. Variant e = 1: hint.punkt_zu_punkt.start "Nach der Sechs kommt die Sieben." Level 2 (m = e − 1)
punkt_zu_punkt.solution prompt.common.nach_der, {m}, prompt.common.kommt_die, {e}. Variant e = 1: hint.punkt_zu_punkt.start "Nach der Sechs kommt die Sieben." Solution demonstration (m = e − 1)
punkt_zu_punkt.reveal the picture's label.dot.<pictureId> line "Ein Stern!" Picture complete; the framework's fb.correct line follows as part of the round completion

Picture list (IDs and steps). Section 14.7.2 owns the album and the sticker IDs sticker.dot.<pictureId>; Section 13.4.3 owns the point data and the point counts per step; the manifest in Section 8.5 lists the same 24 IDs.

Step Picture IDs (German names)
step1 stern (Stern), haus (Haus), fisch (Fisch), sonne (Sonne), herz (Herz), boot (Boot), blume (Blume), luftballon (Luftballon)
step2 schmetterling (Schmetterling), schnecke (Schnecke), katze (Katze), hase (Hase), auto (Auto), rakete (Rakete), tannenbaum (Tannenbaum), vogel (Vogel)
step3 elefant (Elefant), giraffe (Giraffe), dinosaurier (Dinosaurier), schiff (Schiff), eisenbahn (Eisenbahn), burg (Burg), schildkroete (Schildkröte), einhorn (Einhorn)

Decoration name lines (label.deco.<slug>, Section 8.9) are optional content. V1 records none; a decoration without a name line stays silent apart from its tap sound (Section 21.11).

21.6 Common hints (hint.common.*) #

Audio ID German text Used by Notes
hint.common.listen_again Hör nochmal zu. Prefix of the 20 s inactivity re-prompt, followed by the current task prompt (Section 10.11)
hint.common.tap_glowing Tipp auf das, was leuchtet. After every solution line; the child then taps the highlighted answer (Section 10.7.4) Neutral wording fits tiles, pads, beads and cards
hint.common.your_turn Jetzt du! After the demo hand disappears when it was triggered by inactivity and voice is on (Section 10.10.2) Inviting

21.7 Feedback #

21.7.1 Correct (fb.correct.*) #

Audio ID German text Used by Notes
fb.correct.01 Super! Any correct answer
fb.correct.02 Genau! Any correct answer
fb.correct.03 Richtig! Any correct answer
fb.correct.04 Toll gemacht! Any correct answer
fb.correct.05 Prima! Any correct answer
fb.correct.06 Klasse! Any correct answer
fb.correct.07 Ja, das stimmt! Any correct answer
fb.correct.08 Wunderbar! Any correct answer
fb.correct.09 Sehr gut! Any correct answer
fb.correct.10 Richtig, genau diese Zahl! Numeral answers (hoer_hin, was_fehlt, froschsprung, mehr_weniger numerals), after num.<n> (Section 10.8.1) Standalone sentence, no number slot
fb.correct.11 Ja, so viele sind das! Quantity answers with n ≥ 2 (wie_viele, blitzblick, entdecken Zähl mit, schuettelbox), after num.<n> (Section 10.8.1) Standalone sentence, no number slot
fb.correct.12 Genau so viele! Quantity answers, quantity-building and comparison tasks

Pools (owned by Section 10.8.4): numeral answers draw from 01–10; quantity answers with n ≥ 2 from 01–09, 11 and 12; quantity answers with n = 1 and answers that are not a number from 01–09 (plus 12 for quantity-building and comparison tasks). The last three lines used in the session are excluded. After an afterHint success the same lines are used; there is no separate "finally" wording. Nachspuren and Fütter das Zahlenmonster replace the fb.correct line with their own confirmation (Sections 12.4.11 and 13.2.11); Memory plays one only for the board's final pair (Section 13.3.9).

21.7.2 Try again (fb.tryagain.*) #

Audio ID German text Used by Notes
fb.tryagain.01 Probier es nochmal! Before hint level 1 (Section 10.7.2); Punkt zu Punkt wrong dot; Nachspuren stroke miss. Never in Memory (Section 13.3.7). Encourage style
fb.tryagain.02 Noch ein Versuch! same
fb.tryagain.03 Macht nichts! Nochmal! same
fb.tryagain.04 Hmm, versuch es nochmal. same Thoughtful, not disappointed
fb.tryagain.05 Das schaffen wir. Nochmal! same
fb.tryagain.06 Oh, schauen wir nochmal. same
fb.tryagain.07 Gleich hast du es! same
fb.tryagain.08 Du schaffst das! Nochmal! same

21.8 Session lines (session.*) #

The app never speaks a name: not the child's, not the app's, not a game's. Greetings depend only on the local time of day (via the trusted day clock, Section 15.7.1).

Audio ID German text Used by Notes
session.greeting_morning Guten Morgen! Schön, dass du da bist. S-05, first session of the local day, 05:00–10:59 (Section 15.7.1) Not played after an idle end
session.greeting_day Hallo! Schön, dass du da bist. same, 11:00–17:59
session.greeting_evening Guten Abend! Schön, dass du da bist. same, 18:00–04:59
session.welcome_back Da bist du ja wieder! S-05, later sessions of the same local day
session.picker Wer spielt jetzt? S-04, once per appearance (Section 15.5.2)
session.game_pick Welches Spiel möchtest du spielen? S-06, first entry per session
session.abenteuer_intro Heute gibt es ein neues Abenteuer! Komm mit! S-09 at 0.5 s; repeated once after 20 s without a tap (no auto-start, Section 15.10.2)
session.abenteuer_end Das war ein schönes Abenteuer! Die Zahlenfreunde ruhen sich jetzt aus. S-10, after session.stars_bonus (Section 15.10.4) Soft style
session.abenteuer_bye Bis bald! S-10, after abenteuer_end, while the friends fall asleep; S-10 returns to S-05 15 s after this line Soft style
session.abenteuer_done Dein Abenteuer für heute ist geschafft. Morgen gibt es ein neues! S-05 Abenteuer button tapped after today's Abenteuer is complete; the garden (S-11) opens No counter, no reminder
session.break_nudge Puh, ich bin ein bisschen müde. Magst du eine Pause machen? S-26 state nudge, after a round's S-08 (Section 15.9); together with sfx.friend_yawn Soft style, spoken as the friend
session.break_continue Na gut, dann spielen wir noch ein bisschen! S-26, the child taps "Weiterspielen" Soft style
session.break_bye Bis später! S-26, the child taps "Pause"; played once when the state resting begins Soft style
session.time_warning Gleich ist Zeit zum Ausruhen. 2 minutes before the daily limit, once per allowance (Section 15.8.2) Priority .system
session.times_up Jetzt ist Zeit zum Ausruhen. Bis morgen! S-16, once on appearance (Section 15.8.4) Soft style, .system
session.time_extended Du darfst noch ein bisschen spielen! S-05 after a parent grants +10 minutes, instead of the greeting (Section 15.8.5)
session.locked_game Dieses Spiel ist noch zu. Frag deine Eltern. S-15 (Section 17.8). At most once per session per profile; later taps show only the lock animation and highlight the open tiles Calm; no game name, no money word
session.locked_other Schau mal, diese Spiele kannst du jetzt spielen! S-15, directly after locked_game, while the open game tiles are highlighted Redirects without pressure; same damping
session.pause Bist du noch da? Tipp auf den Pfeil, dann geht es weiter. Pause overlay, spoken once when it appears (all pause reasons, Section 10.12)
session.resume Weiter geht's! Leaving the pause overlay
session.hold_to_exit Halt den Finger drauf, dann geht es nach Hause. First short tap on the home button in a session (Section 10.9.7)
session.garden_intro Das ist dein Zahlengarten! S-11, first visit per profile
session.garden_hint Zieh deine Sachen in den Garten. Du kannst sie später noch verschieben. S-11, first time the tray holds an item
session.shop_intro Such dir etwas Schönes für deinen Garten aus. S-12 entry
session.shop_need_more Dafür sammeln wir noch ein paar Sterne. S-12, the dimmed buy button of a not-yet-affordable item is tapped, once per card opening (Section 14.4.4) Never "zu teuer"; never asks the child to play more
session.shop_need_friends Das bekommst du, wenn du mehr Zahlenfreunde hast. S-12, a friend-locked item's requirement is tapped (Section 14.4.4)
session.shop_bought Wie schön! Wo soll es hin? S-12, after a purchase (Section 14.8 C5)
session.friends_intro Das sind deine Zahlenfreunde. S-13, first entry per session with ≥ 1 friend
session.friends_empty Hier wohnen bald deine Zahlenfreunde. Spiel mit den Zahlen, dann lernst du sie kennen. S-13 entry with 0 friends
session.friend_waiting Dieser Zahlenfreund wartet noch auf dich. S-13, a silhouette is tapped, at most once per visit (Section 14.5.6) No number is spoken
session.album_intro Das ist dein Stickeralbum. S-14, first entry per session with ≥ 1 sticker
session.album_empty Hier kommen bald deine Sticker hin. S-14 entry with 0 stickers

S-02, S-03 and the parent area play no session lines. The S-03 sound check plays num.5 only (Section 15.4.1).

21.9 Celebration and reward lines #

Audio ID German text Used by Notes
session.round_done_01 Geschafft! Das war eine tolle Runde! S-08 (Section 14.8 C2) Praise style; rotated without immediate repetition
session.round_done_02 Super gespielt! Schau mal, deine Sterne! S-08
session.round_done_03 Hurra, alles geschafft! S-08
session.round_done_04 Das hast du prima gemacht! S-08
session.stars_bonus Und noch Extra-Sterne für dich! S-10, when the Abenteuer bonus is added (Section 14.8 C6) Soft style
session.new_friend Juhu! Ein neuer Zahlenfreund! S-27, followed by friend.<n>.hello and session.friend_moves_in (Section 14.5.4)
session.friend_moves_in Neu in deinem Garten: die … S-27, after friend.<n>.hello "… Garten: die | Sieben!"
session.new_sticker Ein neuer Sticker für dein Album! Milestone sticker celebration (Section 14.8 C4); the only voice line of C4. Milestone stickers have no voice line of their own; tapping one on S-14 plays sfx.sticker_tap
Template ID Segments Example Used by
common.friend_moves_in session.friend_moves_in, {n} "Neu in deinem Garten: die Sieben!" S-27

Decision: there is no voice line announcing a difficulty step change or a range change. Adaptation is silent, so progress is never framed as a performance test.

21.10 Zahlenfreunde lines (friend.<n>.*) #

Two lines per friend:

  • hello: playful. Played at befriending (S-27, after session.new_friend), after num.<n> on every tap on a befriended friend in the garden or gallery, and after num.<n> on a tap on the friend's sticker (Section 14.5.6, Section 14.7.1).
  • fact: the friend's structure in fives and tens. Played on a second tap on the same friend within 10 s (Section 14.6).

All lines use the Friend style (Section 21.1.3). Friend names and personalities are in Section 14. The lines here name the friend by its numeral noun ("die Sieben"). The fact lines use "fünf und …" and "zehn und …" (Section 4, DR-43) rather than colours, so the meaning does not depend on colour vision.

Audio ID German text Used by Notes
friend.1.hello Hallo, ich bin die Eins! Ich bin immer ganz vorne dabei. S-27, S-11, S-13, S-14
friend.1.fact Schau, ich habe genau einen Punkt. S-11, S-13
friend.2.hello Ich bin die Zwei! Zu zweit macht alles mehr Spaß. S-27, S-11, S-13, S-14
friend.2.fact Ich habe zwei Punkte. Einen und noch einen. S-11, S-13
friend.3.hello Hallo, ich bin die Drei! Ich hüpfe gern: eins, zwei, drei! S-27, S-11, S-13, S-14
friend.3.fact Ich habe drei Punkte. Zwei und noch einen. S-11, S-13
friend.4.hello Ich bin die Vier! Ein Tisch hat vier Beine. S-27, S-11, S-13, S-14
friend.4.fact Ich habe vier Punkte. Nur einer fehlt bis fünf. S-11, S-13
friend.5.hello Ich bin die Fünf! Zähl mal die Finger an einer Hand. S-27, S-11, S-13, S-14
friend.5.fact Ich habe fünf Punkte. Eine ganze Gruppe! S-11, S-13
friend.6.hello Hallo, ich bin die Sechs! Ich bin die größte Zahl auf dem Würfel. S-27, S-11, S-13, S-14
friend.6.fact Ich bin fünf und eins. S-11, S-13
friend.7.hello Ich bin die Sieben! Eine Woche hat sieben Tage. S-27, S-11, S-13, S-14
friend.7.fact Ich bin fünf und zwei. S-11, S-13
friend.8.hello Ich bin die Acht! Ein Krake hat acht Arme. S-27, S-11, S-13, S-14
friend.8.fact Ich bin fünf und drei. S-11, S-13
friend.9.hello Hallo, ich bin die Neun! Ich bin fast schon zehn. S-27, S-11, S-13, S-14
friend.9.fact Ich bin fünf und vier. Nur einer fehlt bis zehn. S-11, S-13
friend.10.hello Ich bin die Zehn! Das sind alle deine Finger. S-27, S-11, S-13, S-14
friend.10.fact Ich bin fünf und fünf. Eine ganze Reihe! S-11, S-13
friend.11.hello Ich bin die Elf! Ich sehe aus wie zwei Einsen. S-27, S-11, S-13, S-14
friend.11.fact Ich bin zehn und eins. S-11, S-13
friend.12.hello Ich bin die Zwölf! Auf der Uhr stehe ich ganz oben. S-27, S-11, S-13, S-14
friend.12.fact Ich bin zehn und zwei. S-11, S-13
friend.13.hello Hallo, ich bin die Dreizehn! Ich tanze gern im Kreis. S-27, S-11, S-13, S-14
friend.13.fact Ich bin zehn und drei. S-11, S-13
friend.14.hello Ich bin die Vierzehn! Ich spiele so gern Verstecken. S-27, S-11, S-13, S-14
friend.14.fact Ich bin zehn und vier. S-11, S-13
friend.15.hello Ich bin die Fünfzehn! Drei Hände voll Finger, so viel bin ich. S-27, S-11, S-13, S-14 3 × 5
friend.15.fact Ich bin zehn und fünf. S-11, S-13
friend.16.hello Ich bin die Sechzehn! Ich singe gern ganz laut: La, la, la! S-27, S-11, S-13, S-14 Sung lightly
friend.16.fact Ich bin zehn und sechs. S-11, S-13
friend.17.hello Ich bin die Siebzehn! Am liebsten schaukle ich. S-27, S-11, S-13, S-14
friend.17.fact Ich bin zehn und sieben. S-11, S-13
friend.18.hello Ich bin die Achtzehn! Ich baue gern hohe Türme. S-27, S-11, S-13, S-14
friend.18.fact Ich bin zehn und acht. S-11, S-13
friend.19.hello Ich bin die Neunzehn! Nur noch eins, dann bin ich zwanzig. S-27, S-11, S-13, S-14
friend.19.fact Ich bin zehn und neun. S-11, S-13
friend.20.hello Ich bin die Zwanzig! Zähl alle Finger und Zehen, dann hast du mich. S-27, S-11, S-13, S-14
friend.20.fact Ich bin zehn und zehn. Zwei ganze Reihen! S-11, S-13

21.11 Sound effects (sfx.*) #

All SFX are locale-independent (Resources/Audio/common/sfx.<key>.m4a). The production specification is in Section 20.16.2. "Max" is the maximum duration. The IDs below are the ones every behaviour section uses.

Audio ID Description Used by Notes
sfx.tap Soft wooden tick Touch-down on a chrome control (home, speaker, resume, check button, Section 10.8.5) Max 120 ms
sfx.tile_select Soft rounded "pop" Touch-down on an answer element (numeral tile, card, side, tray bead); S-04 avatar tile (Section 10.8.5, Section 18.3.4) Max 200 ms
sfx.dot_on Gentle marimba note A Zwanzigerfeld dot or bead fills (shared by several games, Section 10.8.5) Max 150 ms; at most every 40 ms
sfx.dot_off Softer, lower marimba note A Zwanzigerfeld dot or bead empties; also the only sound when the field becomes empty Max 150 ms; at most every 40 ms
sfx.mark Light "tick" with a warm tone wie_viele: an object is marked or unmarked Max 150 ms
sfx.bead_slide A bead sliding on a string was_fehlt bead drag, bead chains Max 300 ms
sfx.drag_pickup Soft lift "fwip" Starting any drag (Section 10.9.5); lifting a placed garden item (Section 14.4.3) Max 150 ms
sfx.drag_drop Soft snap into place Dropping on a valid target Max 200 ms
sfx.return Soft floating "whoosh" A dragged item floats back to its origin Max 350 ms
sfx.correct Warm two-note rising chime Every correct answer (Section 10.8.1) Max 600 ms
sfx.try_again Neutral, warm, low wooden "tock-tock" Every wrong attempt (Section 10.7.2); a wrong dot in punkt_zu_punkt; never in Memory Max 400 ms; never a buzzer
sfx.soft_confirm Gentle single chime, softer than sfx.correct Tapping the demonstrated solution (Section 10.7.4) Max 400 ms
sfx.hint Light shimmer A hint visual appears Max 700 ms
sfx.star Twinkle A progress dot fills (it carries the star of the completed task, Section 10.8.5); each of the 5 Abenteuer bonus stars flying into the star jar (Section 14.8 C6) Max 400 ms
sfx.star_count Tiny tick Star counter counting up in S-08 (at most 8 per second) Max 80 ms
sfx.round_complete Short joyful jingle S-08 (Section 14.8 C2) Max 2.0 s
sfx.path_step Soft stepping-stone "tock" S-09: each path stone lights up; the path transition between Abenteuer rounds (Section 15.10) Max 300 ms
sfx.friend_new Magical welcoming jingle S-27, from 0.0 s (Section 14.5.4) Max 2.5 s
sfx.sticker Peel-and-stick sound Any sticker is awarded (Section 14.8 C4) Max 600 ms
sfx.card_flip Card flip memory: a card turns up or down; blitzblick: the card turns (Section 12.2) Max 250 ms
sfx.card_match Soft double chime memory: a pair is found, before the number word (Section 13.3.5) Max 500 ms
sfx.flash Very soft whoosh blitzblick: the pattern appears and hides Max 300 ms
sfx.trace_stroke Gentle rising chime nachspuren: a stroke is accepted Max 400 ms
sfx.line_connect Soft "plink" punkt_zu_punkt: a line is drawn Max 200 ms
sfx.picture_reveal Shimmering reveal punkt_zu_punkt: the picture is complete Max 1.5 s
sfx.lid_close Wooden lid closing softly schuettelbox: the lid closes after the beads roll in Max 300 ms
sfx.shake Wooden beads rattling in a box schuettelbox: while a shake is detected or the shake button is used; retriggered while shaking continues (Section 20.9) Max 1.2 s
sfx.beads_settle Beads settling with small clicks schuettelbox: after the beads have settled Max 600 ms
sfx.lid_open Wooden lid opening, no creak schuettelbox: the lid lifts Max 400 ms
sfx.frog_hop Soft cartoon hop froschsprung: every hop, on take-off Max 300 ms
sfx.frog_big_jump Longer, springy "boing", soft froschsprung: the big jump to a landmark (step 3) Max 600 ms
sfx.frog_land Water "plop" on a lily pad froschsprung: landing Max 300 ms
sfx.monster_munch Friendly munch zahlenmonster: an item enters the mouth Max 500 ms
sfx.monster_spit Gentle, funny "ptoo", not gross zahlenmonster: extra items returned Max 400 ms
sfx.garden_tap Soft wooden "knock" S-11: tap on a placed decoration that has no own sfx.deco_<slug> sound (fallback, Section 14.6) Max 250 ms
sfx.garden_place Soft grass rustle S-11: a decoration snaps into a slot or is moved Max 400 ms
sfx.shop_spend Stars flying out, soft "whoosh-pling" S-12: stars are spent on a decoration (Section 14.8 C5) Max 700 ms
sfx.lock Soft muted "tock" Tapping a locked game (S-06 → S-15) Max 250 ms; not negative
sfx.page_turn Paper page turn S-14 page change Max 400 ms
sfx.sticker_tap Short, bright sparkle "pling" S-14: tap on an earned sticker that has no voice line, which includes every milestone sticker (Section 14.7.4) Max 400 ms
sfx.transition Very soft whoosh Child-mode screen transitions Max 400 ms
sfx.friend_yawn A cute, non-verbal yawn S-26 nudge state (with session.break_nudge); S-10 friends fall asleep Max 1.2 s; sound-designed, not a voice line

Optional item sounds for decorations use the ID sfx.deco_<slug> (for example sfx.deco_ente), referenced by a decoration's soundId (Section 8.9) and stored like every other SFX. They are optional content and are not counted below. A tap on a decoration without its own sound plays the fallback sfx.garden_tap (Section 14.6).

21.12 Music (music.*) #

The specification is in Section 20.16.1. The screen assignment is in Section 20.8.2.

Audio ID Description Used by Notes
music.home Light, friendly loop: glockenspiel, ukulele and soft piano, 80–90 BPM, major S-04, S-05, S-06, S-13, S-14 60–90 s seamless loop, played at 30 %
music.garden Pastoral, airy loop: soft flute or woodwind, light strings, birdsong-like figures, 75–85 BPM S-11, S-12 60–90 s seamless loop, 30 %
music.abenteuer Gentle, adventurous but calm loop: music box and warm pads, 60–70 BPM S-09 only 60–90 s seamless loop, 30 %. S-10 and S-16 have no music: the loop fades out over 2 s when they appear.

21.13 Illustrations and visual assets #

21.13.1 Format and naming rules #

  • Characters, objects, props, icons, stickers, decorations: SVG in the asset catalog, single scale, "Preserve Vector Data" on.
  • Full-screen backgrounds: PNG in the asset catalog with iPhone @2x and @3x and iPad @2x variants, at most 2732 px on the long edge (the app-size budget is in Section 24).
  • Asset names: flat <category>_<slug>[_<state>], lowercase snake_case ASCII, matching ^[a-z][a-z0-9_]*$. Asset-catalog folders do not provide a namespace. Section 6.4.3 owns the rule; content JSON stores the full asset name (Section 8). Illustrations live in Resources/Illustrations.xcassets; child-area icons are design-system glyphs (glyph_<name>) in the ZKDesignSystem media catalog.
  • Illustration style: flat warm vector, soft outlines, at most 5 colours per character, nothing scary (Section 19).
  • Code-drawn elements (no assets): beads (red solid and blue "Lochperle"), Zwanzigerfeld, dice faces 1–6, finger-pattern hands (Section 19.7.5), numeral tiles, the Perlenkette string, dot-to-dot dots and lines, star counter, progress dots, the "?" badge background. All are drawn in ZKDesignSystem (Section 19).

21.13.2 Avatars #

Asset Count Notes
avatar_baer, avatar_fuchs, avatar_otter, avatar_pinguin, avatar_loewe, avatar_elefant, avatar_hund, avatar_maus, avatar_waschbaer, avatar_koala, avatar_zebra, avatar_panda 12 The 12 animal avatars of Section 15.3.1, named by the slug of the avatar ID (avatar.fuchs → avatar_fuchs). The selection ring and colour-theme background are drawn in code.
avatar_<slug>_sleepy (same 12 slugs) 12 Sleeping pose: S-04 tile of a profile that has reached today's time limit, and S-10/S-16 when no friend is present (Sections 8.13 and 15.5.2)
Total 24

21.13.3 Zahlenfreunde #

Asset pattern Count Notes
friend_<nn>_idle (nn = 01…20) 20 Default pose. The body shows its quantity as beads in groups of five and its numeral once, on a badge, hat, scarf or sign (Section 14.5.1).
friend_<nn>_happy 20 Celebration pose (S-27, tap reaction); also the friend-sticker artwork
friend_<nn>_sleepy 20 S-10 soft end, S-16, S-26 (the yawn is an animation of this pose)
friend_<nn>_silhouette 20 Not yet befriended (S-13). Neutral grey outline with no beads and no numeral, so no number is revealed (Section 14.5)
Total 80 Animations (bounce, wave, yawn) are SwiftUI transforms of these images, not extra assets

21.13.4 Garden decorations #

Asset pattern Count Notes
deco_<slug> 60 One image per catalog item (deco.tulpe → deco_tulpe). The same art is used in the shop (S-12) and the garden (S-11). IDs, German names, sizes and prices are defined in the decoration catalog of Section 14.4.5. By size: 24 small, 20 medium, 12 large, 4 special.
garden_slot_empty 1 Placement slot marker shown while placing an item

21.13.5 Dot pictures #

Asset pattern Count Notes
dotpic_<pictureId>_reveal 24 The coloured picture that fades in when the last dot is connected (e.g. dotpic_stern_reveal). IDs are listed in Section 21.5.12. The point coordinates live in dotpictures/<pictureId>.json (Section 8.12). The drawn lines and dots are code-drawn.

21.13.6 Stickers and album #

Asset pattern Count Notes
sticker_dot_<pictureId> 24 The dot picture as a die-cut sticker with a white border (sticker ID sticker.dot.<pictureId>, Section 14.7.2)
sticker_friend_<nn> 20 One per Zahlenfreund (sticker ID sticker.friend.<n>)
sticker_milestone_first_round, sticker_milestone_first_abenteuer, sticker_milestone_friends_5, sticker_milestone_friends_10, sticker_milestone_friends_20, sticker_milestone_stars_100, sticker_milestone_stars_500, sticker_milestone_first_decoration 8 Milestones per Section 14.7.4
Stickers total 52
ui_album_page_1 … ui_album_page_6 6 Album page backgrounds (page layout per Section 14.7.1)
sticker_tab_friends, sticker_tab_friends_2, sticker_tab_dots_1, sticker_tab_dots_2, sticker_tab_dots_3, sticker_tab_milestones 6 Picture tabs of the six album pages (tabAssetName, Section 8.10)
ui_sticker_slot_empty 1 Empty sticker slot (dashed outline)

21.13.7 Counting objects #

This table mirrors the counting-object catalogue of Section 11.1.4, which owns the type IDs, the movable flag and the food flag. Used by wie_viele, mehr_weniger, zahlenmonster (foods only) and memory quantity cards. Every object is a single SVG, readable at 32 pt, with no overlapping details. No sweets or junk food (Section 23). Names exist for accessibility labels (Section 20.17.4); the recorded voice names an object only inside the whole-line Wie viele? prompts (Section 21.5.2).

Asset Singular (object.<id>.one) Plural (object.<id>.other) Movable (wie_viele step 3) Food (zahlenmonster)
obj_apfel Apfel Äpfel no yes
obj_birne Birne Birnen no yes
obj_erdbeere Erdbeere Erdbeeren no yes
obj_karotte Karotte Karotten no yes
obj_banane Banane Bananen no yes
obj_stern Stern Sterne no no
obj_herz Herz Herzen no no
obj_blume Blume Blumen no no
obj_muschel Muschel Muscheln no no
obj_knopf Knopf Knöpfe no no
obj_ball Ball Bälle yes no
obj_ente Ente Enten yes no
obj_fisch Fisch Fische yes no
obj_schmetterling Schmetterling Schmetterlinge yes no
obj_marienkaefer Marienkäfer Marienkäfer yes no
obj_vogel Vogel Vögel yes no
obj_auto Auto Autos yes no
obj_schnecke Schnecke Schnecken yes no
Total 18 types 8 movable 5 food

Bundle props for Zahlenmonster step 3 (Kraft der Fünf):

Asset Count Notes
game_zahlenmonster_pack5 1 "Päckchen": a tray with 1 × 5 slots; food icons are placed in code
game_zahlenmonster_crate10 1 "Kiste": a crate with 2 × 5 slots

21.13.8 Game props and backgrounds #

Asset Count Notes
bg_game_entdecken, bg_game_wie_viele, bg_game_hoer_hin, bg_game_was_fehlt, bg_game_blitzblick, bg_game_mehr_weniger, bg_game_nachspuren, bg_game_schuettelbox, bg_game_froschsprung, bg_game_zahlenmonster, bg_game_memory, bg_game_punkt_zu_punkt 12 Calm, low-contrast backgrounds so the task area stays dominant
bg_profile_picker, bg_home, bg_garden, bg_friends, bg_album, bg_abenteuer, bg_abenteuer_night, bg_rest 8 Hub backgrounds (S-04, S-05, S-11, S-13, S-14, S-09, S-10, S-16)
game_froschsprung_frog_idle, game_froschsprung_frog_jump, game_froschsprung_frog_land 3 froschsprung
game_froschsprung_lilypad, game_froschsprung_lilypad_landmark 2 Stones; landmark stones (5, 10, 15, 20) are drawn 1.15× larger. Numerals, bead coding and the unlabeled start bank are code-drawn (Section 13.1)
game_froschsprung_bank 1 Unlabeled start bank of the number line
game_zahlenmonster_idle, game_zahlenmonster_mouth_open, game_zahlenmonster_chewing, game_zahlenmonster_happy, game_zahlenmonster_spit 5 zahlenmonster; friendly, round, never scary
game_zahlenmonster_bib 1 The numeral and dot pattern on the bib are code-drawn
game_zahlenmonster_plate 1 "Fertig" plate
game_schuettelbox_box, game_schuettelbox_lid, game_schuettelbox_divider, game_schuettelbox_flap 4 schuettelbox; the flap covers the hidden compartment in step 3
game_memory_card_back 1 memory
ui_demo_hand_open, ui_demo_hand_press 2 Animated demo hand (Section 10.10.2)
Total 41

21.13.9 Finger patterns #

Finger-pattern hands have no image assets. FingerPatternView composes each hand in code from a palm shape and five finger shapes (extended or folded), tinted with one of five skin tones chosen deterministically per round (Section 19.7.5). The demo hand of Section 10.10.2 is a separate asset (Section 21.13.8).

21.13.10 UI icons (child area) #

The parent area uses SF Symbols only. Child-area icons are design-system glyphs (Section 6.4.3).

Asset Meaning Used by
glyph_speaker Replay the prompt Every task (Section 10.10.1)
glyph_home Back home (press and hold) Every child screen
glyph_play Continue / start Pause overlay, S-09 play button, S-26 "Weiterspielen"
glyph_moon Pause (rest) S-26 nudge state
glyph_sun Wake up S-26 resting state
glyph_parent Grown-up entry (press and hold 2 s) S-04, S-05, S-15, S-16
glyph_lock Lock badge Locked game tiles
glyph_star Star Star counter, shop prices
glyph_check "Fertig" (done) entdecken Zähl mit check button
glyph_clear Clear the field (sweeping hand) entdecken
glyph_equal "=" (gleich) mehr_weniger
glyph_shake Shake button schuettelbox
glyph_question "?" badge Sound-off path
glyph_eye_closed Pattern hidden, answer now blitzblick answer phase
glyph_prompt_more, glyph_prompt_less More / fewer pictogram (tall stack / short stack with arrow) mehr_weniger
glyph_prompt_bigger, glyph_prompt_smaller Bigger / smaller numeral pictogram mehr_weniger
glyph_arrow_forward, glyph_arrow_back Direction arrows (one or two per relation) froschsprung relative; was_fehlt backward chain; entdecken backward
glyph_ear Listening pictogram hoer_hin prompt area
glyph_speech_bubble Spoken target entdecken Zähl mit prompt area
glyph_flag Target marker entdecken Zähl mit step 1
glyph_mode_explore, glyph_mode_zaehlmit Mode chooser tiles entdecken
glyph_garden, glyph_shop, glyph_basket, glyph_friends, glyph_album, glyph_abenteuer, glyph_games Hub navigation, garden tray S-05, S-11, S-12
glyph_page_prev, glyph_page_next Album page arrows S-14
glyph_volume_up Turn the volume up Low system volume hint (Section 20.13.1)
game_<gameId>_icon Game tile artwork, 12 items (e.g. game_hoer_hin_icon) S-06, S-09

Icon count: 35 glyphs + 12 game tiles = 47.

21.13.11 App icon and launch screen #

Asset Count Notes
AppIcon 3 appearances (default, dark, tinted), single size 1024 × 1024 No wordmark or text (rename-proof, Section 20.19.5)
bg_launch 1 Launch screen: cream background (Section 19) plus this illustration, no text

21.14 Totals #

21.14.1 Voice lines (German) #

Group Recordings Est. average length Est. finished audio
Number words (num.*) 41 0.7 s 29 s
Carrier fragments (prompt.common.*) 6 1.0 s 6 s
Solution fragments (fb.solution.*) 15 1.3 s 20 s
Common hints (hint.common.*) 3 1.5 s 5 s
Correct feedback (fb.correct.*) 12 1.0 s 12 s
Try-again feedback (fb.tryagain.*) 8 1.4 s 11 s
Game lines (prompt.<gameId>.*, hint.<gameId>.*): entdecken 17, wie_viele 22, hoer_hin 5, was_fehlt 8, blitzblick 9, mehr_weniger 11, nachspuren 12, schuettelbox 15, froschsprung 17, zahlenmonster 12, memory 6, punkt_zu_punkt 4 (every fragment counted as one recording) 138 2.0 s 276 s
Picture names (label.dot.*) 24 1.0 s 24 s
Session lines (session.*, Section 21.8) 32 2.8 s 90 s
Celebration and reward lines (Section 21.9) 8 2.0 s 16 s
Zahlenfreunde lines (friend.*) 40 3.0 s 120 s
Total 327 ≈ 609 s ≈ 10.2 minutes

Templates (no recordings): 5 shared (Sections 21.4 and 21.9) plus 82 game templates (Section 21.5), 87 in total.

21.14.2 Studio and production estimate #

Item Estimate
Recording time at about 45 s per line (2–3 takes, direction) ≈ 4.1 h net → 2 blocks of 3 h (Section 20.15.8)
Pickup session 1–2 h
Editing, carrier cuts, processing, loudness, encoding ≈ 2 working days
In-app QA including the join test (all carrier and fragment templates × all numbers they can take) ≈ 1 working day
App size, German voice (AAC 96 kbps mono ≈ 12 KB/s) ≈ 7.1 MB
App size, SFX (42 × ≈ 0.5 s) ≈ 0.3 MB
App size, music (3 × ≈ 75 s, AAC 128 kbps stereo) ≈ 3.6 MB
English (V1.2) Same inventory and IDs, about the same effort (Section 20.18)

21.14.3 Non-voice audio #

Group Count
SFX (sfx.*, without the optional sfx.deco_<slug> sounds) 42
Music loops (music.*) 3

21.14.4 Visual assets #

Group Count
Avatars (12 × 2 states) 24
Zahlenfreunde (20 × 4 states) 80
Garden decorations (+ 1 slot marker) 60 + 1
Dot-picture reveals 24
Stickers 52
Album pages, page tabs (+ 1 empty slot) 6 + 6 + 1
Counting objects 18
Bundle props 2
Game props and backgrounds 41
Finger-pattern hands 0 (code-drawn, Section 21.13.9)
UI glyphs and game tiles 47
App icon appearances + launch illustration 3 + 1
Total 366

22. Store Listing and Parent-Facing Copy #

This section is the single source of truth for every piece of customer-facing German prose in the product: the App Store Connect listing, the paywall, the parental gate, the first-launch flow, the parent dashboard microcopy, the child-facing "ask a parent" line, the privacy policy outline, and the support e-mail template. All other sections that render text to a parent or child quote this section verbatim rather than inventing new wording; if an executor needs a parent-facing or App-Store-facing string that is not listed here, it must be added here first (in a follow-up edit of this section) rather than authored ad hoc elsewhere.

22.1 Conventions Used in This Section #

  • Audience and register. Every string addressed to an adult uses formal "Sie" (e.g. "Sie können jederzeit..."). Every string spoken or shown to the child uses informal "du" (e.g. "Tippe auf die Zahl!"). No string mixes registers.
  • App name token. The product name is a working title (Section 1, Section 20). No copy in this section hardcodes the literal string "Zahlenkette" as running text. Every occurrence uses the token {{APP_NAME}}, resolved at build/export time for marketing copy and via Brand.appName at runtime for in-app strings (both mechanisms defined in Section 20; this section only supplies the text). Each copy block below shows the token form (what is stored in the source file) and, where a character budget applies, a rendered example using the example name "Zahlenkette" together with the resulting character count. Decision: because the App Store fields have hard limits and a future rename could lengthen the name, every token-bearing field in Section 22.2 is drafted with headroom below its Apple limit so that a plausible replacement name (up to 4 characters longer than "Zahlenkette") still fits; the exact rule and the executor's verification step are given in Section 22.2.1.
  • Character counting method. All character counts in this section count Unicode characters (not bytes/UTF-8 code units), matching how App Store Connect's text fields count characters, e.g. "ä" = 1 character. Every field with an Apple-defined limit states "used/limit" (e.g. "26/30"). Fields without a documented Apple limit (paywall headline, dashboard microcopy, etc.) show no count.
  • No dark patterns. No copy in this section uses countdown timers, artificial urgency ("nur noch heute", "läuft in 3 Minuten ab"), guilt appeals ("Ihr Kind verpasst..."), fake scarcity, or exclamation-mark pressure. This mirrors the anti-pattern rules that Section 14 fixes for the reward economy; this section applies the same standard to monetization and account-management copy. The only "countdown" in this section is the parental-gate cooldown timer (Section 22.4), which is a security control, not a persuasion device, and is exempt from this rule for that reason.
  • No medical/educational overclaiming. No copy claims the app is "wissenschaftlich erwiesen" (scientifically proven), "garantiert" (guaranteed) results, or makes comparative superiority claims against named competitors. Copy states what the app does and which didactic tradition it follows, in plain, honest language.
  • Placeholder convention. Square brackets […] are reserved exclusively for the legal/organizational placeholders in the privacy policy outline (Section 22.8) and the support e-mail sender address (Section 22.9) — values the executor fills in once a legal entity exists. Every other piece of dynamic text in this section uses standard positional format specifiers (%1$@, %2$d, …) consistent with Swift String(format:) and String Catalog variation rules (Section 20), never a second bracket style.
  • Source files. German marketing copy for the App Store listing (Section 22.2) lives in the repository under Marketing/de/ as plain text/Markdown files, one per field, each containing the {{APP_NAME}} token, rendered by the build-time script referenced in Section 20 before upload to App Store Connect. In-app copy (Sections 22.3–22.9) lives in the String Catalogs Localizable.xcstrings (UI chrome, buttons, dashboard) and Content.xcstrings (spoken/child-facing lines), per Section 20's split.

22.2 App Store Listing (de-DE) #

All fields below target the de-DE App Store Connect locale. Section 26 owns the launch checklist (App Store name availability, trademark, domain) that gates when the working title in these fields may need to be replaced; this section only fixes the copy pattern, not the final legal name.

22.2.1 App Name #

Apple limit: 30 characters.

Template (stored in Marketing/de/app-name.txt):

{{APP_NAME}}: Zahlen bis 20

Rendered example (working title "Zahlenkette"): Zahlenkette: Zahlen bis 20 Characters: 26/30. Gloss (EN): "Zahlenkette: Numbers up to 20" — states the product name and, in the remaining budget, the core promise (numbers 1–20).

Decision: the suffix ": Zahlen bis 20" is 15 characters, leaving 15 characters for the name itself before the 30-character ceiling is hit. "Zahlenkette" (11 characters) leaves 4 characters of slack. Verification step for the executor: if the working title changes (Section 26 launch checklist), recompute the rendered length; if the new name is longer than 15 characters, drop the suffix and submit the bare name, or shorten the suffix to ": bis 20" (8 characters, giving 22 characters of budget for the name) — do not exceed 30 characters under any circumstance, as App Store Connect rejects the field outright.

22.2.2 Subtitle #

Apple limit: 30 characters. The subtitle does not repeat the app name (Apple indexes name and subtitle together for search, so repeating wastes budget).

12 Lernspiele, Zahlen 1-20

Characters: 26/30. Gloss (EN): "12 learning games, numbers 1-20" — states the content scope (twelve games, full number range) that the name alone does not convey.

22.2.3 Promotional Text #

Apple limit: 170 characters. Promotional text is the only App Store Connect field editable without a new binary submission; Decision: use it at launch for the copy below, and thereafter for short, factual seasonal notes only (e.g. announcing a quarterly content drop per Section 17's roadmap) — never for discount countdowns.

Perlenkette, Zwanzigerfeld, 12 liebevolle Lernspiele: So entdecken Kinder ab 2 Jahren die Zahlen 1 bis 20 – ganz ohne Druck, ohne Werbung, ohne Tracking.

Characters: 153/170. Gloss (EN): "Bead chain, twenty-frame, 12 loving mini-games: this is how children from age 2 discover the numbers 1 to 20 — with no pressure, no ads, no tracking."

22.2.4 Description #

Apple limit: 4,000 characters. Template stored as Marketing/de/description.md with four {{APP_NAME}} tokens (12 characters each as stored; "Zahlenkette" is 11 characters, so the rendered text is 4 characters shorter than the stored template). Structure, in this fixed order: what the app is; the didactic foundation in plain parent language; the 12 games grouped free/premium; the reward loop in one paragraph; the parent area in one paragraph; the privacy promise; the subscription-terms disclosure required by Apple's subscription guidelines.

{{APP_NAME}} ist eine ruhige, liebevoll gestaltete Lern-App für Kinder von 2 bis 6 Jahren, die Zahlen von 1 bis 20 spielerisch entdecken – ganz ohne Lesenkönnen. Jede Anweisung wird gesprochen, jeder Knopf ist ein Bild oder eine Zahl.

WARUM {{APP_NAME}} ANDERS RECHNET
{{APP_NAME}} folgt bewährter früher Mathematik-Didaktik aus dem deutschsprachigen Raum: dem Zwanzigerfeld und der Perlenkette mit ihren Fünfergruppen in Rot und Blau. Kinder lernen die "Kraft der Fünf" – sie erkennen zum Beispiel die 7 auf einen Blick als 5 und 2, statt jedes Mal einzeln zu zählen. Jede Zahl wird immer auf drei Arten gezeigt: als Ziffer, als gesprochenes und geschriebenes Wort und als Menge – so verbinden Kinder Symbol, Sprache und Anzahl miteinander. Geübt wird nicht nach Zeit, sondern nach Können: Die App merkt sich für jede Zahl und jede Fähigkeit, wie sicher Ihr Kind schon ist, und wiederholt genau das, was gerade dran ist. Fehler werden nie bestraft – Ihr Kind hört einen Hinweis und darf es noch einmal versuchen.

12 LERNSPIELE

Kostenlos, für immer:
- Entdecken: das Zwanzigerfeld zum Antippen, mit "Zähl mit"-Modus
- Wie viele?: Objekte zählen – erst geordnet, dann verstreut, dann in Bewegung
- Hör hin: eine Zahl hören und die richtige Ziffer antippen
- Was fehlt?: die Lücke in der Perlenkette finden, später auch rückwärts zählen

Mit Premium freigeschaltet:
- Blitzblick: Mengen im Blick erfassen, bevor sie wieder verschwinden
- Mehr oder weniger: vergleichen, welche Seite oder Zahl größer ist
- Nachspuren: Ziffern mit dem Finger oder dem Apple Pencil nachfahren
- Schüttelbox: das Gerät schütteln und die Zahl in zwei Teile zerlegen
- Froschsprung: mit dem Frosch die Zahlenreihe entlang hüpfen
- Fütter das Zahlenmonster: genau die richtige Anzahl verfüttern
- Paare finden: Ziffer, Menge und Würfelbild einander zuordnen
- Punkt zu Punkt: Punkte von 1 bis 20 verbinden und ein Bild fürs Stickeralbum freischalten

Beim Spielen sammelt Ihr Kind Sterne für einen eigenen Zahlengarten, freundet sich mit den 20 Zahlenfreunden an und füllt ein Stickeralbum – ganz ohne Zeitdruck, Ranglisten oder Zufallsbelohnungen.

IHR RUHIGER BEGLEITER: DER ELTERNBEREICH
Ein Elternbereich (durch eine einfache Rechenaufgabe für Erwachsene geschützt) zeigt den Fortschritt je Zahl und Fähigkeit, was gerade schwierig ist, und die Spielzeit der letzten 7 Tage. Sie legen Zahlenraum, Schwierigkeitsgrad und ein tägliches Zeitlimit fest – oder überlassen es der App. Daten lassen sich jederzeit exportieren oder löschen.

PRIVATSPHÄRE HAT VORRANG
{{APP_NAME}} zeigt keine Werbung, enthält kein Tracking und keine Analyse-Software von Drittanbietern. Alle Lern- und Spieldaten werden nur auf dem Gerät gespeichert (und in Ihrer eigenen Geräte-Sicherung); wir erhalten keine Daten. Im App Store ist das mit "Daten nicht erfasst" gekennzeichnet.

KOSTENLOS UND PREMIUM
Die vier Spiele Entdecken, Wie viele?, Hör hin und Was fehlt? sind dauerhaft kostenlos, mit allen Schwierigkeitsstufen. Premium gibt es als Monats- oder Jahresabo (in Deutschland 3,99 € pro Monat oder 29,99 € pro Jahr; Preise in anderen Ländern gemäß App Store) und schaltet alle 12 Spiele frei. Neue Abonnentinnen und Abonnenten erhalten 7 Tage kostenlos zum Testen. Das Abonnement verlängert sich automatisch, sofern es nicht mindestens 24 Stunden vor Ablauf des aktuellen Zeitraums gekündigt wird; die Abrechnung erfolgt über Ihr Apple-ID-Konto. Sie können es jederzeit in den Einstellungen Ihres Geräts unter Ihrem Namen > Abonnements verwalten oder kündigen. Mit Family Sharing teilen sich bis zu sechs Familienmitglieder ein Abonnement. Käufe erscheinen ausschließlich im Elternbereich – Ihr Kind sieht nie eine Kaufaufforderung.

Characters (as stored, with literal {{APP_NAME}} tokens): approximately 3,656/4,000. Rendered with "Zahlenkette" (four token substitutions, each 1 character shorter than the token): approximately 3,652/4,000. Gloss (EN): full listing description covering the didactic basis, the complete game list split by tier, the reward loop, the parent area, the privacy promise, and the Apple-required auto-renewal disclosure.

Decision: roughly 350 characters of headroom remain under the 4,000 limit. Verification step for the executor: after any rename or copy edit, re-run the exact character count of the rendered file (e.g. via the build script's dry-run output) and confirm it stays at or under 4,000 before uploading to App Store Connect.

22.2.5 Keywords #

Apple limit: 100 characters, comma-separated, no spaces after commas. Words already present in the app name or subtitle ("Zahlen", "Spiele") are deliberately omitted, since Apple indexes name, subtitle and keywords together and repeating a word wastes budget. No competitor or third-party brand name is included.

rechnen,vorschule,kindergarten,mathe,lernspiel,zählen lernen,zahlenraum,vorschulkind,kita,mengen

Characters: 96/100. Gloss (EN): "arithmetic, preschool, kindergarten, math, learning game, learning to count, number range, preschool child, daycare, quantities" — the discovery terms a German-speaking parent would search for.

22.2.6 What's New (Version 1.0) #

No Apple-mandated minimum; limit is 4,000 characters, used here at a fraction of that since this is a first release with nothing to compare against.

Willkommen bei {{APP_NAME}}! Diese erste Version enthält alle 12 Lernspiele, den Zahlengarten, die Zahlenfreunde und das Stickeralbum. Wir freuen uns, wenn Ihr Kind die Zahlen 1 bis 20 mit uns entdeckt.

Gloss (EN): "Welcome to Zahlenkette! This first version includes all 12 games, the number garden, the number friends and the sticker album. We're glad your child gets to discover the numbers 1 to 20 with us."

22.2.7 Screenshot Caption Set #

Eight captions, each rendered as an overlay on its screenshot at ≤40 characters so it stays legible at App Store thumbnail size. Decision: the first three screenshots are the ones shown before the user taps "more" in App Store search results (current Apple product page behavior; verification step for the executor: confirm the still-visible count in App Store Connect's screenshot preview at submission time, since Apple has changed this number across iOS releases), so the set leads with the emotional hook (the garden) and the four free games before teasing premium content and closing on the parent-trust screen.

Order Screen to capture Caption (German) Characters
1 Child home / Zahlengarten with a few decorations and Zahlenfreunde placed Dein Zahlengarten wächst mit dir 32/40
2 Entdecken game, Zwanzigerfeld mid-tap with numeral+word+bead split shown (Section 11) Entdecke das Zwanzigerfeld 26/40
3 Wie viele? game, structured rows-of-five counting step (Section 11) Wie viele? Zähl einfach mit 27/40
4 Hör hin game, numeral choice screen (Section 11) Hör hin: welche Zahl ist das? 29/40
5 Was fehlt? game, bead-chain gap screen (Section 11) Was fehlt in der Perlenkette? 29/40
6 Blitzblick game, dot flash moment (Section 12) Blitzblick: schnell erkannt 27/40
7 Schüttelbox game, split-result screen (Section 12) Schüttelbox: finde die Teilung 30/40
8 Parent dashboard, Fortschritt number×skill grid (Section 16) Fortschritt für jede Zahl im Blick 34/40

Gloss (EN), in order: "Your number garden grows with you" / "Discover the twenty-frame" / "How many? Just count along" / "Listen up: which number is that?" / "What's missing in the bead chain?" / "Blitzblick: recognized in a flash" / "Shake box: find the split" / "Progress for every number, at a glance".

22.3 Paywall Copy (Screen S-22) #

The paywall is reachable only from inside the parent area, after the parental gate (Section 22.4), per Section 17's entitlement rules — never as a screen the child can see. All copy below is Sie-form.

22.3.1 Headline and Sub-Headline #

Headline: Alle 12 Lernspiele freischalten
Sub-Headline: Acht weitere Spiele und alle künftigen Spiele und Inhalte, die noch dazukommen.

Gloss (EN): "Unlock all 12 games" / "Eight more games, and every future game and content update still to come."

Decision: the benefits named on the paywall and in this sub-headline are exactly the benefits premium actually adds — eight additional games, future game and content updates, and Family Sharing. Zahlenfreunde, garden, stickers, all difficulty steps, no ads and no tracking are available to every user, free or premium (Section 17), so none of them is named as a reason to subscribe anywhere in this section.

22.3.2 Benefit Bullets #

Three bullets, each a factual capability statement naming only a benefit premium actually adds, no hype adjectives:

- Alle 12 Lernspiele statt nur 4
- Neue Spiele und Bilder mit jedem Inhalts-Update
- Für die ganze Familie (Family Sharing)

Gloss (EN): "All 12 games instead of just 4" / "New games and pictures with every content update" / "For the whole family (Family Sharing)".

Decision: "Alle Schwierigkeitsstufen für jedes Alter" and "neue Zahlenfreunde" were removed from this list — every free game already ships with all three difficulty steps, and all 20 Zahlenfreunde are available to free and premium profiles alike (Section 17); listing either as a reason to subscribe would be inaccurate. "Ohne Werbung und ohne Tracking" is not listed either, because it applies to the free version too; that statement stays in the store description (Section 22.2.4).

22.3.3 Plan Cards #

Two cards, "Jährlich" preselected by default and visually highlighted with a badge; "Monatlich" selectable by a single tap. This section never hardcodes a price: every price line is a format string filled at render time from StoreKit 2's Product.displayPrice (Section 17.2.3), so it always shows the currency and amount of the customer's own storefront.

Plan Label Badge Price line Trial line (if eligible, Section 22.3.4)
Yearly (preselected) Jährlich %1$@ günstiger %1$@ pro Jahr %1$lld Tage kostenlos testen, danach %2$@ pro Jahr
Monthly Monatlich (none) %1$@ pro Monat %1$lld Tage kostenlos testen, danach %2$@ pro Monat

Placeholder key: in the price and trial lines, %1$@/%2$@ = Product.displayPrice of the respective product (Section 17.2.3); %1$lld = the introductory-offer period in days, read from the offer's Product.SubscriptionOffer.Period (Section 17.6.11), never a literal number. In the yearly badge, %1$@ is a percentage string (e.g. "ca. 37 %") computed at render time as round(100 × (1 − yearly.price / (monthly.price × 12))), prefixed with "ca." and rounded to the nearest whole percent so the claim never implies false precision; if the computed value is 0 % or negative (a storefront where the yearly plan is not actually cheaper), the badge is hidden rather than shown with a wrong or zero value.

Gloss (EN): "Yearly" / "[n]% cheaper" / "[price] per year" / "Try free for [n] days, then [price] per year"; "Monthly" / "[price] per month" / "Try free for [n] days, then [price] per month". A worked example at the German reference price (monthly 3,99 €, yearly 29,99 €, Section 17) renders as "ca. 37 % günstiger" / "29,99 € pro Jahr" / "7 Tage kostenlos testen, danach 29,99 € pro Jahr" — shown here only to illustrate the format, not as a fixed string.

22.3.4 Trial Eligibility and CTA Button Labels #

Section 17 states eligibility is determined per subscription group via StoreKit 2 (Product.SubscriptionInfo.isEligibleForIntroOffer). This section fixes the two resulting button labels:

Eligible for the 7-day trial: 7 Tage kostenlos starten
Not eligible (already used an intro offer in this subscription group): Jetzt abonnieren

Gloss (EN): "Start 7 days free" / "Subscribe now".

When not eligible, the trial line from Section 22.3.3 is not shown for that plan; the price line alone is shown (%1$@ pro Jahr / %1$@ pro Monat, filled from Product.displayPrice per 22.3.3), so the customer is never told about a trial they cannot actually receive.

22.3.5 Auto-Renewal Disclosure (Fine Print) #

Shown in full below the CTA button, in the smallest legible body-text size the design system allows for parent-area body copy (Section 19), never hidden behind a second tap:

Das Abonnement verlängert sich automatisch um den gewählten Zeitraum, sofern es nicht mindestens 24 Stunden vor Ablauf in den Einstellungen Ihres Geräts unter Ihrem Namen > Abonnements gekündigt wird. Die Zahlung wird Ihrem Apple-ID-Konto belastet. Ein kostenloses Probeabo endet automatisch in ein kostenpflichtiges Abonnement, wenn Sie nicht vor Ablauf der Testphase kündigen. Mit Family Sharing können bis zu sechs Familienmitglieder das Abonnement gemeinsam nutzen.

Gloss (EN): standard Apple-required auto-renewal, billing, trial-conversion and Family Sharing disclosure.

Restore: Käufe wiederherstellen
Terms: Nutzungsbedingungen
Privacy: Datenschutzerklärung

Gloss (EN): "Restore purchases" / "Terms of use" / "Privacy policy".

Decision: the app has no custom subscription terms beyond Apple's standard terms, so "Nutzungsbedingungen" links to Apple's Standard End User License Agreement (the executor inserts Apple's current EULA URL; verification step: confirm the URL against the one Apple auto-populates in App Store Connect's app information page at submission time). "Datenschutzerklärung" opens the in-app privacy policy screen built from Section 22.8. Both links, and the restore link, sit behind the parental gate (Section 16, Section 22.4) like the rest of the paywall — tapping "Käufe wiederherstellen" does not itself re-prompt the gate since the parent is already inside the gated area.

22.4 Parental Gate Copy (Screen S-17) #

The gate mechanism (multiplication question, factors 6–9, numeric keypad, 3-attempt cooldown) is fixed by Section 16; this section fixes only its wording.

Title: Für Erwachsene
Instruction: Bitte lösen Sie diese Aufgabe, um fortzufahren.
Question (example, factors vary per Section 16): Was ist sieben mal acht?
Confirm button: Bestätigen
Error (wrong answer, attempts 1–2 remaining): Das war leider nicht richtig. Bitte versuchen Sie es noch einmal.
Cooldown (after 3rd wrong answer): Zu viele Versuche. Bitte warten Sie %1$d Sekunden und versuchen Sie es dann erneut.
Cooldown, fully elapsed: Sie können es jetzt erneut versuchen.

Gloss (EN): "For adults" / "Please solve this problem to continue." / "What is seven times eight?" (example only; the actual factor pair is generated per Section 16) / "Confirm" / "That wasn't quite right. Please try again." / "Too many attempts. Please wait %1$d seconds and then try again." / "You can try again now.".

Decision: the countdown number shown during the 30-second cooldown (%1$d, counting down from 30 to 0) is a security control against a child guessing repeatedly, not a monetization urgency device, so it is not covered by the no-countdown rule in Section 22.1. The question text is never spoken aloud (Section 16), so no audio ID is allocated for it; number words in the question ("sieben", "acht") are rendered as text glyphs only, sourced from the same German number-word list as Section 21's number-word inventory, restated here only to the extent of confirming they are text, not audio.

22.5 First-Launch Welcome and Setup Copy (Screens S-02, S-03) #

Both screens are parent-facing (Sie-form); the child is not present at the device during this flow per the product's setup assumption. Section 15 owns the field definitions (nickname 0–20 characters optional, avatarID, colorThemeID, level, createdAt) and the profile-picker behavior; this section supplies the copy around those fields.

22.5.1 Welcome Screen (S-02) #

Headline: Willkommen bei {{APP_NAME}}
Body: In wenigen Schritten richten Sie ein Profil für Ihr Kind ein. Ihr Kind braucht dafür nichts zu können – noch nicht einmal lesen.
Privacy promise: Alle Daten bleiben auf diesem Gerät. Keine Werbung, kein Tracking, keine Weitergabe an Dritte.
CTA button: Profil einrichten

Gloss (EN): "Welcome to Zahlenkette" / "In a few steps you'll set up a profile for your child. Your child doesn't need to know anything yet — not even how to read." / "All data stays on this device. No ads, no tracking, no sharing with third parties." / "Set up profile".

22.5.2 Setup Screen (S-03) #

Nickname field label: Wie soll Ihr Kind heißen? (optional)
Nickname placeholder: Spitzname
Nickname helper text: Der Name erscheint nur im Elternbereich und klein unter dem Tier in der Profilauswahl.
Nickname length error (over 20 characters): Der Spitzname darf höchstens 20 Zeichen haben.
Avatar picker header: Lieblingstier wählen
Color picker header: Lieblingsfarbe wählen
Level picker header: Welche Stufe passt zu Ihrem Kind?
Level option 1: Die Kleinen (2–4 Jahre)
Level option 2: Vorschule (4–6 Jahre)
Level helper text: Sie können das jederzeit im Elternbereich ändern.
Finish button: Fertig
Hand-off headline (shown for a few seconds after Fertig, before the child home screen appears): Fertig! Jetzt darf Ihr Kind übernehmen.
Hand-off subtext: Geben Sie das Gerät einfach weiter.

Gloss (EN): "What should your child be called? (optional)" / "Nickname" / "The name appears only in the parent area, and in small text under the animal in the profile picker." / "The nickname may be at most 20 characters." / "Pick a favorite animal" / "Pick a favorite color" / "Which level suits your child?" / "The little ones (ages 2–4)" / "Preschool (ages 4–6)" / "You can change this anytime in the parent area." / "Done" / "Done! Now your child can take over." / "Just hand over the device.".

Decision: the nickname helper text was corrected because the nickname is also shown, in small text, under the child's avatar on the profile picker (S-04) — not only in the parent area (Section 15.2.1, Section 15.5.2). The level picker header was corrected because the app never asks for the child's age (Section 15.6.2); it asks which of the two levels — "Die Kleinen" or "Vorschule" — suits the child, and the age ranges shown next to each option are informational, not a question about age. The "Weiteres Profil" button and the "Maximal 5 Profile pro Gerät" notice are not part of this setup screen; Section 22.6.9 supplies their copy where they actually appear, in the parent area's profile list.

Decision: because a nickname is optional and defaults to empty, the child home and profile picker never show blank text where a nickname would go; when nickname is empty, the profile picker caption shows the animal name (e.g. "Fuchs") instead, per the avatar catalog owned by Section 15 — this section only fixes that the fallback exists and that it is never an empty label or placeholder text mistaken for the child's name.

22.6 Parent Dashboard Microcopy #

Section 16 owns the section list and screen structure (Übersicht, Fortschritt, Gerade schwierig, Zeit, Einstellungen pro Kind, Geräteeinstellungen, Premium, Daten, Hilfe & Rechtliches); this section supplies the header labels, one-line subtitles, empty states, and the microcopy strings requested below, quoted identically to how Section 16 names them.

22.6.1 Section Headers and Subtitles #

Header (German, per Section 16) Subtitle copy (German) Gloss (EN, header / subtitle)
Übersicht So läuft es gerade bei %1$@. Overview / "Here's how things are going for [child name] right now."
Fortschritt Jede Zahl, jede Fähigkeit im Überblick. Progress / "Every number, every skill, at a glance."
Gerade schwierig Das übt %1$@ gerade noch. Currently tricky / "[Child name] is still practicing this."
Zeit Spielzeit der letzten 7 Tage. Time / "Play time over the last 7 days."
Einstellungen pro Kind Zahlenraum, Schwierigkeit und Zeitlimit für %1$@. Per-child settings / "Number range, difficulty and time limit for [child name]."
Geräteeinstellungen Sprache, Musik, Geräusche und Haptik für dieses Gerät. Device settings / "Language, music, sound effects and haptics for this device."
Premium Ihr Abonnement und alle 12 Lernspiele. Premium / "Your subscription and all 12 learning games."
Daten Fortschritt exportieren, zurücksetzen oder löschen. Data / "Export, reset or delete progress."
Hilfe & Rechtliches Datenschutz, Support und rechtliche Angaben. Help & legal / "Privacy, support and legal information."

%1$@ interpolates the profile's nickname if set, otherwise its avatar name, per Section 22.5.2's fallback rule.

22.6.2 Empty States #

Übersicht, before the first session ever played: Noch keine Daten – nach der ersten Runde sehen Sie hier den Fortschritt.
Fortschritt, before the first session: Sobald %1$@ zu spielen beginnt, füllt sich diese Übersicht.
Gerade schwierig, not enough data yet or nothing currently weak: Gerade läuft es gut – nichts Auffälliges.
Zeit, no play in the last 7 days: In den letzten 7 Tagen wurde nicht gespielt.

Gloss (EN): "No data yet — after the first round, you'll see progress here." / "As soon as [child name] starts playing, this overview fills in." / "Things are going well right now — nothing stands out." / "No play in the last 7 days.".

22.6.3 Mastery Band Labels #

Section 9 owns the underlying band definitions (notStarted, practicing, almost, mastered) and their score/attempt thresholds. This section fixes the German parent-facing labels for those four bands, used throughout the Fortschritt grid:

Band (Section 9) German label
notStarted Noch nicht geübt
practicing Wird geübt
almost Fast sicher
mastered Sicher

Gloss (EN): "Not practiced yet" / "Being practiced" / "Almost secure" / "Secure".

22.6.4 "Gerade schwierig" Sentence Templates #

Up to 3 weakest items per Section 16. One template, parameterized by skill display name and number, renders every item; skill display names are restated here identically from the skill taxonomy in Section 4: Ziffer erkennen (recognize), Zahlwort zuordnen (name), Zählen (count), Simultanerfassung (subitize), Ordnen (order), Vergleichen (compare), Zerlegen (decompose), Schreiben (write).

Template: %1$@: Die Zahl %2$d ist gerade noch schwierig.
Example 1: Zählen: Die Zahl 7 ist gerade noch schwierig.
Example 2: Zahlwort zuordnen: Die Zahl 13 ist gerade noch schwierig.
Example 3: Vergleichen: Die Zahl 9 ist gerade noch schwierig.

Gloss (EN): "[Skill]: The number [n] is still a bit tricky right now." with two worked examples.

22.6.5 Time-Limit Labels #

Section 15 owns the value set (Off / 10 / 15 / 20 / 30 / 45 / 60 minutes, default 20) and the enforcement behavior. German labels for the picker, with the default visibly marked:

Value Label
Off Aus
10 10 Minuten
15 15 Minuten
20 (default) 20 Minuten (Standard)
30 30 Minuten
45 45 Minuten
60 60 Minuten

Grant-more-time button (shown inside the gate when the child has hit the daily limit, per Section 15): +10 Minuten für heute. Gloss (EN): "+10 minutes for today".

22.6.6 Reset / Delete Confirmations #

Four distinct destructive actions, each with its own confirmation dialog, from least to most destructive. The first two are easy to conflate but are not the same action: "Fortschritt zurücksetzen" resets only the recorded mastery per number and skill, while stars, the garden, Zahlenfreunde and the sticker album are kept; "Alles zurücksetzen" is the per-child action that resets everything for that child (rewards included) while keeping the profile itself and today's already-counted play time, so a reset can never grant extra play time for the day. Both are distinct from deleting the profile outright and from the device-wide delete.

Reset one child's learning progress only —
  Title: Fortschritt zurücksetzen?
  Body: Der bisherige Lernfortschritt von %1$@ – welche Zahlen und Fähigkeiten schon geübt wurden – wird zurückgesetzt. Sterne, der Zahlengarten, Zahlenfreunde und das Stickeralbum bleiben erhalten.
  Cancel button: Abbrechen
  Confirm button: Zurücksetzen

Reset everything for one child —
  Title: Alles zurücksetzen?
  Body: Sterne, Zahlengarten, Zahlenfreunde, Stickeralbum und der gesamte Lernfortschritt von %1$@ werden unwiderruflich gelöscht. Das Profil selbst und die heutige Spielzeit bleiben erhalten.
  Cancel button: Abbrechen
  Confirm button: Alles zurücksetzen

Delete one child profile —
  Title: Profil löschen?
  Body: Das Profil von %1$@ und alle zugehörigen Daten werden unwiderruflich gelöscht.
  Cancel button: Abbrechen
  Confirm button: Profil löschen

Delete all data on this device —
  Title: Alle Daten löschen?
  Body: Dadurch werden alle Profile, Fortschritte, Sterne und Einstellungen unwiderruflich von diesem Gerät gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
  Confirmation instruction: Geben Sie zur Bestätigung LÖSCHEN in das Feld ein.
  Text field placeholder: LÖSCHEN
  Mismatch error (shown if the field does not exactly equal "LÖSCHEN" when Confirm is tapped): Bitte geben Sie genau LÖSCHEN ein, um fortzufahren.
  Cancel button: Abbrechen
  Confirm button (disabled until the field exactly equals "LÖSCHEN"): Endgültig löschen

Gloss (EN): "Reset progress?" / "[Child name]'s learning progress so far — which numbers and skills have been practiced — is reset. Stars, the number garden, number friends and the sticker album are kept." / "Cancel" / "Reset"; "Reset everything?" / "Stars, number garden, number friends, sticker album and all of [child name]'s learning progress will be permanently deleted. The profile itself and today's play time are kept." / "Cancel" / "Reset everything"; "Delete profile?" / "[Child name]'s profile and all related data will be permanently deleted." / "Cancel" / "Delete profile"; "Delete all data?" / "This permanently deletes all profiles, progress, stars and settings from this device. This action cannot be undone." / "To confirm, type LÖSCHEN into the field." / "LÖSCHEN" placeholder / "Please type exactly LÖSCHEN to continue." / "Cancel" / "Permanently delete".

Decision: the confirmation word is the German imperative "LÖSCHEN" (delete), matched case-sensitively and exactly (no trimming of extra characters, though leading/trailing whitespace is trimmed before comparison) so a child who has wandered into an already-gated parent session cannot trigger it by accident with a stray tap sequence. Only the device-wide delete carries this typed-confirmation step; the two per-child resets and the profile delete use a plain two-button confirmation, since they are reachable only from inside a specific child's settings and are already one step removed from the gate.

22.6.7 Export Success and Failure #

Export success (toast/banner after the iOS share sheet is dismissed): Fertig! Der Fortschritt von %1$@ wurde als Datei gespeichert.
Export failure (write error; not shown when the parent simply cancels the share sheet, which produces no message): Export nicht möglich. Bitte versuchen Sie es erneut.

Gloss (EN): "Done! [Child name]'s progress was saved as a file." / "Export not possible. Please try again.". The exported file's schema and naming are owned by Section 7/Section 8; this section fixes only the surrounding confirmation copy.

22.6.8 Subscription Status Lines #

Every entitlement state Section 17's StoreKit 2 integration can surface has exactly one German status line here; the parent area shows one of them at a time in the Premium section (Section 22.6.1). No state is left to improvised or placeholder copy.

State German line
Free (no subscription) 4 von 12 Spielen freigeschaltet – mit Premium alle 12.
Trial active Testphase aktiv – noch %1$d Tage kostenlos.
Active, auto-renewing Premium aktiv – verlängert sich automatisch am %1$@.
Active, cancelled but not yet expired (auto-renewal off) Premium aktiv bis %1$@ – die automatische Verlängerung ist ausgeschaltet.
Pending plan change (takes effect next renewal) Premium aktiv – wechselt am %1$@ zu „%2$@“.
Billing retry (Apple is retrying the charge; entitlement not yet lost) Zahlung konnte nicht abgeschlossen werden – wir versuchen es erneut. Bitte prüfen Sie Ihre Zahlungsmethode.
Grace period (Apple-granted continued access during a billing issue) Zahlung wird überprüft – Premium bleibt bis %1$@ aktiv. Bitte prüfen Sie Ihre Zahlungsmethode.
Expired Premium abgelaufen. Sterne, Zahlenfreunde und Fortschritt bleiben erhalten – nur die Premium-Spiele sind wieder gesperrt.
Family-shared Premium über Family Sharing aktiv.
Pending purchase (transaction submitted, awaiting confirmation) Kauf wird noch bestätigt – das kann einen Moment dauern.
Purchases restricted on this device (e.g. Screen Time content restrictions) Käufe sind auf diesem Gerät eingeschränkt. Bitte prüfen Sie die Bildschirmzeit-Einstellungen dieses Geräts.
Store unavailable (product fetch failed, e.g. no network) Der Store ist gerade nicht erreichbar. Bitte versuchen Sie es später erneut.
Restore result: success Kauf wiederhergestellt – Premium ist aktiv.
Restore result: nothing found Es wurden keine Käufe gefunden, die wiederhergestellt werden können.
Shown from the offline entitlement cache, not yet refreshed (Section 17.5.5) Zuletzt bekannter Status – wird aktualisiert, sobald eine Internetverbindung besteht. (shown as a small footnote under whichever state line above is currently cached, not as a state of its own)

Gloss (EN), in table order: "4 of 12 games unlocked – all 12 with Premium." / "Trial active – [n] days free remaining." / "Premium active – renews automatically on [date]." / "Premium active until [date] – automatic renewal is switched off." / "Premium active – changes on [date] to '[plan]'." / "Payment could not be completed – we're trying again. Please check your payment method." / "Payment being verified – Premium stays active until [date]. Please check your payment method." / "Premium expired. Stars, number friends and progress are kept — only the premium games are locked again." / "Premium active via Family Sharing." / "Purchase is still being confirmed – this may take a moment." / "Purchases are restricted on this device. Please check this device's Screen Time settings." / "The store isn't reachable right now. Please try again later." / "Purchase restored – Premium is active." / "No purchases were found to restore." / "Last known status – will update once there is an internet connection.".

Decision: the family-shared line intentionally does not name the organizing family member, since StoreKit 2 does not reliably expose that name to a non-organizer device; if a future StoreKit API reliably supplies it, the line may be extended to "Premium über Family Sharing aktiv, bereitgestellt von %1$@" without breaking this base string (the base string remains the fallback). %1$@/%2$@ placeholders that stand for a price or plan name are filled from Product.displayPrice/the plan's display name (Section 22.3.3), never a literal price.

22.6.9 Profile List Microcopy (S-18) #

The parent area's profile list (Section 16) is where profiles are added, not the first-launch setup screen (Section 22.5.2) or the child-facing profile picker: a parent reaches this list only from inside the gated parent area.

Add-another-profile button (shown when fewer than 5 profiles exist): Weiteres Profil
Max-profiles notice (shown in place of the button once 5 profiles exist): Maximal 5 Profile pro Gerät.

Gloss (EN): "Add another profile" / "Maximum 5 profiles per device.".

22.7 Locked-Game Spoken Line (Screen S-15) #

Shown and spoken when a child taps a locked (premium) game while signed into a non-entitled profile, per Section 17's lock-UI rule (a friendly animation with a parent icon, never a purchase screen for the child). This section fixes the wording and the two audio IDs; the recorded assets themselves are inventoried in Section 21 following the ID convention in Section 20.

session.locked_game: Dieses Spiel ist noch zu. Frag deine Eltern.
session.locked_other (optional follow-up, played immediately after session.locked_game while the game picker highlights the open/free tiles): Schau mal, diese Spiele kannst du jetzt spielen!

Gloss (EN): "This game is still closed. Ask your parents." / "Look, you can play these games right now!"

The line names neither the app nor the tapped game, and contains no word for money, purchase or subscription — the child only ever learns that an adult can help, never that something costs something. This also means the line never needs re-recording if the product is renamed (Section 20).

Damping: session.locked_game (with its optional session.locked_other follow-up) plays in full at most once per session per profile. Every later tap on a locked tile in the same session, by the same profile, plays only the lock animation and highlights the open tiles — no line is spoken again, so the app never nags a child who keeps exploring locked tiles out of curiosity.

Decision: the wording is "frag deine Eltern" (ask your parents), not the more generic "frag einen Erwachsenen" (ask an adult), because every other parent-facing surface in the product — the parental gate (Section 22.4), the parent area, the parent dashboard (Section 22.6) — is already built around "Eltern"/"Elternbereich", and reusing that word avoids teaching the child two different words for the same concept. "Eltern" is grammatically gender-neutral and, in everyday German usage, is understood to include a household's primary caregiving adult(s) regardless of family structure, so it does not read as excluding non-traditional families.

22.8 Privacy Policy Outline #

German headings and key statements for the privacy policy shown in-app (linked from Section 22.3.6 and Section 22.6.1's "Hilfe & Rechtliches") and published at a URL referenced from the App Store listing. This is a structural outline with the exact statements to include under each heading, covering the GDPR Article 13 disclosure items; it is not final legal text and Decision: it must be reviewed by qualified legal counsel before publication, since data-protection wording carries legal liability beyond what a product spec can certify. Square-bracketed placeholders are the only placeholders in this section; the executor (or the operator, before submission) fills them in once a legal entity is registered.

Datenschutzerklärung

1. Verantwortlicher
[Firmenname]
[Adresse]
[Kontakt-E-Mail-Adresse]
(Soweit vorhanden: [Handelsregisternummer], [Umsatzsteuer-Identifikationsnummer])

2. Welche Daten wir erheben
{{APP_NAME}} erhebt keine personenbezogenen Daten. Profile, Fortschritt, Sterne und Einstellungen werden ausschließlich lokal auf dem Gerät gespeichert und nicht an [Firmenname] oder Dritte übertragen. Im App Store ist die App entsprechend mit "Daten nicht erfasst" gekennzeichnet.

3. Gerätesicherung
Wenn Sie Ihr Gerät über iCloud oder über Ihren Computer sichern, werden die lokal gespeicherten App-Daten (Profile, Fortschritt, Einstellungen) als Teil dieser Sicherung mitgesichert. Diese Sicherung wird von Apple bzw. von Ihnen selbst verwaltet; [Firmenname] hat darauf keinen Zugriff.

4. Zweck und Rechtsgrundlage der Verarbeitung
Da keine personenbezogenen Daten an [Firmenname] übermittelt werden, findet insoweit keine Verarbeitung durch [Firmenname] als Verantwortlichen statt. Die lokale Speicherung auf dem Gerät des Nutzers erfolgt ausschließlich zur Bereitstellung der App-Funktionen (Art. 6 Abs. 1 lit. b DSGVO, Vertragserfüllung).

5. Käufe und Zahlungsabwicklung durch Apple
Abonnementkäufe werden ausschließlich über Apples In-App-Kaufsystem (StoreKit) abgewickelt. Hierbei verarbeitet Apple Inc. als eigenständig Verantwortlicher Zahlungs- und Kontodaten gemäß der Datenschutzerklärung von Apple. Die Kaufbestätigung wird ausschließlich auf dem Gerät geprüft; [Firmenname] erhält keine Kauf- oder Zahlungsdaten von Apple.

6. iCloud-Synchronisierung (ab Version 1.1)
Ab Version 1.1 kann der Fortschritt optional über die private iCloud-Datenbank des Nutzers zwischen dessen eigenen Geräten synchronisiert werden, sofern diese mit derselben Apple-ID angemeldet sind und die Synchronisierung in den Geräteeinstellungen eingeschaltet wurde. Diese Synchronisierung erfolgt über Apples iCloud-Infrastruktur innerhalb des Apple-Kontos der Familie; [Firmenname] hat keinen Zugriff auf diese Daten. Details zu Apples Verarbeitung finden sich in Apples Datenschutzerklärung.

7. Wiederherstellungskopie bei technischen Problemen
Kommt es beim Speichern zu einem technischen Fehler, legt die App zur Datensicherheit vorübergehend eine lokale Wiederherstellungskopie der betroffenen Daten an. Diese Kopie verlässt das Gerät nicht, verbleibt höchstens 30 Tage darauf und wird danach automatisch gelöscht; sie lässt sich im Elternbereich unter "Daten" auch von Hand löschen.

8. Kinder und besonderer Schutz
{{APP_NAME}} richtet sich an Kinder und verzichtet bewusst auf Kinderkonten, Werbung, Analyse- oder Tracking-Software Dritter sowie auf jede Möglichkeit für Kinder, Käufe zu tätigen. Der Zugriff auf Einstellungen, externe Links, Käufe und die Kaufwiederherstellung ist durch eine für Kinder nicht lösbare Aufgabe (Elternfreigabe) geschützt.

9. Speicherdauer
Daten verbleiben auf dem Gerät, bis die Erziehungsberechtigten sie im Elternbereich exportieren, zurücksetzen oder löschen. Bei Deinstallation der App werden alle lokal gespeicherten Daten durch das Betriebssystem gelöscht.

10. Support-E-Mails
Wenn Sie uns per E-Mail kontaktieren, verarbeiten wir Ihre Nachricht und Ihre E-Mail-Adresse ausschließlich zur Bearbeitung Ihres Anliegens (Verantwortlicher: [Firmenname], Art. 6 Abs. 1 lit. f DSGVO, berechtigtes Interesse an der Beantwortung von Support-Anfragen). Wir speichern diese Korrespondenz höchstens 12 Monate und löschen sie danach, sofern keine gesetzliche Aufbewahrungspflicht entgegensteht.

11. Diagnose- und Statistikdaten von Apple
Sofern Sie in den iOS-Systemeinstellungen der Weitergabe von Diagnose- und Nutzungsdaten an App-Entwickler zugestimmt haben, erhalten wir von Apple ausschließlich aggregierte, anonymisierte App-Analyse- und Absturzberichte. Diese lassen keinen Rückschluss auf einzelne Kinder oder Geräte zu und werden nicht mit anderen Daten verknüpft. Ohne Ihre Einwilligung in den iOS-Einstellungen erhalten wir keine solchen Daten.

12. Ihre Rechte nach der DSGVO
Da {{APP_NAME}} keine personenbezogenen Daten an [Firmenname] überträgt, üben Sie Ihre Rechte auf Auskunft, Berichtigung, Löschung und Datenübertragbarkeit direkt im Elternbereich der App aus (Export und Löschung, siehe Abschnitt "Daten" im Elternbereich). Für Fragen zu dieser Erklärung oder zu Apples Verarbeitung von Kaufdaten erreichen Sie uns unter [Kontakt-E-Mail-Adresse].

13. Beschwerderecht
Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu beschweren, insbesondere in dem Mitgliedstaat Ihres gewöhnlichen Aufenthalts, Ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes.

14. Änderungen dieser Datenschutzerklärung
Wir passen diese Erklärung an, wenn sich die App oder die rechtlichen Anforderungen ändern. Die jeweils aktuelle Fassung ist in der App unter Elternbereich > Hilfe & Rechtliches sowie unter [Datenschutz-URL] abrufbar.

Stand: [Datum der letzten Aktualisierung]

Gloss (EN), by heading: "1. Controller" (company name, address, contact e-mail, optional registration/VAT numbers) / "2. What data we collect" (none; everything is local, App Store label "Data Not Collected") / "3. Device backup" (a device's own iCloud/computer backup includes the app's local data; the company has no access to it) / "4. Purpose and legal basis" (no data reaches the controller; on-device storage is for providing app functionality, GDPR Art. 6(1)(b)) / "5. Purchases and payment processing by Apple" (Apple is an independent controller for payment data; the purchase confirmation is checked only on the device, the company receives no purchase or payment data) / "6. iCloud sync (from version 1.1)" (optional, off by default, same-Apple-ID-only sync via the family's own iCloud account, only while the device toggle is on; company has no access) / "7. Recovery copy after technical problems" (a local, on-device-only recovery copy kept at most 30 days, deletable early from the parent area) / "8. Children and special protection" (no child accounts, no ads, no third-party analytics/tracking, parental gate protects settings/links/purchases/restore) / "9. Retention" (data stays on device until the parent exports/resets/deletes it, or until uninstall) / "10. Support e-mails" (controller, purpose, 12-month retention) / "11. Diagnostic and statistical data from Apple" (aggregated, anonymized App Analytics, only if the parent opted in at the iOS level) / "12. Your GDPR rights" (exercised directly in-app since no data reaches the controller; contact e-mail for questions) / "13. Right to complain" (to a supervisory authority) / "14. Changes to this policy" (kept current in-app and at the published URL) / "Last updated: [date]".

22.9 Support E-Mail Template #

A mailto: link from Section 22.6.1's "Hilfe & Rechtliches" and from the App Store listing's support URL, pre-filling subject and body so a parent does not have to type context by hand. Decision: the pre-filled body never includes any child-specific data (no nickname, no profile ID, no progress data) — only app/device diagnostic fields a parent can see for themselves, keeping the mailto link itself free of personal data about the child.

mailto:[Support-E-Mail-Adresse]?subject=<url-encoded subject>&body=<url-encoded body>

Subject template: Frage zu {{APP_NAME}} (Version %1$@)
Body template:
Bitte beschreiben Sie Ihr Anliegen:


---
App-Version: %1$@
Gerät: %2$@
System: %3$@

Gloss (EN): subject "Question about Zahlenkette (Version [app version])"; body opens with a blank line for the parent's own description, then a diagnostic footer: "App version: [version]", "Device: [device model, e.g. iPhone SE (3rd generation)]", "System: [iOS/iPadOS version]".

%1$@ is the app's marketing version plus build number (e.g. "1.0 (14)"), %2$@ is the device model name (mapped in this section from utsname().machine/UIDevice.current.model to a human-readable string, e.g. "iPhone SE (3rd generation)"; an identifier this mapping does not recognize is shown as the raw utsname identifier rather than a placeholder), %3$@ is the OS version string (e.g. "iOS 17.4"). Implementation note for the executor: subject and body must be percent-encoded per RFC 3986 before being appended to the mailto: URL (spaces as %20, line breaks as %0A, etc.); the German umlauts in the template must also be percent-encoded as UTF-8 byte sequences, not left literal, since some mail clients mis-render unencoded non-ASCII characters in mailto: URLs. The link is always a bare mailto: URL built and opened via openURL behind the leave-app confirmation (Section 16.11.2); no mail-composer framework is used, so the flow has exactly one implementation, independent of whether the device has a Mail account configured.

22.10 System and Storage Banners (Sie-form) #

A small set of parent-facing system banners is triggered from outside the screens covered in Sections 22.3–22.9 — leaving the app through an external link, a low-storage warning, the iCloud sync toggle's helper text, and a profile-limit notice inside the sync flow. Per Section 22.1, every string an adult reads uses "Sie"; this section is the single source for these four so no other section re-derives its own wording.

Leave-app confirmation (shown before opening an external link, e.g. Terms/Privacy/support mail, Section 16.11.2): Sie verlassen jetzt {{APP_NAME}}.
Low-storage warning (shown when the device is too low on storage to save reliably): Der Speicherplatz auf diesem Gerät wird knapp. Bitte geben Sie Speicherplatz frei, damit der Fortschritt Ihres Kindes weiter gespeichert werden kann.
iCloud-sync toggle helper text (Geräteeinstellungen, shown under "Mit iCloud synchronisieren"): Sie können die Synchronisierung jederzeit in den Geräteeinstellungen ein- oder ausschalten.
iCloud profile-limit notice (shown when merging profiles from a second device would exceed 5 profiles on this device): Auf diesem Gerät sind bereits 5 Profile eingerichtet. Bitte löschen Sie ein Profil, bevor Sie fortfahren.

Gloss (EN): "You are now leaving Zahlenkette." / "Storage on this device is running low. Please free up storage so your child's progress can keep being saved." / "You can switch synchronization on or off at any time in the device settings." / "This device already has 5 profiles set up. Please delete a profile before continuing.".

23. Privacy, Security and App Store Compliance #

This section owns: Kids Category compliance, the GDPR position, the App Privacy label, the privacy manifest, the data inventory, the mapping of data-subject rights to app features, the no-network enforcement check, the age rating answers, the App Review notes, and the light threat model. The parental gate behaviour is specified in Section 16; this section specifies why that behaviour is compliant and how compliance is verified. Store listing texts and the privacy policy outline are owned by Section 22.

23.1 Compliance Principles #

The app is built so that compliance follows from the architecture, not from configuration:

# Principle Consequence
P1 Nothing leaves the device. The app contains no networking code (enforced by the policy check in Section 23.10). The only network traffic caused by the app is StoreKit traffic that the operating system performs on the app's behalf (Section 17).
P2 The developer never receives any data. There is no backend, no analytics, no crash-reporting SDK, no remote configuration, no push notifications, no accounts.
P3 Collect as little as possible even on the device. A child profile holds an optional nickname, an avatar, a colour theme and a level. No birthdate, no photo, no real name, no voice recording, no location.
P4 Everything the child can reach is safe. No links out, no purchases, no settings, no text entry, no user-generated content and no communication in child mode. Every exit to the outside world, every purchase and every setting sits behind the parental gate (Section 16).
P5 The family is in control. The parent can see, export, correct and delete all data from the parent area (Section 16, screen S-23). Deleting the app deletes all data (no Keychain use, Section 23.9).
P6 Decisions are verifiable. Every requirement in Section 23.3 maps to a test or a release checklist item (Section 25.18).

23.2 Kids Category Requirements #

23.2.1 Category and age band #

  • Primary category: Education (Bildung). The app joins the Kids category through the "Made for Kids" setting in App Store Connect's age-rating section, not through the primary-category field; the App Store Connect procedure is owned by Section 26.9.3.
  • "Made for Kids": selected, with age band "5 and Under" ("5 Jahre und jünger"). Apple allows exactly one age band per app (the bands are 5 and Under, 6–8, 9–11).
  • Storefronts at launch: Germany, Austria and Switzerland only (Section 26.9.3).
  • Rationale for the band: the target audience is 2–6; the bulk of it (the whole "Die Kleinen" level, ages 2–4, and most of the "Vorschule" level, ages 4–6) is 5 or younger. Six-year-olds in their last Kita year still find the app in the Kids category and via search. Choosing "6–8" would misrepresent the youngest users, who need the stricter band most. The band choice is listed as a decision to revisit in Section 28 with this band as the default.
  • Once an app has been in the Kids category, Apple expects it to continue meeting the Kids category rules in later updates even if the category is changed. Decision: the app never leaves the Kids category in V1.x; every future feature (including V1.1 iCloud sync and V2 features) is designed against the rules in this section.

23.2.2 Rules from App Review Guideline 1.3 (Kids Category) and 5.1.4 (Kids) #

The rules below paraphrase the guideline requirements relevant to this app. The executor re-reads the current text of Guidelines 1.3, 2.3, 3.1, 5.1.1 and 5.1.4 at submission time (release checklist item C-01 in Section 23.16) because Apple revises them several times a year.

ID Requirement (paraphrased) Source
K-01 No links out of the app, no purchasing opportunities and no other distractions to children unless they are in a designated area behind a parental gate. 1.3
K-02 No third-party advertising. 1.3, 5.1.4
K-03 No third-party analytics. 1.3, 5.1.4
K-04 Do not send personally identifiable information or device information to third parties. 1.3
K-05 Comply with applicable children's privacy law (for this app: GDPR, the German TDDDG, the Austrian DSG and the Swiss revFADP; COPPA is only relevant if the app is distributed in the US, which V1 does not target but does not exclude — the design satisfies COPPA because nothing is collected). 5.1.4
K-06 A privacy policy is provided in App Store Connect and inside the app. 5.1.1, 1.3
K-07 Metadata (name, subtitle, description, keywords, screenshots, preview video, promotional text) is appropriate for the age band; no content inappropriate for 4+; no references to other apps, prices in screenshots, or calls to action directed at children to buy. 1.3, 2.3
K-08 Data collection disclosed truthfully in the App Privacy section. 5.1.1, 5.1.2
K-09 Subscription offers ongoing value and discloses price, period and renewal terms before purchase. 3.1.2
K-10 Purchases of digital content use In-App Purchase only. 3.1.1
K-11 Parental gate must be a real barrier for young children (Apple's guidance: the gate requires adult-level knowledge or skill and must not be trivially solved by a child). 1.3

23.2.3 Metadata restrictions #

These rules bind Section 22 (store listing) and the screenshot production:

  1. The app name and subtitle contain no superlatives about learning outcomes ("Nr. 1", "garantiert", "macht Ihr Kind schlauer") and no price words ("kostenlos", "gratis"). "Kostenlos" is also avoided because 8 of 12 games require a subscription; the description states the free/premium split plainly.
  2. Screenshots show only child-mode screens and the parent dashboard. They never show the paywall, prices, the parental gate question with its answer, or a real child's name. Nicknames in screenshots are fictional and generic ("Mia", "Ben") — Decision: screenshots use no nickname at all, only avatars, to avoid any impression that names are required.
  3. No screenshot, video or text addresses the child with a call to action to buy or to ask parents to buy.
  4. The description names the subscription, its price structure (monthly/yearly, trial) and states that four games are free forever. Final wording is owned by Section 22.
  5. Keywords contain no competitor names or trademarks of others.
  6. The "App Preview" video, if produced, obeys the calm-design rules of Section 19 (no flashing above 3 Hz).

23.3 Compliance Traceability Matrix #

Each row: requirement, how the app meets it, the owning section, and how it is verified (test ID prefixes refer to Section 25).

Req Implementation Owning section Verification
K-01 links The only external links (privacy policy, imprint, support e-mail, Apple standard EULA) live on S-24 "Hilfe & Rechtliches" inside the parent area; each opens only after the gate and an extra "Sie verlassen jetzt die App" confirmation sheet (Section 16.11.2). Child mode contains no Link, no openURL, no web view. 16, 18 Policy check (23.10) allowlists exactly one file that may open URLs; UI test ParentLinksAreGated (25.10).
K-01 purchases Paywall S-22, restore, manage subscription, refund request and offer-code redemption exist only in the parent area behind the gate. Locked games in child mode show the "Frag deine Eltern" screen S-15 which contains no price, no buy button, no product name and no app name; its spoken line is session.locked_game "Dieses Spiel ist noch zu. Frag deine Eltern." (Section 17.8). First launch cannot reach the paywall: setup starts behind the gate and S-03 has no premium link (Section 15.4.1). 16, 17, 18 UI test LockedGameShowsAskParent; manual release check C-05.
K-01 settings All settings (per-child and device) are in the parent area. Child mode has no toggles; the only child-reachable controls are games, garden, album, friends, profile picker. 15, 16 UI test ChildModeHasNoSettings (asserts that no element with an identifier starting S20. or S21. (child settings, device settings; identifier format of Section 6.10) exists while child mode is shown).
K-01 distractions No cross-promotion of other apps, no rating prompt in child mode. requestReview is called only from the parent area under the conditions of Section 16.14; never in child mode. 16 Policy check: requestReview symbol allowlisted only in ZKParentArea.
K-02 ads No advertising code, no ad SDK, no AdSupport framework. 5 Policy check forbids AdSupport, AppTrackingTransparency, ASIdentifierManager.
K-03 analytics No analytics of any kind (neither third-party nor first-party). Product metrics come only from App Store Connect's aggregate App Analytics, which Apple collects. 2, 24 Zero package dependencies (Section 5); policy check.
K-04 PII to third parties Nothing is transmitted. 23.1 Policy check; privacy report (23.6.4).
K-05 law GDPR analysis in 23.4; privacy-by-design defaults. 23 Legal-review item of the launch checklist (Section 26).
K-06 privacy policy Privacy policy URL in App Store Connect; link in S-24 (gated). German text outline in Section 22. 22, 16 Release checklist C-06.
K-07 metadata Rules in 23.2.3. 22 Release checklist C-07.
K-08 privacy label "Data Not Collected" (23.5). 23 Release checklist C-08.
K-09 subscription terms Paywall shows price (the storefront price from Product.displayPrice, never a hardcoded amount, Section 17.2.3), period, trial terms, auto-renew disclosure, restore, privacy policy and EULA links. 17 Section 17 acceptance criteria; UI test PaywallShowsDisclosure.
K-10 IAP StoreKit 2 only. 17 Code review; no alternative payment code exists.
K-11 gate strength Written German multiplication question with factors 6–9 in number words, typed answer, never spoken, cooldown after 3 wrong answers (Section 16). 16 Unit tests ParentalGateTests (25.4); child usability observation (25.15).
Notifications V1 sends no notifications of any kind and never requests notification permission. 14 Policy check forbids UNUserNotificationCenter.
No microphone/camera/location/photos/contacts The app requests no privacy-sensitive permission. Info.plist contains no NS*UsageDescription keys (Core Motion accelerometer via CMMotionManager needs none, per Section 5). 5 Policy check forbids the frameworks listed in 23.10.2; Info.plist check C-09.
Data Protection Default file protection class (23.9). 23 Release checklist C-10.
Deletion "Kind löschen" and "Alle Daten löschen" (Section 16.10, semantics in Section 7.18) and app deletion remove all data; "Fortschritt zurücksetzen" and "Alles zurücksetzen" remove the per-child scopes of Section 14.11. 7, 16 Integration tests DeleteChildRemovesAllRecords, DeleteAllLeavesEmptyStore, ResetProgressScope, ResetAllScope (Section 25.8).
Boot-time API The trusted day clock (Section 15.12) reads mach_continuous_time(); the use is declared in the privacy manifest with reason 35F9.1 (23.6). 15, 23 Policy check allowlists the single implementing file (23.10.2); privacy report (23.6.4).

This subsection records the legal reasoning the app is built on. It is a product decision document, not legal advice. The legal-review item of the launch checklist (Section 26) requires a review by a lawyer qualified in German data protection law before the first public release; until that review says otherwise, the defaults below apply.

23.4.1 Facts that the analysis rests on #

  1. The app stores data only on the device, inside the app's sandbox (SwiftData store and UserDefaults, Section 23.7).
  2. The developer has no technical means to access that data: no network code, no backend, no sync in V1, no crash-reporting SDK.
  3. The data about a child is: an optional nickname (0–20 characters, freely chosen by the parent, may be a pseudonym), an avatar, a colour theme, a level, and learning records (which numbers and skills were practised, when, with which outcome, play time per day). This is personal data in the GDPR sense if the nickname or the device context makes the child identifiable to someone — the analysis treats it as personal data of a child, the conservative assumption.
  4. The parent creates profiles, and only the parent can export, reset or delete data.
  5. Purchases are made by the parent through their Apple Account. Apple processes payment and purchase data as its own controller. The app receives signed transaction information on the device through StoreKit 2, verifies it on the device, and stores only an entitlement flag and expiration date in UserDefaults (Section 17). No transaction data leaves the device through the app.
  6. Crash reports and diagnostics reach the developer only through Apple (App Store Connect, Xcode Organizer) and only from users who opted in to share with app developers in iOS settings. That data is collected by Apple under Apple's privacy policy and made available to the developer in aggregated or de-identified form.

23.4.2 Controller role #

  • Data on the device. The processing of learning data happens on the family's device, triggered and controlled by the parent. The developer determines how the software processes data (design of the app) but never obtains the data, cannot access it and cannot instruct its processing after installation. Decision: the privacy policy states factually that the developer does not receive, store or have access to any data entered or created in the app, that all data remains on the device, and that the parent can view, export and delete it at any time. The policy does not assert a legal classification (for example, it does not state "the household exemption applies"); legal classification is for the lawyer review (the legal review, Section 26).
  • Privacy by design anyway. Regardless of whether the developer is a controller for on-device processing in the legal sense, the app is built as if Article 25 GDPR (data protection by design and by default) applied to the developer: minimal data, no optional data enabled by default, local storage, deletion available.
  • Support e-mail. If a parent writes to the support address (opened as a mailto: link from S-24 in the parent's own mail app), the developer receives that e-mail and is the controller for that correspondence. The privacy policy names the developer as controller for support correspondence, the purpose (answering the request), the legal basis (Art. 6(1)(b) or (f) GDPR — lawyer to confirm), and the retention (Decision: support e-mails are deleted 12 months after the conversation closes).
  • Website. The website hosting the privacy policy and imprint is outside the app. Decision: the website uses no cookies, no analytics and no third-party fonts or embeds, so its own privacy notice stays trivial. Hosting provider logs are covered in the website's privacy notice (Section 22 outline).
  • Apple. Apple is an independent controller for App Store, purchase, Family Sharing, TestFlight and opt-in diagnostics data. The privacy policy states this and links to Apple's privacy policy (link opened only behind the gate).

23.4.3 Data minimisation decisions #

Data item that a typical kids app would collect Decision in this app Reason
Child's real name Not collected. Optional nickname only, 0–20 characters, labelled "Spitzname (optional)". The nickname is shown only in the parent area and as a caption in the profile picker; the child identifies their profile by avatar. The app never speaks names.
Birthdate or age Not collected. The parent chooses a level ("Die Kleinen" or "Vorschule"). The level is all the learning engine needs (Section 9).
Photo of the child Not collected. 12 illustrated animal avatars. No camera, no photo library access.
Voice Never recorded. The name skill links a heard word to a numeral or quantity by tapping; the child never speaks into the app. No microphone use.
Handwriting (Nachspuren) Stroke points are evaluated in memory for the current task and discarded. Only the task outcome is stored. Section 12 owns the evaluation; no ink images are persisted.
Device identifiers Not read (identifierForVendor is forbidden by the policy check). Not needed.
Location, contacts, calendar, health Not accessed. Not needed.
Usage analytics None. Section 2 metrics use only App Store Connect data.

23.4.4 Article 8 GDPR (child's consent) #

Article 8 GDPR sets conditions for a child's consent where processing is based on consent (Art. 6(1)(a)) in relation to the offer of information society services directly to a child. Germany keeps the age limit at 16; Austria has set 14.

Reasoning adopted:

  1. The app does not rely on consent as a legal basis for any processing by the developer, because the developer processes no personal data of the child: nothing is transmitted to or accessible by the developer.
  2. There is no account, no registration and no consent dialog in the app. The parent installs the app and creates profiles; the child never provides data on their own initiative (the child cannot type anything in child mode).
  3. The subscription is an information society service offered to the parent, purchased by the parent through Apple, behind a parental gate, with the parent's Apple Account.
  4. Therefore Art. 8 is not triggered. Decision: the app shows no consent screen and no "I am over 16" confirmation. The privacy policy states the facts (1)–(3) in plain German. The lawyer review (the legal review, Section 26) explicitly confirms this point; if the lawyer concludes otherwise, the fallback is a one-time, parent-facing information screen during first-launch setup (S-02) stating what is stored on the device, with a "Verstanden" button — no data processing changes are needed.

23.4.5 TDDDG (storage on the terminal device) #

§ 25 of the German Telecommunications Digital Services Data Protection Act (TDDDG, formerly TTDSG) requires consent for storing or accessing information in the user's terminal equipment unless it is strictly necessary to provide a service the user explicitly requested. All storage the app performs (learning progress, settings, entitlement cache) is strictly necessary for the functions the parent and child use. Decision: no consent banner. The privacy policy lists what is stored on the device and why (the data inventory in Section 23.7 is the source). The legal review (Section 26) confirms.

23.4.6 Austria and Switzerland #

  • Austria: GDPR plus the Austrian DSG; the analysis above applies unchanged.
  • Switzerland: the revised Federal Act on Data Protection (revFADP, in force since September 2023) follows the same principles. Because no data leaves the device, no cross-border transfer occurs. The privacy policy is written to satisfy both regimes; the lawyer review covers Switzerland as well.
Item Decision
Imprint (Impressum, per the German Digital Services Act DDG) Published on the website; linked from S-24 behind the gate and from the App Store listing's support URL page.
EU Digital Services Act trader status The developer declares trader status in App Store Connect before EU distribution; trader contact details are then shown on the App Store product page. The support e-mail used there is the same as in the app.
Consumer withdrawal right for digital content Handled by Apple as the merchant of record for App Store purchases; the app does not add its own withdrawal flow. The refund request sheet in the parent area (Section 17) gives parents a direct path to Apple.
European Accessibility Act Decision: the developer qualifies as a micro-enterprise, which is exempt for services; the app nevertheless meets the accessibility requirements of Section 19 for the parent area. The legal review (Section 26) confirms exemption status.

23.5 App Privacy Label: "Data Not Collected" #

23.5.1 Apple's definition #

Apple defines "collect" as transmitting data off the device in a way that allows the developer and/or third-party partners to access it for longer than necessary to service the request in real time. Data processed only on the device is not collected. Apple states that developers are not responsible for disclosing data collected by Apple.

23.5.2 Answer in App Store Connect #

  • Question "Do you or your third-party partners collect data from this app?" → "No, we do not collect data from this app."
  • Resulting label on the product page: "Data Not Collected" (German App Store: "Keine Daten erfasst").

23.5.3 Justification per data category #

Apple data category Why it is not collected
Contact info, health, financial, location, sensitive info, contacts, user content, browsing/search history, identifiers Never accessed or never transmitted. The nickname is user content that stays on the device.
Purchases StoreKit purchases are processed by Apple. The app verifies transactions on the device and never transmits them. Apple's own collection of purchase data is Apple's, not the developer's. App Store Connect sales reports are Apple data shown to the developer in aggregate.
Usage data No analytics. App Store Connect App Analytics is collected by Apple (from users who consent to share with developers) and presented in aggregate; it is Apple's collection.
Diagnostics No crash reporting SDK, no MetricKit upload (Section 24.12). Crash logs and energy/hang reports visible in Xcode Organizer come from Apple's opt-in "Share With App Developers" diagnostics, which Apple collects.
Other data None.
Support e-mail Sent by the parent from their own mail app, outside the app; the app does not transmit it. Covered by the privacy policy (23.4.2), not the app label.

Verification step (release checklist C-08): before each submission, re-read Apple's "App privacy details" page and confirm (a) the definition of "collect" and (b) the statement that developers are not responsible for disclosing data collected by Apple are unchanged. If Apple ever requires disclosure of opt-in crash data received via Apple, the executor stops and records the conflict in DECISIONS.md; the app must not add any code to "fix" it.

23.5.4 Tracking #

  • The app does not track (no linking of data with third-party data for advertising, no data brokers).
  • NSPrivacyTracking is false, there are no tracking domains, and the app never calls App Tracking Transparency.

23.5.5 Label invariants for future versions #

  • V1.1 iCloud sync (Section 7.15): opt-in through the parent-area toggle "Mit iCloud synchronisieren" (off by default, available only while Premium is active, Section 26.4); when on, data goes to the user's own private CloudKit database. The developer cannot read private CloudKit databases. Decision: the label stays "Data Not Collected" in V1.1; the executor verifies Apple's then-current wording for CloudKit private databases before V1.1 submission.
  • Any future feature that would change the label requires a new decision in DECISIONS.md and is out of scope for V1.x.

23.6 Privacy Manifest (PrivacyInfo.xcprivacy) #

23.6.1 Location and membership #

  • File: App/PrivacyInfo.xcprivacy (repository layout of Section 5.13), target membership: app target Zahlenkette only.
  • Decision: the local package ZahlenketteKit ships no privacy manifests of its own. Its targets are first-party code linked into the app binary; the app-level manifest declares the app's (including those targets') API use. Verification: the privacy report generated from the archive (23.6.4) lists exactly one manifest.

23.6.2 Content #

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>NSPrivacyTracking</key>
    <false/>
    <key>NSPrivacyTrackingDomains</key>
    <array/>
    <key>NSPrivacyCollectedDataTypes</key>
    <array/>
    <key>NSPrivacyAccessedAPITypes</key>
    <array>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>CA92.1</string>
            </array>
        </dict>
        <dict>
            <key>NSPrivacyAccessedAPIType</key>
            <string>NSPrivacyAccessedAPICategorySystemBootTime</string>
            <key>NSPrivacyAccessedAPITypeReasons</key>
            <array>
                <string>35F9.1</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

23.6.3 Required-reason API decisions #

API category Used by app code? Reason code Decision and rationale
User defaults (UserDefaults, @AppStorage) Yes CA92.1 (read/write information only accessible to the app itself) Device toggles, entitlement cache, gate state, trusted-clock anchor and housekeeping keys (Section 23.7.3; key registry in Section 7.9.2). No App Group is used, so 1C8F.1 does not apply.
File timestamp (creationDate, modificationDate, contentModificationDateKey, attributesOfItem, stat family, getattrlist) No — Decision: app code never reads file timestamps. Pruning (Section 7) works on record dates in SwiftData, not on file dates. If a future change needs a file timestamp inside the app container, add C617.1 and update the allowlist in 23.10.
System boot time (mach_continuous_time, ProcessInfo.systemUptime, mach_absolute_time) Yes (trusted day clock, Section 15.12) 35F9.1 (measure time elapsed between events in the app, including across sleep) Decision: the trusted day clock reads mach_continuous_time() (converted with mach_timebase_info; it includes device sleep and resets on reboot) in exactly one file, ZKCore/Time/TrustedDayClock.swift, to detect device-clock changes during one boot. The value never leaves the device and is only compared with a stored anchor of the same boot. All other elapsed time (session time, gate cooldown, break nudge) is measured via the injected AppClock (Section 5). ProcessInfo.systemUptime and mach_absolute_time are not used (they exclude sleep) and stay forbidden by the policy check; mach_continuous_time is allowed only in the one file above (23.10.2). Frameworks (SpriteKit, AVFoundation) may use boot-time APIs internally; Apple frameworks are exempt from the app's manifest.
Disk space (volumeAvailableCapacityKey and variants, systemFreeSize, statfs, statvfs) No — Decision: the app does not check free space proactively. It handles write failures reactively (Sections 7.12.2 and 24.11). If a proactive check is ever added before writing the export file, add E174.1.
Active keyboards (activeInputModes) No — The parental gate uses a custom on-screen keypad; the parent-area nickname field uses the standard keyboard without querying input modes.

23.6.4 Verification #

  1. Archive the Release build in Xcode, open the archive in the Organizer and use "Generate Privacy Report". The report must show: tracking false, no tracking domains, no collected data types, exactly two accessed-API entries (UserDefaults with CA92.1; System boot time with 35F9.1), and exactly one manifest source (the app).
  2. The policy check (23.10) runs on every CI build and fails if a required-reason API not declared in the manifest appears in source.
  3. App Store Connect upload produces no privacy-manifest warning e-mail from Apple. Any such e-mail blocks release until resolved.

23.7 Data Inventory #

Every data item the app stores. SwiftData entities and fields are defined in Section 7; this table classifies them. "Export" refers to the JSON export defined in Section 7 and triggered from S-23 (Section 16). "Delete" lists the parent actions that remove the item; in addition, deleting the app from the device removes every item in this table.

23.7.1 SwiftData store (app container, Application Support) #

Data item (entity) Contents Personal data? Retention Exported Deleted by
ChildProfile Optional nickname (0–20 chars), avatarID, colorThemeID, level, createdAt, cached star balance Yes (nickname; profile as a whole relates to a child) Until deleted by the parent Yes Kind löschen, Alle Daten löschen (the profile identity is kept on both resets)
ProfileSettings Range override, difficulty cap, daily time limit, level-related settings Yes (relates to a child) Lifetime of the profile Yes Kind löschen, Alle Daten löschen
LearningState Engine state per profile: range stage and widening/probation counters, comfort mode, recent first-try history, re-entry counter, session counters (Section 7.5.3) Yes (learning data) Lifetime of the profile Yes Reset to defaults by Fortschritt zurücksetzen and Alles zurücksetzen; deleted by Kind löschen, Alle Daten löschen
MasteryRecord Per skill × number: score, attempts, first-try count, dates, Leitner box Yes (learning data) Lifetime of the profile Yes Fortschritt zurücksetzen, Alles zurücksetzen, Kind löschen, Alle Daten löschen
GameNumberStat Per game × number: first-try statistics used for befriending (Section 7.5.5) Yes (learning data) Lifetime of the profile Yes Fortschritt zurücksetzen, Alles zurücksetzen, Kind löschen, Alle Daten löschen
GameProgress Current difficulty step and stepping counters per game, plus the game's opaque per-game state string gameStateJSON (at most 8 KB, for example the Punkt-zu-Punkt picture rotation; Section 7.5.6) Yes Lifetime of the profile Yes Fortschritt zurücksetzen, Alles zurücksetzen, Kind löschen, Alle Daten löschen
TaskAttempt Log of individual task outcomes Yes 90 days, then pruned by the pruning job (Section 7) Yes (entries not yet pruned) Pruning, Fortschritt zurücksetzen, Alles zurücksetzen, Kind löschen, Alle Daten löschen
SessionRecord Session start/end, active seconds, end reason Yes Per the retention rules of Section 7 Yes Pruning (Section 7), Alles zurücksetzen, Kind löschen, Alle Daten löschen
DailyUsage Active seconds per local date, limit in force, warning allowance, extensions granted Yes Per the retention rules of Section 7 Yes Pruning (Section 7), Alles zurücksetzen (except today's row, so a reset never grants extra play time), Kind löschen, Alle Daten löschen
StarLedgerEntry Append-only star credits and debits Yes (low sensitivity) Lifetime of the profile Yes Alles zurücksetzen, Kind löschen, Alle Daten löschen (kept on Fortschritt zurücksetzen, Section 14.11)
OwnedDecoration, PlacedDecoration Garden items and positions Yes (low sensitivity) Lifetime of the profile Yes Alles zurücksetzen, Kind löschen, Alle Daten löschen (kept on Fortschritt zurücksetzen)
FriendState Befriended Zahlenfreunde and dates Yes (low sensitivity) Lifetime of the profile Yes Alles zurücksetzen, Kind löschen, Alle Daten löschen (kept on Fortschritt zurücksetzen, Section 14.11)
StickerAward Earned stickers Yes (low sensitivity) Lifetime of the profile Yes Alles zurücksetzen, Kind löschen, Alle Daten löschen (kept on Fortschritt zurücksetzen, Section 14.11)
AbenteuerRecord Local date, planned games, rounds completed, completed flag Yes Per the retention rules of Section 7 Yes Alles zurücksetzen, Kind löschen, Alle Daten löschen

Reset scopes are owned by Section 14.11, stored as specified in Section 7.18 and displayed by Section 16.10: "Fortschritt zurücksetzen" removes learning progress and keeps stars, garden, stickers and friends; "Alles zurücksetzen" (per child) resets everything for that child except the profile identity, its settings and today's DailyUsage row; "Kind löschen" removes the profile completely; "Alle Daten löschen" (device-wide) removes every profile and returns the app to S-02. The last remaining profile cannot be deleted with "Kind löschen"; the only path to zero profiles is "Alle Daten löschen". The "Deleted by" column above reflects these scopes.

23.7.2 Recovery folder #

Data item Contents Personal data? Retention Exported Deleted by
Application Support/ZahlenketteData/Recovery/<yyyyMMdd-HHmmss>/ A copy of a SwiftData store that failed to open at launch (procedure owned by Section 7.12.3); exists only after such a failure; contains the data of every profile that existed at that time Yes (same as the SwiftData store) At most one folder exists; a newer recovery replaces the older one; the remaining folder is deleted 30 days after it was created (Section 7.12.3) No "Wiederherstellungsdaten löschen" in the device group of S-23 (Section 16.10.2), Kind löschen (because the copy also contains that child), Alle Daten löschen, automatic deletion after 30 days

23.7.3 UserDefaults (app container, Library/Preferences) #

Every key, its exact name, default and reset behaviour is registered in Section 7.9.2; this table classifies the key groups.

Key group Contents Personal data? Retention Exported Deleted by
Device toggles (zk.device.*) Voice ("Sprache"), music, sound effects, haptics, Schüttelbox motion, pencil-only mode (Section 16.8) No (device preference) Until changed No (device-level, not child data) Alle Daten löschen resets them to defaults
Installation ID (zk.device.installationID) Random UUID used only to partition usage rows and tag records for V1.1 sync (Section 7.9.2); never transmitted, never shown No (random identifier without meaning outside the app) Lifetime of the installation No Kept by Alle Daten löschen; removed with the app
Entitlement cache (zk.entitlement.*) Last known entitlement state and expiration date (Section 17.7) No (no transaction IDs, no Apple Account data) Overwritten on each refresh No Kept by Alle Daten löschen (it is re-derived from StoreKit and prevents a wrong "free" state while offline); removed with the app
Parental gate state (zk.gate.consecutiveWrong, zk.gate.cooldownUntil) Wrong-answer counter and cooldown end after 3 wrong answers (Section 16.2) No Until the cooldown ends No Expires automatically; reset by Alle Daten löschen
Trusted-clock anchor (zk.clock.trustedAnchor and the other zk.clock.* keys) Wall-clock time and continuous time since boot at the last plausible reading (Section 15.12) No Overwritten continuously No Kept by Alle Daten löschen (clock-change protection); removed with the app
Housekeeping Last pruning date, last dedup time, store-recovery notice flag, launch-in-progress flag and crash-loop counter (Section 7.12.3), rating-request state (Section 16.14), last active profile ID (zk.app.lastActiveProfileID) No Until changed No Alle Daten löschen resets them per Section 7.9.2

There is no first-launch flag: launch routing uses the number of profiles (zero profiles lead to S-02, Section 15.4.1).

Rules for UserDefaults (binding for every section): never store a nickname, never store any per-child learning data, never store transaction identifiers or Apple Account information. A profile's UUID may be stored (e.g. last active profile) because it is a random identifier without meaning outside the app. Every key follows the zk.<area>.<name> form and is listed in Section 7.9.2 before it is used.

23.7.4 Transient data (never persisted) #

Item Where Lifetime
Nachspuren stroke points and PencilKit drawing Memory Discarded when the task ends
Core Motion accelerometer samples (Schüttelbox) Memory Discarded continuously; updates run only during Schüttelbox tasks (Section 24.13)
Parental gate question and typed answer Memory Discarded when the gate closes
Export JSON file FileManager.default.temporaryDirectory/Export/; file name and location as defined in Section 7.17.1 (<AppName>-Export-<DisplayName>-<yyyy-MM-dd>.json for one child, <AppName> from Brand.appName, display-name fallback of Section 15.2.1) Deleted when the share sheet closes (completed or cancelled); leftover export files are swept by deferred maintenance (Section 7.13.1)
Unified log (os.Logger) System log System-managed; the app logs no personal data (nickname is never logged; profile identifiers are logged with .private privacy, Section 6)

23.7.5 Data the app never has #

Real names, birthdates, photos, voice, location, device identifiers, contacts, e-mail addresses, Apple Account identifiers, payment data, IP addresses, advertising identifiers.

23.8 Family Rights: Access, Export, Correction, Deletion #

The rights below are exercised by the parent directly on the device. The developer cannot fulfil requests on the family's behalf because the developer holds no data; the privacy policy says so and explains how to use the in-app functions.

Right (GDPR) In-app implementation Screen / section
Access (Art. 15) Parent dashboard, progress grid, "Gerade schwierig", time chart show the learning data; the export shows everything. S-18, S-19, S-23; Section 16
Portability (Art. 20) "Daten exportieren" creates a JSON file (format owned by Section 7) and hands it to the system share sheet. The parent chooses where it goes (Files, Mail, AirDrop). S-23; Sections 7 and 16
Rectification (Art. 16) Profile editor: nickname, avatar, colour theme, level; child settings. Learning data is not hand-editable (it is a record of play), but can be reset. S-25, S-20
Erasure (Art. 17) "Fortschritt zurücksetzen" and "Alles zurücksetzen" (scopes per Section 14.11), "Kind löschen", "Alle Daten löschen" (double confirmation with typed word "LÖSCHEN", Section 16.10), "Wiederherstellungsdaten löschen" (Section 16.10.2). Deleting the app removes everything. S-23; Section 16
Restriction, objection (Art. 18, 21) Not applicable: no processing by the developer. —
Data held by Apple The privacy policy points parents to Apple for purchase and Apple Account data; the link opens only behind the gate. S-24

Deletion guarantees:

  1. Delete operations are hard deletes with cascade from ChildProfile (Section 7); nothing is kept in a trash or soft-deleted state.
  2. After "Alle Daten löschen", the SwiftData store contains zero records (integration test DeleteAllLeavesEmptyStore, Section 25.8), the UserDefaults keys are reset as registered in Section 7.9.2 (kept: the entitlement cache, the installation ID and the trusted-clock keys, none of which is child data), any recovery folder and leftover export files are deleted, and the app returns to S-02.
  3. A store recovery copy (23.7.2) contains every profile that existed when it was made. Therefore "Kind löschen" also deletes any recovery folder (Section 7.18.3), so no copy of a deleted child's data survives on the device.
  4. SQLite may keep deleted data in free pages until they are reused. Decision: after "Alle Daten löschen" and after "Kind löschen", the app saves and then lets the store compact normally; it does not attempt manual vacuuming. Rationale: the file is inside the sandbox, protected by Data Protection, and inaccessible to anyone without the unlocked device; deleting the app removes the file completely. This is stated in the privacy policy as "Gelöschte Daten werden aus der App entfernt; beim Löschen der App werden alle Daten vollständig vom Gerät entfernt."
  5. The app uses no Keychain items, so no data survives app deletion.

23.9 On-Device Security #

Topic Decision
File protection Default iOS Data Protection class for app files: completeUntilFirstUserAuthentication. The app does not set a stricter class, because the SwiftData store and audio playback must work immediately after launch, and the app may be relaunched by the system in the background of a locked-but-previously-unlocked device during memory pressure. No Data Protection entitlement is added.
Secrets None. The app contains no API keys, tokens, shared secrets or credentials. StoreKit 2 verification uses Apple's signed JWS verified by the system on device; no App Store shared secret is embedded.
Keychain Not used (see 23.8).
Networking None (23.10).
Web content No WKWebView, no SFSafariViewController; external pages open in the system browser after the gate. The privacy policy is additionally summarised as native text in S-24 so parents can read the essentials without leaving the app (Section 22 provides the text).
Inter-app communication No URL schemes, no universal links, no deep links (Section 18), no App Groups, no extensions, no Share extension. The app only sends the export file via the share sheet on parent request.
Pasteboard The app never reads the pasteboard. The nickname field allows paste through the standard text field (system-mediated, user-initiated), which is acceptable.
Logging os.Logger only, on-device, no personal data (Section 6).
Debug features The project has exactly three build configurations (Section 5.8.1): Debug, Profile (Release optimisation plus the compile flag UITEST_HOOKS, used only for performance tests and never archived for distribution) and Release. The developer menu (Section 5.10) is compiled only in Debug (#if DEBUG) and is reached only through its entry row on S-18 behind the gate. The UI-test launch arguments (single registry in Section 5.9) and the test seeding code are compiled only under the compilation condition "DEBUG or UITEST_HOOKS"; the Release configuration contains no code that reads them, and no argument bypasses the parental gate. Release checklist C-11 verifies their absence in the archived binary.
Input validation Nickname: trimmed, 0–20 grapheme clusters, control characters and newlines removed, stored as entered otherwise. Content JSON is bundled and validated at build time (Section 8) and again defensively at load (never crash in Release, Section 6). The export file is write-only; V1 has no import function, so there is no untrusted input path.
Backups The SwiftData store is included in the family's iCloud or computer device backup (default iOS behaviour). Decision: keep it included — restoring a device must restore the child's progress. Backups are under the family's Apple Account control; the privacy policy states this (Section 22.8), together with the store recovery copy of 23.7.2.

23.10 No-Network Enforcement #

23.10.1 Mechanism #

A repository script scripts/policy-check.swift (a single-file Swift script run with xcrun swift scripts/policy-check.swift, using NSRegularExpression so regex semantics are ICU and identical on every Mac; no third-party tools) scans all Swift sources under App/ and Packages/ZahlenketteKit/Sources/ and fails with a non-zero exit code if any forbidden symbol appears outside its allowlisted file. It runs:

  1. In Xcode Cloud in ci_scripts/ci_pre_xcodebuild.sh for every workflow (Section 25.20) — a failure fails the build.
  2. Locally in the pre-commit hook scripts/git-hooks/pre-commit (Section 6.11.3); the complete list of repository scripts is kept in Section 5.13.
  3. As a Swift Testing test PolicyCheckTests.sourceTreeHasNoForbiddenSymbols in the app's unit test target, which runs the same rule set with the same NSRegularExpression matching, using #filePath to locate the repository root (the simulator process can read the host file system). Both implementations read the same rule file, scripts/policy-rules.txt, so they cannot diverge; a small fixture test feeds each rule a known-bad and a known-good line to prove every regex matches what it should.

Comments are not exempt: a forbidden symbol in a comment fails the check too (keeps the scan simple and deterministic). Test targets (Tests/) are excluded from the scan except for rules marked all.

23.10.2 Rule file #

scripts/policy-rules.txt, one rule per line: <ICU regex>\t<allowlisted relative path or path prefix, or "-">\t<reason>. Each cell in the first column below becomes one or more rules.

Forbidden symbol (regex) Allowlist Reason
URLSession, NSURLSession, URLRequest, NSURLConnection — No network code
import Network, NWConnection, NWPathMonitor, NWListener, NWBrowser — No network code
CFSocket, CFStream, getStreamsToHost, import MultipeerConnectivity — No sockets, no peer networking
import WebKit, WKWebView, import SafariServices, SFSafariViewController, ASWebAuthenticationSession — No web content
import CloudKit, CKContainer, cloudKitDatabase: \.private — V1 is local only (Section 7 describes enabling it in V1.1; the rule is removed then)
openURL, UIApplication.shared.open, \bLink\( (matches SwiftUI Link( but not NavigationLink( or ShareLink() Packages/ZahlenketteKit/Sources/ZKParentArea/ExternalLinks/ExternalLinkOpener.swift Single gated exit point for links (Section 16)
requestReview, RequestReviewAction Packages/ZahlenketteKit/Sources/ZKParentArea/Review/ReviewPrompter.swift Rating prompt only in the parent area
import StoreKit files under Packages/ZahlenketteKit/Sources/ZKStore/ and Packages/ZahlenketteKit/Sources/ZKParentArea/ StoreKit only where Section 5 allows it
import AdSupport, ASIdentifierManager, import AppTrackingTransparency, identifierForVendor — No ads, no tracking, no device identifiers
UNUserNotificationCenter, registerForRemoteNotifications — No notifications
import CoreLocation, import Contacts, import Photos, import PhotosUI, import AVKit, AVCaptureSession, AVAudioRecorder, requestRecordPermission, inputNode, import Speech — No location, contacts, photos, camera, microphone, speech recognition
import MessageUI — Support e-mail uses a gated mailto: link instead of an in-app composer
import GameKit — No Game Center, no leaderboards
import MetricKit files under App/Debug/ Debug-only diagnostics (Section 24.12)
systemUptime, mach_absolute_time — Not used: both exclude device sleep; boot-time access is limited to the trusted day clock (23.6.3)
mach_continuous_time, mach_timebase_info Packages/ZahlenketteKit/Sources/ZKCore/Time/TrustedDayClock.swift Declared required-reason API (System boot time, 35F9.1), allowed only in the trusted day clock (Section 15.12)
creationDate, modificationDate, contentModificationDateKey, creationDateKey, attributesOfItem, \bstat\(, \bfstat\(, \blstat\(, getattrlist — Undeclared required-reason API
volumeAvailableCapacity, systemFreeSize, \bstatfs\(, \bstatvfs\( — Undeclared required-reason API
activeInputModes — Undeclared required-reason API
import Security, SecItemAdd, SecItemCopyMatching — No Keychain (23.8)

Note on creationDate / modificationDate: these names could collide with legitimate SwiftData property names. Decision: SwiftData properties use createdAt / updatedAt naming (consistent with Section 7), so the rule has no false positives.

23.10.3 Binary check #

In addition to the source scan, the Release workflow (Section 25.20) runs scripts/binary-check.sh on the archived app binary:

# Fails if the linked binary references Objective-C classes of forbidden networking or web APIs.
nm -u "$APP_BINARY" | grep -E '_OBJC_CLASS_\$_(NSURLSession|NSURLConnection|WKWebView|SFSafariViewController|ASIdentifierManager)' && exit 1
otool -L "$APP_BINARY" | grep -E '/(Network|WebKit|SafariServices|AdSupport|AppTrackingTransparency|CloudKit|GameKit|MessageUI)\.framework/' && exit 1
exit 0

Verification step for the executor: build a throwaway branch that references URLSession.shared once and confirm both the source scan and the binary check fail; record the result in DECISIONS.md. If the binary check proves unreliable for Swift-only references on the current toolchain, keep it as advisory and rely on the source scan, which is authoritative.

23.10.4 Runtime traffic check #

The source scan and the binary check prove the absence of networking code; the runtime check proves the absence of traffic (success criterion SC13, Section 2.5.3). Once per release, a full play-through and a parent-area walk-through run on a physical device while its traffic is captured from the connected Mac with the macOS built-in tools rvictl and tcpdump. Pass condition: every captured connection goes to an Apple host used by the system for StoreKit or the App Store; no connection is attributable to app code. The procedure and the pass record are part of the manual device passes in Section 25.14.2.

23.11 Parental Gate Compliance #

The gate specification is owned by Section 16. Compliance rationale:

Aspect Specification (Section 16) Why it satisfies Guideline 1.3
Challenge Written German multiplication question, factors 6–9, factors written as number words ("Was ist sieben mal acht?") Requires reading and multiplication — adult-level skills far beyond ages 2–6. The target group cannot read.
Not spoken The question text is never spoken, and the answer is never shown Prevents a pre-reader from solving it by listening.
Input Numeric keypad; the product is always two digits (36–81); "Bestätigen" is enabled only when exactly 2 digits are entered Guessing is impractical: 90 possible two-digit inputs per question, and every wrong answer brings a new question.
Randomisation Both factors random from 6–9 on every presentation; 10 distinct products (36, 42, 48, 49, 54, 56, 63, 64, 72, 81) A child cannot memorise one tap sequence.
Rate limit 3 wrong answers → 30-second cooldown; cooldown end stored in UserDefaults (zk.gate.cooldownUntil, Section 7.9.2) so relaunching the app does not bypass it Defeats trial-and-error.
Scope Precedes parent area, every external link, paywall and purchase, restore, manage subscription, refund request, offer-code redemption, time-limit extension Covers every item Apple requires to be gated.
Validity Access persists until the parent leaves the parent area or the app is backgrounded for more than 5 minutes Parent convenience without leaving an open door in child mode: returning to child mode always closes access.
External links After the gate, an additional confirmation sheet ("Sie verlassen jetzt die App", Section 16.11.2) before opening Double safeguard; also makes App Review's "clearly designated area" obvious.
Accessibility The gate is VoiceOver-accessible for adults (question read by VoiceOver on request, since VoiceOver is an adult-controlled setting) Accessibility for parents with visual impairment; a child cannot enable VoiceOver unaided.

Decisions not to use: Apple's Screen Time and "Ask to Buy" are complementary system features, not substitutes for the gate; Face ID/Touch ID is not used as a gate because a child may be enrolled or may hold the device to a parent's face, and biometrics would need a usage string for no real gain.

23.12 Content Safety #

Topic Rule
User-generated content None. The only free-text field is the parent-entered nickname, visible only in the parent area and the profile picker caption on this device.
Communication No chat, no messaging, no multiplayer, no sharing features in child mode.
External links in child mode None. Enforced by the policy check (23.10).
Illustrations Friendly animals, plants, everyday objects; no violence, no scary creatures (the Zahlenmonster is round, soft and friendly — Section 19 style guide), no weapons, no brand logos, no religious or political symbols. Diverse skin tones in finger-pattern hands (Section 19).
Audio Warm voice, no shouting, no sudden loud effects; SFX loudness per Section 20.
Food depictions Counting objects may include fruit and vegetables; decision: no sweets or junk food as rewards or counting objects, to avoid reinforcing food-as-reward.
Manipulative design No streaks, pressure timers, loot boxes, random rewards, scarcity or notifications (forbidden-pattern list in Section 14).
Physical safety Schüttelbox uses a gentle shake threshold and always offers a button fallback; the spoken instruction asks the child to hold the device with both hands (Section 12). Motion can be switched off in device settings (Section 16).
Photosensitivity No flashing above 3 Hz (Section 19). Blitzblick "flash" is a display-then-hide of dots, not a strobe.

23.13 Age Rating Questionnaire #

Apple's age rating questionnaire in App Store Connect was revised in 2025 (ratings 4+, 9+, 13+, 16+, 18+) and may change again. The executor answers the questions present at submission time using the principles below; the table gives the expected answers for the question groups known at the time of writing. Expected result: 4+.

Question group Answer Note
Cartoon or fantasy violence None Friendly characters only
Realistic violence, prolonged graphic or sadistic violence None
Profanity or crude humour None
Mature or suggestive themes, sexual content or nudity None
Horror or fear themes None
Alcohol, tobacco, drug use or references None
Medical or treatment information; health or wellness topics None / No The app is educational, not medical
Simulated gambling, gambling, contests None / No
Loot boxes No Rewards are deterministic (Section 14)
Unrestricted web access No No web view; external links only behind the gate
User-generated content No
Messaging and chat No
Advertising No
Parental controls Yes The parent area offers daily time limits, range and difficulty settings behind a parental gate. Decision: answer truthfully "Yes"; this does not raise the rating.
Age assurance No The app does not verify age; the gate is a parental barrier, not age assurance. The Declared Age Range API is not used in V1.
"Made for Kids" / Kids category Yes, age band 5 and Under Section 23.2.1

If a new question appears whose honest answer would raise the rating above 4+, the executor stops and records the question in DECISIONS.md for a product decision; they do not change app behaviour to fit the questionnaire without that decision.

23.14 App Review Notes Template #

Paste into App Store Connect → App Review Information → Notes (English, because reviewers may not read German). Replace bracketed placeholders. The app name placeholder is filled from Marketing/de/ rendering (Section 20) — the executor writes the current display name.

Thank you for reviewing {{APP_NAME}}, a German-language number-learning app for children aged 2-6 ("Made for Kids", age band 5 and Under).

NO LOGIN / NO NETWORK
- No account or login is required. The app makes no network requests of its own; all data stays on the device. StoreKit is the only system service used.

FIRST LAUNCH
- The first screen is parent-facing and silent. Tap "Profil einrichten" (Set up profile); setup starts with the parental gate (see below), then creates the first child profile on one form (choose an animal avatar and a level; the nickname is optional) and hands the device to the child.

PARENTAL GATE (protects the parent area, all purchases, restore, subscription management, all external links and time-limit extensions)
- The gate shows a multiplication question written in German number words, e.g. "Was ist sieben mal acht?" ("What is seven times eight?"). Type the two-digit product on the keypad and tap "Bestätigen" (Confirm).
- Number words: sechs = 6, sieben = 7, acht = 8, neun = 9. "mal" = times.
- Answers: 6x6=36, 6x7=42, 6x8=48, 6x9=54, 7x7=49, 7x8=56, 7x9=63, 8x8=64, 8x9=72, 9x9=81.
- After 3 wrong answers the gate pauses for 30 seconds.
- To open the parent area: on the child home screen (or on the profile picker when several profiles exist) press and hold the small grown-up icon in the top-right corner for 2 seconds.

PREMIUM CONTENT (auto-renewable subscription)
- Free forever: Entdecken, Wie viele?, Hör hin, Was fehlt?.
- Premium (8 games): Blitzblick, Mehr oder weniger, Nachspuren, Schüttelbox, Froschsprung, Fütter das Zahlenmonster, Memory, Punkt zu Punkt. They appear on the game picker with a lock badge.
- Tapping a locked game in child mode never shows a purchase screen; it shows a "Frag deine Eltern" (ask your parents) screen with a parent icon, which leads to the parental gate and then to the paywall in the parent area. The spoken line there is "Dieses Spiel ist noch zu. Frag deine Eltern." ("This game is still closed. Ask your parents."); it names no price, no product and no app name.
- Subscription: parent area > "Premium". Products: [BUNDLE_ID].premium.monthly (3,99 EUR in Germany) and [BUNDLE_ID].premium.yearly (29,99 EUR in Germany), both with a 7-day free trial for eligible new subscribers, Family Sharing enabled. The paywall shows the storefront price from the App Store, never a hardcoded price. The app is offered in Germany, Austria and Switzerland. Use a Sandbox Apple Account to purchase. "Käufe wiederherstellen" (Restore Purchases) is on the same screen.

SHAKE GAME
- "Schüttelbox" asks the child to shake the device gently. A button alternative is always shown, so the game can be reviewed without shaking.

TIME LIMIT
- Each child profile has a daily time limit (default 20 minutes). When reached, a calm "Zeit zum Ausruhen" screen appears; a parent can grant +10 minutes via the parental gate.

SOUND
- All instructions are spoken in German. Please review with sound on. With sound off, visual demonstrations replace the voice.

PRIVACY
- App Privacy: Data Not Collected. No third-party SDKs, no analytics, no advertising, no tracking. A privacy manifest is included.

Contact: [SUPPORT_EMAIL]

Rules for the notes:

  1. Update the notes whenever the gate, the parent entry gesture, product IDs or prices change (release checklist C-12).
  2. Decision: no demo account is provided (none exists). No special review build or review-only unlock is ever added.
  3. If review rejects under Guideline 1.3 for the gate, the fallback order is: (a) reply in Resolution Center explaining the gate; (b) if still rejected, increase difficulty by requiring two-digit × one-digit multiplication (factors 12–19 × 3–9) — recorded as a risk with this mitigation in Section 28.

23.15 Light Threat Model #

Assets worth protecting: the child's safety and attention (no exits, no purchases), the family's data (privacy), the family's settings (time limit), and the developer's revenue (entitlement). Adversaries considered: the child, a sibling, a curious adult with physical access, a tamperer with a modified device.

# Threat Likelihood Impact Mitigation Residual risk and rationale
T1 Child bypasses the gate (older sibling solves it, child memorises one answer, parent answers in front of the child) Medium for siblings of 8+, low for target ages Low: behind the gate the child could change settings or reach the paywall; any purchase still requires Apple Account authentication (Face ID, Touch ID or password) and, for child Apple Accounts, Ask to Buy Random factors, never spoken, cooldown, access ends when leaving the parent area Accepted. The gate meets Apple's standard; purchases have a second, system-level barrier.
T2 Sibling plays in another child's profile or picks the wrong avatar High Low: mastery data of the wrong child shifts Distinct avatars and colours; the profile picker shows the avatar large and the nickname caption for parents Accepted. Decision (Section 15): profile switching needs no gate. The engine self-corrects over time (Section 9).
T3 Sibling resets or deletes another child's progress Low Medium Reset and delete require the gate plus confirmations (Section 16) Accepted: requires solving the gate and an explicit confirmation; a child capable of both is effectively acting as an adult.
T4 Device clock changed to extend the daily time limit or to trigger a new Abenteuer Low Low Time-limit state and usage attribution use the trusted day clock (Section 15.12), which ignores clock changes within one boot; the Abenteuer uses the local day of AppClock (Section 9) Accepted. A reboot plus a clock change can still move the Abenteuer day; the time limit is a family agreement tool, not a security control; Screen Time is the system-level control.
T5 Device clock changed to extend the offline entitlement cache Low Low revenue impact Cache rules owned by Section 17 (cache trusted only until expiration plus the offline grace defined there; refreshed from StoreKit whenever reachable) Accepted. StoreKit re-verifies entitlements on every launch with connectivity.
T6 Jailbroken device or modified binary unlocks Premium Low Low revenue impact Only verified StoreKit transactions grant access (Section 17); no jailbreak detection Accepted. Jailbreak detection requires heuristics that would touch required-reason APIs, create false positives, and cannot be made robust; the affected population is tiny.
T7 Lost or stolen device exposes the child's data Low Low: pseudonymous learning data iOS Data Protection (23.9); no names, birthdates or photos stored Accepted.
T8 Exported JSON file shared carelessly Low Low Export only on parent action behind the gate; file deleted from the temporary directory after sharing; the export contains only the optional nickname as personal identifier Accepted. The parent chooses the destination.
T9 Child reaches the system (Control Center, Home screen, other apps) High Out of scope of the app The app recommends Guided Access in the parent Help page (text in Section 22) Accepted. Guided Access is the system solution; the app does not try to block system gestures.
T10 Malicious content injected into bundled content files Very low Medium Content is bundled and code-signed; no downloadable content in V1 Accepted.
T11 Accidental data loss by crash or disk full Low Medium Atomic units of work and save points (Section 7.12.2), store recovery with in-memory fallback (Section 7.12.3), launch integrity checks (Section 7.19), reliability rules of Section 24.9 Mitigated.

23.16 Compliance Release Checklist #

These items are part of the release QA checklist in Section 25.18 and must all pass for every App Store submission.

ID Check How
C-01 Current App Review Guidelines 1.3, 2.3, 3.1, 5.1.1, 5.1.4 re-read; no new requirement unmet Manual; note date and outcome in DECISIONS.md
C-02 Policy check passes CI log
C-03 Binary check passes Release workflow log
C-04 Every external link is gated and confirmed UI test ParentLinksAreGated passes; manual tap-through of S-24
C-05 Child mode shows no price, product name, buy button or external link on any screen Manual walk through S-04 to S-16 and S-26/S-27 on iPhone SE and iPad
C-06 Privacy policy URL reachable, German text current, matches the data inventory in 23.7 Manual
C-07 Metadata rules of 23.2.3 met for name, subtitle, description, keywords, screenshots, preview Manual review against Section 22
C-08 App Privacy answers "Data Not Collected"; Apple's definitions re-checked (23.5.3) App Store Connect
C-09 Info.plist contains no NS*UsageDescription keys and no UIBackgroundModes plutil -p on the archived Info.plist
C-10 No Data Protection entitlement or class override added; no Keychain use Entitlements file review; policy check
C-11 Developer menu, UI-test launch-argument handling and test seeding absent from the Release binary nm on the archived binary for the developer-menu, launch-argument and seeder type names defined in Sections 5.9 and 5.10 returns nothing
C-12 App Review notes (23.14) current Manual
C-13 Privacy report from the archive matches 23.6.4 Xcode Organizer
C-14 Age rating answers per 23.13 App Store Connect
C-15 Primary category Education; "Made for Kids" selected with age band "5 and Under"; storefronts Germany, Austria, Switzerland (23.2.1) App Store Connect

24. Performance, Offline and Reliability #

This section owns: performance budgets and how they are measured, the size budgets for the app download, the offline guarantee, storage growth limits, data integrity (save points and atomicity), low-storage behaviour, crash and diagnostics handling without third-party SDKs, battery and thermal behaviour, and failure-mode handling. Framework and version choices are in Section 5; SwiftData entities and the pruning job are in Section 7; audio engine behaviour is in Section 20; StoreKit behaviour is in Section 17.

24.1 Reference Devices and Measurement Conditions #

Term Definition
Reference device iPhone SE (2nd generation), A13, 3 GB RAM, 375×667 pt. Every budget in this section must be met on it unless a row names another device.
iPad reference iPad (10th generation). Used for iPad-specific budgets and memory.
Largest-canvas device iPad Pro 13-inch. Used for image memory and download-size variants.
OS versions The deployment-target iOS major line and the current iOS major line (Section 5). Each budget is measured on both.
Cold launch The app process is not running (terminated via the app switcher or by the test harness), the device has not been rebooted within the last 2 minutes.
Post-reboot launch First launch after a device reboot. Budgets are relaxed as stated per row.
Heavy dataset The seeded store heavy-3y: 5 profiles, each with 3 years of simulated play at 60 minutes per day (task attempts pruned to 90 days as the pruning job would leave them, full star ledger, all 60 decorations owned, 24 placed, 20 friends, 52 stickers). Generated by the test seeding code DebugSeeder, compiled only under the compilation condition "DEBUG or UITEST_HOOKS" (seed catalogue in Section 25.10.3, launch arguments in Section 5.9).
Typical dataset The seeded store typical-1y: 2 profiles, 1 year at 20 minutes per day.
Build Release configuration, installed from an archive (TestFlight or ad-hoc/development-signed Release build for Instruments), or the Profile configuration (Release optimisation plus UITEST_HOOKS, Section 5.8.1) for runs that need a seeded dataset. Debug builds are never used for budget measurements.
Conditions Battery ≥ 50 %, not charging for energy tests, Low Power Mode off (unless testing it), room temperature 20–25 °C, brightness 50 %, Wi-Fi on unless testing offline.

24.2 Performance Budgets #

Every row is a release gate (Section 25.18). "p50/p95" are over at least 10 measurements.

ID Metric Budget Device(s) Measured by
PB-01 Cold launch to child home interactive (1 profile, typical dataset) p50 < 2.0 s; post-reboot < 3.0 s Reference device XCTApplicationLaunchMetric(waitUntilResponsive: true) plus signpost LaunchToChildHome (24.3)
PB-02 Cold launch to profile picker S-04 (5 profiles, heavy dataset) p50 < 2.0 s Reference device Same as PB-01
PB-03 Warm resume (background < 5 min) to interactive < 0.5 s Reference device Signpost ResumeToInteractive
PB-04 Tap-to-audio: touch recognised on a numeral, bead or friend to first audible sample, built-in speaker p95 < 100 ms; software portion p95 ≤ 30 ms Reference device Signpost TapToAudio plus reported output latency (24.6)
PB-05 Frame rate in SwiftUI game screens during animations 60 fps; hitch time ratio < 5 ms/s Reference device Instruments "Animation Hitches"
PB-06 Frame rate in SpriteKit scenes (Schüttelbox, Wie viele? moving stage) Sustained 60 fps; no frame > 33 ms after the first second of the scene Reference device SpriteKit showsFPS in Debug for smoke checks; Instruments "Animation Hitches" on Release for the gate
PB-07 Game open: tap on a game tile to first task visible and its prompt starting < 500 ms p95 Reference device Signpost GameOpen
PB-08 Task generation (engine selection + game generator) < 5 ms p99 Reference device (unit perf test on simulator as early warning) Signpost TaskGeneration; XCTClockMetric
PB-09 Save point duration (save points of Section 7.12.2, labelled in 24.9.2) < 20 ms p95, heavy dataset Reference device Signpost SavePoint
PB-10 Parent dashboard S-18 open after the gate < 500 ms p95, heavy dataset Reference device Signpost ParentDashboardOpen
PB-11 Progress grid S-19 open (20 × 8 cells) < 500 ms p95, heavy dataset Reference device Signpost ProgressGridOpen
PB-12 Export JSON generation for all profiles < 3 s, heavy dataset; UI shows a progress indicator after 300 ms Reference device Signpost Export
PB-13 Peak memory (physical footprint) < 250 MB in every flow Reference device, iPad reference, iPad Pro 13-inch Instruments "Allocations"/"VM Tracker"; XCTMemoryMetric in UI perf tests
PB-14 App download size (App Store thinned variant) Hard cap < 250 MB for every variant; target ≤ 150 MB for the largest variant All variants App Thinning Size Report; App Store Connect file sizes (24.4)
PB-15 Energy during non-SpriteKit play Xcode energy impact "Low" on average; 30-minute scripted play drains ≤ 8 % battery Reference device Xcode Debug Navigator energy gauge (on a Release-configuration run); manual drain test
PB-16 Thermal No transition to ProcessInfo.ThermalState.serious during 30 minutes of continuous play at room temperature Reference device Signpost event on thermal state changes; manual test
PB-17 Greeting audio after child home appears Starts ≤ 1.0 s after child home becomes interactive Reference device Signpost GreetingStart
PB-18 Deferred launch jobs (pruning, star-balance verification, temp cleanup) Never cause a hitch > 50 ms; complete within 10 s of child home Reference device, heavy dataset Signposts PruningJob, StarBalanceVerify

24.3 Measurement Instruments #

24.3.1 Signposts #

All performance intervals are emitted with OSSignposter (os framework), subsystem = bundle identifier, category Performance. Signposts stay in Release builds: they write only to the on-device unified log when a profiler is attached and transmit nothing. The signposter lives in ZKCore so every module can use it.

import os

public enum PerfSignpost {
    public static let signposter = OSSignposter(
        subsystem: Bundle.main.bundleIdentifier ?? "Zahlenkette",
        category: "Performance"
    )

    /// Names are StaticString; one name per budget row in Section 24.2.
    public static let launchToChildHome: StaticString = "LaunchToChildHome"
    public static let resumeToInteractive: StaticString = "ResumeToInteractive"
    public static let tapToAudio: StaticString = "TapToAudio"
    public static let gameOpen: StaticString = "GameOpen"
    public static let taskGeneration: StaticString = "TaskGeneration"
    public static let savePoint: StaticString = "SavePoint"
    public static let parentDashboardOpen: StaticString = "ParentDashboardOpen"
    public static let progressGridOpen: StaticString = "ProgressGridOpen"
    public static let export: StaticString = "Export"
    public static let greetingStart: StaticString = "GreetingStart"
    public static let pruningJob: StaticString = "PruningJob"
    public static let starBalanceVerify: StaticString = "StarBalanceVerify"
    public static let contentLoad: StaticString = "ContentLoad"
}

Rules: signpost metadata never contains a nickname or any child data; only game IDs, step names, counts and durations.

24.3.2 XCTest performance tests #

Swift Testing has no performance metrics, so performance tests use XCTest. They live in the test plan Performance.xctestplan (Section 25.2), run only on physical devices, and store per-device baselines in the test target's xcshareddata/xcbaselines.

import XCTest

final class LaunchPerformanceTests: XCTestCase {
    func testColdLaunchToChildHome_singleProfile() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-uiTestSeed", "typical-1y-single"]
        measure(metrics: [XCTApplicationLaunchMetric(waitUntilResponsive: true)],
                options: Self.options) {
            app.launch()
            XCTAssertTrue(app.otherElements["S05.root"].waitForExistence(timeout: 5))
            app.terminate()
        }
    }

    func testLaunchSignpost() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-uiTestSeed", "typical-1y-single"]
        let metric = XCTOSSignpostMetric(subsystem: "de.zahlenkette.app",
                                         category: "Performance",
                                         name: "LaunchToChildHome")
        measure(metrics: [metric, XCTMemoryMetric(application: app)], options: Self.options) {
            app.launch()
            _ = app.otherElements["S05.root"].waitForExistence(timeout: 5)
            app.terminate()
        }
    }

    static var options: XCTMeasureOptions {
        let o = XCTMeasureOptions()
        o.iterationCount = 10
        return o
    }
}

The launch arguments (single registry in Section 5.9) are parsed only under DEBUG || UITEST_HOOKS. Performance tests therefore run against the Profile configuration defined in Section 5.8.1: Release optimisation (-O, whole-module) and the same asset compilation, plus the compile flag UITEST_HOOKS, which enables the launch arguments and the seeding code; no developer menu (that exists only in Debug, Section 5.10). The Profile configuration is never archived for distribution; the release workflow archives Release only (Section 25.20). Accessibility identifiers used in these tests follow the format of Section 6.10 (S05.root is the child-home container). The subsystem string in tests is read from the bundle identifier chosen in Section 1.

24.3.3 Instruments templates #

Budget Instrument template What to look at
PB-01/02/03 App Launch Time to first frame, main-thread blocking during App.init, SwiftData container creation, content load
PB-05/06 Animation Hitches Hitch time ratio, individual hitches > 16.7 ms
PB-04 os_signpost + Audio System Trace TapToAudio intervals; audio I/O cycle
PB-13 Allocations, VM Tracker, Leaks Physical footprint peak, abandoned memory after leaving games
PB-15/16 Energy Log (on device via developer settings), Thermal State CPU/GPU wake, thermal transitions
PB-09/PB-18 Time Profiler + os_signpost Save and deferred-job durations on the main thread

24.3.4 Field data #

After release, the developer reviews Xcode Organizer metrics (launch time, hang rate, memory, disk writes, energy, scrolling hitches) weekly for the first 8 weeks and monthly afterwards. These metrics come from users who opted in to share analytics with app developers and are collected by Apple (Section 23.5). A field p50 launch time above 2.0 s for iPhone SE-class devices, or any hang-rate regression, opens a defect with priority P1 (Section 25.19).

24.4 Download Size Budget #

24.4.1 Budget table (largest thinned variant) #

Component Budget V1 expected Basis
Executable and package code (all ZahlenketteKit targets statically linked, no third-party code) 15 MB 8–12 MB Swift binary for ~23 targets with whole-module optimisation
Content JSON and compiled String Catalogs 2 MB < 1 MB Section 8 files
Audio (all locales, music, SFX, reserve) 60 MB ≈ 30 MB 24.4.2
Illustrations and UI assets 80 MB ≈ 63 MB 24.4.3
App icon, launch screen, misc. 3 MB 1 MB
Total 160 MB budgeted; hard cap 250 MB ≈ 105–110 MB

Decision: the target of ≤ 150 MB for the largest variant keeps the app below the size at which iOS asks users before downloading over cellular with default settings; the executor verifies the current threshold on the reference device (Settings → App Store → App Downloads) at release and records it in DECISIONS.md. The hard cap of 250 MB is a release blocker.

24.4.2 Audio size math #

Encoding is owned by Section 20: voice as AAC in .m4a, 44.1 kHz, mono, about 96 kbps. 96 kbps = 12 kB per second of audio.

Audio block Count Mean duration Encoded size Budget
German voice lines (number words in statement and question intonation, carrier fragments, prompts, hints, feedback, session and friend lines; exact list in Section 21, counts in Section 21.14.1) 400–600 (budget sized for 600) 2.5 s (number words ≈ 1 s; prompts and hints 2–4 s) 600 × 2.5 s × 12 kB/s = 18.0 MB, plus container overhead ≈ 2 kB × 600 = 1.2 MB → 19.2 MB 22 MB
English voice lines (V1.2, same structure) ≤ 600 2.5 s 19.2 MB 22 MB reserved
Music loops 3 ≤ 120 s each Assumed AAC stereo 128 kbps = 16 kB/s: 3 × 120 × 16 kB = 5.8 MB 7 MB
Sound effects ≤ 80 (inventory count in Section 21.14.3) ≤ 1.5 s 80 × 1.5 × 12 kB = 1.4 MB 2 MB
Reserve for voice lines of quarterly content drops — — ≈ 290 additional lines per locale at the same rate 7 MB
Total V1 ships ≈ 27 MB (German voice + music + SFX) 60 MB

Rules:

  1. If Section 21's final German line count exceeds 600, its SFX count exceeds 80, or the measured mean duration exceeds 2.5 s, recompute this table; the 22 MB per-locale budget may grow only by taking from the content-drop reserve.
  2. Audio is never stored as uncompressed WAV/CAF in the bundle.
  3. Audio files are locale folders in the bundle (Resources/Audio/<locale>/); App Thinning does not strip other locales, which is why the English reserve is counted now.
  4. On-Demand Resources are not used in V1 (they require Apple-hosted downloads and would break the fully-offline first launch).

24.4.3 Illustration size math #

App Thinning delivers only the scale factor (@2x or @3x) and idiom the device needs, so the budget applies to the largest single variant (iPad Pro 13-inch at @2x, 2064 × 2752 px screen). Asset catalog images use compression "Automatic"; large opaque backgrounds may use "Lossy". Vector artwork (icons, simple shapes) is stored as PDF or SVG; the "Preserve Vector Data" setting follows Section 19.8. Asset names follow the flat scheme of Section 6.4.3 (<category>_<slug>[_<state>]).

Asset class Count Mean size per asset (largest variant) Total
Scene backgrounds: 12 game backgrounds, child home world, Zahlengarten, sticker album, Zahlenfreunde gallery 16 1.5 MB (layered: a gradient sky drawn in SwiftUI plus 3–6 illustrated foreground layers; no single full-screen photographic bitmap) 24.0 MB
Zahlenfreunde, 20 characters × 4 states (idle, happy, sleepy, silhouette; Section 14.5.1) 80 150 kB 12.0 MB
Garden decorations 60 100 kB 6.0 MB
Punkt-zu-Punkt revealed pictures (the dots are drawn from JSON) 24 400 kB 9.6 MB
Stickers: 24 picture stickers as downscaled variants, 20 friend stickers reuse friend art (0 kB extra), 8 milestone stickers 32 new assets 24 × 60 kB + 8 × 100 kB 2.2 MB
Avatars 12 120 kB 1.4 MB
Counting objects and Zahlenmonster foods (one catalogue, Section 11.1.4) 18 types 60 kB 1.1 MB
Game props (monster, frog, lily pads, shaker box, dice faces, palm and finger shapes for the code-composed hands of Section 19.7.5, bead textures if bitmap) ~60 80 kB 4.8 MB
Custom UI icons (SF Symbols preferred where suitable) ~40 25 kB 1.0 MB
App icon set 1 — 1.0 MB
Total ≈ 63 MB (budget 80 MB)

Rules:

  1. Animation is done with SwiftUI transforms and SpriteKit actions on single images. Frame-by-frame image sequences are forbidden (they are the largest threat to the budget).
  2. Every image asset has a maximum pixel size matching its largest on-screen size on iPad Pro 13-inch; the content validation step (Section 8) warns on any image larger than 1.25× that size.
  3. The 17 MB headroom is reserved for content drops (new garden themes, Punkt-zu-Punkt pictures).

24.4.4 How size is measured #

  1. Archive → Distribute App → "Custom" → "Release Testing"/"Ad Hoc" with "App Thinning: All compatible device variants" → read App Thinning Size Report.txt (compressed and uncompressed size per variant).
  2. After upload, App Store Connect → TestFlight → build → "Build Metadata" → App File Sizes: the authoritative download size per device.
  3. The release workflow (Section 25.20) records the largest variant's size in the build log; a growth of more than 10 MB versus the previous release requires a note in DECISIONS.md.

24.5 Memory Budget #

Peak physical footprint < 250 MB on every device (PB-13). Allocation guide:

Consumer Ceiling Control
Baseline (SwiftUI, frameworks, SwiftData container, content model) 70 MB No eager loading of all images; content JSON decoded into value types once
Decoded audio buffers (PCM, float32 mono 44.1 kHz ≈ 176 kB per second of audio) 48 MB (the cache cap of Section 20.10.1) Cache policy owned by Section 20.10.1: a resident tier of about 19 MB (all number words, carrier and solution fragments, feedback lines, common hints and all SFX) that stays loaded while child mode runs, plus least-recently-used game lines that are evicted when the cap is reached. Never decode the full voice library (600 lines × 2.5 s would be ≈ 265 MB).
Images decoded for the current screen 80 MB One background (a full-screen iPad Pro 13-inch bitmap is ≈ 23 MB decoded) plus visible sprites. Leaving a screen releases its images (no global image cache beyond the system's asset cache).
SpriteKit scene textures 30 MB Texture atlases per scene, preloaded before the round starts, released when the game closes
PencilKit canvas 20 MB Exists only during a Nachspuren task
SwiftData row cache 10 MB Fetches use predicates and fetch limits; the progress grid fetches the 160 MasteryRecords of one profile only

SpriteKit and PencilKit never coexist on screen, so the sum of ceilings is not reached simultaneously (worst case 70 + 48 + 80 + 30 + 10 = 238 MB). On UIApplication.didReceiveMemoryWarningNotification (observed via the SwiftUI app lifecycle per Section 5): the audio service evicts all non-resident buffers and keeps the resident tier (Section 20.10.1), textures of scenes that are not visible are released, and the event is logged with os.Logger.

24.6 Launch Path and Latency Design #

24.6.1 Critical path to child home #

Only the following work may run before the child home (or the profile picker) is interactive:

Step Budget on reference device Notes
Process start and dynamic linking ≤ 250 ms No third-party dynamic frameworks; package products are linked statically (Section 5). No +load/static initialisers with work.
ModelContainer creation (local store, no CloudKit in V1) ≤ 250 ms Store in Application Support; no migration in V1
Content load: all JSON files in Resources/Content/ decoded (≤ 1 MB total) ≤ 150 ms Decoded off the main actor in a nonisolated async function, awaited before the first child screen; the Release build runs the full validation of Section 8.15 except the image-existence check, which runs in tests (Section 25.7)
Read cached entitlement from UserDefaults and register the StoreKit Transaction.updates listener ≤ 5 ms The listener is registered in the App initializer before the first scene appears (Section 17.6.1), so renewals, refunds and Ask-to-Buy approvals delivered during launch are never missed; only the Transaction.currentEntitlements refresh is deferred
Fetch profiles (≤ 5) and the selected profile's garden state ≤ 100 ms Uses the cached star balance on ChildProfile
First frame of S-05 or S-04 ≤ 500 ms Garden images for the visible viewport only

Deferred until after the child home is interactive (in this order, each started only after the previous finishes; each yields with await Task.yield() between batches so no single main-thread slice exceeds 8 ms):

  1. Audio session activation and AVAudioEngine start (not blocking, after the first frame, Section 5.4.4); decoding of the resident tier (Section 20.10.1); greeting playback (PB-17).
  2. StoreKit: refresh Transaction.currentEntitlements (Section 17); the listener already runs from App init.
  3. Delete leftover export files in the temporary directory (Section 7.13.1).
  4. Pruning job, at most once per local day (Section 7.13 owns the job's rules).
  5. Star-balance verification (sum of the ledger compared with the cached balance on ChildProfile; Section 7 owns the correction rule), at most once per local day, in batches of 2,000 ledger entries using FetchDescriptor with fetchLimit/fetchOffset and propertiesToFetch limited to the amount.

24.6.2 Tap-to-audio path #

  • The audio engine is running whenever child mode is active (Section 20); starting it on tap is forbidden.
  • Buffers for any line that can be triggered by a single tap on the current screen are decoded before the screen becomes interactive (for example, number words of the active range on Entdecken).
  • The tap handler makes one synchronous call from the main actor into the audio service's lock-protected playback facade for already-decoded SFX and number-word buffers (Section 20.4.2); there is no await between the gesture and scheduleBuffer/play. Decoding stays inside the audio actor and never happens on the tap path.
  • Measurement: signpost TapToAudio begins in the gesture action and ends when the player node's play() returns with the buffer scheduled (software portion). The audible latency is the software portion plus AVAudioSession.sharedInstance().outputLatency plus ioBufferDuration, logged once per session in Debug and by the performance test. Bluetooth routes add latency outside the app's control; PB-04 applies to the built-in speaker and wired headphones only.

24.6.3 Game open path #

When a game tile is tapped: the engine selects tasks for the round (PB-08), the game module builds its first task, the game's prompt buffers are decoded, SpriteKit atlases (if any) are preloaded, then the screen appears and the intro prompt starts. The transition animation (Section 19) overlaps this work; if the work exceeds the transition duration, the transition's end state holds (no spinner in child mode) until ready, up to the PB-07 budget.

24.7 Rendering Rules #

Topic Rule
SwiftUI updates Game state is @Observable (Section 6); views observe the narrowest state they need so a bead tap does not re-render the whole screen. Bead chains and the Zwanzigerfeld use fixed-size stacks (≤ 20 items), not lazy containers.
Expensive drawing Beads, dice and finger patterns are drawn with SwiftUI shapes (Section 19) and cached with .drawingGroup() only where Instruments shows a benefit on the reference device.
SpriteKit Used only for Schüttelbox and the moving stage of Wie viele? (Section 5). SpriteView is created with preferredFramesPerSecond: 60 on all devices (ProMotion devices do not run SpriteKit at 120 Hz: no visible benefit for these scenes, double the energy). The scene is paused (isPaused) whenever the game's pause overlay is shown, the scene is not visible, or the scene phase is not .active. Physics bodies use simple circles; node count ≤ 60 per scene.
Ambient animation At most one looping ambient animation per screen (Section 19). It stops when the screen is not frontmost.
ProMotion SwiftUI animations use the system default frame rate; no custom CADisplayLink loops.
Parent area charts Swift Charts with at most 7 bars (time chart) or 30 points (mastery gains); no animations on first render in the parent area when Reduce Motion is on.

24.8 Offline Guarantee #

24.8.1 Guarantee #

Every child-facing feature and every parent-area feature except those listed in 24.8.2 works with no network connection, including the very first launch after installation:

  • all 12 games' logic, all bundled content, all recorded audio, TTS fallback (the German on-device voice is part of iOS; if it is not installed, the TTS fallback is silent and the visual path of Section 20 applies);
  • learning engine, rewards, garden, Zahlenfreunde, sticker album, Abenteuer;
  • profiles, time limits, parental gate, progress views, settings, export (the share sheet offers local destinations such as "In Dateien sichern"), reset and delete.

The app contains no networking code (Section 23.10), so no feature can accidentally depend on the network.

24.8.2 Features that need the network (all system-mediated) #

Feature Offline behaviour Owner
Loading products and prices on the paywall Paywall shows an offline state with a retry button; no purchase buttons without loaded products Section 17
Purchase, restore (AppStore.sync()), manage subscription, refund request, offer-code redemption System sheets show their own errors; the app shows the offline state of Section 17 Section 17
Entitlement refresh Cached entitlement is used under the offline rules of Section 17 Section 17
External links (privacy policy, imprint, EULA) and support e-mail The system browser or mail app handles connectivity; the app's native privacy summary on S-24 is always available offline Section 16

24.8.3 First launch without network #

With no cached entitlement and no network, the app treats the family as not subscribed: the 4 free games are available, premium games show the lock. When connectivity returns, the Transaction.updates listener and the next launch refresh the entitlement (Section 17). This is tested in the device QA pass (Section 25.14) with airplane mode enabled before first launch.

24.9 Data Integrity #

24.9.1 Principles #

  1. The SwiftData main context is used on the main actor (Section 5). Decision: autosaveEnabled is set to false on the main context; the app saves explicitly at the save points below. Rationale: explicit saves make each save group one SQLite transaction with a known content, which is what the atomicity guarantees rely on.
  2. A save group (a unit of work in Section 7.12.2) is a set of mutations that must become durable together. A save group is performed inside one synchronous main-actor function: all mutations, then try modelContext.save(). There is no await between the first mutation of a group and its save(). A code-review checklist item (Section 6) enforces this.
  3. Derived values (cached star balance on ChildProfile, consecutive-outcome counters in GameProgress) are updated in the same save group as the records they derive from.
  4. The star ledger is append-only (Section 7). A star credit is never written without the event that earned it in the same group, and a decoration purchase writes the ledger debit and the OwnedDecoration record in one group, so a crash can never produce a charged-but-missing item or a free item.

24.9.2 Save points #

The units of work, their exact contents and their initiators are owned by Section 7.12.2. This section only labels them for measurement (signpost SavePoint, PB-09) and for the failure analysis below. Ledger reasons are the persisted StarReason raw values of Section 7.3.3.

Label Section 7.12.2 unit of work Trigger (summary)
SP-1 Task completed: attempt, mastery, per-game number statistics, game progress including the per-game state string, learning state, taskSolved ledger entry, cached balance, newly befriended friends and their stickers, session counters The game framework reports a task outcome (firstTry, afterHint or shown, Section 10)
SP-2 Round completed: roundCompleted ledger entry, rounds counter, milestone stickers, Punkt-zu-Punkt picture sticker, Abenteuer progress Round completion, before S-08 starts
SP-3 Abenteuer started / completed: AbenteuerRecord insert or update, abenteuerCompleted ledger entry Abenteuer start; soft end S-10 begins
SP-4 Decoration purchased: OwnedDecoration, decorationPurchased debit, caches Purchase confirmed in S-12
SP-5 Decoration placed, moved, returned Placement ends in the garden
SP-6 Parent change (settings, profile fields, profile creation; reset and delete run as the chunked operations of Section 7.18) Each confirmed parent action (Section 16)
SP-7 Activity flush: pending active seconds into DailyUsage and SessionRecord Every 15 s of active child-mode time (Section 15.7.2), and on scene phase .inactive/.background, profile switch and time limit reached
SP-8 Session opened / closed Session start and end (Section 15)
SP-9 Maintenance chunks (at most 500 objects per save) Deferred maintenance (Section 7.13.1)

Consequences:

  • A crash or force-quit mid-round loses at most the task in progress (the unanswered task) and up to 15 s of usage time accounting. All completed tasks of the round keep their mastery updates and their stars. The round bonus is not granted for an incomplete round. After relaunch, the app starts at S-01 and routes normally (Section 15); the interrupted round is not resumed.
  • An interrupted Abenteuer: completed rounds are persisted and the AbenteuerRecord keeps its planned games and the number of completed rounds; the completed flag is set only at SP-3. Tapping the Abenteuer again on the same local day resumes it at the next unplayed round; a new local day discards it (Section 9.16.6).

24.9.3 Save failure handling #

The procedure is owned by Section 7.12.2; the reliability requirements it must satisfy are:

  1. A save error never reaches child mode: no error is shown to the child, and the child flow continues unchanged.
  2. The failing unit of work is rolled back as a whole (rollback()), so a unit of work is either fully durable or absent. This holds for every error kind, including out-of-space (NSFileWriteOutOfSpaceError, or SQLite SQLITE_FULL in the underlying error): unsaved changes are never kept in the context to be retried later, because a later unit of work would then commit a mixture of old and new changes.
  3. The next unit of work simply tries again; retries never loop (each save point attempts one save).
  4. In Debug, assertionFailure with the error; in Release, the error is logged with os.Logger (no personal data).
  5. Repeated failures raise the parent-area notice of Section 7.12.2. When the error kind is out-of-space, the notice reads (Sie-form; final copy owned by Section 22): "Auf dem Gerät ist kein Speicher mehr frei. Neuer Fortschritt kann gerade nicht gespeichert werden. Bitte geben Sie Speicherplatz frei." The notice disappears after the first successful save.

Test SaveFailureRollsBackUnitOfWork (Section 25.8) covers rule 2 for both error kinds.

24.9.4 Store open failure and crash-loop protection #

Store opening, retry, recovery folder and fallback are owned by Section 7.12.3. Summary of the required behaviour:

  1. If ModelContainer creation fails at launch, retry once after 200 ms.
  2. If it fails again, move the store files to Application Support/ZahlenketteData/Recovery/<yyyyMMdd-HHmmss>/ (at most one recovery folder exists; a newer recovery replaces the older one; the remaining folder is deleted 30 days after it was created), open a fresh empty store and start the first-launch flow (S-02). The parent area shows a one-time notice; "Wiederherstellungsdaten löschen" in the device group of S-23 (Section 16.10.2) deletes the folder. The folder age is read from the folder name, not from file timestamps (Section 23.6.3).
  3. If the fresh store also fails (for example because the disk is full), the app opens an in-memory store so the child can still play, and the parent area shows a persistent notice that progress is not being saved (Section 7.12.3). The app never crashes and never shows a dead-end screen.
  4. Crash-loop protection: at process start the app sets a launch-in-progress flag and clears it 10 s after the first child or parent screen is interactive. If the flag is still set at the next launch, a crash counter increments; when the counter reaches 2, the launch runs in safe mode: deferred jobs (24.6.1 steps 3–5) are skipped for that launch. The counter resets after a launch completes normally. Both keys are registered in Section 7.9.2. The launch integrity checks of Section 7.19 run in both modes.

24.10 Storage Growth and Pruning #

The pruning job and per-entity retention rules are owned by Section 7 (TaskAttempt entries older than 90 days are deleted; the job runs at launch, at most once per local day, deferred per 24.6.1). This subsection states the limits the design must stay within.

24.10.1 Growth estimate #

Assumptions: about 6 tasks per minute of active play; per-row storage including indexes and SwiftData overhead ≈ 300 bytes for TaskAttempt and ≈ 150 bytes for StarLedgerEntry; ledger entries per task ≈ 1.2 (one per task plus round and Abenteuer bonuses and purchases).

Scenario TaskAttempt (steady state, 90 days) Star ledger growth per year Other entities Store size after 1 year After 3 years
Typical: 2 profiles, 20 min/day 2 × 120 × 90 = 21,600 rows ≈ 6.5 MB 2 × 144 × 365 ≈ 105,000 rows ≈ 16 MB < 2 MB ≈ 25 MB ≈ 57 MB
Maximum: 5 profiles, 60 min/day every day 5 × 360 × 90 = 162,000 rows ≈ 49 MB 5 × 432 × 365 ≈ 790,000 rows ≈ 118 MB < 5 MB ≈ 170 MB ≈ 410 MB

24.10.2 Limits and decisions #

  1. Design envelope: the typical scenario must stay below 60 MB after 3 years; the maximum scenario is a theoretical ceiling (a household of five children each playing the maximum limit every day for three years) and is accepted.
  2. All budgets in 24.2 marked "heavy dataset" are measured with the maximum scenario at 3 years, so the app is proven to remain fast at the ceiling.
  3. The star balance shown to the child always comes from the cached value on ChildProfile; the full ledger sum is computed only by the deferred daily verification (24.6.1), never on a critical path.
  4. No other files grow over time: no logs are written to files, no caches are written to disk, exports are deleted after sharing.
  5. Decision to revisit (Section 28): if Xcode Organizer disk-usage or disk-write metrics or support requests show storage concerns, introduce ledger compaction in a later version; compaction must preserve the append-only merge property required for V1.1 sync (Section 7).

24.11 Low Storage Behaviour #

Situation Behaviour
Device storage full during play The failing unit of work is rolled back (24.9.3, Section 7.12.2); play continues; the parent-area notice appears after repeated failures.
Storage full during export The export writes to the temporary directory; on write failure the parent sees the export failure alert of Section 22.6.7 ("Export nicht möglich: Auf dem Gerät ist nicht genug Speicher frei.") and no partial file remains (the file is written atomically with Data.write(to:options: .atomic)).
iOS purges caches or temporary files under storage pressure Harmless: the app stores nothing essential in Caches or tmp. All audio and images are in the bundle and cannot be purged.
App offloaded by iOS ("Offload Unused Apps") Documents and data are kept by iOS; on reinstall the store and UserDefaults are intact. Tested once per release (Section 25.18).
Store open fails because of no space Handled by 24.9.4 (Section 7.12.3): retry once, then recovery and a fresh store; if the fresh store also fails, the in-memory fallback lets the child play and the parent area shows a persistent notice (Sie-form, final copy owned by Section 22), for example "Auf dem Gerät ist kein Speicher mehr frei. Fortschritt wird gerade nicht gespeichert. Bitte geben Sie Speicherplatz frei und öffnen Sie die App erneut." It never crashes.

The app does not query free disk space proactively (Section 23.6.3).

24.12 Crash and Diagnostics Handling Without Third-Party SDKs #

24.12.1 Sources #

Source What it provides Collected by
Xcode Organizer → Crashes Symbolicated crash reports from TestFlight and App Store users who share diagnostics with developers Apple
TestFlight feedback Crash feedback and screenshots sent voluntarily by testers Apple (TestFlight)
Xcode Organizer → Metrics, Hangs, Disk Writes, Energy, Launches, Memory Aggregated performance data from opting-in users Apple
App Store Connect → App Analytics Installs, sessions, retention, crashes (aggregate), from opting-in users Apple
Device console and Instruments Logs and traces on test devices connected to the developer's Mac On-device, developer's own devices

Symbols: archives are uploaded with symbols included (the Xcode default "Upload your app's symbols"), so Organizer reports are symbolicated. dSYMs are kept with each archive in the Xcode Organizer; the release workflow (Section 25.20) keeps archives as build artifacts.

24.12.2 MetricKit decision #

Decision: MetricKit is used only in Debug builds. The MXMetricManagerSubscriber implementation lives in App/Debug/MetricKitInspector.swift inside #if DEBUG, and its payloads are shown in the developer menu (Section 5.10) on the developer's own test devices. Release and TestFlight builds do not link or subscribe to MetricKit.

Rationale:

  1. MetricKit payloads are delivered to the app on the device; they would only be useful to the developer if transmitted, and transmitting them would contradict "Data Not Collected" (Section 23.5) and the no-network rule.
  2. Xcode Organizer already provides the same field metrics and diagnostics (crashes, hangs, launch time, memory, energy, disk writes), collected by Apple from opting-in users, at no code cost.
  3. Distinguishing TestFlight from App Store builds at runtime would add code whose only purpose is diagnostics; a Debug-only subscriber is simpler and cannot leak into production.
  4. In Debug, payloads can be triggered on demand with Xcode's "Simulate MetricKit Payloads" debug command; the executor verifies this command exists in the current Xcode and otherwise waits for the daily delivery on a test device.

The policy check (Section 23.10) allows import MetricKit only under App/Debug/.

24.12.3 Crash triage process #

  1. Weekly (first 8 weeks after each release) and monthly afterwards: review Organizer crashes and hangs.
  2. Every crash signature with ≥ 2 occurrences, and every crash in the learning engine, persistence or StoreKit code regardless of count, gets a defect with a reproduction attempt and a regression test (Section 25.19).
  3. A crash affecting ≥ 0.5 % of sessions (Organizer crash rate) triggers an expedited bug-fix release.
  4. Hangs over 2 s on the main thread are treated as crashes for triage.

24.12.4 Runtime robustness rules #

  • Content errors never crash a Release build: a malformed or missing content item disables only the affected task or game step, logs an error, and the engine selects another task (Section 6 error handling, Section 8 validation).
  • fatalError, precondition and force unwraps are forbidden in Release code paths reachable from user input or content (Section 6); assert/assertionFailure are used for Debug-time detection.
  • Every game handles "no valid task could be generated" by ending the round early with the normal round-end flow and granting stars for completed tasks; this is logged as an error and covered by game logic tests (Section 25.6).

24.13 Battery, Thermal and Sensors #

Topic Rule
SpriteKit Paused when not visible, during pause overlays and when the scene phase is not .active; 60 fps cap (24.7); the scene is deallocated when the game closes.
Core Motion One app-wide CMMotionManager, created by the app target and injected into GameSchuettelbox (Apple recommends a single instance). Samples arrive on a private serial OperationQueue, off the main actor; only discrete shake events are delivered to the main actor (Section 5.6.1). Accelerometer updates start only when a Schüttelbox task is on screen and the device setting for motion is on (Section 16.8), and stop when the task ends, the pause overlay appears, the scene phase leaves .active, or the game closes. The update interval is defined in Section 12. No other game uses motion.
PencilKit The canvas exists only during a Nachspuren task.
Audio engine Running while the app is active; stopped on background (Section 20). No audio plays in the background (no background modes).
Haptics Only on the events of the haptics map in Section 19.10 (no haptic for errors, no per-tap haptic); the haptics toggle disables all.
Timers No repeating timers while idle except the 1 Hz session-time accounting timer during active child mode (Section 15), which is suspended in the parent area and in the background.
Thermal state .serious Pause ambient animations, run SpriteKit at 30 fps, use the Reduce Motion variant of celebrations (Section 19). Gameplay is otherwise unchanged.
Thermal state .critical As .serious, plus disable haptics until the state drops to .fair or lower.
Low Power Mode Pause ambient animations and run SpriteKit at 30 fps; all else unchanged.
Screen dimming isIdleTimerDisabled is true only while an S-07 task is on screen and not paused, and false on every other screen including the pause overlay (Section 15.11 owns the rule). Auto-lock on other screens leads to normal backgrounding, handled by the session and pause rules of Section 15.

Thermal and Low Power Mode changes are observed via ProcessInfo.thermalStateDidChangeNotification and NSProcessInfoPowerStateDidChange, published by a small DeviceConditionMonitor (@Observable, main actor) in the app target (ZKCore does not use Observation, Section 5.2.1); the app passes the resulting animation and frame-rate settings to the design system and games through the environment.

24.14 Audio Failure Fallbacks #

Audio behaviour is owned by Section 20; the reliability requirements are:

Failure Required behaviour
Audio file missing for an audio ID TTS fallback per Section 20 (never for number words in Release; content validation prevents that case). If TTS is also unavailable, the visual path (Section 20 sound-off path) carries the instruction.
File present but undecodable Treated as missing; logged once per audio ID per launch.
AVAudioEngine fails to start (for example, during a phone call) The app continues with the visual path; the audio service retries on the audio session interruption-ended notification and on the next scene activation.
Media services reset (AVAudioSession.mediaServicesWereResetNotification) Rebuild the engine and player nodes, re-decode resident buffers, continue.
Route change (headphones removed) Per Section 20.12: the current voice line stops, music pauses, the round is not paused and no pause overlay appears; the speaker button stays available. No crash, no stuck state.
Playback completion never reported Games never wait indefinitely for audio: every awaited line has a timeout of its known duration plus 500 ms, after which the flow continues.

A game must be fully completable with all audio failing (tested in Section 25.13 with the audio service stubbed to fail).

24.15 Accessibility Performance #

Topic Requirement
VoiceOver in the parent area Progress grid cells expose a combined label (number, skill, band) and the grid is navigable without lag: moving focus between cells < 100 ms on the reference device. Charts provide accessibility descriptions (Swift Charts audio graph support).
Dynamic Type Parent area screens at the largest accessibility size render without truncation of essential information and without dropping below 60 fps while scrolling on the reference device.
Reduce Motion Replaces motion with fades (Section 19), which also reduces GPU work; no budget may depend on Reduce Motion being on.
Switch Control / Voice Control Parent area controls are standard SwiftUI controls and work without special performance handling.

24.16 Background and Lifecycle Behaviour #

  • No UIBackgroundModes entries (Section 23.16 C-09). No background tasks (BGTaskScheduler is not used); all housekeeping runs deferred at launch (24.6.1).
  • On scene phase .inactive: pause the game (Section 10 pause/interrupt; interruptions shorter than 3 s resume automatically, Section 10.12.3), pause SpriteKit, stop motion updates, run SP-7.
  • On .background: SP-8 save, stop the audio engine, stop the session-time timer (Section 15), close parental gate access after the timeout defined in Section 16.
  • On return to .active: rebuild audio if needed, resume per Section 15 (a session ends after more than 5 minutes in the background).
  • Multiple windows on iPad: V1 supports a single scene. Decision: UIApplicationSupportsMultipleScenes is false, so two windows can never write to the same store concurrently; the layout still adapts to any window width ≥ 375 pt (Section 5).

24.17 Failure Mode Summary #

# Failure User-visible effect Data effect Section
F1 Crash mid-task App relaunches to normal start Task in progress lost; ≤ 15 s usage lost 24.9.2, 7.12.2
F2 Disk full Parent-area notice; child unaffected Each failing unit of work is rolled back; later units of work are saved once space returns 24.9.3, 24.11, 7.12.2
F3 Corrupt store Fresh start with notice in parent area; in-memory fallback if no fresh store can be created Old store kept in the recovery folder for at most 30 days 24.9.4, 7.12.3
F4 Repeated launch crash Safe-mode launch Deferred jobs skipped 24.9.4
F5 Audio unavailable Visual path None 24.14
F6 Content item invalid Affected task or step skipped None 24.12.4
F7 No network Premium state from cache; paywall offline state None 24.8, 17
F8 Thermal pressure Fewer animations, 30 fps SpriteKit None 24.13
F9 Memory warning None visible (buffers reloaded on demand) None 24.5

25. Testing and Quality Assurance #

This section owns: the test strategy, test targets and plans, coverage targets, required test suites per module, UI test infrastructure (launch arguments, seeds, identifiers), StoreKit testing, the device matrix, accessibility and audio QA, the child and parent usability test protocols, the acceptance criteria index, the release QA checklist, the regression policy and continuous integration. Test naming style is owned by Section 6; the engine test vectors are owned by Section 9; per-game acceptance criteria are owned by Sections 11–13; performance budgets by Section 24; compliance checks by Section 23.16.

25.1 Quality Strategy and Test Pyramid #

Layer Share of tests (by count) Framework Runs where Purpose
Unit ≈ 70 % Swift Testing (import Testing) iOS Simulator, every push Pure logic: engine, generators, evaluators, rewards, content validation, entitlement state machine, gate logic, formatting
Integration ≈ 20 % Swift Testing (StoreKit and SwiftData tests hosted in the app) iOS Simulator, every push SwiftData with an in-memory ModelContainer, repositories + engine + rewards together, real bundled content, StoreKit via SKTestSession
UI ≈ 10 % XCTest / XCUITest iOS Simulator (smoke on every push, full weekly), physical devices before release Critical journeys of Section 3, gating, accessibility audits, layout screenshots
Performance a few dozen XCTest measure(metrics:) Physical reference devices only Budgets of Section 24.2
Manual checklists — Physical device matrix Device QA, audio listening, Pencil, motion, child and parent usability

Principles:

  1. Logic lives in packages without UI dependencies wherever possible (Section 5), so it is testable with fast unit tests.
  2. Everything random or time-dependent receives an injected RandomNumberGenerator and AppClock (Section 5). Tests never depend on the wall clock or on SystemRandomNumberGenerator.
  3. No third-party test libraries (no snapshot-testing packages, no mocking frameworks). Fakes are hand-written.
  4. Every defect fix starts with a failing test (25.19).

25.2 Test Targets, Plans and Tooling #

25.2.1 Targets #

Target Kind Location Host Tests
ZKCoreTests Swift Testing Packages/ZahlenketteKit/Tests/ZKCoreTests none Domain types, Brand, AppClock helpers, local-day calculations
ZKLearningEngineTests Swift Testing .../Tests/ZKLearningEngineTests none 25.5
ZKContentTests Swift Testing .../Tests/ZKContentTests none (reads the real Resources/Content via a test resource copy, 25.7) 25.7
ZKPersistenceTests Swift Testing .../Tests/ZKPersistenceTests none (in-memory container) 25.8
ZKAudioTests Swift Testing .../Tests/ZKAudioTests none Queueing, priority, cancellation, fallback logic with a fake output
ZKGameKitTests Swift Testing .../Tests/ZKGameKitTests none Round lifecycle, hint ladder, outcome classification, pause/resume
ZKRewardsTests Swift Testing .../Tests/ZKRewardsTests none 25.4
ZKStoreTests Swift Testing .../Tests/ZKStoreTests none (fake transaction source) 25.9.1
ZKParentAreaTests Swift Testing .../Tests/ZKParentAreaTests none Gate logic, view models, "Gerade schwierig" text building
Game<Name>Tests (12) Swift Testing .../Tests/Game<Name>Tests none 25.6
TestSupport target used only by test targets (Section 5.3.4) Packages/ZahlenketteKit/Tests/TestSupport — Shared fakes and fixtures: FakeAudioService, FakeTransactionSource, in-memory persistence helpers, content fixtures, test tags (Tags.swift, Section 6.2.3)
ZahlenketteTests Swift Testing, app-hosted Tests/ZahlenketteTests App Composition root, GameRegistry, StoreKit with SKTestSession (25.9.2), policy check test (Section 23.10), end-to-end logic flows
ZahlenketteUITests XCUITest Tests/ZahlenketteUITests App 25.10, 25.12
ZahlenkettePerfTests XCTest UI performance Tests/ZahlenkettePerfTests App 25.11

TestSupport is declared in the package manifest of Section 5.3.4; only test targets depend on it, and scripts/check-imports.sh fails if a production target or the app target imports it. Deterministic inputs are production types from ZKCore, not test-only copies: the seedable generator SplitMix64 (Section 5.3.2) and the clocks FixedClock (immutable struct) and AdjustableClock (Section 5.4.2). Tests use a literal seed and a fixed time zone (Section 6.2.3):

import Testing
import ZKCore

@Test("same seed produces the same sequence")
func sameSeedSameSequence() throws {
    let berlin = try #require(TimeZone(identifier: "Europe/Berlin"))
    let clock = FixedClock(now: Date(timeIntervalSince1970: 1_791_000_000), timeZone: berlin)
    var a = SplitMix64(seed: 42)
    var b = SplitMix64(seed: 42)
    #expect((0..<100).map { _ in a.next() } == (0..<100).map { _ in b.next() })
    #expect(clock.now == Date(timeIntervalSince1970: 1_791_000_000))
}

Tests that need time to move use AdjustableClock from ZKCore (compiled in Debug, the configuration of the Fast, UISmoke and Full plans); no test defines its own clock or generator type.

25.2.2 Test plans #

Test plan Contents Configuration Used by
Fast.xctestplan All Swift Testing targets (unit + integration), property tests with 200 seeds per case Debug Local runs, CI on every push
UISmoke.xctestplan UI journeys J-01, J-02, J-04 and ParentLinksAreGated Debug CI on every push
Full.xctestplan Fast + all UI tests + accessibility audits; environment variable ZK_PROPERTY_SEEDS=2000 Debug Weekly CI, before release
Performance.xctestplan ZahlenkettePerfTests and XCTest performance tests of engine/generators Profile (Section 5.8.1; use in Section 24.3.2) Physical devices before release

Property tests read ZK_PROPERTY_SEEDS from ProcessInfo.processInfo.environment (default 200). These four plans are the complete set; Section 5.13 lists them in the repository layout.

25.2.3 Commands #

# Unit and integration tests
xcodebuild test -project Zahlenkette.xcodeproj -scheme Zahlenkette \
  -testPlan Fast -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation)' \
  -enableCodeCoverage YES -resultBundlePath build/Fast.xcresult

# Coverage gate
xcrun swift scripts/coverage-check.swift build/Fast.xcresult scripts/coverage-thresholds.json

# Policy check (Section 23.10)
xcrun swift scripts/policy-check.swift

scripts/ci-local.sh runs exactly the CI workflow steps (25.20) locally.

25.3 Coverage Targets and Enforcement #

Coverage is line coverage from xcrun xccov view --report --json on the Fast plan's result bundle. scripts/coverage-check.swift reads the JSON report and the thresholds file and fails if any threshold is missed. Thresholds are per target, and optionally per file glob.

Target / file glob Line coverage minimum Additional requirement
ZKLearningEngine ≥ 90 % Every test vector of Section 9 implemented (25.5.1); every public function exercised
ZKCore ≥ 90 % —
ZKContent ≥ 90 % 100 % of content files validated (25.7)
ZKRewards ≥ 90 % Every star source, every sticker trigger, befriending rule branches
ZKStore ≥ 90 % 100 % of entitlement state transitions of Section 17 covered (25.9.1)
ZKPersistence ≥ 85 % Every repository method; every unit of work of Section 7.12.2
ZKGameKit ≥ 80 % Every round lifecycle state and transition of Section 10
Game* targets, files *TaskGenerator.swift, *Parameters.swift ≥ 90 % The naming of Section 6.2.1 places task generation and parameter decoding in these files
Game* targets, files *RoundModel.swift ≥ 80 % Answer evaluation and round state live in the round model (Section 6.2.1)
Game* targets overall ≥ 60 % View code is covered by UI tests and manual QA
ZKParentArea, files *Model.swift (screen models, Section 6.2.1), Gate*.swift (GateChallenge, GateRules and related gate logic, Section 16) ≥ 85 % —
ZKParentArea overall ≥ 50 % —
ZKAudio ≥ 70 % Hardware-bound code excluded by design (thin adapter file AVAudioOutput.swift)
ZKDesignSystem, app target no minimum Covered by UI tests, accessibility audits and screenshot review

Rules:

  1. Thresholds may be raised, never lowered, without a DECISIONS.md entry.
  2. Code excluded from coverage by design must be in files named in scripts/coverage-thresholds.json under "excludedFiles", each with a reason.
  3. Coverage is a floor, not a goal: a test that executes code without asserting behaviour is a review defect (Section 6 review checklist).

25.4 Unit Tests per Module #

Required test suites. Each bullet is at least one @Test; parameterised tests are used wherever a rule applies to many values.

Module Required suites
ZKCore GameID raw values and display-name keys for all 12 games; Skill raw values (8); Level and RangeStage mapping to number ranges (r5 = 1–5, r10 = 1–10, r20 = 1–20); local-day start computation across DST changes (last Sunday of March and October in Europe/Berlin) and across time zones; Brand.appName fallback to "Zahlenkette" when the bundle key is missing; SplitMix64 determinism for a literal seed; TrustedDayClock (Section 15.12) with an injected continuous-time source: a wall-clock change of more than 300 s within one boot is ignored, a reboot re-anchors, a drift that persists for 24 hours of continuous time is accepted; shared enums declared once with their persisted raw values (DifficultyCap, RangeOverride, StarReason, SessionEndReason, Section 7.3.3)
ZKLearningEngine 25.5
ZKContent 25.7; decoding errors produce typed errors, never crashes; unknown JSON keys ignored; schemaVersion other than 1 rejected with a typed error
ZKPersistence 25.8
ZKAudio Priority rules (a new prompt cancels pending lower-priority lines; feedback never overlaps a prompt), per-channel toggles (voice/music/SFX), fallback chain file → TTS → visual-only signal, timeout of awaited lines (known duration + 500 ms, Section 24.14), cache eviction never removes the resident tier, also not on a memory warning (Section 20.10.1)
ZKGameKit A round ends after as many outcomes as the RoundPlan has tasks (4 for littleOnes, 5 for vorschule, or the per-step override of Section 9.9.2, for example Memory); a wrong option fades to 35 % and becomes non-interactive for the rest of the task (Section 10.6.3); a single tap on a draggable item sends it to the active target (Section 10.9.5); no break nudge is ever raised inside a round (Section 15.9); outcome classification (attempt 1 correct → firstTry; attempt 2 or 3 correct → afterHint; third wrong → shown); hint ladder order; pause/resume restores the same task; events emitted in the order defined in Section 10; "no valid task" path ends the round gracefully (Section 24.12.4)
ZKRewards Stars: +1 per completed task for every outcome including shown, +2 round bonus, +5 Abenteuer bonus; a 5-task round yields 7 stars and a 4-task round 6 stars; no other star source exists; ledger entries use the StarReason raw values taskSolved, roundCompleted, abenteuerCompleted, decorationPurchased (Section 7.3.3); balance never negative; purchase with insufficient stars rejected; purchase debit and item grant happen together. Befriending: parameterised over constructed mastery states — befriended iff mastered in ≥ 3 distinct skills active for the level, at least one of count or subitize, and first-try correct in ≥ 3 distinct games; never un-befriended when scores drop or the subscription lapses. Stickers: all 52 award triggers, each at most once; milestone thresholds (first round, first Abenteuer, 5/10/20 friends, 100/500 lifetime stars, first decoration). Catalog: 60 items with prices 10/25/50/100 by size category (Section 14)
ZKStore 25.9.1
ZKParentArea Parental gate (ParentalGateTests): both factors always in 6…9; question string uses German number words (sechs, sieben, acht, neun) and the multiplication word "mal"; answer accepted iff equal to the product; "Bestätigen" enabled only with exactly 2 digits and a third digit ignored (Section 16.2.2); a new question after every wrong answer; 3 wrong answers start a 30 s cooldown measured with FixedClock; cooldown persists across a simulated relaunch (UserDefaults suite injected, keys zk.gate.consecutiveWrong and zk.gate.cooldownUntil, Section 7.9.2); access validity ends on leaving the parent area and after > 5 min in background. "Gerade schwierig": at most 3 items, weakest first, German sentences built from string keys only
Games 25.6

25.5 Learning Engine Tests #

25.5.1 Test vectors (owned by Section 9) #

Section 9 defines the test vectors (inputs and expected outputs for mastery updates, Leitner box transitions, due dates, task-mix selection, difficulty stepping, range widening and narrowing, Abenteuer composition and parent overrides). Requirements:

  1. Every vector is implemented as a case of a parameterised Swift Testing test in ZKLearningEngineTests/EngineVectorTests.swift; the vectors are stored as data in EngineVectors.swift (a Swift array literal, copied verbatim from Section 9, one element per vector with the vector's ID).
  2. Numeric expectations compare with an absolute tolerance of 1e-6, the tolerance of Section 9.23 (expected values in the vectors are rounded to 6 decimals).
  3. The test display name includes the vector ID, so a failure names the vector.
  4. A vector may only change together with a change to Section 9's rules and a DECISIONS.md entry.
import Testing
@testable import ZKLearningEngine

@Suite("Engine vectors (Section 9)")
struct EngineVectorTests {
    @Test("Mastery update vectors", arguments: EngineVectors.masteryUpdates)
    func masteryUpdate(_ v: MasteryUpdateVector) {
        let result = MasteryMath.update(score: v.scoreBefore, outcome: v.outcome,
                                        skillWeight: v.weight, difficultyFactor: v.difficulty)
        #expect(abs(result - v.expectedScore) < 1e-6, "vector \(v.id)")
    }
    // One @Test per vector family defined in Section 9.
}

25.5.2 Invariant (property) tests #

Run for ZK_PROPERTY_SEEDS seeds (200 in Fast, 2,000 in Full), each seed driving a random sequence of 500 outcomes across random skills, numbers 1–20, difficulty steps and dates advancing 0–3 days between sessions:

ID Invariant
EI-01 score always within 0.0…1.0
EI-02 boxIndex always within 0…5; intervalDays always equals [0, 1, 2, 4, 7, 14][boxIndex]
EI-03 nextDueAt equals the start of the local day of lastPracticedAt plus intervalDays
EI-04 A record is reported as mastered only if score ≥ 0.80 and attempts ≥ 6
EI-05 firstTry never lowers the score; shown never raises it; afterHint never lowers it
EI-06 Determinism: the same seed, clock and state produce identical task selections
EI-07 Task selection only returns numbers inside the active range and skills active for the level (littleOnes: decompose only for numbers 2–5; vorschule: decompose only for numbers 2–10 in V1, Section 9.4; write for littleOnes only Nachspuren step 1)
EI-08 littleOnes never auto-widens to r20; only a parent override sets r20
EI-09 With a parent range override, automatic widening and narrowing never change the range
EI-10 Difficulty step never below step1 and never above the parent cap
EI-11 Abenteuer composition contains exactly 3 game rounds, only from Abenteuer-eligible games the profile is entitled to; a non-subscribed profile gets only the three Abenteuer-eligible free games Wie viele?, Hör hin and Was fehlt?; Entdecken never appears (Sections 8.8.1, 9.16.2)
EI-12 With the controller frozen at stretch share 0.10 and N = 5, over 10,000 simulated rounds where all pools are non-empty, the wanted-bucket draw frequencies (the first draw of each slot) match 70/20/10 within ±3 percentage points, and no stretch task occupies slot 0 or slot N−1 (Section 9.9.6)
EI-13 When a pool is empty, selection falls back as defined in Section 9 and never returns an empty round
EI-14 Entdecken free-explore produces no mastery updates; "Zähl mit" does
EI-15 A difficulty step whose minRange is above the active range is never planned (for example Punkt zu Punkt step 3 in r10, Section 9.9.3); the round's task count follows the step override where one exists (Section 9.9.2)
EI-16 An abandoned Abenteuer is resumed at its next unplayed round on the same local day and discarded on a new local day; its completion bonus is paid at most once per local day (Section 9.16.6)

25.5.3 Calibration simulation #

ZKLearningEngineTests/CalibrationSimulationTests.swift simulates synthetic children (a per-number probability of a first-try correct answer that rises with practice) for 30 simulated sessions, one per simulated day. The learner profiles (slow, typical, fast per level, with their starting probabilities and learning gains) are owned by Section 28.3 and copied into the test as data. The test asserts that, for the typical learner of each level, the engine keeps the observed first-try success rate within 70–85 % over sessions 3–30 (Section 9 target). The results for the slow and fast learners are printed, not asserted, and reviewed when engine parameters change.

25.6 Game Logic Tests #

For each of the 12 games, the game target's tests include:

  1. Generator validity — parameterised over every combination of step (step1, step2, step3) × level (littleOnes, vorschule) × range stage (r5, r10, r20) = 18 combinations, and for each combination ZK_PROPERTY_SEEDS seeds. For each generated task:
    • the target number(s) are within the active range and valid for the level (for example, decompose tasks for littleOnes only use 2–5);
    • the task's parameters match the effective step parameters of games/<gameId>.json (step parameters overlaid key by key by parametersByLevel.<level>, Section 8.8.3);
    • every audio ID and string key the task references exists in the content (checked against the loaded content, not hardcoded);
    • generation takes < 5 ms (measured with ContinuousClock, asserted in the Performance plan only; recorded in Fast).
  2. Distractor rules — as specified per game in Sections 11–13: distractors are distinct from each other and from the correct answer, lie within the active range (or the game's defined distractor range), respect the game's closeness rules (for example ±1/±2 at higher steps), and their count matches the step. Tests assert each rule separately so a failure names the rule.
  3. Evaluator correctness — every correct answer is accepted, every incorrect answer rejected; for games with multiple correct answers (for example Schüttelbox splits, Mehr oder weniger "gleich"), every correct answer is enumerated and accepted.
  4. No immediate repeats — where a game's section forbids repeating the same target in consecutive tasks, 1,000 consecutive generations contain no violation.
  5. Game-specific rules — each acceptance criterion in Sections 11–13 that describes logic (not visuals) has a unit test tagged with its AC ID (25.17).
  6. Framework contract — the game module conforms to the GameModule protocol of Section 10 and emits the required events for a scripted round (all first-try; all shown; mixed).
  7. Per-game state — games that keep state across rounds (for example the Punkt-zu-Punkt picture rotation) round-trip their state string through encode and decode, and the encoded string never exceeds 8 KB (gameStateJSON, Section 7.5.6).
import Testing
import ZKCore
import TestSupport
@testable import GameHoerHin

struct GeneratorCase: Sendable, CustomTestStringConvertible {
    let step: DifficultyStep; let level: Level; let range: RangeStage
    var testDescription: String { "\(step)-\(level)-\(range)" }
    static let all: [GeneratorCase] = DifficultyStep.allCases.flatMap { s in
        Level.allCases.flatMap { l in RangeStage.allCases.map { GeneratorCase(step: s, level: l, range: $0) } }
    }
}

@Test("Hör hin generates valid tasks", arguments: GeneratorCase.all)
func generatesValidTasks(_ c: GeneratorCase) throws {
    let seeds = TestEnvironment.propertySeedCount   // 200 or 2,000
    for seed in 0..<UInt64(seeds) {
        var rng = SplitMix64(seed: seed)
        let task = try HoerHinTaskGenerator.makeTask(for: .fixture(c), using: &rng)
        try HoerHinTaskChecks.assertValid(task, for: c)   // test-target helper; throws a descriptive error naming the rule
    }
}

The generator follows the <GameName>TaskGenerator naming of Section 6.2.1; HoerHinTaskChecks is a helper inside the test target. Other type names follow Sections 6 and 10.

25.7 Content Validation Tests #

ZKContentTests validates the real content that ships. The test target copies Resources/Content/, Resources/Audio/ (file list only, plus decoding in 25.13) and the String Catalogs as test resources via a build phase script, so the tests see exactly the shipping files.

ID Check Severity
CV-01 Every JSON file under Resources/Content/ decodes with its schema (Section 8) and has schemaVersion: 1 Fail
CV-02 100 % of content files are covered: the test enumerates the directory and fails on any file without a known schema Fail
CV-03 Every audio ID referenced anywhere in content resolves to Resources/Audio/de/<audioId>.m4a or Resources/Audio/common/<audioId>.m4a for SFX/music; num.0 is declared only through the optional zero entry of numbers.json (Section 8.6) Fail for number words (num.*) and for any line in a Release build; Warning (TTS fallback) otherwise — Section 20
CV-04 Every string key referenced in content exists in Content.xcstrings with a non-empty de value Fail
CV-05 Every Localizable.xcstrings key used in code has a de value (Xcode's "missing" state count is zero) Fail
CV-06 No string value contains the literal app name; texts interpolate Brand.appName (Section 20) Fail
CV-07 Orphans: audio files and string keys not referenced by content or code Warning (listed in the report)
CV-08 games/<gameId>.json exists for all 12 GameID values, uses the envelope of Section 8.8 (promptIds, hintIds.level1/level2, labelKey values game.step.leicht, game.step.mittel, game.step.schwer, parameters + parametersByLevel, optional gameParameters) and defines step1, step2 and step3; Entdecken has abenteuerEligible: false; no game is hidden by validation (allGamesAvailable, Section 8.18) Fail
CV-09 decorations.json: 60 items; 24 small at 10, 20 medium at 25, 12 large at 50, 4 special at 100 stars (Section 14) Fail
CV-10 stickers.json: 52 stickers (24 picture, 20 friend, 8 milestone); IDs unique Fail
CV-11 friends.json: 20 friends, one per number 1–20 Fail
CV-12 dotpictures/: the 24 pictures of Section 8.5 (8 per step) with the IDs used by the sticker album (Section 14.7.2); each picture's points numbered consecutively from 1 with no gaps and the count allowed for its step (Section 13.4); minimum dot distance 0.17 (Section 8.12, C082); sticker IDs sticker.dot.<pictureId>; reveal line label.dot.<pictureId> exists Fail
CV-13 avatars.json: 12 avatars with animal-slug IDs (Section 15.3.1); the 6 colour themes of Section 15.3.2 (theme.sonne … theme.himmel) defined Fail
CV-14 All IDs unique within their file; all cross-file references resolve Fail
CV-15 Image assets referenced by content exist in the asset catalog under flat names matching ^[a-z][a-z0-9_]*$ (scheme <category>_<slug>[_<state>], Section 6.4.3; asset-catalog namespaces off); image pixel sizes within 1.25× of their largest display size (Section 24.4.3) Fail / Warning respectively
CV-16 Every audio ID referenced by code (framework, session, reward and time-limit lines and SFX named in Sections 10, 14, 15, 17 and 18) is listed in the code-referenced ID table CodeReferencedVoiceLines.all and exists in the Section 21 inventory and in Resources/Audio/ Fail
CV-17 No child-facing voice line text (German text in Content.xcstrings for prompt.*, hint.*, fb.*, session.*, friend.*, label.*) contains the app name, a price or money word, or a word on the forbidden list of Section 21.1.4 (for example "schnell", "freischalten") Fail

The same validator is exposed in the Debug developer menu (Section 5.10) as a content validation report.

25.8 Persistence and Data Integrity Tests #

All with an in-memory ModelContainer built from the production schema (Section 7) plus a few on-disk tests in the app-hosted target where file behaviour matters.

Test Asserts
SchemaIsCloudKitCompatible Every @Model property is optional or has a default; no @Attribute(.unique); every relationship optional; delete rules are cascade or nullify only (Section 7). Implemented by reflecting over the schema's entity descriptions.
SaveGroupTaskOutcome After the task-completed unit of work (Section 7.12.2; SP-1 in Section 24.9.2) mastery, attempt, taskSolved ledger entry, game progress including gameStateJSON, learning state and cached balance are all present; simulate a failure injected before save() → none are present after rollback()
SaveGroupPurchase Ledger debit and OwnedDecoration exist together or not at all
CachedBalanceMatchesLedger After random sequences of earn/spend operations (property test), cached balance equals the ledger sum
CrashMidRoundLosesOnlyCurrentTask Drive 3 of 5 tasks, discard the context without saving the 4th (simulated kill), reopen → 3 tasks' data present, no round bonus
DeleteChildRemovesAllRecords Deleting a ChildProfile leaves zero records of every child-related entity for that profile and leaves other profiles untouched
DeleteAllLeavesEmptyStore "Alle Daten löschen" leaves zero records in every entity, deletes any recovery folder and leftover export files, and resets exactly the UserDefaults keys that Section 7.9.2 marks as reset (the entitlement cache, the installation ID and the trusted-clock keys stay)
ResetProgressScope "Fortschritt zurücksetzen" clears exactly the scope defined in Section 14.11 (mastery, game steps, range) and keeps stars, garden, stickers and friends
ResetAllScope Per-child "Alles zurücksetzen" removes everything of that child listed in Section 7.18.2 and keeps the profile identity, its settings and today's DailyUsage row, so today's used time is unchanged (a reset never grants extra play time)
DeleteLastProfileImpossible With one profile, "Kind löschen" is unavailable; the only path to zero profiles is "Alle Daten löschen", after which launch routing shows S-02
DeleteChildRemovesRecoveryFolder "Kind löschen" also deletes an existing store recovery folder, because the copy contains that child's data (Section 7.18.3)
ActivityFlushInterval With an injected clock, pending active seconds are written to DailyUsage and SessionRecord every 15 s of active child-mode time and on backgrounding (Section 15.7.2; SP-7 in Section 24.9.2)
PruningJob TaskAttempt entries older than 90 days are deleted, newer ones kept; aggregates unchanged; the job runs at most once per local day (Section 7)
ExportRoundTrip Export JSON (format of Section 7.17) uses the file name of Section 7.17.1, decodes into the documented structure, contains every profile and every entity listed in Section 23.7.1, contains no data not in the inventory, and the temporary file is deleted after completion
SaveFailureRollsBackUnitOfWork A save failing with a simulated out-of-space error, and one failing with another error, both roll back the whole unit of work; the next unit of work saves normally; repeated failures raise the parent-area notice (Section 7.12.2, Section 24.9.3)
CorruptStoreRecovery (app-hosted, on disk) A store file overwritten with garbage: container creation is retried once, then the files are moved to ZahlenketteData/Recovery/<yyyyMMdd-HHmmss>/ (at most one folder), a fresh store is created and the first-launch flow starts, without crashing (Section 7.12.3)
StoreFallbackInMemory (app-hosted) If the fresh store also cannot be created (simulated), an in-memory store is used, the child can play a round and the parent-area notice is shown (Section 7.12.3)
HeavyDatasetFetchBudgets With the heavy-3y seed, dashboard and progress queries return within budget on the simulator as an early warning (the binding measurement is on device, 25.11)

25.9 StoreKit and Entitlement Tests #

25.9.1 Unit tests with a fake transaction source (ZKStoreTests) #

The entitlement logic in ZKStore reads transactions through the abstraction defined in Section 17. FakeTransactionSource (TestSupport) emits scripted verified and unverified transactions with chosen product IDs, expiration dates, ownershipType (.purchased, .familyShared), revocation dates and upgrade flags. Required cases — together they cover 100 % of the state transitions of Section 17's entitlement state machine:

Case Expected
No transactions Not entitled
Verified monthly, not expired Entitled
Verified yearly, not expired Entitled
Unverified transaction Not entitled (and logged)
Expired Not entitled; lapse behaviour of Section 17 (premium games re-lock; nothing else changes)
Revoked (refund) Not entitled immediately
In billing grace period Entitled until grace ends
Billing retry without grace Per Section 17
Family-shared (ownershipType == .familyShared) Entitled
Upgrade monthly → yearly Entitled without gap; yearly shown as the active plan
Auto-renew off, before expiry Entitled; parent area shows the end date
Offline launch with cached entitlement Behaviour per Section 17's cache rules, evaluated with FixedClock before, at and after the cached expiration plus the offline grace defined there
Clock set backwards (Section 23.15 T5) Behaviour per Section 17
Transaction.updates delivering a change while the app runs State updates without relaunch; locked/unlocked games update on the game picker
Intro offer eligibility false Paywall view model uses the non-trial wording (Section 17)
isUnlocked(_:) per tier .free always true; .premium true exactly while entitled (Section 17.5.4)

25.9.2 Integration tests with SKTestSession (ZahlenketteTests) #

  • StoreKit configuration file Products.storekit (Section 5) defines the subscription group "Zahlenkette Premium" with <bundleID>.premium.monthly (3,99 €) and <bundleID>.premium.yearly (29,99 €), each with a 7-day free-trial introductory offer and Family Sharing enabled.
  • Suites that use SKTestSession are marked @Suite(.serialized) because the session is process-global.
  • Each test starts with resetToDefaultState(), clearTransactions() and disableDialogs = true.
Test Steps
ListenerRegisteredAtLaunch A transaction delivered while the app is starting (before the first screen) is processed: the Transaction.updates listener is registered in the App initializer (Section 17.6.1)
PurchaseYearlyGrantsEntitlement Load products; purchase yearly and pass the purchase result into EntitlementService exactly as ZKParentArea does after its SwiftUI PurchaseAction (Section 17.5.4; ZKStore imports no UIKit); assert entitled, trial active, expiration set
PurchaseMonthlyGrantsEntitlement Same for monthly
RenewalKeepsEntitlement Purchase; force a renewal (forceRenewalOfSubscription(productIdentifier:)); assert still entitled with a later expiration
ExpirationRevokesAccess Purchase; expireSubscription(productIdentifier:); assert not entitled after the updates listener fires
RefundRevokesAccess Purchase; refundTransaction(identifier:); assert not entitled
AutoRenewOffStillEntitled Purchase; disableAutoRenewForTransaction(identifier:); assert entitled until expiration
AskToBuyPending askToBuyEnabled = true; purchase → pending state shown in the paywall view model; approve → entitled
InterruptedPurchase interruptedPurchasesEnabled = true; purchase → interrupted; resolve → entitled
FailedPurchase failTransactionsEnabled = true; purchase → failure state with a retry, not entitled
IntroOfferEligibility Eligible before the first purchase; not eligible after purchase and expiration
UpgradeMonthlyToYearly Purchase monthly then yearly; assert a single active entitlement
BillingGraceAndRetry Using the session's billing-retry and grace-period options; assert Section 17 behaviour

Family-shared transactions: Decision: covered by the fake-source unit tests (25.9.1) and by a manual TestFlight check with two members of one Family Sharing group (25.14.3). Verification step: the executor checks the current StoreKitTest API for family-sharing, billing-retry and grace-period simulation; any case not supported by SKTestSession in the current Xcode stays covered by the fake-source unit tests and the manual sandbox check, and this is recorded in DECISIONS.md. The method and property names above are those of the StoreKitTest framework at the time of writing and must be verified against the current documentation.

25.9.3 Manual sandbox and TestFlight checks (per release) #

Purchase yearly and monthly with a Sandbox Apple Account; restore on a second device; manage subscription sheet opens; refund request sheet opens; offer code redemption sheet opens; lapse after accelerated sandbox renewal period ends (premium games re-lock, stars/friends/garden/stickers/progress intact); airplane-mode relaunch with an active subscription (Section 17 cache rules).

25.10 UI Tests (XCUITest) #

25.10.1 Critical journeys #

One test class per journey of Section 3, named <JourneyName>UITests (Section 6.2.3) and identified by the journey ID of Section 3.2. Each runs on iPhone SE (3rd generation) portrait and on an iPad simulator in landscape in the Full plan. Journeys J-10 (device offline) and J-11 (app interrupted) involve system state that XCUITest cannot drive reliably; they are covered by the manual passes of 25.14.2.

ID Journey (Section 3) Seed Steps and assertions
J-01 First launch (FirstLaunchUITests) fresh S-02 appears and plays no audio (audio stub log empty) → tap "Profil einrichten" → gate S-17 solved (25.10.4) → S-03 single form (avatar, level, optional nickname skipped, "Fertig"; no "Weiteres Kind anlegen" and no premium link) → hand-off → child home S-05 appears → greeting audio event logged
J-02 Daily session (DailySessionUITests) single Child home → game picker → Hör hin → complete one round with scripted correct taps (answers revealed by -uiTestRevealAnswers) → round-end celebration S-08 → star counter increased by 7 (vorschule, 5 tasks). A second run with seed single-little expects 6 (littleOnes, 4 tasks). No break-nudge screen appears inside the round
J-03 Daily Abenteuer (AbenteuerUITests) single Start Abenteuer → S-09 does not start by itself; tap the play button → 3 rounds → soft end S-10 → +5 stars → S-10 returns to the child home by itself 15 s after its last line → the Abenteuer button now leads to the garden with the session.abenteuer_done line event. Variant: leave after round 1 and tap the Abenteuer again the same day → it resumes at round 2
J-04 Parent check-in (ParentCheckInUITests) typical-1y-single Press and hold the parent entry for 2 s → gate → dashboard S-18 shows friends, stars, time this week, "Gerade schwierig" → progress detail S-19 → tap a cell → detail shows attempts and band
J-05 Free user taps a locked game (LockedGameUITests) single Tap a locked game → S-15 "Frag deine Eltern" (assert no price text, no buy button, no app name; audio event session.locked_game) → tap a second locked game in the same session → lock animation and highlighted open tiles, no second session.locked_game event → parent icon → gate → paywall S-22
J-06 Parent subscribes (SubscribeUITests) single, StoreKit configuration active in the scheme Paywall S-22 shows both plans with StoreKit prices, yearly preselected, disclosure text, restore button, privacy and EULA links. The purchase completion itself is covered by 25.9.2 and 25.9.3 (system purchase sheets are not driven by UI tests)
J-07 Subscription lapses (LapseUITests) single with -uiTestEntitlement expired Premium games show lock badges; stars, friends, stickers and garden unchanged versus the seed
J-08 Time limit reached (TimeLimitReachedUITests) time-limit-almost (19:50 of 20 minutes used today) Play one task → the current task always completes → S-16 "Zeit zum Ausruhen" with no music → child mode stays locked after relaunch → parent grants +10 minutes via the gate → child home available
J-09 Sibling switching and adding a profile (ProfileSwitchUITests) five-profiles S-04 shows five avatar tiles and the grown-up icon → pick a profile → home → switch back → parent area → "Weiteres Profil" is unavailable at five profiles; avatars used by other profiles are dimmed and not selectable in the profile editor

Additional UI tests: ParentLinksAreGated (every link on S-24 requires the gate and shows the leave-app confirmation "Sie verlassen jetzt die App"), LockedGameShowsAskParent, ChildModeHasNoSettings (no S20.* or S21.* identifier exists while child mode is shown), PaywallShowsDisclosure (Section 23.3), ProfilePickerWithFiveProfiles, SixthProfileBlocked, GateCooldownAfterThreeWrongAnswers (25.10.4), RotationKeepsState (rotate during a task on iPhone SE; the task and its state are unchanged), NoDeadEnds (from every child screen a home control exists, Section 18).

25.10.2 Accessibility identifiers #

The identifier format <screen>.<element>[.<qualifier>] is owned by Section 6.10, and all identifiers are constants in ZKCore/Accessibility/A11yID.swift, which the UI-test target uses too. The UI tests rely on at least these identifiers:

Identifier Element
S02.setup "Profil einrichten" button on the first-launch screen
S03.avatarTile.<avatarID>, S03.level.<level>, S03.nickname, S03.done First-profile form (Section 15.4.1)
S04.avatarTile.<index>, S04.parentEntry Profile picker tiles (creation order) and grown-up icon
S05.root Child home container
S05.parentEntry Grown-up icon (press and hold 2 s)
S05.play, S05.abenteuer, S05.garden, S05.friends, S05.album, S05.profileSwitch Child home destinations
S06.gameTile.<gameId> Game tile per GameID raw value
S06.gameTile.<gameId>.lockBadge Lock badge on a premium tile
S07.root, S07.home, S07.speaker, S07.progressDot.<i>, S07.pause.resume, S07.answer.<value>, S07.target.<id>, S07.check Generic game container elements (Section 10)
S07.<gameId>.<element>.<qualifier> Game-specific elements, for example S07.hoer_hin.answer.12, S07.entdecken.fieldCell.7
S08.root, S09.play, S10.root, S15.root, S15.parentIcon, S16.root, S16.parentEntry Round end, Abenteuer intro play button, Abenteuer soft end, locked game, time's up
S11.decorationSlot.<column>_<row> Garden grid slot
S17.question, S17.keypadKey.<0-9>, S17.delete, S17.confirm, S17.cancel, S17.cooldown Parental gate
S18.root, S18.childCard.<index>, S18.done Parent dashboard
S19.root, S19.cell.<number>.<skill> Progress grid
S20.*, S21.* Every control of the child settings and device settings screens (used by ChildModeHasNoSettings)
S22.plan.yearly, S22.plan.monthly, S22.subscribe, S22.restore, S22.disclosure Paywall
S23.export, S23.resetProgress, S23.resetAll, S23.deleteChild, S23.deleteAll Data screen
S24.link.privacy, S24.link.imprint, S24.link.support, S24.link.eula, S24.leaveApp.confirm, S24.leaveApp.cancel Help and legal screen and its leave-app sheet
debug.audioLog Audio stub log element, present only under the compilation condition DEBUG or UITEST_HOOKS (25.10.3)

25.10.3 Launch arguments and seeds #

Section 5.9 is the single registry of launch arguments; there is no other argument family. The arguments are parsed only when the build is compiled with DEBUG || UITEST_HOOKS (Debug and Profile configurations; the Release configuration contains no parsing code, Section 23.9). No argument bypasses the parental gate. The UI tests use these registry entries:

Argument Effect
-uiTestSeed <name> Replaces the store with a freshly generated seeded store before the container opens (seed names below)
-uiTestFixedNow <ISO-8601> AppClock returns this time (advancing normally from it)
-uiTestDisableAnimations Sets animation durations to zero (UIView and SwiftUI transactions)
-uiTestTimings instant Sets the framework's waiting times (demo pauses, feedback holds, auto-advance delays; Section 10.5.2) to zero; spoken-line completion is still awaited through the audio stub
-uiTestForcePlan <gameId>:<step>:<n1,…> The next round of that game uses the given step and target numbers instead of the engine's plan
-uiTestForceReduceMotion The app's Reduce Motion provider reports true
-uiTestAudio stub | fail stub: the audio service records requested audio IDs (exposed to tests through the accessibility element debug.audioLog) and plays nothing. fail: every playback fails (Section 24.14 test)
-uiTestEntitlement notSubscribed | subscribed | expired Replaces the entitlement service with a fixed-state fake
-uiTestRevealAnswers Exposes the correct answer of the current task through the accessibility value of S07.root so journeys can play rounds deterministically

Seeds (generated in code by DebugSeeder in the app target, compiled only under DEBUG || UITEST_HOOKS and therefore available to the Profile performance runs, deterministic with a fixed seed):

Seed Content
fresh No store and no app UserDefaults keys other than the installation ID; zero profiles, so launch routing shows S-02
single 1 profile (vorschule, r10), no progress, 0 stars
single-little 1 profile (littleOnes, r5)
five-profiles 5 profiles with different avatars and levels
typical-1y-single / typical-1y 1 or 2 profiles with 1 year of play at 20 min/day (Section 24.1)
heavy-3y Section 24.1 heavy dataset
time-limit-almost 1 profile, limit 20 min, 19:50 used today
friend-almost 1 profile where number 3 is one first-try outcome away from befriending
rich-garden 500 stars, 10 decorations owned, 5 placed

25.10.4 Solving the parental gate in UI tests #

UI tests do not bypass the gate. GateSolver (in the UI test target) reads the label of S17.question, maps the German number words ("sechs" 6, "sieben" 7, "acht" 8, "neun" 9) around the word "mal", computes the two-digit product and taps S17.keypadKey.<digit> twice, then S17.confirm ("Bestätigen"). A dedicated test GateCooldownAfterThreeWrongAnswers enters three wrong answers and asserts S17.cooldown appears.

25.11 Performance Tests #

Test Budget (Section 24.2) Metric
testColdLaunchToChildHome_singleProfile PB-01 XCTApplicationLaunchMetric(waitUntilResponsive: true) + XCTOSSignpostMetric LaunchToChildHome
testColdLaunchToProfilePicker_fiveProfilesHeavy PB-02 Same with heavy-3y
testTapToAudio PB-04 XCTOSSignpostMetric TapToAudio over 20 taps on Entdecken
testGameOpen for each of the 12 games PB-07 XCTOSSignpostMetric GameOpen
testTaskGeneration PB-08 XCTClockMetric over 1,000 generations per game (unit-level XCTest in the Performance plan)
testSavePointHeavy PB-09 Signpost SavePoint
testParentDashboardHeavy, testProgressGridHeavy PB-10, PB-11 Signposts
testExportHeavy PB-12 Signpost Export
testMemoryDuringSchuettelboxAndNachspuren PB-13 XCTMemoryMetric

Baselines are recorded per physical device. A measurement worse than the budget fails; a regression of more than 10 % against the stored baseline while still within budget is reported as a P2 defect (25.19). PB-05, PB-06, PB-14, PB-15, PB-16 and PB-18 are measured manually per Section 24.3 and recorded in the release checklist.

25.12 Accessibility Tests #

Area Automated Manual
Parent area VoiceOver performAccessibilityAudit() (XCUITest, available on the deployment target, Section 5) on S-17 to S-25; all parent controls have labels, traits and values Complete J-04 and "set time limit" with VoiceOver on a device, eyes closed for the navigation part; charts' accessibility descriptions read correctly
Child area labels Audit of the child screens S-04 to S-16, S-26 and S-27 for missing labels (child area is labelled for adult assistive use, Section 19) Spot check with VoiceOver
Dynamic Type UI tests launch with -UIPreferredContentSizeCategoryName UICTContentSizeCategoryAccessibilityXXXL and assert that parent area key elements exist and are hittable; screenshots attached Visual review of all parent screens at XXXL on iPhone SE (no truncation of essential information); child area confirmed fixed-size per Section 19
Reduce Motion UI tests with -uiTestForceReduceMotion: celebrations and transitions complete; no SpriteKit shake animation required to finish Schüttelbox (button fallback) System setting on a device: every celebration uses the fade variant
Colour blindness — Screens with beads, Zwanzigerfeld, dice and progress grid reviewed with iOS Color Filters (Settings → Accessibility → Display & Text Size → Color Filters: protanopia, deuteranopia, tritanopia, and grayscale): red/blue groups remain distinguishable by the Lochperle ring and position; progress bands distinguishable by symbol/label (Section 19)
Touch targets Unit test in ZKDesignSystem asserts the minimum frame constants (child ≥ 60 × 60 pt, parent ≥ 44 × 44 pt). UI test testChildTouchTargetsAtLeast60pt (specified in Section 19.6) iterates every hittable element whose identifier belongs to a child screen (S04. to S16., S26., S27.) on iPhone SE in compact portrait and fails below 60 × 60 pt, with no exemption: interactive Zwanzigerfeld and bead-chain cells use the compact arrangement (rows of five) in windows narrower than 714 pt (field) or 820 pt (chain) (Sections 4.4 and 19.7.3). A second assertion checks ≥ 12 pt between child targets, excepting only the contiguous garden slots S11.decorationSlot.* Spot check of the compact arrangement on iPhone SE portrait and in narrow iPad windows
Numeral size Unit test on the typography tokens: child.numeral is ≥ 44 pt on iPhone and ≥ 64 pt on iPad (Section 19.3.2). Unit tests in the owning targets: every view that renders a numeral as task content (NumeralTile, the Froschsprung pad label, the Punkt-zu-Punkt dot label and any stimulus or answer numeral) takes its font from the child.numeral token, never from child.numeralSmall or child.numeralBadge Visual review of Froschsprung and Punkt zu Punkt at step 3 on iPhone SE
Sound off UI test with voice, music and SFX toggled off completes J-02 using only visual cues (with -uiTestRevealAnswers), including a Hör hin round played with the quantity-card prompt that replaces the spoken number (Section 11.4.6) Manual play of each game with the device muted and all channels off

25.13 Audio QA #

25.13.1 Automated #

Check Where Rule
Every referenced audio ID has a file CV-03 (25.7) Section 20 fail/warn rules
Every file decodes AudioAssetTests (app-hosted) opens every file with AVAudioFile Decodes without error
Format Same Voice: 44.1 kHz, mono, AAC in m4a (Section 20); SFX and music per Section 20
Duration Same Voice line 0.2–8.0 s; SFX ≤ 3.0 s; music ≤ 180 s
Loudness and peaks scripts/audio-qa.swift (run manually on every audio delivery and in the Release workflow) Voice integrated loudness −16 LUFS ± 1 LU, true peak ≤ −1 dBTP, head and tail silence ≈ 30 ms (±20 ms), all per Section 20

scripts/audio-qa.swift is a single-file Swift script using AVFoundation and Accelerate (no third-party tools):

  • implements ITU-R BS.1770-4 integrated loudness: K-weighting (two biquad stages whose coefficients are derived for the file's actual sample rate from the standard's filter prototype, not copied from the 48 kHz table), 400 ms blocks with 75 % overlap, absolute gate −70 LUFS, relative gate −10 LU;
  • estimates true peak by 4× oversampling;
  • measures leading and trailing silence below −50 dBFS;
  • writes build/audio-qa-report.csv (audio ID, duration, LUFS, true peak, head ms, tail ms, pass/fail) and exits non-zero on any failure.

Self-test built into the script (--self-test): a generated mono 997 Hz sine at peak amplitude A must measure −3.01 + 20·log10(A) LUFS within ±0.1 LU (BS.1770 reference behaviour for a single channel), for A = 1.0, 0.5 and 0.1, at 44.1 kHz and 48 kHz.

25.13.2 Manual listening review #

For every recording delivery (Section 20 pickup process): listen to every new line in its in-app context on a device with the built-in speaker and with headphones. Checklist per line: correct text versus Section 21; pronunciation (Hochdeutsch, neutral for DE/AT/CH); statement vs question intonation for num.<n> / num.<n>.q; no clicks, breaths or mouth noise; consistent voice and room tone with the rest of the set; carrier sentence concatenation sounds natural at the gap timing of Section 20. Results go into QA/audio-review.csv (audio ID, reviewer date, pass/fail, note). A failed line is re-recorded in the next pickup session; until then the previous take or TTS fallback stays (never for number words).

25.14 Device Matrix and Manual Device QA #

25.14.1 Matrix #

Device OS Role
iPhone SE (2nd generation) Kept on the deployment-target major line (Section 5), never updated Performance reference (Section 24.1); smallest screen; lowest OS
iPhone SE (3rd generation) Current iOS Smallest screen on current OS; CI simulator model
Mid-size iPhone (6.1-inch, any model from the last three years) Current iOS Typical parent phone
iPhone Pro Max (6.9-inch) Current iOS Largest phone, ProMotion, Dynamic Island insets
iPad (10th generation or later) One unit on the deployment-target major line if obtainable, otherwise current iPad reference; Apple Pencil (USB-C)
iPad mini (6th generation or later) Current iPadOS Small iPad, Pencil, Schüttelbox handling
iPad Pro 13-inch Current iPadOS Largest canvas, Pencil Pro, ProMotion, memory and size budgets

Decision: if an iPad on the deployment-target major line cannot be obtained, iPad functional checks for that OS line run on the matching simulator runtime and this is recorded in the release checklist. The iPhone SE (2nd generation) on the deployment-target major line is mandatory.

25.14.2 Manual passes per release #

Pass Devices Content
Full pass iPhone SE (2nd gen), iPad (10th gen) All 12 games × 3 steps × both levels at least one round each; garden, friends, album, Abenteuer; complete parent area; both orientations for each screen; rotation during a task
Smoke pass All other matrix devices First launch, one round of each game, garden, parent area, paywall display, both orientations
Pencil iPad mini, iPad Pro 13-inch, iPad (10th gen) Nachspuren with finger and with Pencil; pencil-only mode (Section 16)
Motion iPhone SE (2nd gen), iPad mini Schüttelbox shake detection and button fallback; motion off setting
Offline (journey J-10) iPhone SE (3rd gen) Airplane mode before first launch (Section 24.8.3); airplane mode with active subscription
Network traffic (success criterion SC13) iPhone SE (3rd gen) connected to the Mac Create a remote virtual interface with rvictl -s <device UDID> and capture with tcpdump -i rvi0 -w build/traffic.pcap (macOS built-in tools) during a full play-through of all 12 games, the garden, the Abenteuer and a complete parent-area walk-through including the paywall; stop with rvictl -x. Pass condition: every connection goes to an Apple host used by the system for StoreKit or the App Store; none is attributable to app code (Section 23.10.4). The capture file stays on the developer's Mac and is deleted after the result is recorded
Interruptions (journey J-11) iPhone SE (2nd gen) Incoming call, Control Center, notification banner from another app, lock/unlock, headphones unplugged, low-battery alert. Expected: interruptions shorter than 3 s resume automatically (Section 10.12.3), longer ones show the pause overlay; headphones unplugged stops the voice line without pausing the round (Section 20.12); the device auto-locks only outside an S-07 task (Section 15.11)
Offload iPhone SE (3rd gen) Offload app, reinstall, data intact (Section 24.11)
Split View / Stage Manager iPad Pro 13-inch Narrowest window ≥ 375 pt works (Section 5)

25.14.3 Family Sharing check #

Once before V1 launch and after any StoreKit change: subscribe with the organiser's Apple Account in a TestFlight build; a second family member's device (different Apple Account in the same Family Sharing group) gets Premium after restore/launch; revoke by leaving the family → premium re-locks.

25.15 Child Usability Testing Protocol #

25.15.1 Goal and success criteria #

Goal: verify the product principle that a child can play without reading and without adult help (Section 2).

Criterion Threshold
Primary (SC1, Section 2.5.3) At least 4 of 5 four-year-old children complete their first Abenteuer (task C1) without adult help
Three-year-olds (SC2) At least 3 of 5 three-year-old children complete one round of Entdecken "Zähl mit" or Hör hin at step 1 (task C7) without adult help
Game comprehension (SC3) For every one of the 12 games, at least 4 of the 5 children who play it in task C8 give a correct first answer in the first task of their first round, or a correct answer after the first hint, without adult help
Secondary 1 At least 4 of 5 children aged 5–6 complete C1 without adult help
Secondary 2 No child shows distress (crying, repeated "I can't", refusing to continue) caused by an error, the lock screen S-15, or the soft end
Secondary 3 Median number of stuck events (no meaningful interaction for > 10 s) ≤ 2 per child per session
Safety Zero incidents of a device being dropped or thrown during Schüttelbox

"Without adult help" means no verbal hint about what to do and no physical intervention. Neutral encouragement ("Probier ruhig", "Mach weiter") is allowed and recorded.

25.15.2 Participants and recruitment #

  • 15 children from at least 8 families, which also satisfies the recruiting decision of Section 1.2 (at least 10 children): Group A — 5 four-year-olds (4;0–4;11), because the primary criterion is about four-year-olds; Group B — 5 children aged 5–6; Group C — 5 three-year-olds (3;0–3;11) for success criterion SC2.
  • Recruited from families in the developer's network who join the TestFlight test group (their parents' Apple Accounts). Exclusions: none by ability; children with prior exposure to this app's prototype are allowed but noted.
  • Timing: the release-candidate child test CT3 (Section 27.4), after real audio and illustrations are integrated and before App Store submission; a re-test round follows fixes (25.15.7). The earlier checkpoints CT1 and CT2 of Section 27.4 use the same consent, observation and data rules with their smaller samples.
  • Groups A and C use level "Die Kleinen", Group B "Vorschule".
  • A paper consent form signed by a parent or guardian before the session. Content (German, plain language): purpose of the test; what the child will do (play for up to 20 minutes); that the parent stays in the room the whole time; that no photos, videos or audio recordings are made; that notes are taken on paper using a code instead of the child's name; that the child can stop at any time without reason; that consent can be withdrawn and notes destroyed on request; retention periods below; contact details of the developer.
  • Child assent: the observer asks in simple words whether the child wants to try a new game; the session stops immediately if the child does not want to continue or seems unhappy.
  • No data is collected digitally about the child:
    • sessions run on developer-owned test devices with the TestFlight build; no nickname is entered (avatar only); after each child the observer uses "Alle Daten löschen";
    • TestFlight screenshot feedback is not used during sessions;
    • observation sheets are paper, identified only by code (A1–A5 for Group A, B1–B5 for Group B, K1–K5 for Group C) and age in whole years;
    • the anonymised summary (codes, ages in years, counts, issue descriptions without names) is the only digital artefact, stored as QA/usability-child-<yyyy-mm>.md;
    • paper observation sheets are destroyed within 3 months after the summary is written; consent forms are kept separately in a locked place and destroyed 12 months after the test.

25.15.4 Setup #

  • Quiet room at the family's home, child seated at a table (iPad) or on a sofa (iPhone), parent present but asked to sit slightly behind the child and not to help.
  • Devices: iPad (10th generation) without subscription (for C1–C4 and C7, with the Abenteuer drawn from the Abenteuer-eligible free games) and iPhone SE (2nd generation) with a sandbox subscription (for C5, C6 and C8, because C8 includes premium games).
  • Before the child arrives the observer creates the profile with the parent (the parent solves the gate) and sets the volume to 60 %. No stars are pre-seeded (TestFlight builds have no seeding hooks). Decision: C2 uses the stars earned in C1, which are always enough for a small decoration (10 stars): an Abenteuer yields 3 rounds of 6 or 7 stars plus the 5-star bonus, at least 23 stars.

25.15.5 Tasks #

The observer says only the opening sentence per task and then stays silent.

Task Opening sentence (German) Success
C1 "Hier ist ein neues Spiel. Du kannst einfach losspielen." (device on child home) Child starts the Abenteuer and reaches the soft end S-10 without help
C2 "Schau mal, was du mit deinen Sternen machen kannst." Child opens the garden and places one decoration
C3 "Such dir ein Spiel aus." Child opens any game and completes a round
C4 (no sentence; observer points at a locked tile only if the child has not tapped one by itself during C3) Observe reaction to S-15; success = no distress, child returns home without help
C5 (Group B only) "Hier ist die Schüttelbox." Child completes a Schüttelbox task by shaking or by the button; observer notes grip and safety
C6 (Group B only) "Hier kannst du Zahlen nachspuren." Child traces one numeral
C7 (Group C only; replaces C1–C6 for this group) "Hier ist ein Spiel für dich." (the observer has opened Entdecken in "Zähl mit" mode for K1, K3, K5 and Hör hin for K2, K4; step 1) Child completes one round without help (success criterion SC2)
C8 (all groups, separate sittings) "Hier ist noch ein Spiel." (the observer has opened the assigned game at step 1; Entdecken in "Zähl mit" mode) Correct first answer in the first task, or a correct answer after the first hint, without help (success criterion SC3)

Session length: Group A ≤ 20 minutes, Group B ≤ 25 minutes, Group C ≤ 10 minutes, breaks on request.

C8 rotation ("one game per child per sitting", Section 2.5.3): each C8 sitting lasts at most 5 minutes and covers exactly one game the child has never played; a child has at most two C8 sittings per day, at least 1 hour apart, starting the day after the main session. Children are numbered k = 0 … 14 in the order A1–A5, B1–B5, K1–K5, and the 12 games are numbered 0 … 11 in GameID order (entdecken, wie_viele, hoer_hin, was_fehlt, blitzblick, mehr_weniger, nachspuren, schuettelbox, froschsprung, zahlenmonster, memory, punkt_zu_punkt). Child k plays the games (4k), (4k + 1), (4k + 2) and (4k + 3), each taken modulo 12. This gives 60 observations in which every game is played by exactly 5 children. A child who stops or is unavailable is replaced for the missing games by a new child of the same group.

25.15.6 Observation sheet (paper) #

Header: child code, age in years, group, device, date, observer. Body rows: time (mm:ss), screen ID (S-xx), event code, short note.

Code Meaning
H Child asked an adult for help
I Adult intervened (verbal hint or touch)
N Neutral encouragement given
S Stuck > 10 s
M Mis-tap (tap on a non-interactive area or wrong control, not a wrong answer)
D Drag failed (item dropped outside target unintentionally)
A Spoken instruction ignored or misunderstood (child did something unrelated)
E+ Visible joy
E− Frustration
Q Child wanted to stop
X Safety event (device slipped, dropped, thrown)

Summary box per task: completed yes/no, number of H and I events, time on task.

25.15.7 Analysis and follow-up #

  • The observer transcribes the sheets into the anonymised summary within 48 hours.
  • Every issue observed with ≥ 2 children is a P1 defect (25.19) and must be fixed before submission.
  • Every X event triggers a review of the Schüttelbox shake threshold and instruction (Section 12) and the risk entry in Section 28.
  • If the primary criterion fails, fixes are made and a re-test runs with 3 new four-year-olds; at least 3 of 3 must complete C1 without help. Re-tests repeat until passed.
  • If the SC3 threshold fails for a game, that game's spoken instruction, visual demo or target sizes are fixed first (Section 2.5.3 response table) and the game is re-tested in C8 sittings with 5 new children; at least 4 of 5 must succeed. SC1 and SC3 are release gates; an SC2 miss is recorded with its planned correction in the Section 28 register and does not block launch (Section 2.5.3).
  • Re-tests always use new children, never children who have already seen the flow.

25.16 Parent Usability Tests #

  • 5 parents of children aged 2–6, including at least one who mainly uses an iPad and at least one who rarely installs apps. Recruitment from the same TestFlight group; sessions in person or by screen-sharing call (the parent shares their own screen; nothing is recorded — Decision: notes on paper only, same rules as 25.15.3).
  • Think-aloud; the observer does not help unless the parent gives up.
  • After each task the parent answers the Single Ease Question (1 = very difficult … 7 = very easy).
Task Scenario Success
P1 "Richten Sie die App für Ihr Kind ein." (from first launch) "Profil einrichten" tapped, gate solved, profile created on S-03, child home reached
P2 "Finden Sie heraus, welche Zahlen Ihr Kind schon sicher kann." (on the parent's own device after at least one week of TestFlight play, or on a developer test device with a Debug build seeded with typical-1y-single) Reaches S-19 and interprets the mastered band correctly within 2 minutes of looking at S-18 and S-19
P3 "Was fällt Ihrem Kind gerade schwer?" Finds "Gerade schwierig" and correctly states which numbers or skills are hard within 2 minutes of looking at S-18 and S-19 (success criterion SC6)
P4 "Stellen Sie die tägliche Spielzeit auf 15 Minuten." Setting saved
P5 "Schließen Sie das Jahresabo ab." (TestFlight sandbox; no charge) Purchase completed; parent correctly states the price after the trial and how to cancel
P6 "Stellen Sie Ihre Käufe auf einem anderen Gerät wieder her." (second test device) Restore succeeds
P7 "Exportieren Sie die Daten Ihres Kindes." Share sheet reached with the export file
P8 "Öffnen Sie die Abo-Verwaltung." (starting from the child home S-05) Parent reaches the gate, the Premium screen and "Abo verwalten", and the system subscription management sheet opens

Success criteria: each task completed without help by ≥ 4 of 5 parents; P5, P6 and P8 (finding the paywall, "Käufe wiederherstellen" and subscription management, starting from S-05) by 5 of 5 parents (success criterion SC7); P2 and P3 within the 2-minute limit by ≥ 4 of 5 parents (SC6); median SEQ ≥ 5 for every task; gate solved at the first attempt by ≥ 4 of 5 parents. Any failed task becomes a P1 defect for the parent area (Section 16) or paywall (Section 17) with a re-test by 2 new parents after the fix.

25.17 Acceptance Criteria Index #

Acceptance criteria are defined in their owning sections. This index maps each area to its source and to how it is verified. The repository file QA/acceptance-matrix.md lists every individual AC ID from these sections with its verifying test name(s) or manual check ID and the build in which it was last verified.

Area AC source Verified by
Entdecken, Wie viele?, Hör hin, Was fehlt? Section 11, acceptance criteria of each game Game logic tests (25.6), UI journeys J-02/J-03, manual full pass
Blitzblick, Mehr oder weniger, Nachspuren, Schüttelbox Section 12, acceptance criteria of each game Game logic tests, manual full pass, Pencil and motion passes
Froschsprung, Fütter das Zahlenmonster, Memory, Punkt zu Punkt Section 13, acceptance criteria of each game Game logic tests, manual full pass
Learning engine Section 9 (rules and test vectors) 25.5
Shared game framework Section 10 ZKGameKitTests, framework contract tests (25.6)
Rewards Section 14 ZKRewardsTests, J-02/J-03, manual
Profiles and sessions Section 15 Unit tests, J-01, J-08, J-09, interruption pass
Parent area and gate Section 16 ZKParentAreaTests, J-04, gate UI tests, parent usability (25.16)
Monetization Section 17 25.9, J-05, J-06, J-07
Screens and navigation Section 18 UI tests, NoDeadEnds, manual
Design and accessibility Section 19 25.12
Audio and localization Section 20 25.13, CV checks
Privacy and compliance Section 23 Section 23.16 checklist, policy and binary checks
Performance and reliability Section 24 25.11, 25.8
Product success criteria measured before launch Section 2.5.3 (SC1, SC2, SC3 children; SC6, SC7 parents; SC13 no traffic) 25.15, 25.16, network traffic pass (25.14.2)

Rules:

  1. Every AC ID must map to at least one automated test or one manual check before release; unmapped ACs block release.
  2. Automated tests that verify an AC carry the Swift Testing tag .acceptance and include the AC ID in their display name (for example @Test("AC ID: correct numeral accepted", .tags(.acceptance)), with the actual ID as defined in the owning section).
  3. Manual checks are listed in QA/manual-checks.md with IDs MC-<nnn> and referenced from the matrix.

25.18 Release QA Checklist #

Every item must be checked (with date and build number) in QA/release-<version>.md before submitting a build for App Review.

ID Item
R-01 CI Release workflow green for the tagged commit: Full plan passed, coverage thresholds met (25.3), policy check and binary check passed (Section 23.10)
R-02 Content validation report: zero failures; warnings reviewed (25.7)
R-03 All 12 games playable at all 3 steps on the Full-pass devices (25.14.2)
R-04 All manual device passes of 25.14.2 done; Family Sharing check done if StoreKit code changed (25.14.3)
R-05 Performance budgets PB-01 to PB-18 met on reference devices; results recorded (Section 24.2)
R-06 App Thinning Size Report: largest variant within budget (Section 24.4)
R-07 Accessibility checks of 25.12 done
R-08 Audio QA: audio-qa.swift passes; listening review complete for all lines changed since last release (25.13)
R-09 StoreKit manual sandbox checks done (25.9.3)
R-10 Compliance checklist C-01 to C-15 passed (Section 23.16)
R-11 Localization: zero missing German strings; app name appears only via Brand.appName (CV-05, CV-06); marketing texts rendered from Marketing/de/ (Section 20)
R-12 Developer menu, launch-argument parsing and test seeding absent from the Release binary (C-11)
R-13 Version (CFBundleShortVersionString) and build number incremented; German release notes written (Section 22 tone)
R-14 Acceptance matrix complete: every AC mapped and passing (25.17)
R-15 Child usability release gates SC1 and SC3 passed, SC2 result recorded (first release, and any release that changes child navigation, onboarding or a game's interaction model)
R-16 Parent usability criteria passed (first release, and any release that changes the parent area or paywall)
R-17 Open defects: zero P0/P1; P2 list reviewed and accepted in DECISIONS.md
R-18 Data migration: if the SwiftData schema changed, a migration test from every previously released schema version passed (Section 7)
R-19 Upgrade install: previous App Store version installed with seeded data → update to the release build → data, stars, friends, entitlement intact
R-20 Crash-free check: TestFlight build used by internal testers for ≥ 3 days with no unresolved crash in Organizer
R-21 Network traffic pass (25.14.2): no connection attributable to app code (success criterion SC13, Section 23.10.4)

25.19 Regression and Defect Policy #

25.19.1 Severity #

Severity Definition Rule
P0 Crash, data loss, child can reach a purchase, external link or setting without the gate, any network traffic by app code, a privacy label violation Fix immediately; blocks every release; expedited update if in production
P1 A game or journey cannot be completed; wrong mastery or star calculation; child usability issue seen with ≥ 2 children; performance budget missed Blocks release
P2 Visual or audio defect with a workaround; performance regression > 10 % within budget Fixed within the next two releases or accepted in DECISIONS.md
P3 Cosmetic Backlog

25.19.2 Rules #

  1. Every P0–P2 fix starts with a failing automated test that reproduces the defect, unless it is only reproducible manually (then a manual check MC-<nnn> is added).
  2. Engine changes: all Section 9 vectors and all engine invariants must pass; the calibration simulation output (25.5.3) is compared with the previous run and the difference noted in the pull request or commit message.
  3. Content changes (JSON, strings, audio): content validation (25.7) and audio QA for changed files; no code review needed for content-only changes if validation passes (Section 8).
  4. Flaky tests: a test failing intermittently is quarantined (disabled with .disabled("flaky: <defect ID>")) for at most 7 days while fixed; a quarantined test for an AC blocks release.
  5. Performance baselines are updated only intentionally, with a note in DECISIONS.md.
  6. Before every release the Full plan runs on the tagged commit; no release from a commit whose Full run is older than the last code change.

25.20 Continuous Integration (Xcode Cloud) #

25.20.1 Decision #

Decision: CI uses Xcode Cloud, Apple's hosted CI, with no third-party CI services or build tools (no fastlane, no Ruby gems, no package managers). Rationale: the project uses only Apple tooling; Xcode Cloud integrates with App Store Connect and TestFlight without stored credentials in the repository; the Apple Developer Program membership includes a monthly allowance of compute hours (25 hours per month at the time of writing — the executor verifies the current allowance in App Store Connect). A local script scripts/ci-local.sh replicates every workflow step for when the allowance is exhausted or Xcode Cloud is unavailable.

25.20.2 Repository scripts #

Xcode Cloud runs scripts from a ci_scripts/ folder next to Zahlenkette.xcodeproj:

Script Actions
ci_scripts/ci_post_clone.sh Print tool versions (xcodebuild -version, swift --version) into the log
ci_scripts/ci_pre_xcodebuild.sh Run xcrun swift scripts/policy-check.swift (Section 23.10) and scripts/check-imports.sh (module-graph enforcement, Section 5.3.3); either failure fails the build
ci_scripts/ci_post_xcodebuild.sh For test actions: run scripts/coverage-check.swift on the result bundle ($CI_RESULT_BUNDLE_PATH). For archive actions: run scripts/binary-check.sh on the archived binary, plutil check of Info.plist (C-09), and print the archive's app size

All scripts use set -euo pipefail and only macOS built-in tools and xcrun.

25.20.3 Workflows #

Workflow Start condition Actions Post-actions
CI Every push to main; every pull request targeting main Test: Fast plan on iPhone SE (3rd generation) simulator, latest iOS; Test: UISmoke plan on the same simulator Notify the developer on failure (Xcode Cloud e-mail)
Weekly Full Scheduled weekly (Sunday night) and manual start Test: Full plan on iPhone SE (3rd generation) latest iOS, iPad (A16) latest iPadOS in landscape, iPad Pro 13-inch; plus iPhone SE (3rd generation) on the deployment-target simulator runtime (Section 5) if Xcode Cloud offers it for the current Xcode (verify; if not offered, record in DECISIONS.md and rely on the physical deployment-target device, 25.14.1) Notify on failure
Release Tag matching v* (for example v1.0.0) Test: Full plan (as Weekly Full); Archive: iOS, Release configuration TestFlight internal testing distribution; App Store submission is done manually after the release checklist (25.18)

Compute budget estimate: CI ≈ 20 min × 40 runs/month ≈ 13 h; Weekly Full ≈ 60 min × 4 ≈ 4 h; Release ≈ 70 min × 2 ≈ 2.5 h; total ≈ 20 h/month. Decision: when App Store Connect shows more than 80 % of the month's allowance used, the CI workflow is switched to pull requests only for the rest of the month and pushes are validated locally with scripts/ci-local.sh.

25.20.4 Branch protection #

The main branch accepts only commits whose CI workflow passed (for pull requests) or is followed by a green CI run within the same day (for direct pushes by the solo developer; a red CI on main is fixed before any other work). Branch and commit conventions are owned by Section 6.

26. Release Scope: V1, V1.1 and V2 #

This section fixes what ships in which release. It owns the MVP cut, the V1.1 sync release, the V1.2 language release, the release mechanics of quarterly content drops, the V2 scope and the launch checklist. The content of the subscription roadmap (why each release justifies the subscription) is owned by Section 17; the milestone plan that produces V1 is owned by Section 27.

26.1 Release Overview #

Release Working name Theme Earliest start Ships as
V1 MVP / launch 12 games, learning engine, rewards, profiles, parent area, subscription, German audio, English-ready localization structure now (Section 27) App Store version 1.0
V1.1 Sync Opt-in iCloud sync of all app data between devices signed in to the same Apple ID (CloudKit private database), available while premium is active after launch stabilization (first 2 bug-fix updates shipped) App Store version 1.1
V1.2 English English UI text, English content lines and English voice recordings after V1.1 App Store version 1.2
Quarterly drops Content drops New games or major game extensions, garden themes, Punkt-zu-Punkt pictures (Section 17) first drop in the first full calendar quarter after launch App Store minor updates (1.x)
V2 Grow Numbers to 100, adding and subtracting within 10 and 20, Kaufladen with coins, clock, CKShare progress sharing, optional Kita edition not before 9 months after launch App Store version 2.0 (plus a separate Kita app, if built)

Version numbering rule. Decision: CFBundleShortVersionString follows MAJOR.MINOR.PATCH. V1 = 1.0.0. Bug-fix updates increment PATCH. V1.1 = 1.1.0, V1.2 = 1.2.0. Each quarterly content drop increments MINOR (1.3.0, 1.4.0, ...). V2 = 2.0.0. CFBundleVersion (build number) is a monotonically increasing integer across all versions.

Because V1 contains no network code (Section 23), all content is bundled with the app binary (Section 8). Every content addition therefore ships as an App Store update; there is no remote content download in any release covered by this section.

26.2 V1 (MVP) Scope #

Everything in the table ships in V1. "Complete" means every behavior specified in the owning section is implemented, tested per Section 25 and meets the budgets of Section 24.

Area V1 scope Owning section
Platform One universal app for iPhone and iPad, iOS/iPadOS 17.0 deployment target (toolchain per Section 5). Portrait and landscape on every screen, on both device families. Smallest supported device iPhone SE (2nd/3rd generation, 375×667 pt). 5, 18, 19
Free games (4) Entdecken (free explore + "Zähl mit" mode), Wie viele?, Hör hin, Was fehlt? — all 3 difficulty steps each, free forever. 11
Premium games batch I (4) Blitzblick, Mehr oder weniger, Nachspuren, Schüttelbox — 3 steps each. 12
Premium games batch II (4) Froschsprung, Fütter das Zahlenmonster, Memory, Punkt zu Punkt — 3 steps each. 13
Difficulty steps Exactly 3 steps per game (step1 "Leicht", step2 "Mittel", step3 "Schwer"), stepping rules per Section 9. 9, 10
Shared game framework GameModule protocol, round lifecycle, hint ladder, feedback, spoken instructions, visual instruction demo, pause/interrupt handling. 10
Adaptive learning engine Mastery per profile × skill (8 skills) × number (1–20), Leitner spaced repetition, task mix, in-game difficulty stepping, range widening/narrowing (1–5, 1–10, 1–20), daily Abenteuer composition, parent overrides. 9
Didactics Three linked representations (numeral, word, quantity), Kraft der Fünf, Zwanzigerfeld/Rechenrahmen colors with color-blind bead shapes, structured before scattered, error philosophy. 4, 19
Reward economy Stars, Zahlengarten with 24-slot grid and 60 decorations, 20 Zahlenfreunde, sticker album with 52 stickers, celebrations. No streaks, no random rewards, nothing purchasable by the child. 14
Daily Abenteuer 3 engine-chosen rounds with greeting and soft end, one bonus per profile per local day; an interrupted Abenteuer resumes at its next unplayed round on the same local day. Free users get rounds only from the three Abenteuer-eligible free games Wie viele?, Hör hin and Was fehlt? (Entdecken is never part of an Abenteuer, Section 9.16.2). 9, 14, 15
Child profiles Up to 5 profiles per device, 12 animal avatars, 6 color themes, two levels ("Die Kleinen", "Vorschule"), avatar picker without reading. First-launch setup starts with the parental gate (Section 15.4.1). 15
Session management Daily time limit (default 20 minutes; the current task always completes), "Zeit zum Ausruhen" end screen, break nudge after 8 continuous minutes shown only after a round ends (Section 15.9), interruption handling. 15
Parent area Parental gate, Übersicht, Fortschritt, "Gerade schwierig", Zeit, per-child settings, device settings, Premium, Daten (export JSON, reset, delete), Hilfe & Rechtliches. 16
Subscription StoreKit 2 auto-renewable subscription group "Zahlenkette Premium" with monthly and yearly products, 7-day introductory free trial, Family Sharing, paywall and restore only inside the parent area (reached only after the parental gate), lock badges and the locked-game flow in child mode (spoken line "Dieses Spiel ist noch zu. Frag deine Eltern.", audio ID session.locked_game, Section 17.8). 17
German audio Professionally recorded German voice for every number, number word, prompt, hint, feedback line and session line; SFX and music; TTS (AVSpeechSynthesizer, de-DE) only as fallback for lines without a recording. Separate music and SFX toggles; full visual path when sound is off. 20, 21
Localization structure String Catalogs (Localizable.xcstrings, Content.xcstrings), locale-keyed audio folders, no hardcoded display text, English-ready layout. German is the only shipped language in V1. 20
Central app name APP_DISPLAY_NAME in Config/Brand.xcconfig, Brand.appName in code, {{APP_NAME}} token in Marketing/de/, audio never speaks the name. 20
Content system Bundled JSON content (numbers.json, games/<gameId>.json, prompts.json, decorations.json, stickers.json, friends.json, dotpictures/<pictureId>.json, avatars.json), validation at build and launch. 8
Persistence SwiftData, local store only, schema already CloudKit-compatible so V1.1 needs no migration. 7
Design system and accessibility Colors, bead shapes, SF Pro Rounded, 60 pt child touch targets, calm-design rules, Reduce Motion, VoiceOver for the parent area and labelled child controls. 19
Privacy and compliance Kids Category compliance, no network calls except system-managed StoreKit, no analytics, no third-party SDKs, privacy label "Data Not Collected", privacy manifest, GDPR data export and deletion. 23
Reliability Fully offline operation, performance budgets, crash handling without third-party SDKs, data integrity. 24
Apple Pencil Optional enhancement for Nachspuren on iPad; finger is the primary input everywhere. 12
Store presence German App Store listing, screenshots, review notes. 22, 23, 26.9

26.3 Explicitly Cut from V1 #

Anything in this table must not be implemented in V1, not even behind a feature flag, unless the "V1 preparation" column requires a structural provision.

Item Target release Reason for cut V1 preparation (required)
iCloud / CloudKit sync between devices of the same Apple ID V1.1 Sync edge cases (duplicates, merges, account changes) need dedicated testing; not needed to prove learning value. SwiftData schema obeys CloudKit rules from day one (Section 7): no unique attributes, every property optional or defaulted, every relationship optional. No CloudKit entitlement and no iCloud capability in V1.
English text and English voice V1.2 German-first market (DE/AT/CH); recording cost. All display text in String Catalogs; audio resolved by locale folder; layouts tested with 30% longer strings (Section 20).
CKShare parent↔child progress sharing across different Apple IDs V2 Requires CloudKit sharing, which SwiftData does not provide on the private-database integration (see 26.7.5). Persistence access only through repositories in ZKPersistence (Section 7), so a sharing layer can be added without touching game or UI code.
Kita / teacher features (groups, more than 5 profiles, class reports) V2 (optional Kita edition) Different buyer, different licensing, different privacy expectations. Profile limit is a single constant (Section 15); entitlement behind a protocol (Section 17).
Numbers above 20 V2 Scope of V1 didactics is 1–20. Engine and content never hardcode 20; see 26.7.1.
Adding and subtracting V2 Requires new skills and new games. Skill is a string-backed enum; persisted skill values are stored as raw strings and unknown values are skipped when read (Section 7).
Kaufladen (shop with coins) V2 New game plus coin illustrations. Game modules are independent packages on ZKGameKit (Section 10).
Clock reading V2 Outside number range didactics of V1. Same as Kaufladen.
Microphone input (child speaks numbers) Not planned Privacy (voice data of children), recognition accuracy for 2–6-year-olds. None. The name skill links a heard word to a numeral or quantity; the child never speaks into the app.
Push notifications and local notifications Not planned Forbidden pattern (Section 14). No notification entitlement, no UNUserNotificationCenter usage.
Android, web, macOS (including "Designed for iPad" on Mac) Not planned Out of product scope. Decision: in App Store Connect, availability on Apple silicon Macs and on Apple Vision Pro is switched off for V1.
Ads, analytics, tracking, crash-reporting SDKs Never Kids Category and privacy promise. None. Success metrics come only from App Store Connect (Section 2).
Leaderboards, streaks, loot boxes, time-limited offers Never Forbidden patterns (Section 14). None.
Anything a child can buy; purchase UI in child mode Never Kids Category and product principle. Lock badge and "Frag deine Eltern" only (Section 17).
One-time purchase, lifetime purchase, paid upfront Rejected Monetization decision (Section 17). None.
Own backend, accounts, child logins Never in V1.x Privacy and cost. None.
Remote content downloads (On-Demand Resources, Background Assets, own CDN) Not planned for V1.x No network code. Content drops ship as app updates (26.1).
Widgets, Live Activities, App Clips, Siri/App Intents, SharePlay Not planned for V1 Child-facing surfaces outside the gated app are hard to make Kids-compliant. None.

26.4 V1.1 — iCloud Sync (Same Apple ID) #

26.4.1 Scope #

V1.1 adds synchronization of all app data between devices that are signed in to the same Apple ID, using the CloudKit private database through SwiftData's built-in CloudKit integration. Sync is opt-in and a premium feature (Decision, Section 17.4). The technical enablement steps (container identifier, the two ModelConfigurations and the switch between them, deduplication, conflict handling, account-change handling, profile merge) are owned by Section 7.15 and 7.20; this section defines the release scope and acceptance criteria.

In scope:

  • Sync of every SwiftData model defined in Section 7: profiles, mastery records, game progress, stars, garden placements, Zahlenfreunde, stickers, session and time-usage records, per-child settings.
  • A parent-area toggle "Mit iCloud synchronisieren" under Geräteeinstellungen. Decision: the toggle is off by default after the V1.1 update and on new installs; the parent switches it on deliberately. Reason: it changes where the child's learning data is stored, and the parent must make that choice knowingly. The toggle is behind the parental gate like all parent settings.
  • Premium only. The toggle can be switched on only while premium is active (EntitlementService, Section 17.5.4). Without premium the toggle is shown disabled with the explanation "Die Synchronisierung ist in Premium enthalten." and no CloudKit configuration is ever opened.
  • Store configuration. Two ModelConfigurations exist: local-only (cloudKitDatabase: .none, the V1 behavior) and CloudKit-backed (.private(...)), both on the same store file. The app selects one when the container is opened at launch: CloudKit-backed only when the toggle is on and premium is active, otherwise local-only. A change of the toggle takes effect through the container reopen procedure of Section 7.15.2.
  • Lapse. When premium lapses while sync is on, sync stops: from the next launch the local-only configuration is used. All local data stays on every device and remains playable; nothing is deleted locally or in iCloud. The stored toggle value is kept, so sync resumes automatically when premium becomes active again.
  • A one-time information card in the parent area (never in child mode) after the V1.1 update: "Neu: Fortschritt auf allen Geräten mit derselben Apple-ID. Sie können die Synchronisierung in den Geräteeinstellungen einschalten." For parents without premium the second sentence reads "Die Synchronisierung ist in Premium enthalten." (all parent-facing copy uses "Sie"; strings live in Section 22).
  • A sync status line in Geräteeinstellungen with the states defined in Section 7.15.2 (active, not signed in to iCloud, restricted), and the parent action "Profile zusammenführen" for children that exist twice after enabling sync (Section 7.20.5).
  • Privacy policy update describing that data is stored in the family's own private iCloud, which the developer cannot access.

Out of scope for V1.1: sharing between different Apple IDs (V2, 26.7.5), selective sync per child, a web view of progress, any developer-side server.

Subscription status is not synced data; it is resolved per device from StoreKit (Section 17). Family Sharing already covers other family members' devices, so a device of a family member with shared premium can enable sync too.

26.4.2 Release Preconditions #

# Precondition
P1 V1 has shipped at least two PATCH updates without a data-loss bug report.
P2 The V1 schema in production equals the schema Section 7 declares CloudKit-ready; the CloudKit development schema has been generated from it and deployed to the CloudKit production environment before the V1.1 build is submitted.
P3 The iCloud capability with the CloudKit service and the container iCloud.<bundleID> (default iCloud.de.zahlenkette.app) and the Background Modes capability "Remote notifications" (required for CloudKit change pushes) are added only in the V1.1 branch.
P4 The App Store privacy label remains "Data Not Collected": data stored in the user's own private CloudKit database is not accessible to the developer. The executor re-reads Apple's current privacy label definitions before submission and records the check in DECISIONS.md.

26.4.3 Acceptance Criteria #

All criteria are tested with two physical devices (one iPhone, one iPad) signed in to the same test Apple ID, plus a third device signed in to a different Apple ID. Unless a criterion states otherwise, premium is active on the devices (sandbox subscription).

ID Criterion (pass condition)
S1 With the toggle off (default), no CloudKit traffic occurs and the app behaves exactly like V1 (verified by absence of records in the CloudKit Console for the test account).
S2 After the toggle is switched on on device A, profiles, stars, garden, friends, stickers and mastery data created on A appear on device B (toggle on) within 5 minutes while both devices are online and in the foreground at least once.
S3 A profile existing on both devices before sync (created independently) does not appear twice: the deduplication rule of Section 7 merges or keeps them as defined there; no child-visible data (stars, friends, stickers) is lost in either case.
S4 If the union of profiles across devices exceeds 5, no profile is deleted automatically (behavior owned by Section 7.20.5): all profiles remain visible and playable, creating a new profile stays blocked until fewer than 5 profiles exist, and the parent area shows "Es gibt mehr als 5 Kinderprofile. Bitte löschen Sie ein Profil, um ein neues anzulegen."
S5 Offline edits on both devices (airplane mode, play one round each for the same profile) merge after reconnection: stars earned on both devices are both counted; mastery records converge to the result defined by Section 7's conflict rule; no crash, no duplicate friend celebration.
S6 A befriended Zahlenfreund or an earned sticker is never lost through a merge (union semantics).
S7 Signing out of iCloud or switching Apple ID on a device while the app is running does not crash the app, does not delete local data without the handling defined in Section 7, and never shows another Apple ID's data.
S8 Device C (different Apple ID) never receives any data from A or B.
S9 "Alle Daten löschen" (the device-wide delete) in the parent area with sync on deletes local data and the synced copies, shows the parent a clear confirmation text that data is removed from all devices with the same Apple ID, and leads to S-02 (behavior per Section 16.10 and Section 7.18). The per-child actions "Fortschritt zurücksetzen", "Alles zurücksetzen" and "Kind löschen" propagate to the other devices with their scope unchanged.
S10 The full V1 test suite (Section 25) passes with sync on and with sync off.
S11 Launch time and frame budgets of Section 24 are met with sync on and 5 profiles with full mastery data.
S12 Updating from V1.0.x to V1.1 on a device with existing data requires no schema migration and loses no data (automated upgrade test using a V1 store fixture).
S13 Without premium, the toggle is disabled with the explanation of 26.4.1, the local-only configuration is used, and no CloudKit traffic occurs (CloudKit Console shows no records for the test account).
S14 Premium lapses on device A while sync is on (StoreKit test expiration): after the next launch no further changes are uploaded from A, every profile and all rewards and progress remain on A and on B, and nothing is deleted from iCloud; after premium is active again, sync resumes without a duplicate profile.

26.5 V1.2 — English Text and Voice #

Scope:

  • English translations for every key in Localizable.xcstrings and Content.xcstrings.
  • English voice recordings for every audio ID used by German (same IDs; files under Resources/Audio/en/), with the same recording specification as German (Section 20).
  • English number words, English voice recordings and English TTS fallback voice in British English (en-GB; Decision: British English because the next target markets after DACH are expected to be UK/IE and Europe-wide English-medium users; revisit per Section 28.2, RV-18). Section 20.18 specifies the English localization on this basis.
  • English App Store listing, English privacy policy and English parent-area help text.
  • Storefronts: the English release is the trigger to open further storefronts (Section 28.2, RV-22); until then only DE, AT and CH are available (26.9.3).
  • Didactic review of English content: the dot structures, bead colors and Zwanzigerfeld remain unchanged; only words and voice change. English teen numbers ("thirteen" to "nineteen") are spoken units-first like German, so no game logic changes.

Language selection. Decision: the app language (text and voice together) follows the system per-app language setting (Settings > App > Language), which iOS provides automatically once a second localization ships. No in-app language switch in V1.2. A per-profile voice language (for bilingual families) is a V2 candidate and is listed in Section 28.2 (RV-34, default: no).

Acceptance criteria:

  • The content validation of Section 8 reports zero missing English strings and zero missing English audio files.
  • Every screen passes the iPhone SE layout check in both orientations with English text.
  • No German text or voice appears when the app language is English, except the app name if the German name is kept.
  • The German experience is byte-for-byte unchanged in behavior (full test suite passes with de).

26.6 Quarterly Content Drops #

The content and cadence commitment (at least 2 new games or major game extensions per quarter, new garden themes, new Punkt-zu-Punkt pictures) is owned by Section 17. Release mechanics:

Rule Detail
Delivery Every drop is an App Store update (MINOR version bump). No remote content.
Content-only preference Drops prefer content that needs no code change: new dotpictures/<pictureId>.json files, new decorations and garden themes in decorations.json, new steps or parameters in games/<gameId>.json where the game supports them (Section 8).
New game in a drop A new game is a new game module target on ZKGameKit, registered with the GameRegistry (type in ZKGameKit, Section 5.4.3) in the app target's GameRegistry+All registration file, with its own games/<gameId>.json; it adds a new GameID case. It must not change the engine's public API; if it needs a new skill, see 26.7.2.
Tier New games default to premium. Decision: the free set stays exactly the four free-forever games; free users benefit from drops only through new garden decorations and themes.
Audio New lines are recorded before the drop ships; TTS fallback is allowed in a drop only for at most 5% of the drop's new lines and never for number words.
Data Drops never require a schema migration; new models or fields follow the CloudKit rules of Section 7 (optional or defaulted).
Rewards New stickers extend the album with new pages; existing sticker positions never move. Friend count stays 20 until V2.
QA Each drop passes the full Section 25 suite, content validation, and one child usability session with at least 3 children for any new game.

26.7 V2 — Scope and Architectural Preparation #

V2 is a major version. It is started only after the evidence gates in Section 28 (retention and subscription data) have been reviewed. Each item below states the scope and what V1 already provides so V2 does not require rewriting V1 modules.

26.7.1 Numbers to 100 #

Scope: range stages beyond 20 (Decision: new stages r30, r50, r100), Hunderterfeld (10×10 field with the same red/blue five-structure and Lochperle rule), Rechenrahmen with 100 beads, tens and ones (Zehner und Einer), number words to 100 including the German units-first inversion ("siebenundvierzig"), new Zahlenfreunde for tens (Decision: 10 tens friends, not 80 new individual friends).

V1 preparation:

  • No engine, content or UI type may hardcode 20 as a magic number. The single source of truth is the constant NumberSpace.maxNumber (value 20 in V1) in ZKCore, together with NumberSpace.minNumber (value 1). Every loop over numbers, every array size of per-number data, every validation bound in ZKContent, every range check in ZKLearningEngine and every grid computation in ZKDesignSystem derives from these constants or from the active RangeStage's upper bound. Two checks enforce this: a unit test asserting that every per-number collection produced by the engine and the content loader has exactly NumberSpace.maxNumber - NumberSpace.minNumber + 1 entries, and a rule in the repository policy check (scripts/policy-check.swift, Section 23.10) that fails on the integer literal 20 in ZKLearningEngine and ZKContent sources (allow-list: the constant's own declaration, the RangeStage.r20 raw value, and test fixtures).
  • RangeStage is an Int-backed enum whose raw value is the stage's upper bound; adding cases is additive.
  • Mastery records are created lazily per (profile, skill, number) (Section 7), so records for 21–100 simply start to exist; no migration.
  • numbers.json is keyed per number; adding entries 21–100 is a content change.
  • The ZwanzigerfeldView layout is computed from rows × columns × group size; a HunderterfeldView reuses the same bead and grouping primitives.

26.7.2 Adding and Subtracting within 10 and 20 #

Scope: new skills add and subtract (Decision: raw values add, subtract), tasks within 10 first, then within 20 without and with crossing ten (Zehnerübergang), strategies based on Kraft der Fünf and Zahlzerlegung, at least 3 new games.

V1 preparation:

  • Skill is a String-backed CaseIterable enum; persisted records store the raw string, and readers skip unknown raw values instead of failing (Section 7). This keeps older app versions safe when synced with newer ones (after V1.1).
  • The engine's task-mix and mastery functions iterate Skill.allCases filtered by the level's active skills (Section 9); a new skill becomes active by adding it to the level configuration, not by changing algorithms.
  • Schüttelbox and Zahlzerlegung data already model a number as part + part (decompose), which is the basis for addition facts.
  • NumberFact in ZKCore is the shared type for number facts; V2 extends it with an operation case additively.

26.7.3 Kaufladen with Coins #

Scope: a shop game where the child pays with Euro coins and small notes (1 cent to 20 Euro; Decision: prices only in whole Euro up to 20 Euro in the first V2 release, cents later), counting coin values, making a total, and receiving change within 10.

V1 preparation: game modules are independent packages that only depend on ZKGameKit (Section 10); a new module needs no changes to other games. Coin artwork follows the illustration pipeline of Section 21. Currency display text goes through String Catalogs; the coin set is content data (coins.json, new file in V2 with schemaVersion: 1), so a CHF variant for Switzerland is a content addition.

26.7.4 Clock #

Scope: reading analog clocks at full and half hours (Vorschule level), matching analog and digital times, daily-routine pictures.

V1 preparation: same modular game structure; AppClock in ZKCore already abstracts time for testing and is reused for deterministic clock tasks. A new skill time is added per the rules in 26.7.2.

26.7.5 CKShare Parent-Child Progress Sharing #

Scope: a parent with their own Apple ID sees the progress of a child who uses a device under a different Apple ID (for example, a child's own iPad under Family Sharing with a child Apple ID, or a grandparent's device). Read-only progress view for the recipient; no remote control of settings in the first V2 release.

Platform fact and decision: SwiftData's automatic CloudKit integration syncs the private database only; it does not provide CKShare-based sharing. The executor verifies this against current Apple documentation at V2 planning time and records the result in DECISIONS.md. Decision: V2 implements sharing as a separate share layer inside ZKPersistence that exports a read-only progress summary record set into a dedicated CloudKit zone shared with CKShare (using the CloudKit framework directly), while the main data remains in the SwiftData-managed private store. If Apple has added native SwiftData sharing by then, the executor evaluates it and records the choice.

V1 preparation: all reads and writes go through repository types in ZKPersistence; UI and games never use ModelContext directly for queries outside those repositories (Section 7). The parent area's progress views consume value-type summaries produced by those repositories (Section 16), which is exactly the payload a shared zone would carry.

Privacy note for V2: sharing between Apple IDs is initiated only by a parent behind the parental gate, and the privacy label remains "Data Not Collected" because the data moves only between the family's own iCloud accounts. The executor re-verifies this label reading at V2 time.

26.7.6 Optional Kita Edition #

Scope: a separate app for Kitas (Decision: separate App Store app, working name " Kita", same codebase, different app target and bundle ID de.zahlenkette.kita), all games unlocked for the institution, up to 30 child profiles grouped in up to 3 groups, no iCloud sync between teacher devices in its first version, pseudonymous profiles only (avatar + optional nickname, never real names required).

Business model: decided at V2 planning (Section 28.2, RV-35). Default: an auto-renewable subscription, the same purchase type as the family app. A paid-upfront or one-time purchase is not planned, because one-time purchases are rejected for the product (Section 17.13); choosing one for the Kita edition would require a new product decision by the founder, recorded in DECISIONS.md together with a revision of Section 17.

Reason for a separate app: the Kita buyer, privacy expectations (institutional GDPR responsibility), purchasing channels and reporting needs differ from families. Apple School Manager volume purchasing does not transfer in-app subscriptions to managed devices; the executor verifies the current Apple School Manager and volume-purchase rules for subscriptions at V2 planning time, and the business-model decision is made on that basis.

V1 preparation:

  • The app target is the composition root (Section 5); a second app target can compose the same packages with different configuration.
  • Entitlement is resolved through the EntitlementService protocol in ZKStore (Section 17.5.4); the Kita target injects the implementation that matches the chosen business model.
  • The maximum number of profiles is a single configuration constant (Section 15), not a literal spread through the UI.
  • Brand values come from an xcconfig (Config/Brand.xcconfig); the Kita target gets its own Config/BrandKita.xcconfig.

26.8 V2 Non-Goals #

Even in V2: no ads, no analytics SDKs, no own backend, no child accounts, no microphone, no notifications to children, no purchasable rewards, no Android. Any change to these requires a new product decision recorded in DECISIONS.md and a revision of Sections 2, 14, 17 and 23.

26.9 Launch Checklist #

Every box must be checked before the V1 build is released to customers (M10, Section 27). The founder owns the checklist; items marked (E) are prepared by the executor and verified by the founder. Record the date and evidence (link, screenshot file, test report name) for each item in Release/launch-checklist.md in the repository.

26.9.1 Name Check #

The name is changed in one place (APP_DISPLAY_NAME in Config/Brand.xcconfig, {{APP_NAME}} in marketing texts, Section 20), so a name change late in the project costs no code change. Audio never speaks the name.

  • App Store name availability: create the app record in App Store Connect with the intended name (reserving it); if the name is taken, pick the next candidate. The App Store name field is limited to 30 characters (verify the current limit in App Store Connect).
  • Name candidates list with at least 3 alternatives prepared before the searches below, so a conflict does not block the launch.
  • DPMA trademark search (German Patent and Trade Mark Office, DPMAregister): identical and similar word marks in Nice classes 9 (software, downloadable apps) and 41 (education, entertainment services); also check class 28 (games and toys) as a precaution.
  • EUIPO trademark search (eSearch plus / TMview, which also covers national offices of EU member states and WIPO designations) in classes 9 and 41 (and 28 as a precaution).
  • Swiss trademark check (Swissreg, IGE/IPI) in classes 9 and 41, because Switzerland is a launch market outside the EU.
  • Austrian marks are covered by TMview; confirm explicitly for class 9 and 41.
  • App Store search for identical or confusingly similar app names in the German, Austrian and Swiss storefronts.
  • Domain availability checked and the chosen domains registered: .de, .com, .at, .ch (Decision: register all four for the final name; the .de domain hosts the website).
  • Decision on filing an own trademark recorded in DECISIONS.md (Decision default: file a German word mark in classes 9 and 41 before launch if the searches are clean; extend to an EU mark after 3 months of positive sales data).
  • Game-name trademark check: 'Memory' is a registered Ravensburger trademark in Germany. Search DPMA/EUIPO for 'Memory' in classes 9, 28 and 41. Decision default: ship the display name 'Paare finden' unless a lawyer confirms that descriptive use in the app is safe. The switch is the value of the string key game.memory.title in Content.xcstrings (Section 13.3); no code, ID or audio change. Store texts (Section 22.2.4) already use 'Paare finden'. Record the outcome in DECISIONS.md.
  • If the final name differs from "Zahlenkette": APP_DISPLAY_NAME updated, scripts/render-marketing.sh re-run, screenshots regenerated, privacy policy and imprint updated, and a full-text search for the old name in Localizable.xcstrings, Content.xcstrings and Marketing/ returns zero results (the bundle ID de.zahlenkette.app stays, as it is permanent and not user-visible).
  • Imprint (Impressum) published on the website, fulfilling § 5 DDG (Digitale-Dienste-Gesetz): name, postal address, email, and VAT ID if one exists.
  • Privacy policy (Datenschutzerklärung) in German published at a stable URL on the website, following the outline in Section 22 and the data inventory in Section 23 (no data collection, on-device storage, StoreKit handled by Apple, support email handling, children's data, parents' rights under GDPR).
  • Privacy policy URL entered in App Store Connect (required for all apps and for the Kids Category).
  • Legal review by a lawyer familiar with German app and data-protection law of: privacy policy, imprint, App Store description claims (no unsupported learning-outcome promises), subscription wording on the paywall (Section 22), and the terms of use decision below.
  • Terms of use: Decision: use Apple's standard Licensed Application End User License Agreement (EULA); link to it from the paywall and the App Store description (subscription apps must show a link to terms of use; verify current App Review Guideline 3.1.2 wording).
  • EU Digital Services Act trader status declared in App Store Connect (trader: yes, with the contact details that App Store Connect then displays publicly). Verify the phone number and email shown are ones the founder is willing to publish.
  • Tax and business registration for the founder's business in place (Gewerbeanmeldung or company) and tax forms completed in App Store Connect.
  • Export compliance: ITSAppUsesNonExemptEncryption set to NO in Info.plist (the app uses no encryption beyond what the OS provides). (E)
  • Music and SFX licenses on file permitting use in a commercial app worldwide, without attribution requirements in the app (or with attribution placed in Hilfe & Rechtliches).
  • Voice talent contract on file: buyout for use in the app, app updates, App Store previews and marketing, all territories, unlimited time, including the right to use recordings in future versions.
  • Illustration contract on file: full transfer of usage rights (ausschließliche Nutzungsrechte) for app, marketing and derivative works (stickers, merchandise excluded unless negotiated).
  • Font licensing: only system fonts (SF Pro Rounded) are used; no custom font files ship. (E)

26.9.3 App Store Connect #

  • Apple Developer Program membership active (as individual or organization; Decision: organization if a company exists, because the seller name is shown on the App Store).
  • Paid Applications Agreement accepted; banking and tax information completed and approved (subscriptions cannot go live without it).
  • App record created with bundle ID de.zahlenkette.app, primary language German, SKU recorded in DECISIONS.md.
  • Primary category Education; the app is placed in the Kids Category by selecting "Made for Kids" in the age rating section, with age band "5 and Under" (Decision; reasoning in Section 23.2, revisit trigger in Section 28.2, RV-10). Note: once in the Kids Category, the app must keep meeting Kids Category rules in all future updates even if the category is later deselected (App Review Guideline 1.3).
  • Age rating questionnaire answered truthfully (no objectionable content, no unrestricted web access, no user-generated content); the resulting rating is the lowest available.
  • Availability: DE, AT and CH only at launch (Decision, consistent with Section 1.2 and Section 17.2.2; all other storefronts, including LI and LU, stay off until the V1.2 revisit, Section 28.2 RV-22). Mac (Apple silicon) and Apple Vision Pro availability switched off.
  • Subscription group "Zahlenkette Premium" created with products de.zahlenkette.app.premium.monthly and de.zahlenkette.app.premium.yearly, Family Sharing enabled on both, prices 3,99 € monthly and 29,99 € yearly for Germany with Apple's automatic equalization for other storefronts (reviewed per storefront, CHF included), 7-day free trial introductory offer on both (Section 17).
  • Subscription group display name and product display names and descriptions localized in German (copy per Section 22); each product has its App Review screenshot (screenshot of the paywall in the parent area) attached.
  • The first subscription products are submitted together with the app version (first-time in-app purchases must be submitted with a new app version).
  • Price confirmation: final prices re-checked against the current App Store price point table for Germany and confirmed by the founder in DECISIONS.md (Section 1 and Section 17 own the price values).
  • App privacy: privacy label set to "Data Not Collected" (Section 23), answers re-verified against the final binary (no SDKs, no network).
  • Privacy manifest (PrivacyInfo.xcprivacy) present with its two required-reason API declarations (UserDefaults, and SystemBootTime with reason 35F9.1 for the trusted day clock, Section 23.6), and Xcode's privacy report generated from the archive shows no unexpected entries. (E)
  • Accessibility information: if App Store Connect offers accessibility declarations for the product page at submission time, fill them in truthfully based on the accessibility pass (26.9.8); declare only features that work on every common task.
  • German App Store listing entered: name, subtitle, description, keywords, promotional text, support URL, marketing URL, privacy policy URL (copy per Section 22; rendered from Marketing/de/ via scripts/render-marketing.sh).
  • Screenshots uploaded. Required at the time of writing: iPhone 6.9" display (1320×2868 or 1290×2796 pixels, portrait or landscape) and iPad 13" display (2064×2752 or 2048×2732 pixels). App Store Connect scales these down for smaller display sizes. The executor verifies the current required sizes in Apple's "Screenshot specifications" help page on the day of upload and adds any size that has become mandatory. The screenshot set is the 8 captioned screenshots per device family listed in Section 22.2.7 (motifs, order and captions owned there). Screenshots show no real children's photos and no real names.
  • App preview video: Decision: none in V1 (optional in App Store Connect); revisit with the first content drop.
  • App Review notes written (Section 23 owns the content), including at least: the parental gate (written multiplication question with factors 6–9, answer on a numeric keypad) protects first-launch setup, the parent area, all links, the paywall, restore and subscription management; how to reach the paywall (Elternbereich > Premium); that the app makes no network requests except StoreKit; that no account or login exists; that the free games are Entdecken, Wie viele?, Hör hin and Was fehlt?; that lock badges in child mode never lead to a purchase screen. No demo account is needed.
  • Support URL resolves and contains the support email address.
  • Version release option: Decision: "Manually release this version" so the founder controls the launch day.
  • Phased release for automatic updates: Decision: enabled for all updates after 1.0.0.

26.9.4 Audio #

  • All recorded audio IDs from the inventory in Section 21 are present as final masters under Resources/Audio/de/ and Resources/Audio/common/; the content validation of Section 8 reports zero missing files and zero TTS-fallback lines for launch content.
  • Final master QA per file against Section 20's recording spec: loudness target, peak ceiling, sample rate and format, no clipping, no mouth clicks, no room noise, leading and trailing silence within spec.
  • Listening QA pass on device by a native German speaker: pronunciation of every number word 1–20, correct question intonation for num.<n>.q files, consistent voice character across recording sessions.
  • Regional check: no wording that is unusual in Austria or Switzerland in prompts that children hear (Decision: standard German; avoid regionalisms such as "heuer" or "Jänner"; numbers are regionally neutral).
  • Music loop seamless (no audible click at the loop point) and ducks correctly under voice (Section 20).
  • Audio behaves correctly with the silent switch, headphones, Bluetooth speakers, and with music and SFX toggles off (behavior per Section 20).

26.9.5 Illustration #

  • All illustrations from the asset list in Section 21 delivered in final form: 12 avatars, 20 Zahlenfreunde (four states each: idle, happy, sleepy, silhouette; Section 14.5.1), 60 decorations, garden backgrounds, game scene art, 24 Punkt-zu-Punkt pictures, 8 milestone stickers, app icon.
  • Style consistency review: all assets side by side on one sheet; line weight, palette and character proportions match the approved style frame.
  • Each Zahlenfreund shows its quantity as dots in groups of five (first five red solid, second five blue Lochperle), verified for all 20 against Section 14.5.1, including the minimum belly-bead size stated there at every rendered size.
  • Every asset name follows the flat naming scheme <category>_<slug>[_<state>] of Section 6.4.3 (asset-catalog namespaces off), for example avatar_fuchs, friend_07_idle, deco_tulpe, dotpic_stern_reveal; the content validation of Section 8 reports no unknown asset name.
  • No placeholder asset remains: the release asset check (Section 29.6.4) passes on the Release archive.
  • App icon: all required sizes generated from the single 1024×1024 source; no text in the icon; readable at small sizes; no alpha channel.
  • Color-blind check of all bead and friend art using a deuteranopia and protanopia simulation (Section 19).

26.9.6 Testing and Quality #

  • Full automated test suite green on the release commit (Section 25), including engine test vectors and content validation.
  • TestFlight child tests passed: the child usability protocol of Section 25.15 completed with the required number of children in every age group, and the child-usability success criteria of Section 2.5.3 met (including "a 4-year-old finishes a session without adult help").
  • No open P0 or P1 defect; every open P2 defect reviewed and accepted in DECISIONS.md (severity levels P0–P3 per Section 25.19).
  • Upgrade path tested: not applicable for 1.0.0; for every later release, install previous App Store version, create data, update, verify data.
  • StoreKit tested in the sandbox and in TestFlight: purchase monthly, purchase yearly, trial, cancel, lapse (premium games re-lock, rewards kept), restore ("Käufe wiederherstellen"), Family Sharing member access, Ask to Buy on a child account, refund (via StoreKit testing in Xcode), offline start with cached entitlement.
  • Device matrix of Section 25 covered, including iPhone SE in portrait and landscape.

26.9.7 Performance #

  • All budgets in Section 24 met on the slowest device of the device matrix (cold launch, frame rate during games, memory, app size, battery).
  • App size (download size from App Store Connect's App Store file sizes report) within the Section 24 budget.
  • 30-minute continuous play test without memory growth or thermal throttling warnings.

26.9.8 Accessibility #

  • Accessibility pass completed per Section 19: Reduce Motion honored, Dynamic Type in the parent area, VoiceOver labels in the parent area, labelled child controls, color-blind bead shapes, contrast checks, touch targets at least 60×60 pt in child mode.
  • Every interactive element has an accessibility identifier in the <screen>.<element> format of Section 6.10 (required by UI tests, Section 25).
  • Full child flow works with sound off (visual path per Section 20).

26.9.9 Compliance #

  • Kids Category self-review against App Review Guidelines 1.3 and 5.1.4 and the Section 23 checklist: first-launch setup, every external link, the paywall, restore, manage subscription, parent settings and the time-limit extension are behind the parental gate; no child audio line contains the app name, and the locked-game line contains no game name and no money word (Sections 17.8, 20.19 and 21).
  • Binary contains no network code other than StoreKit (verified per Section 23: no URLSession usage in app code, no third-party frameworks in the archive).
  • No notifications requested, no tracking prompt, no IDFA access.
  • Data export and deletion tested (Section 16, Section 23).

26.9.10 Operations #

  • Support mailbox created on the app's own domain (Decision: hilfe@<domain>), tested for send and receive, with an auto-reply in German stating the expected response time (within 2 business days, the support commitment of Section 1.2).
  • Support email address shown in Hilfe & Rechtliches (behind the gate) and on the website.
  • Website live on the .de domain with: product page in German, privacy policy, imprint, support contact, and a short parent FAQ (subscription, restore, Family Sharing, data deletion). The website uses no tracking, no third-party cookies and no analytics (consistent with the product promise).
  • Refund and cancellation FAQ explains that subscriptions are managed and refunded by Apple.
  • Release notes for 1.0.0 in German.
  • Backup of the signing assets and the App Store Connect access (second admin or documented recovery) in place (bus-factor mitigation, Section 28.1, R11).
  • Launch-day plan: release manually, verify the live App Store page, perform a real purchase on a production device with a real Apple ID and request a refund afterwards, monitor App Store Connect crash reports (from users who share diagnostics with developers) and reviews daily for the first 14 days.

27. Milestones and Execution Plan #

This section defines the build sequence from an empty repository to the App Store launch of V1 (scope per Section 26.2). Each milestone has a goal, deliverables, dependencies, testable exit criteria and an effort estimate. A milestone is complete only when every exit criterion is checked and evidence is recorded in Release/milestones.md in the repository (one heading per milestone, one line per criterion with date and evidence: test name, screenshot file or report). The working rules for the coding agent during each milestone (definition of done, increments, multi-agent hand-off) are in Section 29.

27.1 Planning Assumptions #

Assumption Value
Team One founder (product owner, reviewer, tester, business tasks) working with AI coding agents that write most of the code.
Founder capacity Decision: 40 hours per week, of which about 60% goes to development steering and review and 40% to non-code tasks (audio, illustration, legal, store).
External contributors One professional German voice talent with a recording studio (booked per session); one freelance illustrator; one lawyer for a fixed-scope review.
Estimate unit Calendar weeks of the founder's time, including agent work, review, fixes and device testing.
Estimate basis Specified behavior in Sections 7–25 is complete, so agents implement rather than design. Estimates include a 15% buffer per milestone for rework.
Test devices available from M0 iPhone SE (2nd or 3rd generation), one current large iPhone, one base iPad, one iPad with Apple Pencil support (Section 25 device matrix).
Children for testing At least 10 children aged 2–6 from at least 8 families of the founder's network, with the age and device coverage and the written parental consent defined in Section 1.2, available at the three child-test checkpoints (27.4).

27.2 Milestone Summary #

ID Milestone Planned weeks Range (optimistic–pessimistic) Calendar weeks Depends on
M0 Project setup: repository, CI, module skeleton 1 0.5–1.5 W1 –
M1 Design system, content pipeline, audio service with placeholder TTS 2 1.5–3 W2–W3 M0
M2 Game framework, Entdecken, Hör hin 2 1.5–3 W4–W5 M1
M3 Learning engine, persistence, parental gate, profiles, sessions, Wie viele?, Was fehlt? 3 2.5–4 W6–W8 M2
M4 Rewards (stars, garden, Zahlenfreunde, stickers) and Abenteuer 2 1.5–3 W9–W10 M3
M5 Premium games batch 1: Blitzblick, Mehr oder weniger, Memory, Froschsprung 3 2–4 W11–W13 M3 (M4 for star events)
M6 Premium games batch 2: Nachspuren, Schüttelbox, Fütter das Zahlenmonster, Punkt zu Punkt 3 2.5–4 W14–W16 M5 (framework extensions), M4 (stickers)
M7 Parent area and StoreKit 2 2 1.5–3 W17–W18 M3 (parental gate), M4
M8 Real audio and illustration integration 1 0.5–2 W19 M6, M7, audio track A8, illustration track I7
M9 Accessibility, performance, compliance hardening, TestFlight child testing 2 1.5–3 W20–W21 M8
M10 App Store submission and launch 1 1–2 (App Review time is external) W22 M9, launch checklist (Section 26.9)
Total 22 17–29

Decision: the plan of record is 22 weeks. If a second agent stream is run in parallel (Section 29.9), M7 overlaps M6 (W14–W15) and the plan shortens to about 20 weeks; the milestone order and exit criteria remain unchanged. Weekly internal TestFlight builds start at the end of M2. Decision: these weekly builds are archived in the Release configuration and uploaded from Xcode Organizer by the integrator; the Release Xcode Cloud workflow (Section 25.20.3) is reserved for tagged release builds. Internal TestFlight builds therefore contain no developer menu and honor no launch arguments (Section 5.8.1).

27.3 Milestones in Detail #

27.3.1 M0 — Project Setup #

Goal: a buildable, testable skeleton in which the module boundaries of Section 5 are enforced by tooling from the first day.

Deliverables:

  • Git repository with main as the protected integration branch, .gitignore for Xcode, README.md (build instructions), DECISIONS.md (decision log, entry format D-NNNN per Section 6.12.1 and Section 29.5), Release/milestones.md, Release/launch-checklist.md (copy of the checklist in Section 26.9).
  • Xcode project Zahlenkette.xcodeproj with the app target Zahlenkette (universal, iOS/iPadOS 17.0 deployment target, toolchain per Section 5), bundle ID de.zahlenkette.app, all four orientations enabled on iPad and portrait plus both landscape orientations on iPhone, and exactly three build configurations: Debug, Profile (Release optimization plus the UITEST_HOOKS compilation condition, never distributed) and Release (Section 5.8).
  • Local package Packages/ZahlenketteKit with all library targets listed in Section 5 (ZKCore, ZKLearningEngine, ZKContent, ZKPersistence, ZKAudio, ZKDesignSystem, ZKGameKit, ZKRewards, ZKStore, ZKParentArea and the 12 game targets), each with one placeholder public type and one test target containing one passing Swift Testing test.
  • Swift 6 language mode with complete strict concurrency checking in every target; warnings are treated as errors in CI through the xcodebuild command-line setting defined in Section 5.8.2 (including its one-time verification step).
  • Config/Brand.xcconfig with APP_DISPLAY_NAME = Zahlenkette, Info.plist CFBundleDisplayName = $(APP_DISPLAY_NAME), and Brand.appName in ZKCore (fallback "Zahlenkette").
  • The repository check scripts, with the names, locations and rule sets listed in Section 5.13: scripts/check-imports.sh (module graph and allowed Apple frameworks per target, Section 5.3.3; for example a game target importing anything other than ZKGameKit and its re-exports, or ZKLearningEngine importing anything other than ZKCore), scripts/policy-check.swift with scripts/policy-rules.txt (forbidden symbols, Section 23.10: among them URLSession, NWConnection, UNUserNotificationCenter, ASIdentifierManager, @Attribute(.unique), #Unique, and the literal 20 in ZKLearningEngine and ZKContent sources with the allow-list of Section 26.7.1), and scripts/lint.sh (Section 6.13), run by the pre-commit hook of Section 6.11.3.
  • scripts/render-marketing.sh and folder Marketing/de/ with a placeholder file using {{APP_NAME}}.
  • CI on Xcode Cloud with the three workflows CI, Weekly Full and Release, the ci_scripts/ hooks and the test plans exactly as defined in Section 25.20 and Section 25.2.2. In M0, CI (every push to main and every pull request) is set up and green; Weekly Full and Release are created in M0 and become meaningful as tests and archives accumulate.

Exit criteria:

  • A fresh clone builds the app and all package targets with zero warnings and zero errors on CI.
  • CI workflow CI is green on main.
  • A test branch that adds import ZKPersistence to GameMemory makes scripts/check-imports.sh fail on CI (then the branch is deleted); evidence recorded.
  • A test branch that adds import Foundation plus a URLSession.shared call in any target makes scripts/policy-check.swift fail on CI.
  • The deliberate-warning verification of Section 5.8.2 is done and recorded as a DECISIONS.md entry.
  • The app launches on the iPhone SE simulator in portrait and landscape and on an iPad simulator, showing a placeholder root screen.
  • Changing APP_DISPLAY_NAME in Config/Brand.xcconfig changes the home-screen label and the value of Brand.appName shown on the placeholder screen, with no other file changed.
  • The app runs on a physical iPhone SE via a development signing profile.

Effort: 1 week.

27.3.2 M1 — Design System, Content Pipeline, Audio Service #

Goal: every shared building block that games need for visuals, content and sound exists and is tested, with placeholder TTS standing in for recordings.

Deliverables:

  • ZKDesignSystem: color tokens, typography (SF Pro Rounded via the system rounded design), spacing, touch-target constants, and the components BeadView (solid red bead and blue Lochperle), BeadChainView, ZwanzigerfeldView, DiceView, FingerPatternView, NumeralTile, BigButton, LockBadge, all per Section 19. Reduce Motion handling in every animated component.
  • Component gallery screen available in the developer menu (Debug builds only, Section 5.10), showing each component in each state for numbers 1–20, including ZwanzigerfeldView and BeadChainView in both arrangements (.wide and .compact).
  • ZKContent: Codable types and loaders for every content file of Section 8, schema validation (schemaVersion: 1), cross-reference validation (every audio ID referenced is declared: number audio in numbers.json, including its optional zero entry for num.0, and every other line in prompts.json; every string key exists in the String Catalogs; every ID unique; every number within NumberSpace.minNumber...NumberSpace.maxNumber), a command-line validation entry point run by CI, and launch-time validation in Debug builds.
  • Initial content: complete numbers.json for 1–20; prompts.json and games/<gameId>.json for all 12 games with at least the step structure and the prompt IDs from Section 21; Localizable.xcstrings and Content.xcstrings with German values for all keys referenced so far.
  • ZKAudio: the AudioService protocol with its production class LiveAudioService and the test double FakeAudioService (API per Section 20.5; AudioID itself lives in ZKCore), voice, SFX and music channels, audio ID resolution to Resources/Audio/<locale>/<audioId>.m4a and Resources/Audio/common/, TTS fallback (AVSpeechSynthesizer, de-DE) when a file is missing, independent music and SFX toggles, sound-off behavior, audio session handling, all per Section 20.
  • Placeholder audio: no generated files; missing recordings resolve to TTS at runtime and are counted in the developer menu's asset status (Section 29.6.3).

Exit criteria:

  • The component gallery renders without clipping or overlap on iPhone SE (portrait and landscape) and on a 13" iPad simulator (portrait and landscape); screenshots stored in Release/evidence/M1/.
  • ZwanzigerfeldView shows 2 rows × 10 in the wide arrangement and 4 rows × 5 in the compact arrangement (used for interactive fields in windows narrower than 714 pt, with the 1.5× gap between the two tens; Section 19.7.3), always with a 5|5 split per ten and the 1.5× gap between groups of five; beads 6–10 and 16–20 are Lochperlen (verified in a grayscale screenshot of both arrangements). Display-only fields stay 2 × 10, scaled.
  • BeadChainView wraps only at five-group boundaries in the compact arrangement (windows narrower than 820 pt) and is always displayed ascending from left to right.
  • Every interactive cell of a compact field is at least 60 × 60 pt on iPhone SE (unit test on the computed layout).
  • The child.numeral typography token, used for every numeral that is task content (stimulus, answer, pad and dot labels), is at least 44 pt on iPhone SE and 64 pt on iPad (unit test on the typography tokens; the smaller numeral tokens of Section 19.3.2 are allowed only for star counters, prices and badges).
  • The content validator accepts the real content and rejects each of these fixtures with a specific error message: duplicate ID, unknown audio ID reference, unknown string key, schemaVersion: 2, a regular number entry 0 (outside the optional zero entry), number maxNumber + 1, a game file missing a required envelope field of Section 8.8 (for example promptIds).
  • Playing num.1 to num.20 works via TTS when no file exists and via the file when a test .m4a is placed at the resolved path (unit test on resolution plus manual device check).
  • Music off keeps voice and SFX; SFX off keeps voice and music; all sound off leaves the visual path intact (per Section 20).
  • Unit tests for ZKContent and ZKAudio resolution pass on CI.

Effort: 2 weeks.

27.3.3 M2 — Game Framework, Entdecken, Hör hin #

Goal: the shared game framework is complete and proven by two real games.

Deliverables:

  • ZKGameKit per Section 10: GameModule protocol, round lifecycle, task model, answer evaluation, hint ladder, feedback, visual instruction demo, input handling, pause and interruption handling, emitted events.
  • A temporary deterministic task source in the app target that feeds rounds until the engine exists in M3 (removed at the end of M3).
  • GameRegistry (type in ZKGameKit, Section 5.4.3) with the GameRegistry+All registration file in the app target; a minimal child home screen with game tiles (placeholder art).
  • GameEntdecken (free explore and "Zähl mit") and GameHoerHin, each with 3 steps as specified in Section 11, with content files in the envelope of Section 8.8 (promptIds, hintIds, numberMin/numberMax with numberRangeByLevel, parameters with parametersByLevel, optional gameParameters; Entdecken has abenteuerEligible: false).
  • ZKGameKit API documentation in DocC comments on every public symbol.

Exit criteria:

  • Both games are playable end to end in all 3 steps on iPhone SE and iPad in both orientations (screen recordings stored as evidence).
  • Unit tests prove the outcome mapping: correct on attempt 1 → firstTry; correct on attempt 2 or 3 → afterHint; after the 3rd wrong attempt → solution shown, outcome shown.
  • Entdecken free-explore emits no mastery event and no star event; "Zähl mit" emits both (unit test on the event stream).
  • Backgrounding the app during a task and returning resumes the same task in the same state (UI test).
  • Every spoken instruction has a visual instruction demo that plays with sound off.
  • scripts/check-imports.sh is green: both games import only ZKGameKit and the modules it re-exports.
  • The shared drag input of ZKGameKit also accepts a single tap on an item, which sends it to the active target, for every level (Section 10.9.5; unit test on the framework input handling).
  • The ZKGameKit public API is declared frozen in DECISIONS.md; later changes require a decision entry (Section 29.5, change procedure 29.9.4).
  • First internal TestFlight build uploaded (Release configuration, 27.2).

Effort: 2 weeks.

27.3.4 M3 — Learning Engine, Persistence, Parental Gate, Profiles, Wie viele?, Was fehlt? #

Goal: the adaptive engine drives all four free games for real child profiles with persisted progress.

Deliverables:

  • ZKLearningEngine complete per Section 9 (mastery updates, Leitner scheduling, task mix, in-game difficulty stepping, range widening and narrowing, Abenteuer composition API, parent overrides), deterministic with injected RandomNumberGenerator and AppClock.
  • ZKPersistence: all SwiftData models and repositories per Section 7, local store only.
  • Parental gate S-17 per Section 16.2 (question generation, numeric keypad, 3 wrong answers → 30-second cooldown, access expiry), built now because first-launch setup starts with it (Section 15.4.1). In M3 it guards first-launch setup and profile management; M7 places it in front of the remaining parent-area destinations.
  • Profiles and session management per Section 15: gated first-launch flow (S-02 with the button "Profil einrichten", then S-17, then the single-form S-03 and the hand-off), create, edit, delete (the last remaining profile cannot be deleted), avatar picker (placeholder avatars; avatars in use are not selectable), up to 5 profiles, launch routing (1 profile → child home; 2 or more → picker), daily time limit, "Zeit zum Ausruhen", break nudge, interruptions, trusted day clock (Section 15.12).
  • Save points and active-time accounting per Section 7.12.2 (usage flushed every 15 s).
  • GameWieViele (including the SpriteKit moving stage) and GameWasFehlt, 3 steps each per Section 11.
  • Engine wired to all four free games; temporary task source removed.
  • Engine calibration simulator: a test-only harness in the ZKLearningEngine test target that plays synthetic learners (per-number success probabilities that rise with practice) through the real engine for 30 simulated sessions (one per simulated day) with the learner profiles and acceptance values owned by Section 28.3, and writes a report of first-try rate per session, sessions until mastery per number, range-stage changes and step changes (used by risk R09 and the revisits in Section 28.2).

Exit criteria:

  • All engine test vectors of Section 9 pass.
  • Property-based unit tests (seeded, 10,000 random outcome sequences): every score stays within 0.0…1.0, every boxIndex within 0…5, and the same seed produces the same task sequence.
  • Persistence round-trip test: create 5 profiles with mastery data, relaunch the app (new ModelContainer), all data identical.
  • Gate unit tests: a correct answer opens; 3 wrong answers → 30-second cooldown; access expires when the parent leaves the area or the app is backgrounded for more than 5 minutes; the question is never spoken.
  • First launch: S-02 plays no audio; S-03 is reachable only after the gate is solved (UI test with the real gate; no gate bypass exists in any build).
  • Creating a 6th profile is impossible (UI and repository test).
  • With an injected AppClock, the time limit is reached, the current task always completes, then child mode ends; child mode unlocks at the next local midnight of the trusted day clock (Section 15.12), and moving the device clock forward does not unlock it (unit tests); the break nudge appears only after a round's S-08, once 8 continuous minutes are reached, never mid-round (Section 15.9; unit test).
  • scripts/policy-check.swift confirms no unique attributes; a schema review against the CloudKit rules of Section 7 is recorded in DECISIONS.md.
  • The calibration simulator report for the three learner profiles of Section 28.3 (slow, typical, fast) is stored in Release/evidence/M3/ and meets the acceptance values of Section 28.3 (among them: the typical learner's first-try rate stays within 70–85% from session 3 onward); deviations are recorded in DECISIONS.md with the chosen adjustment.
  • All four free games run with engine-selected tasks; a 5-task round (Vorschule) and a 4-task round (Die Kleinen) are verified on device.
  • Child-test checkpoint CT1 completed (27.4).

Effort: 3 weeks.

27.3.5 M4 — Rewards and Abenteuer #

Goal: the complete, pressure-free reward loop and the daily Abenteuer work for free users.

Deliverables:

  • ZKRewards per Section 14 (the RewardService protocol of Section 14.12; ledger reasons taskSolved, roundCompleted, abenteuerCompleted, decorationPurchased): star accounting, Zahlengarten with 24 placement slots (drag placement plus tap-then-tap placement) and the 60-item decoration catalog (placeholder art), Zahlenfreunde befriending logic and move-in celebration, sticker album (6 pages, 52 stickers), milestone stickers, celebrations.
  • Daily Abenteuer flow per Sections 9, 14 and 15: greeting, 3 engine-chosen rounds, soft end, once-per-day bonus, garden fallback with "Morgen gibt es ein neues Abenteuer".
  • Entitlement stub: a fixed-state implementation of the EntitlementService protocol (Section 17.5.4) until M7, switchable through the developer-menu entitlement override (Debug builds only, Section 5.10) and, in UI tests, through the -uiTestEntitlement launch argument (Section 5.9).

Exit criteria:

  • Unit tests: a 5-task round yields 7 stars; a 4-task round yields 6 stars; a completed Abenteuer adds 5; shown tasks still earn their star; stars never go below 0 and decrease only through decoration purchases.
  • Unit tests for the befriending rule of Section 14 with at least one passing and one failing case per condition; a befriended friend stays befriended after scores drop.
  • Each of the 8 milestone stickers is awarded exactly once by its trigger (unit tests).
  • A second Abenteuer on the same local day shows the garden instead and awards no bonus (unit test with injected clock, UI test).
  • An Abenteuer abandoned after round 1 resumes at round 2 when started again on the same local day, and is discarded on the next local day (unit test with injected clock).
  • With premium off, Abenteuer composes rounds only from the three Abenteuer-eligible free games Wie viele?, Hör hin and Was fehlt?; Entdecken never appears (unit test over 1,000 seeded compositions).
  • Every celebration lasts at most 2.5 s (measured in UI test via animation completion timestamps).
  • Review confirms no streak counter, no timer, no random reward, no time-limited item anywhere.

Effort: 2 weeks.

27.3.6 M5 — Premium Games Batch 1 #

Goal: Blitzblick, Mehr oder weniger, Memory and Froschsprung complete.

Deliverables: GameBlitzblick, GameMehrOderWeniger (Section 12), GameMemory, GameFroschsprung (Section 13), each with 3 steps, content files in the envelope of Section 8.8 (Memory with per-step tasksPerRound and expectedRoundSeconds), prompt and hint IDs; lock badges and the locked-game screen S-15 in child mode per Section 17.8 (spoken line session.locked_game "Dieses Spiel ist noch zu. Frag deine Eltern." at most once per session per profile, optional follow-up session.locked_other while the open tiles are highlighted); the parent icon on S-15 opens the real parental gate from M3, behind which a placeholder parent screen stands until M7.

Exit criteria:

  • Each game meets every acceptance criterion listed for it in Sections 12 and 13.
  • Each game's events credit the primary skill with weight 1.0 and the secondary skill with weight 0.5 per the primary and secondary skill assigned to each game in Sections 12 and 13 (weights per Section 9), verified by one unit test per game.
  • Blitzblick: the flash duration is a display time only; the answer phase has no timer and no time-dependent outcome (unit test waits 10 minutes of injected clock time and still accepts a first-try answer).
  • With premium off, the 8 premium tiles show LockBadge; tapping never shows a price, product name or purchase button; a second tap in the same session shows only the lock animation and the highlighted open tiles without the spoken line (UI test with -uiTestAudio stub).
  • Froschsprung: the start bank is unlabeled, landmarks 5, 10, 15 and 20 are labeled (except the target stone of a step-3 task), and pad numerals use the child.numeral token (Section 13.1).
  • No game imports another game, ZKPersistence or ZKStore (scripts/check-imports.sh green).
  • All four games run in both orientations on iPhone SE and iPad.

Effort: 3 weeks.

27.3.7 M6 — Premium Games Batch 2 #

Goal: Nachspuren, Schüttelbox, Fütter das Zahlenmonster and Punkt zu Punkt complete.

Deliverables: GameNachspuren (PencilKit, finger primary, Apple Pencil optional on iPad) and GameSchuettelbox (SpriteKit physics, Core Motion shake with button fallback) per Section 12; GameZahlenmonster and GamePunktZuPunkt (the 24 pictures of Section 8.5 and 14.7.2, sticker award sticker.dot.<pictureId>, per-step minRange) per Section 13.

Exit criteria:

  • Each game meets every acceptance criterion listed for it in Sections 12 and 13.
  • Nachspuren: stroke-order data exists for every numeral 1–20 as required by Section 12; recorded stroke fixtures (correct, wrong order, off-path) are classified correctly by unit tests; finger tracing works on iPhone SE; Pencil tracing works on a Pencil-capable iPad.
  • Schüttelbox: the button fallback completes every task without motion (UI test on the simulator, which has no accelerometer); the shake threshold of Section 12 is verified on a physical device by the founder with the device held in two hands.
  • Zahlenmonster: drag targets and items are at least 60×60 pt; dropping outside the mouth never counts; tap-to-feed works at every level, each tap feeding exactly one item or pack; tested with a 2–4-year-old at CT2.
  • Punkt zu Punkt: all 24 picture files pass content validation (including the minimum dot distance 0.17 of Section 8.12); completing a picture awards its sticker sticker.dot.<pictureId> exactly once through the pictureCompleted game event; no step-3 round is planned while the active range is below its minRange.
  • Child-test checkpoint CT2 completed (27.4).

Effort: 3 weeks.

27.3.8 M7 — Parent Area and StoreKit #

Goal: parents can see progress, configure every setting, manage data and subscribe; the child can never reach any of it.

Deliverables:

  • The parental gate built in M3 (Section 16.2) placed in front of every remaining destination of Section 16: parent area, every external link, paywall and purchase, restore, manage subscription, time-limit extension. Parent entry (grown-up icon, press and hold 2 s, then the gate) on S-04, S-05, S-16 and on S-15 (Section 18).
  • Parent area sections per Section 16: Übersicht, Fortschritt, "Gerade schwierig", Zeit (Swift Charts), per-child settings, Geräteeinstellungen, Premium, Daten (export JSON, "Fortschritt zurücksetzen", "Alles zurücksetzen", "Kind löschen", "Alle Daten löschen"), Hilfe & Rechtliches; the developer-menu entry row on S-18 in Debug builds only (Section 5.10).
  • ZKStore StoreKit 2 implementation per Section 17: EntitlementService per Section 17.5.4 (including isUnlocked(_:)), products, paywall with prices and trial length taken from StoreKit (Product.displayPrice, offer period; no hardcoded prices), purchase through SwiftUI @Environment(\.purchase) and StoreKit messages through @Environment(\.displayStoreKitMessage) in ZKParentArea (no UIKit in ZKStore), Transaction.updates listener registered at App init on the critical launch path, Transaction.currentEntitlements refresh deferred after launch, entitlement cache for offline start, restore ("Käufe wiederherstellen"), manage subscription, lapse handling; a StoreKit configuration file for local and CI testing. The M4 stub is replaced by the real service; the developer-menu override exists only in Debug builds.

Exit criteria:

  • Every destination listed in Section 16 as gated requires the gate (UI test ParentLinksAreGated and the gate coverage tests of Section 25).
  • UI test crawls every child-mode screen and asserts that no screen contains a price, a product name, a purchase button or an external link.
  • StoreKit tests using the StoreKit configuration file: purchase monthly, purchase yearly, trial start, expiration → premium games re-lock with stars, friends, stickers, garden and mastery unchanged, refund → re-lock, restore.
  • Offline start with a valid cached entitlement unlocks premium; with an expired cached entitlement it does not (unit tests with injected clock).
  • Export JSON validates against the export schema of Section 7.17; "Fortschritt zurücksetzen" keeps stars, garden, stickers and friends; "Alles zurücksetzen" resets everything of that child except today's usage entry; "Alle Daten löschen" leaves no profile, mastery or reward data (verified by reopening the store) and leads to S-02 (Sections 14.11, 16.10, 7.18).
  • A transaction delivered while the app launches (StoreKit test session: renewal or Ask to Buy approval at launch) is processed without a relaunch.
  • Parent area passes VoiceOver navigation and Dynamic Type at the largest accessibility size without truncating any setting label.
  • Sandbox purchase on a physical device with a sandbox Apple ID succeeds (requires the Paid Applications Agreement, track L).

Effort: 2 weeks.

27.3.9 M8 — Real Audio and Illustration Integration #

Goal: the app contains only final assets.

Deliverables: final voice masters, SFX and music placed in Resources/Audio/; final illustrations placed in the asset catalogs replacing every placeholder; app icon; Content.xcstrings wording aligned with the recorded lines.

Exit criteria:

  • The release asset check (Section 29.6.4) reports zero placeholders and zero missing recordings for launch content; every asset name follows the flat scheme of Section 6.4.3.
  • Every recorded line matches its Content.xcstrings text (founder listening pass with the script sheet; mismatches either re-recorded in the pickup slot A9 or the text adjusted).
  • Style consistency sheet of all illustrations reviewed and approved by the founder.
  • App download size within the Section 24 budget.
  • Full automated test suite green with final assets.

Effort: 1 week.

27.3.10 M9 — Accessibility, Performance, Compliance Hardening, TestFlight Child Testing #

Goal: the release candidate meets every quality bar and is proven with children.

Deliverables: accessibility fixes; performance fixes (performance tests run in the Profile configuration, Section 5.8.1 and 25.2.2); compliance fixes; privacy manifest final (UserDefaults and SystemBootTime 35F9.1 entries, Section 23.6); App Review notes draft; TestFlight child test report (CT3) per Section 25.

Exit criteria:

  • Launch-checklist groups 26.9.6 (Testing and Quality), 26.9.7 (Performance), 26.9.8 (Accessibility) and 26.9.9 (Compliance) are fully checked.
  • CT3 passed per the protocol and pass thresholds of Section 25, including: a 4-year-old finishes a session without adult help.
  • Zero open P0 or P1 defects; every open P2 defect either fixed or accepted in DECISIONS.md with a reason for shipping (severity levels P0–P3 per Section 25.19).
  • Release candidate build number frozen; any later change restarts the M9 regression pass for the affected area.

Effort: 2 weeks.

27.3.11 M10 — App Store Submission and Launch #

Goal: V1 is live in DE, AT and CH (the only launch storefronts, Section 26.9.3) with working subscriptions.

Deliverables: completed App Store Connect record (Section 26.9.3), submitted build with subscription products, App Review notes, launch-day actions (Section 26.9.10).

Exit criteria:

  • Every box in Section 26.9 is checked with evidence.
  • App Review approved (if rejected: fix per Section 28 risk R01 mitigation, resubmit; each resubmission cycle budgets 3 working days).
  • Version released manually; the live App Store page shows the correct name, screenshots and prices.
  • A real production purchase on a production device succeeds and unlocks premium; the founder then requests a refund through Apple.
  • Daily monitoring of App Store Connect crashes and reviews started for 14 days, logged in Release/launch-log.md.

Effort: 1 week of founder time; App Review duration is external and not under the founder's control.

27.4 Child-Test Checkpoints #

Checkpoint When Build Children Purpose Output
CT1 End of M3 (W8) Internal TestFlight, placeholder art and TTS 3 (at least one aged 2–3, one aged 5–6) Can children start, understand and finish the four free games without reading? Observation notes in Release/evidence/CT1.md; list of changes
CT2 End of M6 (W16) Internal TestFlight, partial real assets 4 (at least two aged 2–3) Drag, trace and shake feasibility for young children; engine pacing Release/evidence/CT2.md; decisions on threshold changes recorded in DECISIONS.md
CT3 M9 (W20–W21) Release candidate on TestFlight per Section 25.15 protocol and the family coverage of Section 1.2 Formal acceptance per Section 25, covering every child-usability success criterion of Section 2.5.3 (including 3-year-olds completing a round and first-answer understanding for each of the 12 games) Release/evidence/CT3.md

Consent, observation protocol and data handling for all checkpoints follow Section 25.15: sessions are documented in a written observation log only (no photos, videos or audio recordings of children), and notes are anonymous.

27.5 Parallel Tracks (Non-Code) #

These tracks run alongside development and are owned by the founder. Their deadlines are hard inputs to M8.

27.5.1 Audio Track (A) #

Step Weeks Activity Done when
A1 W1–W2 Build the German recording script from the Section 21 inventory (audio ID, text, intonation note, context) as a spreadsheet exported from the content files. Script covers 100% of launch audio IDs.
A2 W2–W4 Voice casting: 3 candidates record a 20-line sample (numbers 1–10, 5 prompts, 5 feedback lines); founder and at least 2 children listen; contract signed with buyout (Section 26.9.2). Contract signed.
A3 W5 Script freeze 1: numbers, number questions, all free-game lines, common hints, feedback, session and friend lines. Frozen script version tagged in the repository.
A4 W6–W7 Recording session 1. Raw takes delivered.
A5 W8–W10 Editing and mastering batch 1 to the Section 20 spec; files dropped into Resources/Audio/de/ as they arrive (no code change needed). Batch 1 masters in the repository.
A6 W12 Script freeze 2: all premium-game lines, adjusted after implementation of M5. Frozen script version tagged.
A7 W13–W14 Recording session 2 plus pickups for batch 1 corrections. Raw takes delivered.
A8 W15–W17 Editing and mastering batch 2. Hard deadline: all launch masters delivered by the end of W17. Asset status shows 0 missing recordings.
A9 W20 Reserved pickup session (half day) for errors found in M8/M9. Pickups integrated or slot released.

SFX and music: licensed library selection in W3–W6, final files by the end of W10.

27.5.2 Illustration Track (I) #

Step Weeks Activity Done when
I1 W1–W2 Style guide: palette and bead rules (Section 19), calm-design rules, Zahlenfreunde dot rule (Section 14), asset list (Section 21) with pixel sizes and formats. Style guide final.
I2 W2–W4 Illustrator selection with a paid test (1 avatar, 1 Zahlenfreund, 1 decoration); contract with full usage rights (Section 26.9.2). Contract signed.
I3 W5 Style frame approved: garden scene with 3 Zahlenfreunde and 3 decorations, viewed on iPhone SE and iPad. Founder approval recorded.
I4 W6–W9 12 avatars, 20 Zahlenfreunde, app icon draft. Delivered and integrated.
I5 W9–W13 60 decorations, garden backgrounds, free-game scene art. Delivered and integrated.
I6 W12–W16 Premium-game scene art, 24 Punkt-zu-Punkt pictures, 8 milestone stickers, final app icon. Delivered and integrated.
I7 W17 Hard deadline: all launch illustrations final. Asset status shows 0 placeholder images.
I8 W20–W21 Fix slot for corrections from M8/M9. Corrections integrated.
Step Weeks Activity
L1 W1–W3 Name check (Section 26.9.1): App Store name, DPMA, EUIPO, Swissreg searches, domains. Final name decided by the end of W3 so that the app icon, style work and marketing texts use it.
L2 W4 App record created in App Store Connect (reserves the name); domains registered; trademark filing decision executed.
L3 W6–W12 Paid Applications Agreement, banking, tax forms, DSA trader status; done by the end of W12 (needed for sandbox tests in M7).
L4 W10–W12 Privacy policy and imprint drafts (outline in Section 22).
L5 W16–W18 Lawyer review of privacy policy, imprint, paywall wording, store description.
L6 W18–W20 Website on the .de domain, support mailbox (Section 26.9.10).
L7 W20–W21 Screenshots produced from the release candidate; listing texts rendered from Marketing/de/.

27.6 Gantt Overview #

Legend: X = active work, ! = hard deadline at the end of that week, c = child-test checkpoint.

Track W1 W2 W3 W4 W5 W6 W7 W8 W9 W10 W11 W12 W13 W14 W15 W16 W17 W18 W19 W20 W21 W22
M0 Setup X
M1 Design, content, audio svc X X
M2 Framework + 2 games X X
M3 Engine, gate, profiles + 2 games X X Xc
M4 Rewards + Abenteuer X X
M5 Premium batch 1 X X X
M6 Premium batch 2 X X Xc
M7 Parent area, StoreKit X X
M8 Asset integration X
M9 Hardening + CT3 Xc Xc
M10 Submission, launch X
A Audio X X X X X X X X X X X X X X X X! X
I Illustration X X X X X X X X X X X X X X X X X! X X
L Legal, name, store X X X! X X X X X X X X! X X X X X X

27.7 Critical Path #

The critical path of the plan of record is:

M0 → M1 → M2 → M3 → M4 → M5 → M6 → M7 → M8 → M9 → M10

with two external inputs that join at M8: the audio track (A8, all masters by the end of W17) and the illustration track (I7, all illustrations by the end of W17). The name decision (L1, end of W3) is on the critical path for the app icon and all marketing assets, and the Paid Applications Agreement (L3, end of W12) is on the critical path for M7's sandbox exit criterion.

Slack and escalation rules:

  • The asset tracks have one week of slack (W18) before M8. If an asset deadline is missed by more than one week, M8 moves and every later milestone moves with it; the founder records the new dates in Release/milestones.md.
  • If a development milestone overruns its pessimistic estimate, the founder applies the scope levers below in order, records the decision in DECISIONS.md, and does not cut any item of the free tier, the parental gate, privacy compliance, the reward rules or the fixed V1 content counts (24 Punkt-zu-Punkt pictures, 60 decorations, 52 stickers; Sections 14 and 26.2).

Scope levers (in order of application):

  1. Run M7 in parallel with M6 through a second agent stream (Section 29.9).
  2. Move App Store screenshots for iPad to a simplified set (the same 8 motifs of Section 22.2.7, captured without additional composition).

No lever may remove a game, a difficulty step, a free-tier feature, an accessibility requirement or any item of the fixed V1 content counts from V1.

27.8 Post-Launch Plan (First 90 Days) #

Period Activity
Days 1–14 Daily review of crashes (App Store Connect, from users who share diagnostics), reviews and support mail; PATCH releases for crashes within 3 working days.
Days 15–45 Second PATCH release with usability fixes from support mail and reviews. Start of V1.1 sync implementation (Section 26.4) after precondition P1.
Days 45–90 V1.1 release candidate; first quarterly content drop planned per Section 17; first review of the decisions in Section 28.2 that are due at 3 months.

28. Risks and Decisions to Revisit #

This section owns the risk register and the list of decisions that are fixed for V1 but must be re-examined when specific evidence arrives. Every item has a chosen default; nothing here blocks implementation. Review cadence: the founder reviews the register at every milestone exit (Section 27) and monthly after launch, updates likelihood and status in Release/risks.md in the repository, and records every changed decision in DECISIONS.md (entry format D-NNNN per Section 6.12.1 and Section 29.5). Revisit items in 28.2 carry IDs RV-NN; they are distinct from the D-NNNN entries of the decision log and from the question IDs of Section 1.2.

Scales used:

Scale Low (L) Medium (M) High (H)
Likelihood Unlikely to occur before or within 6 months after launch Plausible; has happened to comparable apps Expected unless mitigated
Impact Minor delay (< 1 week) or cosmetic Delay of 1–4 weeks, noticeable revenue or rating effect Launch blocked, legal exposure, data loss, harm to a child, or product viability threatened

Owners: Founder (F), Executor, meaning the AI coding agent under founder review (E), Voice talent / studio (V), Illustrator (I), Lawyer (Law).

28.1 Risk Register #

ID Risk Likelihood Impact Mitigation Owner
R01 App Review rejects the app under the Kids Category rules (Guidelines 1.3 and 5.1.4): a link, the paywall, restore or a settings screen reachable without the parental gate; the gate judged too easy; a third-party-like data flow suspected; subscription information incomplete on the paywall (Guideline 3.1.2: title, duration, price per period, links to terms and privacy policy). M H Every gated destination listed in Section 16 is covered by the UI crawl test of M7 (no price, product, purchase button or link in child mode). Gate uses a written multiplication question (factors 6–9, number words, numeric keypad) that pre-readers cannot solve and that is never spoken. Paywall copy (Section 22) contains all subscription disclosures and both links. App Review notes (Section 23) explain the gate, the free games and the absence of network traffic. Self-review against Guidelines 1.3, 3.1.2 and 5.1.4 in M9. On rejection: fix exactly the cited issue, reply in Resolution Center with the change, resubmit; no argument without a change unless the rejection is factually wrong, in which case reply with screenshots and the gate description. Gate-specific fallback (Section 23.14): if a rejection under Guideline 1.3 for the gate persists after the Resolution Center reply, the gate difficulty is raised to two-digit × one-digit multiplication (factors 12–19 × 3–9, still written in number words and never spoken); see RV-15. F, E
R02 Parents in DE/AT/CH resist a subscription for a preschool app ("Abofalle" reviews, low trial-to-paid conversion, preference for one-time purchase). H H Four complete games free forever with all steps; rewards, Zahlenfreunde and Abenteuer available to free users; paywall only in the parent area with clear yearly savings and trial end date; honest copy (Section 22) stating how to cancel; visible content roadmap (Section 17) delivered on schedule; respond to every critical review within 2 working days. Revisit prices, trial and free set per 28.2 (RV-01–RV-04). F
R03 Audio production costs more or takes longer than planned (many lines, retakes, pronunciation corrections, studio availability). M H Script generated from content files (no manual list drift); two script freezes (Section 27.5.1); fixed-price quote per session agreed before A4; complete buyout contract; reserved pickup slot A9; TTS fallback keeps development unblocked but is never accepted for launch content; audio never speaks the app name, so a rename needs no re-recording. F, V
R04 Voice consistency across future content drops (voice talent unavailable, different studio sound, voice change). M M Contract includes first refusal for future sessions at agreed rates; studio session notes (microphone, distance, processing chain, reference takes) archived in the repository under Audio/session-notes/; 30 reserve lines recorded in session 2 (generic praise, generic hints) for future games; if the talent becomes unavailable, re-record all number words and core lines with the new voice in one release rather than mixing voices within a game. F, V
R05 Illustrations are inconsistent across batches (line weight, palette, character proportions), or the illustrator drops out. M M Style guide and approved style frame (I3) before volume work; consistency sheet review after every batch; source files (layered, vector where possible) delivered with every batch so another illustrator can continue; payment per accepted batch. Decision: final shipped illustrations are made by a human illustrator; AI-generated images may be used only as internal placeholders and never ship (consistency, and unclear copyright status of generated images). F, I
R06 Two- and three-year-olds cannot reliably perform drag interactions (Zahlenmonster, Memory placement, garden decoration), leading to frustration and adult help. H M Large hit areas and generous snap radius per Section 10; every drag interaction also has a tap alternative, owned by Section 10.9.5 and implemented once in the shared input handling so every game gets it: in games, a single tap on an item sends it to the active target, for every level (in Zahlenmonster each tap feeds exactly one item or pack); in the garden, tap-then-tap placement (tap an item, then tap a slot) works in addition to drag (Section 14.4.3); drops outside a target return the item gently without counting as an error; CT2 includes at least two children aged 2–3 specifically for drag tasks; thresholds adjusted from CT2 observations and recorded in DECISIONS.md. E, F
R07 Shake gesture safety: a child shakes too hard, throws or drops the device during Schüttelbox. M H Gentle shake threshold (a light shake suffices; Section 12 defines the value) so strong shaking is never needed; the spoken prompt asks the child to hold the device with both hands; the on-screen button fallback is always visible and completes every task; parent-facing hint in the parent area help and in the App Store description recommends a protective case with a grip or strap for small children; no escalating feedback that rewards harder shaking (bead animation intensity is capped). The parent setting "Schüttelbox mit Bewegung" (Section 16.8) lets parents switch motion input off. CT2 observes Schüttelbox with every tested child. Revisit RV-17. E, F
R08 Tracing accuracy: children's finger strokes are imprecise, Apple Pencil strokes are too precise to need tolerance, palm contact creates stray input; correct attempts are rejected and wrong ones accepted. M M Tolerance corridor and stroke-order evaluation per Section 12 with a wide corridor on step 1; evaluation tested with recorded stroke fixtures (correct, wrong order, off-path, wobbly correct) captured from real children at CT2 and stored as test data; palm and stray touches ignored per Section 12; tracing never ends in a negative outcome beyond the hint ladder of Section 10. E
R09 Learning engine mis-calibration: tasks too hard (first-try rate below 70%) or too easy (above 85%), range widened too early or never, Zahlenfreunde befriended too slowly to motivate. M H Engine constants live in one configuration type in ZKLearningEngine (values per Section 9), never scattered; deterministic test vectors (Section 9); calibration simulator with synthetic learners (Section 27.3.4 and 28.3) run at M3 and after every engine change; CT observations; parent overrides for range and difficulty as a safety valve; revisits RV-05–RV-09. E, F
R10 Name or trademark conflict with "Zahlenkette" or the final name (existing marks in classes 9, 41 or 28, app with a similar name, domain unavailable), possibly surfacing after launch as a cease-and-desist. M M Name check in W1–W3 (Section 26.9.1) with three alternatives prepared; name held in one place (Section 20: APP_DISPLAY_NAME, Brand.appName, {{APP_NAME}}), audio never speaks it, bundle ID independent of the name; own trademark filing after clean searches. A forced rename after launch costs one app update plus new marketing assets, no code change. F, Law
R11 Solo-founder bus factor: illness, loss of access to App Store Connect, signing certificates or the repository; knowledge only in the founder's head. M H All decisions in DECISIONS.md, all milestones evidenced in Release/; repository hosted remotely with a second offline backup updated weekly; App Store Connect: a second trusted person with an Admin role or documented account recovery; password manager with emergency access; automatic signing in Xcode with the certificate backed up; renewal dates (developer membership, domains) in a calendar with 30-day reminders. F
R12 AI-agent code quality drift: inconsistent patterns across games, duplicated helpers instead of shared framework use, tests that assert nothing, silent spec deviations, scope creep (unrequested features), violations of forbidden rules. H M Executor rules and definition of done (Section 29); enforced by the repository checks of Section 5.13 (scripts/check-imports.sh, scripts/policy-check.swift, scripts/lint.sh, content validation) and warnings-as-errors in CI (Section 5.8.2); small pull requests with a founder review checklist; frozen ZKGameKit API after M2 with changes only via a DECISIONS.md entry; one agent per game module with a shared hand-off template (Section 29.9); architecture review at every milestone exit comparing code against Sections 5, 6 and 10. F, E
R13 Apple platform changes during development or after launch: new iOS major release changes SwiftUI, SwiftData or StoreKit behavior; new App Store requirements (minimum SDK for submissions, new screenshot sizes, new privacy or age-rating questionnaires, new Kids Category rules). H M Toolchain per Section 5 and updated to the current stable Xcode before submission; every June–September the founder installs the iOS beta on one test device and runs the full test suite and a manual smoke test; deployment target stays iOS 17.0 (revisit RV-19); App Store Connect requirements re-verified on the day of submission (Section 26.9.3). F, E
R14 SwiftData on iOS 17 behaves differently from later iOS versions (performance, predicate support, relationship handling), causing crashes or data errors only on older devices. M H Simple models and repositories per Section 7 (no exotic predicates, no reliance on features introduced after iOS 17); the test suite runs on an iOS 17 simulator runtime and on at least one physical device running iOS 17 (Section 25 device matrix); persistence round-trip and migration tests. E
R15 SwiftData plus CloudKit sync pitfalls in V1.1: duplicates (no unique constraints allowed), schema can only change additively once deployed to the CloudKit production environment, conflicting edits from two devices, account sign-out or switch, sync delays confusing parents, larger stores slowing launch. H M V1 schema already follows the CloudKit rules (Section 7) so V1.1 needs no migration; deduplication and merge rules defined in Section 7 and verified by acceptance criteria S1–S14 (Section 26.4.3); sync is opt-in and available only while premium is active (RV-16, Section 26.4.1); CloudKit schema deployed to production only after the development schema passes the tests; every future schema change is additive (new optional or defaulted fields only). E, F
R16 Performance on iPhone SE (2nd generation, oldest supported hardware) with SpriteKit scenes, audio engine and SwiftUI animations: dropped frames, audio latency, heat. M M Budgets in Section 24 measured at every milestone exit on the physical iPhone SE; SpriteKit only where Section 5 allows it; one looping ambient animation per screen (Section 19); audio buffers preloaded per round (Section 20). E
R17 Content scaling: each new game, picture and language multiplies strings, audio files and validation effort; app size grows with every drop and with each added voice language. M M Everything data-driven (Section 8) with validation in CI; audio per locale folder; audio format and bitrate per Section 20; app size budget (Section 24) checked for every release; Decision: if the download size exceeds the Section 24 budget after V1.2, evaluate Apple-hosted Background Assets for non-default voice languages only, which requires a new privacy and compliance review before adoption (no network code otherwise). F, E
R18 Privacy perception or complaint: a parent or authority questions data handling for children (GDPR Art. 8, data about children's learning). L H No data leaves the device in V1 (except system StoreKit); no accounts; privacy label "Data Not Collected"; export and deletion in the parent area; privacy policy reviewed by a lawyer; support answers privacy questions within 2 business days (the support commitment of Section 1.2) with a standard text. F, Law
R19 Parental gate bypassed by an older sibling (for example, a 9-year-old who knows multiplication), leading to unwanted settings changes. M L Purchases still require Apple ID authentication and Ask to Buy for child accounts; settings changes are harmless and reversible; data deletion requires an additional confirmation (Section 16). Accepted residual risk. F
R20 Low retention without notifications or streaks (by design), weakening the subscription. M M Daily Abenteuer, Zahlenfreunde collection and the garden give intrinsic reasons to return; parent-facing progress motivates parents to open the app; no pressure mechanics will be added (fixed commitment 28.4). Track day-7 retention in App Store Connect. F
R21 Accidental or unwanted subscription complaints and refunds (trial converts unnoticed). M M Paywall states the trial end and price clearly (Section 22); the parent area Premium section shows the renewal or trial end date and the manage-subscription entry behind the gate (Section 16); support FAQ on cancellation and Apple refunds. F
R22 Physical-device-only behaviors are not covered by simulators (haptics, shake, Pencil, audio routes, interruptions by calls), leading to field bugs. M M The list of mandatory real-device checks in Section 29.7 is executed at every milestone exit that touches the behavior. E, F
R23 Founder's time is consumed by non-code tracks (audio, illustration, legal), stalling development review and letting agent output pile up unreviewed. M M Track schedule in Section 27.5 with hard deadlines; founder capacity split of 60/40 (Section 27.1); pull requests stay small so review takes minutes; unreviewed work in progress is limited to 3 open pull requests. F
R24 Third-party trademark in a game name: 'Memory' (Ravensburger) used as the display name of the game memory; a warning letter could force a rename after launch. M M Trademark check in the name check (Section 26.9.1). The display name comes only from the string key game.memory.title, so the fallback 'Paare finden' is a one-string change without a code change or new recordings (no voice line says 'Memory', Section 21.5.11). Store copy uses 'Paare finden' from the start (Section 22.2.4). F, Law

28.1.1 Risk Response Triggers #

Trigger Action
A milestone exceeds its pessimistic estimate Apply the scope levers of Section 27.7 in order; update the plan.
CT1 or CT2 shows that fewer than 2 of 3 children can complete a round without help Stop feature work for one week; fix the instruction demo, prompts and input tolerance first.
First App Review rejection Handle per R01 within 2 working days.
Refund rate in App Store Connect above 5% of subscriptions in any month Review paywall copy and trial communication (R21), then RV-01–RV-03.
Any crash report affecting data integrity Stop feature work; PATCH release within 3 working days (Section 24 data-integrity rules).

28.2 Decisions to Revisit #

Each decision below is the binding default for V1. "Evidence" is what would cause a change; "When" is the earliest review point. Available evidence sources are limited by design (no analytics): App Store Connect (App Analytics for users who share data with developers, Sales and Trends, subscription reports, crash reports), ratings and reviews, support email, TestFlight feedback, child-test observation notes (Section 25), the engine calibration simulator (28.3), and JSON progress exports that TestFlight families send voluntarily with written consent (deleted after analysis, never stored in the repository).

ID Decision now (default) Evidence that triggers a revisit When
RV-01 Prices: 3,99 € monthly, 29,99 € yearly (DE/AT), CHF via Apple equalization (Section 17). Trial-to-paid conversion below 30%; or yearly share of new paid subscriptions below 50%; or reviews citing price in more than 20% of 1–2-star reviews; or conversion above 60% (price may be too low). After 3 months of App Store Connect subscription data, then quarterly. Price increases apply per Apple's subscription price-change rules; Decision: existing subscribers keep their price (preserve current price) at any increase.
RV-02 7-day free trial on both products (introductory offer). Trial-to-paid conversion below 30% with most cancellations on days 6–7 (families need more time to see value; candidate change: 14 days); or more than 50% of cancellations within the first 2 days while conversion stays above 30% (parents decide early; the trial length is then irrelevant and stays unchanged). After 3 months.
RV-03 Yearly product preselected and highlighted on the paywall. Refund requests or reviews mentioning surprise yearly charges exceed 3% of yearly subscriptions. After 3 months.
RV-04 Free set: Entdecken, Wie viele?, Hör hin, Was fehlt? with all steps. Day-7 retention below 15% (free tier too thin to build the habit that leads to a trial), or trial starts below 5% of first-time downloads after 3 months (free tier may already satisfy most families; response is better premium content, not a smaller free tier). Constraint: games promised as free forever are never moved to premium; revisits can only add free content. After 3 months, then every 6 months.
RV-05 Tasks per round: Vorschule 5, Die Kleinen 4 (Section 9). In CT2/CT3, more than 20% of rounds abandoned before the last task, or parents report sessions too short (support mail); simulator shows sessions outside 3–8 minutes. CT2, CT3, then 6 months after launch.
RV-06 Mastery rule: score ≥ 0.80 and attempts ≥ 6; update increments 0.15 / 0.04 / 0.10 (Section 9). Simulator: typical learner needs fewer than 3 or more than 15 sessions to master a number in the core skills; CT or support: "Mein Kind bekommt keine neuen Zahlenfreunde". End of M3 (simulator), CT3, 6 months after launch.
RV-07 Difficulty stepping: step up after 5 consecutive first-try outcomes; step down after 3 consecutive shown outcomes or 4 consecutive non-first-try outcomes (Section 9). Simulator or voluntary exports show first-try rate outside 70–85% for more than 2 consecutive sessions for the typical learner. End of M3, CT3, 6 months.
RV-08 Range widening at ≥ 80% of numbers with mean score ≥ 0.70 on recognize, name and count and ≥ 3 sessions in the range; narrowing when first-try accuracy on new numbers is < 50% over ≥ 12 attempts in ≥ 2 sessions (Section 9). Simulator shows oscillation (widen, narrow, widen within 10 sessions) or widening for the typical learner later than session 15. End of M3, 6 months.
RV-09 Leitner intervals [0, 1, 2, 4, 7, 14] days (Section 9). Voluntary exports show mastered numbers dropping below 0.80 on review in more than 30% of reviews (intervals too long) or review tasks exceeding the 20% share because too many items are due (intervals too short). 6 months after launch.
RV-10 Kids Category age band "5 and Under". Reason: the core audience is 2–6 and the largest part of it is under 6; the band determines where the app appears in the Kids Category. App Review feedback, or evidence that the Vorschule audience (5–6) finds the app mainly through search while browse traffic is low; App Store Connect source-type data. Candidate change: "6–8" is not chosen because the content ends at 20 and would under-serve 7–8-year-olds. 6 months after launch.
RV-11 Daily time limit default 20 minutes per profile (Section 15). Support mail from more than 5 families asking for a different default, or CT observations of children stopped mid-engagement repeatedly. 6 months.
RV-12 Break nudge once after 8 continuous minutes, shown only after a round's S-08 and never mid-round (Section 15.9). Parent feedback that it is annoying or ignored; CT3 observation. CT3, 6 months.
RV-13 Abenteuer: 3 rounds, target 5 minutes, one bonus per day (Section 9, 14). CT3 duration measurements outside 4–7 minutes. CT3.
RV-14 Maximum 5 profiles per device (Section 15). More than 10 support requests for more profiles (large families, day-care parents). 6 months; also at Kita-edition planning (Section 26.7.6).
RV-15 Parental gate: written multiplication with factors 6–9 in number words, numeric keypad, 3 wrong → 30-second cooldown (Section 16). App Review rejection of the gate; parent complaints (more than 5) that it is too hard; evidence of children bypassing it. Candidate change (fallback order of Section 23.14): reply in Resolution Center first; if still rejected, two-digit × one-digit multiplication with factors 12–19 × 3–9, written in number words. On any rejection; otherwise 6 months.
RV-16 V1.1 sync is opt-in (toggle "Mit iCloud synchronisieren" off by default) and available only while premium is active; on lapse, sync stops and local data stays (Sections 26.4 and 17.4). More than 70% of parents who open Geräteeinstellungen after V1.1 enable it (visible only indirectly through support mail and CloudKit Console usage); then consider a one-time suggestion card in the parent area. 3 months after V1.1.
RV-17 Schüttelbox motion input is on by default (parent setting "Schüttelbox mit Bewegung", Section 16.8), with the button fallback always visible (Section 12.5). Any report of a dropped or thrown device in CT2/CT3 or support; then consider defaulting the motion setting to off. CT2, CT3, every support incident.
RV-18 V1.2 English variant is British English (en-GB voice and spelling). Storefront mix of downloads in the first 6 months shows non-DACH English-speaking demand concentrated in the US; then choose en-US. Before V1.2 recording.
RV-19 Deployment target iOS/iPadOS 17.0 (Section 5). Fewer than 5% of App Store Connect sessions on iOS 17, or a needed platform feature (for example in SwiftData or StoreKit) requires a newer version. Every September after the new iOS release.
RV-20 Working title "Zahlenkette" as the app name. Name check (Section 26.9.1) finds a conflict. End of W3 (Section 27.5.3); after any legal notice.
RV-21 Decoration prices 10 / 25 / 50 / 100 stars and star amounts per round (Section 14). CT or voluntary exports: a child cannot afford any decoration after the first session, or affords the whole catalog within 14 days of daily play. CT3, 3 months.
RV-22 Launch storefronts DE, AT and CH only (Sections 1.2 and 26.9.3); LI, LU and all other storefronts stay off. Demand evidence from other German-speaking users (support mail); V1.2 English release. At V1.2.
RV-23 No App Store preview video at launch. Product page conversion (App Store Connect) below the category benchmark shown in App Store Connect's peer groups, if available. First content drop.
RV-24 CI on Xcode Cloud (Section 25.20). Included compute hours exceeded in 2 consecutive months. Monthly.
RV-25 TTS fallback allowed for at most 5% of a content drop's new lines, never for number words (Section 26.6). Parent reviews mention robotic voice. Each content drop.
RV-26 Final illustrations human-made only (R05). Changes in copyright law or Apple policy clarifying generated imagery, and illustrator unavailability. Yearly.
RV-27 Every drag has the tap alternative of Section 10.9.5 at every level: in games a single tap on an item sends it to the active target; in the garden tap-then-tap placement works in addition to drag (R06). CT2 shows that single taps send items unintentionally for older children (accidental selections); then consider restricting the single-tap send to the Die Kleinen level. CT2.
RV-28 No lifetime purchase; the auto-renewable subscription is the only purchase type (Section 17.13). Default: no. Sustained reviews or support mail asking for a one-time purchase, and subscription conversion below the RV-01 thresholds. Any change is a new product decision by the founder, recorded in DECISIONS.md together with a revision of Section 17. 6 months after launch.
RV-29 Display numerals use SF Pro Rounded glyphs while Nachspuren uses its own German print glyphs; small glyph differences (for example the open or closed top of the 4) are accepted (Section 19.3.2). Child testing (Section 25) shows children fail to recognize a numeral in one of the two forms. CT2, CT3.
RV-30 No handedness setting and no left-hand layout mirror in V1; in landscape the answer area is on the right (Section 19.15). Default: no mirror. Child testing with left-handed children shows the answering arm hides the task area. CT3, 6 months.
RV-31 No de-CH variant in V1; Swiss users see "ß" in parent-area text (Section 20.17.1). Support mail or reviews from Swiss parents about the spelling. 6 months after launch.
RV-32 V1.2 English keeps the German numeral stroke definitions for Nachspuren (Section 20.18). English usability tests show confusion with the numeral shapes or stroke order. Before V1.2 release.
RV-33 No star-ledger compaction in V1; the ledger is append-only (Section 24.10.2). Xcode Organizer disk-usage or disk-write metrics or support requests show storage concerns; any compaction must keep the append-only merge property needed for V1.1 sync (Section 7). 6 months after launch.
RV-34 No per-profile voice language; text and voice follow the system per-app language (Section 26.5). Default: no. Support mail from bilingual families after V1.2. V2 planning.
RV-35 Kita edition business model: decided at V2 planning; default an auto-renewable subscription, no paid-upfront or one-time sale (Section 26.7.6). Kita interest (support mail, direct requests) and the Apple School Manager rules for subscriptions verified at that time. V2 planning.

28.3 Calibration Simulator Learner Profiles #

The calibration simulator (Section 27.3.4) is the primary evidence source for RV-05–RV-08 before launch. It runs the real ZKLearningEngine with a seeded random number generator (SplitMix64) and an injected clock for 30 simulated sessions (one simulated session per simulated day, one Abenteuer plus one extra round per session). This section owns the learner profiles and the acceptance values; Sections 25 and 27.3.4 cite them.

Learner Initial first-try probability per number Gain per practice task on that number Ceiling Level
slow 0.35 for numbers 1–3, 0.20 above +0.015 0.90 littleOnes
typical 0.55 for 1–5, 0.40 for 6–10, 0.25 for 11–20 +0.03 0.95 vorschule
fast 0.80 for 1–10, 0.60 for 11–20 +0.05 0.98 vorschule

Rules: a simulated answer is first-try correct with the learner's current probability for that number; otherwise the second attempt succeeds with probability 0.7, the third with 0.7, else the outcome is shown. The probability only increases (no forgetting model in V1). Report fields: first-try rate per session, number of mastered (skill, number) pairs per session, range stage per session, step per game per session, sessions until each Zahlenfreund would be befriended (using the simulator's per-game distribution). Acceptance for the default constants: the typical learner's first-try rate is within 70–85% from session 3 onward, widens from r10 to r20 between sessions 8 and 20, and befriends at least 5 Zahlenfreunde within 20 sessions; the slow learner never narrows more than twice in 30 sessions.

28.4 Fixed Commitments (Not Subject to Revisit) #

These are product principles, not tunable defaults. Changing any of them requires a new product decision by the founder, a revision of Sections 2, 14, 17 and 23, and an entry in DECISIONS.md; they are never changed to fix a metric.

  • The four free games stay free forever with all their steps.
  • No ads, no analytics or tracking SDKs, no third-party SDKs, privacy label "Data Not Collected".
  • No push or local notifications.
  • No streaks, pressure timers, leaderboards, loot boxes, random rewards, time-limited offers or fake scarcity.
  • Nothing a child can buy; no price, product or purchase control in child mode.
  • Earned stars, Zahlenfreunde, stickers and garden items are never taken away, including after a subscription lapse.
  • Mistakes are never punished (Section 4 error philosophy).
  • No child accounts, no own backend, no microphone input.

29. Executor Instructions #

This section is addressed to the AI coding agent (the "executor") that builds the app from this specification, and to any additional agents working in parallel. It owns the read order, the build order at task level, the hard rules, the definition of done, the ambiguity procedure, the placeholder-asset strategy, the real-device verification list, and the multi-agent working model. The founder reviews and merges all work.

29.1 Role and Scope #

  • You implement exactly what this document specifies for V1 (Section 26.2). You do not implement anything listed as cut in Section 26.3, even if it seems easy or helpful.
  • Each concern has one owning section (for example, mastery math in Section 9, data models in Section 7, colors in Section 19). When two sections appear to disagree, the owning section wins (29.5).
  • You work in small, reviewable increments (29.10) on the milestone the founder has opened (Section 27). You do not start a later milestone before the current one's exit criteria are checked, except for tasks the founder explicitly assigns to a parallel stream (29.9).
  • You report after every task: what changed, which tests ran, what evidence was produced, and any decision you recorded in DECISIONS.md.

29.2 Read Order #

Before writing any code, read these sections completely and in this order:

Order Section Why before any code
1 Section 1 Before You Start Executor decisions and defaults (bundle ID, team, prices, voice).
2 Section 5 Technology Stack and Architecture Toolchain versions (the only place versions are stated), package graph, dependency rules, concurrency model.
3 Section 6 Conventions and Coding Standards Naming, folder layout, style, error handling, logging, test naming.
4 Section 7 Data Model and Persistence Every model and the CloudKit-compatibility rules you must never break.
5 Section 8 Content System and Data Files Content schemas and validation; why no display text or audio ID is hardcoded in code.
6 Section 9 Adaptive Learning Engine The engine API and math that every game feeds.
7 Section 10 Shared Game Framework The contract every game module implements.

Then, before the first game, read Section 4 (didactics), Section 19 (design system and accessibility) and Section 20 (audio and localization). Before working on any specific area, read its owning section completely: the game's section (11, 12 or 13) before that game; Section 14 before rewards; Section 15 before profiles and sessions; Section 16 before the parent area; Section 17 before StoreKit; Section 23 before any compliance-relevant change; Section 24 and Section 25 before M9. Sections 2 and 3 give product intent and should be read once at the start of M0.

When a task touches a section you have not read in the current working session, read it again before changing code. Do not work from memory of an earlier session.

29.3 Build Order #

Follow the milestone order of Section 27 (M0 to M10). Within a milestone, build in this order unless the founder assigns otherwise:

  1. Types and protocols (public API) with documentation comments.
  2. Unit tests that express the specified behavior (test vectors, edge cases, validation errors).
  3. Implementation until the tests pass.
  4. Content files and String Catalog entries (German) for the feature.
  5. UI, bound to view models or state types, with accessibility identifiers and labels.
  6. UI tests for the critical path of the feature.
  7. Layout check on iPhone SE (portrait and landscape) and iPad (portrait and landscape).
  8. Evidence for the milestone exit criteria in Release/milestones.md.

Package build order in M0–M3 follows the dependency graph from the leaves upward: ZKCore → ZKContent → ZKDesignSystem → ZKAudio → ZKGameKit → first games → ZKLearningEngine → ZKPersistence → engine wiring. ZKRewards in M4, game modules in M2, M3, M5 and M6. ZKParentArea starts in M3 with the parental gate (ZKParentArea/Gate/, needed by first-launch setup) and is completed in M7 together with ZKStore.

29.4 Hard Rules #

These rules have no exceptions. A change that violates one of them is not mergeable, regardless of tests passing. Rules marked "(CI)" are enforced by the scripts introduced in M0 (Section 27.3.1); the others are enforced by review.

# Rule
1 Never add a third-party dependency: no Swift packages from outside the repository, no CocoaPods, no Carthage, no vendored binary frameworks, no copied third-party source files. Only Apple SDK frameworks and the targets of Packages/ZahlenketteKit. (CI)
2 Never add network code. No URLSession, NWConnection, sockets, web views loading remote content, remote images, remote fonts, remote configuration, or CloudKit in V1. The only network activity is the one StoreKit performs itself. External links (privacy policy, support, imprint) are opened only through the gated flow defined in Section 16. (CI)
3 Never add analytics, telemetry, crash-reporting SDKs, advertising identifiers, tracking, or any code that sends data about usage anywhere. Logging uses os.Logger and stays on the device (Section 6). (CI for identifiers)
4 Never show a price, a product name, a purchase or restore control, a subscription status or an external link in child mode. Locked games show only the lock badge and the "Frag deine Eltern" flow (Section 17).
5 Never hardcode display strings or audio IDs in game code or UI code. Display text comes from String Catalogs (Localizable.xcstrings for app UI, Content.xcstrings for content lines); audio IDs, prompt keys, hint keys, step parameters and number data come from the content files of Section 8. The only permitted literals are string-catalog keys referenced through generated or typed accessors defined in Section 20, and identifiers that are not user-visible.
6 Never make a game depend on another game. A game target depends only on ZKGameKit (Section 5). Behavior that two games need is added to ZKGameKit through the framework change procedure (29.9.4). Games never import ZKPersistence or ZKStore. (CI)
7 Keep the engine pure. ZKLearningEngine depends only on ZKCore; it contains no SwiftUI, UIKit, SwiftData, AVFoundation, file I/O, singletons, global mutable state, direct Date() calls (use AppClock) or unseeded randomness (use the injected RandomNumberGenerator). Every public engine function is deterministic for a given input, clock and generator. (CI for imports)
8 Keep versions per Section 5. Do not raise the deployment target, change the Swift language mode, or disable strict concurrency checking. Do not state versions in code comments or documents other than Section 5's list.
9 Never break the CloudKit rules of Section 7: no unique attributes, every stored property optional or with a default, every relationship optional. Never delete or rename a persisted property after it has shipped; add new ones instead. (CI for unique attributes)
10 Never hardcode 20 as the number space upper bound. Use NumberSpace.maxNumber and NumberSpace.minNumber in ZKCore, or the active RangeStage (Section 26.7.1). (CI)
11 Never add notifications, streaks, timers that pressure the child, random rewards, time-limited items, leaderboards or anything the child can buy (Section 14).
12 Never make the app speak the app name, and never write the app name as a literal in localized strings; use Brand.appName (Section 20).
13 Never collect or store personal data beyond the fields defined in Section 7 (no birthdate, no photo, no real name requirement, no device identifiers).
14 Never use private APIs, undocumented entitlements or method swizzling.
15 Never disable, skip or weaken a test to make the build green. If a test is wrong because the specification says otherwise, fix the test and cite the owning section in the commit message.
16 Never suppress a compiler warning (no @preconcurrency imports, nonisolated(unsafe) or @unchecked Sendable without a DECISIONS.md entry explaining why it is sound).
17 Never add features, screens, settings or content beyond the specification. Ideas go into DECISIONS.md as entries with status "proposed (needs founder)".
18 Never add a way to bypass the parental gate, in any build configuration and for any purpose, including UI tests (Section 25.10).

29.5 Handling Ambiguity #

When the specification is unclear, incomplete or seems contradictory:

  1. Find the owning section of the concern (Section 1 to Section 30 ownership as stated in each section's opening paragraph). The owning section's statement wins over any mention elsewhere.
  2. If the owning section answers the question, follow it. If another section contradicts it, follow the owner and record the contradiction in DECISIONS.md so the founder can correct the document.
  3. If no section answers the question, choose the option that is (in order of priority) safest for the child, most private, calmest, simplest to test, and most consistent with existing patterns in the codebase. Implement it and record it.
  4. Stop and ask the founder instead of deciding only when the choice would: change a value stated in any section, add a user-visible feature or screen, affect App Store compliance or privacy, affect prices or monetization, or change the public API of ZKGameKit, ZKLearningEngine or ZKPersistence after its freeze. Continue with other tasks while waiting.

DECISIONS.md lives at the repository root. It uses one entry format, the one of Section 6.12.1, restated here so the executor has it at hand. Entries are append-only: an entry is never edited in substance; a changed decision gets a new entry that supersedes the old one. Each entry starts with a bold title line **D-NNNN: <title>** (four-digit running number) followed by these fields:

**D-0042: Hint timing when voice is off**

- Date: 2026-10-14
- Status: accepted
- Spec: 10 (hint ladder), 20 (sound-off visual path)
- Context: Section 10 defines the hint ladder with voice; with all sound off the delay before the visual hint is not stated.
- Decision: use the same delay as with voice; the visual hint appears after the same idle time.
- Consequences: no new setting; UI test HintLadderSoundOffTests added.
- Author: executor (agent name or stream), reviewed by founder on <date>

Allowed status values: default applied, answered by product owner, accepted, proposed (needs founder), revisited, spec discrepancy, superseded by D-NNNN. A contradiction between sections found under step 2 above is recorded with status spec discrepancy; a change request under 29.9.4 with status proposed (needs founder). Commit messages reference entries by their ID (Section 6.11.2). The founder periodically folds accepted decisions back into the specification.

29.6 Placeholder Assets and the Developer Menu #

Real voice recordings and illustrations arrive during development (Section 27.5). Until then the app runs on placeholders that require no code change when the real asset arrives.

29.6.1 Audio Placeholders #

  • No placeholder audio files are generated or committed. A missing voice file resolves to the TTS fallback at runtime (AVSpeechSynthesizer, de-DE, behavior per Section 20), using the text of the line's string key.
  • Missing SFX play nothing; missing music plays nothing. Neither is an error in Debug or Profile builds; in the Release archive the release asset check (29.6.4) fails on any missing launch asset.
  • When a real file is added at its resolved path (Resources/Audio/de/<audioId>.m4a or Resources/Audio/common/<audioId>.m4a), it is used automatically.

29.6.2 Illustration Placeholders #

  • Every illustration is looked up by its asset name (flat scheme <category>_<slug>[_<state>] per Section 6.4.3, for example avatar_fuchs, friend_07_idle, deco_tulpe; the complete list is in Section 21) through one lookup function in ZKDesignSystem. If the asset catalog has no image of that name, the function returns a placeholder drawn in SwiftUI: a rounded shape in a neutral color from the design system with the first letters of the asset name and a simple silhouette per asset kind (circle for avatars and Zahlenfreunde, square for decorations, rounded rectangle for scenes and pictures). A Zahlenfreund placeholder also draws its dot pattern (groups of five, first five solid red, next five blue Lochperlen) so didactic behavior is testable before final art.
  • Placeholders are never committed as image files and never mixed with final art in the asset catalog.
  • A Release build that meets a missing asset at runtime still shows the placeholder (never crashes) and logs an error through os.Logger.

29.6.3 Developer Menu #

Availability and access are owned by Section 5.10: the developer menu is compiled only into Debug builds (#if DEBUG) and is reached only through the row "Entwicklermenü" at the bottom of the parent dashboard S-18, after the parental gate. There is no other entry point and no launch argument that opens it. The Profile configuration (Release optimization plus UITEST_HOOKS, used for UI and performance tests and never distributed) and the Release configuration (TestFlight and App Store builds, including the CT3 release candidate) contain no developer menu (Section 5.8.1). UI tests reach deterministic states through the launch arguments of the single registry in Section 5.9, never through the developer menu.

The complete panel list is the single list in Section 5.10.2. The placeholder strategy of this section relies on these panels of that list:

Panel Function
Asset status Counts and lists: voice audio IDs total / recorded / resolved to TTS; SFX and music present / missing; illustration names total / final / placeholder. Filter "only missing". It reports the same numbers as the release asset check (29.6.4).
Platzhalter markieren Toggle (default on): draws a small grey "P" badge on every placeholder illustration and plays voice placeholders with a lower TTS pitch so testers can hear which lines are not final.
Komponentengalerie The component gallery of M1 (Section 27.3.2), including the wide and compact arrangements of ZwanzigerfeldView and BeadChainView.
Entitlement override "StoreKit" (real), "Kostenlos erzwingen", "Premium erzwingen". Before M7 the real service does not exist and "Kostenlos erzwingen" is the effective default.
Time travel Offsets the AppClock (for example +1 hour, +1 day, +7 days) or resets it; used to test time limits, Abenteuer per day and spaced repetition. The daily time-limit lock follows the trusted day clock of Section 15.12.
Engine state inspector Read-only view of the raw mastery table, range stage, per-game steps and the last task mix for the active profile.
Demo data Creates and deletes fixture profiles (the same presets as the UI-test seeds of Section 5.9).
Content validation report Runs content validation (Section 8) on the bundled content and lists findings.

29.6.4 Release Asset Check #

scripts/check-assets.sh (listed with all repository scripts in Section 5.13) compares every voice audio ID referenced by the content files for locale de, every SFX and music ID, and every illustration name referenced by content files and by the asset-name list of Section 21 against the files and asset catalogs in the repository. It prints the same numbers as the developer menu's asset status. On pull requests it reports without failing. In the Release Xcode Cloud workflow (Section 25.20.3), which archives the Release configuration for App Store submission, it fails if any launch asset is missing (Section 26.9.4 and 26.9.5). Content-drop TTS exceptions (Section 26.6) are listed explicitly in the script's allow-list with a DECISIONS.md reference.

29.7 Real-Device Verification #

Simulators do not reproduce these behaviors. Verify them on physical devices from the device matrix of Section 25 at the milestone exit that introduces or changes them, and again in M9. Record device model, OS version and result in Release/milestones.md.

Behavior Device(s) Check
Layout and touch targets iPhone SE (2nd or 3rd generation) Every child screen in portrait and landscape: nothing clipped, every child control at least 60×60 pt and reachable by a small finger (interactive Zwanzigerfeld and bead chain in the compact arrangement); task numerals at least 44 pt (child.numeral).
Performance budgets iPhone SE and the oldest iPad in the matrix Cold launch, frame rate in SpriteKit scenes (Wie viele? moving stage, Schüttelbox), memory, 30-minute play without heat warnings (Section 24).
Audio Any iPhone, any iPad Silent switch behavior and ringer volume per Section 20; wired and Bluetooth headphones connect and disconnect mid-prompt (on disconnect the voice line stops, the round does not pause and the speaker button stays available, Section 20.12); voice latency after a tap feels immediate; music and SFX toggles; TTS fallback voice quality.
Interruptions iPhone Incoming call, Siri, Control Center, notification banner from another app, lock and unlock during a task and during a celebration: interruptions shorter than 3 s resume automatically, longer ones show the pause overlay (Section 10.12.3); the task resumes correctly and time counting follows Section 15.
Shake iPhone SE, one large iPhone, one iPad Gentle shake threshold triggers reliably when held in two hands; no trigger from walking or placing the device on a table; button fallback always works (Section 12).
Tracing iPhone SE (finger), Pencil-capable iPad (finger and Apple Pencil) Stroke recognition tolerance per Section 12; palm resting on the iPad does not create strokes; Pencil hover (where supported) does not trigger strokes.
Haptics iPhone Feedback matches the haptics map of Section 19.10 (no haptic for errors, no per-tap tile haptic, light impact on drop); haptics toggle off disables all haptics.
StoreKit iPhone with a sandbox Apple ID; TestFlight build Purchase, trial, cancel, expiration, restore, Ask to Buy on a child account, Family Sharing member access, offline launch with cached entitlement (Section 17).
Parental gate iPhone and iPad Numeric keypad usable, cooldown after 3 wrong answers, gate expiry after more than 5 minutes in background.
Accessibility iPhone, iPad VoiceOver in the parent area; Reduce Motion; Bold Text and largest Dynamic Type in the parent area; Button Shapes; Guided Access session with the app (children's setups often use it).
Storage and data iPhone with nearly full storage App launches and saves progress; data export share sheet works.
Orientation changes iPhone and iPad Rotating mid-task keeps the task state, beads and dragged items (Section 18).
iPad multitasking iPad Split View and Slide Over widths render a usable layout or the behavior defined in Section 18.

29.8 Definition of Done #

A task is done only when all of the following hold. The executor lists the checked items in its report.

  • The behavior matches the owning section; every rule, default, edge case and error state stated there for this task is implemented.
  • Unit tests (Swift Testing) cover the specified behavior, including edge cases and invalid input; UI tests (XCUITest) cover the critical path when the task adds or changes UI.
  • All tests pass locally and on CI.
  • Content validation (Section 8) passes; new strings exist in German in the correct String Catalog; new audio IDs are declared in the content files (Section 8) and listed with identical ID and text in the inventory of Section 21.
  • Every interactive element added or changed has an accessibility identifier (format <screen>.<element> per Section 6.10) and, where required by Section 19, an accessibility label.
  • iPhone SE layouts checked in portrait and landscape (simulator screenshots attached to the pull request), plus one iPad layout in each orientation.
  • Zero compiler warnings under Swift 6 language mode with complete strict concurrency checking; no new warning suppressions.
  • scripts/lint.sh (which runs scripts/check-imports.sh), scripts/policy-check.swift and the content validation pass (scripts per Section 5.13).
  • Sound-off path verified for any feature that plays audio (Section 20).
  • Reduce Motion verified for any feature that animates (Section 19).
  • Public API has documentation comments.
  • Any decision made is recorded in DECISIONS.md.
  • The pull request description states the owning sections, what was tested, and screenshots or recordings.

29.9 Multi-Agent Work #

29.9.1 Streams and Roles #

The founder may run several agents in parallel. Each agent works on its own branch and owns a fixed set of paths.

Role When Owns (may edit) Must not edit
Integrator Always one, from M0 App target (Zahlenkette) including the GameRegistry+All registration file, Config/, scripts/, CI workflows, merges into main, final resolution of String Catalog merge conflicts Game internals, engine math
Framework agent M1–M2, later only by change request ZKCore, ZKContent, ZKDesignSystem, ZKAudio, ZKGameKit Game targets, app target
Engine agent M3, later only by change request ZKLearningEngine, its tests and the calibration simulator Everything else
Persistence and rewards agent M3–M4 ZKPersistence, ZKRewards, profile and session features Engine math, games
Game agent (one per game) M2, M3, M5, M6 Game<Name> target and its tests, Resources/Content/games/<gameId>.json, the game's own content files (for example dotpictures/ for Punkt zu Punkt), string keys with the game's prefix (naming per Section 6) Other games, ZKGameKit, shared content files other than new keys
Parent and store agent M7 ZKParentArea, ZKStore Games, engine

Decision: at most 3 agents run concurrently (integrator plus 2 feature agents) so the founder can review every pull request within one working day.

29.9.2 Rules for Parallel Work #

  • The ZKGameKit public API is frozen at the end of M2 (Section 27.3.3). Game agents build only on the frozen API.
  • Game modules are independent: a game agent never waits for another game agent.
  • Shared files (Content.xcstrings, Localizable.xcstrings, prompts.json) are edited only by adding new entries, never by reordering or reformatting existing ones. The integrator merges pull requests one at a time and rebases the next branch on the updated main before merging.
  • Each game pull request includes: the game target, its tests, its content files, its strings and prompt entries, and evidence of both orientations on iPhone SE and iPad.
  • Games are registered in the app target's GameRegistry+All file (the GameRegistry type lives in ZKGameKit, Section 5.4.3) only by the integrator, in a separate small pull request after the game's pull request is merged.

29.9.3 Hand-Off Template for a Game Agent #

The founder or integrator starts each game agent with this hand-off (filled in for the game):

You are the game agent for <German game name> (GameID raw value: <gameId>, target Game<Name>).
Read completely before coding: Sections 1, 4, 5, 6, 8, 9 (engine API only), 10, 19, 20, 29, and the game's own specification in Section <11|12|13>.
You may edit only: Packages/ZahlenketteKit/Sources/Game<Name>/, Packages/ZahlenketteKit/Tests/Game<Name>Tests/, Resources/Content/games/<gameId>.json, <game-specific content paths>, and new string keys with the prefix defined for this game in Section 6.
Your target depends only on ZKGameKit. Do not import any other game, ZKPersistence or ZKStore. Do not change ZKGameKit; if you need a framework change, write a change request in DECISIONS.md with status "proposed (needs founder)" and continue with the parts that do not need it.
Implement all 3 difficulty steps, all prompts and hints by content ID, the visual instruction demo, sound-off behavior, Reduce Motion behavior, accessibility identifiers, and the acceptance criteria of the game's section.
Definition of done: Section 29.8. Work in pull requests of at most about 400 changed lines of Swift (content and string files excluded). Report after each pull request: what changed, tests run, evidence, decisions recorded.
Current branch base: main at <commit>. Milestone: <M2|M3|M5|M6>.

29.9.4 Framework Change Procedure #

When a game or feature needs a change in ZKGameKit, ZKLearningEngine or ZKPersistence after its freeze:

  1. The requesting agent writes a DECISIONS.md entry with status "proposed (needs founder)": the need, the proposed API change, affected modules.
  2. The founder accepts or rejects.
  3. If accepted, the owner agent (framework, engine or persistence) implements it in its own pull request, additive where possible (new API instead of changed API), with tests.
  4. After the merge, the integrator informs the other active agents to rebase.

29.10 Small, Reviewable Increments #

Topic Rule
Branches One branch per task, named per Section 6.11.1 (<type>/<milestone>-<slug>, for example feat/m5-blitzblick-step2).
Pull request size Decision: at most about 400 changed lines of Swift per pull request, content and string-catalog changes excluded. Larger work is split into vertical slices (for example: game step 1 end to end, then step 2, then step 3).
Commits Small, each builds and passes tests. Message format per Section 6.11.2 (Conventional Commits in English, imperative summary of at most 72 characters, Spec: trailer citing the owning sections, Decision: trailer with DECISIONS.md IDs where applicable).
Order within a feature API and tests first, then implementation, then UI (29.3).
Main branch Always green and runnable. Nothing is merged that breaks CI.
Work in progress Never more than 3 open pull requests per agent.
Review checklist for the founder Owning section cited; definition of done checked; no hard rule violated (29.4); screenshots in both orientations; no new warnings; no unrequested scope.
Demo cadence From the end of M2, an internal TestFlight build at least weekly (Section 27.2), with release notes listing the merged pull requests.

29.11 What the Executor Reports #

After each task, reply to the founder with:

  1. Task and owning sections.
  2. Pull request or branch name and commit range.
  3. Tests added and their results; CI status.
  4. Evidence (screenshots, recordings, reports) and where it is stored.
  5. DECISIONS.md entries added (IDs and one-line summary each), and any proposal that needs the founder.
  6. Anything blocked, with the specific missing information.

30. Glossary #

German terms appear in this document in their German form because they are UI strings, spoken lines, game names or established terms of German early-mathematics didactics. This glossary gives the English meaning and a one-line note on how the term is used in the app. Technical terms follow in 30.6. Where a term has an owning section, it is named in the note.

30.1 Didactic Terms #

German term English meaning Note
Zwanzigerfeld Twenty-frame Grid of 2 rows × 10 places, each row split 5 and 5; row 1 holds 1–10, row 2 holds 11–20. The central representation of the app (Sections 4, 19). An interactive field in a window narrower than 714 pt uses the compact arrangement (rows of five, 1.5× gap between the two tens) so every cell keeps the 60 pt child touch target; display-only fields stay 2 × 10, scaled.
Zehnerfeld Ten-frame One row of the Zwanzigerfeld (5 and 5); used for the range 1–10.
Fünferfeld Five-frame A row of five places; used in the range 1–5 for Die Kleinen.
Hunderterfeld Hundred-frame 10×10 field with the same five-structure; V2 only (Section 26.7.1).
Rechenrahmen Bead frame (German school abacus) Two rows of 10 beads, each row 5 red and 5 blue; the app's bead chain follows its colors (Section 4).
Rechenkette / Perlenkette Counting bead chain A string of beads grouped in fives with alternating colors; the visual origin of the working title and of Was fehlt?.
Kraft der Fünf Power of five Seeing a quantity as five plus a remainder (7 = 5 + 2) without counting one by one (Section 4).
Fünferbündelung Grouping in fives The rule that quantities are always shown in groups of five, separated by a visible gap (Section 19).
Lochperle Bead with a hole The app's color-blind-safe second bead shape: a blue round bead with a clear white center ring, used for the second group of five (Section 19).
Zahlzerlegung Number decomposition, number partition Splitting a number into two parts (7 = 3 + 4); the skill decompose, practised in Schüttelbox (Section 4, Section 12). V1 covers 2–10 for Vorschule and 2–5 for Die Kleinen; splits of 11–20 follow in a content drop (Section 17.12).
Schüttelbox Shaker box Kindergarten material: a box with a divider in which beads are shaken and fall into two halves; digital version in the game of the same name.
Zahlenstrahl Number line A line with marked, evenly spaced numbers; used in Froschsprung, where the start is an unlabeled bank (never labeled "0") and the landmarks 5, 10, 15 and 20 are labeled (Sections 4.8, 13.1).
Zahlenreihe Number sequence, counting sequence The ordered sequence 1, 2, 3, ...; the basis of the skill order.
Vorgänger / Nachfolger Predecessor / successor The number one less / one more; used in Was fehlt? and Froschsprung.
Nachbarzahlen Neighbor numbers Predecessor and successor of a number together.
Simultanerfassung Subitizing Recognizing a small or well-structured quantity at a glance without counting; the skill subitize (Section 4).
Quasi-simultane Erfassung Conceptual subitizing Recognizing larger structured quantities (for example 8 as 5 and 3) by combining seen parts.
Zählen / Abzählen Counting / counting objects Determining how many objects there are by one-to-one counting; the skill count.
Rückwärtszählen Counting backwards Counting down; used in the later steps of Was fehlt? (Section 11).
Mengenvergleich / Vergleichen Comparing quantities / comparing Deciding which is more, less or equal; the skill compare.
Ziffer Digit, numeral The written symbol (for example "7"); recognized in the skill recognize.
Zahlwort Number word The spoken or written word (for example "sieben"); linked in the skill name.
Menge Quantity, set The amount of objects; the third of the three representations.
Drei Darstellungen Three representations Every number is shown as numeral, word and quantity, linked together (Section 4).
Würfelbild Dice pattern Standard pip arrangement of a die; used in Blitzblick, Memory and Mehr oder weniger.
Fingerbild Finger pattern A number shown with fingers, five on one hand first; used in Blitzblick.
Strukturierte / unstrukturierte Menge Structured / scattered quantity Structured quantities (rows of five, dice patterns) come before scattered ones (Section 4).
Druckschrift Print script Upright printed letter and numeral forms; the reference for numeral shapes in Nachspuren (Section 12).
Grundschrift Grundschrift (basic school script) A script taught in many German primary schools since the 2010s; its numerals match print-style numerals closely. Section 12 defines the shapes the app uses.
Schreibrichtung / Strichreihenfolge Writing direction / stroke order The order and direction of strokes when writing a numeral; shown with guide arrows in Nachspuren.
Zehnerübergang Crossing ten (bridging through ten) Addition or subtraction across 10 (for example 8 + 5); V2 only.
Fehlerfreundlichkeit Error-friendliness The principle that mistakes are never punished: the child hears a hint and tries again (Section 4).
Stufe Level The child level: "Die Kleinen" or "Vorschule" (Level, Section 15), for example in the parent-area setting "Stufe" and the dialog "Stufe ändern?". Difficulty steps are called "Leicht", "Mittel" and "Schwer", never "Stufe".
Leicht / Mittel / Schwer Easy / medium / hard The three difficulty steps step1, step2, step3 of every game, and the labels of the parent's difficulty cap (Section 9).

30.2 Education System and Audience Terms #

German term English meaning Note
Kita (Kindertagesstätte) Day-care center, nursery Institution for children before school; Kita features are out of scope for V1 (Section 26.3).
Kindergarten Kindergarten Often used synonymously with Kita for ages 3–6.
Vorschule Preschool year The final year(s) before school entry at about age 6; also the name of the older child level vorschule (ages 4–6).
Vorschulkinder Preschool children Children in the year before school.
Die Kleinen The little ones The younger child level littleOnes (ages 2–4), starting with the range 1–5.
Grundschule Primary school School from age 6; the app prepares for its first year.
Eltern Parents The adult users of the parent area.
Erzieherin / Erzieher Early-years educator Kita staff; potential users of the optional Kita edition in V2.

30.3 Game Names #

German name GameID English meaning Note
Entdecken entdecken Discover, explore Interactive Zwanzigerfeld; free explore records no progress, the "Zähl mit" mode does (Section 11). Free.
Zähl mit – Count along The guided counting mode inside Entdecken.
Wie viele? wie_viele How many? Counting objects from structured rows of five to scattered to moving items (Section 11). Free.
Hör hin hoer_hin Listen closely Hear a number and tap the matching numeral (Section 11). Free.
Was fehlt? was_fehlt What is missing? Fill the gap in a bead chain; later counting backwards (Section 11). Free.
Blitzblick blitzblick Flash look, quick glance Dots, dice or finger patterns shown for a moment, then "how many?"; the display time is not a response timer (Section 12). Premium.
Mehr oder weniger mehr_weniger More or less Which side or number is bigger, smaller, or are they equal (Section 12). Premium.
Nachspuren nachspuren Trace Trace numerals in the correct stroke order with guide arrows; finger primary, Apple Pencil optional on iPad (Section 12). Premium.
Schüttelbox schuettelbox Shaker box Shake the device (or press the button) so beads fall into two halves; find the split (Section 12). Premium.
Froschsprung froschsprung Frog jump A frog jumps along the number line to a target; later "2 mehr", "1 weniger" (Section 13). Premium.
Fütter das Zahlenmonster zahlenmonster Feed the number monster Feed exactly N items into the monster's mouth by dragging, or by tapping an item (each tap feeds one item or pack) (Section 13). Premium.
Memory memory Memory (pairs game) Match numeral, quantity and dice pattern cards (Section 13). Premium.
Punkt zu Punkt punkt_zu_punkt Dot to dot Connect dots in order to reveal a picture that goes into the sticker album (Section 13). Premium.
Kaufladen – Toy shop, play shop Shop game with coins; V2 only (Section 26.7.3).
Uhr – Clock Clock-reading game; V2 only (Section 26.7.4).

30.4 Rewards, World and Session Terms #

German term English meaning Note
Sterne Stars The only in-app currency: earned per task, round and Abenteuer, spent on garden decorations, never purchasable (Section 14).
Zahlengarten Number garden Each profile's personal world with 24 placement slots and the Zahlenfreunde area (Section 14).
Dekoration Decoration Items bought with stars and placed in the garden; 60 in V1 (Section 14).
Zahlenfreunde Number friends 20 characters, one per number 1–20, each showing its quantity as grouped dots; befriended by mastering the number (Section 14).
befreunden / Freund gefunden to befriend / friend found The event when a Zahlenfreund moves into the garden; never reversed.
Sticker-Album Sticker album 6 pages, 52 stickers from Punkt zu Punkt, Zahlenfreunde and milestones (Section 14).
Meilenstein Milestone One of 8 achievements that award a sticker (Section 14).
Abenteuer Adventure The daily 5-minute session of 3 engine-chosen rounds with a greeting and a soft end; one bonus per day. An interrupted Abenteuer resumes at its next unplayed round on the same local day and is discarded on a new day (Sections 9, 14, 15).
"Morgen gibt es ein neues Abenteuer" "Tomorrow there is a new adventure" Shown after the day's Abenteuer is done; the button leads to the garden.
Runde Round A sequence of tasks in one game: 5 tasks for Vorschule, 4 for Die Kleinen (Section 9).
Aufgabe Task One question or activity within a round.
Tipp / Hinweis Hint Help given after a wrong attempt as part of the hint ladder (Section 10).
"Zeit zum Ausruhen" "Time to rest" The calm goodbye screen when the daily time limit is reached (Section 15).
Pause-Hinweis Break nudge The friend character's one-time yawn and break suggestion after 8 continuous minutes, shown only after a round's end screen (S-08), never during a round (Section 15.9).
Tageslimit Daily time limit Per-profile limit of foreground child-mode time, default 20 minutes (Section 15).
Profil / Kinderprofil Profile / child profile Up to 5 per device, with avatar, color theme, level and optional nickname (Section 15).
Spitzname Nickname Optional profile caption, 0–20 characters, shown only in the parent area and the profile picker.
"Frag deine Eltern" "Ask your parents" Short name of the child-safe locked-game screen (S-15) shown when a locked game is tapped. Its spoken line is "Dieses Spiel ist noch zu. Frag deine Eltern." (session.locked_game, at most once per session per profile); its parent icon leads to the parental gate (Section 17.8).
Schloss Lock The lock badge on premium games for non-subscribers.
German term English meaning Note
Elternbereich Parent area All parent-facing screens, reachable only through the parental gate (Section 16).
Elternschranke Parental gate The written multiplication question (factors 6–9 as number words) answered on a numeric keypad that guards the parent area, links, paywall, restore, subscription management and time extensions (Section 16).
Übersicht Overview Parent-area summary per child.
Fortschritt Progress Number × skill grid with mastery bands.
"Gerade schwierig" "Currently difficult" Up to 3 weakest items, described in plain German.
Zeit Time Last-7-days usage chart.
Einstellungen pro Kind Per-child settings Range, difficulty cap, time limit, level, avatar and nickname.
Geräteeinstellungen Device settings Voice, music, sound effects and haptics toggles (and, from V1.1, the opt-in toggle "Mit iCloud synchronisieren", available only while premium is active; Section 26.4).
Premium Premium Subscription status, paywall, restore and manage subscription inside the parent area (Section 17).
Käufe wiederherstellen Restore purchases Re-syncs StoreKit entitlements; only in the parent area.
Abo / Abonnement Subscription The auto-renewable "Zahlenkette Premium" subscription.
Probezeitraum / Gratis-Testphase Free trial period The 7-day introductory offer (Section 17).
Daten Data Export, reset and delete functions (Section 16.10).
Fortschritt zurücksetzen Reset progress Per-child action: resets learning progress; stars, garden, stickers and Zahlenfreunde are kept (Section 14.11).
Alles zurücksetzen Reset everything Per-child action: resets everything of that child except today's usage entry, so a reset never grants extra play time (Section 14.11).
Kind löschen Delete child Deletes one profile; not available for the last remaining profile (Section 16.10).
Alle Daten löschen Delete all data Device-wide action that deletes all profiles and data and leads back to the welcome screen S-02; the only way to reach zero profiles (Section 16.10).
Sie / du Formal / informal "you" Parent-facing copy always uses "Sie"; child lines always use "du" (Section 22).
Hilfe & Rechtliches Help and legal Privacy policy, support email and imprint links.
Datenschutzerklärung Privacy policy Required legal document; outline in Section 22.
Impressum Imprint, legal notice Mandatory provider identification for German online services (§ 5 DDG).
DDG (Digitale-Dienste-Gesetz) German Digital Services Act German law containing the imprint obligation (successor of the Telemediengesetz).
DSGVO GDPR General Data Protection Regulation (Datenschutz-Grundverordnung).
DPMA German Patent and Trade Mark Office Register searched in the name check (Section 26.9.1).
EUIPO European Union Intellectual Property Office Registers EU trade marks; searched in the name check.
Nizza-Klassifikation Nice Classification International classification of goods and services for trade marks; classes 9 (software) and 41 (education) are relevant.
Abofalle Subscription trap Colloquial complaint term for unclear subscriptions; the paywall copy is designed to avoid it (Section 28, R02).

30.6 Technical and Product Terms #

Term Meaning Note
SwiftData Apple's persistence framework built on Swift macros (@Model) Stores all app data locally in V1 (Section 7).
CloudKit Apple's iCloud database service Used from V1.1 for opt-in, premium-only sync through the user's private database; the developer cannot read this data (Section 26.4).
CloudKit private database Per-Apple-ID iCloud storage for an app Syncs only devices signed in to the same Apple ID.
CloudKit-compatible schema SwiftData model rules required for CloudKit sync No unique attributes, every property optional or defaulted, every relationship optional (Section 7).
CKShare CloudKit record-sharing object Shares records with other Apple IDs; needed for parent-child progress sharing, V2 earliest (Section 26.7.5).
StoreKit 2 Apple's Swift-native in-app purchase API Used for the subscription, entitlements, restore and transaction updates (Section 17).
Auto-renewable subscription Subscription that renews until cancelled The only purchase type in the app.
Subscription group Set of subscription products of which a user can hold one "Zahlenkette Premium", containing the monthly and yearly products.
Introductory offer Discounted or free first period for new subscribers The 7-day free trial; eligibility determined by Apple per subscription group.
Family Sharing Apple feature sharing purchases with up to five other family members Enabled on both products, so one subscription covers the family's devices (Section 17).
Ask to Buy Family Sharing feature requiring a parent's approval for a child account's purchase Additional protection beyond the parental gate.
Entitlement The right to use premium features Derived from current StoreKit transactions and cached for offline start (Section 17).
Paywall Screen that presents the subscription offer Exists only inside the parent area (Section 17, copy in Section 22).
Kids Category App Store category for apps made for children Imposes rules on ads, data, links and purchases (Section 23).
Age band Kids Category age range "5 and Under" chosen for V1 (Section 26.9.3; revisit item RV-10 in Section 28.2).
Made for Kids App Store Connect setting that places an app in the Kids Category Selected with age band "5 and Under"; the primary category is Education (Section 26.9.3).
Parental gate Challenge an adult can pass and a child cannot German: Elternschranke.
Privacy nutrition label App Store privacy disclosure "Data Not Collected" for this app (Section 23).
Privacy manifest PrivacyInfo.xcprivacy file declaring data use and required-reason APIs Required in the app bundle (Section 23).
String Catalog Xcode .xcstrings localization file Localizable.xcstrings for UI, Content.xcstrings for content lines (Section 20).
TTS (text-to-speech) Synthetic speech via AVSpeechSynthesizer Fallback only, for lines without a recording (Section 20).
Audio ID Stable identifier of a spoken line, SFX or music file Lowercase dot-separated, for example num.7 (Section 8, Section 21).
SpriteKit Apple's 2D game and physics framework Used only for Schüttelbox physics and the moving stage of Wie viele? (Section 5).
PencilKit Apple's drawing framework Captures ink in Nachspuren for finger and Apple Pencil (Section 12).
Core Motion Apple's motion-sensor framework Accelerometer input for Schüttelbox (Section 12).
Swift Testing Apple's test framework (import Testing) Used for unit tests; XCTest/XCUITest for UI tests (Section 25).
Strict concurrency Swift 6 compile-time data-race checking Required in all targets (Section 5).
Composition root The place where all modules are assembled The app target Zahlenkette, which registers games with the GameRegistry of ZKGameKit in its GameRegistry+All file (Section 5.4.3).
GameModule Protocol every game implements Defined in ZKGameKit (Section 10).
NumberSpace.maxNumber Upper bound of the supported number space 20 in V1; never hardcoded elsewhere (Section 26.7.1).
RangeStage Active number range of a profile r5 (1–5), r10 (1–10), r20 (1–20) (Section 9).
Mastery score Value 0.0–1.0 per profile × skill × number Updated after every task; mastered at score ≥ 0.80 with at least 6 attempts (Section 9).
Mastery band Parent-facing label of a mastery score notStarted, practicing, almost, mastered; the type MasteryBand lives in ZKCore (Section 9, Section 16).
Outcome Result of a task firstTry, afterHint or shown (Section 9, Section 10).
Hint ladder Escalating help after wrong attempts After the third wrong attempt the solution is shown (Section 10).
Leitner box Spaced-repetition scheme with numbered boxes Box 0–5 with review intervals of 0, 1, 2, 4, 7 and 14 days (Section 9).
Spaced repetition Reviewing items at increasing intervals Drives the "review" share of the task mix (Section 9).
Task mix Composition of a round 70% focus, 20% review, 10% stretch (Section 9).
Subitizing Recognizing a quantity at a glance English for Simultanerfassung.
First-try rate Share of tasks solved on the first attempt Target 70–85% (Section 9).
Calibration simulator Test-only harness running synthetic learners through the engine Evidence for engine tuning (Sections 27.3.4, 28.3).
Developer menu Internal tools screen Compiled only into Debug builds; reached only through the row "Entwicklermenü" on the parent dashboard S-18 after the parental gate (Section 5.10).
Build configurations Xcode build variants Exactly three: Debug (development, developer menu), Profile (Release optimization plus UITEST_HOOKS for UI and performance tests, never distributed) and Release (TestFlight and App Store) (Section 5.8.1).
UITEST_HOOKS Swift compilation condition Enables the UI-test launch arguments of Section 5.9 in Profile builds; never set in Release.
Trusted day clock Clock-manipulation-resistant local date Decides the local day for the daily time limit using mach_continuous_time() (Section 15.12).
DECISIONS.md Decision log at the repository root Records every executor decision and proposal as append-only D-NNNN entries (Sections 6.12.1, 29.5).
RV-NN Revisit item A V1 default to be re-examined when stated evidence arrives (Section 28.2).
P0–P3 Defect severity levels P0 and P1 block a release; P2 is fixed or accepted; P3 is cosmetic (Section 25.19).
TestFlight Apple's beta distribution service Used for internal builds and child testing (Section 27.4).
Xcode Cloud Apple's CI service CI provider for this project (Section 27.3.1).
DACH Germany (D), Austria (A), Switzerland (CH) The launch region.

Generated at GenerateSpecs.com — one idea in, one buildable spec out.

Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.