The pack
levelSaga
function levelSaga(): Pack<{ levelSaga: typeof kit; }>
Compose the level saga rules under game.levelSaga.
Helpers
createLevelSaga
function createLevelSaga(config: SagaConfig): LevelSaga
Make a saga from its config. Throws, naming the field, on a config it cannot run.
Classes
LevelSaga
class LevelSaga
The meta-game around a level-based puzzle: a map of levels in chapters with hard and super-hard tiers, lives that
refill on a clock, coins, stars and boosters, continues with rising prices, win-streak gifts, and timed events
(milestone tracks and races against seeded rivals). It never reads a wall clock: time moves only through
elapse(seconds) (and away for time the game was closed), so everything replays. It plays no level itself: the
game starts one, plays it with its own rules and reports the result. Get one from createLevelSaga.
constructor(config: SagaConfig)get config(): SagaConfig— A copy of the config.get state(): SagaState— A copy of the state: everything a save needs.get time(): number— Saga time in seconds.get coins(): number— Coins.get stars(): number— Spendable stars.get streak(): number— Wins in a row.get attempt(): SagaAttempt | null— The level being played, or null.get next(): string | null— The first level not yet won, or null when every level is won.get won(): number— Levels won.lives(): { count: number; max: number; nextIn: number | null; unlimited: number }— Lives now, the maximum, seconds to the next one (null when full or with lives off) and seconds of unlimited lives left.boosters(id: string): number— How many of a booster the player owns.items(name: string): number— The game's collectible count by name.map(chapter?: string): SagaMapLevel[]— Every level with its number, tier, chapter, status and stars; or only one chapter's whenchapteris given.chapters(): { id: string; first: number; last: number; won: number; done: boolean }[]— The chapters in order, with their levels' first and last numbers and how many are won.level(id: string): SagaMapLevel | undefined— One level's map entry, or undefined for an unknown id.streakBoosters(): Record<string, number>— The before-boosters the current streak gives the next start (empty below the first tier).continuePrice(): number | null— The price of the next continue in the current attempt, or null when none is left or no level is being played.elapse(seconds: number): SagaEvent[]— Move saga time on byseconds(0 to 31536000): lives refill, unlimited lives run out, events end.away(seconds: number, cap = 28800): SagaEvent[]— Time the game was closed:elapsecapped atcapseconds (default 8 hours), so a wrong clock cannot give a day of lives.startLevel(level?: string, boosters: Record<string, number> = {}): SagaResult— Start a level, by default the next one. Takes a life first when lives cost on start, consumes the chosen before-boosters (boosters, by id), adds the streak's free ones, and returns them in thestartedevent for the game to apply.useBooster(id: string): SagaResult— Use a during-booster in the current level.buyBooster(id: string, count = 1): SagaResult— Buycount(1 to 99, default 1) of a booster with coins.buyLives(): SagaResult— Buy a full set of lives with coins.continueLevel(): SagaResult— Buy a continue in the current level atcontinuePrice(): what it gives (moves, slots) is the game's to apply.finishLevel(result: { won: boolean; stars?: number; items?: Record<string, number> }): SagaResult— End the current level. A win records its best stars (0 to the maximum), pays coins (a first win's by tier, a replay's less), grows the streak, unlocks the next level and moves events on, countingitemsthe game collected. A loss takes a life (when lives cost on failing), ends the streak and can set a race back.abandonLevel(): SagaResult— Give up the current level: a loss.grant(reward: SagaReward, receipt?: string): SagaResult— Give a reward. With areceiptid (1 to 100 characters) it is given once only: a second grant with the same receipt is refused as'duplicate'and changes nothing.spendStars(count: number): SagaResult— Spend stars (on renovation, say). Refused, changing nothing, when there are not enough.startEvent(def: SagaEventDef): SagaResult— Start a timed event now. An event whose id is running is refused; an ended one with that id is replaced.dropEvent(id: string): SagaResult— Drop an ended event from the state.events(): (SagaEventState & { left: number; standings: { who: string; wins: number }[] })[]— The events, each with seconds left and, for a race, the standings now (the player as'you', best first).restore(state: SagaState): void— Put back a statestategave, from this config's id. Ids the config no longer has are dropped, not refused.
Types
SagaAttempt
interface SagaAttempt
A level being played.
level: string— The level id.tier: SagaTier— Its tier.boosters: Record<string, number>— Before-boosters it started with, bought or from the streak: the game applies them.continues: number— Continues bought in this attempt.used: Record<string, number>— During-boosters used in this attempt.paid: boolean— Whether starting took a life (lives that cost on start), so a win gives it back.
SagaBoosterDef
interface SagaBoosterDef
A booster kind.
id: string— Unique id, 1 to 100 characters.when: 'before' | 'during'—'before': chosen when a level starts and applied from its first move.'during': used at any point in a level.price?: number— Coins for one; 0 (default) means it cannot be bought.start?: number— How many a new player has; default 0.
SagaChapterDef
interface SagaChapterDef
A run of consecutive levels shown as one area of the map.
id: string— Unique id, 1 to 100 characters.levels: number— Levels in it, 1 to 1000. Chapters take levels in order; levels past the last chapter form no chapter.
SagaConfig
interface SagaConfig
The authored saga. Saves are keyed on ids and survive edits to everything else.
id: string— Names this game's saves: a save restores only into a config with the same id.levels: number | SagaLevelDef[]— The levels in order: a count (idslevel-1up, tiers fromhardEveryandsuperHardEvery) or definitions; 1 to 100000.hardEvery?: number— With a level count: every n-th level is hard; default 0 (none).superHardEvery?: number— With a level count: every n-th level is super hard, which wins over hard; default 0 (none).chapters?: number | SagaChapterDef[]— Map areas: a level count per area (eachchapter-1up), or definitions; default 20 levels an area.lives?: { max?: number; refill?: number; cost?: 'fail' | 'start' }— Lives:max0 to 99 (0 turns lives off; default 5),refillseconds a life, 1 to 86400 (default 1800),costwhen a life is taken (default'fail';'start'takes it on starting and gives it back on a win).coins?: { start?: number; win?: number; replay?: number; tiers?: Partial<Record<SagaTier, number>>; refill?: number }— Coins:startfor a new player (default 0),winfor a first win (default 10),replayfor winning again (default 0),tiersmultipliers (default normal 1, hard 2, super hard 3),refillprice of a full set of lives (default 900; 0 means not for sale).stars?: number— Most stars a level can earn, 0 to 5; default 3.starReward?: number— Stars added to the spendable stars per new star earned on a level; default 1.continues?: number[]— Coins for each continue in one attempt, in order; default [900, 1900, 2900]. An empty list allows none.boosters?: SagaBoosterDef[]— Booster kinds.streak?: { wins: number; boosters: Record<string, number> }[]— Free before-boosters for a run of wins: the highest tier the current streak reaches applies to the next start.replay?: boolean— Whether a won level can be played again; default false.seed?: number— Seed for rivals' paces, 0 to 4294967295; default 1.
SagaEvent
type SagaEvent = | { type: 'life-gained'; lives: number } | { type: 'life-lost'; lives: number } | { type: 'started'; level: string; boosters: Record<string, number> } | { type: 'won'; level: string; stars: number; first: boolean; coins: number } | { type: 'lost'; level: string } | { type: 'unlocked'; level: string } | { type: 'chapter-done'; chapter: string } | { type: 'streak'; wins: number } | { type: 'reward'; source: string; reward: SagaReward } | { type: 'milestone'; event: string; step: number; reward: SagaReward } | { type: 'race-finished'; event: string; place: number; reward: SagaReward | null } | { type: 'event-started'; event: string } | { type: 'event-ended'; event: string }
What the saga did, in order.
SagaEventDef
type SagaEventDef = SagaMilestoneEventDef | SagaRaceEventDef
A timed event.
SagaEventState
interface SagaEventState
An event in progress or finished and not yet dropped.
def: SagaEventDef— The event's definition.startedAt: number— Saga second it started.endsAt: number— Saga second it ends.points: number— Milestones: points so far.reached: number— Milestones: steps reached (and rewarded).race: SagaRaceState | null— Race standing; null for milestones.ended: boolean— True once it has ended.
SagaLevelDef
interface SagaLevelDef
One level of the map.
id: string— Unique id, 1 to 100 characters; progress is saved against it.tier?: SagaTier— Difficulty tier; default'normal'.
SagaMapLevel
interface SagaMapLevel
A level's place on the map, from map().
id: string— Level id.number: number— 1-based position in the saga.tier: SagaTier— Tier.chapter: string | null— Chapter id, or null past the last chapter.status: 'done' | 'next' | 'locked'—'done'(won),'next'(the first not won) or'locked'.stars: number— Best stars, 0 until won.
SagaMilestoneEventDef
interface SagaMilestoneEventDef
A milestone track: points from levels unlock rewards in turn.
kind: 'milestones'—'milestones'.id: string— Unique id among events, 1 to 100 characters.duration: number— How long it runs, in seconds of saga time, 1 to 31536000.points?: { win?: number; perStar?: number; items?: Record<string, number> }— Points for a won level, per star on it, and per collectible reported byfinishLevel; default 1 a win.steps: { points: number; reward: SagaReward }[]— The rewards, in order, each at a running total of points (strictly rising).
SagaRaceEventDef
interface SagaRaceEventDef
A race: first to win target levels against rivals who win at their own seeded pace.
kind: 'race'—'race'.id: string— Unique id among events, 1 to 100 characters.duration: number— How long it runs, in seconds of saga time, 1 to 31536000.target: number— Wins needed, 1 to 1000.rivals: number— Rival racers, 1 to 99.pace?: [number, number]— Each rival's seconds per win, drawn once between these; default [120, 600].resetOnLoss?: boolean— True: a lost level sets the player's wins back to 0. Default false.rewards: SagaReward[]— Rewards by finishing place, first place first; a place past the list gets nothing.
SagaRaceState
interface SagaRaceState
A race in progress.
wins: number— Player's wins.finishedAt: number | null— When the player reached the target, in saga seconds; null until then.paces: number[]— Each rival's seconds per win.place: number | null— The place the player finished in, from 1, once known; null before.
SagaRefusal
type SagaRefusal = | 'in-level' | 'not-in-level' | 'locked' | 'already-won' | 'no-lives' | 'unknown-level' | 'unknown-booster' | 'wrong-booster-kind' | 'no-booster' | 'not-for-sale' | 'not-enough-coins' | 'no-continues-left' | 'not-enough-stars' | 'lives-full' | 'unknown-event' | 'event-running' | 'bad-count' | 'bad-reward' | 'duplicate'
Why an action was refused.
SagaResult
type SagaResult = { valid: true; reason?: undefined; events: SagaEvent[] } | { valid: false; reason: SagaRefusal; events: [] }
The answer to an action: done, with its events, or refused with a reason and nothing changed.
SagaReward
interface SagaReward
Anything the saga can give. Every field is optional; counts are whole numbers from 0.
coins?: number— Coins.lives?: number— Lives, added past the maximum if need be.unlimited?: number— Seconds of unlimited lives, added to any already running.boosters?: Record<string, number>— Boosters, by id.stars?: number— Stars, the currency a renovation or decoration meta spends.items?: Record<string, number>— The game's own collectibles, by name.
SagaState
interface SagaState
Everything that changes; a save holds exactly this.
id: string— The config id this state belongs to.time: number— Saga time in seconds: what lives and events run on.lives: number— Lives now.refillAt: number | null— Saga second the next life arrives, or null when lives are full.unlimitedUntil: number— Saga second unlimited lives run out; at or beforetimemeans none.coins: number— Coins.stars: number— Spendable stars.items: Record<string, number>— Collectibles, by name.boosters: Record<string, number>— Boosters owned, by id.done: Record<string, number>— Best stars on each won level, by id (0 for a win with no stars).streak: number— Wins in a row.plays: number— Levels started.attempt: SagaAttempt | null— The level being played, or null.events: SagaEventState[]— Events running or ended and not yet dropped.receipts: string[]— Ids of rewards granted with a receipt, so a reward is never given twice.
SagaTier
type SagaTier = 'normal' | 'hard' | 'super-hard'
A level's difficulty tier, which scales its coin reward.


