Plots
Plots tracks the stories in your game. You author story arcs and upload them. While the player plays, Plots reads the events you send, starts and advances arcs, and tells Director what the story needs. This page covers the arc library, the running arcs, the Plots verdict in a decision, and what Plots costs.
Plots is off by default for every account, on every plan. Switch it on under Modules at https://architect.eposyne.com/profile?section=director-keys. A switch reaches your API keys within a minute and your sign-in tokens within an hour. Until then every Plots endpoint answers 403: {"detail": "module 'plots' is not enabled for this tenant"}.
How Plots works
- An arc is a story: a list of beats, with forks between them. An arc you author is authored. An arc Director starts from the template catalogue or invents is emergent. The template catalogue holds 1,144 folk-tale story templates and is the same on the hosted and the local Director.
- Your events feed Plots through POST /v2/events. For each player an event names (its
player_id, or each of itsplayers), Plots decides whether the event belongs to a running arc, completes its current beat, or could start a new story. - Your policy says what Plots may do: whether Director may start arcs, where they may come from, how many a player may have, and how much of each arc reaches a decision.
- A decision at a stream position gets the arcs that matter for the deciding character as direction, and the reply carries a verdict that says what Plots did.
Plots never changes your game. It reports and suggests. Your engine stays the final authority.
The arc library
The library holds the arcs you author. When plots.sources in the policy includes authored, Plots offers them as candidates for a new arc. An account has at most 500 arcs, and one arc document is at most 262,144 bytes (256 KB).
| Endpoint | Purpose |
|---|---|
PUT /v2/plots/library | Store one arc. |
GET /v2/plots/library | List the stored arcs. |
DELETE /v2/plots/library/{arc_id} | Remove one arc. |
GET /v2/plots/runtime/arcs | List the arcs running on a timeline. |
Upload an arc
curl -X PUT https://director.eposyne.com/v2/plots/library \
-H "Authorization: Bearer $EPOSYNE_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @arc.json{"arc_id": "arc.missing-sister", "stored": true}One arc goes in each request. The arc_id is the id of the Arc entity. Upload an arc whose id the library already holds, and it replaces the old one. Replacing is always allowed, also when the library is full. Director stores every arc as authored, whatever its source says.
{
"source": "authored",
"entities": [
{"id": "arc.missing-sister", "entity_type": "Arc", "name": "Beatrice's missing sister",
"data": {"description": "Beatrice's sister vanished near the docks."}},
{"id": "beat.hear-plea", "entity_type": "Beat", "name": "Hear Beatrice's plea",
"data": {"description": "Beatrice asks the player to look for her sister.",
"complete_when": "The player agrees to help."}},
{"id": "beat.search-docks", "entity_type": "Beat", "name": "Search the docks",
"data": {"description": "The player searches the docks for clues.",
"complete_when": "The player finds the sister's scarf."}},
{"id": "char.beatrice", "entity_type": "Character", "name": "Beatrice",
"data": {"game_id": "npc-beatrice", "group": "harbour"}}
],
"relationships": [
{"from_id": "beat.hear-plea", "to_id": "arc.missing-sister", "relationship_type": "children", "order": 1},
{"from_id": "beat.search-docks", "to_id": "arc.missing-sister", "relationship_type": "children", "order": 2},
{"from_id": "beat.hear-plea", "to_id": "beat.search-docks", "relationship_type": "beat_beat"},
{"from_id": "beat.hear-plea", "to_id": "char.beatrice", "relationship_type": "cast"}
],
"schema": {
"Arc": [{"name": "description", "type": "text", "description": "What the arc is about"}],
"Beat": [
{"name": "description", "type": "text", "description": "What happens in the beat"},
{"name": "complete_when", "type": "text", "description": "What completes the beat"}
],
"Character": [{"name": "group", "type": "string", "description": "The character's group"}]
},
"roles": []
}The arc format
An arc is a small graph, written in four lists:
- entities: exactly one
Arc, itsBeatandConditionalNodechildren, and the other things they refer to, such asCharacter,Location, orItem, or any type of your own. Each entity has anid, anentity_type, aname, and optionaldata. - relationships: the structure.
children(a child to the Arc, in the order given byorder),beat_beat(an earlier node to a later one, through a ConditionalNode at a fork),cast(a Beat to a Character, a fixed binding),props(a Beat to an Item), and a location edge namedtakes place at,set in, orlocated in(a Beat to a Location). Other kinds are carried unchanged. - schema: for each entity type, the list of fields it has, each with a
name, atype, and adescription. Every key indatathat is not reserved is a project field the schema describes. - roles: open role slots that Plots fills at run time with an eligible character. Write
{{SLOT}}in a beat's name or description to use the character's name. A slot can be used as a token when it has only letters, digits, and underscores.
Some data keys have a fixed meaning:
| Key | On | Meaning |
|---|---|---|
game_id | Any entity | The id your game uses for it in events and decisions. Plots matches a cast character, or a beat's location, by game_id, then by entity id. |
requires_roles | Beat | The open role slots that take part in the beat. A character bound to one counts as in the current beat. |
branches | ConditionalNode | The branches of the fork. Required. |
reference_id | Arc | For an arc built from the template catalogue: the catalogue reference id. |
Fields Director reads by name, every one of them optional:
| Type | Field | Read for |
|---|---|---|
| Arc | description | Choosing and filling in an arc |
| Beat | description | Filling in, {{SLOT}} tokens, relevance |
| Beat | complete_when | When the beat is complete. Without it Plots infers completion. |
| Character | group | Memory group meetings, see Memory |
Plots fills in fields the schema lists that are empty (missing, null, blank, an empty list, or an empty object), with a model call. It never changes a field you wrote.
Forks and conditions
A ConditionalNode is a fork. Its data.branches is a list of branches. Each branch has a label, an order (tried in ascending order), a target_id, and a condition.
- A condition in the guard form,
$variable operator value, is a game rule. The operator is one of==!=>=<=><. Several clauses joined by spaces mean AND, and text after#is ignored. Director evaluates it with no model against the variables of the player's latest decision that named a stream position. A guard that is false is never overridden, unlessplots.may_override_guardsis on. - Any other text in
conditionis judged by a model. A blank or null condition leaves the choice to Plots, which asks a model. - A branch with a null
target_idleads nowhere and is ignored.
The strict rules
Director checks the whole arc before it stores anything. An arc that breaks a rule is refused with 422 and nothing is stored. The detail starts with arc bundle rejected: and lists every reason, separated by semicolons.
{"detail": "arc bundle rejected: the bundle has an unknown member 'colour'; the bundle needs exactly one Arc entity, found 0"}- The arc is a JSON object with only the members
source,entities,relationships,schema, androles. Any other member is refused.sourceisauthoredoremergent. entitiesis a list with at least one entity and exactly oneArc.- An entity has only
id,entity_type,name, anddata.idis 1 to 128 characters,entity_typeandnameare not empty, anddatais an object. Entity ids are unique inside the arc. - A
ConditionalNodehasdata.branches. A branch has alabel. A branchorderis a number, andtarget_idandconditionare a string or null. Other members of a branch are allowed and ignored. - Every non-null
target_idnames aBeatorConditionalNodeof the same arc. - A relationship has only
from_id,to_id,relationship_type,field_name, andorder, and the first three are required. Both ends name an entity of the arc. game_idis a non-empty string,requires_rolesis a list of non-empty strings, andreference_idis a string.- In
schema, each entity type maps to a list of fields, and each field has a non-emptyname.typeanddescriptionare strings. - A role has only
slot,description, andentity_type, andslotis not empty. - The document is at most 262,144 bytes, or the answer is 422 with
"code": "arc_too_large".
Library limits
| Limit | Value | When exceeded |
|---|---|---|
| Arcs in the library | 500 | 409, "code": "library_full", for an arc id the library does not hold. Delete an arc first, or replace one of those it holds. |
| Size of one arc document | 262,144 bytes (256 KB) | 422, "code": "arc_too_large". Director reads no further than the limit. |
Library writes (PUT and DELETE) | 600 per minute | 429, "code": "library_rate_exceeded", with a Retry-After header in seconds. The write is not made. |
{"detail": "...; delete an arc first, or replace one of those it holds", "code": "library_full"}Reading the library counts toward no limit. Library writes have a limit of their own and do not use the request limit of 60 per minute.
List and delete
curl https://director.eposyne.com/v2/plots/library \
-H "Authorization: Bearer $EPOSYNE_API_KEY"{"arcs": [{"arc_id": "arc.missing-sister", "name": "Beatrice's missing sister"}]}The list is in the order the arcs were first stored.
curl -i -X DELETE https://director.eposyne.com/v2/plots/library/arc.missing-sister \
-H "Authorization: Bearer $EPOSYNE_API_KEY"A delete answers 204 with no body. An id the library does not hold answers 404 with a plain detail. A deleted arc is no longer a candidate for a new arc. Arcs already running keep the version they started with and run to the end, so a delete never changes a story the player is in. Upload the arc again to bring it back.
Running arcs
Read the arcs a timeline sees:
curl "https://director.eposyne.com/v2/plots/runtime/arcs?world_instance_id=save-1&timeline_id=main&player_id=player-1" \
-H "Authorization: Bearer $EPOSYNE_API_KEY"{
"world_instance_id": "save-1",
"timeline_id": "main",
"player_id": "player-1",
"arcs": [
{
"instance_id": "inst-7f3a",
"arc_id": "arc.missing-sister",
"title": "Beatrice's missing sister",
"player_id": "player-1",
"source": "authored",
"status": "active",
"current_beat": {"id": "beat.search-docks", "title": "Search the docks"},
"previous_beat": {"id": "beat.hear-plea", "title": "Hear Beatrice's plea"},
"bindings": {
"cast:Beatrice": {"id": "npc-beatrice", "name": "Beatrice", "fixed": true}
},
"started_seq": 3,
"updated_seq": 9
}
]
}| Member | Meaning |
|---|---|
instance_id | The running arc. A verdict names it as arc_instance_id. |
arc_id, title | The Arc entity's id and name. |
player_id | The player the arc runs for. |
source | authored (from your library) or emergent (from the catalogue, or invented). |
status | active or completed. The read lists both. Filter on your side. |
current_beat, previous_beat | The beat now current and the one before, with the title from the arc, or null. A completed arc has no current beat. |
bindings | Role name to the character bound to it. fixed is true for a character you cast in the arc, false for one Plots bound to an open role. |
started_seq, updated_seq | The stream positions at which the arc started and last changed. |
world_instance_idandtimeline_idare required, each 1 to 128 characters and given once. A missing or malformed one answers 400 with a plaindetail.player_idis optional and filters to one player.- The read shows the arcs as Plots has processed the events so far. A child timeline sees the arcs as they were at its branch point, plus its own changes.
- A world or timeline Director has never received events for answers 200 with an empty list.
- The read calls no model, costs nothing, and counts toward no limit.
The Plots verdict in a decision
Name a stream position on POST /v2/decide and the reply carries what Plots did for the player since their previous decision.
curl -X POST "https://director.eposyne.com/v2/decide?world=save-1&timeline=main&after_seq=44" \
-H "Authorization: Bearer $EPOSYNE_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @request.jsonThe stream position
- Give
world,timeline, andafter_seqtogether, once each, or none. One or two of them is a 422. - With a position, Director first makes sure that every event of the timeline up to
after_seqis held and processed by Plots and Memory, including events an earlier delivery deferred. It waits up to 10 seconds. Past that it answers 409 with the acknowledgement and the range it misses (missing) and adetail. No model is called and nothing is charged. Send the missing events, then repeat the decision. - The event at
after_seqtells Director which player and which character the decision is about. When the player is the actor, the character is the event's target. Otherwise the character is the actor. Name the player asplayer_idon that event, or list the players inplayers. The decision reports one player's verdicts, see Whose verdicts. - Without a position the decision behaves as it always did: no verdict, no direction from Plots.
Whose verdicts
A decision reports the verdicts of one player. With a stream position, add the query parameter player_id to name whose:
curl -X POST "https://director.eposyne.com/v2/decide?world=save-1&timeline=main&after_seq=60&player_id=player-2" \
-H "Authorization: Bearer $EPOSYNE_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @request.json- Without
player_id, the player is the one the event atafter_seqnames: itsplayer_id, else the first of itsplayers. An event that names no player gives a decision without Plots verdicts. - The parameter needs a stream position.
player_idwithoutworld,timeline, andafter_seqis a 422, and so is an empty or repeatedplayer_id. - Every player has their own verdicts, and a decision reports and clears only those of the player it names. After a fight of two players, the decision for the second player names
player_id=player-2and carries the second player's verdicts. The first player's wait for the decision that names the first player. - The other players an event lists are never the character who decides.
The verdict
{
"schema": "eposyne.director.decision-response/v2",
"changes": [
{"name": "hale_mood", "value": "pleading", "rationale": "Hale needs the player's silence about the dark lamp."}
],
"plots": {
"steering": "subtle",
"verdicts": [
{
"kind": "started",
"seq": 44,
"arc_instance_id": "inst-3c9e",
"arc_name": "The Lighthouse Debt",
"to_beat": "The dark lamp",
"reason": "The player saw the lighthouse lamp go dark and is asking the keeper why."
}
]
}
}seq is a number. verdicts is always an array.
steeringis theplots.steeringof your policy.- Each entry of
verdictsis one event Plots acted on, in stream order.seqis the sequence number of that event. - With nothing to report the member is still there, with an empty list:
"plots": {"steering": "subtle", "verdicts": []}. - The
plotsmember is absent when the decision names no stream position, when Plots is off for your account, and when Plots failed. The decision is then made without direction from Plots. - A verdict is reported once, by the decision that answers. A decision that ends in an error leaves its verdicts for the next one. A verdict belongs to its timeline, so one from a future the player abandoned is not reported on the child timeline.
kind | Meaning | Members |
|---|---|---|
started | A new arc started. | arc_instance_id, arc_name, to_beat (the first beat), reason (the premise) |
advanced | The current beat completed and the arc moved on, through any fork. | arc_instance_id, arc_name, from_beat, to_beat |
completed | The last beat completed and the arc ended. | arc_instance_id, arc_name, from_beat |
fits | The event belongs to a running arc without completing its beat, or the beat is complete and no branch applies yet. | arc_instance_id, arc_name |
refused | A possible new arc was not started, by your policy's limits or by the gate. The reply steers the player away from it, as steering says. | reason. A refused verdict names no arc. |
The arcs that matter for the deciding character also reach the decision as direction, filtered by plots.relevance. That text is for Director. It is never knowledge the character holds, and it is not part of the response.
What Plots costs
- Storing arcs, listing them, reading running arcs, and sending events cost nothing. They are capped, see the limits.
- Every Plots model call costs usage credits, exactly like a decision: fitting an event to an arc, the gate for a new arc, choosing an arc, picking a branch, and filling in a beat.
- Plots makes one fitting call for every player an event names: one for an event with a
player_id, and one for each player inplayers, up to 16. See Events for several players. Starting an arc adds a gate, a choosing, and a fill-in call. Completing a beat can add a call for each fork that needs a judgement, and a fill-in call for the next beat. - The decision itself, with a stream position, is billed as a decision. A larger
plots.relevanceputs more arc text in its prompt. - Judgements use the model you bind with
shared.judgement_model, else the decision's model inside a decision, else your account's default model. - A judgement your credits cannot pay for is skipped, and the event counts as processed. The events response reports it in
refused_judgements, see Events.
The counts and the settings that change them are on Policy. What credits cover is on Billing and limits.