Skip to content

Types de blocks ​

Les blocks sont les briques d'une scène de dialogue — chaque nœud dans le graphe de l'éditeur est un block. Le engine route le flow de block en block et appelle le handler correspondant à chaque type.

Il existe 6 types : Dialog, Choice, Condition, Router, Action et Note. Dialog, Choice, Condition et Action sont des blocks de contenu avec un handler dédié (onDialog, onChoice, onCondition, onAction) — les quatre sont required et validés à l'appel de start(). Un Router n'a pas de handler : le engine le dispatche tout seul. Les blocks Note sont automatiquement ignorés.

Les handlers se déclinent en deux niveaux : les global handlers (enregistrés sur le engine) couvrent toutes les scènes et suffisent pour la plupart des jeux. Les scene handlers (enregistrés sur un SceneHandle) peuvent compléter ou remplacer les globaux pour une scène spécifique. Voir Handlers pour le détail.

DIALOG ​

Un block dialog représente une réplique — un personnage qui parle, un narrateur, un texte à l'écran. Le engine résout le personnage via le callback onResolveCharacter et l'expose dans context.character. Un handler dialog typique crée une instance de texte dans le jeu (textbox, bulle, sous-titre…), attend que le joueur ou une animation termine, puis appelle next() pour avancer le engine. La fonction de cleanup optionnelle permet de nettoyer les effets de bord quand le engine passe au bloc suivant.

ts
engine.onDialog(({ block, context, next }) => {
  const { text, props } = block;
  const { character, resolveCharacterPort } = context;
  const line = game.getLocalizedText(text);
  const emotion = game.getCharacterEmotion(character);

  character && resolveCharacterPort(character.id);

  game.moveCameraToCharacter(character);
  game.animateCharacter(character, emotion);

  const dialog = game.createDialog(line, character, emotion);

  // next() tells the engine this block is done. WHEN to call it is answered by three
  // natives, and `timeout` comes FIRST because it outranks the other two: all three
  // say when the block is left, and the card is the most specific answer.
  if (props?.timeout) {
    // MILLISECONDS, and the countdown starts when the line has been SAID — `timeout`
    // is how long it STAYS on screen afterwards. Arming it on arrival truncates any
    // line slower to reveal than the timeout allows.
    dialog.onRevealComplete(() => game.wait(props.timeout).then(() => next()));
    // A click may HURRY that reveal; it may not dismiss a line that plays its own time.
    dialog.onInput(() => dialog.skipReveal(), { once: true });
  } else if (game.shouldWaitPlayerInputForDialog(props)) {
    dialog.onInput(() => next(), { once: true });
  } else {
    dialog.onRevealComplete(() => next());
  }

  // cleanup: runs when the engine moves to the next block
  return () => {
    dialog.destroy();
    game.animateCharacter(character, false);
  };
});
csharp
engine.OnDialog(args => {
    var (scene, block, context, next) = args;
    var (text, ch, emotion) = (
        Game.GetLocalizedText(block.Text),
        context.Character,
        Game.GetCharacterEmotion(context.Character)
    );

    if (ch != null) context.ResolveCharacterPort(ch.Id);

    Game.MoveCameraToCharacter(ch);
    Game.AnimateCharacter(ch, emotion);

    var dialog = Game.CreateDialog(text, ch, emotion);
    var natives = block.NativeProperties;

    // next() tells the engine this block is done. Timeout comes FIRST: it outranks
    // WaitInput and it outranks leaving at once. All three say when the block is left,
    // and the one the writer put on the card is the most specific answer.
    if (natives?.Timeout > 0) {
        // MILLISECONDS, and the countdown starts when the line has been SAID — Timeout
        // is how long it STAYS on screen afterwards. Arming it on arrival truncates any
        // line slower to reveal than the timeout allows.
        dialog.OnRevealComplete(() =>
            Game.Wait(natives.Timeout.Value).Then(() => next()));
        // A click may HURRY that reveal; it may not dismiss a line playing its own time.
        dialog.OnInput(() => dialog.SkipReveal(), once: true);
    } else if (Game.ShouldWaitPlayerInputForDialog(natives)) {
        dialog.OnInput(() => next(), once: true);
    } else {
        dialog.OnRevealComplete(() => next());
    }

    // cleanup: runs when the engine moves to the next block
    return () => {
        dialog.Destroy();
        Game.AnimateCharacter(ch, false);
    };
});
cpp
engine.onDialog([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
    auto* ch = ctx->character();
    auto text = game.getLocalizedText(block->text);
    auto emotion = game.getCharacterEmotion(ch);

    if (ch) ctx->resolveCharacterPort(ch->id);

    game.moveCameraToCharacter(ch);
    game.animateCharacter(ch, emotion);

    auto* dialog = game.createDialog(text, ch, emotion);
    auto natives = lsde::getNativeProperties(*block);

    // next() tells the engine this block is done. timeout comes FIRST: it outranks
    // waitInput and it outranks leaving at once. All three say when the block is left,
    // and the one the writer put on the card is the most specific answer.
    if (natives.timeout.value_or(0) > 0) {
        // MILLISECONDS, and the countdown starts when the line has been SAID - timeout
        // is how long it STAYS on screen afterwards. Arming it on arrival truncates any
        // line slower to reveal than the timeout allows.
        const auto stayMs = *natives.timeout;
        dialog->onRevealComplete([&game, next, stayMs]() {
            game.wait(stayMs).then([next]() { next(); });
        });
        // A click may HURRY that reveal; it may not dismiss a line playing its own time.
        dialog->onInput([dialog]() { dialog->skipReveal(); });
    } else if (game.shouldWaitPlayerInputForDialog(block->props)) {
        dialog->onInput([next]() { next(); });
    } else {
        dialog->onRevealComplete([next]() { next(); });
    }

    // cleanup: runs when the engine moves to the next block
    return [dialog, &game, ch]() {
        dialog->destroy();
        game.animateCharacter(ch, false);
    };
});
gdscript
engine.on_dialog(func(args):
    var block = args["block"]
    var ctx = args["context"]
    var next_fn = args["next"]
    var ch = ctx.character
    var text = game.get_localized_text(block.get("text"))
    var emotion = game.get_character_emotion(ch)

    if ch:
        ctx.resolve_character_port(ch.get("id", ""))

    game.move_camera_to_character(ch)
    game.animate_character(ch, emotion)

    var dialog = game.create_dialog(text, ch, emotion)
    var natives = block.get("props", {})
    var stay_ms = natives.get("timeout", 0)

    # next_fn.call() tells the engine this block is done. timeout comes FIRST: it
    # outranks waitInput and it outranks leaving at once. All three say when the block
    # is left, and the one written on the card is the most specific answer.
    if stay_ms > 0:
        # A click may HURRY the reveal; it may not dismiss a line playing its own time.
        dialog.on_input(func(): dialog.skip_reveal(), true)
        # MILLISECONDS, counted from the moment the line has been SAID — stay_ms is how
        # long it REMAINS on screen afterwards, never how long it has to be said in.
        await dialog.reveal_finished
        await game.wait(stay_ms)
        next_fn.call()
    elif game.should_wait_player_input(natives):
        dialog.on_input(func(): next_fn.call(), true)
    else:
        await dialog.reveal_finished
        next_fn.call()

    # cleanup: runs when the engine moves to the next block
    return func():
        dialog.destroy()
        game.animate_character(ch, false)
)

Quand le narrative designer assigne un output dédié par personnage (portPerCharacter), le handler doit appeler resolveCharacterPort() pour indiquer au engine quel chemin suivre lors du next().

CHOICE ​

Un block choice représente un embranchement où le joueur choisit — un menu de réponses, des options de dialogue. Le context.options contient toutes les options disponibles. Quand onResolveCondition() est configuré, chaque option est taggée visible: true | false — le handler filtre et affiche celles qu'il veut. Après l'interaction du joueur, selectChoice(optionId) indique au engine quel chemin suivre — l'id de l'option EST le port par lequel le flow sort (C1, C2…) — puis next() avance le flow.

ts
engine.onChoice(({ block, context, next }) => {
  const natives = LsdeUtils.getNativeProperties(block);
  const { options, selectChoice } = context;

  // EVERY option is handed over, tagged. Filtering is your call — keeping the hidden ones lets
  // you grey them out or show "[locked]" instead of making them vanish.
  const offered = options.filter((o) => o.visible !== false);
  const dialog = game.createChoice(offered);

  // The option id IS its exit port (C1, C2…), so hand it straight back.
  dialog
    .then((selected) => selectChoice(selected))
    .finally(() => next());

  // Optional: if the writer set a timeout on this block. MILLISECONDS in v2, and a
  // CHOICE has no line to reveal, so the countdown starts once the options are readable.
  //
  // It must still SELECT one. An option id IS the exit port, so a choice left without
  // `selectChoice()` resolves to NO link at all and the branch dies silently — the
  // engine has nothing to follow. Picking the first offered option is the usual answer;
  // a `when` on it lets the writer decide which one that is.
  if (natives.timeout) {
    const timeout = game.wait(natives.timeout).then(() => {
      if (offered.length > 0) selectChoice(offered[0].id);
      next();
    });
    dialog.finally(() => timeout.cancel());
  }

  return () => dialog.destroy();
});
csharp
engine.OnChoice(args => {
    var natives = LsdeUtils.GetNativeProperties(args.Block);
    var offered = args.Context.Options
        .Where(o => o.Visible != false).ToList();

    var dialog = Game.CreateChoice(offered);

    // The option id IS its exit port (C1, C2…), so hand it straight back.
    dialog.OnSelect(selected => {
        args.Context.SelectChoice(selected);
        args.Next();
    });

    // Optional: MILLISECONDS in v2, counted from the moment the options are readable.
    // It must still SELECT one: an option id IS the exit port, so a choice left without
    // SelectChoice() resolves to NO link and the branch dies silently.
    if (natives.Timeout is { } timeout)
        Game.Wait(timeout).Then(() => {
            if (offered.Count > 0) args.Context.SelectChoice(offered[0].Id);
            args.Next();
        });

    return () => dialog.Destroy();
});
cpp
engine.onChoice([&game](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
    std::vector<const lsde::RuntimeChoiceItem*> offered;
    for (const auto& option : ctx->options())
        if (option.visible.value_or(true))
            offered.push_back(&option);

    auto* dialog = game.createChoice(offered);

    // The option id IS its exit port (C1, C2…), so hand it straight back.
    dialog->onSelect([ctx, next](const std::string& selected) {
        ctx->selectChoice(selected);
        next();
    });

    // Optional: MILLISECONDS in v2, counted from the moment the options are readable.
    // It must still SELECT one: an option id IS the exit port, so a choice left without
    // selectChoice() resolves to NO link and the branch dies silently.
    auto natives = lsde::getNativeProperties(*block);
    if (natives.timeout) {
        game.wait(*natives.timeout).then([ctx, next, offered]() {
            if (!offered.empty()) ctx->selectChoice(offered.front()->id);
            next();
        });
    }

    return [dialog]() { dialog->destroy(); };
});
gdscript
engine.on_choice(func(args):
    var ctx = args["context"]
    var natives = LsdeUtils.get_native_properties(args["block"])

    var offered = []
    for option in ctx.options:
        if option.get("visible") != false:
            offered.append(option)

    var dialog = game.create_choice(offered)

    # The option id IS its exit port (C1, C2…), so hand it straight back.
    var selected = await dialog.choice_selected
    ctx.select_choice(selected)
    args["next"].call()

    # Optional: natives.get("timeout") is in MILLISECONDS. Use a Timer node, started
    # once the options are readable. It must still SELECT one: an option id IS the exit
    # port, so a choice left without select_choice() resolves to NO link and the branch
    # dies silently.

    return func(): dialog.destroy()
)

visible is a tag, not a filter

With engine.onResolveCondition() installed, each option carries visible: true | false. Without one it is undefined — unknown, not hidden.

That distinction matters: saying "hidden" about a question nobody could answer would take an answer away from the player. Filter on visible !== false, never on visible === true.

Voir Choice Visibility pour le système complet de tagging opt-in.

CONDITION ​

Un block condition est un aiguillage invisible — il consulte l'état du jeu et envoie le flow sur un chemin sans que le joueur le voie.

Le engine ne compare rien lui-même. Il ne lit aucun dictionnaire, ne sait pas ce que contient credits, n'implémente pas greaterOrEqual. Il passe chaque test à onResolveCondition() et assemble les réponses. Chaque test n'est demandé qu'une seule fois, quel que soit le mode.

Quand un resolver est installé, le engine connaît déjà le port de sortie avant d'appeler le handler : onCondition devient facultatif, et sert alors à journaliser ou à forcer une sortie. Le handler reçoit context.cases, chaque cas portant son port et son result déjà évalué. Pour forcer une sortie, context.resolve(port) prend un nom de port — "out", "default" ou un port de cas ("K1").

Il y a deux modes, et deux seulement :

  • portPerCase absent — tous les cas doivent tenir. S'ils tiennent, le flow sort par out ; sinon par default.
  • portPerCase: true — le premier cas qui tient sort par son propre port (K1, K2…). Si aucun ne tient, default.

Un cas sans when est toujours vrai, et rend inatteignables les cas en dessous de lui en mode portPerCase. C'est le dessin du writer, pas une erreur à signaler. Un block sans aucun cas sort par out : rien n'a été demandé, donc rien n'a échoué.

default veut dire « aucun cas n'a tenu » — pas « la sortie choisie n'a pas de fil ». Un port sans fil termine le flow, ce qui est une fin légitime.

Un test dont le dictionnaire est le mot réservé choice interroge une réponse déjà donnée par le joueur : { dict: "choice", entry: "CHOICE-001", value: "C1" }. Le engine y répond lui-même, depuis l'historique de la scène — la question n'atteint jamais le jeu. Voir aussi scene.getChoice(blockId) et scene.evaluateCondition(test).

ts
// Optional once onResolveCondition is installed: the engine has already evaluated every case and
// already knows which port the flow leaves by. This handler is where you log it, or override it.
engine.onCondition(({ block, context, next }) => {
  // Each case carries its own exit port and its pre-evaluated result.
  const matched = context.cases.filter((c) => c.result).map((c) => c.port);
  console.log(`${block.id} matched:`, matched.join(', ') || 'none → default');

  // Override the routing with a PORT NAME: 'out', 'default', or a case port like 'K2'.
  // context.resolve('default');

  next();
});
csharp
engine.OnCondition(args => {
    var matched = args.Context.Cases
        .Where(c => c.Result == true)
        .Select(c => c.Port)
        .ToList();
    Console.WriteLine($"{args.Block.Id} matched: {string.Join(", ", matched)}");

    // args.Context.Resolve(Ports.Default);   // override with a PORT NAME

    args.Next();
    return null;
});
cpp
engine.onCondition([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
    for (const auto& c : ctx->cases()) {
        std::cout << c.port << "=" << c.result.value_or(false) << " ";
    }

    // ctx->resolve(Ports::Default);   // override with a PORT NAME

    next();
    return {};
});
gdscript
engine.on_condition(func(args):
    var ctx = args["context"]
    var matched = []
    for c in ctx.cases:
        if c["result"]:
            matched.append(c["port"])
    print("%s matched: %s" % [args["block"]["id"], ", ".join(matched)])

    # ctx.resolve(LsdeTypes.PORT_DEFAULT)   # override with a PORT NAME

    args["next"].call()
    return Callable()
)

Two modes, and only two

portPerCase absent — every case must hold: the flow leaves by out if they all do, default otherwise. portPerCase: true — the first case that holds takes its own port (K1, K2…), default when none does.

A case with no when is always true, and makes every case below it unreachable.

The dispatcher mode is gone. It fired every matching case at once, in parallel, which meant a writer read three wires leaving a condition as a choice when it was three simultaneous launches. Use isAsync on the target blocks instead — where a reader of the graph can see it.

ROUTER ​

Un block router porte les mêmes cases qu'une condition et les lit dans l'autre sens. Une condition demande lequel tient et sort par un seul port ; un router demande lesquels : il évalue tous les cas, lance le port de chaque cas vrai, puis continue toujours — par then si tous ont tenu, par catch si l'un d'eux n'a pas tenu. Un router sans aucun cas sort par then, comme Promise.all([]) se résout.

Ses routes K* sont parcourues comme n'importe quel port : une cible isAsync ouvre sa propre piste, les autres sont jouées à tour de rôle, et la continuation vient en dernier. catch n'annule rien — les pistes des cas vrais courent déjà.

Il n'y a pas de handler onRouter et start() n'en exige aucun : le engine dispatche un router tout seul. Pour l'observer, passez par handle.onBlock(id).

ts
import type { RouterContext } from '@lsde/dialog-engine';

// A ROUTER has no handler of its own: the engine evaluates EVERY case, launches the port of each
// true one and picks `then` or `catch` before a handler could speak. To WATCH one, target it by id.
handle.onBlock('ROUTER-001', ({ block, context, next }) => {
  const { cases } = context as RouterContext;
  const launched = cases.filter((c) => c.result).map((c) => c.port);
  console.log(`${block.id} launched:`, launched.join(', ') || 'nothing');

  // Nothing to resolve: the exits are a tally, not a choice.
  next();
});
csharp
handle.OnBlock("ROUTER-001", args => {
    var cases = ((IRouterContext)args.Context).Cases;
    var launched = cases.Where(c => c.Result == true).Select(c => c.Port).ToList();
    Console.WriteLine($"{args.Block.Id} launched: {string.Join(", ", launched)}");

    // Nothing to resolve: the exits are a tally, not a choice.
    args.Next();
    return null;
});
cpp
handle->onBlock("ROUTER-001", [](auto*, auto* block, IBaseBlockContext* ctx, auto next) -> CleanupFn {
    auto* router = dynamic_cast<IRouterContext*>(ctx);
    for (const auto& c : router->cases()) {
        if (c.result.value_or(false)) std::cout << block->id << " launched " << c.port << "\n";
    }

    // Nothing to resolve: the exits are a tally, not a choice.
    next();
    return {};
});
gdscript
handle.on_block("ROUTER-001", func(args):
    var ctx = args["context"]  # an LsdeBlockContext.RouterContext
    var launched = []
    for c in ctx.cases:
        if c["result"]:
            launched.append(c["port"])
    print("%s launched: %s" % [args["block"]["id"], ", ".join(launched)])

    # Nothing to resolve: the exits are a tally, not a choice.
    args["next"].call()
    return Callable()
)

A tally, not a choice

A condition asks which one and leaves by a single port. A router asks which ones: every case is evaluated — a false one in the middle hides nothing after it — each true case launches its port, and the flow then continues by then when they all held, by catch when any did not. No case at all is then, the way Promise.all([]) resolves.

The K* routes are walked like any other port: an isAsync target opens its own track, the others are this track's, in turn, and the continuation comes last — which is what keeps then/catch as the main flow when the routes are async. catch cancels nothing: the tracks of the true cases are already running.

Voir Le bloc Router pour le contrat complet et les schémas, et Distribuer les personnages pour inPortPerCharacter — le port d'entrée que les routes d'un router nomment typiquement.

ACTION ​

Un block action déclenche des effets de bord dans le jeu — donner un item, jouer un son, activer un flag. context.calls porte les appels : chacun cite l'fn d'une function déclarée, et ses args arrivent par nom, jamais par position. Le handler les exécute puis appelle context.resolve() pour suivre le port then, ou context.reject() pour suivre le port catch — et si le designer n'a câblé aucun catch, le flow repart par then plutôt que de laisser le joueur en plan.

ts
engine.onAction(({ block, context, next }) => {
  const { actions } = block;
  game
    .executeActionsList(actions)
    .then(() => context.resolve())
    .catch((err) => context.reject(err))
    .finally(() => next());
});
csharp
engine.OnAction(args => {
    var (scene, block, context, next) = args;
    Game.ExecuteActionsList(block.Actions, onComplete: () => {
        context.Resolve();
        next();
    }, onError: err => {
        context.Reject(err);
        next();
    });
    return null;
});
cpp
engine.onAction([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
    auto* ab = dynamic_cast<const ActionBlock*>(block);
    game.executeActionsList(ab->actions, [ctx, next]() {
        ctx->resolve();
        next();
    }, [ctx, next](const auto& err) {
        ctx->reject(err);
        next();
    });
    return {};
});
gdscript
engine.on_action(func(args):
    var block = args["block"]
    var ctx = args["context"]
    var next_fn = args["next"]

    await game.execute_actions_list(block.get("actions", []))
    ctx.resolve()
    next_fn.call()
)

NOTE ​

Un block note est un pense-bête pour le narrative designer — commentaires, rappels, contexte. Il est automatiquement ignoré pendant la traversée. Il est techniquement possible d'intercepter un block note via onBeforeBlock, mais c'est déconseillé — le block action devrait couvrir tous vos besoins en effets de bord.

Propriétés communes ​

Tous les blocks partagent ces champs de base (BlueprintBlockBase) :

ChampTypeDescription
idstringIdentité relative à sa scène — DIALOG-002. Les ids se répètent d'une scène à l'autre.
keystringLa clé i18n complète, telle que les fichiers de localisation la portent
typeBlockTypedialog, choice, condition, router, action ou note
labelstring?Nom lisible, quand le writer en a mis un
parentLabelsstring[]?Hiérarchie des dossiers parents dans l'éditeur
notestring?Le pense-bête du writer
actorsstring[]?Les ids de cards que le block cite, dans l'ordre du fichier
emotionstring?L'id de card de l'émotion — elle appartient au block, pas à chaque acteur
intensitynumber?L'intensité de cette émotion
textTextByLocale?Le texte par locale, si l'export est en mode inline
propsPropertyBag?Un seul sac : les natives et les propriétés du writer, par id nu
optionsOption[]?CHOICE uniquement
casesConditionCase[]?CONDITION et ROUTER — les mêmes données, lues dans les deux sens
callsActionCall[]?ACTION uniquement
nextLink[]?Les fils sortants du block. Il n'y a pas de table de connexions en v2

Le block d'entrée n'est pas marqué sur le block : c'est la scène qui le nomme, dans scene.start. Une scène ne peut donc pas en déclarer deux.

NativeProperties ​

Les dix propriétés que le engine lit, prises dans props. Les ids ne peuvent pas entrer en collision — LSDE refuse une propriété de projet portant un nom natif — donc les distinguer est une simple recherche.

ChampTypeDescription
isAsyncboolean?Ouvre une piste parallèle sur ce block au lieu de continuer la piste courante
waitForBlocksstring[]?Ids de blocks de cette scène. Le block est retenu avant d'être dispatché tant qu'ils ne sont pas tous terminés — aucun handler n'est appelé
delaynumber?MILLISECONDES avant que le block joue. Appliqué par onBeforeBlock, jamais par le engine
timeoutnumber?MILLISECONDES pendant lesquelles le block RESTE après que sa réplique a été dite, puis il repart de lui-même — une auto-avance pour les blocks. Prime sur waitInput. Passé tel quel ; le engine n'impose rien
waitInputboolean?Attendre une entrée joueur. Passé tel quel, jamais interprété — timeout prime sur lui
debugboolean?Flag de debug pour l'éditeur. Passé tel quel
portPerCharacterboolean?Le block sort par un port nommé par l'id de card de l'acteur, au lieu de out
inPortPerCharacterboolean?Le fil nomme l'acteur : le toPort d'un lien est un id de card, et seul cet acteur est offert à onResolveCharacter. Voir Distribuer les personnages
skipIfMissingActorboolean?Passé tel quel — c'est le jeu qui décide
portPerCaseboolean?CONDITION : chaque cas sort par son propre port (K1…) au lieu de partager out

delay et timeout sont en MILLISECONDES en v2

Ils étaient en secondes en v1, et rien ne le signale à l'exécution : un projet migré transforme une pause de 3 secondes en 3 ms.

timeout est une auto-avance pour les blocks — on compte depuis la FIN de la réplique

Le compte à rebours part quand la réplique a été dite, pas quand le block est arrivé. Ce que l'auteur règle, c'est le temps qu'elle RESTE à l'écran une fois son dernier caractère tapé (ou sa dernière syllabe prononcée) ; ensuite le block repart de lui-même.

Compter depuis l'arrivée est l'erreur qui se lit bien et qui joue mal : 2500 ms sur une réplique de 120 caractères la tronquent au milieu.

Il prime sur waitInput, et il prime sur le départ immédiat. Les trois disent QUAND on quitte le block, et celui que l'auteur a écrit sur la carte est la réponse la plus précise. Un clic ne peut donc qu'accélérer la révélation, jamais congédier le block — presser une réplique qui joue son temps n'a pas de sens, l'accélérer en a un, et c'est cette accélération qui arme le compte à rebours.

Et comme quitter un block est ce qui le marque terminé, un timeout est aussi ce qui libère un waitForBlocks qui le nomme.

Seules trois de ces propriétés changent quelque chose au parcours : isAsync, waitForBlocks et inPortPerCharacter. Les sept autres sont transmises intactes au jeu, qui décide de ce qu'il en fait.