ultratears
Unofficial native Windows port of Tears of the Kingdom 1.4.3 using static recompilation. Builds locally from the player's own extracted game dump; no game files included.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A cute, kid-friendly run 'n gun game in the style of Cuphead and Mega Man, themed around plants. Little Pip the sprout cheers up the garden's grumpy plants by showering them with seeds: the small ones along a scrolling stage, then its boss.
The game is named after the garden and each chapter after its boss. There are two so far. In The Grumpy Sunflower, walk the Garden Path to Sunny's meadow, then win by emptying Sunny's bar without losing all your hearts. In The Moody Mushroom, do the same through the Mushroom Wood for Mo. Cheering up a boss earns a life and opens the next chapter.
Built with TypeScript, Vite and Phaser 4. There are still no art or sound files: every picture is drawn in code when the game starts, and every sound is synthesized with WebAudio.
Farul Ivan made this game with Claude Code, Anthropic's AI coding agent,
and the history says so: most commits carry a Co-Authored-By: Claude line.
A tour of the code explains in plain words how the game works and why it is built this way.
pnpm install
pnpm dev # play at http://localhost:5173
pnpm build # static site in dist/; upload that folder to any static host
pnpm preview # serve dist/ locally to check the build
pnpm check # type-check, lint, tests and formatting; run it before committing
Needs Node 22 (22.12 or newer) or Node 24, and pnpm. The pnpm version is pinned in
package.json; run corepack enable once and the right one is fetched for you. pnpm is the only package
manager for this project: pnpm-lock.yaml is the one lockfile, npm refuses to install here, and other
managers' lockfiles are ignored by git so a second one can never be committed.
pnpm build writes a static site to dist/: an index.html and an assets/ folder. Any static host
serves it; where the host builds for you, the build command is pnpm build and the output folder is dist.
The game has no server, no account and no cookie, and its page asks for nothing outside its own folder
(the font is bundled), so it works at the top of a domain or in any subfolder. A host can still add things
of its own to the pages it serves; what this one adds is listed under "On Cloudflare". File names in
assets/ are made from their contents, so they can be cached for good, and the engine has a file of its
own that only changes when Phaser is upgraded: after an update to the game, returning players fetch about
40 kB, not the whole 400 kB. index.html is the one file that must not be cached for long.
To keep an old version playable beside a new one, build each from its own tag or branch and give each its own site.
v1.example.com and v2.example.com, or two projects on a host that gives
each its own address) the copies cannot see each other. There is nothing to set.example.com/v1/ and example.com/v2/, or two project sites on
GitHub Pages, which share you.github.io) both run, but they share one save, because a browser keeps
one localStorage per domain: easy mode, mute and best times set in one show up in the other. To give
a copy a save of its own, build it with a slot: VITE_SAVE_SLOT=v2 pnpm build stores under
sprout.save.v2 and touches nothing else. Builds from before this option existed always use the main
save, so give the slot to the newer copy.Build each version with the tools it was written with. Up to tag v1.0.0 (commit 06e2972), the last
one before the visual overhaul, that is npm (npm ci && npm run build); from the overhaul on, tag
v2.0.0, it is pnpm. Most hosts pick the right one from the lockfile. The older version does not say
which Node it needs (the newer one does, in .nvmrc): if its build fails on a host, set that site's Node
version to 22 or 24.
The game is hosted on Cloudflare as two Workers that serve only files, which is free with no limit on visits. Each has a subdomain of its own, so their saves stay apart:
| Address | Worker | What | How it gets there |
|---|---|---|---|
sprout.farulivan.com | sprout | the game, from main | Cloudflare builds and deploys every merge |
sprout-v1.farulivan.com | sprout-v1 | the first version, tag v1.0.0 | uploaded once by hand |
The game. wrangler.jsonc says what the site is (the dist/ folder), what the Worker is called and
which address it answers on, and public/_headers sets the caching described above. The build is set up
once in the Cloudflare dashboard, under the Worker's Settings → Build, and these values live only
there:
| Setting | Value |
|---|---|
| Git branch | main |
| Build command | corepack pnpm install --frozen-lockfile && corepack pnpm build |
| Deploy command | ./node_modules/.bin/wrangler deploy |
| Preview command | ./node_modules/.bin/wrangler preview |
| Build variable | SKIP_DEPENDENCY_INSTALL = 1 |
They differ from Cloudflare's defaults for two reasons. Its builder comes with an older pnpm than the one
pinned here and would install with that, so the variable turns its own install off and the build command
fetches the pinned pnpm through Corepack instead. And its default commands start with npx, which this
project refuses (see "Run it"), so they call the installed wrangler directly.
With Enable Preview Builds ticked there, every pull request is built too and gets an address of its own to try the change at. Those addresses are public.
To serve a build locally the way Cloudflare will, headers included: pnpm build && pnpm exec wrangler dev.
The first version never changes, so nothing builds it automatically. To upload it:
git worktree add ../sprout-v1 v1.0.0 && cd ../sprout-v1
npm ci && npm run build
npx wrangler@4 deploy --name sprout-v1 --assets ./dist --compatibility-date 2026-10-01 \
--domain sprout-v1.farulivan.com
What the domain adds. Two settings of farulivan.com each put a small script on every page it serves,
on both sites. They are Cloudflare's, not the game's, and nothing in this repo switches them on or off:
static.cloudflareinsights.com and reports each page view to /cdn-cgi/rum on the same domain: the
page's address, how long it took to load, and the browser and system version. It sets no cookie and
keeps nothing in the browser. The figures are in the Cloudflare dashboard under Web Analytics, and
Manage Site there switches it off./cdn-cgi/challenge-platform/ to tell browsers from bots, and keeps the result in a
cf_clearance cookie on .farulivan.com. The check itself never blocks or challenges a visitor. It is
set for the whole domain, under Security Settings → Bot traffic, and cannot be left out for these
two addresses alone.Both were checked on the live site in October 2026 and deliberately left on: the first shows how many people play each version, and the second guards the rest of the domain.
The game opens the way cartoons and console games of old did, in about nine seconds, and any key, click, tap or button skips straight to the title:
The yard is Pip's home, not any boss's, so it stays the title however many chapters the game gets.
The opening is silent on a first visit, on purpose. A browser lets a page play sound only after the player has pressed a key, clicked or tapped, so the press that passes the title is where the sound begins: a chime, then the title's tune. If the browser already allows sound (some do for sites people come back to), the opening has its sounds too. A gamepad's buttons don't count as such a press, so after Start on a gamepad a note under the signpost says that sound starts with any key or tap. Don't add a second "click to begin" screen before the sting: Press any key already is that click.
| Action | Keyboard | Gamepad | Touch (tablet/phone) |
|---|---|---|---|
| Move | ← → or A D | left stick or d-pad | ◀ ▶ buttons (bottom left) |
| Jump (hold = higher) | Space, Z or K | A | the smaller button, up and to the right of Shoot |
| Shoot | hold X, J or L | hold X, RB or RT | hold the big seed button (bottom right) |
| Aim diagonally up | hold ↑ or W | stick or d-pad up | no aiming: seeds fly straight |
| Pause / resume | P or Esc | Start | ⏸ button |
| Sound on/off | M | 🔊 button | |
| Back, on a menu page | Esc or Backspace | B | the Back button |
How to play keeps this short: one key for each of the four actions (Move ←→, Jump Space, Shoot X, Aim ↑), or a gamepad's or touch screen's own buttons once the game sees one. Everything else in the table still works; it is just not listed there, and pause and sound have buttons on screen during play.
On a touch screen the buttons are icons, as in other mobile action games: arrowheads, an arrow up, and one of Pip's seeds. Shoot is the biggest, as the main action. A thumb can slide from button to button without lifting, and resting it between Shoot and Jump presses both, to jump while shooting. There is no aiming on a touch screen: Sunny's face is still in reach of a straight seed from the high leaf.
The pause card has Keep playing, Start over (the stage or the boss fight again from its start, with the same Easy setting, for one of Pip's lives) and Main menu (back to the signpost, where Easy mode can be changed in Options). The win card and the Game over card have a Main menu button too.
Every card works the same way: click or tap a button, or choose one with the arrow keys, the d-pad or the stick and press it with Space, Enter or A. With nothing chosen, Enter or Start presses the card's main button (Play, Try again, Next chapter, Play again, Keep playing, or Back on a menu page), and so does Space or A on the menu and pause cards. On a volume slider, ◀ ▶ turn it instead of moving off it. A menu page opens on its first control, and going back returns to the board that opened it. Pausing from the keyboard or a gamepad starts on Keep playing, so pressing A or Space right away just resumes.
Face shots do 2 damage; stem shots do 1. The game auto-pauses when the tab loses focus. With ← and → both held, the one pressed last wins, so turning round never stops Pip. Some keyboards can't send certain three-key combinations at all (arrows with Space is a common one), and the game never sees the third key. If a key seems to drop out, try the other keys in the table (A D, W, K or Z, J or L). Easy mode (a checkbox in Options and on the lose card) gives 8 hearts instead of 5. With only a keyboard, Tab to it and press Space, or move to it with the arrow keys. A gamepad reaches it with the d-pad and ticks it with A. Enter or Start never ticks it, so on the lose card it starts the game right after. A jump pressed a moment before landing still counts, and so does one pressed a moment after walking off an edge.
Play starts Stage 1, the Garden Path: about 7,300 px of garden (7½ screens) from the cottage gate to Sunny's meadow, a minute or two of walking. Sunny's mood has spread to the little plants along it, and each one Pip cheers up pops into petals and leaves a flower where it stood. Sunny stands on the horizon all the way, growing as Pip gets closer, and the bar at the top shows how far along the path Pip is.
| Enemy | What it does | Seeds to cheer it up |
|---|---|---|
| Grumbud, a flower bud on root feet | waddles along the ground or a leaf pad and turns back at its edges | 2 |
| Puffball, a dandelion clock | drifts after Pip and sinks to his height, too slowly to catch him while he keeps moving. If it reaches him it costs a heart and bursts, gone either way. Aim up to stop it while it is high, or shoot straight as it comes down | 1 |
| Pea Pod | swells under a "!" for 0.9 s, then spits a pea at chest height: hop over it | 3 |
| Snapper, a snapdragon in a mound | seeds bounce off the mound; it rumbles under a "!" for 0.8 s, then snaps up for 0.7 s | 2, while it is up |
It is a warm-up for Sunny. The bot from docs/future-improvements.md ("About the numbers"), playing at a
young child's reaction time (0.6 s), clears it every time and is hurt about once in two runs.
| Attack | Phases | How to dodge |
|---|---|---|
| Seed Spit: lobbed seeds aimed where Pip is heading | 1-3 | keep moving |
| Petal Fan: a fan of spinning petals | 1-3 (double volley in 3) | slip between them or jump |
| Vine Sweep: a vine slithers along the ground | 2-3 | jump over it or stand on a leaf |
| Pollen Rain: pollen balls fall on ringed spots | 2-3 | step off the ring |
| Root Pop: roots burst up under Pip | 3 | step away from the "!" |
There are no image files. Every picture is drawn in code at startup, and these rules keep it reading as one world:
The game is split into a simulation that knows the rules and a presentation that shows them.
keyboard / gamepad / touch ─► Controls ─► App ──step(input)──► Fight, Stage or Yard (src/sim)
│ │ events
│◄─────────────────────┘
├─► presentation.ts ─► sound, particles, shake, toasts
└─► Phaser scenes read the level every frame and draw it
| Path | What it does |
|---|---|
src/sim/ | The game itself in plain TypeScript: no Phaser, no browser, no Math.random. Runs in tests as it runs in the game |
src/sim/fight.ts | Fight: one boss fight. step(input) advances exactly 1/60 s; drainEvents() reports what happened |
src/sim/player.ts | Pip: movement, jump, shooting, hurt and invulnerability. He asks the level he is in for its edges, platforms and projectile list (PlayerWorld), so he isn't tied to a boss fight |
src/sim/level.ts | What the views need from the level being played (Level); Fight is one |
src/sim/yard.ts | Yard: Pip's yard behind the opening, the title and the menu. Its beats (sting, intro, press, menu, leave), the opening's timings, and the real Pip walked by a script: out of the door, idle hops and strolls, and off down the path after Play |
src/sim/boss.ts | What every boss shares: health, three phases, the intro, the attack scheduler |
src/sim/bosses/ | One file per boss (sunny.ts, mo.ts) and the registry of all of them (index.ts) |
src/sim/bouncer.ts | A bounce pad: what a cheered-up Capling leaves. Pip falling onto one is thrown up, higher with the button held (bounceOff in player.ts). Springy is anything that does that, which includes Mo's cap |
src/sim/hazards.ts, projectile.ts, pickups.ts, arena.ts | Vine, root and pollen, and Mo's acid smoke and fairy ring; seeds and petals; the dewdrop; the leaf platforms of Sunny's arena |
src/sim/events.ts | The list of things the simulation announces (jump, bossHit, phase, the opening's cues, ...) |
src/sim/stage/stage.ts | Stage: one scrolling stage, the way Fight is one boss fight. Ground in solid boxes with pits between, logs, trunks, thorns, leaf pads, the camera, pickups, the glades that shut until every plant in them is cheered up, and the gate, and the rules that advance it |
Yard beats: sting → intro → press → menu → leave (any press during the first two skips to press).
Fight states: play → ending (slow-mo bloom) → win, or play → down (Pip naps) → lose. Stage states:
play → clear (Pip walks through the gate) → exit (on to the boss), or play → down → lose.
A new level replaces the old one rather than resetting it. The game opens on a Yard at its sting. Play
(once Pip has run off), Play again and Start again go back to the first chapter, make its stage and give Pip
all his lives (LIVES); its gate makes the boss fight with the hearts Pip has left; a win earns a life and Next
chapter makes the next chapter's stage, with full hearts, or after the last chapter the button is Play again; Try
again and Start over make the level being played again (a boss fight with full hearts) for a life, or a new game
when none are left; and Main menu makes a Yard straight at its menu, which ends the run.
RES
(2), so nothing in the simulation or the views deals with screen pixels.INV (1/RES). When a view scales a part it multiplies:
petal.setScale(INV * 1.2, INV).px, py), and views draw lerp(px, x, alpha) so motion stays smooth on any display.fight.rng, which is seeded. The same seed and inputs replay the
same fight. A test fails if src/sim/ uses Math.random, Phaser or the DOM.fight.emit({ type: ... }) and
src/presentation.ts decides what that looks and sounds like. A few of its fields exist only so the
views can draw (player.aimUp, player.recoil, boss.attackName, boss.puff...); they never feed back
into the rules.Math.random freely.bake() only queues a small part; finishBaking() (called
once in BootScene) packs them onto a 2048-pixel page, which is about 75% full (it warns in the console
if a second page is ever needed). Art that only a stage draws is baked inside bakeApart('stage', ...)
and goes on a page of its own: it doesn't crowd the shared page, and a boss fight never switches between
pages because of it. Make images with part() and swap them with show(); never call setTexture
yourself. Parts longer than 512 texture pixels keep their own texture. Big soft glows are baked small
(SOFT, HAZY) so they fit: scale those with unit(key), not INV. Art that only a later chapter draws is
baked as that chapter's scenes first start (bakeLate, called from prepare in chapterViews.ts), so nobody
who hasn't got there pays for it in memory or at boot.Add a boss attack. In src/sim/bosses/sunny.ts, write a generator method. yield <seconds> waits,
this.warn(t) shows the "!" telegraph, and the attack must end with this.endAttack(). Then add its name to
ATTACKS, and to POOLS for the phases that should use it:
*leafBoomerang(): Attack {
this.warn(0.6);
yield 0.6;
// push a Projectile into this.fight.projectiles, or a hazard into this.fight.hazards;
// use this.fight.rng for anything random
yield 1.0;
this.endAttack();
}
Add a hazard. Write a class in src/sim/hazards.ts that implements Hazard, and add its kind to the
interface. shape() returns the area that hurts right now and must return null while the warning is showing
(a kids' game should never hurt without warning). Then give it a view: bake its parts in bakeHazards
(src/game/views/hazards/art.ts), write a view class in src/game/views/HazardViews.ts, and add it to the
factory in HazardViews. Use the shared warn-mark badge (showMark draws it in front of Pip) or an
orange ring for its warning, so it reads like the others.
Add a sound or an effect. Add an event to src/sim/events.ts and emit it from the simulation. TypeScript
then points at present() in src/presentation.ts until the new event has a case there. A new sound is a
name in SfxName plus a recipe in src/audio.ts. For the picture, present() can call burst (particles),
ring, shake, flash, punch (a camera jolt), hitStop and toast. A new particle shape is a name in
ParticleShape plus a drawing in SHAPES in src/game/fx.ts. Effects that belong to one view (dust at
Pip's heels, steam from Sunny) are made by that view, which is handed the same fx.
Add a boss. Write src/sim/bosses/<name>.ts extending Boss (its arena's platforms and maxX, its
movement, hurtboxes and attacks) and add it to src/sim/bosses/index.ts. TypeScript then asks for its view in
src/game/views/bossViews.ts. A boss is played as part of a chapter, so it needs one too. The weather already
follows any boss's phase (moodOf in src/game/views/world/weather.ts; an arena can bring its own with mood),
and the title (Pip's yard) belongs to no boss and needs nothing. A boss can also bring small enemies
(fight.addEnemy), bounce pads (fight.pads) and something of his own that throws Pip up (fight.bouncers), and
can override blocks (seeds bounce off here) and touches (touching him hurts). src/sim/bosses/mo.ts does all
of these. Keep how he moves in plain fields stepped by move(), as Mo does, rather than inside an attack's
generator: a bot can then copy the fight to look ahead.
Add a chapter. Add its profile to CHAPTERS in src/chapters.ts: its stage and boss, its tunes, and what
the game says and shows about them. TypeScript then asks for its entry in CHAPTER_VIEWS
(src/game/views/chapterViews.ts): the theme of its stage's world, the land and ground of its arena, and the
light at its gate. src/main.ts gives it scenes of its own, so nothing has to hide anything, and the last
chapter's win card then offers Next chapter by itself (App.onward). How to play in index.html names the bosses
by hand, so add a line there.
Add a stage. Write its layout as data in src/sim/stage/stages/<name>.ts, a StageData: its length,
ground and pits, logs, thorns, leaf pads, enemies, dewdrops, golden seeds and gate. Add it to STAGES in
src/sim/stage/stages/index.ts, and give it the Garden Path's checks in tests/sim/stage.test.ts (it can be
played to the end, every pit can be jumped, every log and pad can be reached). A chapter's profile names the
stage it plays. StageWorldView draws any layout out of a StageTheme, so a stage somewhere else needs parts
of its own and a theme that lists them (the Garden Path's are stage/art.ts and stage/gardenTheme.ts).
Add a small enemy. Write a class extending Enemy (src/sim/stage/enemies/enemy.ts) next to the others,
add its kind to EnemyKind and a line to ENEMIES in index.ts. TypeScript then asks for its view in
EnemyViews (src/game/views/EnemyViews.ts, parts in enemies/art.ts). Like a hazard, it must warn before
it hurts: draw its "!" with showMark.
Tune difficulty.
super(fight, 400) in src/sim/bosses/sunny.ts.idleTime (pause between attacks) and speedMuls (projectile speed) per phase, in the same file.src/sim/player.ts.hurt() in src/sim/player.ts.level for a harder one (so far a level-2 Pea Pod spits two peas after each "!").Replace the code-drawn art. Every picture is made by a bake(...) call with a key such as pip-body or
sunny-petal-front-0. bake.ts keeps a table of where each key lives (texture, frame, pivot, scale) and
part() reads it. To use drawn sprites, load an atlas in BootScene and fill that table from its frames
instead of baking; the views keep working.
pnpm check type-checks the app, type-checks src/sim/ a second time without browser types, lints,
runs the tests and checks formatting. The lint (pnpm lint) is oxlint with only its
"correctness" rules, for code that is outright wrong or does nothing; .oxlintrc.json says why the
stricter sets are left off. Unlike ESLint's TypeScript support, it doesn't need the compiler's JavaScript
API, which TypeScript 7 no longer has (it ships only an unstable new one).
The simulation tests compare this version with the original vanilla-JS game, using
tests/fixtures/legacy-baseline.json. That file was recorded from the original by a script; both were removed
once the port was verified and are still in git history (commit 0bdffb0). The fixture holds:
A fight needs no browser, which makes balancing with a bot quick:
const fight = new Fight({ seed: 1 });
while (fight.state === 'play') fight.step({ ...NO_INPUT, shoot: true, up: true });
console.log(fight.fightTime, fight.player.hp);
In the browser, open the game with ?debug to get window.sprout. step(n) runs n whole frames
without requestAnimationFrame, so it also works in background tabs and scripts (in a hidden tab, call it
once before anything else so the scenes start):
const S = window.sprout;
S.startBoss(); S.step(120); // Sunny's fight; skip the "Ready? GO!" intro
S.input.shoot = S.input.up = true; // hold shoot + aim up
S.step(60 * 10); // simulate 10 seconds
console.log(S.game.boss.hp, S.game.player.hp, S.game.state);
Add ?hitboxes (or call S.hitboxes(true)) to draw what the simulation actually collides with,
?skipintro to open on the menu instead of watching the opening on every reload, &chapter=2 to make new
games start at that chapter, without playing the ones before it, and &boss to make them start at its boss. S.startGame() is Play
(the Garden Path, then Sunny through its gate); S.startStage() and S.startBoss(hearts) start either one
directly; S.skipIntro() skips to "Press any key" and S.toMenu() goes back to the menu.
Two things to know when scripting it. Hit-stop (see App.hitStop) holds the simulation for a few frames
after Pip is hurt or Sunny changes phase, so step(n) can advance the fight by a little less than n
steps. And timing a tight step() loop does not measure frame time: WebGL calls queue up, so it measures
how fast the graphics card drains the queue. To check for stutter, let the game's own loop run and record
the gaps between requestAnimationFrame callbacks. To see what a frame costs, count drawElements calls
on S.phaser.renderer.gl and take a CPU profile while the loop runs. For reference, a phase-3 fight on an
M4 Mac at 1920x1080 is about 30 draw calls, 0.2 ms of script and 140 kB of short-lived memory per frame,
and the Garden Path about 35 draw calls. The title takes 20–26 draw calls through the opening and on the
menu.
Every pull request is checked by GitHub Actions (.github/workflows/ci.yml), and main takes it only
once both checks pass:
pnpm-lock.yaml lists, then runs pnpm check and pnpm build. It runs
again on main after each merge.Cloudflare's preview build (see "On Cloudflare") is a third check on the same pull request.
Dependencies are kept current by Dependabot (.github/dependabot.yml). A fix for a known
vulnerability arrives as a pull request as soon as the advisory is out. Routine updates come once a month,
minor and patch ones together and each major one on its own, and only once a release is a week old: a
hijacked release is usually found and pulled before then.
pnpm has guards of its own for when it picks a version. It waits a day before using a new release, and
refuses a dependency of a dependency that comes from git or a bare URL. It runs no install scripts:
allowBuilds in pnpm-workspace.yaml turns down the two packages that ask (esbuild and workerd), and the
build needs neither. And trustPolicy there refuses a release published with less proof of origin than
the ones before it, which is how most hijacked npm packages have shipped.
On GitHub, these are set in the repository's settings, not in any file:
| Setting | Value |
|---|---|
| Merging | merge commits only, titled after the pull request; its branch is deleted once merged |
Rule on main | changes only through a pull request whose checks pass; no force-push, no deletion |
Rule on tags v* | a version tag can be created, never moved or deleted |
| Actions | GitHub's own actions only, pinned to a commit; a pull request from a fork waits for approval |
| Dependabot | alerts, security updates and malware alerts |
| Code scanning | CodeQL, default setup |
| Secret scanning | on, with push protection: a push that contains a key or a token is refused |
| Private vulnerability reporting | on, as SECURITY.md explains |
The owner can still merge a pull request past a failing check, for an emergency such as an outage of
GitHub Actions, but nobody can push to main directly.
docs/future-improvements.md lists what could come next, from small fixes to a
second boss, with what each change would take. It also lists the ideas that were decided against, and why.
Copyright © 2026 Farul Ivan. All rights reserved.
The code is public so it can be read and its history followed, but it isn't open source. Beyond what GitHub's terms allow for every public repository (viewing it, and forking it on GitHub), no permission is given to copy, change or redistribute any part of it.
src/sim/stage/enemies/ | The small enemies a stage places (Grumbud, Puffball, Pea Pod, Snapper, and the Mushroom Wood's Capling, Burr, Dropcap and Toadlet), one file each, and their registry (index.ts). A boss can bring them too: Mo sprouts Caplings and blows spore bubbles (spore.ts) |
src/sim/stage/stages/ | The stages, as data: where everything goes. gardenPath.ts is Stage 1, mushroomWood.ts Stage 2 |
src/presentation.ts | The one table that turns each event into a sound, particles, rings, a shake, a flash or a toast |
src/chapters.ts | The chapters (CHAPTERS), one profile each: the stage and the boss, the tunes, and what the game says and shows about them (the chapter's title, the toasts in the fight, the win and lose cards, the sign and the gate, the boss's bar colours and portrait) |
src/app.ts | Screen flow (the opening, the title and the menu in the yard; pause, mute, win, lose; a stage, then its boss through the stage's gate; three lives a game), which chapter is being played (chapter), the fixed-step loop and hit-stop. Knows nothing about Phaser |
src/controls.ts, src/gamepad.ts | Keyboard, touch and gamepad merged into one input per step |
src/audio.ts | Synthesized sound effects and music, each on a bus with its own volume |
src/save.ts | Easy mode, mute, the music and effects volumes, and the best time for each boss and each stage, in one versioned localStorage entry (or in a slot of its own: see "More than one version online") |
src/game/art/ | draw.ts: Canvas 2D paths, gradients and shading helpers. face.ts: the small shared face. color.ts: colour maths and the shared outline pens. bake.ts: turns drawings into textures, packs them into an atlas, and bends one along a curve (Ribbon). shared.ts: parts several views use |
src/game/views/ | One view per thing on screen. Each bakes its own parts and animates them from the simulation: PlayerView, SunnyView, MoView, ProjectileViews, HazardViews, PickupViews, WorldView, and EnemyViews (parts in enemies/art.ts). chapterViews.ts says how each chapter's world looks. pool.ts reuses the views of things that come and go |
src/game/views/title/ | Pip's yard (YardView), the logo in parts (Logo), the studio's sting (Sting), and their art (art.ts) |
src/game/views/Iris.ts | The round hole that opens on a scene and closes on it, as old cartoons do: the title's, and each stage's start |
src/game/views/stage/ | A stage's world: StageWorldView (the sky that stays put, landscape strips that repeat and slide by how far away they are, the ground with its pits, logs, thorns and leaf pads, the sign, the gate, and the flowers cheered-up plants leave), which lays out any theme, and the Garden Path's theme (gardenTheme.ts) and parts (art.ts) |
src/game/views/wood/ | The Mushroom Wood's theme (woodTheme.ts), its world (art.ts), its enemies and bounce pad (enemyArt.ts) and Mo's hollow (hollow.ts), baked the first time the chapter is played |
src/game/views/mo/ | Mo: his parts and palette (art.ts), his layered face (face.ts), and the art and views of his two big attacks (smoke.ts, ring.ts). bake.ts bakes all of it on an atlas page of its own. docs/mo-art/ is the reference art it was drawn from, and docs/chapter-2-boss-spec.md the design |
src/game/views/world/ | The arena, one file per layer: sky.ts, air.ts and weather.ts (which blends between the moods: Sunny's four, the title's dawn, and the wood's and Mo's four) are shared; land.ts and ground.ts are the garden's, handed to WorldView by chapterViews.ts |
src/game/views/sunny/, hazards/ | The bigger views keep their drawing code beside them: Sunny's parts (art.ts) and layered face (face.ts), the hazards' parts (art.ts) |
src/game/scenes/, src/game/hud/ | BootScene (font, baking), TitleScene, FightScene and StageScene (sync the views every frame; only the one for what is being played runs, and each chapter has a FightScene and a StageScene of its own; TitleScene also stages the opening), HudScene (hearts, the boss's bar or the stage's path bar and golden seeds, toasts, the chapter and stage names); hud/art.ts bakes the HUD's parts |
src/game/fx.ts, motion.ts, debug.ts | Particles, rings, the screen flash, shake and camera punch; small helpers for moving things (rand, easeOutBack); the hitbox overlay |
index.html, src/style.css, src/ui/ | The loading screen, the HTML cards ("Press any key", the signpost and its pages, win, lose, pause) and the touch buttons layered over the canvas |
tests/ | Simulation tests (the boss fight and the stages), bot fights, and tests for the save, gamepad, music and weather code and for moving between a card's buttons |
BootScene sets this for every device (Phaser's default on
phones and tablets). Otherwise Phaser compiles a new shader the first time a batch uses a new number of
textures, which drops frames mid-fight. Thanks to the atlas a frame is still only about 20-35 draw calls.BlendModes.ADD) cannot share a draw call
with one drawn as paint, so interleaving them (halo, petal, halo, petal) costs a draw for each. Give
glows a depth a hair under or over the things they belong to, as the shots, pollen and particles do, and
only make something light if it has to be.Ribbon; a bar that grows is a stretched sprite. Graphics is cut into
triangles again on every frame it changes, so it is kept for the debug overlay.sync() runs every frame, so it should not make things. Views keep their images, the particles a
whole fight needs are made at startup and recycled (READY in src/game/fx.ts), and work that only
matters when something changes (tinting the ground to the weather) waits for the change.front in FightScene) must stay below the line Pip
stands on, or it will hide feet and ground warnings.style.css (src/ui/layout.ts only keeps --ui
in step with it), and the loading screen (#loading in index.html) is plain HTML and CSS. It covers
the stage, with the cards hidden under it, until the title has been drawn once; then src/ui/loading.ts
fades it out onto the studio's sting, printed on the same cream paper. Keep it free of anything the
script makes.GameAudio.canPlay), and notes scheduled meanwhile would all play at once.setTintFill,
Geom.Point). Phaser ships its own notes per topic in node_modules/phaser/skills/*/SKILL.md; read the
relevant one before using an API.