How to play a campaign in your own program
These steps take a story from the editor into a program of yours: a game, an app, a page or a tool. Each step has a tab for each binding, and choosing a language once switches every tab on the page. What each call does, and every option it takes, is in the binding’s reference: JavaScript and TypeScript, C and C++, C#, Dart.
-
Export the story as a campaign. In the editor, choose Export project in the project panel, choose Campaign, and export to a folder. The folder, with
campaign.jsonat its root, is what your program opens. If your program will use the engine without Python, tick Needs no Python first. See How to export a project copy or a campaign. -
Add the binding.
Terminal window npm install @ripplefx/player @ripplefx/soundscapeServe the campaign folder beside your page, and serve
.wasmfiles asapplication/wasm. Your bundler copies the engine’s files from the package.include(FetchContent)FetchContent_Declare(ripple_effectGIT_REPOSITORY https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integrationGIT_TAG v1.5.2)FetchContent_MakeAvailable(ripple_effect)target_link_libraries(my_game PRIVATE Ripple::cpp)Configuring downloads and checks the engine for your platform. Link
Ripple::engineinstead for C alone.Terminal window dotnet add package RippleFx.Enginedotnet add package RippleFx.Engine.NativeReference exactly one engine package:
RippleFx.Engine.Native, orRippleFx.Engine.Native.NoPythonfor the engine without Python.Terminal window dart pub add ripplefx_engine # or: flutter pub add ripplefx_engineThe package’s build hook downloads, checks and bundles the engine for the platform you build.
-
Choose the variant, if the campaign needs no Python and you want the smaller engine.
Nothing to do:
Campaign.loadreads the campaign’s manifest and loads the engine with Python only when the campaign needs it. Passpython: falseto refuse one that does.Terminal window cmake -B build -DRIPPLE_ENGINE_PYTHON=OFFReference
RippleFx.Engine.Native.NoPythoninstead ofRippleFx.Engine.Native.In your pubspec, and on the web
--dart-define=RIPPLEFX_PYTHON=false:hooks:user_defines:ripplefx_engine:python: falseThe engine without Python refuses a campaign that needs it when the campaign is opened.
-
Open the campaign, make a session, and wrap it in a player. Choose the seed yourself: the same seed and the same answers play the same story.
import { Campaign, Player } from '@ripplefx/player';const campaign = await Campaign.load('/campaigns/the-city-of-elsenmar/');const player = new Player(campaign.session({ seed: BigInt(Date.now()) }), {onEffect: draw, // step 5onError: (error) => console.error(error),});#include <ripple/ripple.hpp>const ripple::Campaign campaign = ripple::Campaign::open_dir("assets/story");ripple::SessionConfig config;config.board = campaign.manifest().entries.front();config.seed = seed;ripple::PlayerHandlers handlers;handlers.on_effect = [&](const ripple::Effect& effect) { draw(effect); }; // step 5handlers.on_error = [](const ripple::Failure& failure) { log(failure.message); };ripple::Player player(ripple::Session::create(campaign, config), handlers);using RippleFx.Engine;using var campaign = Campaign.OpenDirectory("Assets/Story");var config = new SessionConfig { Board = campaign.Manifest.Entries[0], Seed = seed };using var player = new Player(Session.Create(campaign, config), new PlayerHandlers{OnEffect = Draw, // step 5OnError = error => Log(error.Message),});import 'package:ripplefx_engine/ripplefx_engine.dart';final campaign = await Campaign.openDirectory('assets/story');final player = Player(Session.create(campaign,SessionConfig(board: campaign.manifest.entries.first, seed: seed),),onEffect: draw, // step 5onError: (error, stack) => log('$error'),);A program that packs its assets its own way opens the campaign from its files in memory instead:
Campaign.fromFiles,Campaign::open_files,Campaign.OpenFilesorCampaign.openFiles. -
Draw the turn. Every effect reaches your handler in order. The ones that need drawing are a passage (
show), a form (ask) and an ending (ended,failed,stopped). Draw a choice that is not available greyed, and do not let it be pressed.function draw(effect: Effect) {switch (effect.kind) {case 'show': showPassage(effect.passage); break; // passage.text, passage.choices[i].availablecase 'ask': showForm(effect.prompt); break;case 'ended': showEnding(effect.title); break;case 'failed': showProblem(effect.message); break;}}void draw(const ripple::Effect& effect) {if (const auto* shown = std::get_if<ripple::Show>(&effect)) show_passage(shown->passage);else if (const auto* asked = std::get_if<ripple::Ask>(&effect)) show_form(asked->prompt);else if (const auto* ended = std::get_if<ripple::Ended>(&effect)) show_ending(ended->title);else if (const auto* failed = std::get_if<ripple::Failed>(&effect)) show_problem(failed->message);}void Draw(Effect effect){switch (effect){case Effect.Show s: ShowPassage(s.Passage); break; // Passage.Choices[i].Availablecase Effect.Ask a: ShowForm(a.Prompt); break;case Effect.Ended e: ShowEnding(e.Title); break;case Effect.Failed f: ShowProblem(f.Message); break;}}void draw(Effect effect) {switch (effect) {case Show(:final passage): showPassage(passage); // passage.choices[i].availablecase Ask(:final prompt): showForm(prompt);case Ended(:final title): showEnding(title);case Failed(:final message): showProblem(message);default:}}A passage’s text is Markdown, or HTML when the campaign was exported that way, as the manifest’s text format says. Render it with a library of your choice.
-
Start the story, answer the reader, and give the player your clock. Call the verb for what the reader did. Outside JavaScript, call the player’s
updatefrom your frame or event loop: it answers a wait once its time has come.player.start();choiceButton.onclick = () => player.choose(index);leaveButton.onclick = () => player.choose(); // the reader leaves the passageform.onsubmit = () => player.answer({ name: 'Ada', age: 31n }); // an Int is a bigint// The player waits out a hold by itself.player.start();player.choose(index); // or player.choose(ripple::leave)player.answer(answers); // a ripple::Value::Map, or std::nullopt to leave the form// every frame:player.update();player.Start();player.Choose(index); // or player.Leave()player.Answer(answers); // field name to Value; null leaves the form// every frame:player.Update();player.start();player.choose(index); // or player.leave()player.answer({'name': TextValue('Ada')}); // null leaves the form// every frame:player.update();A verb the story was not asking for is refused and nothing moves. Catch the refusal for a spent choice and for an unknown language, which are the two met in ordinary use.
-
Register the author’s callbacks. Agree the names with the author. A handler must not call a verb, so queue one for after it returns.
player.onCallback('chapterTwoBegan', (fired) => game.unlock('chapter-2'));player.onCallback('quitToTitle', () => queueMicrotask(() => player.stop()));player.on_callback("chapterTwoBegan", [&](const ripple::CallbackEvent&) { game.unlock("chapter-2"); });player.on_callback("quitToTitle", [&](const ripple::CallbackEvent&) { later.push_back([&] { player.stop(); }); });player.OnCallback("chapterTwoBegan", fired => game.Unlock("chapter-2"));player.OnCallback("quitToTitle", fired => later.Enqueue(() => player.Stop()));player.onCallback('chapterTwoBegan', (fired) => game.unlock('chapter-2'));player.onCallback('quitToTitle', (fired) => scheduleMicrotask(player.stop)); // dart:async -
Give the story a speaker. Make a soundscape driver over the campaign and a speaker, pass the driver to the player as its audio, and tick it with the player. In a browser,
@ripplefx/soundscapeis the speaker; elsewhere you implement the binding’s speaker interface over your own audio API, as its reference describes.import { Player, SoundscapeDriver } from '@ripplefx/player';import { WebAudioSpeaker } from '@ripplefx/soundscape';const speaker = new WebAudioSpeaker();const audio = new SoundscapeDriver(campaign, speaker);const player = new Player(campaign.session({ seed }), { onEffect: draw, audio });begin.onclick = async () => { await speaker.unlock(); player.start(); }; // sound needs a click firstMySpeaker speaker; // implements ripple::Speakerauto audio = ripple::SoundscapeDriver::create(campaign, speaker);handlers.audio = audio.get();// every frame: audio->update(); player.update();using var audio = new SoundscapeDriver(campaign, new MySpeaker()); // implements ISpeaker// in PlayerHandlers: Audio = audio,// every frame: audio.Update(); player.Update();final audio = SoundscapeDriver(campaign, MySpeaker()); // implements Speaker// in Player(...): audio: audio,// every frame: audio.update(); player.update(); -
Save, and resume. Checkpoint while the story is on a passage or a form, and keep the save with the seed. To resume, open a session from the save over the same campaign and start it: it shows the saved turn again.
const save = player.session.checkpoint(); // undefined except on a passage or a formconst resumed = Session.resume(campaign, save, { seed });std::optional<std::string> save = player.session().checkpoint();ripple::Session resumed = ripple::Session::resume(campaign, *save, seed, options);string? save = player.Session.Checkpoint();var resumed = Session.Resume(campaign, save, seed, options);final save = player.session.checkpoint();final resumed = Session.resume(campaign, save!, seed: seed);A save from a campaign that has since changed too much is refused when it is resumed. Tell the player the save is from an older build of the story. If the engine reports a trap, the campaign is unusable from then on: dispose of it, open it again, and resume from the last checkpoint.
-
Ship the engine’s notice. Reproduce the engine’s
NOTICEwherever your product lists its third-party software. Each binding names the file:ENGINE-NOTICEin the npm package,share/ripple_effect/ENGINE-NOTICEin a CMake install,ENGINE-NOTICE.txtin the C# engine packages, and the archive’sNOTICEfor Dart.
Related
Section titled “Related”- Embedding the engine: why the API is a session answering with a list of effects.
- How the engine ships: what the binding puts in your build, and the two variants.
- Engine platforms and variants: the platforms each binding ships for.
- Host callbacks: the author’s side of a named callback.