Skip to content

t1k:cocos:playable:gameflow

FieldValue
Moduleplayable
Version2.14.4
Efforthigh
Toolsโ€”

Keywords: CTA, end card, game flow, gameflow, loading, UI views

/t1k:cocos:playable:gameflow

This skill handles game states, views, loading screens, end cards, and CTA integration. Does NOT handle parameter definitions (use t1k-cocos-playable-parameter) or SDK adapters (use t1k-cocos-playable-sdk-core).

The state machine concept is universal; which classes implement it is not. Probe the filesystem before writing any code โ€” never assume a layer or an architecture exists because this skill documents it. There are two independent axes: the tunable-layer axis (dashboard parameters / ad-network CTA) and the view architecture axis (which classes own state + screen/popup lifecycle). Probe both.

Terminal window
ls -d assets/PLAGameFoundation assets/H5GameFoundation assets/packages/@playablelabs 2>/dev/null # framework root
ls -d assets/PlayableParamterTool assets/scripts/parameter 2>/dev/null # parameter layer
grep -rl "CTAService\|STORE_LINK" assets/scripts 2>/dev/null | head # CTA layer
ls assets/scripts/**/GameConfig.ts 2>/dev/null # tuning location
grep -rl "class GameFlow\b" assets/scripts 2>/dev/null | head # ๐Ÿ…— H5 registered-state architecture
grep -rl "class ScreenManager\|class PopupService\|class ViewRegistry" assets/scripts 2>/dev/null | head
grep -rl "class GameView\b" assets/scripts 2>/dev/null | head # ๐Ÿ…› legacy view-stack architecture
Playable-ad profileGameplay-only profile (e.g. H5 template)
Dashboard parametersโœ… PlayableConfig / ParameterBinder / AllAsyncParametersReadySignalโŒ removed โ€” tune in GameConfig.ts
Ad-network CTAโœ… CTAService + STORE_LINKโŒ no CTA service; store routing is an optional host integration, not wired by default
End cardfull end card with CTA buttonresult view, no CTA button
Loadingwaits on AllAsyncParametersReadySignalno parameter gate โ€” drive from asset/bundle load only
Framework rootdb://assets/packages/@playablelabs/game-foundation/โ€ฆwhatever the probe found (H5GameFoundation, @playablelabs/*, โ€ฆ)

Sections below marked ๐Ÿ…Ÿ apply to the playable-ad profile only (dashboard-parameter / CTA axis). On a gameplay-only project, skip them โ€” do not reintroduce PlayableConfig, ParameterBinder, CTAService, STORE_LINK, or a parameter-gated loading screen into a project that deliberately removed them.

View architecture is a SEPARATE axis โ€” probe it independently of the tunable-layer axis above:

๐Ÿ…› Legacy view-stack๐Ÿ…— H5 registered-state
State ownerGameView singleton (GameView.instance)GameFlow + registered state classes (typically Loading / Ftue / Gameplay / ChapterResult / Win / Lose โ€” six states, one per GameState value)
Screen/popup lifecycle@property(Node) refs on GameView, toggled .activeScreenManager (full-screen states) + PopupService (overlays) + ViewRegistry (view-id โ†’ prefab resolution), runtime-spawned rather than scene-wired
Win / loseWinView / LoseView extend EndCardView, toggled by GameView.onWin()/.onLose()ordinary popups shown via PopupService; GameRoot.instance.onWin() / .onLose() is the gameplay boundary that triggers them โ€” popup-over-gameplay is an invariant, gameplay never destroys or replaces itself for a win/lose result
Base screen classn/a (concrete Component subclasses)BaseScreen โ€” the abstract base every registered full-screen state extends
GameView.instancepresentgone โ€” do not import or reference GameView on this profile

Sections below marked ๐Ÿ…› apply to the legacy view-stack profile only; sections marked ๐Ÿ…— apply to the H5 registered-state profile only. Writing ๐Ÿ…› code (a GameView property, hideAllViews(), extending EndCardView) into an ๐Ÿ…— project reintroduces a class the project doesnโ€™t have and the compiler will catch โ€” but writing ๐Ÿ…—-shaped guidance (registering a new GameFlow state) into a ๐Ÿ…› project invents classes that were never there either. If the probe is ambiguous โ€” neither GameFlow nor GameView is found โ€” ask rather than guess; both architectures are expensive to unwind once code assumes the wrong one.

LOADING -> FTUE -> GAMEPLAY -> WIN / LOSE
export enum GameState { LOADING, FTUE, GAMEPLAY, WIN, LOSE }

Ads that chain several rounds add one interstitial state between rounds:

LOADING -> FTUE -> GAMEPLAY -> [CHAPTER_RESULT -> GAMEPLAY]* -> WIN / LOSE

Route every round outcome โ€” win, lose, and timeout alike โ€” through a single handler that decides โ€œinterstitial vs end/resultโ€. A secondary path that calls the win/lose entry point directly (a countdown timer is the usual culprit) skips the chain entirely and ends the round on the first loss. ๐Ÿ…› that handler is onLevelFinished(won) on GameView. ๐Ÿ…— it is whatever calls GameRoot.instance.onWin() / .onLose() โ€” route through one place, not scattered call sites.

The six GameState values above map 1:1 to the ๐Ÿ…— profileโ€™s six registered GameFlow states (Loading / Ftue / Gameplay / ChapterResult / Win / Lose) โ€” same state machine, different owner class. Confirm the exact registered set in the projectโ€™s own GameFlow registration code before assuming names; this skill documents the shape, not a byte-exact API for every consumer.

  • GameView (singleton) โ€” State machine. onWin(), onLose(), onTutorialComplete()
  • LoadingView โ€” Progress bar. ๐Ÿ…Ÿ waits for AllAsyncParametersReadySignal and is bound via LoadingScreenParameter (replaces old scattered LoadingBackgroundColor + LoadingIcon + LoadingGameName params). Without the parameter layer, resolve the loading promise from asset/bundle load alone โ€” a project with no parameters will otherwise wait forever on a signal that never fires.
  • EndCardView (abstract) โ€” Base for end cards, plays audio. ๐Ÿ…Ÿ triggers CTA and exposes backgroundSprite, titleLabel, subtitleLabel for ParameterBinder.bindEndCard(). Without the CTA layer this is a plain result view โ€” keep the audio, drop the CTA button.
  • WinView / LoseView โ€” Extend EndCardView, provide audio name. ๐Ÿ…Ÿ bound via EndCardParameter (replaces old EndCardWinCTA / EndCardLoseCTA params).
  • CTAService ๐Ÿ…Ÿ โ€” Routes CTA click to correct store per SDK
  • PlayableHelper ๐Ÿ…Ÿ โ€” First touch -> BGM, optional redirect-after-N-clicks. The first-touch-starts-BGM half is generally useful; the redirect half is ad-only.
  • GameFlow (singleton) โ€” Owns the state machine. Registers the projectโ€™s state classes at boot; drives transitions between them. There is no GameFlow.instance.onWin() โ€” the gameplay boundary is GameRoot, not GameFlow (see below).
  • State classes (typically Loading / Ftue / Gameplay / ChapterResult / Win / Lose) โ€” one class per GameState value, registered with GameFlow rather than scene-wired as @property(Node) refs.
  • ScreenManager โ€” owns full-screen state transitions (loading โ†’ ftue โ†’ gameplay). Each managed screen extends BaseScreen.
  • PopupService โ€” owns overlay lifecycle (win/lose results, confirmation dialogs, anything shown over the current screen rather than replacing it). A PopupView never destroys the screen beneath it.
  • ViewRegistry โ€” resolves a view id to its prefab/class for ScreenManager/PopupService to runtime-spawn. There is no scene-wired @property(Node) per view on this profile โ€” views are instantiated on demand, not pre-placed and toggled.
  • BaseScreen / PopupView โ€” the abstract bases every registered full-screen state / popup extends.
  • GameRoot (singleton) โ€” the gameplay boundary. GameRoot.instance.onWin() / GameRoot.instance.onLose() is what gameplay code calls; GameRoot then asks PopupService to show the win/lose popup. Gameplay code never imports PopupService or ViewRegistry directly โ€” it only ever calls through GameRoot.

Playable-ad profile only. Skip this section entirely if the profile probe found no parameter layer โ€” tune in GameConfig.ts instead.

EndCardParameter and LoadingScreenParameter are composite types that bundle all view fields into one dashboard group. Use ParameterBinder to apply them:

// In ParameterController.SetUpOnUpdate():
PlayableConfig.EndCardWin.onUpdate = (config) => {
const p = ParameterBinder.bindEndCard(this.winView, config);
if (p) this._spriteUpdatePromises.push(p);
};
PlayableConfig.LoadingScreen.onUpdate = (config) => {
const p = ParameterBinder.bindLoadingScreen(this.loadingView, config);
if (p) this._spriteUpdatePromises.push(p);
};

See t1k-cocos-playable-parameter skill for composite type definitions and migration guide.

  1. Create component extending Component in assets/scripts/UI/
  2. Add @property(Node) reference in GameView.ts
  3. Hide in GameView.hideAllViews()
  4. Add transition method (e.g., onMyState()) setting currentState + activating view
  5. Wire parameters if needed (use t1k-cocos-playable-parameter skill)
  6. Assign in Cocos Editor Inspector
  1. Create a component extending BaseScreen (full-screen state) or PopupView (overlay).
  2. Register the view id with ViewRegistry so ScreenManager/PopupService can resolve and runtime-spawn it โ€” there is no @property(Node) scene wiring to add.
  3. Add the state to GameFlowโ€™s registered-state set if it is a new full-screen state, or call PopupService from the triggering code if it is a popup (win/lose and similar results are popups, not new GameFlow states).
  4. Wire parameters if needed (use t1k-cocos-playable-parameter skill) โ€” most H5 projects have removed this layer; confirm with the profile probe first.
  5. Verify in the running game, not the Inspector โ€” runtime-spawned views have no scene node to assign; there is nothing to wire in the editor for this step.
ParametersReadySignal ๐Ÿ…Ÿ sync params ready
AllAsyncParametersReadySignal ๐Ÿ…Ÿ async params loaded (LoadingView waits for this)
FirstInteractionSignal -- user first touch (InputService)
TapSignal / SwipeSignal -- user input events

The two parameter signals exist only where the parameter layer does. On a gameplay-only project the input signals remain; nothing should await a parameters-ready signal.

Always use the db:// protocol for cross-package imports โ€” but resolve the framework root from the project, donโ€™t hardcode one. At least three roots are in circulation: assets/PLAGameFoundation/ (classic playable), assets/H5GameFoundation/ (H5 template), and assets/packages/@playablelabs/* (CPM packages, which prefer the barrel import @playablelabs/game-foundation). Use whichever the profile probe found, and match the import style already used by the files around you.

// <FOUNDATION> = the root your probe found: PLAGameFoundation | H5GameFoundation | โ€ฆ
import { SignalBus } from "db://assets/<FOUNDATION>/signalBus/SignalBus";
import { AudioService } from "db://assets/<FOUNDATION>/gameControl/utilities/AudioSystem/AudioService";
// ๐Ÿ…› legacy view-stack profile only โ€” GameView does not exist on the ๐Ÿ…— H5 profile:
import { GameView } from "db://assets/scripts/UI/GameView";
// ๐Ÿ…— H5 registered-state profile only:
import { GameRoot } from "db://assets/scripts/GameRoot";
// ๐Ÿ…Ÿ parameter-layer imports โ€” only where PlayableParamterTool exists:
import { SdkType } from "db://assets/packages/@playablelabs/parameter-tool/GameConfig";

โš ๏ธ db://assets/packages/@playablelabs/parameter-tool/GameConfig is the parameter toolโ€™s GameConfig, and is unrelated to the gameplay-tuning GameConfig.ts a gameplay-only project keeps under assets/scripts/. Same filename, different file โ€” donโ€™t import the former to reach the latter.

AudioService.instance.playMusic(Constant.AUDIO_NAME.BGM);
AudioService.instance.playSFX(Constant.AUDIO_NAME.WIN);

Audio files in resources/audio/. Register names in constant.ts -> AUDIO_NAME.

Playable-ad profile only.

// constant.ts: STORE_LINK = { ANDROID_LINK, IOS_LINK }
// CTAService routes per SDK: THE_ONE -> gameEndHandler, VOODOO -> ParameterManager.redirect()

On a gameplay-only project there is no CTAService and no STORE_LINK, and the host redirect is typically not wired at all. Treat store routing as an optional integration the host owns: leave the result view without a CTA button and surface the gap, rather than authoring a redirect the project deliberately dropped.

See references/gameflow-code-examples.md for complete implementation patterns.

  • A missing layer is a project decision, not a gap to fill. When porting gameplay into a project whose parameter/dashboard or ad-network SDK layer was deliberately removed, following this skillโ€™s playable-ad sections reintroduces PlayableConfig, ParameterBinder, CTAService, STORE_LINK, and dead framework imports โ€” all of which the target excluded on purpose. Run the profile probe first and keep tuning where the project keeps it (GameConfig.ts). The sharpest symptom of getting this wrong is a loading screen that never completes, because LoadingView is waiting on AllAsyncParametersReadySignal in a project that has no parameters to load.
  • ๐Ÿ…› NEVER extend EndCardView for an interstitial (between-rounds) view. EndCardView.onEnable() calls setupCTA() unconditionally, and when fullScreenCTA is true it binds TOUCH_END on the whole node โ†’ CTAService.handleCTAClick(). A card shown mid-gameplay would redirect the player to the store on any tap. Write a plain Component (~80 lines) instead; carving a โ€œdisable CTAโ€ hole into the base class breaks the two real end cards that depend on it. Also give the interstitial a cc.BlockInputEvents: Cocos dispatches a touch to the topmost node that registers a listener, so a bare dimming Sprite overlay does NOT stop taps from reaching the live board underneath. (On ๐Ÿ…— H5, the equivalent is: donโ€™t route an interstitial through PopupServiceโ€™s win/lose popup path โ€” give it its own PopupView subclass so it never inherits win/lose-specific input handling.)
  • A restartable level breaks everything written for a one-shot level. The moment a round can rebuild in place (chapter chains, retry-on-lose), audit for: state armed by a once-per-playthrough signal (a level-selected signal fires once, but per-round freezes fire every round); scheduleOnce callbacks that outlive teardown and land on the next round; pooled visual state left dirty by the previous round; and values captured lazily on first use, which then inherit round Nโˆ’1 instead of the editor default. Capture such defaults exactly once in onLoad(), and call unscheduleAllCallbacks() in teardown.
  • ๐Ÿ…› GameView lifecycle owns the FSM, not vice versa โ€” destroying GameView while FSM holds a reference dangles the stateโ€™s this.
  • Scene transitions during state.enter() are a footgun โ€” the next state runs onLoad against an unmounted parent.
  • ๐Ÿ…— GameView.instance is gone on the H5 profile โ€” never import or reference it, even defensively. A null-guarded GameView.instance?.onWin() still imports a class the projectโ€™s tsc build doesnโ€™t have; the H5 gameplay boundary is GameRoot.instance.onWin() / .onLose(). Porting legacy gameplay code that calls GameView.instance directly onto an H5 project is the most common way this mismatch surfaces โ€” grep for GameView before assuming the legacy path.
  • ๐Ÿ…— Popup-over-gameplay is an invariant, not a style choice. Win/lose results are PopupViews shown by PopupService over the still-live gameplay screen โ€” gameplay is never destroyed, unloaded, or replaced to show a result. Code that tears down the gameplay screen before showing win/lose breaks any โ€œpeek behind the popupโ€ or retry-in-place flow the popup depends on.
  • ๐Ÿ…— Gameplay code calls through GameRoot, never PopupService/ViewRegistry directly. GameRoot.instance.onWin()/.onLose() is the one boundary gameplay crosses; if you find gameplay code importing PopupService to show its own popup, that is coupling gameplay to the presentation layer the H5 architecture was built to separate out.
  • Inspector-wired callback params must guard with typeof param === 'function', never bare ?.() or a truthy check. Any public method you wire from the Cocos Inspector via an EventHandler (Button clickEvents, view onWin/onLose/CTA handlers, custom [EventHandler] arrays) is invoked by Cocos with the EventHandlerโ€™s CustomEventData string as its FIRST argument โ€” NOT undefined. So an optional-callback param guarded with onComplete?.() (guards only null/undefined) or if (onComplete) (a non-empty string is truthy) throws onComplete is not a function when fired from the Inspector. Guard with if (typeof onComplete === 'function') onComplete(); so the same handler works called from code OR from an Inspector EventHandler (string arg ignored, no crash).
  • Audio clips resolve ONLY from the in-scene AudioContainer.audioList. AudioService.playMusic(name) / playSfx(name) look the clip up via AudioContainer.instance.getAudioClip(name), and AudioContainer.buildAudioMap() keys clips by clip.name (the asset basename, no extension) built solely from the inspector-assigned audioList: AudioClip[]. There is no resources/audio fallback โ€” a name not present in audioList returns null and only logs a warning (silent no-sound). So a new BGM/SFX must be (1) added to the sceneโ€™s AudioContainer component audioList, and (2) referenced by a name that exactly matches the asset basename (case- and space-sensitive). AUDIO_NAME comments like โ€œplace X.mp3 in resources/audioโ€ are misleading โ€” the resources path is not consulted.