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
ACradlPlayerControllerexec 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(Projectsadded soStartupModulecan useIPluginManagerto register the tag-ini search path — see Module entry deviation below). - [x] Do not add
CommonUI— the widget extendsUTextBlock, notUCommonTextBlock(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 againstNativeGameplayTags.cppandGameplayTagsManager::ConstructGameplayTagTree). StartupModule callsIPluginManager::FindPlugin("TypewriterText")->GetBaseDir() / "Config/Tags"and passes that toUGameplayTagsManager::AddTagIniSearchPath. AddsProjectsto plugin's Build.cs PrivateDependencyModuleNames forIPluginManager. - [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 isPlugin/Config/Tags/*.iniwith section[/Script/GameplayTags.GameplayTagsList]and un-prefixedGameplayTagList=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 perfeedback_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
InProgressdecorative path looks up the tag by name viaFGameplayTag::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 thePlugins[]array.
Verification.
- User compiles; UBT picks up the new plugin and links
TypewriterText.dllcleanly. - 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/.Authresolve 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 Snippet—FStringper contract Localization rule (snippets are intentionally non-localized). - [x]
UPROPERTY(EditAnywhere, meta=(Categories="TypewriterText.Snippet")) FGameplayTag Category— theCategories=meta restricts the picker to plugin-owned tags. - [x]
UPROPERTY(EditAnywhere, meta=(ClampMin="1")) int32 Weight = 1—int32per contract Footguns (matchesFDropTableEntry::Weightprecedent; deviation from the original spec'sfloatis 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.csv→ Import → 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_SampleSnippetsopens, rows are visible with validCategorytags resolved against the plugin namespace. - Editor: rows authored with a tag outside
TypewriterText.Snippet.*either fail to pick or are filtered by theCategories=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]
- [x] ~~
virtual void NativeTick(const FGeometry&, float DeltaTime) override~~ — contract deviation:UTextBlockextendsUWidget, notUUserWidget, soNativeTickdoesn't exist on the base. Replaced with the Slate-native pattern: overrideRebuildWidget()to register anFActiveTimerHandleon the underlyingSWidget, overrideReleaseSlateResources()to drop the handle. The active-timer callbackActiveTick(double, float)callsTickInternal(float)which carries the per-frame body Phase 3 originally targeted atNativeTick. 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 deviation —EditDefaultsOnlyhides the props from the UMG Designer's Details panel when the widget is selected on a WBP canvas (instances aren't CDOs).EditAnywherelets each placed instance configure its ownSnippetTable/CharsPerSecond/etc. Property Matrix View ignored the specifier and exposed the problem.- [x]
float CharsPerSecond = 30.f. - [x]
float InjectionProbability = 0.5fwithmeta=(ClampMin="0.0", ClampMax="1.0"). - [x]
int32 PreRealSnippetCount = 3withmeta=(ClampMin="0"). - [x]
ENewlineMode NewlineMode = ENewlineMode::NewlineBetweenSegments. - [x]
TSoftObjectPtr<UDataTable> SnippetTable(per-widget; not a settings-class property — contract calls this out).
- [x]
- [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
ETypewriterRunStateenum (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)insideSetTargetTextis 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
NativeTickbody that callsSuper::NativeTick(MyGeometry, InDeltaTime).
Verification.
- User compiles;
UTypewriterTextBlockshows up in the WBP palette under the same category asTextBlock. - In a throwaway WBP, drop a
TypewriterTextBlockinto a panel, save, reopen — it persists and renders an empty text region. - Replace an existing
TextBlockpalette entry withTypewriterTextBlockin a test WBP; any C++UPROPERTY(... TObjectPtr<UTextBlock>)binding the corresponding member must still resolve (LSP smoke test).
Footguns.
- Don't shadow
Slot— perfeedback_slot_shadows_uwidget.md, never name a localSlotin aUUserWidgetsubclass.UTypewriterTextBlockextendsUTextBlock(aUWidget), so the same C4458 risk applies; useSegmentSlot,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, callsSuper::SetText(FText::GetEmpty()), setsRunState = Idle. - [x]
SetTargetText(FText): callsResetState(), populatesRealPhrases = { InText }as a single-element array, transitionsRunState = TypingReal,CurrentPhraseIndex = 0,CharsRevealedInPhrase = 0,TimeAccumulator = 0.f. Does not touchRng(no injection). - [x]
Clear(): callsResetState(). Does not reseedRng. - [x]
NativeTick: whileRunState == TypingReal, advanceTimeAccumulator += DeltaTime * CharsPerSecond; on each whole-character boundary advanceCharsRevealedInPhraseand rebuild the displayed string asAccumulatedDisplay + RealPhrases[0].ToString().Left(CharsRevealedInPhrase); on full reveal, append the completed phrase toAccumulatedDisplay, transition toHoldingFinal. WhileRunState == HoldingFinalorIdle, do nothing. - [x] Fractional-character carry:
TimeAccumulatorretains 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 cachedLastDisplayedStringmember, or skip the rebuild whenTimeAccumulatoradvance produced no whole-character delta). - [ ] Optional cheat command — Source/CRADL/Player/CradlPlayerController.h /
.cpp(existing): add aUFUNCTION(Exec)likeTypewriterPlayDemo(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
TypewriterTextBlockin a test WBP; callSetTargetText(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.
NativeTickonly 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::SetTextrebuilds 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]
SetTargetPhrasesentry — TypewriterTextBlock.cpp: - [x] Calls
ResetState(), populatesRealPhrases = Phrases,CurrentMode = Mode,CurrentCategory = Category. - [x] Reseeds
Rng: ifSeed == 0, useFRandomStream(FDateTime::Now().GetTicks()); ifSeed != 0, useFRandomStream(Seed)(deterministic). - [x] Empty
Phrasesarray degenerates toClear()-equivalent (log warning under#if !UE_BUILD_SHIPPINGperfeedback_log_level_warning_for_diagnostics.md— Warning level, not Log). - [x] Per-mode entry transitions:
- [x]
RealFirst→RunState = TypingReal,CurrentPhraseIndex = 0. - [x]
Interleaved→RunState = TypingReal,CurrentPhraseIndex = 0. - [x]
RealLast→PreRealRemaining = PreRealSnippetCount; if>0, transitionRunState = PlayingInjectionand pick the first snippet; if==0, fall through toTypingReal(per contract:PreRealSnippetCount=0is legal and degenerates to plain typing). (Impl tracksPreRealRemainingas "still-pending after current", so first pick consumes one andPreRealRemaining = PreRealSnippetCount - 1.)
- [x]
- [x] Empty / invalid
Categoryin 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 intoCachedSnippetTableUPROPERTY insideSetTargetPhrases; helper receives the already-resolved pointer.) - [x] Filters rows by
Categoryexact match (no hierarchical matching for v1 — keep it predictable). - [x] Sum
Weightover filtered set; ifTotalWeight <= 0return nullptr. - [x]
Rng.RandRange(0, TotalWeight - 1), iterate subtractingWeightuntil 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]
NativeTickextension: - [x]
TypingRealcompletion handler: appendRealPhrases[CurrentPhraseIndex]+ separator (perNewlineMode) toAccumulatedDisplay; advanceCurrentPhraseIndex. If more phrases remain, decide whether to inject (Interleaved: rollInjectionProbability; RealFirst: never mid-sequence). If transitioning to injection, pick snippet and enterPlayingInjection; else continueTypingRealwith the next phrase. If no more phrases:RealFirst→ForeverInjecting;Interleaved/RealLast→HoldingFinal. - [x]
PlayingInjectionstate: typed character-by-character likeTypingReal, but againstCurrentInjectionSnippet(anFString, not anFText). On completion, append toAccumulatedDisplaywith separator, then decide next state (back toTypingRealforRealLastifPreRealRemaining == 0; samePlayingInjectionwith a new snippet forForeverInjecting; etc.). - [x]
ForeverInjecting: continuously pick + play snippets untilClear()or newSetTarget*. IfPickSnippetByCategoryreturns nullptr (empty table for that category), fall toHoldingFinalto 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 theAuthcategory plays forever untilClear(). - Interleaved path: call with
Interleaved,InjectionProbability=1.0, two real phrases. Every gap between phrases plays exactly one snippet. DropInjectionProbabilityto0.0— no snippets play. - RealLast path: call with
RealLast,PreRealSnippetCount=3. Three snippets play, then real phrases type, thenHoldingFinal. - 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()andInterleaved; 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 aIsValid()check; reset only onSetTarget*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 thatSetTargetTextrepresents; the contract's degenerate-case note still applies — degrade RealFirst-with-empty-Category toHoldingFinal.
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
StatusTextTextBlockentry with aTypewriterTextBlock. Preserve the existing layout/anchors/styling. Set the per-widgetSnippetTabletoDT_SampleSnippetsandNewlineMode = NewlineBetweenSegments. - [x] Choose binding strategy — Option 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]
ApplyStateedit — Source/CRADL/UI/CradlAuthScreen.cpp: routed throughCast<UTypewriterTextBlock>inside the existingif (StatusText)guard; falls back toStatusText->SetText(Message)when not yet migrated. - [x] Optional decorative injection for
InProgress: if the design intent is "Connecting..." with rolling handshake gibberish, replace theInProgressbranch'sSetTargetTextwithSetTargetPhrases({Message}, RealFirst, TypewriterText.Snippet.Handshake, 0). TheIdleandErrorbranches stay onSetTargetText(no decoration needed). (Done. Tag resolved via function-localstatic 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
TypewriterTexttoPrivateDependencyModuleNamesso 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
Idlestate with the existing "Sign in to play online..." message, typed character-by-character. Click Login → screen transitions toInProgress, typewriter animates "Connecting..." (with optional handshake snippets if the decorated path was chosen). - Force the error path (offline build, bad credentials, or test toggle);
Errorstate 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
BindWidgetOptionalfallback: temporarily remove theStatusTextslot from the WBP, run the screen — no crash, theif (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
.uassetfiles directly. - Per
feedback_no_clean_up_later.md— if the C++ edit invalidates an existing comment onStatusText(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 wantsif (Cat == TypewriterTextTags::Snippet_Handshake), declare the symbol locally inside the plugin (not in main-moduleCradlGameplayTags.h).