You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Phaser Version: 4.2.1 (the cited files are unchanged on master @ 02d8931, checked 2026-09-25)
Operating system: N/A (documentation)
Browser: N/A (checked in headless Chromium against the npm 4.2.1 build)
Description
The first-party agent skills in skills/ and the Phaser 4 Pixel Art Guide contain some claims that don't match the 4.2.1 source. Each item gives the file and line, the source that decides it, and what we saw at runtime.
skills/animations/SKILL.md, "Per-Frame Duration" (line 247) and gotcha 4 (line 459): per-frame duration is described as "added to the base msPerFrame" and "additive". Since 3.80 (When providing frames with duration to anims.create(), 41.6ms is added to every frame (1 frame time @24 FPS) #6712) it replaces msPerFrame for that frame. getFirstTick and getNextTick in src/animations/Animation.js use state.frameRate === state.currentAnim.frameRate ? state.currentFrame.duration || state.msPerFrame : state.msPerFrame. It is also ignored when play() passes a frameRate different from the animation's own. Runtime: at 10 fps, a frame with duration: 500 shows for 500 ms, not 600.
skills/groups-and-containers/SKILL.md, gotcha 4 (line 384): "A child's depth only orders within the Container." Container never sorts by depth. addHandler takes the child off the display list, so setDepth queues no sort, and ContainerWebGLRenderer / ContainerCanvasRenderer draw container.list in order. Children draw in list order unless you call container.sort('depth'). Layer, by contrast, does sort. Runtime: a depth-10 child added first draws under a depth-0 child added second, in both renderers.
skills/groups-and-containers/SKILL.md, Layer (lines 364–365): the mixin list omits EventEmitter and GameObject. Layer "is now a true GameObject" since 4.1.0 (Layer.js mixins; CHANGELOG-v4.1.0), and that is where the listed Filters and RenderSteps come from. Worth noting that layer instanceof Phaser.GameObjects.GameObject is still false at runtime, although phaser.d.ts declares class Layer extends GameObject (related: Layer is not extended from GameObject class #7295).
skills/cameras/SKILL.md (lines 457–458): const fx = cam.filters.external.addColorMatrix(); fx.grayscale(); throws TypeError: fx.grayscale is not a function. It should be fx.colorMatrix.grayscale() (src/filters/ColorMatrix.js), as skills/filters-and-postfx/SKILL.md already shows.
skills/cameras/SKILL.md (lines 443–444): internal is described as "applied by the system" and external as "for user-added filters". Both lists start empty and both are for user filters. Internal filters apply to things within the camera; external filters apply to the camera itself, in its rendering context (FilterList.js class doc). skills/filters-and-postfx/SKILL.md describes this correctly.
skills/cameras/SKILL.md (line 454): addGlow(0xff0000, 4, 0, false, 0.1, 10) follows Phaser 3's preFX.addGlow(color, outerStrength, innerStrength, knockout, quality, distance) order. The v4 signature is (color, outerStrength, innerStrength, scale, knockout, quality, distance), so scale becomes false. The glow gets zero radius and draws nothing, 0.1 lands in knockout, and 10 lands in quality. A v4 equivalent is addGlow(0xff0000, 4, 0, 1, false, 10, 10).
skills/events-system/SKILL.md, two things:
The "BAD: leaks listeners on every scene restart" example (lines 431–434) uses this.input.on('pointerdown', …). But InputPlugin#shutdown calls this.removeAllListeners(), which drops every listener on this.input, including user ones, and KeyboardPlugin does the same. What does accumulate is listeners on this.events, game.events, registry.events and scale. Runtime, across three scene starts: this.input pointerdown listeners 1, 1, 1; game.events blur listeners 1, 2, 3.
The "CRITICAL: always clean up" example (lines 130–137) registers its cleanup with this.events.on(SHUTDOWN, …), and that handler accumulates the same way. It should use once.
skills/time-and-timers/SKILL.md, gotcha 9 (line 480): it says a re-added TimerEvent "must not be in a completed state" and that the Clock will reset it, but not why. addEvent does reset elapsed and dispatch state. However, a finished event is destroyed at the Clock's next preUpdate (Clock.js), which clears its callback, scope and args (TimerEvent#destroy). Re-adding it on a later frame silently never fires and leaves it in the Clock's active list. Call reset(config) first, as the skill's "Timer Reset and Reuse" section does.
skills/scale-and-responsive/SKILL.md, "Pixel Art with Max Zoom" (lines 202–215): FIT with zoom: MAX_ZOOM is presented as giving crisp pixel rendering. But ScaleManager#updateScale uses zoom only in the Scale.NONE branch, so FIT sizes the canvas to the parent at a usually non-integer ratio: sharp with pixelArt, but with unevenly sized pixels. Also, MAX_ZOOM is resolved when the config is parsed and is only re-resolved by scale.setMaxZoom(). Runtime, 320×240 in a 1000×700 parent:
FIT + MAX_ZOOM displays at 933.33×700, identical to FIT alone.
NONE + MAX_ZOOM displays at 640×480.
For integer steps you can use Scale.NONE + MAX_ZOOM with setMaxZoom() on resize, or FIT with snap: { width: 320, height: 240 }.
skills/game-setup-and-config/SKILL.md, "v4 Changes from v3" (lines 456–458): three of the listed changes predate v4.
Scale.EXPAND is @since 3.80.0.
The 1024×768 default size dates from 3.0.0.
loader.maxRetries was added in 3.85.0, with default 2.
Phaser 4 Pixel Art Guide (lines 29 and 320): "Apply a Pixelate filter: addBlocky(6)". addBlocky takes a config object, so the 6 is silently ignored and you get a size-4 Blocky filter. It should be addPixelate(6); the guide's own Pixelate section (line 261) is correct.
Related, and possibly an engine issue rather than a docs one: sprite.play({ key, duration }) ignores duration. AnimationState#play fills a missing frameRate from the animation (GetFastValue(key, 'frameRate', anim.frameRate)), so calculateDuration never takes its duration branch unless frameRate: null is also passed. PlayAnimationConfig documents duration as an override.
Example Test Code
// Item 2: depth inside a Containerconstc=this.add.container(0,0);constred=this.add.rectangle(50,50,40,40,0xff0000).setDepth(10);constblue=this.add.rectangle(60,60,40,40,0x0000ff).setDepth(0);c.add([red,blue]);// blue draws on top; c.sort('depth') puts red on top// Item 8: re-adding a finished TimerEventconstt=this.time.addEvent({delay: 100,callback: ()=>console.log('fired')});this.time.delayedCall(500,()=>this.time.addEvent(t));// 'fired' logs only once// Item 11: addBlocky with a numberconstf=this.cameras.main.filters.external.addBlocky(6);console.log(f.size);// { x: 4, y: 4 }; addPixelate(6) gives amount 6
Additional Information
Every item was checked against the 4.2.1 source and reproduced in headless Chromium with the npm build. I can open a PR with these corrections if that's easier.
Version
master@ 02d8931, checked 2026-09-25)Description
The first-party agent skills in
skills/and the Phaser 4 Pixel Art Guide contain some claims that don't match the 4.2.1 source. Each item gives the file and line, the source that decides it, and what we saw at runtime.skills/animations/SKILL.md, "Per-Frame Duration" (line 247) and gotcha 4 (line 459): per-framedurationis described as "added to the base msPerFrame" and "additive". Since 3.80 (When providing frames with duration to anims.create(), 41.6ms is added to every frame (1 frame time @24 FPS) #6712) it replaces msPerFrame for that frame.getFirstTickandgetNextTickinsrc/animations/Animation.jsusestate.frameRate === state.currentAnim.frameRate ? state.currentFrame.duration || state.msPerFrame : state.msPerFrame. It is also ignored whenplay()passes aframeRatedifferent from the animation's own. Runtime: at 10 fps, a frame withduration: 500shows for 500 ms, not 600.skills/groups-and-containers/SKILL.md, gotcha 4 (line 384): "A child'sdepthonly orders within the Container." Container never sorts by depth.addHandlertakes the child off the display list, sosetDepthqueues no sort, andContainerWebGLRenderer/ContainerCanvasRendererdrawcontainer.listin order. Children draw in list order unless you callcontainer.sort('depth'). Layer, by contrast, does sort. Runtime: a depth-10 child added first draws under a depth-0 child added second, in both renderers.skills/groups-and-containers/SKILL.md, Layer (lines 364–365): the mixin list omitsEventEmitterandGameObject. Layer "is now a true GameObject" since 4.1.0 (Layer.jsmixins; CHANGELOG-v4.1.0), and that is where the listed Filters and RenderSteps come from. Worth noting thatlayer instanceof Phaser.GameObjects.GameObjectis still false at runtime, althoughphaser.d.tsdeclaresclass Layer extends GameObject(related: Layer is not extended from GameObject class #7295).skills/cameras/SKILL.md(lines 457–458):const fx = cam.filters.external.addColorMatrix(); fx.grayscale();throwsTypeError: fx.grayscale is not a function. It should befx.colorMatrix.grayscale()(src/filters/ColorMatrix.js), asskills/filters-and-postfx/SKILL.mdalready shows.skills/cameras/SKILL.md(lines 443–444):internalis described as "applied by the system" andexternalas "for user-added filters". Both lists start empty and both are for user filters. Internal filters apply to things within the camera; external filters apply to the camera itself, in its rendering context (FilterList.jsclass doc).skills/filters-and-postfx/SKILL.mddescribes this correctly.skills/cameras/SKILL.md(line 454):addGlow(0xff0000, 4, 0, false, 0.1, 10)follows Phaser 3'spreFX.addGlow(color, outerStrength, innerStrength, knockout, quality, distance)order. The v4 signature is(color, outerStrength, innerStrength, scale, knockout, quality, distance), soscalebecomesfalse. The glow gets zero radius and draws nothing, 0.1 lands inknockout, and 10 lands inquality. A v4 equivalent isaddGlow(0xff0000, 4, 0, 1, false, 10, 10).skills/events-system/SKILL.md, two things:this.input.on('pointerdown', …). ButInputPlugin#shutdowncallsthis.removeAllListeners(), which drops every listener onthis.input, including user ones, and KeyboardPlugin does the same. What does accumulate is listeners onthis.events,game.events,registry.eventsandscale. Runtime, across three scene starts:this.inputpointerdown listeners 1, 1, 1;game.eventsblur listeners 1, 2, 3.this.events.on(SHUTDOWN, …), and that handler accumulates the same way. It should useonce.skills/time-and-timers/SKILL.md, gotcha 9 (line 480): it says a re-added TimerEvent "must not be in a completed state" and that the Clock will reset it, but not why.addEventdoes reset elapsed and dispatch state. However, a finished event is destroyed at the Clock's nextpreUpdate(Clock.js), which clears its callback, scope and args (TimerEvent#destroy). Re-adding it on a later frame silently never fires and leaves it in the Clock's active list. Callreset(config)first, as the skill's "Timer Reset and Reuse" section does.skills/scale-and-responsive/SKILL.md, "Pixel Art with Max Zoom" (lines 202–215): FIT withzoom: MAX_ZOOMis presented as giving crisp pixel rendering. ButScaleManager#updateScaleuseszoomonly in theScale.NONEbranch, so FIT sizes the canvas to the parent at a usually non-integer ratio: sharp withpixelArt, but with unevenly sized pixels. Also,MAX_ZOOMis resolved when the config is parsed and is only re-resolved byscale.setMaxZoom(). Runtime, 320×240 in a 1000×700 parent:Scale.NONE+MAX_ZOOMwithsetMaxZoom()on resize, or FIT withsnap: { width: 320, height: 240 }.skills/game-setup-and-config/SKILL.md, "v4 Changes from v3" (lines 456–458): three of the listed changes predate v4.Scale.EXPANDis@since 3.80.0.loader.maxRetrieswas added in 3.85.0, with default 2.Phaser 4 Pixel Art Guide (lines 29 and 320): "Apply a Pixelate filter:
addBlocky(6)".addBlockytakes a config object, so the 6 is silently ignored and you get a size-4 Blocky filter. It should beaddPixelate(6); the guide's own Pixelate section (line 261) is correct.Related, and possibly an engine issue rather than a docs one:
sprite.play({ key, duration })ignoresduration.AnimationState#playfills a missingframeRatefrom the animation (GetFastValue(key, 'frameRate', anim.frameRate)), socalculateDurationnever takes its duration branch unlessframeRate: nullis also passed.PlayAnimationConfigdocumentsdurationas an override.Example Test Code
Additional Information
Every item was checked against the 4.2.1 source and reproduced in headless Chromium with the npm build. I can open a PR with these corrections if that's easier.