22 KiB
22 KiB
Phaser 3 Engineering Standards
Use this reference when creating, extending, or reviewing a Phaser 3 game. It defines a small, reliable default and the conditions under which that default should grow.
Rule levels
- Required rules apply to all new work. In an existing project, preserve a sound established convention when changing it would be unrelated or risky; record the exception.
- Conditional guidance applies only when its stated condition is true. Do not add its machinery in anticipation of possible future needs.
Contents
- Compatibility baseline
- Grow-on-demand structure
- Scenes and lifecycle
- Assets and loading
- Input actions
- Physics choice and rules
- Scale and responsive layout
- State ownership
- Time, delta, and determinism
- Tuning and configuration
- Verification and completion
- Common failure patterns
- Official references
Compatibility baseline
Required
- For a new project, declare the exact dependency
"phaser": "3.90.0"and commit the package-manager lockfile. Do not uselatest,^3.90.0,~3.90.0, or an unversioned install command.latestnow resolves to Phaser 4 and can silently change the API surface. - Start new projects with Vite, the
vanilla-tstemplate, strict TypeScript, and no UI framework. SetcompilerOptions.stricttotrue; fix type errors instead of broadly disabling checks or spreadingany. - Use ES modules and keep one obvious browser entry point that creates exactly one
Phaser.Gameinstance. - Use the Phaser 3.90 API documentation as the authority. Official examples may target an earlier Phaser 3 release, so verify copied APIs against 3.90 and reject Phaser 2 or 4 code.
- Keep the dependency surface small. A library must solve a present, demonstrated need and must not duplicate a Phaser facility already sufficient for the game.
Conditional
- In an existing project, keep its working bundler, language, package manager, and framework unless migration is explicitly in scope.
- Add React, Vue, Svelte, or another UI framework only when Phaser is embedded in an application whose non-game UI already needs it. Keep the Phaser game boundary explicit.
- Upgrade Phaser only as a deliberate compatibility task with release-note review and a complete browser regression pass.
Grow-on-demand structure
Required
Begin with the smallest structure that gives the current game clear ownership:
index.html
src/
main.ts
game/
config.ts
scenes/
GameScene.ts
public/
assets/
main.tsowns game startup only;config.tsownsPhaser.Types.Core.GameConfig; Scenes own presentation and orchestration.- Keep a one-scene prototype in one scene file until a real boundary appears.
- Organize new files by cohesive gameplay system or responsibility, not by generic class type alone.
- Delete abandoned experiments and unused assets once a direction is chosen. Do not leave two active implementations of the same rule.
- Avoid speculative architecture: no service container, event bus, ECS, repository layer, global state library, or plugin framework without a present need.
Conditional
- Add
BootScenewhen startup configuration or a tiny boot asset set must load before the main preload. - Add
PreloadScenewhen load time is visible enough to need progress, error, or retry UI. - Add
MenuScene,ResultScene, or overlay Scenes when they have distinct lifecycle, input, or rendering needs—not merely to shorten a file. - Extract
gameplay/,ui/,audio/,data/, orshared/only after each has more than one cohesive consumer. - Introduce a pure domain module when rules such as scoring, progression, spawning, or combat can be expressed without Phaser objects; this is also the preferred unit-test boundary.
Scenes and lifecycle
Phaser starts a Scene through init → preload → create, then calls update(time, delta) while it is running. A stopped Scene instance can start again; its constructor is not a per-run reset hook.
Required
- Give every Scene one stable, unique key. Keep its constructor limited to
super(...)and values that truly live for the entire Scene instance. - Reset every run-specific flag, counter, collection, and reference in
initorcreate. A restart must behave like a fresh run. - Load required assets in
preloadbefore using them increate. Keep gameplay setup out ofpreload. - Create input handlers, colliders, timers, tweens, and subscriptions from one visible setup path. Prevent a second registration on restart.
- Treat
shutdownas the cleanup boundary. Remove anything not owned and cleaned by the Scene systems:windowordocumentlisteners, game/registry/external-emitter listeners, observers, intervals, network subscriptions, and retained callbacks. - Use
destroyonly for final Scene removal. Do not rely on it for ordinary stop, start, or restart cleanup. - Guard terminal transitions so collisions or consecutive update frames cannot start, stop, or restart a Scene twice.
- Pass small typed data objects between Scenes. Never pass live Game Objects, physics bodies, cameras, or Scene plugins as shared state.
- Make
this.scene.restart()safe to run repeatedly. Smoke-test at least two complete restarts. - Guard global animation creation with
this.anims.exists(key)or create global animations once in a dedicated startup path.
Conditional
- When using
sleep/wake, handle their events explicitly: sleep does not run shutdown, and wake does not reruncreate. - When using
pause/resume, decide which timers, physics, input, audio, and overlay Scenes should pause; verify the decision rather than assuming all systems share one clock. - Run a separate UI Scene above gameplay only when UI must retain an independent lifecycle. Define which Scene owns shared data and transition authority.
- Stream assets during play only when level size requires it; specify loading, cancellation, cache, and failure behavior first.
Assets and loading
Required
- Give every texture, atlas, animation, audio clip, tilemap, and data file a stable semantic key. Centralize keys as typed constants; do not scatter raw string keys through gameplay code.
- Use namespaced keys such as
player.body,player.run, andaudio.hitto avoid collisions and clarify asset type and ownership. - Keep filenames lowercase and portable; avoid spaces, case-only distinctions, and platform-specific path separators.
- For Vite assets placed under
public/assets, derive the loader base fromimport.meta.env.BASE_URL. Do not assume the game is always hosted at domain root/. - Surface load failures. A missing required asset must produce a clear error or retry state, not a silent blank canvas.
- Load each shared asset once and reuse Phaser's global caches. Do not reload the same level-independent asset in every Scene.
- Keep source art separate from runtime exports. Commit only optimized runtime formats and sizes the game actually uses.
- Match spritesheet frame dimensions exactly. Verify atlas frame names, animation ranges, texture filtering, and origins in the browser.
Conditional
- Use a texture atlas when many small sprites are displayed together or a production art pipeline already emits one.
- Enable
pixelArtand disable smoothing only for intentional pixel art; render at integer-friendly scales and inspect movement for shimmer. - Preload alternate audio formats when the supported browser matrix requires them. Start or resume audio only after a user gesture and preserve the user's mute setting.
- Add asset manifests, generated key types, compression, or lazy loading only when asset volume makes manual management error-prone or initial load materially slow.
Input actions
Required
- Translate devices into semantic actions such as
moveLeft,jump,confirm,pause, andrestart. Gameplay rules consume actions, not scattered key codes or pointer events. - Distinguish held actions from edge-triggered actions. Poll
isDownfor continuous movement; usePhaser.Input.Keyboard.JustDown,JustUp, or explicit event edges for one-shot actions. - Keep input registration and teardown with the owning Scene. Clear latched actions on shutdown, pause, focus loss, and device disconnection.
- Prevent browser defaults for gameplay keys such as arrows or Space when scrolling would interfere with play.
- Convert pointer screen coordinates through the relevant camera before using them as world coordinates.
- Ensure one physical gesture produces at most one gameplay command, even when keyboard, pointer, and UI handlers overlap.
- Keep restart, pause, and debug controls separate from the core movement state so they cannot remain stuck.
Conditional
- When touch is supported, provide visible touch controls or direct gestures; test multi-touch, finger occlusion, cancellation, and both target orientations.
- When gamepads are supported, handle connect/disconnect at runtime, dead zones, axis normalization, and a keyboard fallback.
- Add rebinding and persisted mappings when the brief or accessibility requirements call for them. Store semantic action mappings, not engine objects.
- Use input buffering or coyote time only when the design calls for forgiving timing; keep its duration in tuning configuration.
Physics choice and rules
Choose the least complex model that can express the game:
| Need | Choice |
|---|---|
| No collision or only simple range checks | Manual movement and geometry checks |
| Fast axis-aligned or circular collision, platformer, top-down movement | Arcade Physics |
| Rotated/compound bodies, joints, torque, stacking, physical simulation | Matter Physics |
Required
- Default to Arcade Physics for ordinary arcade gameplay. Use Matter only when a documented mechanic needs its capabilities.
- Use one physics system for interacting objects. Arcade and Matter bodies do not collide with each other; never attach the same Game Object to both.
- Specify units for velocity, acceleration, gravity, dimensions, and cooldowns. Treat Arcade velocity as pixels per second; do not multiply a velocity passed to Arcade by frame delta.
- Match physics bodies to the intended collision silhouette, not blindly to transparent texture bounds. Recheck offsets after changing scale or origin.
- Separate collision (
collider, with resolution) from trigger detection (overlap, without resolution) intentionally. - Make collision callbacks idempotent or guard them; physics can report contact across multiple steps.
- Define world bounds and out-of-bounds behavior explicitly. Do not let important objects disappear forever without a recovery or terminal rule.
- Keep physics debug rendering development-only and disable it in production builds.
Conditional
- After moving or scaling an Arcade static body, call
refreshBody()so its body matches its visual transform. - Use Arcade collision categories, body masks, immovable/pushable settings, and custom process callbacks when layers have distinct interaction rules.
- For Matter, define body shapes, friction, restitution, mass/density, collision categories, and sleeping deliberately; test unstable stacks and high-speed tunneling.
- Use a fixed simulation strategy or seeded replay only when determinism is a product requirement. Isolate rendering interpolation from authoritative simulation state.
Scale and responsive layout
Required
- Choose one logical game resolution and design gameplay in those world units.
- For new fixed-composition games, default to
Phaser.Scale.FITwithPhaser.Scale.CENTER_BOTH. Let CSS size the canvas container; do not derive simulation coordinates from CSS pixels. - Give the parent element an explicit usable size. Remove unintended page margins and prevent page scrolling around the game canvas.
- Anchor HUD and menus to camera/viewport bounds, not world objects, unless the UI is intentionally diegetic.
- Test the minimum and maximum supported aspect ratios. Critical play space, text, and controls must remain visible and usable.
- Recompute responsive layout from current dimensions; do not incrementally nudge old positions on every resize event.
- Keep camera bounds, physics bounds, and visible world bounds conceptually separate and configure each intentionally.
Conditional
- Use
RESIZEinstead ofFITonly when the design should reveal more or less world as the viewport changes. Listen for resize and relayout cameras, UI, and hit areas. - For mobile targets, account for orientation changes, browser chrome, display cutouts, and CSS safe-area insets. Do not place essential controls at unsafe edges.
- Raise render resolution for high-density displays only after profiling fill rate and texture memory; cap it on lower-powered mobile devices.
- Letterbox intentionally when preserving composition matters more than filling every pixel. Give the surrounding page a designed background.
State ownership
Use the narrowest owner that matches the state's lifetime:
| State lifetime | Preferred owner |
|---|---|
| One Game Object | That object or its focused controller |
| One Scene run | The Scene or a Scene-owned system |
| One play session across Scenes | A typed plain-data session store |
| Across browser sessions | A versioned persistence adapter |
| Static balance values | Read-only tuning/configuration modules |
Required
- Assign one authoritative writer for each piece of state. Other systems receive snapshots, queries, or explicit commands.
- Keep rules state as plain serializable data where practical. Keep Phaser Game Objects, bodies, sounds, and timers out of saves and cross-Scene stores.
- Separate definition data, current runtime state, and rendered view. Do not mutate imported configuration to represent a run.
- Use the Phaser registry only behind named, typed accessors for genuinely game-global values. Do not turn it into an unstructured dumping ground.
- Make transitions explicit: title → playing → paused → result → restart. Reject commands invalid for the current state.
- Avoid mutable globals and values attached to
window. Destroy thePhaser.Gameinstance if the host page unmounts or recreates it. - Version persistent save data, validate it on load, and provide a safe fallback for corrupt or old data.
Conditional
- Use events for discrete notifications with multiple legitimate listeners. Use direct calls or returned values for single-owner commands and queries.
- Introduce a finite-state machine when booleans allow impossible combinations or transition guards are being duplicated.
- Add immutable updates, snapshots, or replay logs only when undo, deterministic replay, networking, or difficult state debugging requires them.
Time, delta, and determinism
Required
- Remember that
update(time, delta)receivesdeltain milliseconds. Convert once withconst dt = delta / 1000for manual velocities expressed per second. - Make manual movement, meters, cooldowns, spawning, and animation-independent rules frame-rate independent. Never express gameplay duration as a count of render frames.
- Do not multiply Arcade Physics velocity by
delta; the physics world already integrates it. - Prefer Scene-owned
this.timeevents and tweens to rawsetTimeoutorsetIntervalso lifecycle and pausing remain coherent. - Decide what happens on tab blur or a long frame. Pause, or clamp the delta used by manual integration to prevent a large teleport or instant timer drain.
- Remove or invalidate delayed callbacks when their owner shuts down. A callback from a previous run must never mutate the next run.
- Store durations with unit-bearing names such as
spawnIntervalMsorinvulnerabilitySeconds.
Conditional
- Use wall-clock time only for mechanics that must advance while the game is closed; validate clock jumps and persist a reference timestamp.
- Use
Phaser.Math.RandomDataGeneratorwith an explicit seed when reproducing a run, testing procedural output, or synchronizing simulations matters. - Use a custom fixed-step loop only when the design or networking model requires it. Define accumulation, maximum catch-up steps, interpolation, and overload behavior.
Tuning and configuration
Required
- Put player-facing balance values in small typed, read-only configuration objects grouped by system. Keep engine boot configuration separate from gameplay tuning.
- Name values by meaning and unit; include a short rationale or valid range when a number is not self-evident.
- Avoid repeated magic numbers in Scenes and callbacks. One mechanic must read from one source of truth.
- Keep tunable values traceable to the relevant game-design rule and acceptance criteria. When tuning changes the designed behavior, update both.
- Clamp user- or data-supplied values at system boundaries. Reject invalid level or content data with a useful message.
- Keep secrets and privileged rules off the client. Everything shipped to a browser can be inspected or modified.
Conditional
- Load level/content JSON when non-programmers need to edit repeated content or when many levels share one schema. Validate it before constructing gameplay objects.
- Add a development-only tuning panel when iteration speed justifies it. Ensure its state and debug commands cannot leak into production behavior.
- Introduce schema generation, migrations, or remote configuration only when content volume or live operations creates a current need.
Verification and completion
Required automated checks
- Install from the committed lockfile in a clean environment.
- Run strict type checking and every existing lint or automated test command.
- Run the production build. A development server compiling successfully is not a substitute.
- Keep rules such as score calculation, spawn selection, damage, progression, and save migration pure where possible, and add focused tests when they are nontrivial or regression-prone.
Required browser smoke check
Run the built game in a real browser, preferably from the production preview, and verify:
- The page loads into the intended first interactive state.
- The primary input performs the core action and visible/audio feedback occurs.
- The player can reach the success or failure result through the intended loop.
- Restart returns to a clean initial run; complete and restart the loop at least twice.
- There are no new console errors, unhandled promise rejections, failed required asset requests, or duplicate-event symptoms.
- Pause/focus loss and return do not cause stuck input, a time jump, or resumed sounds that should remain paused.
- The supported minimum and maximum viewport sizes remain playable.
Conditional checks
- Test each declared input family—keyboard, pointer, touch, and gamepad—on representative hardware.
- Add browser automation for stable critical flows such as boot, start, result, restart, save/load, and resize when manual regression becomes costly.
- Profile frame time, draw calls, memory, asset size, and mobile thermal behavior when measured performance misses its target. Optimize measured bottlenecks, not guesses.
- Test offline, slow network, load failure, localization, accessibility, and persistence recovery when the product claims those capabilities.
Common failure patterns
| Symptom | Likely cause | Required response |
|---|---|---|
| Phaser 4 types or APIs appear in a Phaser 3 game | Installed latest or a version range resolved unexpectedly |
Pin phaser to exactly 3.90.0, recreate the lockfile deliberately, and use the versioned API docs |
| Blank canvas with no visible error | Wrong parent size, Scene not registered, or required asset failed | Inspect console/network, verify parent dimensions and Scene order, and surface loader errors |
| Input fires twice after restart | Listener registered on every create without cleanup |
Register once per run and unsubscribe on shutdown |
| Result Scene starts repeatedly | Transition is triggered from repeated overlap/update callbacks | Add a terminal-state guard before changing Scenes |
| Score, health, or flags survive restart | State initialized only in the constructor | Reset all run state in init or create |
| Movement speed changes with frame rate | Per-frame movement or wrong delta units | Express speed per second and use delta / 1000 for manual integration |
| Object teleports after tab return | A large delta was integrated after suspension | Pause on blur or clamp manual-integration delta |
| Pointer aim is offset when camera moves or canvas scales | Screen coordinates used as world coordinates | Convert through the active camera |
| Sprite and collision do not line up | Body size/offset was not updated after origin or scale changes | Configure and visually debug the body after final transform |
| Static collider remains at its old position | Arcade static body changed without refresh | Call refreshBody() after the transform |
| Assets work locally but 404 under a subpath | Root-relative URL ignored Vite base path | Derive loader URLs from import.meta.env.BASE_URL |
| Audio is silent on mobile | Playback started before a user gesture | Unlock/resume audio from an explicit interaction and expose mute state |
| Pixel art is blurred or shimmers | Filtering or fractional scaling/positioning | Enable pixel-art settings and use integer-friendly scales and positions |
| Game exists twice after navigation | Host recreated the game without destroying the old instance | Keep one owner and call game.destroy(true) during host teardown |