t1k:cocos:playable:gameflow
| Field | Value |
|---|---|
| Module | playable |
| Version | 2.14.4 |
| Effort | high |
| Tools | โ |
Keywords: CTA, end card, game flow, gameflow, loading, UI views
How to invoke
Section titled โHow to invokeโ/t1k:cocos:playable:gameflowCocos Playable Game Flow
Section titled โCocos Playable Game Flowโ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).
โ Detect the project profile FIRST
Section titled โโ Detect the project profile FIRSTโ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.
ls -d assets/PLAGameFoundation assets/H5GameFoundation assets/packages/@playablelabs 2>/dev/null # framework rootls -d assets/PlayableParamterTool assets/scripts/parameter 2>/dev/null # parameter layergrep -rl "CTAService\|STORE_LINK" assets/scripts 2>/dev/null | head # CTA layerls assets/scripts/**/GameConfig.ts 2>/dev/null # tuning locationgrep -rl "class GameFlow\b" assets/scripts 2>/dev/null | head # ๐
H5 registered-state architecturegrep -rl "class ScreenManager\|class PopupService\|class ViewRegistry" assets/scripts 2>/dev/null | headgrep -rl "class GameView\b" assets/scripts 2>/dev/null | head # ๐
legacy view-stack architecture| Playable-ad profile | Gameplay-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 card | full end card with CTA button | result view, no CTA button |
| Loading | waits on AllAsyncParametersReadySignal | no parameter gate โ drive from asset/bundle load only |
| Framework root | db://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 owner | GameView 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 .active | ScreenManager (full-screen states) + PopupService (overlays) + ViewRegistry (view-id โ prefab resolution), runtime-spawned rather than scene-wired |
| Win / lose | WinView / 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 class | n/a (concrete Component subclasses) | BaseScreen โ the abstract base every registered full-screen state extends |
GameView.instance | present | gone โ 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.
Game State Machine
Section titled โGame State MachineโLOADING -> FTUE -> GAMEPLAY -> WIN / LOSEexport 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 / LOSERoute 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.
Core Components
Section titled โCore Componentsโ๐ Legacy view-stack profile
Section titled โ๐ Legacy view-stack profileโ- GameView (singleton) โ State machine.
onWin(),onLose(),onTutorialComplete() - LoadingView โ Progress bar. ๐
waits for
AllAsyncParametersReadySignaland is bound viaLoadingScreenParameter(replaces old scatteredLoadingBackgroundColor+LoadingIcon+LoadingGameNameparams). 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,subtitleLabelforParameterBinder.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 oldEndCardWinCTA/EndCardLoseCTAparams). - 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.
๐ H5 registered-state profile
Section titled โ๐ H5 registered-state profileโ- 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 isGameRoot, notGameFlow(see below). - State classes (typically Loading / Ftue / Gameplay / ChapterResult / Win / Lose) โ one class per
GameStatevalue, registered withGameFlowrather 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
PopupViewnever destroys the screen beneath it. - ViewRegistry โ resolves a view id to its prefab/class for
ScreenManager/PopupServiceto 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;GameRootthen asksPopupServiceto show the win/lose popup. Gameplay code never importsPopupServiceorViewRegistrydirectly โ it only ever calls throughGameRoot.
๐ Composite Parameter Binding for Views
Section titled โ๐ Composite Parameter Binding for Viewsโ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.
Workflow: Adding a New View/State
Section titled โWorkflow: Adding a New View/Stateโ๐ Legacy view-stack profile
Section titled โ๐ Legacy view-stack profileโ- Create component extending
Componentinassets/scripts/UI/ - Add @property(Node) reference in
GameView.ts - Hide in
GameView.hideAllViews() - Add transition method (e.g.,
onMyState()) settingcurrentState+ activating view - Wire parameters if needed (use
t1k-cocos-playable-parameterskill) - Assign in Cocos Editor Inspector
๐ H5 registered-state profile
Section titled โ๐ H5 registered-state profileโ- Create a component extending
BaseScreen(full-screen state) orPopupView(overlay). - Register the view id with
ViewRegistrysoScreenManager/PopupServicecan resolve and runtime-spawn it โ there is no@property(Node)scene wiring to add. - Add the state to
GameFlowโs registered-state set if it is a new full-screen state, or callPopupServicefrom the triggering code if it is a popup (win/lose and similar results are popups, not newGameFlowstates). - Wire parameters if needed (use
t1k-cocos-playable-parameterskill) โ most H5 projects have removed this layer; confirm with the profile probe first. - 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.
Signal Flow
Section titled โSignal FlowโParametersReadySignal ๐
sync params readyAllAsyncParametersReadySignal ๐
async params loaded (LoadingView waits for this)FirstInteractionSignal -- user first touch (InputService)TapSignal / SwipeSignal -- user input eventsThe 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.
Import Convention
Section titled โImport Conventionโ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.
Audio Integration
Section titled โAudio Integrationโ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.
๐ Store Links & CTA
Section titled โ๐ Store Links & CTAโ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.
Code Examples
Section titled โCode ExamplesโSee references/gameflow-code-examples.md for complete implementation patterns.
Gotchas
Section titled โGotchasโ- 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, becauseLoadingViewis waiting onAllAsyncParametersReadySignalin a project that has no parameters to load. - ๐
NEVER extend
EndCardViewfor an interstitial (between-rounds) view.EndCardView.onEnable()callssetupCTA()unconditionally, and whenfullScreenCTAis true it bindsTOUCH_ENDon the whole node โCTAService.handleCTAClick(). A card shown mid-gameplay would redirect the player to the store on any tap. Write a plainComponent(~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 acc.BlockInputEvents: Cocos dispatches a touch to the topmost node that registers a listener, so a bare dimmingSpriteoverlay does NOT stop taps from reaching the live board underneath. (On ๐ H5, the equivalent is: donโt route an interstitial throughPopupServiceโs win/lose popup path โ give it its ownPopupViewsubclass 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);
scheduleOncecallbacks 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 inonLoad(), and callunscheduleAllCallbacks()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.instanceis gone on the H5 profile โ never import or reference it, even defensively. A null-guardedGameView.instance?.onWin()still imports a class the projectโstscbuild doesnโt have; the H5 gameplay boundary isGameRoot.instance.onWin()/.onLose(). Porting legacy gameplay code that callsGameView.instancedirectly onto an H5 project is the most common way this mismatch surfaces โ grep forGameViewbefore assuming the legacy path. - ๐
Popup-over-gameplay is an invariant, not a style choice. Win/lose results are
PopupViews shown byPopupServiceover 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, neverPopupService/ViewRegistrydirectly.GameRoot.instance.onWin()/.onLose()is the one boundary gameplay crosses; if you find gameplay code importingPopupServiceto 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 anEventHandler(ButtonclickEvents, viewonWin/onLose/CTA handlers, custom[EventHandler]arrays) is invoked by Cocos with the EventHandlerโs CustomEventData string as its FIRST argument โ NOTundefined. So an optional-callback param guarded withonComplete?.()(guards only null/undefined) orif (onComplete)(a non-empty string is truthy) throwsonComplete is not a functionwhen fired from the Inspector. Guard withif (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 viaAudioContainer.instance.getAudioClip(name), andAudioContainer.buildAudioMap()keys clips byclip.name(the asset basename, no extension) built solely from the inspector-assignedaudioList: AudioClip[]. There is noresources/audiofallback โ a name not present inaudioListreturns null and only logs a warning (silent no-sound). So a new BGM/SFX must be (1) added to the sceneโsAudioContainercomponentaudioList, and (2) referenced by a name that exactly matches the asset basename (case- and space-sensitive).AUDIO_NAMEcomments like โplace X.mp3 in resources/audioโ are misleading โ the resources path is not consulted.