Crabrix blog

Crabrix notes: local progress before optional Game Center

What Crabrix’s October progress update teaches about offline records, duplicate events, achievement projections and reliable Rust state transitions.

The development change and its useful engineering lesson

Development record, 5 October 2026. Crabrix’s changelog records two related changes on this date: an optional Game Center switch and a mapping from local achievement ladders to Apple achievements. The underlying learning record remains local. This is a development-history entry, rather than a claim that every recorded build was publicly available through the App Store on that day. The distinction matters when a product’s source history moves faster than store review.

The interesting engineering question extends beyond achievements: how can an application repeat a submission without repeating the underlying reward? A lesson can finish while a phone is offline, a screen can reopen after an interruption, and a remote service can accept a request whose response never reaches the caller. This article develops a small original Rust ledger to explore those cases. Crabrix’s native progress implementation is Swift; the Rust program below is an independent teaching example, not a translation or a description of its exact persistence format.

A coral crab places a milestone token in a local notebook, separate from an optional achievement display.
The learning record belongs to the local application. An optional social display is a projection of that record.

Sources: Crabrix source changelog, October 5 development entries

Start with the record that must survive without a connection

Suppose a learner completes a borrowing exercise on a train. The application should remember the result before it tries to tell any other service about it. If the next tunnel interrupts connectivity, the lesson should still be completed when the learner opens the app again. Defining the local record as authoritative gives the application a clear answer even when a network operation has an ambiguous outcome.

A remote leaderboard has a different purpose: it displays selected information associated with an authenticated player. It may be unavailable, disabled, delayed or associated with a different identity after an account change. None of those conditions should make a locally completed exercise disappear. This separation is also useful without Game Center. A desktop backup, an export file and a future synchronization service can each consume a record without becoming the only place where the learner’s work exists.

Name the three identities before designing storage

A lesson identifier names the educational material. An attempt identifier names one submission of work against that material. A player identity names an optional external account. These are different relationships. Using the lesson identifier as the only submission key prevents a second genuine attempt from being represented; generating a new attempt identifier during every retry permits the same attempt to receive multiple awards.

Create the attempt identity when the application starts the submission transaction, then retain it through retries and recovery. In the teaching example, the caller supplies a short string identifier so the code remains dependency free. A production design needs a documented allocation method with collision resistance appropriate to its lifetime and device boundaries. Do not infer security from the identifier’s visual appearance: the record still needs validation, and an untrusted caller must not be allowed to choose arbitrary score increments.

A duplicate and a conflict require different responses

If a previously seen attempt identifier arrives with exactly the same lesson and result, the operation is a duplicate. Returning success without changing the total is a useful idempotent behavior: retrying the same logical request produces the same stored effect. It is reasonable for the caller to dismiss its pending indicator after receiving that response because the desired record already exists.

If the identifier arrives with different content, silently replacing the old event hides a real inconsistency. Perhaps the caller reused an identifier, a persistence migration changed a field incorrectly, or two devices generated a collision. The example reports a conflict and leaves both the score and stored event unchanged. Choosing this behavior makes a defect observable. It also avoids an especially confusing interface where the displayed score changes merely because an old completion screen was opened twice.

An attempt passes validation and deduplication into a local ledger; duplicate requests make no change and optional Game Center receives a separate projection.
Identical retries are harmless; conflicting reuse of an identifier is an error. The example models an in-memory transition, not durable database transactions.

Validate the event before changing either collection or total

The ledger accepts a bounded lesson name, a bounded attempt identifier and a small nonnegative award. A failed attempt cannot award points in this example. These are explicit demonstration rules, rather than universal rules for educational products. A product might reasonably award practice effort even after an incorrect answer; if it does, the event model and tests should say so directly.

Validation occurs before insertion or arithmetic changes. That order prevents a rejected event from occupying an identifier or altering the aggregate. The collection also has a finite event count, because an offline application still has a storage budget. Returning a full-ledger error is incomplete product behavior, but it is a better teaching boundary than allocating indefinitely. A production policy could archive old attempts while retaining deduplication evidence, provided it clearly defines how old retries will be recognized after that compaction.

A small runnable ledger in Rust

The complete program uses only the standard library. BTreeMap gives the example stable key-ordered iteration, which is useful when inspecting a saved transcript or reproducing a test. The example does not depend on map order to decide which event is valid. That decision follows from the attempt identity, payload comparison and validation rules. Another collection could implement the same logical contract with different performance characteristics.

The checked addition is computed before either stored field changes. Once the candidate total is valid, the event is inserted and the aggregate is assigned. The main function verifies a new award, an identical retry and a conflicting retry. You can run this as a single Rust source file without downloading crates. It deliberately has no network client, global account identity or persistence layer, leaving those boundaries visible for the later sections rather than disguising them inside a helper.

use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq)]
struct Attempt { lesson: String, passed: bool, points: u64 }
#[derive(Default)]
struct Ledger { attempts: BTreeMap<String, Attempt>, score: u64 }
#[derive(Debug, PartialEq)]
enum Record { Added, Duplicate }
#[derive(Debug, PartialEq)]
enum Error { Invalid, Conflict, Full, Overflow }
impl Ledger {
    fn record(&mut self, id: &str, attempt: Attempt) -> Result<Record, Error> {
        if id.is_empty() || id.len() > 128 || attempt.lesson.is_empty()
            || attempt.lesson.len() > 256 || attempt.points > 100
            || (!attempt.passed && attempt.points != 0) { return Err(Error::Invalid); }
        if let Some(previous) = self.attempts.get(id) {
            return if previous == &attempt { Ok(Record::Duplicate) }
                else { Err(Error::Conflict) };
        }
        if self.attempts.len() >= 50_000 { return Err(Error::Full); }
        let next_score = self.score.checked_add(attempt.points).ok_or(Error::Overflow)?;
        self.attempts.insert(id.to_owned(), attempt);
        self.score = next_score;
        Ok(Record::Added)
    }
}
fn main() {
    let mut ledger = Ledger::default();
    let a = Attempt { lesson: "ownership-1".into(), passed: true, points: 20 };
    assert_eq!(ledger.record("attempt-1", a.clone()), Ok(Record::Added));
    assert_eq!(ledger.record("attempt-1", a), Ok(Record::Duplicate));
    assert_eq!(ledger.score, 20);
    let conflict = Attempt { lesson: "ownership-1".into(), passed: true, points: 30 };
    assert_eq!(ledger.record("attempt-1", conflict), Err(Error::Conflict));
    assert_eq!(ledger.score, 20);
    println!("one attempt, one award: ok");
}

Sources: Rust standard library: BTreeMap

Understand the transaction the example does not provide

An in-memory function can ensure that ordinary error returns leave its fields unchanged. That does not make it crash-safe storage. If an application writes the event and aggregate into separate files, a process termination between those writes can leave them inconsistent. Returning Ok from the Rust method proves only that the demonstrated memory transition completed; it says nothing about whether bytes reached durable storage.

For a real application, select a persistence mechanism with an appropriate transaction boundary or design a recoverable record protocol. One strategy stores validated events and reconstructs aggregates, with explicit handling of incomplete trailing writes. Another uses a database transaction to commit the event and updated projection together. The correct choice depends on expected history size, recovery time and platform guarantees. Write down what happens if the process stops at each persistence step, then test those points with a deliberately interrupted writer.

Make derived progress reproducible

A score and an achievement badge are convenient projections, but neither should be the only explanation of what happened. Given the accepted attempt records and a documented scoring policy, a diagnostic tool should be able to reconstruct the aggregate or explain why an earlier aggregate used a different rule. This is especially useful when a learner reports that an achievement sometimes fails to unlock.

Keep the scoring policy version separate from lesson content identifiers. Editing a lesson’s explanation should not accidentally create a new attempt identity or award the same completion again. Changing the scoring policy is a deliberate migration: decide whether previous attempts retain their original awards or are recalculated. Both policies can be coherent, but switching between them implicitly produces surprises. Record the chosen rule so an export, a support report and a repaired local database reach the same answer.

Project to Game Center only after local acceptance

Crabrix’s October development record describes Game Center as optional and off by default. Its documentation distinguishes 37 local five-tier ladders from ten earlier individual milestones. The mapping is a product decision about which local evidence appears in the external achievement system. A local ladder with several tiers need not become several independent remote achievements, and an external catalog’s limits should not constrain the richness of local learning feedback.

Treat submission as a separate operation after a valid local transition. The application can show that learning progress is saved while reporting that the optional social update is pending. Apple’s GameKit facilities supply account and achievement APIs; they do not choose the application’s local authority or scoring semantics. Check the current platform documentation when implementing authentication and reporting. The example here intentionally contains no guessed GameKit calls or simulated claim that a remote achievement was actually accepted.

Sources: Crabrix optional Game Center documentation · Apple GameKit documentation

An uncertain response is a normal state

Imagine that the remote service receives an achievement update, stores it and closes the connection before the phone receives confirmation. From the phone’s perspective the request failed; from the service’s perspective it succeeded. A retry strategy must tolerate this uncertainty without treating the local lesson as incomplete or inventing another local award. This is why a spinner’s visible lifetime should not define the transaction’s identity.

Store enough submission state to retry the projection deliberately. Distinguish a request that has never been sent, a request awaiting confirmation and a request confirmed for a particular external player. Apply bounded retry delays and stop automatic retries when the user disables the feature. A repeated network failure should not prevent editing projects or reading downloaded lessons. Remote submission is useful background work; the learner should not have to wait for it before continuing an offline session.

Account changes invalidate assumptions about remote state

A phone can change its authenticated external player while keeping the same local learning history. A cache saying that an achievement was submitted yesterday is meaningful only in relation to the player who received it. Reusing that cache for another player can suppress legitimate submissions or make the interface claim that data was sent to the wrong account.

Associate any remote acknowledgement with the external player identity and the projection version. Decide what opting in under a new account means for existing local progress, explain that behavior to the user, and invalidate the old submission cache where appropriate. Do not silently transfer private project sources as part of an achievement update. A learning score, a lesson identifier and the source text that produced the result are different data categories and should have separately justified handling rules.

Test the awkward sequences rather than the celebration animation

Begin with sequences that a real interface can produce: the learner taps twice, the application restarts immediately after completion, an old screen retries a request, and a new attempt legitimately revisits the same lesson. Verify both the stored event set and the visible aggregate after each sequence. A test that only checks that a badge appears once cannot reveal whether the underlying score was incremented twice.

Add rejection tests for a conflicting identifier, an oversized field, a failed attempt with an invalid award and an exhausted storage allowance. Test arithmetic overflow by placing the aggregate near its type’s maximum in a dedicated internal test fixture. Every rejection should preserve the previous ledger. For persistence, inject interruption around the actual commit boundary and reopen from disk. For remote projection, use a controlled service substitute that accepts a request but loses its acknowledgement, then verify that a retry remains harmless locally.

Keep privacy explanations as concrete as the state machine

A useful privacy explanation says which optional action sends which kind of data to which service. It should also say which functions remain available without that action. “Offline first” is too broad if the interface leaves users guessing whether reading a lesson, compiling code or opening a leaderboard starts a connection. Concrete boundaries let a learner decide whether to use the optional feature.

For this design, the teaching ledger itself needs no account and sends nothing. The product’s actual Game Center integration follows its own published privacy and platform terms, and its settings should agree with those descriptions. When adding a new remote projection, update the native About and privacy views together with website documents. Documentation drift is a functional defect: users can encounter either copy first, and an offline application must be able to explain its behavior without requiring a browser visit.

Choose the deduplication lifetime deliberately

An event identifier is useful only while the system remembers having accepted it. If a cleanup operation deletes all records older than thirty days, a delayed retry from an old export can appear new again. That may be acceptable for a temporary interaction log, but it is usually surprising for permanent learning credit. Retention and deduplication therefore belong in the same design discussion rather than in unrelated storage and networking tickets.

A compact tombstone can preserve an attempt identifier and a digest of its canonical payload after detailed evidence is archived. The digest must cover the fields that define equality, and its encoding must be versioned; concatenating ambiguous strings can make different payloads look identical. Another option retains the full accepted event in a compressed archive and indexes its identity separately. Whichever policy you choose, test a retry before and after compaction and make the expected result identical unless the product explicitly documents an expiration rule.

Separate a content update from an achievement migration

Educational material changes for good reasons: an explanation becomes clearer, an exercise receives a missing edge case, or a compiler update changes a diagnostic. These edits should use stable lesson identifiers when they represent the same learning objective. A course download version identifies a package revision, while a completion event identifies work the learner actually submitted. Replacing the package version everywhere with the lesson identity merges two distinct histories.

When an exercise changes substantially, decide whether previous completion remains sufficient. Preserve the original evidence and attach a new objective or policy version if reassessment is required. Avoid resetting an entire course merely because an illustration was corrected. A migration test should install the old material, complete selected lessons, apply the new package, reopen the progress view and verify the documented carry-forward behavior. That test connects content delivery to learning state without assuming that every update must either erase progress or grant everything automatically.

Treat time as evidence rather than identity

A timestamp is useful for showing when an attempt occurred, but it is a weak deduplication key. Two legitimate actions can share the same clock resolution, and a device clock can move backward or be corrected after an offline period. Sorting events by a displayed local time also gives different answers across time zones unless the stored representation and presentation policy are explicit.

Use the attempt identifier for identity and store time separately for explanation. If ordering affects scoring, define a deterministic sequence rule that survives imports and repeated synchronization. If order does not affect scoring, keep the aggregate commutative where practical so delivery order cannot change the answer. Test equal timestamps and out-of-order arrival explicitly. The objective is an understandable record, not a claim that an ordinary phone clock can establish a globally trusted timeline across disconnected devices.

Make a support report useful without exporting private projects

Intermittent progress defects are easier to investigate when a report contains the attempt identifier, lesson identity, policy version, accepted transition and relevant projection status. Those fields can explain duplicate suppression, migration behavior or an account-specific submission cache without including the learner’s complete source project. A bounded report also prevents a long local history from becoming an unexpectedly large attachment.

Provide a preview of the report before sharing and use ordinary platform sharing so the learner chooses its destination. Include counts and validation errors rather than secrets, authentication tokens or raw account credentials. For the teaching ledger, a diagnostic transcript can list each accepted key and the resulting score, then verify that replay produces the same aggregate. This makes the example valuable beyond a successful assertion: it supplies a concrete method for explaining why a state was reached when the interface alone cannot reproduce the sequence.

A compact review checklist for a real implementation

Before shipping, ask another engineer to trace one attempt through validation, deduplication, durable acceptance, derived progress and optional submission. At each boundary, identify the owner, the identity being used and the result of interruption. If two components can independently award the same attempt, consolidate that decision or make the shared transaction explicit. If a remote error can erase local progress, revisit which record is authoritative.

Finally, inspect the interface with networking disabled and with the optional account unavailable. Complete an exercise, reopen the application and repeat the same submission. The local record should explain the observed behavior without appealing to an eventually successful network call. That is the practical value of the October change as an engineering topic: reliable progress begins with a small, understandable local transition, and every additional display or service should preserve that transition’s meaning.

Explore the free Crabrix lessons →