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.
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
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.jsnavigator.vibratewrapper.managers/OrientationManager.js- Forces portrait, shows a rotate-device overlay.
utils/storage.js- JSON-safe
localStoragewrapper.
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.
Two different Phaser transition styles are used deliberately:
| Call | Effect | Where 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) |
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.
| Key | Type | Set by | Read 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_easyhighScore_mediumhighScore_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, andscene.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.
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:
- Picks one random column (0–5) as
gapIndex— the one column with no block. - Picks one random color from
this.allowedCubes(difficulty-gated, see below) asrow.requiredColor— every block in the row shares this color. - Creates an
Imageper 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 againstthis.deathLine(a static invisible sprite at the bottom of the play area) that callsonRowHitBottom(row).
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.
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():
| Difficulty | Allowed colors | Fall speed | Spawn interval |
|---|---|---|---|
| Easy | ["cubeGreen"] | 65 px/s | 1600ms |
| Medium | ["cubeGreen","cubeBlue"] | 95 px/s | 1200ms |
| Hard | ["cubeGreen","cubeBlue","cubeOrange"] | 125 px/s | 900ms |
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
| Key | Effect | Availability |
|---|---|---|
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
@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
| Screen | Ad | When |
|---|---|---|
| StartScene | Banner | Shown on create(), hidden right before Play starts MainScene. |
| ModeScene | Banner | Shown on create(), hidden before Back or before a difficulty starts MainScene. |
| PauseScene | Banner | Shown on create(), hidden before Resume/Home/Replay. |
| ScoreScene | Banner | Shown on create(), hidden before Replay/Home/Revive-resume. |
| MainScene | Interstitial | On lives reaching 0, right before pausing into ScoreScene. |
| ScoreScene | Rewarded | Revive 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
- Add the image to the manifest in
PreloadScene.images()(or reusecubeRed, already loaded). - Add it to
difficultyColorsininitDifficulty(). - The color-picker row (
createDifficultySelector()) and spawn logic both readthis.allowedCubesautomatically — 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.
build.rollupOptions.output.manualChunks
if load-time becomes a concern for a specific distribution channel.