Phaser 4 · Vite · Web
Internal Reference

Block Buster — Developer Documentation

A deep technical guide to how this game is built: scene lifecycle, the frosted-glass masking trick, row spawning and matching logic, the difficulty system, ad integration, and the specific bugs this codebase has already hit (and how they were fixed) — so the next developer doesn't reintroduce them.

Phaser 4 (Arcade Physics) Vite Vanilla JS (ES Modules) Web-only (no native wrapper) localStorage persistence

Overview

Block Buster is a single-lane match/shooter puzzle game. Rows of colored cube blocks fall from the top of a "frosted glass" play area. Each row has exactly one gap in it. The player taps/clicks (or aims with the mouse) to shoot a cube of a chosen color up into that gap. If the shot color matches the row's required color, the row clears and the score goes up. If it misses the gap, it sticks underneath the row as a "blocker" and stacks up. If a row (or a blocker stack) reaches the bottom of the play area, the player loses a life. Lose all 3 lives and the run ends on the Score screen, with an optional rewarded-ad revive.

The project ships as a pure web build (Vite + Phaser). There is no native/Capacitor wrapper — it previously had one for AdMob, but it was removed; see Ads System for why and what replaced it.

Project Structure

blockBuster/ ├── index.html ├── vite.config.js ├── package.json ├── public/ │ └── assets/ └── src/ ├── main.js ├── config/ │ └── gameConfig.js ├── scenes/ │ ├── PreloadScene.js │ ├── StartScene.js │ ├── ModeScene.js │ ├── MainScene.js │ ├── PauseScene.js │ ├── ScoreScene.js │ └── StatScene.js ├── managers/ │ ├── AdsManager.js │ ├── AudioManager.js │ ├── UIManager.js │ ├── HapticsManager.js │ └── OrientationManager.js └── utils/ └── storage.js

Key files

public/assets/
Images, audio, and fonts — served as static files.
src/main.js
Phaser.Game bootstrap.
config/gameConfig.js
Phaser game config + scene list.
scenes/PreloadScene.js
Custom loading screen + asset manifest.
scenes/StartScene.js
Main menu.
scenes/ModeScene.js
Difficulty picker.
scenes/MainScene.js
The actual gameplay — the largest file.
scenes/PauseScene.js
Launched on top of MainScene.
scenes/ScoreScene.js
Game over / revive / high score.
scenes/StatScene.js
Best-scores panel, launched from Start.
managers/AdsManager.js
Banner / interstitial / rewarded ads (simulated).
managers/AudioManager.js
Background music + SFX, singleton.
managers/UIManager.js
Button/text factory helpers.
managers/HapticsManager.js
navigator.vibrate wrapper.
managers/OrientationManager.js
Forces portrait, shows a rotate-device overlay.
utils/storage.js
JSON-safe localStorage wrapper.
Where to look first: almost all gameplay logic lives in src/scenes/MainScene.js. It's ~1100 lines and intentionally not split further — see Gotchas for why some of that logic is order- and lifecycle-sensitive in ways that are easy to break if split carelessly.

Getting Started

# install deps
npm install

# local dev server (Vite, hot reload) — http://localhost:3000
npm run dev

# production build → outputs to dist/
npm run build

# preview the production build locally
npm run preview

There's no build flag or environment file to configure — the only environment-sensitive code is in AdsManager.js, which checks import.meta.env.DEV (true under vite/npm run dev, false for a real vite build).

Scene Flow

Registered in gameConfig.js in this order: PreloadScene, StartScene, ModeScene, MainScene, PauseScene, ScoreScene, StatScene. Order in that array doesn't matter for behavior (Phaser looks scenes up by key), it's just registration.

PreloadScene→ StartScene→ ModeScene→ MainScene
MainScene⇄ pause/launch ⇄ PauseScene
MainScene→ pause + launch → ScoreScene→ resume (revive) or start (replay) → MainScene

Two different Phaser transition styles are used deliberately:

CallEffectWhere used
scene.start(key, data) Fully re-runs init() → create() on the target scene. StartScene/ModeScene → MainScene (new game), PauseScene/ScoreScene → MainScene (Replay)
scene.pause() + scene.launch(key) Freezes the current scene in place (it keeps its state) and runs a second scene on top of it. MainScene → PauseScene, MainScene → ScoreScene
scene.resume(key, data) Un-freezes a paused scene without re-running create(); fires a "resume" event with data. PauseScene → MainScene (Resume), ScoreScene → MainScene (Revive)
This distinction matters a lot. create() only ever runs on a genuine scene.start()/initial launch — never on scene.resume(). MainScene relies on this: see Gotchas #1 for a real bug this caused.

Manager Pattern

Every scene that needs audio, UI helpers, or ads instantiates its own manager in an initManagers() method at the top of create(). Managers are plain classes constructed with new SomeManager(this) — they are not Phaser plugins, just scoped helper objects that hold a reference to their owning scene.

AudioManager singleton

Deliberately a singleton (AudioManager.instance) so background music survives scene transitions instead of restarting. playMusic/pauseMusic/resumeMusic/ toggleMute/playSfx.

UIManager per-scene

createButton(x, y, texture, callback, scale) — adds hover tint, click SFX, and the callback in one call. Used by every scene for buttons.

AdsManager per-scene

Simulated banner/interstitial/rewarded ads with a themed loading spinner. See Ads System for the full picture and how to wire in a real network.

HapticsManager static object

Thin wrapper over navigator.vibrate(). Silently no-ops on browsers without vibration support (e.g. iOS Safari). light/medium/heavy/selection().

OrientationManager is instantiated per- scene too, but is layout logic rather than a shared service: it listens to scene.scale's resize event and shows a "please rotate your device" overlay when the required orientation (passed in the constructor) doesn't match the current one.

Persistence & Storage Keys

src/utils/storage.js is a tiny wrapper around localStorage that JSON-encodes/ decodes automatically and never throws (failures are caught and logged). All game state persistence goes through it — nothing else touches localStorage directly.

KeyTypeSet byRead by
difficulty "easy" | "medium" | "hard" StartScene (Play → always "medium"), ModeScene (explicit pick) MainScene.initDifficulty()
gameScore number MainScene.updateScore(), reset to 0 on hard reset ScoreScene (final score display / high-score check)
highScore_easy
highScore_medium
highScore_hard
number ScoreScene ScoreScene, StatScene

PreloadScene

Draws a fully custom animated loading screen with Phaser.GameObjects.Graphics (sky, clouds, grass, floating candy-block rectangles, a progress bar) — none of it is loaded assets, so it can render instantly before anything is fetched. Then queues every image/audio asset from a single manifest object in images() (see Asset Manifest). On create(), it waits for the custom ProtestGuerrilla webfont to finish loading (document.fonts.load(...)) before starting StartScene, so the menu never flashes a fallback font.

StartScene

The main menu: logo, Play button, Difficulty button (→ ModeScene), Stats button (→ StatScene), mute toggle. Shows a banner ad (this.ads.showBanner()).

Play button behavior: always starts a fresh game at "medium" difficulty directly — it does not route through the difficulty picker. Players who want Easy or Hard explicitly go to ModeScene via the Difficulty button. This was a deliberate product decision, not a default left over from earlier development — don't "fix" it back to Easy without checking.

ModeScene

Difficulty picker (Easy / Medium / Hard buttons). Sets storage.difficulty and gameScore = 0, hides its banner ad, then scene.start("MainScene").

MainScene

The gameplay scene. create() runs, in order: managers → difficulty → background → UI layout/play-area rect → frosted glass graphics → difficulty color selector → glass mask → top UI (pause/hearts/score) → game state init → hardReset() (always, unconditionally — see Gotchas #1) → keyboard hint → physics bounds/death line → spawn timer → input handlers → scene event listeners.

Deep-dived by topic below: see Frosted Glass & Mask, Row Spawning, Shooting & Matching, Difficulty System, and Lives & Scoring.

PauseScene

Launched (not started) on top of a paused MainScene. Shows a banner ad. Three buttons:

  • Resume — hides the banner, scene.stop()s itself, scene.resume("MainScene").
  • Home — hides the banner, stops MainScene entirely, scene.start("StartScene").
  • Replay — hides the banner, stops itself, scene.start("MainScene", { fresh: true }).

ScoreScene

Launched when lives reach 0. Shows final score, updates the relevant highScore_<difficulty> key if beaten, shows a banner ad, and offers:

  • Revive (one-time per run) — plays a rewarded ad via AdsManager.showRewarded(callback); on completion, resumes background music, hides the banner, and scene.resume("MainScene", { revive: true, reviveHearts: 2 }).
  • Replay — scene.start("MainScene", { fresh: true }).
  • Home — stops MainScene, starts StartScene.

StatScene

Read-only panel launched from StartScene showing best score per difficulty from storage. No gameplay logic.

Frosted Glass & Mask

The play area is a rounded rectangle drawn with Phaser.GameObjects.Graphics in createFrostedGlass() (dark fill + faint inner highlight + border stroke — pure decoration, no gameplay role). Separately, createGlassMask() builds a GeometryMask from an identical rounded-rect shape and stores it as this.glassMask.

Every block — falling row blocks and player-shot blocks alike — gets block.setMask(this.glassMask) right after creation. Because a GeometryMask clips per-pixel to that exact rect (top, bottom, and rounded corners), nothing can ever visually render outside the glass boundary, in any direction, regardless of where the underlying game object's coordinates actually are.

Design implication: because the mask already guarantees nothing renders outside the play area, "blocks visible outside the glass" bugs in this codebase were never actually a masking failure — they were always a gameplay logic problem (a block's position math letting it drift past the boundary before something else caught it). See Gotchas #2.

Why rows spawn where they do

Rows spawn with their center at this.playArea.y + cubeSize / 2 — i.e. already fully inside the top of the glass, not above it. Earlier iterations of this game spawned rows above the play area and relied on the mask to hide them as they slid into view, but that produced a visible "hard-edge slice" look as the mask boundary revealed each row. Spawning already-inside plus a quick scale+fade pop-in tween (Back.Out, ~180ms) gives a clean "materializes in place" look instead.

Row Spawning

setupSpawning()/restartSpawning() add a looping this.time.addEvent() that calls spawnRow() at an interval driven by difficulty (1600ms Easy / 1200ms Medium / 900ms Hard).

spawnRow() for each new row:

  1. Picks one random column (0–5) as gapIndex — the one column with no block.
  2. Picks one random color from this.allowedCubes (difficulty-gated, see below) as row.requiredColor — every block in the row shares this color.
  3. Creates an Image per non-gap column, adds Arcade Physics, sets downward velocity by difficulty (65/95/125 px/s), applies the glass mask, and adds a physics collider against this.deathLine (a static invisible sprite at the bottom of the play area) that calls onRowHitBottom(row).
This collider — a falling row block physically touching the death line — is the only path that should end a run / cost a life. See Lives & Scoring.

Shooting & Matching

A tap/click anywhere in setupInput() calls shootBlock(pointer.x), which spawns a block of this.selectedShootColor at the bottom of the play area in the tapped column, with upward velocity (-600 px/s). Each shot block gets a custom block.update() function (invoked every frame from the scene's main update() loop, which iterates this.blocks.getChildren()) that checks it against every unresolved row:

Case A — wrong column (misses the gap)

Once the shot overlaps the row vertically, it's frozen in place (body.enable = false) and pushed into row.blockers[col] as a stacked "blocker" directly under that row. Each column tracks its own independent stack. A blocker's position is recalculated every frame relative to either the row itself (stackIndex === 0) or the blocker below it in the stack — so the whole stack rides down together as the row keeps falling.

Bottom-of-glass behavior: if a blocker's computed position would render past the bottom of the glass, it fades out and removes itself from the stack — it does not end the game or cost a life. Only the row's own blocks touching the death line (see Row Spawning) should do that. This was a real bug: an earlier version made any bottom-of-glass contact call onRowHitBottom(), which incorrectly ended runs on missed shots. See Gotchas #2.

Case B — correct column (hits the gap)

Compares block.getData("color") against row.requiredColor:

  • Match: updateScore(score + 10).
  • Mismatch: updateScore(score - 50) + a red flash/shake penalty animation (penaltyPulse()). Note a wrong-color hit in the gap still clears the row — only the color has to be wrong for the penalty, the shot still lands.

Either way the row is marked isResolved = true, every block in row.blocks and every stacked blocker in row.blockers tweens out (fade + scale to 0, staggered), and the row is removed from this.rows.

Difficulty System

Difficulty controls three independent things, all keyed off the same storage.get("difficulty") value read once in initDifficulty():

DifficultyAllowed colorsFall speedSpawn interval
Easy["cubeGreen"]65 px/s1600ms
Medium["cubeGreen","cubeBlue"]95 px/s1200ms
Hard["cubeGreen","cubeBlue","cubeOrange"]125 px/s900ms

this.allowedCubes drives both which colors can appear in rows and which color-picker cube icons are shown along the bottom of the play area (createDifficultySelector()). To add a 4th color or a new difficulty tier, extend difficultyColors in initDifficulty() and the three ternary chains in spawnRow(), restartSpawning(), and setupSpawning() (fall speed / spawn interval) — they aren't currently pulled from one shared config object, so all three need updating together.

Lives & Scoring

onRowHitBottom(row) is the single failure path: it's guarded by this.isResetting so it can't double-fire in one frame, marks the row visually dangerous, clears all spawn timers, zeroes every block's velocity (freezeGame()), and calls loseLife(). If lives remain, it shows a "life lost" overlay, then resetBoard() (tweens every block out, clears this.rows) and restartSpawning(). If lives hit 0, it shows an interstitial ad, pauses MainScene, and launches ScoreScene.

handleRevive(hearts) (triggered by the "resume" event with { revive: true }) restores lives and calls the same resetBoard() + restartSpawning() pair.

Keyboard Shortcuts

KeyEffectAvailability
Space Cycles selectedShootColor to the next entry in allowedCubes (cycleShootColor()) — same effect as tapping the next color-picker cube icon. Desktop only (this.sys.game.device.os.desktop), Medium/Hard only. Not shown/available on Easy (only one color exists) or on touch devices (no keyboard).

A small pill-shaped hint badge ("Press SPACE to switch color") is drawn near the top of the screen under the same conditions. It's deliberately styled as a dark rounded badge with a gold border and stroked/shadowed text (createKeyboardHint()) rather than plain text, since plain text was hard to read over the busy background art.

Ads System

AdsManager.js exposes four methods every scene uses the same way, regardless of what's actually behind them:

ads.showBanner()          // menu screens only
ads.hideBanner()
ads.showInterstitial()   // await'd — resolves when the "ad" finishes
ads.showRewarded(callback) // callback fires when the reward is earned
Current state: fully simulated, web-only. This project previously integrated @capacitor-community/admob with a native Android build (via Capacitor). That was removed entirely — no android/ folder, no Capacitor dependencies, no native AdMob calls remain. AdsManager now only ever shows a themed loading spinner followed by a fake "Ad Playing" countdown overlay (showFakeAd()), then resolves/calls back exactly like a real ad SDK would.

Wiring in a real web ad network

Because every call site only depends on the four methods above, swapping in a real network (Google Ad Manager/GPT, AdSense for Games, or a marketplace network like GameDistribution/CrazyGames) only requires editing the bodies of showBanner(), showInterstitial(), and showRewarded() in AdsManager.js — no scene code needs to change. Keep the spinner overlay (showSpinnerOverlay()/ hideSpinnerOverlay()) around the network's actual load/show calls for the same UX.

Ad placement, as currently wired

ScreenAdWhen
StartSceneBannerShown on create(), hidden right before Play starts MainScene.
ModeSceneBannerShown on create(), hidden before Back or before a difficulty starts MainScene.
PauseSceneBannerShown on create(), hidden before Resume/Home/Replay.
ScoreSceneBannerShown on create(), hidden before Replay/Home/Revive-resume.
MainSceneInterstitialOn lives reaching 0, right before pausing into ScoreScene.
ScoreSceneRewardedRevive button, one-time per run.

Audio

AudioManager is a singleton — the first scene to construct one wins, and every later new AudioManager(scene) call just updates which scene it's attached to while reusing the same background-music Sound object. This is what lets music keep playing continuously across scene transitions (Start → Mode → Main) instead of restarting from the beginning each time.

SFX keys in use: click, pop, shoot, destroy, crash, tickTock. All played via audioManager.playSfx(key, config) or directly through this.sound.play(key) in a couple of spots in MainScene.

Asset Manifest

All assets are declared in one object in PreloadScene.images() and loop-loaded — add a new image/audio file by adding one line to that object, no other registration needed.

Images

techxedoLogo, gameLogo, background, home, back, play, resume, pause, stats, replay, soundON, soundOFF, difficulty, easy, medium, hard, ads, heart, noHeart, cubeBlue, cubeOrange, cubeGreen, cubeRed, close, scoreBoard, pauseBoard

Audio

bgMusic, click, pop, crash, shoot, destroy, tickTock

cubeRed is loaded but not currently in any difficultyColors entry — it's available if you want to add a 4th color tier without sourcing new art.

Common Customizations

Change fall speed / spawn rate per difficulty

Edit the three matching ternary chains (search for "easy" ? 65):

// spawnRow() — fall speed
const speed = this.difficulty === "easy" ? 65 : this.difficulty === "medium" ? 95 : 125;

// setupSpawning() / restartSpawning() — spawn interval (ms)
delay: this.difficulty === "easy" ? 1600 : this.difficulty === "medium" ? 1200 : 900,

Add a new color / difficulty tier

  1. Add the image to the manifest in PreloadScene.images() (or reuse cubeRed, already loaded).
  2. Add it to difficultyColors in initDifficulty().
  3. The color-picker row (createDifficultySelector()) and spawn logic both read this.allowedCubes automatically — no other changes needed for a new color within an existing tier.

Change lives / starting hearts

this.lives = 3 appears in createTopUI() (initial UI) and hardReset() (actual reset value) — both need to match, plus the heart-icon loop in createTopUI() which iterates this.lives to create icons.

Change scoring values

In the gap-hit branch of shootBlock()'s block.update(): +10 for a correct-color match, -50 for a wrong-color hit.

Gotchas & Lessons Learned

These are real bugs this codebase hit during development. Documented here so they don't come back.

1. Always call hardReset() unconditionally in create()

Originally, hardReset() only ran when scene.start("MainScene", { fresh: true }) explicitly passed that flag — which the Start/Mode screens' normal "start a new game" calls did not do (they call scene.start("MainScene") with no data). Since Phaser reuses the same Scene instance across restarts, skipping the reset let the old spawn timer and leftover blocks from the previous session keep running alongside the new ones — visually, blocks piled on top of each other after Home → Play again. Fix: create() only ever runs on a genuine restart (never on resume()), so it's always safe — and now mandatory — to call hardReset() unconditionally at the top of every create().

2. Don't let "reached the bottom" mean "game over" for every block type

A missed shot that stacks up as a blocker underneath a row will, given enough failed shots or enough time, drift down toward the bottom of the glass on its own (it's pinned to the still-falling row above it). An early fix for "blocks visible outside the glass" called onRowHitBottom() whenever a blocker's position would exceed the glass boundary — which technically stopped the visual overflow, but incorrectly ended runs on missed shots that had nothing to do with a row physically reaching the bottom. The correct fix keeps the two concepts separate: a blocker exceeding the boundary just fades out and removes itself from the stack (no penalty); only the physics collider between a row's own blocks and the death line should end a run.

3. Keyboard listeners need explicit cleanup too

hardReset() already called this.input.removeAllListeners() to prevent duplicate pointer-input handlers from stacking up across repeated Replay clicks. this.input.keyboard is a separate event emitter from the main input plugin and is not cleared by that call — if you add new this.input.keyboard.on(...) listeners anywhere in MainScene, also clear them in hardReset() (this.input.keyboard?.removeAllListeners()), or repeated restarts will fire the same key handler multiple times per press.

4. Rows spawn already-inside the glass, not above it

It's tempting to spawn rows above the play area and let the GeometryMask hide them until they slide into view — this technically works but produces a visible hard-edge "slicing" look right at the top boundary as the mask reveals each row. Spawning with the row's center already at playArea.y + cubeSize / 2 plus a quick pop-in tween avoids this entirely; there's never anything positioned outside the glass to begin with.

Build & Deploy

npm run build outputs a static site to dist/ (Vite's default). Because vite.config.js sets base: "./", the build uses relative asset paths — it can be dropped into any static host or subfolder (a marketplace preview host, itch.io, a CDN bucket, etc.) without adjusting a base path.

There is currently no code-splitting configured; the production build reports one JS chunk over 500KB (mostly Phaser itself). This is a Vite warning, not an error — safe to ship as-is, but worth revisiting with build.rollupOptions.output.manualChunks if load-time becomes a concern for a specific distribution channel.