0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI TYPEWRITERTEXT_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
TYPEWRITERTEXT_IMPLEMENTATION.md 3879 words ~18 min read Updated 2026-07-03

CRADL TypewriterText Implementation

Companion to TYPEWRITERTEXT_SYSTEM.md (the contract) and ARCHITECTURE.md. This doc tracks the build order for v1 TypewriterText: phased delivery, per-phase rationale, task checklists, and verification gates. The contract doc says what TypewriterText is; this doc says what we build first, what depends on what, and how we know each step works.

Greenfield plugin — no existing TypewriterText code in the tree. The build extends the project only at the auth-screen migration step (Phase 5); every earlier phase lands entirely inside Plugins/TypewriterText/ and is self-contained.

Conventions

  • Phase status legend: [ ] not started · [~] in progress · [x] done · [!] blocked / deferred.
  • Verification gate: every phase ends with a runnable demo / observable behavior. If a phase can't be verified end-to-end, it's split.
  • Cheat commands: any test fixtures we add land under ACradlPlayerController exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded.
  • Per CLAUDE.md "validators in lockstep": TypewriterText deliberately ships without an editor-time validator for v1 (see TYPEWRITERTEXT_SYSTEM.md § Snippet Data Source Footguns for rationale). No phase below adds a Source/CRADLEditor/Validators/ entry; if a future maintainer wants one, that's a contract iterate, not a silent addition.
  • Per CLAUDE.md "no UBT here": Claude does not build. After C++ edits, the user compiles and reports back.

Phase tracking

Phase Title Status Unblocks
0 Plugin scaffold & tag registration [x] All later phases
1 Snippet data source (struct + CSV + DataTable) [x] Phase 4 (injection)
2 Widget class skeleton & API surface [x] Phase 3, Phase 4
3 Single-phrase typing (SetTargetText + state machine) [x] Phase 4, Phase 5 (minimal first consumer path)
4 Multi-phrase + injection (SetTargetPhrases + full state machine) [x] Phase 5 (decorated first consumer path)
5 First consumer: UCradlAuthScreen migration [~] — (end of v1)

Phase 0 — Plugin Scaffold & Tag Registration

Goal. A new runtime-only plugin TypewriterText exists at Plugins/TypewriterText/, is referenced from CRADL.uproject, and compiles clean as an empty module. Tag namespace TypewriterText.Snippet.* is registered through the plugin's own .ini. No widget class, no row struct, no behavior yet.

Rationale. Per TYPEWRITERTEXT_SYSTEM.md § Plugin Shape, the plugin mirrors the runtime-only layout of Plugins/CommonUser/CommonUser.uplugin. Landing the empty shell first guarantees every later phase has a stable module to target and that .uproject / Build.cs plumbing surfaces compile errors in isolation, not mixed with widget logic.

Tasks.

  • [x] Plugin descriptor — Plugins/TypewriterText/TypewriterText.uplugin (new):
  • [x] Modules[] with a single entry: Name="TypewriterText", Type="Runtime", LoadingPhase="Default".
  • [x] Category="UI", EnabledByDefault=true, Installed=false.
  • [x] Build script — Plugins/TypewriterText/Source/TypewriterText/TypewriterText.Build.cs (new):
  • [x] PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs.
  • [x] PublicDependencyModuleNames: Core, CoreUObject, Engine, UMG, GameplayTags.
  • [x] PrivateDependencyModuleNames: Slate, SlateCore, Projects (Projects added so StartupModule can use IPluginManager to register the tag-ini search path — see Module entry deviation below).
  • [x] Do not add CommonUI — the widget extends UTextBlock, not UCommonTextBlock (per contract Footguns).
  • [x] Module entry — Plugins/TypewriterText/Source/TypewriterText/Private/TypewriterTextModule.cpp (new):
  • [x] ~~Empty FTypewriterTextModule~~ → contract deviation: StartupModule() is required to register the plugin's tag-ini search path. UE 5.4 only auto-scans <Project>/Config/Tags, not plugin config folders (verified against NativeGameplayTags.cpp and GameplayTagsManager::ConstructGameplayTagTree). StartupModule calls IPluginManager::FindPlugin("TypewriterText")->GetBaseDir() / "Config/Tags" and passes that to UGameplayTagsManager::AddTagIniSearchPath. Adds Projects to plugin's Build.cs PrivateDependencyModuleNames for IPluginManager.
  • [x] IMPLEMENT_MODULE(FTypewriterTextModule, TypewriterText).
  • [x] Public/Private folders — Plugins/TypewriterText/Source/TypewriterText/Public/ and Private/ (new): create both directories so later phases drop headers/cpps without first reshaping the layout.
  • [x] Plugin tag config — Plugins/TypewriterText/Config/Tags/TypewriterTextTags.ini (new): (Originally placed at Config/DefaultGameplayTags.ini — that path is NOT auto-scanned by the GameplayTagsManager for plugins. The auto-discovery target is Plugin/Config/Tags/*.ini with section [/Script/GameplayTags.GameplayTagsList] and un-prefixed GameplayTagList= entries; moved + reformatted accordingly.)
  • [x] Declare TypewriterText.Snippet (parent).
  • [x] Declare TypewriterText.Snippet.Handshake, TypewriterText.Snippet.Scan, TypewriterText.Snippet.Auth (sample leaves per contract Tag Taxonomy).
  • [x] Do not also add entries to the project's Config/DefaultGameplayTags.ini — plugin tags merge into the global namespace at load time (per contract Footguns, and per feedback_gameplay_tag_decl_minimal.md).
  • [x] Do not add a C++ tag symbols header — no call site references these tags by name in v1 (per contract Tag Taxonomy). (Phase 5's InProgress decorative path looks up the tag by name via FGameplayTag::RequestGameplayTag(FName(TEXT("..."))) at the single call site rather than promoting to a plugin-owned symbols header.)
  • [x] Project plugin enable — CRADL.uproject: append { "Name": "TypewriterText", "Enabled": true } to the Plugins[] array.

Verification.

  • User compiles; UBT picks up the new plugin and links TypewriterText.dll cleanly.
  • In-editor: open Edit → Plugins, search "Typewriter", confirm the plugin is listed under the UI category and is enabled.
  • In-editor: open Project Settings → GameplayTags, confirm TypewriterText.Snippet.Handshake/.Scan/.Auth resolve as valid tags (drop-downs containing them are non-empty).

Exits. Phase 1 can land the snippet row struct in Public/. Phase 2 can subclass UTextBlock from this module.


Phase 1 — Snippet Data Source

Goal. The FTypewriterSnippetRow struct exists and is registered as a DataTable row type. A repo-tracked CSV at Plugins/TypewriterText/Content/Samples/SampleSnippets.csv and an editor-imported DT_SampleSnippets.uasset are present. No widget code touches the table yet.

Rationale. Per TYPEWRITERTEXT_SYSTEM.md § Snippet Data Source, the data shape mirrors the weighted-pick pattern proven by FDropTableEntry::Weight in Source/CRADL/Loot/DropTableDefinition.h and the selection logic in Source/CRADL/Loot/CradlLoot.cpp. Landing data before the widget means the widget's first injection-aware compile already has a valid row struct + asset to bind against.

Tasks.

  • [x] Row struct — Plugins/TypewriterText/Source/TypewriterText/Public/TypewriterSnippetRow.h (new):
  • [x] USTRUCT(BlueprintType) struct TYPEWRITERTEXT_API FTypewriterSnippetRow : public FTableRowBase.
  • [x] UPROPERTY(EditAnywhere) FString SnippetFString per contract Localization rule (snippets are intentionally non-localized).
  • [x] UPROPERTY(EditAnywhere, meta=(Categories="TypewriterText.Snippet")) FGameplayTag Category — the Categories= meta restricts the picker to plugin-owned tags.
  • [x] UPROPERTY(EditAnywhere, meta=(ClampMin="1")) int32 Weight = 1int32 per contract Footguns (matches FDropTableEntry::Weight precedent; deviation from the original spec's float is deliberate).
  • [x] Sample CSV — Plugins/TypewriterText/Content/Samples/SampleSnippets.csv (new):
  • [x] CSV header: Name,Snippet,Category,Weight.
  • [x] Author 5-10 rows each under TypewriterText.Snippet.Handshake, .Scan, .Auth (sample-content only; theme-flavored gibberish stays here, never leaks to main module). (Drafted 3 placeholder rows per category — values "1","2","3" — per user direction; expand later when authoring real flavor.)
  • [x] Keep snippet length short (≤ ~30 chars) — long snippets stall the per-character animation.
  • [x] Sample DataTable — Plugins/TypewriterText/Content/Samples/DT_SampleSnippets.uasset (new, editor-authored):
  • [x] In editor: right-click SampleSnippets.csvImport → row struct = TypewriterSnippetRow.
  • [x] Confirm reimport works (modify a row in the CSV, right-click the asset → Reimport, verify the change lands).

Verification.

  • User compiles; the struct registers (visible in Project Settings → DataTable import dialogs).
  • Editor: DT_SampleSnippets opens, rows are visible with valid Category tags resolved against the plugin namespace.
  • Editor: rows authored with a tag outside TypewriterText.Snippet.* either fail to pick or are filtered by the Categories= meta restriction.

Exits. Phase 4 can sync-load DT_SampleSnippets via the widget's TSoftObjectPtr<UDataTable> SnippetTable UPROPERTY and run weighted picks against it.


Phase 2 — Widget Class Skeleton & API Surface

Goal. UTypewriterTextBlock : public UTextBlock exists with the three public methods declared (SetTargetText, SetTargetPhrases, Clear) and all UPROPERTY tunables in place. Method bodies are empty (or no-op) — the class compiles, appears in the WBP palette, and binds correctly to existing UPROPERTY(... TObjectPtr<UTextBlock>) slots, but does not animate.

Rationale. Per TYPEWRITERTEXT_SYSTEM.md § Widget Class & Public API, the API surface is exactly three methods. Locking that shape in before any animation work means later phases never have to renegotiate the public surface — they only fill in NativeTick and helpers. This also unblocks the auth-screen migration (Phase 5) to start the WBP palette swap as soon as the class exists.

Tasks.

  • [x] Enums — Plugins/TypewriterText/Source/TypewriterText/Public/TypewriterTextBlock.h (new):
  • [x] UENUM(BlueprintType) enum class ETypewriterInjectionMode : uint8 { RealFirst, Interleaved, RealLast }.
  • [x] UENUM(BlueprintType) enum class ENewlineMode : uint8 { SameLine, NewlineBetweenSegments, NewlineAroundInjections }.
  • [x] Widget class declaration — same header:
  • [x] UCLASS() class TYPEWRITERTEXT_API UTypewriterTextBlock : public UTextBlock.
  • [x] Three UFUNCTION(BlueprintCallable, Category="Typewriter") methods, signatures per contract:
    • [x] void SetTargetText(FText InText).
    • [x] void SetTargetPhrases(const TArray<FText>& Phrases, ETypewriterInjectionMode Mode, FGameplayTag Category, int32 Seed = 0).
    • [x] void Clear().
  • [x] ~~virtual void NativeTick(const FGeometry&, float DeltaTime) override~~ — contract deviation: UTextBlock extends UWidget, not UUserWidget, so NativeTick doesn't exist on the base. Replaced with the Slate-native pattern: override RebuildWidget() to register an FActiveTimerHandle on the underlying SWidget, override ReleaseSlateResources() to drop the handle. The active-timer callback ActiveTick(double, float) calls TickInternal(float) which carries the per-frame body Phase 3 originally targeted at NativeTick. Semantics match — ticks while the Slate widget is alive (i.e., while added to a panel).
  • [x] Authoring UPROPERTYs (all ~~EditDefaultsOnly~~ → EditAnywhere, BlueprintReadWrite, Category="Typewriter"): contract deviationEditDefaultsOnly hides the props from the UMG Designer's Details panel when the widget is selected on a WBP canvas (instances aren't CDOs). EditAnywhere lets each placed instance configure its own SnippetTable/CharsPerSecond/etc. Property Matrix View ignored the specifier and exposed the problem.
    • [x] float CharsPerSecond = 30.f.
    • [x] float InjectionProbability = 0.5f with meta=(ClampMin="0.0", ClampMax="1.0").
    • [x] int32 PreRealSnippetCount = 3 with meta=(ClampMin="0").
    • [x] ENewlineMode NewlineMode = ENewlineMode::NewlineBetweenSegments.
    • [x] TSoftObjectPtr<UDataTable> SnippetTable (per-widget; not a settings-class property — contract calls this out).
  • [x] Private state members (no UPROPERTY; transient, non-replicated): the full set listed in TYPEWRITERTEXT_SYSTEM.md § Animation Driver Implementation surface (RunState, CurrentMode, RealPhrases, CurrentPhraseIndex, CharsRevealedInPhrase, TimeAccumulator, AccumulatedDisplay, Rng, CurrentCategory, PreRealRemaining, CurrentInjectionSnippet, InjectionCharsRevealed).
  • [x] Private ETypewriterRunState enum (file-local or nested): { Idle, TypingReal, PlayingInjection, HoldingFinal, ForeverInjecting }.
  • [x] Widget cpp — Plugins/TypewriterText/Source/TypewriterText/Private/TypewriterTextBlock.cpp (new):
  • [x] Empty bodies for all three public methods (a Super::SetText(InText) inside SetTargetText is acceptable as a Phase-2 placeholder so the WBP shows the static text immediately; explicitly mark it as throwaway in a one-line comment).
  • [x] Empty NativeTick body that calls Super::NativeTick(MyGeometry, InDeltaTime).

Verification.

  • User compiles; UTypewriterTextBlock shows up in the WBP palette under the same category as TextBlock.
  • In a throwaway WBP, drop a TypewriterTextBlock into a panel, save, reopen — it persists and renders an empty text region.
  • Replace an existing TextBlock palette entry with TypewriterTextBlock in a test WBP; any C++ UPROPERTY(... TObjectPtr<UTextBlock>) binding the corresponding member must still resolve (LSP smoke test).

Footguns.

  • Don't shadow Slot — per feedback_slot_shadows_uwidget.md, never name a local Slot in a UUserWidget subclass. UTypewriterTextBlock extends UTextBlock (a UWidget), so the same C4458 risk applies; use SegmentSlot, InjectionSlot, etc., if any helper needs a local with that root.

Exits. Phase 3 implements NativeTick against the now-declared private state. Phase 5's WBP swap can start in parallel — the palette item is sufficient for the editor authoring step even before animation lands.


Phase 3 — Single-Phrase Typing

Goal. SetTargetText(FText) animates the provided FText character-by-character at CharsPerSecond, holds the final state, and supports latest-wins interruption (a second SetTargetText mid-animation restarts cleanly). Clear() returns the widget to Idle and blanks the displayed text. No injection logic yet.

Rationale. Per TYPEWRITERTEXT_SYSTEM.md § Animation Driver, the NativeTick + float accumulator pattern is proven by UCradlDebugStateWidget::NativeTick + TimeSinceCountdownRefresh in Source/CRADL/Debug/CradlDebugStateWidget.cpp. Phase 3 lands the typing-only path so the state machine's Idle → TypingReal → HoldingFinal spine is verified before phrase queueing and injection states pile on top.

Tasks.

  • [x] State machine spine — Plugins/TypewriterText/Source/TypewriterText/Private/TypewriterTextBlock.cpp:
  • [x] Private ResetState() helper: blanks all internal members, calls Super::SetText(FText::GetEmpty()), sets RunState = Idle.
  • [x] SetTargetText(FText): calls ResetState(), populates RealPhrases = { InText } as a single-element array, transitions RunState = TypingReal, CurrentPhraseIndex = 0, CharsRevealedInPhrase = 0, TimeAccumulator = 0.f. Does not touch Rng (no injection).
  • [x] Clear(): calls ResetState(). Does not reseed Rng.
  • [x] NativeTick: while RunState == TypingReal, advance TimeAccumulator += DeltaTime * CharsPerSecond; on each whole-character boundary advance CharsRevealedInPhrase and rebuild the displayed string as AccumulatedDisplay + RealPhrases[0].ToString().Left(CharsRevealedInPhrase); on full reveal, append the completed phrase to AccumulatedDisplay, transition to HoldingFinal. While RunState == HoldingFinal or Idle, do nothing.
  • [x] Fractional-character carry: TimeAccumulator retains the remainder after each integer-character advance (do not floor-and-discard).
  • [x] Display rebuild guard: only call Super::SetText(FText::FromString(...)) when the composed string has actually changed since last tick (compare against a cached LastDisplayedString member, or skip the rebuild when TimeAccumulator advance produced no whole-character delta).
  • [ ] Optional cheat command — Source/CRADL/Player/CradlPlayerController.h / .cpp (existing): add a UFUNCTION(Exec) like TypewriterPlayDemo(FString Text) that pushes the string into a known test WBP for live verification. Per CLAUDE.md, declare unconditionally, guard only the body with #if !UE_BUILD_SHIPPING. (Skip this if a designated test WBP already exists in the build that exercises the widget directly.) — Skipped per the doc's allowance; the Phase 5 auth-screen migration provides a live test surface.

Verification.

  • User compiles.
  • Place a TypewriterTextBlock in a test WBP; call SetTargetText(FText::FromString("Hello world")) from a button. Observe character-by-character reveal at ~30 cps.
  • Mid-animation, call SetTargetText(FText::FromString("Different message")) again — animation restarts cleanly with no leftover characters from the first call.
  • Call Clear() mid-animation — text blanks immediately, no further updates occur.
  • At CharsPerSecond=10, total animation duration for an 11-character phrase is ~1.1s (sanity check the accumulator).

Footguns.

  • NativeTick only fires while the widget is in the viewport. A widget assembled but not added to a panel will not animate; this is desired behavior, but document it in code comments if it surprises a future author.
  • Per-frame Super::SetText rebuilds Slate text geometry. Cheap for short strings, do not optimize until profiling shows a problem (per contract Footguns).

Exits. Phase 4 layers phrase queueing, injection states, and snippet selection on top of the now-working spine.


Phase 4 — Multi-Phrase + Injection

Goal. SetTargetPhrases(...) animates a phrase array with one of three injection modes (RealFirst / Interleaved / RealLast), driven by the configured SnippetTable and Category. ForeverInjecting runs perpetually for RealFirst; HoldingFinal ends Interleaved / RealLast. Snippet selection is weight-aware and seedable.

Rationale. This is the heart of the system — the phase-level injection invariant that keeps real text un-clobbered is enforced structurally by the AccumulatedDisplay + current-segment-substring layout from TYPEWRITERTEXT_SYSTEM.md § Newline & Text Layout Invariants. The weighted-pick logic mirrors CradlLoot::RollDropTable in Source/CRADL/Loot/CradlLoot.cpp.

Tasks.

  • [x] SetTargetPhrases entry — TypewriterTextBlock.cpp:
  • [x] Calls ResetState(), populates RealPhrases = Phrases, CurrentMode = Mode, CurrentCategory = Category.
  • [x] Reseeds Rng: if Seed == 0, use FRandomStream(FDateTime::Now().GetTicks()); if Seed != 0, use FRandomStream(Seed) (deterministic).
  • [x] Empty Phrases array degenerates to Clear()-equivalent (log warning under #if !UE_BUILD_SHIPPING per feedback_log_level_warning_for_diagnostics.md — Warning level, not Log).
  • [x] Per-mode entry transitions:
    • [x] RealFirstRunState = TypingReal, CurrentPhraseIndex = 0.
    • [x] InterleavedRunState = TypingReal, CurrentPhraseIndex = 0.
    • [x] RealLastPreRealRemaining = PreRealSnippetCount; if >0, transition RunState = PlayingInjection and pick the first snippet; if ==0, fall through to TypingReal (per contract: PreRealSnippetCount=0 is legal and degenerates to plain typing). (Impl tracks PreRealRemaining as "still-pending after current", so first pick consumes one and PreRealRemaining = PreRealSnippetCount - 1.)
  • [x] Empty / invalid Category in a mode that injects: log Warning once per call, degrade gracefully (RealFirst skips ForeverInjecting → HoldingFinal; Interleaved skips injection rolls; RealLast skips pre-real budget → straight to TypingReal).
  • [x] Snippet selection helper — file-local in cpp:
  • [x] static const FTypewriterSnippetRow* PickSnippetByCategory(const UDataTable* Table, FGameplayTag Category, FRandomStream& Rng).
  • [x] Sync-loads via SnippetTable.LoadSynchronous() at call site if not already resolved (cache the resolved pointer in a private member to avoid repeated syncs). (Cached into CachedSnippetTable UPROPERTY inside SetTargetPhrases; helper receives the already-resolved pointer.)
  • [x] Filters rows by Category exact match (no hierarchical matching for v1 — keep it predictable).
  • [x] Sum Weight over filtered set; if TotalWeight <= 0 return nullptr.
  • [x] Rng.RandRange(0, TotalWeight - 1), iterate subtracting Weight until pick falls in the current entry's interval.
  • [x] Returns nullptr on empty filter or empty table; all callers must handle nullptr by skipping the injection.
  • [x] NativeTick extension:
  • [x] TypingReal completion handler: append RealPhrases[CurrentPhraseIndex] + separator (per NewlineMode) to AccumulatedDisplay; advance CurrentPhraseIndex. If more phrases remain, decide whether to inject (Interleaved: roll InjectionProbability; RealFirst: never mid-sequence). If transitioning to injection, pick snippet and enter PlayingInjection; else continue TypingReal with the next phrase. If no more phrases: RealFirstForeverInjecting; Interleaved / RealLastHoldingFinal.
  • [x] PlayingInjection state: typed character-by-character like TypingReal, but against CurrentInjectionSnippet (an FString, not an FText). On completion, append to AccumulatedDisplay with separator, then decide next state (back to TypingReal for RealLast if PreRealRemaining == 0; same PlayingInjection with a new snippet for ForeverInjecting; etc.).
  • [x] ForeverInjecting: continuously pick + play snippets until Clear() or new SetTarget*. If PickSnippetByCategory returns nullptr (empty table for that category), fall to HoldingFinal to avoid a busy loop.
  • [x] Separator builder — SeparatorFor(ENewlineMode, PrevSegmentKind, NextSegmentKind):
  • [x] SameLine"".
  • [x] NewlineBetweenSegments"\n" between every adjacent segment pair.
  • [x] NewlineAroundInjections"\n" only when one side of the boundary is an injection; real-to-real joins inline.

Verification.

  • User compiles.
  • RealFirst path: call SetTargetPhrases({"Authenticating", "Connected"}, RealFirst, TypewriterText.Snippet.Auth). Both real phrases type out in order, then gibberish from the Auth category plays forever until Clear().
  • Interleaved path: call with Interleaved, InjectionProbability=1.0, two real phrases. Every gap between phrases plays exactly one snippet. Drop InjectionProbability to 0.0 — no snippets play.
  • RealLast path: call with RealLast, PreRealSnippetCount=3. Three snippets play, then real phrases type, then HoldingFinal.
  • Determinism: call SetTargetPhrases(..., Seed=12345) twice with the same args; the sequence of selected snippets matches across runs.
  • Real-text invariant: during any animation, observe that completed real characters never disappear or get overwritten by a subsequent injection.
  • Invalid Category: call with FGameplayTag() and Interleaved; one Warning logs, animation completes without injection.

Footguns.

  • Per feedback_loose_tag_writer_bool_no_reseed.md — does not directly apply here (no GAS loose tags), but the principle ("writer-owned bools track 'did I publish', not 'is the value present'") is the same shape as the resolved-snippet-pointer cache: it tracks "did we sync-load already", not "is the table valid right now". Don't reseed it from a IsValid() check; reset only on SetTarget* calls.
  • DataTable iteration order is not contractually stable. For sample content this is acceptable; if a future consumer needs cross-run identical sequences, sort the filtered row list by row name before the weighted pick (Phase-N polish per contract Open Questions, not v1).
  • SetTargetPhrases({InText}, RealFirst, EmptyTag, 0) is the no-injection fast path that SetTargetText represents; the contract's degenerate-case note still applies — degrade RealFirst-with-empty-Category to HoldingFinal.

Exits. The plugin v1 is functionally complete. Phase 5 adopts it at the first consumer.


Phase 5 — First Consumer: UCradlAuthScreen Migration

Goal. UCradlAuthScreen::ApplyState drives StatusText via SetTargetText (or SetTargetPhrases for the InProgress state's decorative case) instead of UTextBlock::SetText. The WBP backing the auth screen swaps its StatusText palette entry from TextBlock to TypewriterTextBlock. No regression in the existing three-state auth flow.

Rationale. Per TYPEWRITERTEXT_SYSTEM.md § First Consumer & Migration, the auth screen's ApplyState state-message pattern is the prototypical use case. The migration is two surfaces: a WBP palette swap and a C++ call-site update. The BindWidgetOptional UPROPERTY(... TObjectPtr<UTextBlock> StatusText) declaration in Source/CRADL/UI/CradlAuthScreen.h continues to bind by LSP — no header change is mandatory.

Pre-work.

  • None mandatory. The plugin v1 (Phases 0-4) is self-contained and ships independently — this phase only runs once a human is ready to migrate.

Tasks.

  • [ ] WBP palette swap (editor-authored): in the auth screen WBP, replace the StatusText TextBlock entry with a TypewriterTextBlock. Preserve the existing layout/anchors/styling. Set the per-widget SnippetTable to DT_SampleSnippets and NewlineMode = NewlineBetweenSegments.
  • [x] Choose binding strategyOption A (loose binding) chosen. Graceful fallback if the WBP isn't yet migrated; minimal header churn; easy to promote to Option B later if a second consumer arrives.
  • [x] ApplyState edit — Source/CRADL/UI/CradlAuthScreen.cpp: routed through Cast<UTypewriterTextBlock> inside the existing if (StatusText) guard; falls back to StatusText->SetText(Message) when not yet migrated.
  • [x] Optional decorative injection for InProgress: if the design intent is "Connecting..." with rolling handshake gibberish, replace the InProgress branch's SetTargetText with SetTargetPhrases({Message}, RealFirst, TypewriterText.Snippet.Handshake, 0). The Idle and Error branches stay on SetTargetText (no decoration needed). (Done. Tag resolved via function-local static const FGameplayTag HandshakeTag = FGameplayTag::RequestGameplayTag(...) — single call site doesn't yet justify promoting to a plugin-owned C++ tag symbols header.)
  • [x] Build.cs sweep — Source/CRADL/CRADL.Build.cs: added TypewriterText to PrivateDependencyModuleNames so the public header resolves from the main module. (Doc had this listed as Option-B-only; the include path still needs the dep at compile time, so adding it under Option A is the safe choice.)

Verification.

  • User compiles.
  • Launch the game / PIE; auth screen shows Idle state with the existing "Sign in to play online..." message, typed character-by-character. Click Login → screen transitions to InProgress, typewriter animates "Connecting..." (with optional handshake snippets if the decorated path was chosen).
  • Force the error path (offline build, bad credentials, or test toggle); Error state animates "Connection failed..." cleanly.
  • Rapid state toggling (clicking Login, immediately clicking Quit or cancelling): typewriter latest-wins behavior swaps message cleanly with no leftover characters.
  • Confirm BindWidgetOptional fallback: temporarily remove the StatusText slot from the WBP, run the screen — no crash, the if (StatusText) guard short-circuits.

Footguns.

  • Do not migrate UCradlDebugStateWidget reflexively. That widget is dev-only diagnostics with per-frame updates; animating its text would obscure rapidly-changing values. Out of scope (per contract Footguns).
  • WBP palette swap is the editor authoring step — the user does this manually; Claude cannot edit .uasset files directly.
  • Per feedback_no_clean_up_later.md — if the C++ edit invalidates an existing comment on StatusText (e.g. one that says "plain UMG TextBlock"), update the comment in the same change. Don't park it as a follow-up.

Exits. v1 ships. Polish items (sound hooks, deterministic row sort, long-body profiling) live in the Follow-ups section below, not as numbered phases.


Follow-ups (Deferred — Not v1)

Per TYPEWRITERTEXT_SYSTEM.md § Open Questions, v1 has no open contract questions; the items below are deliberate deferrals that do not block the build.

  • Sound: per-character SFX. Future v2 extension point: USoundBase* TypeSound + int32 EveryNChars + pitch jitter UPROPERTYs. Discussed during design, explicitly out of scope per contract. Clean extension — no contract changes needed to add it later.
  • Editor-time validator for the snippet DataTable. Deliberately omitted per contract Snippet Data Source Footguns. If a future maintainer wants one, that triggers a contract iterate, not a silent addition.
  • DataTable iteration-order stability. Phase-N polish noted in the contract — sort the filtered row list by row name before the weighted pick for strict cross-run reproducibility. Sample content does not need it.
  • C++ tag symbols header. Not required for v1 — no call site references TypewriterText.Snippet.* tags by name. If a future call site wants if (Cat == TypewriterTextTags::Snippet_Handshake), declare the symbol locally inside the plugin (not in main-module CradlGameplayTags.h).