0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI TYPEWRITERTEXT_SYSTEM
UTC 00:00:00
◀ RETURN
TYPEWRITERTEXT_SYSTEM.md 4781 words ~22 min read Updated 2026-07-03

CRADL TypewriterText System

Non-gameplay feature. Companion to ARCHITECTURE.md only. This system does not bind to GAS, replication, the stat pipeline, combat, AI, or any other gameplay subsystem. It is a local, cosmetic, client-side text-rendering plugin. This document is the contract TypewriterText must satisfy — its widget class, API surface, animation lifecycle, injection modes, data source, tag taxonomy, and authority boundaries. Implementation patterns, per-widget styling, and snippet authoring live elsewhere; what's here does not change without a deliberate edit to this file.

North Star

TypewriterText is a drop-in UTextBlock subclass that animates SetText-style FText payloads character-by-character, with optional decorative "gibberish" snippets interleaved at phrase boundaries. Authors swap UTextBlock for UTypewriterTextBlock in their WBP and call its API from C++ — no async actions, no driver components, no binding ceremony. The plugin lives at Plugins/TypewriterText/ under a generic name so theme-flavored sample snippets (handshake banter, scanlines, etc.) stay inside the plugin's own Content/Samples/ folder and never leak into the main CRADL module. TypewriterText reuses every UMG and DataTable pattern CRADL already established: it adds presentation polish, not foundation.

Quick Reference

Topic Answer Section
Plugin location Plugins/TypewriterText/ (single Runtime module, generic name) Plugin Shape
Widget class UTypewriterTextBlock : public UTextBlock (new) Widget Class & API
Public API SetTargetText / SetTargetPhrases / Clear Widget Class & API
Animation driver NativeTick, no FTimeline, no async action Animation Driver
Injection modes RealFirst / Interleaved / RealLast (phrase-level) Phrase Model
Pre-real budget (RealLast) PreRealSnippetCount UPROPERTY, default 3 Phrase Model
Real-text invariant Real characters never overwritten by injections Newline & Layout
Snippet source UDataTable of FTypewriterSnippetRow (new), CSV-backed Snippet Data Source
Selection Weight-aware, category-filtered, FRandomStream seeded per call Snippet Data Source
Tag namespace TypewriterText.Snippet.*, .ini-only inside plugin Config/ Tag Taxonomy
Localization Real text is FText; injected snippets are FString (not localized) Localization
Replication None — local cosmetic only Replication
First consumer UCradlAuthScreen::ApplyStateStatusText First Consumer
Interruption Latest-wins; any new SetTarget* cancels in-flight animation Animation Driver
Validator None for v1 (deliberate deviation from CRADL pattern) Snippet Data Source

Plugin Shape & Module Layout

Rule. TypewriterText ships as a standalone runtime-only plugin at Plugins/TypewriterText/ with a single Runtime module (no editor module). The plugin name is intentionally generic — no theme references in directory names, class names, or tag namespaces. Theme-flavored content (sample snippet CSVs flavored as a "hacker handshake") lives only inside Plugins/TypewriterText/Content/Samples/ and is not imported into the main CRADL Content directory.

Why. The plugin exists to (a) be reusable across future CRADL screens that want stylized text, and (b) keep theme bleed contained — sample-content categories like Handshake or Scan stay inside the plugin's sample folder, never colonizing the main module. CommonUser at Plugins/CommonUser/ is the canonical in-tree plugin template; mirror its structure for a single-Runtime-module presentation plugin.

Implementation surface. - Files: - Plugins/TypewriterText/TypewriterText.uplugin (new) — Modules[] declares one Runtime module, LoadingPhase=Default, Category="UI". - Plugins/TypewriterText/Source/TypewriterText/TypewriterText.Build.cs (new) — PCHUsage = UseExplicitOrSharedPCHs. PublicDependencyModuleNames: Core, CoreUObject, Engine, UMG, GameplayTags. PrivateDependencyModuleNames: Slate, SlateCore. - Plugins/TypewriterText/Source/TypewriterText/Public/ + Private/ (new) — match CommonUser's Public/Private split. - Plugins/TypewriterText/Source/TypewriterText/Private/TypewriterTextModule.cpp (new) — empty FTypewriterTextModule : public IModuleInterface, IMPLEMENT_MODULE macro. - Plugins/TypewriterText/Config/DefaultGameplayTags.ini (new) — plugin-local tag declarations for TypewriterText.Snippet.*. - Plugins/TypewriterText/Content/Samples/SampleSnippets.csv (new) — repo-tracked CSV source for the sample snippet DataTable. - Classes: FTypewriterTextModule (new). - Plugins enabled in CRADL.uproject — append TypewriterText to the Plugins[] array.

Footguns. - No editor module. If a future need surfaces (e.g., a custom DataTable editor or a validator), add it then — don't preemptively scaffold an empty editor module. The CommonUser template is runtime-only and works fine. - Don't add CommonUI as a public dependency. The widget extends UTextBlock (UMG), not UCommonTextBlock (CommonUI), so the dep is unnecessary; adding it would couple a generic plugin to CommonUI for no reason. - Plugin tags merge into the global GameplayTagsManager namespace at load time. Authoring the plugin's Config/DefaultGameplayTags.ini is sufficient — do not also add entries to the project's Config/DefaultGameplayTags.ini.

Related. CradlAuthScreen.cpp::ApplyState (first consumer); Plugins/CommonUser/ (template plugin).


Widget Class & Public API

Rule. Exactly one widget class: UTypewriterTextBlock : public UTextBlock (new). Authors place it in a WBP by swapping any TextBlock palette item for TypewriterTextBlock; C++ code calls one of three methods on it. No async actions, no separate driver component, no BlueprintPure node interface — the widget owns its own animation.

// Three modes — phrase-level, never per-character
UENUM(BlueprintType)
enum class ETypewriterInjectionMode : uint8
{
    RealFirst,    // all real phrases first, then gibberish forever until Clear()
    Interleaved,  // gibberish between real phrases, holds final state
    RealLast      // PreRealSnippetCount gibberish snippets, then real phrases, holds final state
};

UCLASS()
class TYPEWRITERTEXT_API UTypewriterTextBlock : public UTextBlock
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintCallable, Category="Typewriter")
    void SetTargetText(FText InText);

    UFUNCTION(BlueprintCallable, Category="Typewriter")
    void SetTargetPhrases(
        const TArray<FText>& Phrases,
        ETypewriterInjectionMode Mode,
        FGameplayTag Category,
        int32 Seed = 0);

    UFUNCTION(BlueprintCallable, Category="Typewriter")
    void Clear();
};

Why. Subclassing UTextBlock (LSP) means existing BindWidgetOptional UPROPERTY(... TObjectPtr<UTextBlock> StatusText) declarations in widget headers — e.g., UCradlAuthScreen::StatusText — keep binding correctly to a WBP slot that now holds a TypewriterTextBlock instead of a plain TextBlock. No header changes required at consumer sites; only the WBP needs the palette swap. This avoids the "introduce an interface" rewrite cost flagged in feedback_interface_rule_reading.md — a single widget concrete subclass with three methods is not a cross-system touchpoint; it is a leaf consumer that ends at the UMG boundary. The three-method API is the smallest surface that covers single-phrase animation (SetTargetText), multi-phrase + injection (SetTargetPhrases), and the kill-switch needed for RealFirst's forever-loop (Clear).

Implementation surface. - Files: - Plugins/TypewriterText/Source/TypewriterText/Public/TypewriterTextBlock.h (new) - Plugins/TypewriterText/Source/TypewriterText/Private/TypewriterTextBlock.cpp (new) - Classes: UTypewriterTextBlock (new), ETypewriterInjectionMode (new), ENewlineMode (new — see Newline & Layout). - Authoring tunables (UPROPERTYs, all EditDefaultsOnly, BlueprintReadWrite, Category="Typewriter"): - float CharsPerSecond (default 30.f). - float InjectionProbability (default 0.5f, clamped 0..1, Interleaved mode only). - int32 PreRealSnippetCount (default 3, RealLast mode only). - ENewlineMode NewlineMode (default NewlineBetweenSegments). - TSoftObjectPtr<UDataTable> SnippetTable (per-widget; resolves on first use).

Footguns. - No queueing. Calling SetTargetText while animation is in flight cancels and restarts; there is no "wait until done then play this next" surface. Authors who want independent streams instantiate multiple widgets. Per the user's design call: "authors that want multiple independent streams should use more than one entity in the editor." - Clear() is the only way to stop a RealFirst forever-loop without replacing it. Calling SetTargetText with FText::GetEmpty() would also clear the visible text but would set up a new (zero-character) animation in Idle; Clear() is preferred because it short-circuits state machine bookkeeping and resets any pending injection RNG. - Don't introduce an IFoo interface for this. The interface rule in CLAUDE.md ("prefer interfaces over concrete types for cross-system touchpoints") and feedback_interface_rule_reading.md ("forward-looking, not reactive") apply when a system might gain a second implementer. A typewriter text widget is a leaf — a second implementer means a different effect (e.g., glitch text), which would warrant its own subclass, not a shared interface. - Subclass UTextBlock, not UCommonTextBlock. Per the grounding pass: no UCommonTextBlock subclasses exist anywhere in CRADL; UCradlAuthScreen::StatusText is UTextBlock. Subclassing UTextBlock keeps the plugin generic. If a future consumer needs CommonUI styling, it can wrap or compose, not block this design.

Related. Animation Driver, First Consumer.


Animation Driver & State Machine

Rule. Animation is driven by UTypewriterTextBlock::NativeTick(const FGeometry&, float DeltaTime). The widget owns an internal state enum and time accumulator; each tick advances the appropriate counter (characters-revealed for typing states, snippet-elapsed for injection states), rebuilds the displayed FText from internal buffers, and calls SetText on the base class. No FTimeline (actor-only), no UBlueprintAsyncActionBase, no separate FTSTicker delegate.

Idle
 │ SetTargetText(F) ─────►  TypingReal      (single-phrase path)
 │ SetTargetPhrases(...) ──► RealLast: PlayingInjection (pre-real countdown)
 │                            Interleaved: TypingReal (first phrase)
 │                            RealFirst:   TypingReal (first phrase)
 ▼

TypingReal  ─[chars complete + more phrases queued]─► PlayingInjection (mode-dependent)
            ─[chars complete + last phrase]──┬──► HoldingFinal      (Interleaved, RealLast)
                                              └──► ForeverInjecting   (RealFirst)

PlayingInjection ─[snippet complete + more snippets queued]─► PlayingInjection
                 ─[snippet complete + transition to real]───► TypingReal
                 ─[snippet complete + ForeverInjecting]─────► ForeverInjecting

HoldingFinal       ─[Clear() or new SetTarget*]──► Idle / fresh animation
ForeverInjecting   ─[Clear() or new SetTarget*]──► Idle / fresh animation
                   ─[snippet complete]───────────► PlayingInjection (loop)

Why. UCradlDebugStateWidget already uses NativeTick + a float TimeSinceCountdownRefresh accumulator pattern for per-frame text updates (Source/CRADL/Debug/CradlDebugStateWidget.cpp). That pattern is proven in CRADL; TypewriterText reuses it. FTimeline is rejected because it's actor-only (UWidget doesn't have a timeline component). Async actions are rejected because they push state out of the widget and complicate latest-wins interruption.

Implementation surface. - Files: TypewriterTextBlock.cpp (new) — NativeTick override + state machine impl. - State enum (private nested or file-local): ETypewriterRunState { Idle, TypingReal, PlayingInjection, HoldingFinal, ForeverInjecting }. - Internal members (all private, no UPROPERTY needed since none are reflected/replicated): - ETypewriterRunState RunState = ETypewriterRunState::Idle; - ETypewriterInjectionMode CurrentMode; - TArray<FText> RealPhrases; — real text broken into phrase units (single-phrase form populates a one-element array). - int32 CurrentPhraseIndex; — which RealPhrases[i] is currently being typed. - int32 CharsRevealedInPhrase; — within the current phrase, how many characters have been emitted. - float TimeAccumulator; — fractional-character carry between ticks. - FString AccumulatedDisplay; — frozen buffer of all previously-completed real characters and previously-completed injected snippets. Each tick the displayed FText is built from AccumulatedDisplay + <current-phrase-substring> (or current-injection-substring). This guarantees the real-text-uninterrupted invariant — see Newline & Layout. - FRandomStream Rng; — owned by the widget, reseeded each SetTargetPhrases call. - FGameplayTag CurrentCategory; — for snippet lookups in PlayingInjection and ForeverInjecting. - int32 PreRealRemaining; — RealLast countdown. - FString CurrentInjectionSnippet; and int32 InjectionCharsRevealed; — analogous to phrase typing, for the active injection. - Latest-wins: every entry point (SetTargetText, SetTargetPhrases, Clear) calls a private ResetState() before configuring the new animation. ResetState blanks all of the above and clears the base UTextBlock via Super::SetText(FText::GetEmpty()).

Footguns. - NativeTick is only called when the widget is in the viewport. A widget assembled but not yet added (e.g., a tooltip widget pre-constructed) will not tick. This matches existing CRADL precedent (UCradlDebugStateWidget only animates while visible) and is the desired behavior here — there is no benefit to ticking off-screen. - Per-frame SetText rebuilds the Slate widget tree text geometry every tick the displayed string changes. This is acceptable for short ranges (state messages, log-style screens); avoid using a TypewriterTextBlock for very long bodies (>5000 chars) without profiling. - Fractional-character carry matters. At CharsPerSecond=30 and DeltaTime≈0.016, you advance ~0.48 chars per tick — without a float accumulator that carries the fractional remainder forward, the effective rate drifts. The accumulator pattern in UCradlDebugStateWidget shows the right shape. - Reseeding behavior. SetTargetPhrases(..., Seed=0) reseeds Rng from platform time; Seed!=0 reseeds deterministically. Clear() does NOT reseed (no animation is queued). SetTargetText does not touch RNG (no injection in single-phrase path).

Related. CradlDebugStateWidget NativeTick pattern; Phrase Model.


Phrase Model & Injection Modes

Rule. A "phrase" is the atomic unit of real text — typed character-by-character without interruption. Authors pass either a single FText (treated as one phrase) via SetTargetText, or an explicit TArray<FText> via SetTargetPhrases. Injections only occur at phrase boundaries; real characters are never overwritten or interleaved at the character level. Three injection modes, all open-ended unless Clear() is called or a new SetTarget* interrupts:

  • RealFirst — All real phrases type out in order. After the last phrase finishes, the widget enters ForeverInjecting: snippets play perpetually, separated according to NewlineMode, until Clear() or interruption. Use for hanging "Connecting…" screens.
  • Interleaved — Between each adjacent pair of real phrases, roll InjectionProbability against Rng; on hit, play one snippet from the configured Category. After the final phrase (and any final injection between phrase N-1 and N), the widget enters HoldingFinal: text stays as-is until Clear() or interruption.
  • RealLast — Play exactly PreRealSnippetCount snippets (default 3) from the configured Category first. Then type each real phrase in order. After the last phrase, the widget enters HoldingFinal. Snippet count, not duration, by user design call.

Why. Phrase-level injection (not per-character) preserves the user's hard invariant: "real text must stay un-clobbered during playback." Per-character interleave would risk splitting real words ("Conn[gibberish]ecting"), which breaks readability and accessibility. Three modes cover the design space (gibberish-after, gibberish-around, gibberish-before) without a fourth (gibberish-during) that the invariant rules out. RealLast uses a snippet-count budget rather than a time budget because it composes cleanly with CharsPerSecond — if write speed changes, total animation duration scales, but the structure (3 snippets → real) stays predictable.

Implementation surface. - Files: TypewriterTextBlock.cpp — phrase advancement logic inside NativeTick. - SetTargetPhrases parameter list: (const TArray<FText>& Phrases, ETypewriterInjectionMode Mode, FGameplayTag Category, int32 Seed). - Single-phrase SetTargetText is equivalent to SetTargetPhrases({InText}, ETypewriterInjectionMode::RealFirst, FGameplayTag(), 0) — but with a fast path that skips RNG reseeding and snippet lookup since no injection slot exists. (One-element + RealFirst + empty Category degenerates to "type the phrase, then enter ForeverInjecting with no available snippets" — the fast path short-circuits to HoldingFinal instead.)

Footguns. - Empty Category in a mode that injects. If Category is an invalid FGameplayTag and the mode is not SetTargetText's implicit fast path, snippet lookups will return nothing. Behavior: log a warning once per SetTargetPhrases call (#if !UE_BUILD_SHIPPING only — per CLAUDE.md), then degrade gracefully — RealFirst skips ForeverInjecting and enters HoldingFinal; Interleaved skips injection rolls; RealLast skips the pre-real budget and goes straight to real text. - SetTargetText does not inject, period. It is the no-injection-needed convenience path. Authors who want a single phrase with injection use SetTargetPhrases({InText}, Mode, Category, Seed). - PreRealSnippetCount = 0 is legal and degenerates RealLast to a plain TypingReal → HoldingFinal (equivalent to SetTargetText for a single phrase, or sequential phrase typing for multi). Documented behavior, not a bug.

Related. Animation Driver, Snippet Data Source.


Snippet Data Source

Rule. Decorative snippets are sourced from a UDataTable whose row struct is FTypewriterSnippetRow : public FTableRowBase (new), backed by a CSV at Plugins/TypewriterText/Content/Samples/SampleSnippets.csv. The widget holds a TSoftObjectPtr<UDataTable> SnippetTable UPROPERTY; the asset is sync-loaded on first injection lookup (SnippetTable.LoadSynchronous()). Selection is weight-aware: filter rows by Category, sum Weight over the filtered set, Rng.RandRange(0, TotalWeight - 1), iterate subtracting Weight until pick < entry.Weight.

USTRUCT(BlueprintType)
struct TYPEWRITERTEXT_API FTypewriterSnippetRow : public FTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere)
    FString Snippet;

    UPROPERTY(EditAnywhere, meta=(Categories="TypewriterText.Snippet"))
    FGameplayTag Category;

    UPROPERTY(EditAnywhere, meta=(ClampMin="1"))
    int32 Weight = 1;
};

Why. DataTable beats DataAsset here because the user wants CSV as the repo-tracked source of truth — UE's DataTable CSV reimport pipeline is first-class for that. int32 Weight matches the project pattern proven by FDropTableEntry::Weight in DropTableDefinition.h and the selection logic in CradlLoot::RollDropTable (CradlLoot.cpp). The deviation from the user's original spec of float Weight = 1.f to int32 Weight = 1 aligns with established codebase precedent — see Footguns. TSoftObjectPtr + sync-load on first use mirrors how UCradlInventorySettings::ItemTable is loaded.

Implementation surface. - Files: - Plugins/TypewriterText/Source/TypewriterText/Public/TypewriterSnippetRow.h (new) — FTypewriterSnippetRow. - Plugins/TypewriterText/Content/Samples/SampleSnippets.csv (new) — CSV source. - Plugins/TypewriterText/Content/Samples/DT_SampleSnippets.uasset (new — authored in editor by importing the CSV with FTypewriterSnippetRow as the row struct). - Selection helper (private, file-local in TypewriterTextBlock.cpp — no need to expose): cpp static const FTypewriterSnippetRow* PickSnippetByCategory( const UDataTable* Table, FGameplayTag Category, FRandomStream& Rng);

Footguns. - No editor-time validator. This is a deliberate deviation from CLAUDE.md's "validators in lockstep" rule. The plugin is non-gameplay, the snippet table has no cross-asset coupling (no foreign keys, no GE class references, no skill-tag resolution), and bad data degrades gracefully (a missing category returns nothing → injection skipped). Adding UCradlEditor::FAssetValidator_TypewriterSnippets for v1 is overkill. Per the user's explicit design call. Don't reach for one as "consistency" — see Why. - Don't promote SnippetTable to a UDeveloperSettings class. The project pattern for DataTable refs (e.g., UCradlInventorySettings::ItemTable, UCradlStoreSettings::PriceTable) is settings-driven — but those tables are project-global (one item table, one price table). TypewriterText snippets are per-widget (an auth screen wants Handshake snippets; a future lore screen might want different ones). Per-widget UPROPERTY is the right shape; a global settings class would force every consumer into the same content. - Weight is int32, not float. Deviation from the user's original prompt (which said float). Rationale: matches the established FDropTableEntry::Weight precedent, simplifies selection to integer arithmetic (no float-cumulative drift), and there is no use case in this design that needs sub-integer weight granularity. If a future need surfaces, migrate the column with a single edit; CSV reimport will round/coerce existing values cleanly. - PickSnippetByCategory returns nullptr on empty filter or empty table. Callers must handle nullptr gracefully — degenerate to "no injection played" (see Phrase Model Footguns). - Seeded determinism, but DataTable iteration order is not contractually stable. UDataTable::GetRowMap() returns a TMap<FName, uint8*> — TMap iteration order is "insertion-ordered" in practice but not part of the API contract. For strict reproducibility across runs, sort the filtered rows by row name before the weighted pick. This is a Phase-N polish item, not a Phase-0 requirement.

Related. FDropTableEntry weighted-pick pattern, CradlLoot::RollDropTable, UCradlInventorySettings::ItemTable.


Newline & Text Layout Invariants

Rule. The displayed FText is constructed each tick as the concatenation of:

  1. AccumulatedDisplay — a frozen string buffer holding every character emitted so far in any completed segment (real phrase or injected snippet). Never edited, only appended-to when a segment finishes.
  2. Current segment substring — for TypingReal, the first CharsRevealedInPhrase characters of the current phrase; for PlayingInjection/ForeverInjecting, the first InjectionCharsRevealed characters of the current snippet.

A separator string is inserted into AccumulatedDisplay between segments based on ENewlineMode:

UENUM(BlueprintType)
enum class ENewlineMode : uint8
{
    SameLine,                  // no separator; segments run inline
    NewlineBetweenSegments,    // "\n" between every segment (real-to-injection, injection-to-real, injection-to-injection)
    NewlineAroundInjections    // "\n" before AND after each injection; real-to-real joins inline
};

The real-text-uninterrupted invariant — once a real character lands in AccumulatedDisplay, it is never modified — falls out structurally from the design: real characters only enter the accumulator after the current phrase fully completes, and the accumulator is append-only thereafter.

Why. Treating display as frozen prefix + animating tail makes the invariant a structural property of the data layout, not a runtime check. The user's phrase-level injection rule plus "real text un-clobbered" means real characters and injected characters never share the same animation timeline — they share the same display surface, but at different times, with the boundary marked by a separator. NewlineBetweenSegments is the safe default for log-style screens (one segment per line); SameLine is for terminal-prompt aesthetics; NewlineAroundInjections is the middle ground.

Implementation surface. - Files: TypewriterTextBlock.cpp — segment-completion handler appends AccumulatedDisplay += SeparatorFor(NewlineMode, PrevSegment, NextSegment) + CompletedSegmentText. - Enum: ENewlineMode in TypewriterTextBlock.h. - Display rebuild: Super::SetText(FText::FromString(AccumulatedDisplay + CurrentSegmentSubstring)) once per tick when either side has changed.

Footguns. - The base UTextBlock::AutoWrapText setting interacts with explicit newlines. Authors who set both AutoWrapText=true and NewlineMode=NewlineBetweenSegments will get hard line breaks at segment boundaries plus soft wraps inside long phrases — usually the desired result, but worth noting. - Super::SetText is called every tick the display changes. Don't add custom dirty-tracking optimizations until profiling shows they're needed; UTextBlock's internal text-change detection is already cheap. - FText::FromString discards localization keys. This is correct — AccumulatedDisplay is a partially-revealed mix of localized real text and non-localized injected text; the composed string is presentation-only and cannot meaningfully participate in localization. See Localization for the FText/FString split.

Related. Localization, Phrase Model.


Localization & FText Discipline

Rule. Real text accepted by the widget is FText — author-controlled, localizable, sourced from NSLOCTEXT/LOCTEXT or any other FText origin (e.g., the FText Message constructed in UCradlAuthScreen::ApplyState). Injected snippets are FString in the DataTable row struct — stylistic gibberish that is not a localization candidate. The displayed composition is FText::FromString(...) because once mixed, the string is no longer a localizable unit.

Why. Per feedback_presentation_struct_resolve_tags.md, the discipline for presentation data is "no untyped tag → label lookups in BP." That rule applies to tags surfaced to BP for display. The injected-snippet FString is the inverse case: text that is deliberately not authored for translation. Marking it FText would invite well-meaning future contributors to feed it through the translator pipeline and waste translator hours on intentionally nonsensical content. Keeping it FString documents the intent in the type.

Implementation surface. - Files: TypewriterSnippetRow.h (FString Snippet), TypewriterTextBlock.h (API takes FText). - API: SetTargetText(FText), SetTargetPhrases(const TArray<FText>&, ...).

Footguns. - If a future requirement surfaces wanting localized "flavor" text (e.g., a sci-fi game where the gibberish is actually meaningful in-universe language), promote Snippet from FString to FText then. Don't preemptively over-engineer for it now. - American English spellings per feedback_american_english.md — "Defense" not "Defence", etc. Applies to any UI strings TypewriterText itself emits (none currently, but worth recording for sample snippet authoring).

Related. Snippet Data Source, Newline & Layout.


Replication / Authority

Rule. Zero. No replicated UPROPERTYs, no RPCs, no bReplicates. The widget is a UTextBlock subclass — UMG widgets are not replicated. All state (RunState, AccumulatedDisplay, Rng, the entire phrase queue) is per-client transient memory. The ApplyState call site that publishes "Connecting" is itself driven by client-local state (login flow phase tracking on the local PC), so the source-of-truth lives client-side too.

Why. Per CLAUDE.md: "Replicate cosmetic-only state (sounds, particles, local UI are client-side only)" — explicitly excludes UI text animation from replication. Per feedback_p2p_replication_audit.md, every server-mutated field needs a deliberate replication answer; the deliberate answer for TypewriterText is "none, because nothing is server-mutated." The widget runs entirely on the client that owns the WBP instance.

Implementation surface. - Files: TypewriterTextBlock.h — no UPROPERTY(Replicated), no GetLifetimeReplicatedProps override, no RPCs.

Footguns. - No AddReplicatedLooseGameplayTag shenanigans. This is a non-GAS feature; the loose-tag-pairing trap from feedback_replicated_loose_tag_ue54.md does not apply but is worth noting as a "don't reach for it" — there is no scenario in this plugin that involves loose tag mirroring. - Don't replicate the snippet table. The snippet content is per-client cosmetic; even if two clients in a P2P session see different snippets, that's desired (each client rolls its own RNG locally). Determinism per Seed is for reproducibility on the same client across runs, not cross-client agreement.

Related. CLAUDE.md cosmetic-state rule; feedback_p2p_replication_audit.md (audit completeness — recorded answer: "no replicated state").


First Consumer & Migration

Rule. The first concrete consumer is UCradlAuthScreen::ApplyState. The migration is contained to two surfaces:

  1. WBP edit. In the auth screen WBP, change the StatusText palette entry from TextBlock to TypewriterTextBlock. The existing BindWidgetOptional UPROPERTY(... TObjectPtr<UTextBlock> StatusText) in UCradlAuthScreen continues to bind correctly (LSP: UTypewriterTextBlock IS-A UTextBlock).
  2. C++ edit. In ApplyState, replace StatusText->SetText(Message) calls with Cast<UTypewriterTextBlock>(StatusText)->SetTargetText(Message) — or change the UPROPERTY declared type from UTextBlock to UTypewriterTextBlock to drop the cast. The latter ties the auth screen header to the plugin module dependency; the former keeps the auth screen agnostic. Defer the choice to migration time.

Why. Validating the design against a real consumer before generalizing avoids over-fitting the plugin to imagined use cases. The auth screen's ApplyState state-message pattern is the prototypical use case: a small number of canned messages (Idle, Connecting, Authenticating, Connected, Failed), each suitable for typewriter presentation with optional decorative injection.

Implementation surface. - Files (in main CRADL module, modified during migration): - Source/CRADL/UI/CradlAuthScreen.h — optionally promote StatusText from UTextBlock to UTypewriterTextBlock. - Source/CRADL/UI/CradlAuthScreen.cppApplyState SetText calls become SetTargetText / SetTargetPhrases. - Source/CRADL/CRADL.Build.cs — add TypewriterText to PrivateDependencyModuleNames if the header references the subclass directly. (If using Cast<> at call sites, no dep change needed.) - WBP asset (authored in editor): the auth screen WBP's StatusText slot palette swap.

Footguns. - Don't migrate UCradlDebugStateWidget to TypewriterText reflexively. That widget is dev-only diagnostics with per-frame updates; animating its text would obscure the rapidly-changing values it displays. Out of scope. - The auth screen migration is not part of this plugin's v1. The plugin v1 delivers the widget class, data source, and sample CSV. The auth screen swap is a follow-up PR that can ship independently — the plugin must be useful before any consumer adopts it, but does not require a consumer to be valid. - BindWidgetOptional means missing-in-WBP is silent. If a WBP is rebuilt without the StatusText member or with a non-text widget in its place, the C++ side gets nullptr and the existing if (StatusText) guard short-circuits. Preserve the guard in any migration edit.

Related. Widget Class & API, UCradlAuthScreen::ApplyState.


Tag Taxonomy

All TypewriterText tags live under the root namespace TypewriterText.Snippet.* and are authored in Plugins/TypewriterText/Config/DefaultGameplayTags.ini (new). The plugin's .ini merges into the project-wide tag namespace at engine init per UE's GameplayTagsManager behavior.

Per feedback_gameplay_tag_decl_minimal.md, only tags referenced by name in C++ get a C++ symbol declaration. For TypewriterText v1, no plugin tag is referenced by name in C++ — they are all referenced as property values on FTypewriterSnippetRow::Category (authored in CSV, loaded as FGameplayTag) and as parameters to SetTargetPhrases(..., FGameplayTag Category, ...) (passed in from the call site, not literal-named). Therefore: no TypewriterTextTags.h is needed in v1. If a future call site wants if (Cat == TypewriterTextTags::Snippet_Handshake), declare the symbol then.

Initial taxonomy (sample content, authored in plugin .ini only):

Tag Description
TypewriterText.Snippet Root namespace (parent).
TypewriterText.Snippet.Handshake Sample category — connection-handshake-flavored gibberish.
TypewriterText.Snippet.Scan Sample category — scan/probe-flavored gibberish.
TypewriterText.Snippet.Auth Sample category — authentication-step-flavored gibberish.

Authors who add new categories edit the plugin .ini and the sample CSV; no C++ edit required.

Footguns. - No project-namespace overlap. The TypewriterText.* root is plugin-owned; no Cradl.* or Combat.* tags inside the plugin per the user's naming-discipline call. - Don't preemptively add a CradlTags::AllTypewriterSnippetLeaves() accessor in the main module's tags header. The list-of-leaves accessor pattern (feedback_presentation_struct_resolve_tags.md cross-ref project_combat_style_leaves_refactor_deferred.md CradlTags::AllCombatStyleLeaves) exists for consumers that need a priority-ordered or canonical-listed leaf set. TypewriterText has no such consumer — selection is data-driven (filter the DataTable's rows by Category, not enumerate the namespace). If a future feature needs the leaf list, declare it locally in the plugin, not in CradlGameplayTags.h.


Forward Code References

PRs implementing TypewriterText land at these stable paths:

Migration follow-up (separate from plugin v1):


Open Questions

None for v1. The design surface is intentionally narrow:

  • Sound (per-character SFX) is deferred — discussed during design, explicitly out of scope. A future v2 with USoundBase* TypeSound, EveryNChars, and pitch-jitter UPROPERTYs is a clean extension point that does not require contract changes.
  • An editor-time validator for the snippet DataTable is not an open question — see Snippet Data Source Footguns for the deliberate-omission rationale. If a future maintainer wants one, that triggers a contract iterate, not a silent addition.
  • The DataTable iteration-order stability note in Snippet Data Source Footguns is a Phase-N polish, not an open question — the v1 behavior is acceptable for sample content.

Contract drafted at TYPEWRITERTEXT_SYSTEM.md. Review and tell me when aligned, or run /design-system TypewriterText again with iteration feedback. Run /design-system --phase=derive-implementation TypewriterText to derive the phased implementation doc from it.