Artificial World

Client/server protocol#

Everything a client and this server say to each other, and every HTTP endpoint the server answers.

One WebSocket, JSON text frames, one message per frame:

ws://127.0.0.1:5080/ws

The server is authoritative. Everything a client sends is a request: it is validated, the server decides, and the server reports what happened. No client message changes world state by being sent, and all of it is treated as untrusted input — a malformed message is answered with an error and the connection stays open, because being sent nonsense is ordinary rather than exceptional.

Protocol version: 1. hello.ProtocolVersion must be 1 or the server replies with an error and ignores the rest of the message. A frame larger than 256 KB is refused with Message too large.

Shape and conventions#

Both directions use a type discriminator, so a message is self-describing:

{ "type": "chunk_subscribe", "Chunks": [ { "X": 0, "Z": 0 } ] }

Field names on the socket are exactly as declared, which means PascalCase — ProtocolVersion, RequestId, Chunks. Only the two discriminators are lowercase: type on a message, action inside an action_request. The HTTP endpoints below are separate and use camelCase.

ValueOn the wire
EntityId, PlanetId, PlayerId, BlueprintId, AgentIdA UUID string
OwnerIdworld, or player:<uuid> / agent:<uuid>
EntityTypeA string: tree, robot, house — a validated name, not a fixed set
ChunkIdAn object with integer X and Z
WorldPositionAn object with double X, Y, Z, in metres. Y is up
Simulation timeA raw tick count, 10,000,000 ticks per simulated second
EnumsTheir names, never their numbers

Simulation time is never a wall-clock timestamp: the two are different concepts, and the world's clock runs at whatever speed the world is running at.

A component is written the same way on the socket as it is in the world's own storage, so what a client is sent is the component's real shape and not a translation of it.

Every message, field by field with its type and whether it is required, is on the message reference, read off the server's own message definitions; this page says how they are used together. Every enum's members, every validated string's charset and every bound are in the schema reference.

Accounts: identifying yourself#

A client says who it is in hello. There are three cases:

You sendWhat happens
No LoginNameAn anonymous session. It can watch the world and owns nothing
A LoginName nobody has takenThe account is registered, and the reply carries a token
A LoginName and its TokenThe account is resumed, with everything it owns restored into the live world

The token is 32 random bytes, base64. Only its SHA-256 hash is stored, so it is sent exactly once — in the world_bootstrap that answers the hello that registered the account. Keep it; the server cannot send it again. Connecting to an existing account without the right token is refused with

'<name>' belongs to somebody. Connect with its token, or pick another name.

and the connection stays open so a client can try a different name.

Saying hello is rate limited per calling address, in the same two buckets POST /api/register and POST /api/session spend: registering an account over the socket is a registration, and a hello the world refuses is a failed sign-in. Over the limit, the reply is an error message saying so — the connection stays open, and a minute buys back an attempt. Resuming an account with its own token costs nothing, however often a client reconnects. The figures are in the schema reference.

Login names are case-folded, so Youri and youri are one person, and must be 2 to 32 characters starting with a letter, using letters, digits, underscore, hyphen or dot.

An account registered before tokens existed has none stored; it is let in and issued one, which arrives in the same place as a new account's.

An account can also be registered from a web page, with POST /api/register, described under HTTP endpoints below. It is the same account, the same table and the same token: what that route answers is what hello takes.

Several tokens, one account#

An account may hold more than one token, each named for the thing it was minted for — the browser you registered in, a phone you added, an MCP client you configured. Any of them proves the account, so nothing about hello changes: a client that has had one token since the day it registered goes on sending exactly that.

What the several buy is the ability to take one back. Revoking a token stops that token and leaves the others working, which is why revoking a laptop does not turn off a model you handed a configuration to. The routes that list and revoke them are below; the page is /account.

A token that has been revoked is refused on the socket the same way a wrong one is, and with the same sentence.

If you lose the token#

There is no email here, so the token cannot be sent to you again — but an account can carry two other ways back, both optional and neither required of a model:

  • A passphrase. Something a person remembers. With one, POST /api/signin takes a login name and the passphrase and answers with a new token, minted for the device that asked. The old one keeps working.
  • A device code. Eight digits, good for five minutes, usable once. A client that is already signed in asks POST /api/devices/code; the second device types them at POST /api/devices/claim and gets a token of its own.

An account that sets neither behaves exactly as it always did, and is exactly as lost if its only token goes.

Coming back after a reload#

hello may also carry a ResumeKey: an opaque string, 8 to 64 characters of letters, digits, hyphens or underscores, naming the browsing context this connection belongs to. Anything else is refused with the rule as its message, and the connection stays open.

It exists because a browser that navigates away does not reliably close its socket, and the server cannot tell an idle connection from a lost one. Without it a reload arrives as a second person and stands in the players list beside the connection it left behind.

What the server doesWhen
Replaces the older connection: ends it, drops its subscriptions, releases its regions, removes it from the connection countA connection arrives with the same ResumeKey and the same identity — the same account, or both anonymous
NothingThe key differs, the identity differs, or no key was sent

Both halves are required, so a key on its own takes over nothing: eviction of a registered player's connection needs that player's token as well. A client that sends no key — an MCP caller, a script, a test — never replaces anything and is never replaced.

The browser client keeps its key in sessionStorage, which is exactly the right lifetime: it survives a reload and is not shared with other tabs. So a reload takes its own place back, and two windows on one account both stay connected — neither is the other's reload, and nobody can boot anybody.

A connection nobody replaces is still noticed eventually: the server pings every 30 seconds and drops a peer that has not answered within 30, so an abandoned socket stops being counted rather than lingering until TCP gives up.

A token is a bearer secret, not a password. Holding it is being that account. It protects an account from somebody who merely knows its name, and from nothing else: it does not expire, there is no rotation and no revocation, and there is no password, no password hash and no reset flow anywhere in this server. It is sent over whatever the transport gives it — on localhost that is a plain WebSocket, which is why HTTPS is still listed as a limitation of this build.

By default a token is optional: naming a free login name registers it. A server started with World:RequirePlayerToken set to true refuses a hello that names an account without one, and refuses a name that is not registered yet, so the only way in is to register first and connect with what that answered. Anonymous sessions are unaffected — they own nothing, so there is nothing for a token to protect.

Client to server#

hello#

Opens the session.

FieldTypeRequiredMeaning
ProtocolVersionintegeryesThe protocol this client speaks. Must be 1; anything else is answered with an error and the rest of the message is ignored.
TokenstringnoThe secret proving this account is yours. Given when the account was registered; omitted on a first connection, which registers.
ClientNamestringno, default "unknown"Free text for logs, such as "Unity Editor".
LoginNamestringnoStable account name. Null for an anonymous session.
DisplayNamestringnoName other players see. Defaults to the login name.
ResumeKeystringnoWhich browsing context this connection belongs to, so a reload is recognised as the same one coming back rather than a second arrival. Opaque to the server, which never reads it as anything but a label.
{ "type": "hello", "ProtocolVersion": 1, "ClientName": "curl", "LoginName": "youri", "Token": "..." }

The reply is a second world_bootstrap, this time carrying Player.

chunk_subscribe and chunk_unsubscribe#

FieldTypeRequiredMeaning
Chunksarray of ChunkIdyesThe chunks to start watching. Each one newly watched is answered with its snapshot, and from then on with deltas.

This is the client's area of interest. The server answers each newly subscribed chunk with a chunk_snapshot, and afterwards sends entity_delta only for chunks in this set — nothing at all is sent about a chunk nobody is watching. The total is capped by World:MaxSubscribedChunksPerClient, 512 by default.

The ground comes first. No entity_delta for a chunk reaches a client before that chunk's chunk_snapshot does. A snapshot replaces everything the client held for that chunk, so a delta arriving ahead of one would simply be undone — and since deltas are only ever sent for what changed, nothing would say it again until the client dropped the chunk and asked for it afresh.

action_request#

Asks the server to do something.

FieldTypeRequiredMeaning
RequestIdstring (a UUID)yesCorrelates the reply, so a client can await one action among many.
Actionone of the actions, by actionyesWhat to do: one of the actions, named by its action field.

Always answered with an action_result carrying the same RequestId, refusals included. The actions are general primitives rather than one entry per activity; complex behaviour comes from combining them.

The result comes before the consequence. An entity_delta describing what an action changed is never sent to the connection that asked for it ahead of that action's action_result. The server guarantees this rather than usually managing it: the reply is queued for the connection in the same step that changes the world, before anything else can see the change. It matters because the result describes an entity the delta may have just said is gone — a client that applied them in the wrong order would put back what it had removed. The one exception is create_agent, which is several steps by nature and so may have its new agent announced first; nothing there contradicts anything, because the delta and the result both say the agent exists.

Deltas from other people's actions carry no such promise and need none: they are unrelated to the request in flight and may arrive at any point.

actionFieldsWhat it does
move_entityEntity, DestinationStarts a journey for something with a movement component. It takes simulated time, and the destination's height is the server's: the point is snapped to the ground, or to a floor the walker can reach from where it is. Needs Control.
teleport_entityEntity, DestinationPuts an entity somewhere now, with no journey. Judged exactly as MoveEntityAction is, plus a refusal for open water; the height names the storey wanted and the server lands it on the surface there. Needs Control.
remove_naturalEntityFells a tree or clears a rock, recording a world delta.
demolish_entityEntity, WithContentsUnbuilds something that was built, deleting what was stored for it.
inspect_entityEntityAsks for one entity in full: every component, what can be asked of it, and what you may do to it. Needs Inspect, which anybody has.
claim_landChunksClaims unheld ground.
release_landChunksGives claimed ground back, in whole or in part.
program_entityEntity, ScriptPathPuts a script on something, which needs Program permission.
spawn_blueprintBlueprint, PositionBuilds a machine from a stored design. Needs Build permission on the ground it will stand on.
save_blueprintName, EntityType, Visual, Components, Scripts, Includes, RevisesSaves a design, so a person can make a thing rather than only place one somebody else made.
delete_blueprintBlueprintRemoves a design of the sender's from the catalogue.
set_accessTo, Permissions, Granting, Entity, AtSays who else may do what with something of yours.
set_public_accessPermissions, Entity, AtSays what anybody at all may do with something of yours.
request_accessPermissions, Note, Entity, AtAsks somebody to let you in.
create_agentName, Brain, Goals, At, ScriptAsks for an agent to be created and set to work.
dismiss_agentAgentDismisses an agent.
use_entityEntityUses something. The general verb: the server checks permission and delivers a use event to the thing's scripts, which decide what it means. A door and a market stall are the same action with different Lua.
transfer_itemsFrom, To, Item, QuantityMoves items between two inventories. Needs Handle on both ends, because taking is as much an imposition as giving, and the two ends have to be within reach of each other unless both are the asker's own. Everything moves, or nothing does.
exchange_itemsA, B, Give, GiveQuantity, Take, TakeQuantitySwaps goods both ways at once, or moves nothing.
start_scriptEntity, ScriptPathRuns a script already on a computer's filesystem.
stop_scriptEntity, PidStops one running process on a machine. Needs Program.
answer_promptPrompt, Option, ValuesAnswers a question the world put to you.
set_readoutEntity, Key, ReadoutPuts a reading above something, or takes one away.

The two removals are one question, answered on the server by the entity's PersistenceClass: what the generator produced is Aggregatable and is felled — regenerated from the seed, so its removal is a row that is written — and everything else was built and is demolished, which deletes the rows that were stored for it. Sending the wrong one is refused and told which to use, and the affordance advertised on the entity is already the right one. The refusal names the action in the words of whichever door you came in by: this protocol says remove_natural or demolish_entity, and MCP names its own two tools.

create_agent in full: Name is 1 to 64 characters, Goals is 1 to 16 strings of at most 120 characters each, At is optional and is moved to the nearest dry ground that fits, and Script is an optional Lua brain — given one, Brain is ignored and the agent runs the script. Asking for a brain the server does not have is refused with the list of ones it does — and the list is also sent up front, in world_bootstrap.Brains, so a client can offer the names this build actually has rather than ones it wrote down. A Brain is a name, a Script is Lua source; sending one where the other goes is refused. An agent acts as itself, so asking for one grants no authority the asker did not have.

A design that asks for a physics component falls when it is built in mid-air and lands on the ground beneath it; one that does not stays exactly where it was put. Neither costs a tick — the landing moment is solved in closed form and scheduled as a single event.

set_readout hangs a reading over anything in the world, which every client draws above it: a bar, or a line of text. It needs Modify, the same permission recolouring one of the thing's parts needs, because what a thing shows about itself is presentation on a thing you may change. Key names the reading, so sending the same one twice replaces it; a reading left with neither words nor a number of its own comes down and nothing is stored for it.

Readout.Source decides where the number comes from. Health, Energy and Growth read the thing's own, which costs nothing on the wire — those components are in the snapshot already, so a client works the bar out from what it has — and binding to one the thing does not carry is refused rather than producing a bar that could never move. Given is a number of the sender's own, sent once as a value, a rate and a limit, so a bar that empties over the next thirty seconds is one message and nothing after it. A reading shows words or a number and never both, and < and > are refused outright rather than escaped.

Everything that can see the thing sees what it shows: the delta that carries it is one message per chunk for every watcher, so a reading is as public as the colour of a wall.

{
  "type": "action_request",
  "RequestId": "2b7a1f0e-2c4d-4a21-9b3f-7d1e8c5a0f44",
  "Action": {
    "action": "set_readout",
    "Entity": "0198f1a2-...",
    "Key": "health",
    "Readout": { "Kind": "Bar", "Source": "Health", "Label": "health" }
  }
}
{
  "type": "action_request",
  "RequestId": "6f1c1f3c-6a3b-4c62-8f2c-2f9a2b5c4d10",
  "Action": {
    "action": "create_agent",
    "Name": "TownPlanner",
    "Brain": "city",
    "Goals": ["claim land", "build a city"],
    "At": { "X": 400, "Y": 0, "Z": 528 }
  }
}

blueprint_request and blueprint_list_request#

MessageFieldsAnswers with
blueprint_requestBlueprintblueprint — the whole design
blueprint_list_requestnoneblueprint_list — a summary of every design

Designs are fetched on demand rather than pushed with every snapshot: a client needs a given design once however many machines are built from it, and most clients will never see most designs.

Designs are world knowledge, not private property — anyone who can see a machine may read the design it was built from. Building from one is a different question, and needs Build permission on the ground.

player_list_request#

No fields. Answers with player_list: who else is in the world, and where. Nothing private — this is what anybody standing in the world could see by looking. Everybody the world counts as here is in it, whether they hold a socket or act over MCP.

agent_list_request#

No fields. Answers with agent_list: what every agent in the world is doing.

Polled while somebody has the panel open, exactly as the player list is, and for the same reason: watching an agent is something a person does occasionally, and pushing every agent's state at every client would be a message per agent per change for a panel almost nobody has open.

chat_send#

Says something. Answered with a chat to everybody who should hear it, or an error to the sender alone.

FieldTypeRequiredMeaning
ScopeChatScopeno, default PublicWho should hear it. Public when omitted.
TextstringyesWhat to say. Length-capped and stripped of control characters server-side.
TostringnoWho to say it to, for Direct: a player's login name or an agent's name. Ignored otherwise, and resolved to an OwnerId by the server — a name is how somebody addresses another, an id is who they reached.

There is deliberately no sender field and no recipient list. Who said it is taken from the session's account, so no client can choose who it appears to be, and the only address a client may write names exactly one player.

ScopeReaches
PublicEverybody connected with an account
LocalEverybody watching ground the speaker is also watching
DirectOne named player — on every connection they have open — and the sender, so the client has one source of truth rather than a locally invented echo

Refusals, each answered as an error:

WhenWhat it says
An anonymous session speaksChat needs an account
The text is empty after cleaningThere was nothing to say
It is longer than 240 charactersRefused rather than truncated: half a message says something its author did not
Too many messages too quickly30 a minute sustained, 5 back to back, counted per account rather than per connection
A direct message reaches nobodyThere is nobody here by that name. — the same answer whether the account does not exist, is offline or is anonymous, so this cannot be used to ask which accounts exist

Nothing polls: a line is delivered when it is said. It takes no world state and never touches the world gate, so talking cannot delay a player.

Local is answered from areas of interest rather than positions — the server already knows which chunks each client asked for — which is what makes it free.

What is kept, and for how long#

Every line is written down — the ones everybody hears and the ones addressed to one person. Both go to the same table, and the person running the world can read it. That is said plainly rather than left to be discovered, because it is true either way and a world that was quiet about what it keeps would be worse than one that admits it.

It is kept so that a conversation survives a reload and so that a world can be moderated at all: without a record, "what did they say to her" has no answer anybody can give.

It does not grow for ever. Lines older than the retention — thirty days by default, and whatever the operator of the world you are in has set — are deleted, by a prune that runs by itself. Deleting an account deletes what it said and what was said to it, in the same act.

When a client connects it is handed the last fifty lines it is entitled to hear: the public ones, and the direct ones it is one end of. Local is never handed back, because "whoever is nearby" means the ground a connection is watching and a connection that has just said hello is watching none.

prompt_list_request#

Asks what the world is currently waiting on an answer to. Answered with prompt_list. See questions the world asks you.

simulation_control#

FieldTypeRequiredMeaning
SpeedintegeryesThe multiplier: 0, 1, 2, 5, 10 or 50. Anything else is refused, and so is anybody not connected from the machine the world runs on.

This sets the server's simulation speed. Everyone shares one clock, so every connected client is sent a simulation_state when it changes.

Because everyone shares that clock, this is an administrator's message, and an administrator is a connection from the loopback interface — somebody at the machine the world runs on. Anything else is refused with

How fast the world runs is an administrator's decision, and an administrator is
somebody connected from the machine the world runs on.

and the connection stays open. Whether a caller may is also told to them up front, in world_bootstrap.MayControlSimulation, so a client need not draw a control it will be refused — but the check above runs whatever the client believed. The peer address is the socket's own; no forwarded header is read, so behind a reverse proxy every connection looks like the proxy's own, and none of them is an administrator.

metrics_request#

No fields. Answers with server_metrics. The same counters are available over HTTP at /metrics, so a headless run needs no client at all.

Server to client#

world_bootstrap#

Sent immediately on connect, and again after hello.

FieldTypeRequiredMeaning
ProtocolVersionintegeryesWhat this server speaks.
PlanetIdPlanetIdyesWhich world this is.
PlanetNamestringyesWhat the world is called.
SeedintegeryesWhat the terrain is generated from. A client holding the seed can generate matching natural visuals itself.
GeneratorVersionintegeryesWhich generator made the terrain from that seed; a different version is a different planet from the same number.
RegionSizeMetersnumberyesThe unit of region activity, in metres.
ChunkSizeMetersnumberyesThe unit of streaming, claiming and searching, in metres.
SeaLevelMetersnumberyesThe height water stands at, in metres.
SimulationSimulationStateSnapshotyesThe clock, its speed and what it has scheduled, as of this message.
SpawnPositionWorldPositionyesWhere a client should place a newly arrived player. Deterministic for a given seed, and chosen to be dry land rather than whatever the origin happens to be.
PlayerPlayerSnapshotnoWho this connection is. Null for an anonymous session, which can watch the world but owns nothing.
MayControlSimulationbooleanyesWhether this connection may change how fast the world runs.
Brainsarray of stringyesThe names of the brains this server can run, for a create_agent's Brain. Empty means this world runs no agents at all.

Player carries Id, LoginName, DisplayName, WasRegistered, OwnedEntities — already restored into the live world, so the ids refer to things that exist — and Token, present only on the connection that registered the account.

chunk_snapshot#

FieldTypeRequiredMeaning
ChunkChunkIdyesWhich chunk.
DominantBiomeBiomeTypeyesThe biome most of it is.
HeightmapHeightmapSnapshotyesIts terrain, sampled on a grid that shares its edges with the neighbouring chunks.
Entitiesarray of EntitySnapshotyesEverything standing in it, described in full.

The heightmap shares its edges with its neighbours, so terrain has no seams, and it carries a biome per point rather than one per chunk — colouring a whole chunk by its dominant biome makes a patchwork whose edges fall on the chunk grid.

entity_delta#

FieldTypeRequiredMeaning
ChunkChunkIdyesWhich chunk this is about.
Updatedarray of EntitySnapshotyesThe entities that changed, described in full.
Removedarray of EntityIdyesThe entities gone from this chunk: destroyed, or walked into another one.

Sent only to clients subscribed to that chunk, and only for entities that actually changed, so traffic follows change rather than world size. An entity moving between chunks appears as a removal from the one it left and an update in the one it entered.

An entity snapshot#

Everywhere an entity appears, it appears like this:

FieldTypeRequiredMeaning
IdEntityIdyesIts identity.
TypeEntityTypeyesWhat kind of thing it is: a validated name, not a closed set.
OwnerOwnerIdyesWhose it is: the world, a player or an agent.
PersistenceClassPersistenceClassyesWhat the world owes it, which is also what decides how it is removed.
VersionintegeryesThe version this snapshot describes, so a client can discard stale deltas.
Componentsobject of any JSON by stringyesComponents keyed by their registered name, each as raw JSON. The inspector shows these directly, so a new component becomes visible to the client without a protocol change.

A component is sent as its registered name and raw JSON, which is why a new kind of component becomes visible to a client — and to the entity inspector — without a protocol change. The names and their fields are listed in BLUEPRINTS.md.

The transform inside a snapshot is the stored position, which for anything on a journey is the point it set off from: a walk is one scheduled arrival and the transform does not move until it fires. That is deliberate and is not changing. A client draws the walk by combining it with the movement component's Origin, DepartedAt and MaxSpeedMetersPerSecond, exactly as the server does when something asks it where an entity is.

action_result#

FieldTypeRequiredMeaning
RequestIdstring (a UUID)yesThe request this answers.
SuccessbooleanyesWhether it happened.
ErrorstringnoWhy the server refused. Null when it did not.
EntityEntitySnapshotnoThe entity the action concerned, when the client asked to inspect one.
Affordancesarray of EntityAffordancenoWhat can be asked of that entity, whenever one is described.
PermissionsPermissionnoWhat the server currently thinks the asker may do to that entity.
RemovedbooleannoWhether the entity the action concerned is gone because of it.
PromptPromptSnapshotnoA question the world put to the asker while carrying this out.
DesignDesignResultnoWhat became of the design a save_blueprint or delete_blueprint concerned. Absent for every other action.

After a removal#

Success says the action happened. Removed says whether anything is left to ask about, which is a different question — an action can succeed and leave nothing behind. A client that refreshes what it is showing after an action must read this first, or it will enquire after something it has just destroyed and be told, correctly, that there is no such entity.

The server says it rather than the client working it out from the verb, because which entities a removal reaches is the server's judgement: a composite goes with its root, generated nature is felled where built things are unbuilt, and the persistence class that decides between them is not the browser's business.

After a design is saved or deleted#

save_blueprint and delete_blueprint answer with Design: the design as the action left it, and which of created, unchanged, updated, superseded or deleted happened — a save is no longer always a creation.

FieldTypeRequiredMeaning
IdBlueprintIdyes
Namestringyes
Versionintegeryes
Outcomestringyescreated, unchanged, updated, superseded or deleted.
SupersedesBlueprintIdnoFor superseded: the design that was revised and is still there.
BuiltFromPreviousintegernoFor superseded: how many stored things were built from it, and keep it.

Affordances#

Wherever the server describes an entity it also says what can be asked of it, so a client renders verbs rather than working them out. Each affordance is:

FieldTypeRequiredMeaning
IdstringyesA stable identifier for this affordance on this kind of thing, so a client can keep a button in the same place between two descriptions. Distinct from Action, because one action can be offered twice with different settled arguments — sharing and revoking are both set_access.
ActionstringyesThe WorldAction discriminator this asks for.
LabelstringyesThe button, in the words of whoever designed the thing where it says.
HintstringyesA line explaining what pressing it does.
AudienceAffordanceAudienceno, default VisitingWhich kind of client this verb belongs in front of.
Needsarray of InputFieldno, default []What the person has to supply. Empty when the verb needs nothing.
Settledobject of any JSON by stringno, default {}Arguments the server has already settled for this affordance, merged into the action as they are. It is how one action kind can be two buttons: Granting is true on Share and false on Revoke.

Kind is an InputFieldKind, the same field a question uses. Name is the key the client collects a value under and is part of the contract: item, quantity, path, pid, to, permissions, note. Value is filled in where the server knows a good answer — the script path the thing actually runs, the first item it actually holds.

Settled is how one action becomes two buttons: share and revoke are both set_access and differ only in Granting. A client merges it into the message without interpreting it.

Which affordances an entity has is derived from what it is: an interactable component gets Use, an inventory gets Take, anything the world may aggregate gets Fell or Clear and everything else gets Take it down, a computer or a script gets Program, Run and Stop, and the holder gets the three ways to share a thing while everybody else gets Ask. So a kind of thing nobody has invented yet gets the right verbs the day it is first built.

They are offered, not filtered. A verb appears because the thing could take it, never because the asker is allowed — a grant can change between a button being drawn and being pressed, so the server judges on arrival and refuses in its own words. Permissions says what the asker may do at the moment it was computed; it is there for a person to read and must not be used to hide anything.

One argument is the client's to supply and is not in Needs: transfer_items needs a To inventory, and exchange_items an A, both of which are the browser's own body.

Questions the world asks you#

An affordance is the client asking what it may do with a thing. A prompt is the thing asking the person something — usually because of what they just did. They share the same Needs list, so a client that can draw one form draws both.

A prompt reaches a client two ways, and both carry the same PromptSnapshot:

FieldTypeRequiredMeaning
IdPromptIdyesThe question's identity. Sent back with the answer.
FromEntityIdyesThe thing that is asking, so the client can point at it.
FromNamestringyesWhat that thing is called, which is all the client has to show.
QuestionstringyesWhat it wants to know.
Optionsarray of PromptChoiceyesThe answers it will take. At least one.
Needsarray of InputFieldno, default []What to collect beside the choice. Usually empty.
SecondsLeftnumberyesHow long the question has left, in seconds of simulated time.

On the result of the action that caused it. A thing's use handler runs inside use_entity, before the reply is built, so a lift has already asked which floor by the time the button press is answered. The same is true of answer_prompt: when the answer to one question is another, it rides back on that answer's result. This is the whole of how a client that is never pushed anything takes part in a dialogue, and it needs no channel of its own.

As an unsolicited prompt message, for the questions no request provoked — a timer going off, a machine noticing something — and for the other windows the same account has open. A client may therefore be told about the same question twice; it is keyed by Id, so that is one dialogue and not two.

prompt_list_request asks what is currently waiting and is answered with prompt_list. A browser sends it after a reload, so a dialogue survives the page going away. It reads the same board a push is sent from, which is why a client that missed a push and a client that never gets one see the same thing.

Answering#

answer_prompt carries Prompt, Option, and Values keyed by the names in Needs. It is judged when it arrives and never trusted because it was offered : the question may have run out, been answered from another window, or stopped offering that option since the button was drawn, and the thing that asked may have been demolished. Each of those is refused in the server's own words.

What bounds it#

A question is at most 160 characters, offers at most eight answers of at most 48 characters, and collects at most four values. Text is cleaned exactly as a chat line is, and < and > are refused outright rather than escaped — the client also builds no markup at all, and both halves are the rule.

Something may only put a question to somebody who has just used it or just answered it, and who is standing within about eight metres of it. That is a rule about consent rather than about rate: an unasked-for dialogue is spam however slowly it arrives. One thing has one question waiting on one person at a time, and nobody has more than eight waiting at once.

A question stands for two minutes and then is gone. Nothing is scheduled for that — it carries the moment it dies and is found dead when somebody next reads or answers it, so an unanswered question costs no CPU at all.

Handle, and why it is not Use#

transfer_items and exchange_items need Handle, not Use. They are two permissions because a player's body is property standing on ground like any crate: if the claim that widens Use over what stands on your land also widened the right to reach inside it, then on a publicUse claim anybody could take items out of anybody's body, and — worse, because it is how you plant something — put items into one. That is also why there is a reach: the two ends have to be within 8 m of each other, unless both are the asker's own.

A claim still widens Inspect and Use over anything standing on it, which is what makes a street a street. It widens Handle only over things belonging to the claim's own owner: a shopkeeper may open their own stall to the street with one grant, and holding the land says nothing about the satchels of the people walking across it.

So there are two ways to run a shop, and both are the owner saying so out loud: set_public_access on the stall itself, or set_public_access on the claim with Handle in the set.

simulation_state#

Simulation: SimulationTimeTicks, Speed, ScheduledEvents. Sent to every client when the speed changes.

server_metrics#

Metrics, with counters for entities (total and active), loaded chunks, scheduled events, events processed and failed, connected clients, subscribed chunks, database writes, Lua processes, executions and errors, designs, active and dormant regions, aggregated chunks, agents and thoughts, claims and claimed chunks, batteries emptied, things grown to maturity, and the simulation time.

blueprint and blueprint_list#

blueprint carries a whole BlueprintDefinition: its named primitives, their materials and placement, the surface patterns it paints them with, the components a machine built from it gets, the designs it includes, its scripts, and Supersedes — the design this one is a new version of, or none for the first of a line. It is everything the client needs to generate the model — no mesh crosses the wire, and nothing the client does not already know how to draw.

blueprint_list carries summaries: Id, Name, Version, Creator, PartCount, EntityType and Versions — how many versions the line has. One summary per line, its latest version; an older version is still fetched by id with blueprint_request, because the things built from it are drawn from it. A catalogue is browsed far more often than a design is built from. A design changed in place keeps its Id and climbs its Version, so a client that cached it by id compares the version in the list and fetches it again.

Surface materials#

A design may paint its parts with small SVG drawings — brick, tile, tartan, stained glass — which the thirteen named materials and three channels of colour could never have said. The drawings travel with the design, inside Visual, so a client that has the design has everything it needs to draw it and never makes a second request.

Visual.Materials is a list, at most 8 per design:

FieldMeaning
IdA UUID string. Derived, never chosen: a digest of the drawing's own bytes
NameWhat a person calls it — brick, tile. Not the identity; two materials may share a name
SvgThe drawing, as SVG source. At most 16 KiB of UTF-8

A part references one by Surface, and says how tightly it repeats:

FieldDefaultMeaning
SurfacenullA MaterialId from this design's Materials, or null for a flat surface
SurfaceTiling1.0Repeats across a metre, so a brick is the same size on a garden wall and on a tower. 0.01 – 64

The drawing is multiplied over the part's Colour (or its named material's colour), alpha included. A white part therefore shows the drawing's own colours exactly, and a coloured part shows the same pattern tinted.

The identity is settled by the server, so a client need not compute a digest. Send any placeholder UUID as a material's Id, as long as the part's Surface names the same one within that design; on save_blueprint the server mints each material's real name from its bytes and rewrites the parts to match. A client that already has the right id is left alone. This is the same step world_create_design performs, so the two doors cannot drift apart.

What is refused. SVG is a document format with scripting, external references and entity expansion in it, and a material is untrusted input from a player, a Lua script or a language model. It is checked against an allowlist — anything not named is refused, including everything invented after this was written:

  • Elements: svg, g, defs, title, desc, rect, circle, ellipse, line, polyline, polygon, path, linearGradient, radialGradient, stop, pattern. Notably not script, style, image, use, text, foreignObject, filter, mask, clipPath or any animation element.
  • Attributes: identity and framing (id, viewBox, preserveAspectRatio, version, width, height); geometry (x, y, x1, y1, x2, y2, cx, cy, r, rx, ry, fx, fy, fr, d, points, transform); painting (fill, fill-opacity, fill-rule, opacity, stroke, stroke-width, stroke-opacity, stroke-linecap, stroke-linejoin, stroke-miterlimit, stroke-dasharray, stroke-dashoffset, shape-rendering, vector-effect); gradients and patterns (offset, stop-color, stop-opacity, gradientUnits, gradientTransform, spreadMethod, patternUnits, patternContentUnits, patternTransform). Every event handler, every href and xlink:href, and style are refused.
  • Values: no javascript:, data:, http://, https://, //, &#, < or expression(. url(...) may name only a fragment of the same drawing — url(#bricks) — so a gradient or pattern defined above a shape still works and nothing else does.
  • Namespaces: svg's, or none. A prefixed namespace declaration is refused, which is how xlink:href goes.
  • Parsing: no DOCTYPE, no entity declaration or reference, no processing instruction, no text content. DTD processing is prohibited outright, which is the whole of the billion-laughs and external-entity attacks rather than a count of expansions.
  • Bounds: ≤ 16 KiB, ≤ 512 elements, ≤ 12 levels of nesting, ≤ 24 attributes on an element, ≤ 4096 characters in an attribute value, ≤ 8 materials in a design. The root must be an svg with a viewBox.

Refusals are values, not exceptions: save_blueprint comes back as action_result with Success: false and every problem listed at once.

What a design may claim it can do#

save_blueprint carries Components as component name → JSON, and that is the widest door into the catalogue: a person's browser can spell any component the registry knows a name for. So the same reasoning the SVG allowlist rests on applies to it, and in the same shape. Ten components may be declared — computer, energy_consumer, energy_producer, energy_storage, interactable, inventory, movement, physics, script, sensor — each within bounds; the other eight are refused by name with a sentence saying why that one is not an author's to write. ownership is the sharpest of them: a design carrying grants would hand them out again on every copy anybody built, and its author would keep a key to other people's things.

The rule is applied at the one place a design enters the catalogue, so save_blueprint, an agent's create_blueprint and world_create_design all meet it in the same words. Out of range is refused, never clamped, and the refusal names the component, the value and the bound. The figures are in BLUEPRINTS.md.

The server never rasterises a drawing and never re-emits one into a page it serves. The client rasterises it by handing it to an <img> as a data: URL, which is secure static mode — no script, no fetches, no animation — so the allowlist is the first defence and not the only one.

player_list#

Players, each with ClientId, LoginName (null for a spectator), ClientName, Position, SubscribedChunks and IsYou.

Position is where they are standing, when the server knows which entity that is — their character, read live. For somebody with no body it falls back to the middle of the chunks they have subscribed to, and it is null for somebody who has neither, which is the honest answer for a spectator that has asked for no ground at all. A player acting over MCP therefore reads as SubscribedChunks: 0 at a real position: they are in the world without anything being streamed to them.

world_players over MCP answers the same question from the same roster, shaped for a caller with no screen: the same people and the same positions, plus the chunk under each of them and how far away they are. Two doors onto one roster, not two rosters.

Somebody walking is reported where they have got to. A character's stored transform does not move until the journey's single scheduled arrival fires, so this figure is interpolated from the journey at the moment the roster is built — otherwise a walking player would be listed at their starting point for the whole walk and then teleport. It is worked out, not tracked: nothing is written and the walk still costs one event.

agent_list#

Agents, each with:

FieldTypeRequiredMeaning
IdAgentIdyesWhich agent.
NamestringyesWhat it is called.
BrainstringyesWhich brain decides for it, such as city or dummy.
Employerstringno, default ""Who employed it, written as an owner — player:<uuid> — or empty where nobody is on record.
StatestringyesIdle, Waiting, Thinking, Finished or Failed.
ThoughtCountintegeryesHow many times its brain has run.
NextThinkTimeTicksintegernoWhen it is next due to think, in simulation ticks. Null means dormant.
BodyEntityIdyesIts character, or the empty identity while it has none.
HomeEntityIdyesThe machine it thinks on.
PositionWorldPositionnoWhere its character stands, or where its machine is if it has none.
DestinationWorldPositionnoWhere it is walking to, if it is.
Goalsarray of AgentGoalSummaryyesWhat it is for, and how far it has got.
Memoryobject of string by stringyesWhat it has written down about its own work, such as which step it is on.
ReasoningstringnoThe line its brain last gave for what it did.
LastActionsarray of stringyesWhat its last thought asked for, and whether the world agreed.

The character itself is not in here: it is an ordinary entity and arrives in chunk snapshots like everything else, so this only names it. An entity in transit is stored at its origin until it arrives, but Position is not the stored value: it is interpolated along the journey when the list is built, so a walking agent reads where it actually is with its destination beside it. Drawing the walk frame by frame is still the client's job, from the movement component on the entity, and the two agree because they are the same arithmetic over the same three numbers.

Nothing private is in here. An agent's reasoning is what it wrote down about its own work, and anybody standing in the world can already inspect its machine.

chat#

One line of chat, as delivered.

FieldTypeRequiredMeaning
ScopeChatScopeyesWho this line reached.
FromOwnerIdyesWho said it: a player or an agent, kind included.
FromNamestringyesTheir name, as the server holds it — never as a sender asserted it.
TextstringyesWhat was said, after cleaning.
Atstring (a timestamp)yesWhen it was said, by the wall clock.
ToOwnerIdnoThe recipient, for a direct message. Null for everything else.
ToNamestringnoThe recipient's name, for a direct message.

The line is identical for everybody who receives it, so it is serialised once and sent to many. A client that wants to know whether a line is its own compares From with the player id it was given at bootstrap.

At is the wall clock, never simulation time: a conversation happens between people at a real moment, and a world running at 50x would otherwise put "ten minutes ago" on a line said while you were reading it.

The same message carries what was said before you arrived. It follows the bootstrap that answers your hello and precedes anything said afterwards, so a client can append what it receives in the order it receives it.

prompt and prompt_list#

prompt carries one Prompt and arrives unasked; prompt_list carries Prompts and answers a prompt_list_request. Both are described under questions the world asks you.

error#

FieldTypeRequiredMeaning
MessagestringyesWhat was wrong.
DetailstringnoMore, when there is more to say.

Never a reason to close the connection. Malformed input is expected from clients, scripts and agents; a client that sends nonsense stays connected and usable.

A typical session#

client                          server
  |  ── connect ─────────────────>  |
  |  <──────────── world_bootstrap  |   (anonymous)
  |  ── hello(login, token) ─────>  |
  |  <──────────── world_bootstrap  |   (with Player and owned entities)
  |  ── chunk_subscribe ─────────>  |
  |  <──────────── chunk_snapshot   |   per chunk
  |  ── action_request ──────────>  |
  |  <──────────── action_result    |
  |  <──────────── entity_delta     |   to every subscriber of that chunk

HTTP endpoints#

Read-only except for the account routes and the MCP endpoint. There is still no REST API for changing the world: the socket is the only way in, because every change has to be a request the server can refuse. POST /api/register creates an account, which is not world state.

PathAnswers withPurpose
/HTMLThe landing page: what this world is, what it is doing, and the way in
/registerHTMLAn account for a person or for a model
/accountHTMLThe devices that can act as you, your passphrase, and a code for adding one
/docs/{slug}HTMLOne document
/healthJSONLiveness. Does not touch the database, so it answers during startup
/readyJSONReadiness: migrations applied, world built, simulation running. 503 while starting
/worldHTML or JSONPlanet, seed, grid and the simulated clock
/metricsHTML or JSONThe counters above
/eventsHTML or JSONRecent world history, newest first
/regionsHTMLEvery region anybody has used, as a map and a list. Asking it for figures sends you to /api/regions
/api/regionsJSONEvery region with anything in it: who holds the ground, what is standing, who is there
/designsHTMLEvery design in the world, drawn
/designs/{id}HTMLOne design: five views, a review and its parts
/designs/{id}.jsonJSONThe same design for something that cannot look at a picture
/designs/{id}/{view}.pngPNGOne drawing: iso, front, right, left or top
/designs/{id}/visual.jsonJSONThe same design as geometry, with its surface drawings, for something that is going to draw it
/playHTMLThe browser client: this world, playable, with nothing installed
/play/{asset}CSS, JavaScriptWhat that client is made of
/api/registerJSONPOST. Registers an account and answers its token
/api/sessionJSONPOST. Checks a stored login name and token
/api/signinJSONPOST. Signs in with a passphrase and answers with a token for this device
/api/passphraseJSONPOST. Sets or changes the passphrase on an account
/api/devicesJSONPOST. Lists the tokens this account can be proved with
/api/devices/revokeJSONPOST. Takes one of them back
/api/devices/codeJSONPOST. Mints a short-lived code for adding a device
/api/devices/claimJSONPOST. Redeems one, and answers with a token for the new device
/api/onlineJSONWho is connected, and what the world's agents are doing
/api/spawnJSONWhere a newcomer should start, and why
/mcpJSON-RPCPOST. The Model Context Protocol endpoint
/wsWebSocketThe world

Four of those say HTML or JSON: /world, /metrics, /events and /regions answer a page to a browser and figures to anything else. What decides is the Accept header: a caller that asks for text/html gets the page, and a caller that accepts anything, asks for JSON, or sends no preference at all gets the JSON — unchanged, field for field, from what these answered before there were pages. Add ?format=json to see the figures from a browser, or ?format=html to see the page from something that would otherwise get the figures.

/health answers status, service, version, commit, build and utc. version is the build this server is — 2026.09.13.412 from the pipeline that deployed it, or local for one somebody built themselves; commit is the short commit it was made from, or null where the build recorded none; and build is the two of them written out, which is the line in the footer of every page here. Whatever the footer says, this says.

/world answers planet, planetId, seed, generatorVersion, regionSizeMeters, chunkSizeMeters, simulationTimeTicks, simulationTime and speed.

/events?limit= is clamped to 1–500 and defaults to 50. Each entry has kind (WorldCreated, ClaimCreated, EntityCreated, EntityDestroyed, BlueprintCreated, ScriptStarted, ScriptCrashed, AgentCreated, AgentGoalCompleted, AgentDismissed, AccessChanged, BlueprintUpdated, BlueprintDeleted, ClaimReleased), atTicks, at, subject, summary and detail.

The account routes#

POST /api/register takes { "loginName": "...", "displayName": "...", "kind": "human" | "agent", "passphrase": "..." } and answers 201 with playerId, loginName, displayName, kind, token, mcp, spawn and passphraseSet. displayName is optional and defaults to the login name; kind is optional and defaults to human; passphrase is optional, is for a person rather than a model, and is refused if it is shorter than 12 characters, longer than 256, or simply the login name.

{
  "playerId": "5f2c…",
  "loginName": "youri",
  "displayName": "Youri",
  "kind": "agent",
  "token": "base64…",
  "mcp": {
    "url": "http://127.0.0.1:5080/mcp",
    "headers": { "X-World-Player": "youri", "X-World-Token": "base64…" }
  },
  "spawn": { "x": 812.0, "y": 34.5, "z": -96.0, "why": "2 people are connected …" }
}

mcp is null for a human, and for an agent on a server whose MCP endpoint is switched off. For an agent it is the configuration to paste into an MCP client: those two headers are the ones the endpoint already reads, so a model that is given them acts as that account with nothing else configured.

The refusals are the ones a form needs to distinguish.

StatusWhen
400The login name breaks the rule above, the display name is over 64 characters, or kind is neither human nor agent
409That login name is already registered. Case-folded, so shouting it does not get a second account
201Registered. token is in the answer and is never recoverable afterwards

POST /api/session takes { "loginName": "...", "token": "..." } and answers 200 { "ok": true, "playerId": "...", "displayName": "...", "passphraseSet": true } or 401 { "ok": false, "error": "..." }. It is what a browser calls on load to find out whether a stored credential still works. An unknown name, an invalid name and a wrong token are all the same 401, so guessing is told nothing about which half was wrong. Nothing is created: this route never registers anybody.

Coming back, and taking a device away#

Six routes, all POST, all reading their credential out of the body. A token, a passphrase and a device code never appear in a URL: a query string ends up in the access log, in the browser's history and in the referrer of the next request.

POST /api/signin takes { "loginName", "passphrase", "deviceName" } and answers 200 { "ok", "playerId", "loginName", "displayName", "token", "deviceName" }, or 401 with the same sentence a wrong token gets. The token is new and belongs to the device that asked. deviceName is optional; without one the server names the device from the browser it recognises — Safari on iPhone, Chrome on macOS — and calls it A device when it recognises nothing.

POST /api/passphrase takes { "loginName", "token", "currentPassphrase", "passphrase" } and answers 200 { "ok", "loginName" }. Send either a token of that account or its current passphrase; both are proofs of the same account, and somebody on a borrowed laptop has only the second. 401 if neither proves anything, 400 with the rule if the new passphrase breaks it.

POST /api/devices takes { "loginName", "token" } and answers 200 { "ok", "loginName", "passphraseSet", "devices": [...] }. Each device carries id, name, issuedAt, lastUsedAt and currentcurrent being the one the asking client is holding.

POST /api/devices/revoke takes { "loginName", "token", "deviceId" } and answers 200 { "ok", "deviceId", "wasThisDevice" }. 404 if the account has no such device, which is also the answer for somebody else's. 409 if it is the only credential left and the account has no passphrase: that would be a permanent, unrecoverable lock-out one click away, so it is refused and says how to make it safe.

POST /api/devices/code takes { "loginName", "token" } and answers 200 { "ok", "code", "expiresAt", "expiresInSeconds" }. An account has one live code at a time: asking again replaces the previous one.

POST /api/devices/claim takes { "code", "deviceName" } — no account name, the digits are the whole credential — and answers the same body POST /api/signin does. 401 for a code that was never minted, has expired, has already been used or has been replaced, all with one sentence.

Every one of these spends from the failed-sign-in bucket when the answer was wrong, and nothing when it was right.

GET /api/online answers players and agents.

FieldInMeaning
displayNameplayersTheir name, or the client's own name for an anonymous session
loginNameplayersTheir account, or null when anonymous
anonymousplayersWhether this session is watching without an account
positionplayers{x, y, z}, their body where they have one, otherwise the middle of what they are watching, and null if neither
regionplayersThe region that falls in, as R[x,z], or null with no position
connectedSinceplayersUTC, real time. When the socket was accepted
name, owner, state, positionagentsOne of the world's agents, which is here whether anybody is connected or not

GET /api/spawn answers { "x", "y", "z", "why" }: dry ground for somebody who has never played, and a sentence saying why it is there. It is ranked — people connected now, then the nearest claimed land, then the planet's default spawn — and deterministic, so a page can show a newcomer where they will appear. It invents nothing: this world has no settlement names, so the landmark in why is a coordinate.

/designs/{id}/{view}.png?size=thumb gives the small version; anything else gives the large one. Only those two sizes exist, so a caller cannot fill the cache with a thousand widths of the same picture. Drawings carry an ETag and are immutable — a new version of a design is a new design with a new identity, so the picture of this one can never change.

The design JSON is described in BLUEPRINTS.md; it is the shape an agent reads its own work back from.

/designs/{id}/visual.json is the other half: the design as geometry rather than as a description. It carries the ProceduralAssetDefinition the socket sends, PascalCase and unchanged, with the design's surface drawings in it and the included designs already placed in the outer design's frame. It exists because the catalogue's patterned picture is drawn in the browser — the server rasterises no SVG — and the thing that draws it is the same client module that draws the world, so it takes the same shape the socket gives that module rather than a second one. It is cached as immutably as a drawing is, for the same reason.