saga_map
saga_map is a Flutter package for building world-map style level progression UIs.
It provides:
- responsive layout policies
- background layers (image, solid colour, multi-segment, or any widget you build)
- interaction policy/handler primitives
- level generation and progression utilities
- a character that walks the path, in any visual format
- curved paths, a walked/upcoming split, scenery, episode headers, parallax and gates
Screenshots
The images below are the package's own rendering output (from the golden tests), so they show exactly what it draws.
| Curved path (vertical) | Walked vs. upcoming | Curved path (horizontal) |
|---|---|---|
![]() |
![]() |
![]() |
- Curved path —
pathCurvaturebends the line between nodes; the nodes stay put. Continuous across chunk seams. - Walked vs. upcoming — the stretch the player has covered is drawn in the bright colour, the road ahead dimmed, split exactly under the character.
New in 2.1.0
Photographs of the example app on a device, not golden renders — see CONTRIBUTING.md for the difference.
| Replay modes on the map | Mode scores side by side |
|---|---|
![]() |
![]() |
- Replay modes (
starsByMode) — levels can be replayed under alternative mode rules (e.g.'hard') without overriding normal mode star counts or advancing the unlock frontier. - Per-mode star accounting —
LevelProgress.starsFor(modeId)inspects scores per mode, keeping the progression history intact and cleanly separated.
| The star economy | Economy toggles in the sheet |
|---|---|
![]() |
![]() |
- Star economy (
spentStars&availableStars) — stars are no longer just an ever-growing score;SagaProgress.spendStars()allows spending them on toll gates, perks, or content unlocks. - Pity rule and persistence injection —
SagaPityRuleguarantees rare-or-better loot drops after bad streaks, whileexecuteAndPersistties roll results directly to an injectedInventoryRepository.
New in 2.0.0
Photographs of the example app on a device, not golden renders — see CONTRIBUTING.md for the difference.
| Host-defined realms | The realm turning over | The same stretch, built-in ids |
|---|---|---|
![]() |
![]() |
![]() |
- Host-defined biome ids — the demo passes five realm ids of its own to
SagaMapConfig.biomeIds, none of which the package ships a theme for. The package cycles them and hands each back to the host's resolver. biomeSpanand the id count set the cycle together — ten levels each against five realms closes in fifty, so the middle image turns over twice in one screen. The right-hand image is the same scroll position with the built-in three ids at fifty levels each: one colour, all the way down.SagaBiomeTheme.ambientTint— the wash over each realm, painted by the chunk painter over the background and the path but under the node widgets.SagaBiomeTheme.assets— the letter on each node is read back out of that opaque map. The package carried it from the theme to the node builder without ever looking inside.
| Injected boss rule and loot table | The walked path | The feature sheet |
|---|---|---|
![]() |
![]() |
![]() |
- Injectable rewards — the square nodes are bosses. The demo injects
bossRule: (id) => id % 5 == 4and its ownlootTable, so bosses land every fifth level instead of the package's fifteenth and drop the demo's own items. - Gates — the demo's gate sits at level 12 and starts closed. One predicate drives all three consumers: the character stops before it, nodes past it stop responding, and clearing level 11 no longer opens level 12.
New in 1.1.0
| Level-anchored bands and host data | Episode header from the chunk context |
|---|---|
![]() |
![]() |
- Level-anchored bands — the red stripes are
SagaMapDecoration.atLevel: aligned to a level id rather than a pixel coordinate, spanning the chunk's full width and ignoring the path's lateral wander. The round scenery is tinted fromSagaChunkContext.dominantBiomeId, which the decoration builder now receives. - Host-owned data on a node — the dark stripe on the second node is drawn
from a
bookmarkedflag the app stored inLevelProgress.extra. The package persists it and never interprets it. - Stars without a loop — the banner's amber pips are
SagaProgressStars.starsInRangeover that chunk's levels, read straight off theSagaChunkContexthanded toepisodeHeaderBuilder.
These are golden-test renders, so decorations sit above the background layer and below the path and nodes exactly as the package draws them. Note that a chunk paints its own base background when
backgroundConfigisSagaMapBackgroundConfig.none(), which covers decorations — give the map a background layer when you use scenery.
Run the demo (cd example && flutter run) for the whole feature set live: a
walking character, pinch zoom, scenery, episode banners, parallax, gates, and a
control panel to toggle each one.
Installation
flutter pub add saga_map
import 'package:saga_map/saga_map.dart';
Quick Start
import 'package:flutter/material.dart';
import 'package:saga_map/saga_map.dart';
class WorldMapScreen extends StatelessWidget {
const WorldMapScreen({super.key});
@override
Widget build(BuildContext context) {
final levels = <LevelData>[
const LevelData(
id: 0,
position: SagaPoint(0.20, 0.08),
biomeId: kBiomeIdForest,
),
const LevelData(
id: 1,
position: SagaPoint(0.35, 0.16),
biomeId: kBiomeIdDesert,
),
];
return Scaffold(
appBar: AppBar(title: const Text('Saga Map')),
body: MapChunkWidget(
levels: levels,
chunkIndex: 0,
chunkExtent: 900,
chunkSpanNormalized: 1.0,
biomeThemeResolver: const DefaultSagaBiomeThemeResolver(),
interactionPolicy: const SagaNodeInteractionPolicy(
emitTapForLockedNode: false,
emitTapForCompletedNode: true,
),
nodeBuilder: (context, level, layout) {
return DecoratedBox(
decoration: const BoxDecoration(
color: Colors.indigo,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${level.id}',
style: const TextStyle(color: Colors.white),
),
),
);
},
),
);
}
}
This quick start mirrors the real package usage in example/lib/main.dart
with MapChunkWidget, SagaInfiniteMapView, SagaMapLevelGenerator,
and CompleteLevelUseCase.
Level ids
Level ids are zero-based. The first level a player sees on the map has id == 0.
Wherever a player reads the number, you must display id + 1.
| Context | What to use | Example |
|---|---|---|
| Storage | level.id |
Saved progress uses 0 for the first node |
| Logic | level.id |
Generator seeds, boss math (id % 15 == 14) |
| UI | id + 1 |
"Level 1", screen-reader announcements |
// Inside your nodeBuilder:
Text(
'${level.id + 1}',
style: const TextStyle(color: Colors.white),
)
The function isBossLevel(levelId) is public because callers need to know if a
level was a boss to display the appropriate icon before it is played, and
because it is the default value of CompleteLevelUseCase.bossRule — a default
has to be nameable to be overridable.
Because ids are zero-based, "every fifteenth level" is id % 15 == 14. That
also makes every boss a difficulty-5 board, since difficulty is 1 + id % 5.
Versioning
This package follows Semantic Versioning.
A change is breaking (requires a major version bump) if it breaks:
- Compilation: A public signature changes or is removed.
- Saved data format: Existing serialized progress can no longer be read.
- Saved data meaning: Reading old data behaves differently (e.g. altering the deterministic sequence of levels).
Deprecation policy: Nothing marked @Deprecated is removed in the same major version. It will emit a warning until the next major release.
Upgrading from 2.0.0
2.1.0 has no breaking changes. The one thing worth a look: if you kept a spent-
star ledger or per-mode scores in extra because 2.0.0 had nowhere else for
them, the package does not move them for you — extra is never read. The
changelog has a copy-pasteable migration for each.
Upgrading from 1.x
2.0.0 is a breaking release; the full list, each item with a copy-pasteable
escape hatch, is in CHANGELOG.md.
One item needs a decision before you ship, not after. 1.x read a level with
no saved record as unlocked if it sat below currentMaxUnlockedLevelId, so a
1.x host could persist a pointer far ahead of a sparse levels map. 2.0.0 reads
an unrecorded level as locked and reconciles the pointer against the records
beside it, so such a save loads with a lower pointer — and with
enforceUnlockOrder now on by default, the levels above it refuse to complete.
To the player that is lost progress.
SagaProgress.migrateFrom1x is the one-time conversion, and
SagaProgress.fromJson's onClamp callback tells you whether you are affected
at all. See
Migration — 1.x saves in the changelog.
Architecture decisions
Design rationale that would bloat a doc comment lives in numbered decision
records. The CHANGELOG and several public doc comments cite them by number
(ADR-0003, ADR-0009, ...); every one resolves to a file in
docs/adrs/.
They are tracked in the repository but excluded from the published package
archive, so a pub consumer follows the link rather than downloading them with
every version.
Public API Design
Only import this file from your app:
import 'package:saga_map/saga_map.dart';
lib/src is internal implementation detail and is not part of the stable API contract.
Avoid direct imports like package:saga_map/src/... in app code.
The package keeps a "showroom + kitchen" boundary:
- showroom:
lib/saga_map.dart(stable public API) - kitchen:
lib/src/**(internal organization and implementation)
Main Features
Background layer
The package positions and scrolls the background; it never loads it. Four ways to supply one:
// Nothing — the biome theme paints the chunk.
backgroundConfig: const SagaMapBackgroundConfig.none()
// A flat colour.
backgroundConfig: const SagaMapBackgroundConfig.color(color: Color(0xFF24451F))
// A raster asset the package loads with Image.asset. One, or one per chunk:
backgroundConfig: const SagaMapBackgroundConfig.imageAsset(
assetPath: 'assets/map/world.webp',
fit: BoxFit.cover,
)
backgroundConfig: const SagaMapBackgroundConfig.imageAssets(
assetPaths: ['assets/map/world_1.png', 'assets/map/world_2.png'],
overflowBehavior: SagaMapBackgroundOverflowBehavior.loop,
)
// Anything else — you build the widget, the package places it.
backgroundConfig: SagaMapBackgroundConfig.builder(
(context, chunkIndex) => const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Color(0xFF1B3B1A), Color(0xFF0D1F0F)]),
),
),
)
SVG is a builder like anything else. As of 2.0.0 the package does not
depend on flutter_svg; add it to your own app and return an SvgPicture:
# your pubspec.yaml
dependencies:
flutter_svg: ^2.2.4
import 'package:flutter_svg/flutter_svg.dart';
backgroundConfig: SagaMapBackgroundConfig.builder(
(context, chunkIndex) => SvgPicture.asset(
'assets/svg/map_vertical.svg',
fit: BoxFit.cover,
),
)
The builder receives the chunk index, which is how you vary artwork across chunks — including choosing your own behaviour once the chunks outrun the artwork, rather than picking from the package's three:
backgroundConfig: SagaMapBackgroundConfig.builder(
(context, chunkIndex) => SvgPicture.asset(
assets[(chunkIndex ?? 0) % assets.length],
fit: BoxFit.cover,
),
)
A builder config ignores fit, alignment and overflowBehavior — those
describe how the package would place an asset it loaded, and here it loads
nothing. Size and align the widget yourself. A null builder renders nothing.
Orientation
pathAxis is the single switch between a vertical and a horizontal map. It
drives coordinate mapping, which viewport dimension counts as lateral, and the
scroll direction of SagaInfiniteMapView — there is no separate scroll axis to
keep in sync.
final responsiveResolver = SagaResponsiveResolver(
config: SagaMapResponsiveConfig.defaults.copyWith(
pathAxis: SagaMapPathAxis.horizontal,
),
);
Sizes are expressed along the path axis, never as width/height:
chunkExtent— pixels the chunk occupies along the path axis (height when vertical, width when horizontal).chunkSpanNormalized— normalized span the chunk covers along that axis. It must equalstepHeight * levelsPerChunk; useconfig.spanForLevelCount(levelsPerChunk)on yourSagaMapConfigrather than hardcoding it. (It is an instance method, not a static one — it reads the config's ownstepHeight.)maxLateralExtentPolicy— caps the lateral axis only, so it never shortens the direction the path travels in.
The cross axis fills whatever space the parent gives it, so a horizontal map
belongs in a horizontally scrollable parent and a vertical map in a vertical
one. SagaInfiniteMapView handles this for you.
Responsive policies
Breakpoints always resolve from the real viewport width, on both orientations —
a long horizontal map on a phone still resolves as mobile.
final responsiveResolver = SagaResponsiveResolver(
config: SagaMapResponsiveConfig.defaults.copyWith(
nodeSizePolicy: const SagaMapValuePolicy(
mobile: 1.20,
tablet: 1.0,
desktop: 0.95,
ultra4k: 0.9,
),
// Same value everywhere.
nodeSpacingPolicy: const SagaMapValuePolicy.all(1.0),
),
);
Every policy affects rendering:
| Policy | Effect |
|---|---|
nodeSizePolicy |
Node visual size. |
zoomPolicy |
Node size and path stroke width together. |
nodeSpacingPolicy |
Chunk extent along the path axis, so the gap between nodes. |
interactionRadiusPolicy |
Touch target relative to visual size. |
cameraPaddingPolicy |
Pixels reserved at each end of the lateral axis. |
maxLateralExtentPolicy |
Cap on the lateral axis; never the path axis. |
scrollSensitivityPolicy |
Drag distance multiplier while scrolling. |
Node visual size and touch target are separate. Visual size is
baseNodeSize * nodeSize * zoom; the touch target is derived from it via
interactionRadius and floored at minTouchTarget (44dp). A node can therefore
shrink below the accessibility minimum visually while staying comfortably
tappable.
zoomPolicy is the viewport-driven baseline; user pinch zoom multiplies it.
Pinch zoom
SagaInfiniteMapView(
zoomConfig: const SagaMapZoomConfig(min: 0.5, max: 3),
onZoomChanged: (zoom) => setState(() => _zoom = zoom),
// ...
)
Leaving zoomConfig null keeps the map at a fixed scale.
Zoom is applied to the layout, not as a paint transform: the chunk really becomes larger. The enclosing list therefore keeps a correct scroll extent, chunk recycling keeps working, hit testing needs no inverse mapping, and the path is rasterised at its final size instead of being magnified.
One finger scrolls, two fingers zoom. The pinch recognizer stays out of the gesture arena until a second finger lands, then claims it immediately — waiting for movement would lose the race to the scrollable underneath, which sits deeper in the tree and is offered each event first. The known limit of that approach: a pinch begun after a one-finger drag has already captured the arena will scroll rather than zoom, until the fingers lift.
Progress-aware interaction
MapChunkWidget(
levels: levels,
chunkIndex: 0,
chunkExtent: 900,
chunkSpanNormalized: 1.0,
biomeThemeResolver: const DefaultSagaBiomeThemeResolver(),
progressResolver: (level) => progressByLevel[level.id],
interactionHandler: SagaNodeInteractionHandler(
onNodeTap: (level) => debugPrint('Tapped ${level.id}'),
),
nodeBuilder: (context, level, layout) => const SizedBox.shrink(),
)
Path roundness
pathCurvature runs from 0 — straight lines between nodes, the default — to
1, a fully rounded spline in the style of a casual world map.
SagaInfiniteMapView(
pathCurvature: 0.8,
// ...
)
The spline interpolates, so raising the value bends the line between nodes
without moving the nodes themselves. Each control handle points from the
previous node towards the next, which is what makes the curve enter and leave a
node on one smooth tangent, and its length is capped at half its segment — that
cap is why 1 is safe rather than folding the line into a cusp. At 0 the
handles collapse onto the endpoints and the result is exactly the straight
polyline.
A curved path needs kSagaPathNeighborCount levels of context on each side of a
chunk to stay smooth across a seam, because a curve's shape depends on its
surroundings. SagaInfiniteMapView supplies them; if you drive MapChunkWidget
yourself, fill leadingNeighbors and trailingNeighbors.
Accessibility
Nodes are exposed to screen readers as buttons carrying the level number, its state and star count, and are marked disabled when the interaction policy rejects taps. Override the label to localise:
MapChunkWidget(
// Ids are zero-based; a player hears the number, so announce `id + 1`.
semanticsLabelBuilder: (level, progress) => 'Niveau ${level.id + 1}',
// ...
)
Visual size and touch target are independent, so a node can shrink below 44dp while staying comfortably tappable. Right-to-left layouts mirror a horizontal map's path axis; vertical maps are unaffected.
Nodes are keyboard-reachable: Tab moves between them in level order (not paint order), and Enter or Space activates the focused node. Shift+F10 or the context-menu key triggers the long-press action. Locked nodes are skipped — the tap gate, the Semantics tree and the Tab order all read the same value, so they cannot disagree.
A node with no progress record is locked, not open. Two absences, two answers:
| Situation | Node reads as |
|---|---|
No progressResolver at all — the host does not model progression |
unlocked |
A progressResolver that returns null for this level |
locked |
An unrecorded level on a map that does track progress must not become tappable just because a record was never written; that would be a progression skip needing no tampering at all. A map with no progress tracking stays fully navigable. (1.x treated every absence as open.)
Draw a focus ring by reacting to SagaNodeInteractionState.focused,
which is emitted only when focus arrives by keyboard:
interactionHandler: SagaNodeInteractionHandler(
onNodeFocusChange: (level, state) => setState(() {
focusedLevelId = state == SagaNodeInteractionState.focused ? level.id : null;
}),
)
Headers and the camera
If you build episode headers, tell the view how tall they are:
SagaInfiniteMapView(
chunkEpisodeHeaderBuilder: (context, chunk) => EpisodeBanner(chunk),
episodeHeaderExtent: 80, // the banner's height, or width on a horizontal map
// ...
)
Each list item is header + chunk, so a header the view does not know about
shifts chunk c by c headers and every camera target — scrollToPathPosition,
the opening scroll, following the character — lands short by a growing margin.
It is declared rather than measured for the same reason chunkExtent is: the
target has to be computed before the header is laid out.
Bounded memory
SagaInfiniteMapController(
chunkLoader: loadChunk,
maxRetainedChunks: 12,
)
A target rather than a hard cap: chunks currently on screen are never dropped, so a budget below the visible working set is exceeded rather than thrashing. Evicted chunks reload when scrolled back to, which is safe because loaders are deterministic.
Determinism
Level positions come from (globalSeed, levelId) alone, via stableHash. That
means:
- Generating chunk 10,000 costs the same as chunk 0 — no replay from level zero.
- The same seed produces the same map on every run, platform and release, so saved progress keeps pointing at the same map.
Do not use Object.hash for anything you persist or regenerate: it mixes in
identityHashCode(Object), which is randomised per program run.
What determinism costs: client-rolled loot is advisory
A boss reward is a pure function of (levelId, globalSeed, table), and
saveGlobalSeed makes the seed writable. A player who can reach the stored seed
can work out offline — before clearing the boss — which seed drops the item they
want, write that seed, and then clear it.
This is not a hole to patch. It is the same property that makes a bug reproducible and a golden test stable, seen from the other side; mixing in something unpredictable would buy integrity the package cannot enforce anyway and would cost determinism outright (ADR-0009).
So, plainly: the reward this package rolls is advisory, not authoritative.
- Inventory that never leaves the device — nothing to defend, ignore this.
- Inventory promoted to a server — roll the reward on that server and treat
rollBossRewardas a preview of what a boss would give. The first-clear guard inCompleteLevelUseCase.executeis a convenience, not a security boundary.
Regenerating the map
The whole map derives from one seed: level positions, biomes and boss rewards. Persist a new one and the world is a different world.
await repository.saveGlobalSeed(12345);
// Regenerate against the stored seed. Everything downstream follows.
final seed = await repository.loadGlobalSeed();
final levels = generator.generateLevels(
globalSeed: seed,
config: SagaMapConfig.defaultConfig,
startLevelId: 0,
count: 50,
);
Saved progress survives, but it means something different afterwards: a
SagaProgress keeps its level ids, and those ids now point at different
terrain. Level 12 is still complete; it is no longer the same level 12. Reseed
on a new game, not on an existing one, unless you intend exactly that.
loadGlobalSeed must not write. If your implementation used to persist a
default seed on first read, that write belongs in saveGlobalSeed — a load
that changes the map is not a load.
Domain utilities
const generator = SagaMapLevelGenerator();
final levels = generator.generateLevels(
globalSeed: 42,
config: SagaMapConfig.defaultConfig,
startLevelId: 0,
count: 50,
);
You can also track star collections without duplicate loops:
final stars = progress.starsInRange(0, 10);
final perfect = progress.isRangePerfect(0, 10);
Rewards
execute returns the reward; it does not store it. Write it yourself:
final result = completeLevelUseCase.execute(
currentProgress: progress,
levelId: level.id,
globalSeed: 42,
);
if (result.reward != null) {
await inventoryRepository.addItem(result.reward!);
}
Or inject the repository and let the use case write it (2.1.0):
final useCase = CompleteLevelUseCase(inventory: inventoryRepository);
final result = await useCase.executeAndPersist(
currentProgress: progress,
levelId: level.id,
globalSeed: 42,
);
// true when there was a reward and it was written
print(result.rewardPersisted);
await progressRepository.saveProgress(result.nextProgress);
executeAndPersist applies exactly execute's rules, so a completion that
mints nothing writes nothing, and replaying a cleared boss writes no second
item. If the write throws, the exception reaches you and no result comes back:
treat the completion as not having happened, and do not save progress for it.
The reward store and your progress store are two stores the package does not
own, so making them one transaction is yours to do. execute never writes,
even with an inventory injected, and executeAndPersist without one throws a
StateError.
Custom rewards
Both halves of the reward decision are injected. bossRule picks which levels
drop; lootTable picks what they drop from. Omit either and you get the
package's own default, so const CompleteLevelUseCase() behaves as it always
did.
const myTable = <LootTableEntry>[
LootTableEntry(
itemId: 'ember_shard',
itemName: 'Ember Shard',
rarity: InventoryRarity.common,
weight: 70,
),
LootTableEntry(
itemId: 'sunspire_crown',
itemName: 'Sunspire Crown',
rarity: InventoryRarity.legendary,
weight: 30,
),
];
const useCase = CompleteLevelUseCase(
// Every tenth level a player sees. Ids are zero-based, so that is `% 10 == 9`.
bossRule: myBossRule,
lootTable: myTable,
);
bool myBossRule(int levelId) => levelId >= 0 && levelId % 10 == 9;
bossRule is a SagaBossRule — a plain bool Function(int levelId) — so a
tear-off, a closure or a const top-level function all work. It is consulted
before the first-clear guard, so a reward is still minted only once per level.
An empty table, a negative weight, or weights totalling 0 throw an
ArgumentError at roll time. There is deliberately no silent fall back to
kMvpLootTable: handing out the package's items while you believe your own
table is live is the hardest kind of bug to find.
Disclosing drop rates
When showing loot probabilities in your UI, derive them directly from the loot table rather than hardcoding percentages. This ensures the disclosed rates cannot drift apart from the weights used by the actual roll logic.
final odds = kMvpLootTable.rarityOdds();
// odds[InventoryRarity.common] == 0.75
// odds[InventoryRarity.rare] == 0.175
// odds[InventoryRarity.legendary] == 0.075
Text('Legendary drop rate: ${(odds[InventoryRarity.legendary]! * 100).toStringAsFixed(1)}%');
Pity
A long run of common drops reads to a player as a broken chest. SagaPityRule
guarantees the next boss drop is at least a given rarity once threshold
drops in a row have come up below it (2.1.0).
The counter is save data, so it lives with you — SagaProgress.extra is the
natural place. The rule lives in the package, so every consumer counts the same
way: nextCounter resets on the guaranteed rarity or better and adds one
otherwise.
const pity = SagaPityRule(threshold: 10, guaranteedRarity: InventoryRarity.rare);
const useCase = CompleteLevelUseCase(pityRule: pity);
final counter = (progress.extra['app.pity'] as int?) ?? 0;
final result = useCase.execute(
currentProgress: progress,
levelId: level.id,
globalSeed: seed,
pityCounter: counter,
);
var next = result.nextProgress;
final reward = result.reward;
if (reward != null) {
next = next.copyWith(extra: {
...next.extra,
'app.pity': pity.nextCounter(counter, reward.rarity),
});
}
The counter narrows which entries are drawn from and never touches the seed, so
the roll stays deterministic. A table with no entry of the guaranteed rarity
rolls normally rather than throwing. Without a pityRule, nothing changes.
Character on the path
A character walks the map, Candy-Crush style. The library computes where it is, which way it faces and what it is doing; the host draws it, so any format plugs in — sprite sheet, Lottie, Rive, GIF, plain Flutter.
final character = SagaCharacterController(vsync: this);
SagaInfiniteMapView(
character: SagaCharacter(
controller: character,
builder: (context, state) => YourAvatar(
walking: state.motion == SagaCharacterMotion.walking,
facingLeft: state.facesLeft,
),
),
followCharacter: true, // camera keeps it in view
initialPathPosition: 12, // opens where the player left off
// ...
);
// After a level completes:
await character.moveTo(13); // walks there, node by node
Position is a fractional level index (4.5 is halfway between levels 4 and 5),
measured by distance along the curve so the pace stays even. Movement steps node
by node with a pause on each, resumes from where it got to if interrupted, and
honours reduced-motion. Scrolling and pinch zoom lock while it travels so the
user cannot drag the map out from under the camera.
SagaCharacterGait.hop arcs between nodes instead of walking.
Sprite sheets have no ready package, so one is built in — dependency-free,
horizontal strips by default (SagaSpriteSheet(frameWidth: 60, frameHeight: 60, frameCount: 6) is a 360×60 image), plus vertical and grid, loop/once/
pingPong, and FilterQuality.none for crisp pixel art:
SagaSpriteAnimation(
image: const AssetImage('assets/hero_walk.png'),
sheet: const SagaSpriteSheet(frameWidth: 60, frameHeight: 60, frameCount: 6),
clip: const SagaSpriteClip(count: 6, fps: 12),
)
Walked path, scenery, headers, parallax, gates
SagaInfiniteMapView(
// Bright behind the player, dim ahead — set the theme's upcoming colours and:
pathProgressPosition: highestLevelReached.toDouble(),
pathCurvature: 0.8,
// Trees, houses, clouds — positioned by the library, drawn by you, below
// nodes. The 1.1.0 builder receives a SagaChunkContext carrying the chunk's
// levels, progress and dominant biome. (The 1.0.0 `decorationBuilder`, which
// takes a bare chunk index, still works and is deprecated.)
chunkDecorationBuilder: (context, chunk) => [
SagaMapDecoration.besidePath(
pathPosition: chunk.chunkIndex * 10 + 3,
lateralOffset: 120,
builder: (context) => Icon(Icons.park, color: tintFor(chunk.dominantBiomeId)),
),
// A full-width band aligned to a level rather than a raw coordinate
// (1.1.0). It ignores the path's lateral wander:
SagaMapDecoration.atLevel(
levelId: chunk.chunkIndex * 10 + 5,
height: 34,
builder: (context) => const ColoredBox(color: Color(0x33FFC107)),
),
],
// A banner before a chunk, also handed the SagaChunkContext (1.1.0):
chunkEpisodeHeaderBuilder: (context, chunk) => chunk.chunkIndex.isEven
? EpisodeBanner('World ${chunk.chunkIndex ~/ 2 + 1}')
: null,
// React as the map scrolls: which chunk was entered, which level was walked
// over, and a long-press on a node (1.1.0). All debounced against jitter:
onChunkEnter: (chunk) => trackEpisode(chunk.chunkIndex),
onLevelReached: (level) => trackReached(level.id),
onLevelLongPress: (level) => showLevelSheet(level),
// A layer that lags the scroll for depth:
parallaxBackground: const SkyGradient(),
parallaxFactor: 0.4,
)
Biomes
The generator cycles through SagaMapConfig.biomeIds. It defaults to the
built-in three, so omitting it produces exactly the biome sequence 1.x did.
const realms = <String>[
'sunspire', 'drownlands', 'ashreach', 'verdant', 'hollow',
'saltmarch', 'emberfall', 'stillwood', 'gloamvale', 'highcrown',
];
// Ten realms, five levels each: the cycle closes after 50 levels.
final config = SagaMapConfig.defaultConfig.copyWith(
biomeSpan: 5,
biomeIds: realms,
);
biomeSpan sets how many levels each id covers; the list length sets how many
ids there are. Together they set the cycle:
biomeSpan |
ids | biome changes every | cycle closes at |
|---|---|---|---|
| 50 | 3 (default) | 50 levels | 150 levels |
| 5 | 10 | 5 levels | 50 levels |
| 1 | 4 | every level | 4 levels |
| 50 | 1 | never | never |
Duplicates are kept, not deduplicated — ['forest', 'forest', 'desert'] is how
you weight one biome twice as heavily. An empty list throws an ArgumentError
at generation time.
Theming your own ids
DefaultSagaBiomeThemeResolver only knows the built-in three. Any other id
falls back to the forest theme — and prints one debug-mode warning per unknown
id, so a typo does not silently paint the world one colour. With your own
realms, write your own resolver:
class RealmThemeResolver implements SagaBiomeThemeResolver {
const RealmThemeResolver();
@override
SagaBiomeTheme resolve(String biomeId) => SagaBiomeTheme(
backgroundColor: _background[biomeId] ?? const Color(0xFF2D5A27),
pathFillColor: const Color(0xFF1E3D1A),
pathBorderColor: const Color(0xFF0F2610),
shadowColor: const Color(0x40000000),
// Opaque art keys. The package never loads these; it carries them so a
// builder can. Your keys, your formats, your loader.
assets: {
'nodeSprite': 'assets/$biomeId/node.webp',
'pathStone': 'assets/$biomeId/stone.webp',
},
// A translucent wash painted over the chunk, under your node widgets.
ambientTint: _tint[biomeId],
);
}
Reach the assets from a builder through the same resolver you pass the view:
chunkDecorationBuilder: (context, chunk) {
final theme = resolver.resolve(chunk.dominantBiomeId);
final sprite = theme.assets['pathStone'];
// …
}
Gates
A gate is the "ask 3 friends" or "spend a ticket" barrier. Whether it is open stays your call — that is game economy, not map geometry. What changed in 2.0.0 is that the package now applies the consequence, in all three places a player would notice it.
Pass the gate list to the view and the character stops on the near side; you no
longer wire clampTravelThroughGates yourself:
SagaInfiniteMapView(
gates: [SagaMapGate(pathPosition: 29, isOpen: hasTicket)],
// …
)
That alone only stops the walk. A gate that stops the journey needs all three hooks, derived from one predicate so they cannot disagree:
// The one condition. Ids are zero-based, so this shuts the road after the
// 30th level a player sees.
bool gateOpen(int levelId) => levelId <= 29 || hasTicket;
SagaInfiniteMapView(
// 1. The character halts before the gate.
gates: [SagaMapGate(pathPosition: 29, isOpen: hasTicket)],
// 2. Nodes past it stop responding: disabled, skipped by Tab, announced as
// locked to a screen reader.
interactionPolicy: SagaNodeInteractionPolicy(
isReachable: (level, progress) => gateOpen(level.id),
),
// …
);
// 3. Clearing level 29 no longer opens level 30.
// `const` works because `gateOpen` is a top-level function; a closure or a
// method tear-off would need `final` here.
const useCase = CompleteLevelUseCase(canUnlock: gateOpen);
final result = useCase.execute(
currentProgress: progress,
levelId: 29,
globalSeed: 42,
);
if (result.unlockBlocked) {
showTicketPrompt(); // completed, but the road ahead is still shut
}
A veto never undoes a completion: the level itself is still marked completed and
a boss reward still drops, because the player did clear it. Only the successor
and currentMaxUnlockedLevelId stand still, and unlockBlocked says so.
result.outcome (a CompleteLevelOutcome) separates the three cases outright:
applied, rejectedUnreached (the enforceUnlockOrder refusal — nothing was
applied), and appliedUnlockBlocked (a completion whose successor stayed shut).
unlockBlocked is exactly
outcome == CompleteLevelOutcome.appliedUnlockBlocked.
canUnlock and enforceUnlockOrder guard opposite directions.
enforceUnlockOrder looks backwards and rejects completing a level the player
never reached; canUnlock looks forwards and refuses to open the next one. Use
either, both or neither.
enforceUnlockOrder is a constructor field and ships on. In 1.x it was an
execute parameter defaulting to false, so the shipped configuration accepted
any level id and the guard was something you had to remember at every call site
— one omission among five re-opened the hole. Decide it once:
// A debug build, or a chapter-skip purchase that deliberately jumps ahead.
const jumper = CompleteLevelUseCase(enforceUnlockOrder: false);
// Or override it for the single call that means it, and leave the rest guarded.
useCase.execute(..., enforceUnlockOrder: false);
A negative levelId is refused whatever the guard says, and
SagaProgress.fromJson clamps currentMaxUnlockedLevelId to at most one past
the highest recorded level — the guard rests on that integer, so it is
reconciled with the map it points into rather than trusted.
One caveat on rewards. The first-clear check that stops a boss item being
minted twice is read from the SagaProgress you pass in. Two calls made against
the same snapshot — a widget callback and an async save, say — both see an
uncompleted level and both mint. Thread each result into the next call, or
persist before completing again. rollBossReward is public and applies no guard
at all; prefer execute unless you want an unpersisted preview.
All three hooks default to off — gates: const [], isReachable: null,
canUnlock: null — which is exactly 1.x behaviour.
Storing your own data
The progression model includes an extra field, a Map<String, dynamic> where the host application can store custom data without changing the package models. The package preserves this data and never interprets it.
final progress = SagaProgress(
currentMaxUnlockedLevelId: 0,
levels: {
0: LevelProgress(
levelId: 0,
state: LevelCompletionState.completed,
stars: 2,
extra: const {'app.no_mistake_streak': 10}, // per-level data
),
},
extra: const {'app.opened_chests': 2}, // global data
);
Note: Keys should be namespaced (e.g., using an
app.prefix) to avoid future collisions. Keep the stored data small, as it is serialized on every save.
Spent stars and per-mode scores used to be the classic uses of extra. Since
2.1.0 both are first-class — see the next two sections, and the migration notes
in CHANGELOG.md
if you kept them in extra before.
Star economy
Earned stars are totalStars; spentStars is the other half of the ledger, and
availableStars is what is left.
final next = progress.spendStars(5);
if (next == null) {
showToast('${5 - progress.availableStars} more stars needed');
} else {
await repository.saveProgress(next);
openTheGate();
}
spendStars returns null when the player is short rather than throwing — too
few stars is an ordinary moment in a game, and the nullable return makes the
compiler ask you to handle it. Spending 0 or less throws an ArgumentError.
spentStars never exceeds totalStars: the constructor refuses one that
would, and fromJson clamps a tampered save into range. Alternate-mode scores
are not part of totalStars, so they cannot be spent.
Replay modes
The same level under different rules keeps a score per mode. The default mode is
null and its score stays in stars; every other mode has its own entry in
LevelProgress.starsByMode.
final result = useCase.execute(
currentProgress: progress,
levelId: level.id,
globalSeed: seed,
stars: 3,
modeId: 'hard',
);
final record = result.nextProgress.levels[level.id]!;
record.starsFor(null); // the normal score, untouched
record.starsFor('hard'); // 3
A mode run records its best score and does nothing else to progression:
- It never unlocks. The next level, the unlock pointer and the level's own
state are left alone, and
canUnlockis not asked. Otherwise clearing a level on hard would open the next one a second time. - It never drops a boss reward. That belongs to the first clear, which is the default mode's.
- The order guard still applies. Whether a mode needs the normal clear first is your rule.
Mode ids '', whitespace-only and 'default' throw an ArgumentError: they
would name the default mode, which is null.
Example App
See example/lib/main.dart for a full showcase:
- map rendering with background modes
- infinite map controller usage
- generator and progression use-case demo
- the 2.1.0 economy: a pity rule, rewards written through
executeAndPersist, a star toll that opens the gate, and hard replays scored beside normal ones
Run it:
cd example
flutter pub get
flutter run
Testing
flutter analyze
flutter test
Golden tests carry the golden tag. Their output depends on the host renderer,
so on a platform that does not match the committed references either regenerate
them or skip them:
flutter test --exclude-tags golden # skip
flutter test --update-goldens test/golden # regenerate after an intended change
Changelog
All notable changes are documented in CHANGELOG.md.
License
This package is licensed under the MIT License. See LICENSE.














