A PixiJS v8 grow-and-harvest puzzle game, packaged with Capacitor 8 for iPhone and iPad (iOS 15+).
src/engine/— reusable, game-agnostic: app bootstrap (createEngine), typedContainerwrapper, observableCStore, manifest asset loader, resize/storage helpers, Capacitor platform glue. Future games build on this.src/game/model/— the single source of truth:TGameState(board, tray, goals, score) as plain data, the rules that change it, and snapshot serialization. No pixi imports, so it is directly unit-testable.src/game/— the rest of Sprout: components, controller, level config, save state.src/assetpack/— build-time AssetPack pipes + the generatedassets.d.tskey union.src/assets/— raw art/fonts (input);public/assets/is generated output (git-ignored).ios/— Capacitor-generated Xcode project (SPM-based; committed).
A crop is a TPiece with a state: SEED in the tray, GROWING on the board
under foliage (the overlay art thins as its remaining value drops), or SUPPLY
once spent. Tapping an empty cell plays the rightmost tray crop; the tray's
circular-arrow button swaps its two rightmost crops for free. With no growing
cardinal neighbour the played crop sprouts and starts growing at its value;
otherwise it becomes supply and feeds its value into the strongest growing
neighbour — ties go left, feeds clamp at zero. A crop fed to zero is harvested:
it credits its goal, scores its starting value toward the milestone ring, and
takes its adjacent supplies with it. Harvesting never cascades into another
growing crop.
A level is won when every goal completes, and failed when the board fills
first. Each ring milestone banks a leaf into the persistent profile
(sprout:profile). Levels are generated deterministically from a small library
of board templates (config/templates.ts) across a 1,000-level difficulty ramp
(config/generate.ts); the first ten are gentle. Finishing all 1,000 grants a
leaf bonus and then reshuffles the post-tutorial pool endlessly. Every level
draws its crops from the active skin — a fixed palette of readable crops
(config/skins.ts) — and the tray's settings button opens a window with the
sound toggles, that shop, and the asset credits.
From the third tier on, a level can start with a crop pinned under a rock
(rocked in config/schema.ts). A rocked crop cannot be fed and does not
count as a growing neighbour, so a crop played beside it sprouts instead;
harvesting anything cardinally adjacent breaks the rock and the crop plays
normally from then on. The generator only ever places a rock that keeps room
to be freed.
The first level is authored rather than generated (config/tutorial.ts): a
3x3 board dealt a fixed run of crops, which model/tutorial.ts reads to
direct the player through planting, swapping, feeding and harvesting. The
director is pure and derives its step from the move count and the tray, so a
save restored mid-tutorial resumes on the right step. The controller draws its
target as a pulsing ring (components/coach.ts) and ignores every input but
the one it asks for, leaving the rules untouched.
A move returns a TMoveReport: an ordered log of what happened (SPROUTED,
FED, HARVESTED, SCORED, …). The state is still the whole truth of what
the board is, and the views redraw from it alone; the report carries only
the sequence, which a re-render cannot recover, and drives the effects and the
controller's level-flow orchestration.
Every animation runs through tween in components/motion.ts, which hands
motion a custom driver clocked by app.ticker — so stopping the ticker
(backgrounding on iOS) freezes tweens and particles too, and they resume where
they left off. Celebration rain uses one ParticleContainer per crop texture,
because a container batches a single texture.
Only the reward beat — a harvest, a won or failed level — holds the player's input. A plant or a feed draws at once, so crops can be placed back to back while the drains from earlier moves are still in flight. Because a drain lasts longer the more it carries, a later feed can land before an earlier one, and the controller draws only the newest value claimed for a crop.
The canvas fills the screen and the game reads the device's safe-area insets
through engine/platform/safearea.ts, fitting the 720x1280 design box inside
them (engine/fit.ts). The insets come from the native plugin rather than
env(safe-area-inset-*), which a webview reports as zero once the status bar
is hidden. Sound plays through @pixi/sound (game/sound.ts), honouring the
profile's toggles; AssetPack's ffmpeg pipe transcodes src/assets/audio/, and
the music track loads after the game is already playable.
pnpm install
pnpm assetpack # generate public/assets/ — rerun after changing src/assets/
pnpm dev # vite dev server on :8080Dev builds accept ?seed=<n> to fix the tray RNG, ?level=<n> to boot a
specific level, ?safearea=<top,right,bottom,left> to stand in for a device's
insets in the browser, and ?gallery to render every active-skin crop on a
shared baseline alongside the growth stages — a quick visual check for
readability and alignment. They also expose window.__sprout for scripted testing: state(),
lastReport(), place(x, y), swap(), goToLevel(n), setSkin(id),
tutorialStep() — the step the tutorial director is asking for, or null —
and idle() — a promise that resolves once the board, tray and effect animations
settle, so a script can drive a move and screenshot a deterministic frame. All
of it is stripped from production builds.
pnpm build runs assetpack → prettier → eslint → tsc → vite build — the order
matters because AssetPack regenerates src/assetpack/assets.d.ts, which the
format, lint and tsc steps all consume.
pnpm verify runs just the checks (format:check → lint → typecheck → test),
skipping AssetPack and the bundle. pnpm install points core.hooksPath at
.githooks/, whose pre-commit runs pnpm verify — project-wide, since tsc
and eslint are project-wide by nature — so a commit that is misformatted, fails
lint, fails to typecheck or breaks a test is rejected. The hook deliberately
does not run AssetPack; CI verifies assets.d.ts is current. Bypass a single
commit with git commit --no-verify.
pnpm test (vitest) covers the pure layers: src/game/model/, the level schema
in src/game/config/, and the engine's store and storage helpers. None of them
need a browser or the art pipeline — the one suite that wants localStorage
stubs it — so they run in the node environment in well under a second.
pnpm ios:sync # build web + copy into ios/ — rerun after EVERY web change
pnpm ios:open # open the Xcode project (SPM template — there is no .xcworkspace)In Xcode: select your signing team; bundle id is com.tayfunturgut.sprout
(must match App Store Connect); Deployment Target is iOS 15.0.
App icons/splash: put a 1024×1024 assets/icon.png (and optional 2732×2732
assets/splash.png / splash-dark.png) at the repo root, then run
npx @capacitor/assets generate --ios.
package.json is the source of truth for the marketing version. Bump it, then
run pnpm run version:sync (or just pnpm ios:sync, which runs it) to write
MARKETING_VERSION into project.pbxproj, and commit both. CI fails if the two
disagree, and a v* release tag must match package.json exactly.
Must be one to three dot-separated integers — App Store Connect rejects semver
prerelease suffixes like 1.0.0-beta.1.
Build numbers (CURRENT_PROJECT_VERSION) are not tracked in the repo: the
release workflow sets one from the CI run number, which is monotonic, so
re-archiving a version never collides in App Store Connect. Bump it by hand if
you archive locally.
CI (.github/workflows/release-ios.yml) archives, signs and uploads on a v*
tag, given the Apple signing and App Store Connect secrets listed at the top of
that file. To do it by hand instead: choose "Any iOS Device (arm64)" → Product ▸
Archive → Distribute App ▸ App Store Connect. iPad is a separate review surface
— test and screenshot both device families (or declare iPhone-only).
Device smoke checklist: splash → progress bar → board; status bar hidden; safe areas respected — the goal panel clears the notch, the background fills behind it, and rotating and back re-fits (check a home-button device too); taps place pieces, and placing several quickly leaves one crop per tray slot; music starts on the first tap and both toggles survive a relaunch; backgrounding pauses the ticker, every animation and the audio with it; a fresh install opens on the guided tutorial while an upgrade keeps its level, leaves and skins; memory well under ~300 MB in Xcode's gauge.