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 vertical path Walked path lit up Curved horizontal path
  • Curved pathpathCurvature bends 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 with distinct rings Long-press dialog showing default and hard scores
  • 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 accountingLevelProgress.starsFor(modeId) inspects scores per mode, keeping the progression history intact and cleanly separated.
The star economy Economy toggles in the sheet
Stars spent on opening a gate toll Economy controls with pity rule
  • 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 injectionSagaPityRule guarantees rare-or-better loot drops after bad streaks, while executeAndPersist ties roll results directly to an injected InventoryRepository.

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
Five host realms, each with its own colour and ambient wash The biome changing every ten levels One biome across the whole stretch
  • 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.
  • biomeSpan and 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
Square boss nodes every fifth level Completed levels lit with stars The 2.0.0 switches
  • Injectable rewards — the square nodes are bosses. The demo injects bossRule: (id) => id % 5 == 4 and its own lootTable, 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
atLevel bands, biome-tinted scenery and an extra-driven bookmark Episode header reporting stars earned in the chunk
  • 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 from SagaChunkContext.dominantBiomeId, which the decoration builder now receives.
  • Host-owned data on a node — the dark stripe on the second node is drawn from a bookmarked flag the app stored in LevelProgress.extra. The package persists it and never interprets it.
  • Stars without a loop — the banner's amber pips are SagaProgressStars.starsInRange over that chunk's levels, read straight off the SagaChunkContext handed to episodeHeaderBuilder.

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 backgroundConfig is SagaMapBackgroundConfig.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:

  1. Compilation: A public signature changes or is removed.
  2. Saved data format: Existing serialized progress can no longer be read.
  3. 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 equal stepHeight * levelsPerChunk; use config.spanForLevelCount(levelsPerChunk) on your SagaMapConfig rather than hardcoding it. (It is an instance method, not a static one — it reads the config's own stepHeight.)
  • 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 rollBossReward as a preview of what a boss would give. The first-clear guard in CompleteLevelUseCase.execute is 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 canUnlock is 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.

Libraries

saga_map