Skip to content

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.

  1. 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.json at 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.

  2. Add the binding.

    Terminal window
    npm install @ripplefx/player @ripplefx/soundscape

    Serve the campaign folder beside your page, and serve .wasm files as application/wasm. Your bundler copies the engine’s files from the package.

  3. Choose the variant, if the campaign needs no Python and you want the smaller engine.

    Nothing to do: Campaign.load reads the campaign’s manifest and loads the engine with Python only when the campaign needs it. Pass python: false to refuse one that does.

    The engine without Python refuses a campaign that needs it when the campaign is opened.

  4. 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 5
    onError: (error) => console.error(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.OpenFiles or Campaign.openFiles.

  5. 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].available
    case 'ask': showForm(effect.prompt); break;
    case 'ended': showEnding(effect.title); break;
    case 'failed': showProblem(effect.message); break;
    }
    }

    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.

  6. 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 update from 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 passage
    form.onsubmit = () => player.answer({ name: 'Ada', age: 31n }); // an Int is a bigint
    // The player waits out a hold by itself.

    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.

  7. 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()));
  8. 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/soundscape is 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 first
  9. 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 form
    const resumed = Session.resume(campaign, save, { 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.

  10. Ship the engine’s notice. Reproduce the engine’s NOTICE wherever your product lists its third-party software. Each binding names the file: ENGINE-NOTICE in the npm package, share/ripple_effect/ENGINE-NOTICE in a CMake install, ENGINE-NOTICE.txt in the C# engine packages, and the archive’s NOTICE for Dart.