` has an attribute, style, and the value is in CSS. The two `
` elements likewise have style attributes. The text, "Cool Game", will be in the combined style of all those elements.
+
+Open your browser's Developer Tools again whilst playing (in Chrome, More Tools - Developer Tools; in Firefox, More Tools - Web Developer Tools; this works the same way in the desktop app). These vary a bit between browsers, but you should find you can highlight elements of HTML, and you will see what CSS applies, and where it comes from.
+
+
+### A note about colours
+
+In CSS you can use two formats for colours. The name or the hex value. About a hundred colours are named (note they have no spaces, but are case insensitive). The hex value must start with a #. It must be followed by three pairs of characters, one pair each for red, green and blue, where each pair is the hexadecimal value, from 00 to FF. Alternatively, you can use three characters one each for red, green and blue; in this case each character is doubled to make the full version, so "#f08" is the same colour as "#ff0088".
+
+If the hex value makes no sense, stick to the names!
+
+[https://en.wikipedia.org/wiki/Web_colors](https://en.wikipedia.org/wiki/Web_colors)
+
+
+## jQuery
+
+Static web pages use CSS like that, but if you want things to change, you need JavaScript. JavaScript is a fully-fledged programming language (and is _not_ the same as Java), and has become the standard for web browsers. We will try to avoid writing JavaScript code as far as possible - which is where jQuery comes in.
+
+JQuery is a library for JavaScript that is built in to Quest. Among other things, it offers relatively easy ways to access parts of the HTML page.
+
+This is how JQuery/JavaScript could be used to set the styles in the CSS example.
+
+```js
+$('#gameBorder').css('background-color', '#800080');
+$('#gameBorder').css('color', 'pink');
+```
+
+Notice that all the same information is there, just arranged differently, according to the syntax of JavaScript/JQuery. The $ at the start signifies this is JQuery (it is a shorthand for a function called `JQuery`), and $('#gameBorder') will grab the thing with the ID "gameBorder" (again, the # indicates this is an ID). Once we have that we call a method (or function) called "css", and send it two parameters, the thing we want to change and the new value.
+
+
+
+## Quest
+
+Quest sets up the User Interface in the `InitInterface` function, which is defined in Core.aslx. Almost the last thing it does is call a script, "inituserinterface", on the game object (if it exists), after which game.start will run (unless the player is resuming with a saved game). The best way to modify the user interface, then, is using the "inituserinterface" script.
+
+The big advantage of doing it this way is that this will be called whenever Quest thinks the interface needs updating, which is not just at the start of the game (for example, when the screen is cleared). You also get the bonus of having all your interface stuff in the same place, which keeps it neat.
+
+To edit the script, go to the _Features_ tab of the game object, and check that "Show advanced scripts for the game object" is ticked. Then go to the _Advanced Scripts_ tab. The "inituserinterface" script is at the top.
+
+Note, however, that you should not print anything from the "inituserinterface" script (you might feel tempted to output CSS or some JavaScripts using msg or OutputTextRawNoBr). If you do, when a saved game is reloaded, all the new text will get inserted into the top of the existing text. Instead, use the `JS.addScript` function, which will add your JavaScript or CSS or whatever outside the normal flow of text.
+
+Because it is easier to show, all the tricks here will be in code. Click the "Code view" button, and a text box will appear. Just copy-and-paste code into here. You can paste in as many code blocks as you like, and it should work fine (note that that is not necessarily true of all code).
+
+
+## Using all that in Quest
+
+So now we know where to put the code in Quest, and we know the JavaScript to do it. We just need a way to pass the JavaScript from the game to the interface. This is done using the `JS` object, for example using the `eval` function:
+
+```quest
+JS.eval("$('#gameBorder').css('background-color', '#800080');")
+JS.eval("$('#gameBorder').css('color', 'pink');")
+```
+
+The JS object is a way to access any JavaScript function, even those you add yourself. The `eval` function is useful because it will run any JavaScript code. So the first line above is saying, "JavaScript, please run this string as though it is JavaScript code", and the string to run is `$('#gameBorder').css('background-color', '#800080');`, i.e., the code we had before.
+
+Note that this is not a way to get information from the interface; this is a one-way street. Data is going from Quest to JavaScript only (there is a way to go the other way; that is how the player's inputs get to Quest, but that is beyond the scope of this article).
+
+
+## Shortcuts
+
+You can use the `setCss` function to do this sort of thing. Like `eval`, this belongs to the JS object. It takes two parameters, the element and the style. The style should be in the standard CSS format, with a colon between the name and the value, and a semi-colon between each setting. The example above would therefore look like this:
+
+```quest
+JS.setCss("#gameBorder", "background-color:#800080;color:pink;")
+```
+
+Using this function, you can now change any element in the game (well nearly any, a few are a bit odd). You just need to know the id of the element and the right CSS to use.
+
+
+To quickly format the game panes you can use `setPanes`. This takes two, four or five parameters, all of which are colours.
+
+```quest
+JS.setPanes ("black", "white")
+JS.setPanes ("orange", "black", "black", "orange")
+JS.setPanes ("midnightblue", "skyblue", "white", "midnightblue", "blue")
+```
+
+
+## Elements
+
+Bits of an HTML page are called elements, and "gameBorder" is just one of them. All HTML documents have an "html" element that contains everything else, and inside that it has a "head" and a "body" elements. Quest then has a few dozen elements that make up the interface inside the "body" element.
+
+You can look at those elements as you play a game, using your browser's Developer Tools (right-click on the page and choose "Inspect", or similar - this works the same way in the desktop app). On the left you will see a hierarchy of elements (you will need to expand them to see them all), and on the right a list of properties. Click on an element, and it will be highlighted in your game so you can see what it refers to.
+
+Most of the interesting elements are of the type "div", and each is identified by an "id". The gameBorder one looks like this:
+
+
+
+
+## CSS properties and values
+
+There are a large number of CSS properties, to get a full list, use the internet. I will mention just some of the interesting ones. You do need to be careful that you supply the right type of value, but we will look at that too. Also, be aware that CSS uses America spelling for "center" and "color" (but you can use both "grey" and "gray").
+
+
+### color
+
+The colour of text is determined by the "color" property. You can set colours in a number of ways, the easiest is to use a name. This [Wiki page](http://en.wikipedia.org/wiki/Web_colors) has a full list of available names (note that there are no spaces in the name; for once, capitalisation does not matter):
+
+```quest
+JS.setCss("#gameBorder", "color:blueviolet;")
+```
+
+You can also set colours by using the RGB code. These both set the colour to red.
+
+```quest
+JS.setCss("#gameBorder", "color:rgb(255, 0, 0);")
+JS.setCss("#gameBorder", "color:#ff0000;")
+```
+
+Each splits colours in to three components: red, green, blue. In the first, each component is a number from 0 to 255. In the second, it is a hexadecimal number from 00 to ff. If you do not know what hexadecimal is, use the other format.
+
+
+### background-color
+
+This works just the same as color, but changes the background for this element.
+```quest
+JS.setCss("#gameBorder", "background-color:blueviolet;")
+```
+
+### background-image
+
+You can set the background image for each element. The CSS requires that the image name go inside a url function call, and to ensure it works on-line, Quest requires the name go inside a GetFileURL, so it gets complicated:
+
+```quest
+JS.setCss("#gameBorder", "background-image:url(" + GetFileURL("gravestone.png") + ");")
+```
+
+The status bar at the top uses an image. If you want to stop that image displaying, do this:
+
+```quest
+JS.setCss("#qv-status", "background-image:none;")
+```
+
+### width
+
+This will change the width of the element. You have the potential to mess up big time here, so change one element at a time and see what happens. Elements do impact on each other, so you may not see any difference. When experimenting, change the width of Quest itself (or the browser) to see what effect that has too.
+
+Note that the value must include "px", which says the units are pixels.
+
+```quest
+JS.setCss("#gameBorder", "width:950px;")
+```
+
+### opacity
+
+The opacity property defines how much this element covers the one below (the reverse of transparency). It can range from 0.0 (this element is not visible) to 1.0 (this element is completely opaque).
+
+```quest
+JS.setCss("#gameBorder", "opacity:0.5;")
+```
+
+### border
+
+The border property lets you set borders. You can set various aspects in one go, so in this example a dashed line, 5 px wide and blue, will be added.
+
+```quest
+JS.setCss("#gameBorder", "border:dashed 5px blue;")
+```
+
+The status bar at the top has a blue border. If you want to remove it, do this (also set the width to 950px to keep it aligned):
+
+```quest
+JS.setCss("#qv-status", "border:none;")
+```
+
+
+## Awkward attributes
+
+### The command bar
+
+Some attributes are difficult to change, and the usual technique just does not work. A good example is the border of the command bar. The element's ID is `txtCommand`, and it has `border` and `outline` properties, but if you set them to "none", it does not work. Why not? No idea.
+
+However, there is a way around. If you go into full code view (press F9), you can add an attribute to the XML of the game object that includes CSS.
+
+```xml
+
+ #txtCommand {
+ outline:none;
+ border: none;
+ }
+
+]]>
+```
+
+Be careful how you do that; I would suggest pasting it below this line:
+
+```xml
+2016
+```
+
+You can output that in game.start, and it should now make the required change.
+
+```quest
+JS.addText (game.css)
+```
+
+You can turn off the border on the _Interface_ tab of the game object, but there may well be other elements that need to be handled like this, for example....
+
+### Inventory items
+
+This technique will also allow you to change how inventory items are displayed. They do not have IDs, they uses classes instead, `ui-selectee` (for all objects in the list), `ui-selected` (for the selected one) and `ui-selecting` (for the selected one whilst clicked). The difference is that only one element on the page can have a specific ID but any number can have a class. You specify a class by using a `.`, rather than a `#`.
+
+This example will alter the background colour when an item is selected.
+
+```xml
+
+ .ui-selected {
+ background-color: darkblue;
+ color: white;
+ }
+ .ui-selecting {
+ background-color: blue;
+ color: white;
+ }
+
+]]>
+```
+
+
+## Testing
+
+When you are messing with the interface, it is easy to get things wrong - or try to do something that is not possible. You should test your game to make sure it works as you expect and looks as you expect. In particular, you should check that it still works and looks the same after the player has reloaded a save game, as this is when problems most often come to light, and it is easy to forget to check this.
+
+
+## Various tricks
+
+A collection of tricks using the techniques already discussed.
+
+
+### The "Continue" link
+
+You can change the colour of hyperlinks on the Display tab of the game object, but it does not affect the "Continue" message when the game waits for the player to press a button, because that is actually part of the command line, not the output text. However, you can change it like this:
+
+```quest
+JS.setCss ("#txtCommandDiv a", "color:pink;")
+```
+
+Note that the first parameter is identifying an `a` element (an HTML anchor, which is used for hyperlinks) inside of the `#txtCommandDiv`.
+
+
+### The "Saved" text
+
+The message that says the game is saved is also odd, in that is has no ID so cannot be changed through JQuery/CSS.
+
+The solution is to change the style of a container element, however, even that is problematic as they may not exist yet when 'InitUserInterface' fires, so I suggest setting style properties on the body element (this is not an id, so has no # before it.
+
+```quest
+JS.setCss ("body", "color:orange;font-family:georgia,serif;")
+```
+
+### Changing the ending
+
+The `finish` script command terminates the game, and replaces the panes on the right with a message. You can change the default font using JQuery again, to make it consistent with your game:
+
+```quest
+JS.setCss ("#gamePanesFinished", "font-family:Berkshire Swash;")
+```
+
+You can also change what gets displayed, using the JQuery html method. In this example, I am modifying the text (using the `html` method of JQuery), and adding an image (and we have to use GetFileURL to do that). I am also building the string first, and then calling JS.eval.
+
+This is the HTML I want to add:
+
+```xml
+Game Over
+This game has finished and you are dead!
+
+```
+
+This is how we do it:
+
+```quest
+s = "$('#gamePanesFinished').html('Game Over "
+s = s + "This game has finished and you are dead!
"
+s = s + " "
+s = s + "');"
+JS.eval (s)
+finish
+```
+
+### Changing the arrows
+
+The arrows in the compass rose and the triangles to the left of the panes are icons that are defined in JQuery. To change their color, you need to replace the image file (they are all in one file).
+
+You can get an image file with the right colours, from here:
+[http://download.jqueryui.com/themeroller/images/ui-icons_800080_256x240.png](http://download.jqueryui.com/themeroller/images/ui-icons_800080_256x240.png)
+
+You can change the number 800080 to the RGB colour what you want (I guess the file server creates the images on the fly, and will accept any value, but that may not be the case), this is a dark purple I was trying. Save the file in your game folder.
+
+Then you just need to do this to get the new icons in your game (again, modifying the number for your downloaded file):
+
+```quest
+JS.setCss (".ui-icon", "background-image:url(" + GetFileURL("ui-icons_800080_256x240.png") + ");")
+```
+
+Once you have the file, you could edit it to change the shape of the arrows too, or make them multicoloured (upload the image via the Assets manager in the editor toolbar).
+
+
+### Disable the panes
+
+This will leave the panes there, but clicking on them will do nothing.
+
+```quest
+JS.setCss ("#gamePanesRunning", "pointer-events:none;")
+```
+
+To enable them again:
+
+```quest
+JS.setCss ("#gamePanesRunning", "pointer-events:inherit;")
+```
+
+
+### Moving the screen to the bottom
+
+Sometimes when you display something on the screen, Quest fails to scroll down for. You can force that with this:
+
+```quest
+JS.scrollToEnd()
+```
+
+### Sticking the command bar to the bottom of the screen.
+
+You can use this to keep the box where the player types pinned to the bottom of the screen. The first line sets its position to "fixed", this means it will stay in one place relative to the screen. The second line specifies where it will be fixed. The third line stops the game printing messages behind the input box.
+
+```quest
+JS.setCss("#txtCommandDiv", "position:fixed;bottom:10px")
+JS.setCss("#gameContent", "margin-bottom:70px;")
+```
diff --git a/site/src/content/docs/howto/ux/display_verbs.md b/site/src/content/docs/howto/ux/display_verbs.md
new file mode 100644
index 000000000..c2a9ed771
--- /dev/null
+++ b/site/src/content/docs/howto/ux/display_verbs.md
@@ -0,0 +1,63 @@
+---
+title: Using display verbs
+sidebar:
+ order: 3
+---
+
+When you play a text adventure using Quest, there will usually be a set of panels on the right. As well as the compass rose, there will be lists of objects in the current location and in your inventory. Or there may be hyperlinks for each object in the text. If you click on an object, buttons will appear giving short-cuts to commands with the object. These are display verbs and inventory verbs.
+
+
+## Adding and removing verbs
+
+There is a simple way to change the list of verbs for an object. On the _Object_ tab, at the bottom, is a section called "Display verbs". You can add and remove as appropriate. For example, if an object cannot be picked up, remove the "Take" entry from the display verbs.
+
+By the way, you can add anything you like here, even if it makes no sense to Quest. It is, therefore, a good idea when playing through your game to click on each verb for every object to see how Quest responds (if it can be picked up, do it for both in the inventory and in the room).
+
+Changing the object type on the _Setup_ tab will also change the verbs. Changing it to a male character, for example, will change the display verbs to "Look at" and "Speak to", rather than "Look at" and "Take".
+
+When you add a verb to an object via the _Verbs_ tab, Quest will automatically add that verb to both the display verbs and the inventory verb. You can stop it doing that by unticking the "Automatically generate object display verbs list" box on the _Room Descriptions_ tab (I do not know why either) of the game object. I prefer to do this, as it gives you full control over the verbs that will be shown. You can also stop verbs being generated automatically for a specific item by ticking the box on the _Object_ tab for that object.
+
+
+## Adding and removing verbs on the fly
+
+So far so good, but what if you want verbs to change during the course of the game? Say there is a hat that can be worn, so you want a "Wear" verb, but when put on you want a "Remove" verb instead (actually this happens automatically for wearable objects).
+
+The verbs are held in two string list attributes, `displayverbs` and `inventoryverbs`.
+
+There are issues to be aware of. Firstly, automatically generated verbs are not in that list (another good reason to turn the feature off).
+
+Secondly, your object will only have those attributes if you have modified the lists on the Object tab. You can go to the Attributes tab, look for "displayverbs" in the list at the bottom. If it is in grey, your object is getting its list from its type, and if you try to add or remove something in the list during play, you will get this helpful message:
+
+```
+Error running script: Cannot modify the contents of this list as it is defined by an inherited type. Clone it before attempting to modify.
+```
+
+
+
+## Coding...
+
+So how do you actually add and remove verbs? We have an object called "hat", and we want to add a "Wear" verb to the inventory list. One approach is to create a new list each time. This is easily done with the `Split` function. This takes two strings, the first being a list of verbs, separated by semi-colons, the second just a semi-colon, telling Quest what to break the first list on.
+
+```quest
+hat.inventoryverbs = Split("look at;Drop;Wear", ";")
+```
+
+Then when the hat is worn:
+
+```quest
+hat.inventoryverbs = Split("look at;Remove", ";")
+```
+
+That will not work if there are potentially other verbs that may or may not be there, and you are better off assigning the attribute each time using `ListCombine`:
+
+```quest
+object.displayverbs = ListCombine(object.displayverbs, Split("Attack"))
+```
+
+When you want to remove the verb, it should be safe to use `list remove` as you know the object has the list, given you set it yourself earlier. To be extra safe, check the list has the verb first.
+
+```quest
+if (ListContains(object.displayverbs, "Attack")) {
+ list remove (object.displayverbs, "Attack")
+}
+```
\ No newline at end of file
diff --git a/site/src/content/docs/howto/ux/ui-callback.md b/site/src/content/docs/howto/ux/ui-callback.md
new file mode 100644
index 000000000..f37a68eeb
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-callback.md
@@ -0,0 +1,92 @@
+---
+title: JavaScript to Quest with ASLEvent
+sidebar:
+ order: 9
+---
+
+We can think of the game as two distinct parts, the game world, handled by Quest, and the user interface, handled by JavaScript in the browser window (even the desktop version uses a browser). The `JS` object can be uses to pass information and commands from Quest to JavaScript; how do we get information to pass the other way?
+
+## Callback function
+
+The callback function is a function in your game code that will be called from JavaScript. You can call it what you want (and you might have several different ones to handle different events). However, its return type must be "None" and it must take a single parameter, which will be a string.
+
+For example, let us create a function called "InputboxCallback", with a parameter, "s". The code might look like this:
+
+```quest
+msg ("You are " + s + " years old.")
+```
+
+
+## ASLEvent function
+
+Quest has a special JavaScript function called `ASLEvent`, which will pass two string values from the browser/JavaScript to the game world. The first parameter has to be the name of a Quest function, the second will be a string parameter to that function.
+
+Here is a very simple example of some JavaScript code. A discussion of the language is way beyond the scope of this tutorial, but the first line says we are defining a function, and the second displays a text box on screen, putting the players response in a new variable called "answer". We then check the user actually types something (i.e., `answer` is not empty), and if so, invoke the `ASLEvent` function, which in turn will call the function we created above.
+
+```js
+function askAge() {
+ var answer = prompt("How old are you?");
+ if (answer != null && answer != "") {
+ ASLEvent("InputboxCallback", answer);
+ }
+}
+
+
+```
+## To test...
+
+If you want to see that in action, wrap the JavaScript in `script` tags, and put it in a string. We can then add that to the HTML document using `addScript`. In the game start script it would lok like this:
+
+```quest
+s = ""
+JS.addScript(s)
+JS.askAge()
+```
+
+
+
+## Custom status pane
+
+Using this technique, you could change the [custom status pane](/howto/ux/custom_panes) into a control panel. Go to the game object, and turn on the custom status pane on the _Interface_ tab, then add this to the start script:
+
+```xml
+html = "HERE
"
+JS.setCustomStatus (html)
+```
+
+Create a new function, HandleClick, that will print its single parameter. When you go in game, you can click "HERE" and Quest will respond. Obviously this does nothing more than the custom command pane, but potentially you could set up a sophisticated control panel with switches and flashing lights and sliders.
+
+
+## Handling multiple parameters
+
+If you have a lot of bits of data to pass from JavaScript to Quest (say the results from a character creation dialogue), you will have to collect them altogether into one long string in JavaScript before calling ASLEvent, and then in the Quest function, you will need to split them apart again. Each bit of data should be separated with a specific character, say the vertical bar, |.
+
+The JavaScript might look like this:
+
+```js
+var s = name;
+s += "|" + age;
+s += "|" + eyeColour;
+ASLEvent("CreatorCallback", s);
+```
+
+In Quest, you can use Split to break the string up, and then handle each section. Remember to convert to integers where necessary:
+
+```quest
+l = Split(s, "|")
+player.name = StringListItem(l, 0)
+player.age = ToInt(StringListItem(l, 1))
+player.eyecolour = StringListItem(l, 2)
+```
+
+
+## Timers
+
+If you want to use split second timing, then `ASLEvent` is the way to go. Quest's built-in timers only work in whole seconds. You can use a JavaScript timer instead, and have that fire events in Quest using ASLEvent for much finer control.
+
+The details are beyond the scope of this article, but you can see examples [here](https://textadventures.co.uk/forum/samples/topic/gz1msne3k0_mjvoj8vpubw/countdown) and [here](https://textadventures.co.uk/forum/samples/topic/4rajpgh0ikicac9we2rsiq/thunder-and-lightning-effect).
diff --git a/site/src/content/docs/howto/ux/ui-custom.md b/site/src/content/docs/howto/ux/ui-custom.md
new file mode 100644
index 000000000..4f98684a1
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-custom.md
@@ -0,0 +1,131 @@
+---
+title: Modifying the status and game panes
+sidebar:
+ order: 5
+---
+
+In this article we will modify the status bar and game panes of the Quest interface, to show how easy it is to get a look that is unique to your game. We will go for an old-fashioned look, in brown. This is what we are aiming for:
+
+
+
+The first thing to do is to decide what we want; what colours will we use and so on.
+
+Once you know that, you can work out how it is described in CSS. CSS is a language that is used by web pages. A full description is beyond the scope of this article, but in essence it is about associating a description of how an HTML element will be displayed with that element. The description consists of a list of properties, each made up of a name and a value separated by a colon. Each property is separated by a semi-colon.
+
+Here is an example:
+
+> border: chocolate ridge 6px;background:sandybrown
+
+This is setting two properties. The first is called "border", the second is "background". The "border" property is set to "chocolate ridge 6px", i.e., the colour chocolate, the ridge line style and a thickness of 6 pixels.
+
+We will use these properties a lot as we want several elements of the page to have this style, so it is convenient to assign it to a local variable:
+
+```quest
+backandborder = "border: chocolate ridge 6px;background:sandybrown"
+```
+
+I want to include a command panel, and to have the buttons stand out a bit, so here is the CSS for the buttons, assigned to a second local variable:
+
+```quest
+button = "padding:5px;background:BurlyWood;border:ridge chocolate 1px;"
+```
+
+I want the text in a certain colour and font.
+
+```quest
+text = "color:black;font-family:georgia, serif"
+```
+
+To set the status bar at the top is now easy:
+
+```quest
+JS.setCss ("#qv-status", backandborder)
+```
+
+`JS.setCss` is a Quest function that takes two parameters; the HTML element and the CSS styling. The HTML element in this case is "#qv-status". The hash at the start indicates this is the ID of an element by the way.
+
+To set the panes on the right, we can modify to classes, one used for the header and one for the content. As these are classes they start with a full stop (period). I also want square corners, so will be adding to the CSS. Oh, and the content should not have a border at the top because it has the one from the bottom of the header.
+
+```quest
+JS.setCss (".ui-accordion-header", "border-radius: 0px;" + backandborder)
+JS.setCss (".ui-accordion-content", "border-radius: 0px;" + backandborder + ";border-top:none")
+```
+
+Then we can modify the text style:
+
+```quest
+JS.setCss (".accordion-header-text", text)
+```
+
+The orange triangles are awkward to change, so we will just hide them. Who actually clicks on them?
+
+```quest
+JS.setCss (".ui-icon", "display:none")
+```
+
+Then we can add the command pane, and modify its style (note the text colour must be set in the first step). we can also set up the buttons to stand out (if you have different commands here you will need to alter or add as required).
+
+```quest
+JS.setCommands ("Look;Wait", "black")
+JS.setCss ("#commandPane", text + ";" + backandborder)
+JS.setCss ("#verblinkwait", button)
+JS.setCss ("#verblinklook", button)
+```
+
+Finally, because the borders are much wider, we need to space things out a bit more:
+
+```quest
+JS.setCss ("#gamePanes", "margin-top: 16px")
+JS.eval ("$('#gamePanes').width(227);")
+```
+
+Note that we have to use JS.eval for the width as it is not a CSS property.
+
+Here is the whole thing (which should go in the interface script at the top of the _Advanced Scripts_ tab of the game object):
+
+```quest
+backandborder = "border: chocolate ridge 6px;background:sandybrown"
+button = "padding:5px;background:BurlyWood;border:ridge chocolate 1px;"
+text = "color:black;font-family:georgia, serif"
+JS.setCss ("#qv-status", backandborder)
+JS.setCss (".ui-accordion-header", "border-radius: 0px;" + backandborder)
+JS.setCss (".ui-accordion-content", "border-radius: 0px;" + backandborder + ";border-top:none")
+JS.setCss (".accordion-header-text", text)
+JS.setCss (".ui-icon", "display:none")
+JS.setCommands ("Look;Wait", "black")
+JS.setCss ("#commandPane", text + ";" + backandborder)
+JS.setCss ("#verblinkwait", button)
+JS.setCss ("#verblinklook", button)
+JS.setCss ("#gamePanes", "margin-top: 16px")
+JS.eval ("$('#gamePanes').width(227);")
+```
+
+## Actually I would prefer...
+
+Because we set up strings at the start, we can change the first two lines to see some dramatic differences...
+
+```quest
+backandborder = "border: darkblue double 6px;background:dodgerblue"
+button = "padding:5px;background:skyblue;border:double darkblue 1px;"
+```
+
+
+
+
+
+```quest
+backandborder = "border: darkgrey outset 6px;background:grey"
+button = "padding:5px;background:silver;border:outset darkgrey 1px;"
+```
+
+
+
+
+
+
+```quest
+backandborder = "border: Indigo dotted 6px;background:MediumPurple"
+button = "padding:5px;background:Violet;border:dotted Indigo 1px;"
+```
+
+
diff --git a/site/src/content/docs/howto/ux/ui-dialogue-points.md b/site/src/content/docs/howto/ux/ui-dialogue-points.md
new file mode 100644
index 000000000..0729c86c9
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-dialogue-points.md
@@ -0,0 +1,158 @@
+---
+title: Adding a dialogue panel that assigns points
+sidebar:
+ order: 11
+---
+
+
+This will build on the dialogue in the [first part](/howto/ux/ui-dialogue).
+
+Let us suppose you want the player to set some numerical attributes by spending points, for example, the player has 10 points to spend between three attributes; magic, combat and social.
+
+You need to add a row to your HTML table (in dialogue.html) for each attribute. Here is one row, for magic, you will need one of these for each attribute (they need to go before the `` line):
+
+```xml
+
+
+ Magic
+
+
+ ▲
+ ▼
+ 0
+
+
+```
+
+The first line that start `span` will add an up arrow (the 25B2 is the code for that character), and when clicked, it will call a function called `intAtt`. The second `span` does that for the down arrow.
+
+We also want to show the points remaining, so add this too (again just before the `` line):
+
+```xml
+
+
+ Points left
+
+
+ 10
+
+```
+
+And we need to define those `intAtt` and `decAtt` functions. This needs to go inside the `
+```
diff --git a/site/src/content/docs/howto/ux/ui-dialogue.md b/site/src/content/docs/howto/ux/ui-dialogue.md
new file mode 100644
index 000000000..a62856d18
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-dialogue.md
@@ -0,0 +1,136 @@
+---
+title: Adding a dialogue panel
+sidebar:
+ order: 10
+---
+
+We are going to use JQuery/JavaScript together with HTML to build a dialogue panel. This could be used at the start of an RPG-style game to create the character, for example, and you can see what is possible [here](https://textadventures.co.uk/games/view/em15b32xd0o-y-ysvgrtcg/deeper).
+
+This is not trivial, and some idea of CSS and HTML will be useful; it would be a good idea to have read through [Customising the UI](/howto/ux/customising_the_ui) first.
+
+The way it will work is we will hand some HTML to JQuery and JQuery will put it in a dialogue. We will then need to collect the data and pass it to Quest.
+
+There will be quite a bit of HTML and JavaScript code, and the neatest way to handle that is in its own file, so the first step is to create a text file called "dialogue.html", and upload it to your game via the Assets manager in the editor toolbar.
+
+
+## Basic dialogue panel
+
+The first step is to create a snippet of HTML with all the widgets (a widget is a control such as a checkbox or textfield) you want on your dialogue panel. It all has to go inside a `div` element, with its own id and title, with the class set to "dialog_window". Here is a simple example:
+
+```xml
+
+```
+
+I have chosen to set out the widgets in a table, as this helps keep things neatly aligned. I have a single text field, and two radio buttons. How to code HTML tables and widgets is beyond the scope of this article, but there are plenty of resources on the internet.
+
+It is a good idea to always give default values as it will stop the player leaving anything blank. This is complicated enough without checking for empty fields and then re-showing the dialogue panel!
+
+To get the code into your game, add this to your game start script:
+
+```quest
+JS.addText (GetFileData("dialogue.html"))
+```
+
+If you start the game, you will see your widgets, but they are embedded in the page. We need JQuery to insert them into a dialogue panel. To do that, add this JavaScript code to the file:
+
+```xml
+
+```
+There are two parts to this. The first part of that defines a function called `setValues`. At the moment it just closes the dialogue box.
+
+The other part puts the HTML into a dialogue box. I am not going deeply into JavaScript, but briefly the first line says we are defining a function that will be called when the document is loaded. The second line puts out HTML into a jQuery dialogue, using the `dialog` method. The next two lines obvious set the width and height of the dialogue (and you may well need to make these bigger for your dialogue panel). The next three lines define a block that adds buttons to it. Just one button here, called "Done", which will call the `setValues` function we defined before. The next line removes the "Close" button from the dialogue, ensuring the only way to get passed the dialogue is clicking the "Done" button (try deleting the line and see what it looks like to see the difference).
+
+Save the file. Now if you go into the game, you will see the dialogue panel, and it will disappear when you click "Done".
+
+
+## Communicating with Quest
+
+The next step is to get the data into your game. This will be done with the special JavaScript function `ASLEvent`, which is provided by Quest. A complication here is that that can only take two parameters; the name of the Quest function to use, and a string. Either we need to use it numerous times, once for each value, or use it once but send it all the data in a single string. We will be doing the latter.
+
+In the code above there was this function:
+
+```js
+function setValues() {
+ $("#dialog_window_1").dialog("close");
+}
+```
+
+We need to change that to collect the data, and then to send it to Quest. You can get data from a form element with the JQuery `val` method. For text, it is trivial:
+
+```js
+name = $('#name_input').val();
+```
+
+For the radio buttons, a bit more complicated:
+
+```js
+gender = $("input:radio[name='sex_input']:checked").val();
+```
+
+Both values need to be combined into a single string, separated by some obscure character; I use |. The new code looks like this:
+
+```js
+function setValues() {
+ $("#dialog_window_1").dialog("close");
+ answer = $('#name_input').val() + "|" + $("input:radio[name='sex_input']:checked").val();
+ ASLEvent("HandleDialogue", answer);
+}
+```
+
+Then we need to create a function in Quest to accept that data. Add it in the normal way, and call it `HandleDialogue`, no return type, and a single parameter, s. Paste in this code:
+
+```quest
+l = Split(s, "|")
+msg ("You are " + StringListItem(l, 0) + ", " + StringListItem(l, 1))
+```
+
+The first line splits the given string on the separator character, the second line just displays it. Obviously you could set attributes on the player object here if desired.
+
+
+## Disabling other input
+
+The dialogue box is not "modal", which means that the player can play your game whilst the dialogue box is still there. The best way around that is to turn off the command bar and panes on the right in the editor (_Interface_ tab of the game object), and turn them back on it the `HandleDialogue` function, so that is now:
+
+```quest
+JS.panesVisible(true)
+JS.uiShow("#txtCommandDiv")
+l = Split(s, "|")
+msg ("You are " + StringListItem(l, 0) + ", " + StringListItem(l, 1))
+```
+
+
+To load the file into the page, add this to the game's start script:
+
+```quest
+JS.addText (GetFileData("dialogue.html"))
+```
+
+
+In the [second part](/howto/ux/ui-dialogue-points) we will build on this to create a dialogue panel where the player can assign points to attributes.
diff --git a/site/src/content/docs/howto/ux/ui-fonts.md b/site/src/content/docs/howto/ux/ui-fonts.md
new file mode 100644
index 000000000..35c629394
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-fonts.md
@@ -0,0 +1,40 @@
+---
+title: Fonts
+sidebar:
+ order: 6
+---
+
+## Fonts
+
+There are about a dozen "base fonts" available in Quest. These are fonts that are pretty much guaranteed to be available on any computer (or at least equivalents, so we have Arial on PC, or Helvetica on Mac or failing that sans-serif).
+
+If you want to change the font during a game, use the `SetFontName` function. This allows you to list the equivalent fonts, so will ensure users on other operating systems see more-or-less the same thing.
+
+```quest
+SetFontName("Arial, Heletica, sans-serif")
+msg("This is in Heletica")
+SetFontName("'Courier New', Courier, monospace")
+msg("This is in Courier")
+SetFontName("Impact, Charcoal, sans-serif")
+msg("This is in Charcoal")
+```
+
+The sans-serif and monospace are generic fonts; there are also serif, cursive and fantasy. They will all map to something on every computer, though the cursive and fantasy tend to fall well short of the names.
+
+You also have access to web fonts. These are provided on-line by Google, and by default you can access just one in your game. To use any more, you need to call the `SetWebFontName` to pull the font off the internet, and then `SetFontName` as normal to actually use it.
+
+```quest
+// Pull the fonts off the internet
+SetWebFontName("Wallpoet")
+SetWebFontName("Admina")
+
+// Now we can swap between them as much as we like
+SetFontName("Wallpoet")
+msg("This is in Wallpoet")
+SetFontName("Admina")
+msg("This is in Admina")
+SetFontName("Wallpoet")
+msg("This is in Wallpoet")
+```
+
+Make sure you choose a font that is easy to read for the main text!
\ No newline at end of file
diff --git a/site/src/content/docs/howto/ux/ui-game-play.md b/site/src/content/docs/howto/ux/ui-game-play.md
new file mode 100644
index 000000000..3ebe870f9
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-game-play.md
@@ -0,0 +1,57 @@
+---
+title: The UI and game-play
+sidebar:
+ order: 1
+---
+
+Quest offers a number of options for the player to interact with the game:
+
+- Command bar
+- Hyperlinks
+- Game panes on the right
+
+Before releasing your game, you should consider if all these are applicable to the game.
+
+## Command bar
+
+The command bar is the traditional input method for interactive fiction. It offers the most flexibility to the player, creating a great sense (or illusion at least) of freedom. At the same time, this puts extra demands on the creator, as she has to anticipate all reasonable commands. If an object is mentioned in a room description, many players will expect to be able to look at it. You will also need to think of all possible synonyms for objects and verbs.
+
+However, if you decide to turn off the command bar, you need to address the limitations of hyperlinks and the game panes. By default, they can only handle moving the player to another room and simple VERB OBJECT commands. How will the player do stings like LOOK, WAIT, PUT BALL IN SACK and ATTACK ORC WITH FIREBALL?
+
+The command bar can be turned off or customised on the _Interface_ tab of the game object.
+
+
+## Hyperlinks
+
+Hyperlinks are the bread-and-butter of hypertext books, and Quest allows you to build games that are entirely navigated by such link, but still has a sophisticated world model (i.e., objects and rooms existing in a meaningful relationship to each other).
+
+Quest will create hyperlinks for you. In object lists, each object will be given a link, that will show a list of appropriate options. In the exits list, each exit again will be a hyperlink.
+
+In addition, you can use text processor commands to add your own link. Text processor commands are indicated by curly braces, with the sections separated by colons.
+
+> If you would like help, click {command:HELP:here}.
+
+> Perhaps you could {command:PUT BALL IN SACK:put the ball in the sack}
+
+The text processor command in this case is called "command", so that is the first section. The next part I put it in capitals, but it does not have to be; this is the actual command, what the player would type into the command bar. This can be as complicated as you like - just as long as Quest can understand it. The last bit is the text the player sees.
+
+Hyperlinks can be turned off and customised from the _Display_ tab. You can give objects their own individual hyperlink colour on their _Object_ tab.
+
+
+## Game panes
+
+The game panes are an alternative to hyperlinks, and may be more appropriate if you do not want your text interrupted by underlining and different colours. The compass also gives a quick indication of what exits are available. As with hyperlinks, Quest will list the appropriate verbs for an object.
+
+The game panes can be turned off or customised on the _Interface_ tab of the game object.
+
+### Command pane
+
+An additional pane can be added for simple commands, such as LOOK and WAIT, that the player can click instead of typing. See [Custom Command Panes](/howto/ux/command_pane) for how to set it up.
+
+
+## Further consideration
+
+It can be easier to create puzzles for a game using the command bar, as it is far less obvious to the player what to do at a certain point (in contrast to randomly linking links until something works). This can also lead to the "guess the verb" problem, where the player is trying to work out what obscure phrase the game is expecting next.
+
+If you choose to have the command bar in addition to either hyperlinks or the game panes, be aware that some players may assume they can complete the game using exclusively one or the other.
+
diff --git a/site/src/content/docs/howto/ux/ui-location-bar.md b/site/src/content/docs/howto/ux/ui-location-bar.md
new file mode 100644
index 000000000..6f112997d
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-location-bar.md
@@ -0,0 +1,111 @@
+---
+title: Messing with the location bar
+sidebar:
+ order: 7
+---
+
+_NOTE:_ Basic knowledge of HTML will be useful here.
+
+By default the location (or status) bar across the top of screen tells the player the current room. You can turn it off, and you can change how it looks, on the _Interface_ tab of the game object.
+
+In HTML terms, it consists of two elements: the "location" element, which holds the text, and is updated when the player goes into the room; and the "status" element, which contains it, and to which the styling is applied.
+
+Using JQuery we can change the location bar to display anything we want. The basic code (in JavaScript( is this:
+
+```js
+$('#location').replaceWith('Some new HTML code')
+```
+
+That new HTML can include a new "location" element, in which case Quest will continue to update the location, or not if you do not want that.
+
+
+## Tracking turns and score
+
+A good example would be to show the score and number of turns in the top right corner, and keep the room name in the left corner. To do that, we will insert a table into the location bar, one row high, two columns wide. The first cell is called "location", so will still display the room name, the second is called "altlocation".
+
+```quest
+s = ""
+s = s + " "
+s = s + "0/0 "
+s = s + "
"
+JS.eval ("$('#location').replaceWith('" + s + "')")
+if (HasAttribute(game, "pov")) {
+ JS.eval ("$('#altlocation').html('" + game.score + "/" + game.turncount + "')")
+}
+```
+
+I find it easiest to build up the string in steps so I can see it all, so the first four lines do that, the fifth line just replaces the "location" element.
+
+The last three lines update the display to the current values. At the start of the game, those attributes do not exist, and we do not want this code to run (we only need it when the player reloads a saved game). So we check if the "pov" attribute of the game object has been set. If it has, we are loading a saved game, and need to update.
+
+This needs to go in the "User interface initialisation script", on the _Advanced scripts_ tab of the game object (tick "Show advanced scripts..." on the _Features_ tab if you cannot see it).
+
+If you go in game, you show see the score and turn... but it does not update.
+
+
+## Updating the display
+
+We need to first initialise the score and turn counter, and this has to be done in the start script, on the _Scripts_ tab of the game object, as we want this to happen at the start of the game, but not when a saved game is loaded:
+
+```quest
+game.turncount = 0
+game.score = 0
+```
+
+Now add a new turn script, and tick it to be enabled at the start. Paste in this code, which will increment the turn counter, and then update the location bar.
+
+```quest
+game.turncount = game.turncount + 1
+JS.eval ("$('#altlocation').html('" + game.score + "/" + game.turncount + "')")
+```
+
+
+## Adding commands
+
+We can also add commands to the location bar. Just change the "User interface initialisation script" to this:
+
+```quest
+s = ""
+s = s + ""
+s = s + "LOOK |"
+s = s + "WAIT "
+s = s + " "
+s = s + "0/0 "
+s = s + "
"
+JS.eval ("$('#location').replaceWith('" + s + "')")
+if (HasAttribute(game, "pov")) {
+ JS.eval ("$('#altlocation').html('" + game.score + "/" + game.turncount + "')")
+}
+```
+
+The first line is the same as before, as are the last seven (except the "location" element is now centrally aligned). The difference is we have inserted these three lines:
+
+```xml
+s = s + ""
+s = s + "LOOK |"
+s = s + "WAIT "
+```
+
+They add a new cell to the table, so now it has three columns. Note that `width=\"25%\"` adjusts the width of the new cell, you may want to modify that number to suit your game.
+
+Our new table cell has two commands, LOOK and WAIT. When it is on the page, the HTML for the LOOK command will look like this:
+
+```xml
+LOOK
+```
+
+The "onclick" attribute is an event handler; when the player clicks this element, run the JavaScript. In this case it runs the Quest JavaScript function, `ASLEvent`, which in turn will call the Quest function `HandleSingleCommand`, passing it the value "look". The "style" attribute changes the cursor to tell the player this is clickable.
+
+When we put this code into Quest, we need to escape the double quotes, by putting a backslash before them, so Quest knows they are part of the string, not marking the end of it. We also need to escape the apostrophes so JQuery knows that _they_ are not marking the end of the string for it, and in this case we use the special HTML code `'`.
+
+
+## Modifying the style
+
+As we have not touched the "status" element, changes you make on the _Interface_ tab will still be applied. The one exception to that is the colour of the text for commands, because links always get displayed in a different colour. The simple way to handle that is to set the colour in the "style" attribute.
+
+For example, to have it black (note the spelling of "color"!):
+```xml
+s = s + ""
+s = s + "LOOK |"
+s = s + "WAIT "
+```
diff --git a/site/src/content/docs/howto/ux/ui-style.md b/site/src/content/docs/howto/ux/ui-style.md
new file mode 100644
index 000000000..6fe483344
--- /dev/null
+++ b/site/src/content/docs/howto/ux/ui-style.md
@@ -0,0 +1,83 @@
+---
+title: The UI style
+sidebar:
+ order: 2
+---
+
+Quest offers a number of options for making your game look good, and fit the style and atmosphere you want. All these options can be accessed from various tabs on the game object.
+
+This is the classic Quest interface:
+
+
+
+
+## The _display_ tab
+
+On the Display tab of the game object, there are options for changing the text, the background and (if used) hyperlinks.
+
+For text, you can set the colour, font and size. There are two types of fonts, the built-in (base) fonts and web fonts.
+
+A web font will be downloaded to the player's computer when she starts your game, and offers a huge variety of fonts (and a link below will help you choose). When choosing a font, remember that text adventures involve a fair bit of reading, so ensure you chose a font that not only looks cool, but is easy to read too. Changing the font size can also improve readability.
+
+If you do use a web font, it is a good idea to also select a suitable base font, and this will be used if the web font cannot be accessed.
+
+The background section allows you to set the colour of the page and the margins on either side. You can also choose to have an image for the margins, and to make the page transparent. You can also have the page a blend from one colour at the top to another at the bottom. Some restraint is suggested here!
+
+There are further options for setting the style for hyperlinks, and also for verb text (the drop down lists that appear when an object hyperlink to clicked).
+
+This example shows the subtle use of a colour blend, in a game with the command line turned off.
+
+
+
+
+## The _interface_ tab
+
+The Interface tab is where you control the features of the UI. Here you can turn on or off: the map; the game panes; the command bar; the location bar; the border; custom layout; and the picture frame.
+
+Turning a feature on will display additional options for that feature.
+
+### Map
+
+You can set the scale and height. You can also set the colour and width for exits.
+
+
+### Game panes
+
+You can selectively turn off a pane, or add a [command pane](/howto/ux/command_pane) or [custom status pane](/howto/ux/custom_panes). You can move the status and compass to the top (which will stop them moving up and down as the inventory changes). You can also select from a number of colour schemes.
+
+### Command bar
+
+You can use a shadow box, or no box at all (and give own cursor). Due to the limitations of CSS, if you are using a colour blend for the background, the command bar background will ignore it, so the classic box is probably best.
+
+This example uses a cursor with the panes turned off to give a minimalist look.
+
+
+
+You can use any HTML character as the cursor (or several), and there are some [pretty funky ones](https://www.w3schools.com/charsets/ref_utf_symbols.asp) in UTF-8, from a pointing hand to the yin yang symbol (though they may not be available in every font in every browser, so do test thoroughly).
+
+To get these symbols in your game, you need to use the decimal value, with `` before it and `;` after. This will give a heart cursor, for example:
+
+> ♥
+
+### Location bar
+
+You can turn off the classic location bar style, and set your own colours. See [Messing with the location bar](/howto/ux/ui-location-bar) for how to customise it further with code.
+
+### Border
+
+The border surrounds the page, separating it from the margins. You can set the colour.
+
+### Custom layout
+
+You can set the padding and the width.
+
+### Picture frame
+
+You can set it to be clear for rooms with no image set.
+
+
+## The _room descriptions_ tab
+
+With the Room Descriptions tab, you can control what happens when the player enters a room (or types LOOK). Note that "Automatically generate room descriptions" does not mean Quest will do the work for you! Rather, it means it will list the exits and the objects for you. If the box is ticked, you can control their order by modifying the numbers (set to zero if you do not what that one displayed).
+
+Hopefully most of the options are self-explanatory; experiment and see what works best for your game.
diff --git a/site/src/content/docs/howto/world/about_save.md b/site/src/content/docs/howto/world/about_save.md
new file mode 100644
index 000000000..e2d52cca2
--- /dev/null
+++ b/site/src/content/docs/howto/world/about_save.md
@@ -0,0 +1,45 @@
+---
+title: When the player saves a game
+sidebar:
+ order: 13
+---
+
+When a player saves a game, she saves _everything_.
+
+Everything in Quest is an object with attributes, and potentially all those attributes could change. So Quest saves the lot. In effect, playing the game is like editing and saving your own version of it.
+
+When the player later loads a saved game, Quest does not do anything with the original. It does not need to know anything about the original, because all the data, the entire game (in its modified state) was saved.
+
+That works fine, until you update your game.
+
+When the player now loads a saved game, Quest does not bother to look at the game itself. The player saved the old version (in whatever game state), and so that is what is loaded, and so the player is still using the old version.
+
+Generally, this is not much of a problem; hopefully your original game was in a decent state before you released it (you did beta-test, right?). For longer games, players may be disappointed that they need to start again to see new content.
+
+Possibly the biggest problem is when you upload your latest version, you may be confused why the changes are not there. It may be because you are played a saved game; you need to restart the game to see the changes.
+
+
+## Are there any alternatives?
+
+If you feel you absolutely must have player's saved games updated too, you have two approaches, neither of which will be easy to implement.
+
+### Patching
+
+One approach would be to upload a text file to a website with all your changes in it, and have a command, PATCH, that fetches the file, checks if the patch has already been installed, and if not, apply the changes.
+
+The "apply the changes" bit is the difficult bit. You would need to devise a set of instructions for the text file that the PATCH command could process to create objects and exits and to update existing ones.
+
+Your PATCH command would need to be in the original game.
+
+You would need to think about how a player could do several patches.
+
+Quest does not currently expose a way to convert a string into an executable script at the `.aslx` level, so all your scripts would need to be in your original game (but you can add them to new objects).
+
+I am not aware of anyone attempting this. Test well before release.
+
+
+### Alternative saving
+
+An alternative approach is to have your own save system. To get this to work you would need to flag every attribute that can potentially change, and just save them. Then the player would restart the game, and use a new LOAD command, apply her saved file, and all those attributes get set.
+
+There is a library available [here](https://github.com/ThePix/quest/wiki/Library:-Save-and-Load).
diff --git a/site/src/content/docs/howto/world/changing_templates.md b/site/src/content/docs/howto/world/changing_templates.md
new file mode 100644
index 000000000..e14ba31a2
--- /dev/null
+++ b/site/src/content/docs/howto/world/changing_templates.md
@@ -0,0 +1,53 @@
+---
+title: Changing templates
+sidebar:
+ order: 2
+---
+
+When a player types in a command that Quest doesn't recognise, it will by default respond "I don't understand your command". If you mis-type the name of an object, you get "I can't see that". These responses are all very well, but it might break the flow of our game if they don't fit in with the rest of the text that we've written. Fortunately, Quest provides a way for you to change all of its default responses - in fact none of the text is "hard coded" at all, which is why it is possible to create games in any language, not just English.
+
+The standard responses are all defined by **templates**, and these all exist in the **standard libraries**. These libraries contain standard game text, most of the standard behaviour in a game, all of the Editor screens - in fact a lot of Quest's functionality comes from the libraries. The libraries are .aslx files, just like your game file - a lot of Quest is written in Quest itself.
+
+Everything in the libraries is included in your game, but the Editor usually hides all this from you, so you can focus on your game itself. You can view library elements by clicking the Filter button at the bottom of the editor tree, and selecting "Show Library Elements".
+
+
+
+When you turn this on, you'll see all of the standard commands and functions that are included in a game.
+
+Click "Advanced" in the tree and then "Templates". You'll see a full list of templates.
+
+
+
+To edit one of these templates, you must first copy it into your game file, as you can't edit the library directly. Click the "Copy" button in the top right, and you'll be able to modify the template text.
+
+There is a second section "Dynamic Templates", which contains templates which are more than just static text. These templates change depending on the object they're referring to. For example, the TakeSuccessful template is used when you pick up an object - usually it says "You pick it up", but in the case of a plural object it would say "You pick them up".
+
+A Dynamic Template is an expression, and the relevant object is passed in to the expression via a variable called "object". So the TakeSuccessful template expression is:
+
+```quest
+"You pick " + object.article + " up."
+```
+
+Using the object article means that this template can print the correct thing for a singular or plural object, or even a male/female character if you have one that can be taken.
+
+These Dynamic Templates often take advantage of some functions which are defined in the English.aslx library - the [Conjugate](/functions/string#conjugate) and [WriteVerb](/functions/string#writeverb) functions ensure that correct English is written. For example, the AlreadyOpen template is:
+
+```quest
+WriteVerb(object, "be") + " already open."
+```
+
+This means it will correctly write "It is already open", "They are already open" etc.
+
+## Printing square brackets
+
+Since text in square brackets gets automatically substituted with a template's contents, you need another way to print an actual square bracket. As the output is HTML, you can use the HTML code `[`.
+
+This in your code will give an open square bracket, `[`, on output:
+
+```
+[
+```
+
+## See also
+
+For the underlying `` and `` XML elements, see the [template](/elements#template) and [dynamictemplate](/elements#dynamictemplate) reference pages.
diff --git a/site/src/content/docs/howto/world/containers.md b/site/src/content/docs/howto/world/containers.md
new file mode 100644
index 000000000..69fcb80b2
--- /dev/null
+++ b/site/src/content/docs/howto/world/containers.md
@@ -0,0 +1,143 @@
+---
+title: Using containers
+sidebar:
+ order: 3
+---
+
+
+
+Containers have been a feature of text adventures from the very early days, and are simple to implement in Quest.
+
+A container is a type of object, so the first thing to do is to create an object. Let's say we want to create a chest.
+
+To make it a container, go to the _Features_ tab of the object, and tick "Container: ...". A new tab will appear; go to the _Container_ tab, and select the type of container that you want.
+
+**Container:** Your basic container. We will look in detail in a moment.
+
+**Closed container:** As above, but it starts closed.
+
+**Surface:** A surface is a special container that you might not think is a container at all. It is really something you can put stuff on (like a table or shelf), rather than inside, but for most purposes it acts like a container. Objects on a surface are always visible and reachable, and obviously a surface cannot be closed or locked. If you select "Surface", you will see there are far less options.
+
+**Limited container:** A limited container can only hold a certain amount. We will look further later.
+
+**Openable/closable:** A object that is not a container, but can be opened and closed; a door or window. This is quite different to a container, and not discussed on this page (but see [here](/howto/tasks/setting_up_door)).
+
+
+## Container
+
+We want our chest to be a container, so select that option.
+
+At this point it is just a case of making some choices. Can it be opened and closed? Is it already open? Is it transparent (player can see the contents even if closed, but not get it)?
+
+There are also scripts that will trigger when the chest is opened (perhaps the chest is trapped), is closed or an item is added (see later).
+
+
+
+
+## Locked container
+
+_Be careful using locked containers. The "Key hunt" is something of a cliche in computer games._
+
+That said, we will set up our chest as lockable. Make sure it starts closed.
+
+In the "Locking" section, select: "Lockable". Again, you will see a bunch of options, and two new script options. The important part is the key. You can set up to five different objects to be keys. Select the number, and a number of dropdown lists will appear. Simply select the key object from the list.
+
+By default, the player will need to have all the keys to unlock the container. You can untick the "Require all keys" check box, and the player will be able to unlock the container with any of the keys.
+
+
+
+Alternatively, you may require some event to unlock the chest. Perhaps the player has answered a riddle or moved the iron beam that was keeping the lid closed. In this case we will say the player has to talk to the pixie, who will magically unlock the chest. Set the number of keys to zero and untick the "Require all keys" check box (if using earlier versions of Quest you will need to set the number of keys to 1, and create a dummy key the player cannot get to).
+
+Set up the script like this:
+
+
+
+
+## Limited container
+
+_Again, you need to be a little bit careful here. The limited container is something of a text adventure cliche, and can end up just annoying the player._
+
+Let's say we have a backpack, but it is not very big, and we want to limit how much the player can put in there (by the way, you can allow the player to wear the backpack just by making it wearable).
+
+This time we need to set it to be a "Limited container". We get a warning, now, telling us we also need to activate the feature for the game. Go to the _Features_ tab of the `game` object, and tick the "Inventory limits:... " check box. Go back to the object, and some new options are visible.
+
+Quest allows you to limit a container by count and by volume. The player will only be able to add an item to the container if the container has less than the maximum number of items _and_ it has enough volume for the new item. By default the volume of an item is 0, and if you are not interested in volumes, you can just leave everything to the default. We will do that for our backpack for now. Set the "Maximum number of objects" to some suitable number and, if you want, put in a message for when it is full.
+
+
+
+If you want to limit the volume, you need to set the "Maximum number of objects" to some really big number, and then set the volume limit.
+
+
+
+You will also need to set the volume of any object in your game that the player can pick up - including any containers.
+
+You can use any units that are convenient; it does not matter as long as you are consistent across your game. Note that if you put one container inside another, the volume of the inner container will be its own volume plus the volume of everything in it (Quest assumes containers are floppy bags that expand to hold things, rather than rigid boxes with fixed volumes).
+
+
+
+## Advanced
+
+Let's quickly look at scripts.
+
+### It's a trap!
+
+Suppose the chest has a trap that will cause a small explosion when it is opened. We only want it to fire the first time it is opened, and if the player disarms it (which we will flag with an attribute called "disarmed"), it will not fire. Here is how we might create the script:
+
+
+
+This is the code behind it:
+
+```quest
+firsttime {
+ if (not GetBoolean(this, "disarmed")) {
+ msg ("As you open the chest, there is a sudden explosion! It was trapped.")
+ DecreaseHealth (20)
+ }
+}
+```
+
+Note that "this" is a special value that refers to the object the script is attached to.
+
+
+### A fussy chest
+
+Limited containers are a special type of fussy containers; they will refuse to accept a new object under certain conditions. For other containers, we can add a script to make the container only accept objects using other criteria. In this example, the player will only be able to put clothing in the chest:
+
+
+
+```quest
+if (DoesInherit(object, "wearable")) {
+ MoveObject (object, this)
+ msg ("You put " + object.article + " in the chest.")
+}
+else {
+ msg ("You can't put " + object.article + " in the chest; it only likes clothing!")
+}
+```
+
+The basic principle is straightforward. An `if` command is used to test the condition of the `object`. If it is okay, we move the object to the container, and tell the player. Otherwise, we tell the player it failed.
+
+
+### Keeping count
+
+We can use a similar script to track how many items are in the chest, and to react accordingly. In this example, the game finishes when three or more things are put in the chest. The player might put some items in the backpack, and then put the backpack in the chest, so the total could be more than three; this is a good general principle, always check if the player has exceeded a certain amount rather than got a certain amount.
+
+
+
+```quest
+MoveObject (object, this)
+msg ("You put " + object.article + " in the chest.")
+if (ListCount(GetAllChildObjects(this)) > 2) {
+ msg ("Congratulations, you filled the chest, and completed your quest.")
+ finish
+}
+```
+
+Note that if you use this script, you must move the object to the container yourself and you must keep the player informed.
+
+Also note that we use `object.article` in the message, so if the player puts a pair of trousers in the container, it will say "them" not "it" (as long as the trousers are set up as "Inanimate objects (plural)").
+
+
+### Better messages
+
+You might just want a better message when the player puts things in the chest (just use the first part of the last script, up to, but not including, the `if`).
\ No newline at end of file
diff --git a/site/src/content/docs/howto/world/exits.md b/site/src/content/docs/howto/world/exits.md
new file mode 100644
index 000000000..862cec6cf
--- /dev/null
+++ b/site/src/content/docs/howto/world/exits.md
@@ -0,0 +1,217 @@
+---
+title: Exits
+sidebar:
+ order: 2
+---
+
+Exits are dead easy to set up in Quest. Go to the _Exits_ tab of the room, click the direction, select a destination, choose if you want to also create a reverse exit and click "Create exit".
+
+Like everything else in Quest, an exit is an object, and your exit will appear in the left hand pane, under the room (you may need to expand the node to see it).
+
+We will start by looking at the various settings for an exit.
+
+
+## To
+
+The "To" dropdown determines where the exit will take the player.
+
+When you create a room, you will find you cannot create an exit that goes to the room it came from. However, once the exit is created, you can change its destination to any room (or object), including the same room.
+
+
+## Type vs alias
+
+Quest uses the alias of the exit to decide which direction it is. The type is used when the exit is created, but does not really do anything once that has happened (it does provide an alternative name, if the alias is "east" and the type is "north", then the exit will get used for `EAST`, `E` and `N`).
+
+You can set the alias to any value you like, so you could have "kitchen" as a direction. Note that the exit will then appear in the list of "Places and Objects" rather than the compass. Set the type to "Non-directional exit".
+
+
+
+## Prefix and suffix
+
+This is text that will get added to the direction in the list of exits for the room description. For example, if you have an exit going south, you could add the prefix "through an arched doorway to the" and when the player sees the room description, she will see:
+
+> You can go through an arched doorway to the south.
+
+Note that if you are using hypertext links, only the alias will be a link, not the prefix or suffix.
+
+If you are using names instead of directions, you might want to have a prefix like "to the", so the player sees:
+
+> You can go to the kitchen.
+
+
+## Name
+
+A name is optional for an exit (if you do not give the exit a room, Quest will when the game starts). However, if you want to change any attribute of the exit during play (say to unlock it), you will need a name to refer to it by.
+
+
+## Locked vs visible vs scenery
+
+An exit that is not **visible** will not appear in the compass rose or room description, and cannot be used. As far as the player is concerned, it does not exist.
+
+Having an exit invisible is a great way of making an exit during the game. Suppose the explosion has created a new opening through the wall. Rather than create a new exit during the game, it is far easier to have the exit already created in the editor, and just set it to be visible at the explosion.
+
+An exit that is **locked** will be listed in the room description, and will be visible in the compass rose, but the player will not be able to use it (she will see the locked message instead).
+
+An exit flagged as **scenery** will not be listed in the room description, and will not be visible in the compass rose, but the player will still be able to use it.
+
+If you have a flight of stairs heading up to the east, the player might type `UP` or `EAST`, so you need to be able to handle both. However, if the room description lists both up and east as exits, the player will think they are different exits. The solution is to flag one as scenery.
+
+
+### Example: a locked door
+
+You can create an exit which is impassable until something else happens in your game. This could be a locked door, or perhaps something like a guard blocking the way.
+
+Let's create a locked door in the kitchen of the [tutorial](/tutorial/tutorial_introduction) game, leading to a back garden. Create the following three elements:
+
+- a new room, "garden"
+- an object in the kitchen, "door"
+- an exit leading south from the kitchen to the garden
+
+Select "Exit: garden" in the tree and tick the "Locked" box. You should see a warning message that we need to give the exit a name. This is because, to unlock the exit during the game, we will need to use a script command. The script command will need some way of referring to this particular exit, which is why we need to give it a name here. Call it something like "garden exit".
+
+
+
+Go to the door object, and on the Verbs tab add a verb "unlock". Set it to "Run a script", and then add a command to print a message (such as "You unlock the door"). Add an "unlock exit" command, and choose "garden exit" from the list.
+
+Run the game and verify that the exit now works correctly:
+
+ > south
+ That way is locked.
+
+ > unlock door
+ You unlock the door.
+
+ > south
+ You are in a garden.
+ You can go north.
+
+For a guide on setting up a door that is accessible from both sides — using lockable exits as described above — see [Setting Up a Door](/howto/tasks/setting_up_door).
+
+
+## Print message when used
+
+By default, Quest does not print anything when an exit is used, and just gives the details of the new room. You can use this text field to have a message when the player heads that way.
+
+
+## Attributes
+
+To change the state of an exit during a game, we need to change its attributes. Actually, that is what we were changing with all the setting above, but during a game you need to do that with a script.
+
+As mentioned before, you need to give your exit a name to be able to do this. Say we have an exit called "exit to kitchen" (lines that start with two slashes are comments by the way)...
+
+```quest
+// lock the exit
+exit to kitchen.locked = true
+
+// unlock the exit
+exit to kitchen.locked = false
+
+// make the exit appear
+exit to kitchen.visible = true
+
+// have the exit go to the garden object
+exit to kitchen.to = garden
+```
+
+
+## Exit script
+
+You can have an exit run a script when the player uses it. Tick the "Run a script" check box to activate the script, and an area for the script will appear.
+
+There are any number of reasons why you might want to run a script, so we can only look at a few examples. Note that by default the player will not be moved if we have "Run a script"; if we want the player to go to the new room, we need to do that in the script.
+
+### Conditional move
+
+A common reason to run a script is to only allow the exit to be used if a certain condition is met. Perhaps the player has to complete a quest before the portal opens, or needs to be carrying the magical amulet or has to have persuaded the guard to let her pass.
+
+
+
+```quest
+if (Got(talisman)) {
+ msg ("The talisman hums as you pass through the portal.")
+ MoveObject (player, room2)
+}
+else {
+ msg ("For some reason you cannot get through the portal.")
+}
+```
+
+The basic principle is that we test the condition. If the condition passes, then we print a message, and move the player (it is important to do the message first, as moving the player will cause the room description to get printed, and you want the message before that). If the condition fails, just give a message.
+
+This is very much like having the exit locked, so when would you use this, rather than unlocked? This technique is best for checking an on-going situation, so in fact whether the player is carrying a key is actually better done this way. The "locked" attribute is better for specific events, such as the player using the `UNLOCK` command... Hmm, turns out setting up a locked door is pretty involved, but is discussed in detail [here](/howto/tasks/setting_up_door).
+
+
+### Move and...
+
+You might want the player to trigger some event by using the exit.
+
+
+
+```quest
+firsttime {
+ msg ("As you walk down the path, the sky darkens alarmingly ")
+ SetObjectFlagOn (player, "apocolyse started")
+}
+MoveObject (player, room2)
+```
+
+In this instance, we only want it to happen once, so we use the `firsttime` script command. Again, we need the message to appear before the room description, so we move the player in the last line.
+
+
+### Using `this.to`
+
+Rather than using a specific destination in your scripts, it can be a good idea to use `this.to` instead. `this` is a special value in Quest that refers to the object the script is attached to (i.e., the exit), and the "to" attribute is the destination of an exit. This means you can potentially use the same script for different exits to different destinations. It also means that if you later modify your game and change the destination of an exit, your script will still work fine; it will send the player to the new destination without you having to remember to update the script. It is probably less typing too!
+
+
+## Room scripts
+
+It is worth briefly mentioning room scripts. Rooms have a number of scripts that fire in different situations; before entering, after entering, when leaving, etc. Do not be tempted to move the player in any of these scripts; it will confuse Quest, and the output will confuse you.
+
+So what if you want to trap the player in a room with several exits?
+
+The best way is to set all the exits to be either locked or invisible. In this example, we will set all the exits in the current room to be locked, using a `foreach` command. To unlock them all again, just set the attribute to `false`.
+
+
+
+```quest
+foreach (ext, ScopeExits ()) {
+ ext.locked = true
+}
+```
+
+Note that `ext` is a local variable. Do not be tempted to use `e` as a local variable for an exit (or anything else); this is a built-in constant and cannot be set to anything (though Quest will fail to tell you that!).
+
+
+## Useful functions
+
+### Creating exits on the fly
+
+Sometimes the tricking of setting a exit to be visible is not going to work, and you really need to create an exit. Quest has the [create exit](/scripts#create-exit) script command for just this purpose. If you want to create an exit going the other way at the same time, we have the [CreateBiExits](/functions/objects#createbiexits) function.
+
+### Finding an exit
+
+To find a specific exit, use [GetExitByLink](/functions/objects#getexitbylink) to get the exit from one room to another or [GetExitByName](/functions/objects#getexitbyname) to get the exit from a room in a specific direction (uses the alias of the exit). These both return the name of the exit (or `null` if there is none). Use `GetObject` to get the exit itself.
+
+```quest
+exitname = GetExitByName(room2, "north")
+if (not exitname = null) {
+ ext = GetObject(exitname)
+ msg ("The exit north goes to " + ext.to.name + ".")
+}
+else {
+ msg ("No exit north")
+}
+```
+
+### Finding exits
+
+There are three scope functions that will return a list of exits for a given room.
+
+- [ScopeExits](/functions/scope#scopeexits) All visible exits for current room
+- [ScopeExitsForRoom](/functions/scope#scopeexitsforroom) All visible exits for the given room
+- [ScopeUnlockedExitsForRoom](/functions/scope#scopeunlockedexitsforroom) All visible and unlocked exits for the given room
+
+
+### Random exit
+
+Two functions, [PickOneExit](/functions/random#pickoneexit) and [PickOneUnlockedExit](/functions/random#pickoneunlockedexit), will give a random exit from the given room (or `null` if there are none).
\ No newline at end of file
diff --git a/site/src/content/docs/howto/world/handling_light_and_dark.md b/site/src/content/docs/howto/world/handling_light_and_dark.md
new file mode 100644
index 000000000..48bd005df
--- /dev/null
+++ b/site/src/content/docs/howto/world/handling_light_and_dark.md
@@ -0,0 +1,162 @@
+---
+title: Handling light and dark
+sidebar:
+ order: 5
+---
+
+Quest has a system built in for handling light and darkness in your game.
+
+
+## A dark room
+
+The first step is to go to the features tab of the game object and tick the box "Lightness and darkness..." (actually this is optional; it just turns the editor features on, your game will run the same either way).
+
+By default rooms are lit. We will create a dark room, called "darkroom". Create the room as normal, make exits to and from it, and give is a description. Now go to the Light/Dark tab, and tick the "Room is initially dark" checkbox.
+
+Try the game, and you will find two things. The first is that there is no default dark room description; it is just blank. The second is that you are trapped in the dark room - there is no way to use the exit if it is too dark to see it!
+
+
+## A light from the door
+
+Go to the exit from this room, and on the Options tab, tick the "This object is a light source" box. In the dropdown box that appears, set it to be weak. Now the player will see and be able to use this exit, even if the room is dark - there is a faint light coming from the other room, enough to show you the way out.
+
+Go back to the Light/Dark tab of the room, and add a description to display when dark. Perhaps: "It is dark, but you can just make out an exit to the west." Now when you play the game the room is still dark, but the exit is useable, and the player will not be trapped here.
+
+
+## Weak and strong
+
+Quest has three levels of light for objects. None at all, weak and strong. A strong light will illuminate the whole room. A weak source only illuminates itself. The exit was a weak light source, so it could be seen in the dark room but nothing else could. What we need is a strong light source.
+
+
+## Implementing a torch
+
+Create a new object, called "torch". On the Inventory tab tick it so it can be taken. On the Features tab, tick Lightness and Darkness. Then on the Light/Dark tab, tick it as a light source and set it to be Strong.
+
+Now go in-game. With the torch in hand, your darkroom will be illuminated.
+
+
+### A note about containers
+
+Quest has a sophisticated container system. If the player puts the torch in a container that is flagged as transparent, the torch will still illuminate the room.
+
+
+## Implementing a light switch
+
+Create an object, lightswitch, inside the dark room. On the Features tab, make it switchable. On the Switchable tab, also make it Switchable, and fill in the message boxes. Then in the script to run when turned on, put in this (not sure what to do with code? See [here](/howto/scripting/copy_and_paste_code)):
+
+```quest
+darkroom.dark=false
+```
+
+For the other script, you need this:
+
+```quest
+darkroom.dark=true
+```
+
+Very simple, they just alter the "dark" attribute of your dark room.
+
+If you try it out, you will find the light switch now controls the darkness of the room (you will need the torch to find the switch, but then leave the torch elsewhere to confirm the room is now lit). You could, of course, set the switch to be a weak light source, so it can be found in the dark.
+
+
+## Implementing a switchable torch
+
+We should be able to turn the torch off, to save the battery. Pretty similar to before - on the torch object, first set it to not be a light source, as it is initially turned off (but keep it as a Strong light source!), then go to the Features tab, and make it switchable. On the Switchable tab, make it Switchable (the default messages are good enough). Then in the script to run when turned on, put in this:
+
+```quest
+this.lightsource=true
+```
+
+For the other script, you need this:
+
+```quest
+this.lightsource=false
+```
+
+## A torch that fails
+
+No torch lasts forever; let us put a limit on this one. First create a new attribute for the torch, called "battery". You can do that by going to the Attributes tab to create it, and set it to be an integer, with a value of 5 (we want a small number whilst we are playing around; for your game you will want it much higher). Alternatively, you can do the same thing in a script - go to the Script tab of the game object, and add this code:
+
+```quest
+torch.battery = 5
+```
+
+We now need a turn script. We could do this two ways: have the turn script enabled and disabled when the torch is turned on and off, or have it running all the time, but only use the battery when turned on. I am going to do the former.
+
+Create a turn script, and make sure it is under the Object object (i.e., it is vertically aligned with your rooms, not the stuff in the rooms). Give the turn script a name, torchturnscript, and paste in this code:
+
+```quest
+torch.battery = torch.battery - 1
+if (torch.battery < 1) {
+ torch.switchedon = false
+ torch.lightsource = false
+ DisableTurnScript (torchturnscript)
+ msg ("Your torch flickers and dies.")
+ torch.cannotswitchon = "You cannot turn the torch on, the battery is dead."
+}
+```
+
+The first line reduces the life of the battery. If it gets to zero the rest of the script kicks in (I am checking for less than one rather than zero in case something odd happens, and it jumps to -1; I still want the torch to fail then). Once the battery fails, we need the torch to be switched off, to not be a light source and for this turn script to stop. We also need a message to the player.
+
+The last line sets a special attribute that Quest will check before switching the object on; if the attribute is a string, the string is displayed, rather than turning on the item.
+
+Now we need to go back to the torch, and the scripts on the Switchable tab. The turn off script now looks like this, as we now want to turn off the turn script when the torch is off:
+
+```quest
+this.lightsource = false
+DisableTurnScript (torchturnscript)
+```
+The turn on script is more complicated, as we have to test if the battery is dead.
+```quest
+if (this.battery > 0) {
+ this.lightsource = true
+ EnableTurnScript (torchturnscript)
+}
+else {
+ msg ("No light - the battery is dead.")
+ this.switchedon = false
+}
+```
+
+If the battery is good, the torch becomes a light source, and the turn script goes on.
+
+If the battery is dead, we need to turn the torch off again, and give a message. The turning on message will fire every time, that is just how Quest works, so the fail message needs to be crafted around that.
+
+Want to recharge or replace the battery? Here is the code:
+
+```quest
+torch.battery = 5
+torch.cannotswitchon = null
+```
+
+## Is it dark?
+
+If you want to know if it is dark in the current room, use the `CheckDarkness` function. This will return `true` if the room is dark and there is no strong light source in it, and false otherwise. For example, for a `SEARCH` command, the code might look like this:
+
+```quest
+if (CheckDarkness()) {
+ msg("It is too dark to search.")
+}
+else {
+ msg("You search but find nothing of interest.")
+}
+```
+
+## Descriptions: scripts vs text
+
+If you use text for a room or object description, Quest will check if it is dark first, and only give the description if there is light to see the object.
+
+If you have set the room description to be a script, then Quest will again check if it is dark, and will only run the script if the room is illuminated.
+
+For objects, however, the Quest will run the script, whatever the illumination. Note that this is only an issue when they are in the inventory - objects in the room are not reachable if the player cannot see them. You may want to check in each script, then, whether there is enough light to see the object. On the other hand, you might reason that since the player has picked the object up, it is reasonable to assume she can remember what it looks like or can feel it, and so it does not matter. Or you could give different descriptions depending on the lighting.
+
+To get you started, this script will check if it is dark, and if it is, give the standard response; otherwise if gives the proper description.
+
+```quest
+if (CheckDarkness()) {
+ msg(DynamicTemplate("LookAtDarkness", this))
+}
+else {
+ msg("You search but find nothing of interest.")
+}
+```
diff --git a/site/src/content/docs/howto/world/multistate-clothing.md b/site/src/content/docs/howto/world/multistate-clothing.md
new file mode 100644
index 000000000..dbb24c756
--- /dev/null
+++ b/site/src/content/docs/howto/world/multistate-clothing.md
@@ -0,0 +1,119 @@
+---
+title: Multi-state wearable items
+sidebar:
+ order: 8
+---
+
+A multi-state [garment](/howto/world/wearables) is something that can be worn in more than one way. I am going to use a jacket as an example; it can be worn fastened up, or it can be worn open, or worn half-buttoned. This just has three states, but you can have as many as you want.
+
+Create the jacket as normal, setting it up as wearable. Then tick the "Multistate?" box, and a whole bunch of new stuff will appear (it is easy to untick the box by mistake, and have it all disappears; do not worry, it will all reappear with the data, when you tick it again).
+
+You will see a number of list controls waiting for strings to be put in. Each line will be one state, so the first entry in each control will define the first state, the second line on each control will define the second state and so. It is therefore vital that you have clear in your head what each state is.
+
+Also, bear in mind that the first state will be the default; i.e., this is how it will be whenever the player first puts the garment on.
+
+For the jacket, there are three states:
+
+```
+Open
+Half-buttoned
+Fastened
+```
+
+With that in mind, we can fill in the data. The first box is the descriptor - a word to add to the alias to note that it is in this state, just as "(worn)" is added when it is put on. This is optional, and you can use * to indicate nothing should be added. For the jacket, we will put in:
+
+```
+unfastened
+half-buttoned
+*
+```
+
+Next the wear slots. Again you can use * to just use the default, and that will be enough for us.
+
+```
+*
+*
+*
+```
+
+Now the additional verbs. This is likely to be important as this will be how the player can move between states (though they are only display verbs, the actual work will be done later). When the jacket is open, we want "Fasten" to be displayed, and when fully buttoned, "Unfasten" (we cannot use "open", by the way, because of the container system). If there are several, separate with semi-colons; the middle state, half-buttoned, can be fastened or unfastened.
+
+```
+Fasten
+Fasten;Unfasten
+Unfasten
+```
+
+Finally the attribute bonuses. If you have the jack unbuttoned you will feel cool, buttoned up will make you feel warm, so we could add these (assuming "cool" and "warm" are both integer attributes of the player). This is designed for RPGs in which the item might give a bonus, perhaps to the player's armour.
+
+```
+cool
+*
+warm+2;cool-1
+```
+
+Every time the player wears a multi-state item the system will check it has these four lists, and that there is the same number of entries in each one, so it is worth going in game and confirming you get no errors when the item is worn.
+
+You should see that if you wear the jacket, it is now `(worn unfastened)` and it has a "Fasten" verb.
+
+
+## Adding verbs
+
+To transition from one state to another, use verbs. Each verb has to check if the item is worn, check it is not already in the new state, and if all is okay, change its state. The state changing is done with the `SetMultistate` function, which takes the object and the new state as parameters. States number from 1, so the first state is 1; you will get an error if you try to set it to a state that does not exist ("Attempt to set state to ...").
+
+For the jacket, then, on the _Verbs_ tab, add a new verb, "fasten". Set it to run a script and paste in this code, which will put the jacket into the second state, fastened up:
+
+```quest
+if (not this.worn) {
+ msg ("You're not wearing it.")
+}
+else if (this.multistate_status = 3) {
+ msg ("It already is.")
+}
+else {
+ msg ("You button up the jacket.")
+ SetMultistate (this, this.multistate_status + 1)
+}
+```
+
+All your verbs should be variations on this, just changing the numbers and strings as appropriate.
+
+
+## Only removeable when...
+
+You may decide the garment should only be removed when in a certain state, perhaps when it is already unfastened. This is easy to accomplish, you just have to set the `removeable` flag as appropriate. To ensure the display verbs are right, call `SetVerbs` after doing so. For example:
+
+```quest
+if (not this.worn) {
+ msg ("You're not wearing it.")
+}
+else if (this.multistate_status = 3) {
+ msg ("It already is.")
+}
+else {
+ msg ("You button up the jacket.")
+ SetMultistate (this, this.multistate_status + 1)
+ this.removeable = false
+ SetVerbs
+}
+```
+
+Of course, your unfasten verb will need to set "removeable" to true when the state becomes 1.
+
+```quest
+if (not this.worn) {
+ msg ("You're not wearing it.")
+}
+else if (this.multistate_status = 1) {
+ msg ("It already is.")
+}
+else {
+ msg ("You unbutton the jacket.")
+ SetMultistate (this, this.multistate_status - 1)
+ if (this.multistate_status = 1) {
+ this.removeable = true
+ }
+ this.removeable = false
+ SetVerbs
+}
+```
\ No newline at end of file
diff --git a/site/src/content/docs/howto/world/score_health_money.md b/site/src/content/docs/howto/world/score_health_money.md
new file mode 100644
index 000000000..44d08d7c6
--- /dev/null
+++ b/site/src/content/docs/howto/world/score_health_money.md
@@ -0,0 +1,73 @@
+---
+title: Score, health and money
+sidebar:
+ order: 7
+---
+
+
+Quest offers an easy way to incorporate these common attributes in your game. The first step is to go to the _Features_ tab of the game object, and to turn on the ones that you want in your game.
+
+To change the values, use one of these script commands (they will only be present if the relevant option was selected on the _Features_ tab):
+
+
+
+Here you can see the "Increase score" script command, set to add 5 to the player score:
+
+
+
+It you prefer to do this in code, you just need to add to or subtract from the attribute.
+
+```quest
+game.score = game.score + 5
+player.health = player.health - 5
+player.money = player.money - 199
+```
+
+If your game is set to display the panes on the right, score, health and money will automatically be added to the status pane, if ticked on the _Features_ tab.
+
+
+## Score
+
+Score is the simplest. The score attribute belongs to the game object, so is the same even if the player can change to other characters during the game. It starts at zero.
+
+
+## Health
+
+The health attribute belongs to the player object (or objects, if you have more than one). Health is treated as a percentage, so starts at 100, and is capped at that (if you try to set it to 120, it will become 100).
+
+On the _Player_ tab of the game object, you can set what happens when health goes to zero or less. Here is a simple example that gives a message and then ends the game (which is probably all you need):
+
+
+
+Here it is in code (just to show how simple it is):
+
+```quest
+msg ("You died!")
+finish
+```
+
+You can set an object to be food (or a potion, or whatever) that will heal the player. Go to the _Features_ of the object and tick "Edible", then go to the _Edible_ tab, select "Can be eaten". You can then set how much health the food will give the player.
+
+
+**NOTE:** If you want a more flexible health system, for example you want to be able to set the maximum health, you are best starting from scratch with a custom attribute, not called "health", and turning off the health feature.
+
+
+## Money
+
+Like health, money is an attribute of the player object. By default, money starts at zero, but you can change that on the _Player_ tab of the player object (and if you have several player objects, you can give each their own money).
+
+By default, money is displayed in dollars, but you can change the format on the _Player_ tab of the game object. The exclamation mark in the string stands for the actual value, so if the player currently has money equal to 235:
+
+```
+$!
+-> $235
+
+! credits
+-> 235 credits
+```
+
+The money format string is also used by the `DisplayMoney` function, and has a lot of options beyond the scope of this page, but can be seen [here](/functions/string#displaymoney).
+
+When "Money" is ticked, you will find there is a "Price" number box for every object on its _Inventory_ tab.
+
+For a tutorial on building a more flexible scoring system with achievements, rankings, and a SCORE command, see [How to Keep Score](/howto/tasks/keeping_score).
diff --git a/site/src/content/docs/howto/world/switchable.md b/site/src/content/docs/howto/world/switchable.md
new file mode 100644
index 000000000..4e24a3573
--- /dev/null
+++ b/site/src/content/docs/howto/world/switchable.md
@@ -0,0 +1,219 @@
+---
+title: Items that can be switched on and off
+sidebar:
+ order: 4
+---
+
+In a world of electronic goods, items that can be turned on and off are very common. How would you implement that in Quest?
+
+Let's create an object called "machine", and do just that!
+
+## Switchable
+
+On the _Features_ tab, of the object, tick "Switchable:...", and then go to the _Switchable_ tab. Select "Can be switched on/off". Various options will appear that you can fill in as you see fit, or just leave blank:
+
+
+
+## Descriptions
+
+Let us say the machine has a description that is text, and says "A funny looking machine." With the values set above, when the machine is looked at, the player will see "A funny looking machine." when it is turned off, and "A funny looking machine. It is chugging away to itself." when it is turned on.
+
+You can sometimes get better prose using the text processor, as you are not limited to tacking a sentence on the end. Make the two "Extra object description..." fields blank, and have the description (_Setup_ tab) like this:
+
+```quest
+A funny looking machine{if machine.switchedon: chugging away}.
+```
+
+This uses the "switchedon" flag (or Boolean attribute) of the object, which Quest will set to true when the object is switched on.
+
+For complex descriptions, you may have to use a script, instead of text, and in that case the two "Extra object description..." fields will be ignored. Again the text processor is a solution:
+
+```quest
+msg ("A funny looking machine{if machine.switchedon: chugging away}.")
+```
+
+Or an `if` command. This is a trivial example, but could be much more complicated. Note that "this" is a special variable that means the object the script belongs to (it cannot be used with the text processor unfortunately).
+
+
+
+```quest
+if (this.switchedon) {
+ msg ("A funny looking machine chugging away.")
+}
+else {
+ msg ("A funny looking machine.")
+}
+```
+
+
+## It won't turn on!
+
+Switchables can be given a special attribute, "cannotswitchon", that will indicate it cannot be turned on - for example, there is no power or it needs a part or needs repairing. You can set this in the third text field on the _Switchable_ tab. In your game, you will need to set this to null at some point - when the device has power, perhaps.
+
+In this simple example, the player just has to use a new `POWER` command to get power to the machine:
+
+
+
+This is the code:
+
+```quest
+machine.cannotswitchon = null
+```
+
+If the object becomes unuseable (perhaps the power is turned off), just set the "cannotswitchon" attribute to some appropriate string. Remember to also turn the machine off, by setting its "switchedon" attribute to false.
+
+Note that using the "After switching on the object" script is not a good option in this case, as Quest will report that the object has switched on before running the script; the player would see, "You turn the machine on. You can't turn it on, it has no power."
+
+
+
+## Doing something
+
+So it is great that we can turn it on and off, but so what? How does that impact the game world? There are two approaches here. The first is to have other systems check if the object is on or off. A simple example might be checking if a generator is turned on, before allowing something else to work. This is best done by checking the "switchedon" attribute of the machine.
+
+Suppose we have a crystal ball that can be used only when our machine is turned on, we could set it up like this:
+
+
+
+```quest
+if (machine.switchedon) {
+ msg ("You consult the crystal ball, and learn all sorts of stuff.")
+}
+else {
+ msg ("The crystal ball is dark for some reason.")
+}
+```
+
+Alternatively, you could have the machine change the state of another object - or as many objects as you like. Let us say we have a new switchable object, a generator. This is connected to our machine and to a light (for details on using light and dark in your game, see [here](/howto/world/handling_light_and_dark)).
+
+In this case the turning-on script for the generator needs to change the state of the other objects affected; for the light, we need to set it as a light source and update the description. For the machine, we need to set the "cannotswitchon" attribute to null to allow it to be turned on.
+
+For the turn off script, we need to reverse all that. We have some extra house keeping to do, as the machine may be turned on, we need to ensure it is turned off.
+
+
+
+The turn on code:
+
+```quest
+light.lightsource = true
+light.look = "A light, shining brightly."
+machine.cannotswitchon = null
+```
+
+The turn off code:
+
+
+```quest
+light.lightsource = false
+light.look = "A light."
+machine.cannotswitchon = "No power!"
+machine.switchedon = false
+```
+
+You might also want to put some text in there to let the play know these things have happened. Here is an example that checks if the machine is on (before turning it off!), and if it is, gives a message:
+
+```quest
+light.lightsource = false
+light.look = "A light."
+machine.cannotswitchon = "No power!"
+if (machine.switchedon) {
+ msg("The machine stops when the power fails.")
+}
+machine.switchedon = false
+```
+
+
+So which is the best approach for you? If you want to test if the object is switched on for something happening instantly, like using the crystal ball, the first approach is best. It is probably the safest way, in the sense that the state of the object is in one place only, so your game cannot get in a state where one thing thinks it is turned on and another thinks it is turned off.
+
+However, the second approach is easy for on-going situations, such as the light; the light will continue to give light as long as the generator is on.
+
+
+
+## On for a moment
+
+Occasionally you might want to implement a machine that the player turns on, it does something straight away, and then is off again.
+
+Let us suppose our machine will clone rabbits. We need to add a script that does two things; clone the rabbit and switch the machine back off. Note that in this case we do not need a message when the player turns the machine off.
+
+
+
+```quest
+CloneObjectAndMove (rabbit, player.parent)
+SwitchOff (machine)
+```
+
+Or change the attribute directly:
+
+```quest
+CloneObjectAndMove (rabbit, player.parent)
+machine.switchedon = false
+```
+
+It is also a good idea to go to the _Object_ tab and delete "Switch off" from the two lists at the bottom.
+
+
+## Better display verbs
+
+In fact, it will look better if the player only sees "Switch on" when the object is off, and "Switch off" when it is on.
+
+We will do this for the generator. The generator cannot be picked up, so we only need to worry about the display verbs. As it starts turned off, on the _Object_ tab delete "Switch off" and "Take" from the list of display verbs at the bottom.
+
+Then go to the _Switchable_ tab, and set it to change the display verbs when turned on and off:
+
+
+
+```quest
+light.lightsource = true
+light.look = "A light, shining brightly."
+machine.cannotswitchon = null
+this.displayverbs = Split("Look at;Switch off", ";")
+```
+
+```quest
+light.lightsource = false
+light.look = "A light."
+machine.cannotswitchon = "No power!"
+machine.switchedon = false
+this.displayverbs = Split("Look at;Switch on", ";")
+```
+
+### Portable objects...
+
+If the object can be picked up, then you need to modify the inventory verbs, and include the "Take" and "Drop" verbs. Delete just "Switch off", but from both lists at the bottom of the _Object_ tab. The code on the _Switchable_ tab would then look like this:
+
+```quest
+light.lightsource = true
+light.look = "A light, shining brightly."
+machine.cannotswitchon = null
+this.displayverbs = Split("Look at;Take;Switch off", ";")
+this.inventoryverbs = Split("Look at;Drop;Switch off", ";")
+```
+
+```quest
+light.lightsource = false
+light.look = "A light."
+machine.cannotswitchon = "No power!"
+machine.switchedon = false
+this.displayverbs = Split("Look at;Take;Switch on", ";")
+this.inventoryverbs = Split("Look at;Drop;Switch on", ";")
+```
+
+### Remember...
+
+If your object can be turned off another way, you will need to update the display verbs there. For the machine powered by the generator, when the generator is turned off, we would have to also update the verbs for the machine.
+
+```quest
+light.lightsource = false
+light.look = "A light."
+machine.cannotswitchon = "No power!"
+machine.switchedon = false
+machine.displayverbs = Split("Look at;Switch on", ";")
+this.displayverbs = Split("Look at;Switch on", ";")
+```
+
+## Testing
+
+It is vital that you test your switchable objects, as there is potential for weird bugs.
+
+What happens if the player switches it on three times in a row, or off three times in a row or on and off three times in a row. Do display verbs and descriptions change as they should. What happens if the player turns things on out of the expected sequence?
+
+Note that you can move the player object to the same room as the switchable object whilst you test. When you are sure it works as expected, move the player object back to its normal place.
\ No newline at end of file
diff --git a/site/src/content/docs/howto/world/text_processor.md b/site/src/content/docs/howto/world/text_processor.md
new file mode 100644
index 000000000..3e90c0142
--- /dev/null
+++ b/site/src/content/docs/howto/world/text_processor.md
@@ -0,0 +1,315 @@
+---
+title: Text processor
+sidebar:
+ order: 1
+---
+
+The text processor gives an easy way to conditionally print text, show object links, show text only once, and more.
+
+To use the text processor, you can simply add a directive in curly braces in any text that gets displayed. In this simple example, a room description is set to say that room smells only the first time the text is printed:
+
+
+
+The more important text areas have shortcut buttons for some text processor commands; these are the buttons on the right in the image above. However, you can use text processor commands in almost any text, for example, in an [msg](/scripts#msg) command:
+
+```quest
+msg ("Would you like some {command:help}?")
+```
+
+You can use as many sections as you like within the same text, and even nest them:
+
+```quest
+msg ("You can {command:go to shop:go into the shop}. {if player.coins>10:You have {player.coins} coins, which is more than enough.}")
+
+```
+Supported processor commands are:
+
+## Text adventure mode and gamebook mode
+
+{once:**text**}
+Displays the text only once. The text will not be printed on subsequent occasions.
+
+{notfirst:**text**}
+Does not displays the text the first time it is printed; the text will only be printed on subsequent occasions.
+
+{random:**text 1:text 2:text 3**}
+Choose text at random (you can have as many sections as you like). This is a great way to add some movement to a character.
+
+```quest
+You can see Mary {random:paddling in the sea:building a sand castle:running in the sand}.
+```
+
+{img:**filename.png**}
+Insert the specified image.
+
+{**object.attribute**}
+Displays the value of an object's attribute. A great example of this is where the player can set the name of the main character, you can use `{player.alias}` as a stand-in for the character's name.
+
+```quest
+'Hi, {player.alias},' says Mary, 'I've not seen you in a while!'
+```
+{if **object.attribute**:**text**}
+Display text only if object attribute is true (so requires a flag, otherwise known as a Boolean attribute). Containers have a flag called "isopen", and you could use that to modify the description, for instance.
+
+```quest
+The chest is old, and almost falling apart. {if chest.isopen:The lid is open.}
+```
+
+{if not **object.attribute**:**text**}
+Display text only if object attribute is false.
+
+{if **object.attribute=value**:**text**}
+Display text only if an object attribute equals a certain value. Note that there should be no spaces either side of the `=`.
+
+{if **object.attribute\<\>value**:**text**}
+Display text only if an object attribute does not equal a certain value. Note that there should be no spaces either side of the `\<\>`.
+
+{if **object.attribute\>value**:**text**}
+Display text only if an object attribute is greater than a certain value. Note that there should be no spaces either side of the `\>`.
+
+{if **object.attribute\>=value**:**text**}
+Display text only if an object attribute is greater than or equal to a certain value. Note that there should be no spaces either side of the `\>=`.
+
+{if **object.attribute\ "You are the player",
+"'Oh, {either player.male_flag:he|she} is not worth it.'"
+ -> "'Oh, he is not worth it.'",
+```
+
+{eval:**code**}
+The code is evaluated, just as normal Quest code is, and the result displayed.
+
+{=**code**}
+This is a short cut for eval, and works just the same. The samples below show the potential, though by its nature this is rather less forgiving that the other commands available.
+```quest
+"You are in the {eval:player.parent.name}"
+ -> "You are in the kitchen"
+"You are in the {=player.parent.name}"
+ -> "You are in the kitchen"
+"You are in the {=CapFirst(player.parent.name)}"
+ -> "You are in the Kitchen"
+"There are {=ListCount(AllObjects())} objects"
+-> "There are 6 objects"
+"You look out the window: {=LookOutWindow}"
+ -> "You look out the window: A figure is moving by the bushes"
+```
+
+
+
+
+## Additional gamebook commands
+
+{counter:**countername**}
+Displays the value of an counter
+
+{if **flag**:**text**}
+Display text only if flag is set
+
+{if not **flag**:**text**}
+Display text only if flag is not set
+
+{if **countername=value**:**text**}
+Display text only if a counter equals a certain value.
+
+{if **countername\>value**:**text**}
+Display text only if a counter is greater than a certain value.
+
+{if **countername\>=value**:**text**}
+Display text only if a counter is greater than or equal to a certain value.
+
+{if **countername\ "player.count = {player.count}"
+ ```
+
+
+## Using text processor with object aliases
+
+You cannot use text processor commands in an object's name, as only a limited set of characters is allowed (letters, numbers, space and underscore). You can for the object's alias, however, so you could set an alias to "{i:big} settee". You will find that the alias as it appears in the pane on the right has not been processed; the player will see the raw "{i:big} settee". To get around that, give the object a list alias on the _Object_ tab.
+
+
+## Support for "this"
+
+In Quest, "this" is a special local variable that refers to the object that owns the current script. Text processor directives do not naturally support "this", because when they are being processed they do not belong to a script. However, you can fake it by setting a special attribute of the game object called "text_processor_this". This would allow you to do something like this:
+
+```quest
+game.text_processor_this = teapot
+msg("The {this.alias} is {if this.capacity<5:not }big enough.")
+```
+
+
+## Local variables
+
+In fact you can add any number of local variables in a dictionary attribute of the game object called "text_processor_variables". The key will be the name of the variable, and the value should be the object.
+
+```quest
+game.text_processor_variables = NewDictionary()
+dictionary add (game.text_processor_variables, "animal", tiger)
+msg("You can see a {animal.name}")
+```
+
+You can add as many variables as you like to the dictionary, and they will last until you set "text_processor_variables" to be a new dictionary again. Note that if you have "this" set in the dictionary and using "text_processor_this", the latter value will be used.
+
+
+## Extending
+
+You can add your own text processor directives. This should be done in the "start" script of the game object (top of the _Scripts_ tab on the game object).
+
+Here is a very simple example that will replace `{test}` with `Some Text`:
+
+```quest
+game.textprocessorcommands = game.textprocessorcommands
+scr => {
+ game.textprocessorcommandresult = "Some Text"
+}
+dictionary add(game.textprocessorcommands, "test", scr)
+```
+
+The first step is to clone the script dictionary to the game object, which might look as if it is not actually doing anything, but behind the scenes is vital (you only ever need to do this once; if you forget you will get an error saying "Cannot modify the contents of this dictionary..."). The next three lines create a script, whilst the last line adds that script to the dictionary, using the key "test", which is then the name of the directive.
+
+The script is where the action happens. In this case it just sets the result (a special attribute on the game object).
+
+The script has access to a local variable called "section", which contains the text inside the curly braces (including the name of the directive). For the example above, that would just be "test".
+
+Let us add another directive to see how that can be used:
+
+```quest
+scr => {
+ s = Mid(section, 6)
+ game.textprocessorcommandresult = "" + s + " "
+ }
+dictionary add(game.textprocessorcommands, "blue", scr)
+```
+
+This will print the text in blue.
+
+```quest
+msg("Here is the {test}, now with some in {blue:a different colour!}")
+```
+
+Inside the script, scr, there are two lines. The first gets the actual text. The word "blue" is four characters, then there is the colon, so the bit we want starts at the sixth character.
+
+The second line then sets the return value, using HTML and CSS to change the text colour to blue.
+
+## HTML tags
+
+You can also use HTML tags directly in any text output. For example:
+
+```xml
+This text is bold . This text is italic . This text is underlined .
+```
+
+For more complex styling, use `` tags with inline CSS, for example `this is red `. The text processor `{colour:}` and `{back:}` commands above are generally more convenient for this.
\ No newline at end of file
diff --git a/site/src/content/docs/howto/world/transcript.md b/site/src/content/docs/howto/world/transcript.md
new file mode 100644
index 000000000..b80a182f6
--- /dev/null
+++ b/site/src/content/docs/howto/world/transcript.md
@@ -0,0 +1,33 @@
+---
+title: Transcripts
+sidebar:
+ order: 11
+---
+
+A transcript is a recording of everything the player types and the game prints, and can be very useful when beta-testing, for example.
+
+Transcripts are saved to the browser's local storage - this is the same whether you're playing online or using the [desktop app](/download/), since the desktop app runs the same browser-based player.
+
+To view, download, or delete your transcripts, type `VIEW TRANSCRIPT` (or `SHOW TRANSCRIPT`, or `DISPLAY TRANSCRIPT`) during play. Quest will print a link to a transcript viewer running locally alongside the game.
+
+IMPORTANT NOTE: Because transcripts live in the browser's local storage, they'll be lost if you clear that site's browsing data, or if you were playing in a private/incognito window. Make sure to view and/or download your transcript(s) before doing either of those if you want to keep them.
+
+To turn the transcript on, use any of these commands during play.
+
+ SCRIPT
+ TRANSCRIPT
+ SCRIPT ON
+ TRANSCRIPT ON
+ ENABLE SCRIPT
+ ENABLE TRANSCRIPT
+
+If it is already enabled, Quest will print, "The transcript is already enabled."
+
+To stop the transcript:
+
+ SCRIPT OFF
+ TRANSCRIPT OFF
+ DISABLE SCRIPT
+ DISABLE TRANSCRIPT
+
+Note that during play you can type a `*` and then some text, and Quest will ignore it. This is useful for when you want to comment on something, such as a bug you have found. The comment will appear in the transcript (and can be searched for as it starts with a `*`), but you will not confuse the game with your weird command.
\ No newline at end of file
diff --git a/site/src/content/docs/howto/world/trizbort.md b/site/src/content/docs/howto/world/trizbort.md
new file mode 100644
index 000000000..132759c8f
--- /dev/null
+++ b/site/src/content/docs/howto/world/trizbort.md
@@ -0,0 +1,160 @@
+---
+title: Creating with Trizbort and Quest
+sidebar:
+ order: 3
+---
+
+
+Trizbort is a map-making program specifically designed for text adventures, first developed by genstein, and now maintained by JasonLautzenheiser. It is partly for players to be able to map a game as they play through, but also for designers. It has been around for a few years, but recently the ability to export a map to Quest has been added.
+
+You can find it here:
+
+
+
+
+## So how would you use it?
+
+The first thing to realise is that this is a one way trip. You start creating with Trizbort, then export to Quest, and then create with Quest. You cannot go back to Trizbort once you have started to make changes in Quest.
+
+So with that in mind, the way to approach it is to design the geography in Trizbort, then turn that into a game in Quest.
+
+Trizbort is available as a .zip file, and should be extracted into a folder called “trizbort”. You can then double click the app to start it. You will be presented with a blank page.
+
+
+
+## Rooms
+
+Press “R” to create a room. When a room is selected you can drag it to move it, or select it and then drag its square handles to change its size. Double click on the room to change its properties. Here you can type in a description. You can tick it as dark too.
+
+
+
+You should make sure one room is flagged as the start room so the exporter will create a player object there.
+
+Rooms can be named any way you like. When exported, it will be given a name that is made up of just letters and numbers and underscores, but it will also get an alias that is just as you typed it.
+
+Rooms in Quest have to have unique names; it is a good idea to select Validation – Rooms must have a unique name, so Trizbort will warn you if that is not the case.
+
+Note that Subtitle, Regions and Room shapes will not be exported to Quest.
+
+
+
+## Exits
+
+To create an exit, make sure no room is selected (just click outside a room). Now if you hover your cursor over a room the circular exit ports will appear. Drag the port from one room to another. Ports line up to the normal compass directions. Trizbort actually supports sixteen compass directions; I am not aware of _any_ text adventure that uses that many and Quest certainly does not. Just use the standard eight (you might want to use the others for up/down and in/out).
+
+
+
+Double click on a link to change it. You can do this to make it up/down or in/out (otherwise it will use compass directions as you would expect), or one way. One way exits will have arrows to indicate the direction. For up/down and in/out, the exit will be labelled. The label indicates what the player in the room will see. If it says “Down” at that end of the link, then the room has a “Down” exit.
+
+You can make other changes too, but nothing that will get exported into Quest.
+
+
+
+## Objects
+
+You can add objects to rooms. Double click the room to open the properties box, and go to the _Objects_ tab. It is a simple list; one object per line. Each object can be flagged to be of a certain type, the flags should go inside square brackets. The following are supported:
+
+```quest
+s scenery
+f female
+m male
+! proper-named (only with f or m)
+c container
+2 plural named (singular is by default)
+```
+
+Other flags will not be exported.
+
+In this example, the room will have a sofa, which is scenery, a named, female called Mary, and curtains, flagged as both scenery and plural.
+
+```
+sofa[s]
+Mary[f!]
+curtains[s2]
+```
+
+Objects will be given names and aliases in the same way as rooms, however two objects can have the same name; they will get modified on export so the alias is the same, but not the name. Objects must NOT have the same names as rooms – Trizbort does not check, you need to ensure this yourself.
+
+This is a great way to ensure everything mentioned in the room description gets implemented - but remember to gve them all descriptions.
+
+Note that position just determines where the list appears in Trizbort, and is not exported to Quest.
+
+
+
+## Map settings
+
+Go to _Tools – Settings_ to see general setting. Here you can give your game a title, add yourself as the author and add a description. This is just as easily done in Quest, and you may prefer to do it there.
+
+None of the other settings will be included in your Quest game.
+
+
+
+## Exporting
+
+Before exporting, check: Do you have a start room (it will have a yellow glow around it; Trizbort will not let you have more than one)? Do you have any objects/rooms with the same names?
+
+You do not have to, but it is probably best to follow the Quest convention, and to create a new folder for your game inside the “Quest Games” folder.
+
+Once you have completed your map, go to _File – Export_ to export your game, and select _Quest_. Navigate to the folder, and click “Save”. The exported file is your new Quest game.
+
+
+
+
+
+
+## In Quest
+
+You should now be able to open your game in Quest.
+
+Remember to give all those objects a description. If any can be picked up, you will need to tick the box for that. Note that if you have rooms flagged as dark, you will need to turn the feature on in the _Display_ tab of the game object.
+
+
+
+## Quest maps
+
+You will need to go to the _Interface_ tab of the game object to turn the map on, if you want to have an in-game map.
+
+Quest handles maps very differently to Trizbort. Quest tries to guess where each room is in relation to each other, whilst Trizbort is a drawing package, and allows rooms to go anywhere. Quest uses exits and Trizbort uses links. The upshot is that map itself does not export from Trizbort. The only values that are exported are the colours and size of the room.
+
+Alternatively, export the Trizbort map to an image, and add that to your game.
+
+
+
+## Languages
+
+If you want to create a game in a language other than English, you just need to add the language file to your game. In the editor toolbar, open the raw XML code view. You will see the code behind your game. It will start like this:
+
+```xml
+
+
+
+
+```
+
+You need to add a line to add your language, after English, and before Core. This example is for French.
+
+```xml
+
+
+
+
+
+```
+
+Close the code view to go back to the normal editor.
+
+
+
+## Adding to an existing game
+
+Another way to use Trizbort is to create a new region in an existing game. You will need to be careful to make sure every room and object has a unique room, as Trizbort will not be able to check against rooms and objects already in your game. I recommend backing up your Quest game before doing this!
+
+After creating the new region, go to _File – Export_ to export your game, and select _Quest to clipboard (no header)_. All the new rooms and objects will be copied to the clipboard. In the editor, open the raw XML code view. You will see the code behind your game. Right at the bottom, you will see this line:
+
+```xml
+
+```
+
+Put in a couple of blank lines above that line, and paste in the copied XML there, then click Apply to add all those new rooms.
+
+Close the code view to go back to the normal editor.
\ No newline at end of file
diff --git a/site/src/content/docs/howto/world/wearables.md b/site/src/content/docs/howto/world/wearables.md
new file mode 100644
index 000000000..ead44e44f
--- /dev/null
+++ b/site/src/content/docs/howto/world/wearables.md
@@ -0,0 +1,351 @@
+---
+title: Wearable items
+sidebar:
+ order: 6
+---
+
+A common feature of text adventures is items that can be worn.
+
+
+## Basic clothing
+
+Adding clothing is very simple. Let us suppose we want to add some trousers. Create an object called "trousers", and on the _Features_ tab, tick "Wearable: object can be put on and taken off". You will see there is a new tab, "Wearables". On that tab, set the garment to "Can be worn" and a bunch of new boxes will appear.
+
+That is it. At its simplest, that is all you need to do. You can now go in game, and wear the trousers.
+
+You should see this:
+
+```
+> wear trousers
+You put it on.
+```
+
+Wait, it should say "them" not "it". This is not specifically about clothing, but we might as well get it right. Go to the "Setup" tab, and set the type to "Inanimate objects (plural)". Also untick "Use default prefix and suffix" and it will not get referred to as "a trousers".
+
+You can add specific messages for putting on and taking off items too. For the trousers, you could put "You pull on the trousers, one leg at a time." in the "Message to print when wearing" box. Now you will see:
+
+```
+> wear trousers
+You pull on the trousers, one leg at a time.
+```
+
+That is the basics, however, the library allows you to do rather more. What about underwear? And what if you want to ensure underwear cannot be worn over the top of the trousers?
+
+
+
+
+## Layers and slots
+
+Garments can be assigned to layers and slots. Slots are where the item is worn. Each slot is a string, for example "head". At this point, we need to make a design decision - how are we going to divide the body up? For simplicity, we will say: feet, lower, upper and head. Trousers cover the lower body, so in the wear slots section, add "lower"
+
+As well as slots, clothing has layers. Trousers went in the default layer, 2. Create some underpants and give them a wear layer of 1, and again a wear slot "lower". Now if you go in game you will find you cannot put the underpants on if you are already wearing trousers. The verbs offered in the pane on the right will not include "Wear", and if you type it in the command bar you will see this:
+
+```
+> wear trousers
+You put them on.
+> wear underpants
+You cannot wear that over trousers.
+```
+
+The logic here is that, for garments that have the same slot, you can only put on an item that has a higher layer than the items already worn, and you can only take off the garment with the highest layer.
+
+Garments can occupy more than one slot, so overalls could be set to have both "lower" and "upper". And any garment with no slots can be worn without restrictions.
+
+
+### Layer zero
+
+Layer zero is special; it is effectively all layers. Say you have a pair of shorts that cannot be worn under trousers or over underpants, you can set its layer to 0.
+
+
+## Advanced features
+
+Most people will not need to worry about the advanced features, so they are hidden to keep the tab uncluttered. To turn them on, go to the _Features_ tab of the game object, and tick "Show advanced options for wearables".
+
+
+
+### Removeable
+
+Untick this if you do not want the player to be able to remove the garment. This might be because the item is cursed, or you just want to prevent the player getting completely naked, and having to handle the reactions of the NPCs to a naked person.
+
+If you go to the _Attributes_ tab, you can create a string attribute called `notremoveablemessage` that will be displayed when the player tries to take the garment off.
+
+
+### Protection
+
+The library gives some facilities to handle armour for a combat-orientated RPG-style game, which is discussed more below. The `armour` attribute of this item can be set in the "Protection" box.
+
+
+### Bonuses and penalties
+
+This feature is for garments that give bonuses when worn. Bonuses (and penalties) are set in the "Wearing gives a bonus to these attributes" box. In there you can list any attributes on the player character that get increased when the garment is worn. If there is more than one, separate them with semi-colons (and no spaces). By default, the increase is 1, but you can specify other values too, so you can use a minus to give a penalty.
+
+For example:
+
+```
+protection;charisma+2;agility-1
+```
+
+When the player puts on this garment, his protection will increase by 1, his charisma by 2 and his agility will drop by 1. When the garment is removed, the bonuses are all lost.
+
+The bonuses are applied by the SetBonuses function, which takes the garment as its first parameter, and a Boolean as the second. The Boolean should be true if the garment is being put on, and false if taken off.
+
+The WEAR and REMOVE commands, together with the WearGarments function will call SetBonuses automatically. If you have garments getting put on or taken off any other way, you must remember to call SetBonuses for each garment, if you use this feature.
+
+You can override the `ClothingBonusMultiplier` function to have the effects doubled or tripled in certain situations. By default it returns 1, but if the player should get double the effect, have it return 2.
+
+NPCs are not affected by garment bonuses.
+
+
+### Display verbs
+
+The system automatically updates the display verbs for all clothing as garments are put on and taken off. Occasionally, you may want to add your own. For verbs that should be seen when the item is in the room, rather than the player's inventory, do this on the _Object_ tab as normal.
+
+For verbs that will be visible when the player has the item, set these on the _Wearables_ tab. There are two boxes, one for when the garment is just being carried and one for when it is worn (but only if it is the outmost garment). You can put as many verbs as you like, separated with semi-colons.
+
+You can change the additional display verbs mid-game by modifying the `wornverbs` or `invverbs` attributes, then calling `SetVerbs`. Here is an example where two verbs are added to a hat for when it is worn, and one when it is not.
+
+```quest
+pink_hat.wornverbs = "Activate;Show off"
+pink_hat.invverbs = "Activate"
+SetVerbs
+```
+
+You might also want to call `SetVerbs` in the start script of the game object so any garments in the player's inventory at the start are set up correctly.
+
+
+
+### Scripts
+
+You can also set scripts to trigger when an item is put on or when it is taken off.
+
+
+### Multistate
+
+
+Tick the _Multistate?_ box to show options related to garments that can be in more than one state (such as a jacket that can be open or fastened), as described on [this page](/howto/world/multistate-clothing).
+
+
+
+
+
+## A note on inventory limits
+
+This only applies if you are using the inventory limits feature.
+
+Wearing something will automatically increase the player's inventory limit by 1, and removing it reduce it by 1. What this effectively means is that items that are worn will not contribute to the inventory limit.
+
+In Quest, the volume limit works more like a weight limit, and the player is still carrying the weight, so putting something on or taking it off does not affect the volume limit.
+
+
+
+
+
+## More on wearables
+
+There are various functions that can help you when handling clothing.
+
+### What is the player wearing?
+
+Use the `ListClothes` function to get a string that lists the clothes currently worn (note, not a string list). If the player is naked, it will return the string "nothing". It could be used like this:
+
+```quest
+msg("You are wearing " + ListClothes () + ".")
+```
+
+### What is covered?
+
+Do you need to know if the player is wearing anything at a certain location? For example, perhaps the floor is hot and you want to know if the player is barefoot.
+
+You can get the outer most garment for a wear slot, using the GetOuter function.
+
+```quest
+GetOuter ("feet")
+```
+
+If the player is wearing socks and boots, the function will return the boots object, as they are worn on the outside. If the player has nothing on his feet, it will return null.
+
+
+
+### Putting on and removing garments in code (starting clothing)
+
+There are two functions, `WearGarment` and `RemoveGarment`, that you can use if you need to put garments on or take garment off the player in code (as opposed to the player doing it via the normal commands).
+
+For example, to have the player wearing clothes at the start, use the WearGarment function. This will ensure the item is in the player's inventory, and all its attributes properly set. You can do this in the start script of the game object.
+
+```quest
+WearGarment (underpants)
+WearGarment (trousers)
+WearGarment (shirt)
+```
+
+The `RemoveGarment` function works similarly, taking the garment to be removed as a parameter. To remove all garments (without any message to the player), do this:
+
+```quest
+foreach (o, GetAllChildObjects(game.pov)) {
+ if (GetBoolean(o, "worn")) {
+ RemoveGarment (o)
+ }
+}
+```
+
+### Changing the name of clothing
+
+Quest handles changing the name of a garment, so when it is worn, its alias has "(worn)" added to it. However, that means that if the name of a garment changes, just setting the alias is going to confuse Quest. There are, therefore, two functions to do this. The `SetAlias` function takes the name of the object and the new alias, whilst `SetListAlias` takes the name of the object, the new alias and the new list alias. For example:
+
+```quest
+SetListAlias (trendy_jacket, "unfashionable jacket", "Unfashionable jacket")
+```
+
+You can use this with any object, by the way; they will just change the alias and list alias.
+
+
+### Appropriate clothing?
+
+You can [override](/advanced-topics/overriding) a function called `TestGarment` if you want to check a garment can be worn, for example to ensure it is not too small for the player. `TestGarment` must return a boolean, and take a single parameter; the garment. It should return true if the garment can be worn. If it cannot, it should give a message to say that, and then return false.
+
+```quest
+if (GetBoolean(object, "toosmall")) {
+ msg("That is too small for you!")
+ return (false)
+}
+return (true)
+```
+
+`TestGarment` is called after the system has already determined that the object is wearable, is held and is not currently worn, so you can safely assume these are true.
+
+There is a corresponding function `TestRemove` that is called before an item is removed. You can see how this might be used in the "No Public Nudity" section later.
+
+
+### Checking
+
+There is no built-in system to ensure that you only add the right wear_slots. If you have some jeans in a slot called "Lower", the player will be able to wear them at the same time as the trousers in slot "lower". To check you have not done that by mistake, add this line to the start script of your game object:
+
+```quest
+msg (Slots())
+```
+
+When you start the game, you should see something like this:
+
+List: lower; feet; upper; head;
+
+If a slot appears twice, you have a bug and can sort if out before you release your game. In the example just mentioned, you might see this:
+
+List: lower; feet; upper; Lower; head;
+
+Remember to delete that line from the start script before you upload your game; it is just for testing.
+
+
+### A "Worn Clothing" location
+
+If you feel the player's inventory is getting cluttered with what the player is wearing, an option is to create a container, perhaps called "Worn Clothing", on the player object, and to have anything worn go in there. If the container is open, the clothing will get listed, but if it is closed, they will be hidden.
+
+To get this to work successfully, you need to create a new object on the player object, and give it a Boolean attribute called `wornclothinglocation` that is set to true. On the _Features_ tab, set it to be a container, then on the _Container_ tab, set it to be a container. On the _Inventory_ tab, untick the box so it cannot be dropped.
+
+At this point it should work fine, but there is some tidying up we can do to make it more slick (however, you should only do this if you do not have `SHOW` or `HIDE` as commands; if you do this will screw them up). On the Inventory tab, tick "Disable automatically generated display verb list", and in the list of _Inventory Verbs_, delete everything. Click _Add_ and type in "Hide".
+
+Now go to the verbs tab, and click _Add_ there. Again, type in "Hide" and set this to run a script, and paste this in:
+
+```quest
+this.isopen = false
+this.inventoryverbs = Split("Show", ";")
+```
+
+Click _Add_ there again, and this time type in "Show" and set this to run a script, and paste this in:
+
+```quest
+this.isopen = true
+this.inventoryverbs = Split("Hide", ";")
+```
+
+If you are using inventory limits, increase the maximum by one to allow for this new object.
+
+
+### No public nudity
+
+Let us suppose you want to ensure the player is modestly attired in public places. How might you do that? The first step is to decide what that means in game terms, and then to create a function, let us say, `IsDecent` that will test that, and return a Boolean as appropriate. How that works will depend on your game, but let us suppose there is a flag on the player "isfemale" that is true for female characters, and the important body slots are "lower" and "upper". We will also set an attribute, "private" on rooms that are private. The code might look like this:
+
+```quest
+if (player.isfemale and GetOuter("upper") = null) {
+ // Female player not decent if topless
+ return (false)
+}
+if (GetOuter("lower") = null) {
+ // Player not decent if nothing below the waist
+ return (false)
+}
+// Player decent
+return (true)
+```
+
+For exits from private locations to public locations, you need to set the exit to run a script, and have that check `IsDecent`:
+
+```quest
+if (IsDecent()) {
+ player.parent = this.to
+}
+else {
+ msg ("You can't go out there looking like that!")
+}
+```
+
+Finally, you need to [override](/advanced-topics/overriding) the `TestGarment` function.
+
+```quest
+if (GetBoolean(player.parent, "private")) {
+ // Not a public area, so player can remove what he or she likes
+ return (true)
+}
+// Hypothetically, what would it be like without the item?
+object.worn = false
+if (IsDecent()) {
+ // Player would be decent, reset the flag and return true
+ object.worn = true
+ return (true)
+}
+else {
+ // Player would not be decent, give message and return false
+ msg ("You can't take that off!")
+ return (false)
+}
+```
+
+
+### Support for NPCs
+
+The library offers some support for having NPCs wearing clothing. Clothing worn by the player can be worn by NPCs too.
+```quest
+// Get an object list containing garments worn by the NPC
+ListWornFor (char)
+
+// Get an object list containing garments worn by the NPC
+// and visible. Garments without wear_slots assigned
+// will not be listed
+ListVisibleFor (char)
+
+// Gets the outermost garment worn by the NPC in the given
+// slot, or null if there is none.
+GetOuterFor (char, slot)
+
+// As GetArmour, but for the specified NPC
+GetArmourFor (char)
+```
+
+Note that `ListVisibleFor` has some limitations, as it can only guess at what is visible. If a girl is wearing tights under shorts and shoes, and you are using feet, lower, upper and head as locations, the tights will not be considered visible. Using more body locations will solve this issue; however you need to think about this as early as possible! See-through garments are not supported.
+
+
+
+### Armour
+
+Quest gives some facilities to handle armour. If you want to use the default armour system, then the body locations you use must be:
+
+ feet, legs, shoulders, arms, hands, head, torso
+
+You can set the protection for each garment the player is wearing. The function `GetArmour` will calculate the total protection worn. This is calculated by checking the protection for each location (if more than one item is worn in a location, the highest is used plus half the rest). Values for each location are added together, with head counting double and torso counting three times. If the protection value of each item can be from 0 to 5, the result from `GetArmour` can range from 0 to 100.
+
+What you do with that is up to you!
+
+The `UpdateArmour` function is called when the player puts on or removes a garment. By default it does nothing, but you can override it to update a status display or to set an attribute on the player with the current armour.
+
+
+
+
diff --git a/site/src/content/docs/important_attributes.md b/site/src/content/docs/important_attributes.md
new file mode 100644
index 000000000..550f193ee
--- /dev/null
+++ b/site/src/content/docs/important_attributes.md
@@ -0,0 +1,149 @@
+---
+title: Important attributes
+sidebar:
+ order: 2
+---
+
+Attributes are what make objects (including rooms) in Quest what they are and do what they do. Where an object is, where it can be picked up, what it looks like, whether it can be locked or worn or eaten are all handled with attributes. Furthermore, attributes are the only things that change as the game progresses.
+
+You can add your own attributes to an object, but this page is about the built-in attributes that may be important as you craft your game.
+
+
+Quest has a number of ways of naming things...
+
+
+## The name attribute
+
+Everything in Quest is identified by its name attribute; effectively this is the ID of the database record. This means everything must have a name (they are some things that get names automatically, such as exits), and each must be unique.
+
+The name is also the way to reference the object in code, and this means there are certain characters that cannot be used, including most punctuation. The name attribute can contain spaces, but not at the start or end, and it cannot contain consecutive spaces. Some people do not like spaces in names, as it looks weird in code if you are familiar with programming. It can contain digits, but not start with a digit. It can contain underscores. It can also contain upper and lower case letters. Note that when you later refer to an object by its name, the name is case-sensitive.
+
+It is good practice to have a consistent naming policy, for example, always using lower case. That will make it easier to remember what you called it later.
+
+The name attribute is the only one of these attribute that is required; the rest are optional.
+
+
+## The alias attribute (and others)
+
+The alias attribute is what the player will see when Quest mentions the object, for example in a list of objects present in the room. If it does not exist, the name attribute is used instead. Similarly, when matching objects in a command the player has typed, Quest will use the alias attribute if it exists, and the name attribute otherwise (if there is an alias attribute, it will not even attempt to match with the name).
+
+The alias attribute can contain any characters you like, so you could use this for items that have punctuation in their title, such as "Dave's ball".
+
+
+### The listalias attribute
+
+The listalias attribute is a string, what the player will see in the _Inventory_ and _Places and Objects_ lists on the right. It can be set on the _Object_ tab. If it does not exist, Quest will use the attribute alias instead, and if that does not exist, the name attribute.
+
+Like the alias attribute, this can contain any characters.
+
+The listattribute is useful if you want to capitalise objects in the inventory pane, but not in the room descriptions. You can also include HTML codes in the listalias attribute to control how the item is displayed in the pane (if you do this for the alias attribute, the HTML codes will appear in the room descriptions too).
+
+```xml
+Ball
+```
+
+### The alt attribute
+
+The alt attribute is a string list of alternative names, and again is found on the _Object_ tab. Quest will use this list, in addition to alias or name, when trying to match an object to a command the player has typed. This is where you type all the synonyms for the object.
+
+Quest will match bits of names, so if you have "Dave's ball" as the alias, and "blue orb" in the alt list, it will match all these:
+
+> X BALL
+
+> X DAVE
+
+> X BLUE
+
+> X BLUE ORB
+
+If there is also someone called "dave" in the room, it will match that in preference to his ball for X DAVE.
+
+You can also use the alt list in a text processor command. The `rndalt' command will pick an entry in the alt list at random.
+
+```
+You see a {rndalt:ball}
+```
+
+
+## The parent attribute
+
+The parent attribute is an object that determines where a thing is. If the parent is a room, then the object is in that room. If it is the player, then the player is carrying it. If the parent is a container, then the object is in the container.
+
+The parent attribute also determines how an object is displayed in the editor. This means that an object in a room in the editor will also be in that room at the start of the game.
+
+Use the tree's "Move to..." option (in its "..." menu, or the "Move" button towards the top right) to change an object's parent. For any object you want the player to have at the start, for instance, just move them to the player.
+
+Objects can and will change there parent as the game is played. When the player picks up and then drops an item, its parent will change to the player, and then to the room. When the player moves to a different room, the parent attribute of the player changes to the new room (so the parent attribute of the player is the current room).
+
+You can change the parent attribute directly in code, or use the helper functions. All three of these will move the object `ball` to the current room:
+
+```quest
+ball.parent = player.parent
+MoveObject (ball, player.parent)
+MoveObjectHere (ball)
+```
+
+The `RemoveObject` function clears the parent attribute (sets it to `null`); for an item, this means it exits in limbo, rather than in a room, so the player can never get at it.
+
+Even commands and turnscripts can have parent attributes. If they do, they will only apply when the player is inside that room.
+
+
+## The look and description attributes
+
+These are the descriptions the player will see. The "look" attribute is used when the player examines an object. The "description" is seen when the player enters a room. Both can be a string or a script.
+
+
+
+
+## The visible and scenery attributes
+
+These can be set on the first tab, both are flags (Booleans) and both apply to exits as well as objects.
+
+If an object is not visible, then effectively it does not exist. The player cannot see it and cannot interact with it.
+
+This then is a good way to keep objects "off-stage". Have the object in the room already, but with visible set to false (untick the box). When the player does whatever it is, set the object be visible, and suddenly it will be there. This can be especially useful for exits (say a hole in the wall that suddenly appears, or conversely set an exit to be invisible if it is blocked).
+
+The scenery attribute can also be used to hide objects - but in this case your should set scenery to true (tick the box). An item that is scenery cannot be seen in the list of object for the room, but the player can still interact with it. It can be examined, picked up (depending on setting on the _Inventory_ tab), etc. This is best used for objects that are mention in the room description, so the player knows they exist, and so might want to interact with them.
+
+The scenery attribute is set to false when an object is picked up. This means that if the player drops it in another room, it will appear in the list of objects in that room. If the object is picked up, it will be odd if it is still in the room description; you can use the text processor to handle that, using the scenery attribute.
+
+For example:
+
+> This is an empty room, dominated by a marble fireplace{if ornament.scenery:, with a bizarre ornament on the mantelpiece}.
+
+The player will see:
+
+> This is an empty room, dominated by a marble fireplace, with a bizarre ornament on the mantelpiece.
+
+However, when the ornament is picked up, scenery will be set to false, and thereafter the player will see:
+
+> This is an empty room, dominated by a marble fireplace.
+
+Setting an exit to scenery can be a good idea if there are two ways it could be used. If there are stairs going down to the east, you would want to have both EAST and DOWN going to the same destination, but you might want to set one to scenery so only one appears on the compass rose (otherwise the player might think there are exits to two different locations).
+
+
+## The visited attribute
+
+The visited attribute gets set to true when the player visits a room. This is how Quest tracks whether to use the scripts that only happen when the player first visits a room, but can also be used by your own scripts to track the player's progress.
+
+
+
+## The to attribute
+
+The to attribute of an exit is an object - where the exit goes to (the parent attribute is when it comes from, of course).
+
+
+
+## The locked and isopen attributes
+
+Obviously these determine if something is locked or open (the "open" verb uses the "open" attribute, so Quest had to use "isopen" instead). Exits can be locked; containers can be opened or locked, an item that is openable/closeable can be opened.
+
+Note that Quest will handle the setting of "isopen" for a container. However, for openable/closeable items, it is up to you to provide a script that will do that (this is to allow you to check if the item can be opens first). If you set a container to be lockable on the _Container_ tab, Quest will likewise handle the lockable attribute for you.
+
+
+
+## Various verb attributes
+
+Several attributes are used by Quest to determine how the object will respond to commands:
+
+> take, drop, use, open, close, lock, unlock
\ No newline at end of file
diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx
index 5115747cb..7e7e7fe1e 100644
--- a/site/src/content/docs/index.mdx
+++ b/site/src/content/docs/index.mdx
@@ -15,7 +15,7 @@ hero:
icon: right-arrow
variant: primary
- text: Read the Docs
- link: /guides/introduction
+ link: /intro
icon: open-book
variant: secondary
- text: Download
diff --git a/site/src/content/docs/guides/introduction.md b/site/src/content/docs/intro.md
similarity index 72%
rename from site/src/content/docs/guides/introduction.md
rename to site/src/content/docs/intro.md
index b9977c908..401552357 100644
--- a/site/src/content/docs/guides/introduction.md
+++ b/site/src/content/docs/intro.md
@@ -22,4 +22,8 @@ Because there's [another system](https://github.com/ThePix/QuestJS) already call
## Where's all the documentation?
-The proper Quest Viva documentation is a work in progress - for now, the [Quest 5 documentation](https://docs.textadventures.co.uk/quest/) is the place to look. Quest Viva 6 is pretty much "a modern version of Quest 5" so the way you build a game is pretty much the same. The docs will be migrated to this site soon.
\ No newline at end of file
+The [Quest Overview](/overview) is a good place to start, followed by the [Tutorial](/tutorial/tutorial_introduction). Quest Viva is pretty much "a modern version of Quest 5" under the hood, so almost everything in these docs applies equally to both.
+
+## Getting help
+
+Quest is discussed on [Discord](https://textadventures.co.uk/community/discord) and [GitHub Discussions](https://github.com/textadventures/quest/discussions). If you find a bug or want to request a feature, [open an issue](https://github.com/textadventures/quest/issues).
\ No newline at end of file
diff --git a/site/src/content/docs/js/index.md b/site/src/content/docs/js/index.md
new file mode 100644
index 000000000..9312e6e34
--- /dev/null
+++ b/site/src/content/docs/js/index.md
@@ -0,0 +1,383 @@
+---
+title: JS functions
+sidebar:
+ order: 27
+---
+
+The `JS` object is how Quest exposes the user interface. What this means is that we can use the JS object to call JavaScript functions that will modify what the player sees. The basic format is to append the JavaScript function name with a dot, so to call `addText` (the JavaScript function Quest uses to show text on the screen), use something like this:
+
+```quest
+JS.addText("You are in a deep hole.")
+```
+
+## addExternalStylesheet
+
+```quest
+JS.addExternalStylesheet (string url)
+```
+
+Adds a ` ` for the given URL to the page, loading an external CSS file.
+
+## addScript
+
+```quest
+JS.addScript (string text)
+```
+
+Inserts the text into the HTML document. This can be used for adding JavaScript or CSS, or for adding HTML that will be out of the normal sequence, such as a custom pane or dialogue panel. Use [addText](#addtext) for game text.
+
+```xml
+JS.addScript("")
+```
+
+## addText
+
+```quest
+JS.addText (string text)
+```
+
+Inserts the given text into the page. This is how `msg` displays text. Use [addScript](#addscript) to add code, such as CSS or JavaScript, or to add HTML outside the normal text flow.
+
+## AddYouTube
+
+```quest
+JS.AddYouTube (string id)
+```
+
+Embeds an autoplaying YouTube video for the given video ID. See [Adding videos](/howto/multimedia/adding_videos).
+
+## colourBlend
+
+```quest
+JS.colourBlend (string colour1, string colour2)
+```
+
+Sets a colour blend as the background, going from colour1 at the top to colour2 at the bottom.
+
+```quest
+JS.colourBlend("red", "#ff0080")
+```
+
+## eval
+
+```quest
+JS.eval (string JavaScript code)
+```
+
+Causes the given string to be evaluated by the JavaScript engine. This is a way to run any JavaScript code in your game, which can be used to move around the components of the UI or add new ones, among other things.
+
+## Grid_ClearAllLayers
+
+```quest
+JS.Grid_ClearAllLayers ()
+```
+
+Clears everything drawn on the map grid - rooms and any custom layers. Used when resetting the map entirely, e.g. when the player teleports to an unconnected region. See [Showing a map](/howto/tasks/showing_a_map).
+
+## hideBorder
+
+```quest
+JS.hideBorder ()
+```
+
+Removes the border around the game area.
+
+## panesVisible
+
+```quest
+JS.panesVisible (boolean visible)
+```
+
+Shows or hides the panes on the right of the screen.
+
+```quest
+JS.panesVisible(false)
+```
+
+## scrollToEnd
+
+```quest
+JS.scrollToEnd ()
+```
+
+Moves the displayed text down to the bottom. This should happen automatically, but occasionally it is useful to be able to call it yourself from your game.
+
+## setBackground
+
+```quest
+JS.setBackground (string colour)
+```
+
+Sets the background colour of the game area.
+
+## setCommands
+
+```quest
+JS.setCommands(string commands, string colour)
+```
+
+Sets the commands to be displayed on the command pane (turn the command pane on on the _Interface_ script of the game object). Commands should be sent as a string, separated by semi-colons. The colour of the text can be specified, but is optional.
+
+```quest
+JS.setCommands("Wait;Look")
+JS.setCommands("Wait;Look;Get apple", "red")
+```
+
+## setCompassDirections
+
+```quest
+JS.setCompassDirections (string directions)
+```
+
+Takes a semicolon-separated list of names for the twelve compass directions - northwest, north, northeast, west, east, southwest, south, southeast, up, down, in, out, in that order - and uses them as the tooltip text for the compass buttons. These names also then stop appearing as exits in the "Places and Objects" list.
+
+```quest
+JS.setCompassDirections("northwest;north;northeast;west;east;southwest;south;southeast;up;down;in;out")
+```
+
+The compass directions must be specified in the same order and with the same number of elements as the default shown above. The exit in the compass rose is only active if the alias of the exit matches the text set here.
+
+## setCss
+
+```quest
+JS.setCss (string element name, string css styling)
+```
+
+Sets the CSS styling for the given element. If the element name is for an ID, this should be prefixed with a #. CSS styling should be given as a serious of name-value pairs, each pair separated by a semi-colon, with a colon between the name and the value.
+
+This example sets the `` element to have the "serif" font.
+
+```quest
+JS.setCss ("body", "font-family: serif")
+```
+
+This example sets styling for the element with the ID "qv-status" (the strip across the top of the screen). It sets two properties, the background image and background colour.
+
+```quest
+JS.setCss ("#qv-status", "background-image:none; background-color: green;")
+```
+
+## setCustomStatus
+
+```quest
+JS.setCustomStatus(string html)
+```
+
+Sets the HTML text to be displayed on the custom status pane (turn the command pane on on the _Interface_ script of the game object). This is an involved issue, rather than give an example, go see this [page](/howto/ux/custom_panes).
+
+## setGameName
+
+```quest
+JS.setGameName (string name)
+```
+
+Sets the name of the game, shown in the browser tab title.
+
+```quest
+JS.setGameName("My Cool Game")
+```
+
+## setGamePadding
+
+```quest
+JS.setGamePadding (string top, string bottom, string left, string right)
+```
+
+Sets the padding (CSS values, e.g. `"10px"`) around the game text.
+
+## setGameWidth
+
+```quest
+JS.setGameWidth (int width)
+```
+
+Sets the maximum width, in pixels, of the game area.
+
+## setInterfaceString
+
+```quest
+JS.setInterfaceString(string name, string value)
+```
+
+Use this to set the text of the various elements of the user interface. The values allowed for the name are:
+
+> InventoryLabel, StatusLabel, PlacesObjectsLabel, CompassLabel
+> InButtonLabel, OutButtonLabel
+> EmptyListLabel, NothingSelectedLabel, TypeHereLabel, ContinueLabel
+
+For example, to change the name of the player inventory:
+
+```quest
+JS.setInterfaceString("InventoryLabel", "You are holding")
+```
+
+## SetMenuBackground
+
+```quest
+JS.SetMenuBackground (string colour)
+```
+
+Sets the background colour of the popup menu shown when the player clicks a hyperlink in the text.
+
+## SetMenuFontName
+
+```quest
+JS.SetMenuFontName (string fontName)
+```
+
+Sets the font used in the hyperlink popup menu.
+
+## SetMenuFontSize
+
+```quest
+JS.SetMenuFontSize (string size)
+```
+
+Sets the font size used in the hyperlink popup menu. The size must be given as a number followed by `"pt"`, e.g. `"14pt"`.
+
+## SetMenuForeground
+
+```quest
+JS.SetMenuForeground (string colour)
+```
+
+Sets the text colour of the hyperlink popup menu.
+
+## SetMenuHoverBackground
+
+```quest
+JS.SetMenuHoverBackground (string colour)
+```
+
+Sets the background colour of a hyperlink popup menu item when hovered over.
+
+## SetMenuHoverForeground
+
+```quest
+JS.SetMenuHoverForeground (string colour)
+```
+
+Sets the text colour of a hyperlink popup menu item when hovered over.
+
+## setPanes
+
+```quest
+JS.setPanes (string text, string background)
+JS.setPanes (string text, string background, string text2, string background2)
+JS.setPanes (string text, string background, string text2, string background2, string highlight)
+```
+
+Sets the colours for the panes on the right. You can use either two, four or five parameters.
+
+The text will be in `fore`, whilst the background will be in `back`. When an object is selected, it will be in `secFore` on a `secBack` background, if given, otherwise it will be `back` on a `fore` background (i.e., reversed colours). When a player is clicking on an object, the background will be the `highlight` colour, or orange if not given.
+
+```quest
+JS.setPanes("black", "white")
+JS.setPanes("black", "white", "white", "#444")
+JS.setPanes("black", "white", "white", "#444", blue)
+```
+
+## ShowGrid
+
+```quest
+JS.ShowGrid (int height)
+```
+
+Sets the height for the grid map. Setting this to zero turns the map off, setting it to any other values turns it on.
+
+## showPopup
+
+```quest
+JS.showPopup(title, text)
+```
+
+Shows a pop up, with an okay button, which the player can click to close. This version has a fixed width (of 300 px when I checked), and the height will expand up to the full Quest windows size to accommodate the text.
+
+```quest
+JS.showPopup("Hi!", "This is where it all begins")
+```
+
+## showPopupCustomSize
+
+```quest
+JS.showPopupCustomSize(title, text, int width, int height)
+```
+
+As [showPopup](#showpopup), but allows for custom width and height to be set; scrollbars will be added if the text is too long.
+
+## showPopupFullscreen
+
+```quest
+JS.showPopupFullscreen(title, text)
+```
+
+As [showPopup](#showpopup), but will fill the Quest window (so the size will depend on how the player has it set up).
+
+## showStatusVisible
+
+```quest
+JS.showStatusVisible (boolean visible)
+```
+
+Shows or hides the status variables pane (see [status attributes](/status_attributes)).
+
+## TurnOffHyperlinksUnderline
+
+```quest
+JS.TurnOffHyperlinksUnderline ()
+```
+
+Removes the underline from in-text command hyperlinks.
+
+## uiHide
+
+```quest
+JS.uiHide(string element)
+```
+
+Hides the given element the same way (see [uiShow](#uishow) for the available selectors). `#gamePanes` is special-cased to behave the same as `panesVisible(false)`.
+
+```quest
+JS.uiHide("#compassLabel")
+JS.uiHide("#compassAccordion")
+```
+
+## uiShow
+
+```quest
+JS.uiShow(string element)
+```
+
+Shows the given element - any CSS selector works. `#gamePanes` is special-cased to behave the same as `panesVisible(true)`.
+
+```quest
+JS.uiShow("#gamePanes")
+JS.uiShow("#location")
+JS.uiShow("#txtCommandDiv")
+```
+
+You can also selectively show or hide one pane (if game panes are shown). Each pane has two components, so to show/hide the compass: `#compassLabel` and `#compassAccordion`. For the inventory, use `#inventoryLabel` and `#inventoryAccordion`; for the places and objects pane, `#placesObjectsLabel` and `#placesObjectsAccordion`. For the custom status pane and the custom command pane, use `#customStatusPane` and `#commandPane` respectively (these have only one part).
+
+## updateLocation
+
+```quest
+JS.updateLocation(string text)
+```
+
+Changes the location in the location bar at the top.
+
+```quest
+JS.updateLocation("Dining Room")
+```
+
+## updateStatus
+
+```quest
+JS.updateStatus(string text)
+```
+
+Puts the given text into the status pane on the right. This should be formatted in HTML; for example, use to indicate a new line.
+
+```xml
+JS.updateStatus("Money: $45 Health: 23")
+```
diff --git a/site/src/content/docs/notes.md b/site/src/content/docs/notes.md
new file mode 100644
index 000000000..d73b1571e
--- /dev/null
+++ b/site/src/content/docs/notes.md
@@ -0,0 +1,21 @@
+---
+title: Mutable attributes on inherited types
+---
+
+## Mutable attributes on inherited types
+
+When you inherit an attribute from a type, the type's attributes are not copied, just pointed to. For most attribute types there's no problem with this, but lists and dictionaries are mutable - i.e. they can be changed by commands such as [list add](/scripts#list-add), but you're still pointing to the same list.
+
+If type "MyType" has a list attribute "TypeList", and object "MyObject" inherits from MyType, then we potentially have a problem if we call "list add (MyObject.TypeList, value)", as we would then change the list on the underlying type - affecting all other objects that inherit from it.
+
+To prevent this, mutable attributes which are defined on types are *locked*. If you try to call the "list add" command in the example above, an error will be raised.
+
+You can get around the problem by cloning the list or dictionary first. Quest automatically clones on assignment. This means you can write objectA.list = objectB.list, and objectA actually gets a *clone* of objectB's list, so you can change objectA's list without affecting objectB.
+
+The same principle works for cloning an attribute defined on an underlying type - in our example above, we can clone the TypeList attribute first using this:
+
+```quest
+MyObject.TypeList = MyObject.TypeList
+```
+
+Yep, it looks like assigning to the same thing. But we've made use of the fact that assignment clones mutable types to give MyObject an editable version of TypeList.
diff --git a/site/src/content/docs/other_guides/a_hint_system.md b/site/src/content/docs/other_guides/a_hint_system.md
new file mode 100644
index 000000000..57fe1d2d9
--- /dev/null
+++ b/site/src/content/docs/other_guides/a_hint_system.md
@@ -0,0 +1,100 @@
+---
+title: A hint system
+sidebar:
+ order: 8
+---
+
+Adding a hint system will allow more players to get to the end of your game, and so see more of your brilliant creation. but only if it works properly! So let us see how to do just that...
+
+## The HINT command
+
+First you need a HINT command, so right click on the left pane, and select Add Command. Give it a pattern, "hint;clue" and a name, say "Hint" (the name does not matter, the pattern does). In the script section, click on the seventh icon, code view, and paste the code in:
+
+```quest
+if (HasScript (game.pov.parent, "hint")) {
+ game.pov.parent.hintflag = false
+ do (game.pov.parent, "hint")
+}
+if (not GetBoolean(game.pov.parent, "hintflag")) {
+ flag = false
+ foreach (hint, GetDirectChildren (hints)) {
+ if (not flag) {
+ if (not GetBoolean(hint, "passed")) {
+ flag = true
+ hint.done = true
+ if (HasScript (hint, "look")) {
+ do (hint, "look")
+ }
+ if (HasString (hint, "look")) {
+ msg (hint.look)
+ }
+ }
+ }
+ }
+ if (not flag) {
+ msg ("Sorry, no more clues")
+ }
+}
+```
+
+## Hint set-up
+
+Create a room called "hints". This should have no exits and should not be accessible to the player.
+
+For each hint, create an object inside the "hints" room, and give it a "look at" description. These need to be in the order the player will need them (to change the order, you can go to the Objects tab of the "hints" room).
+
+At each stage-gate, set the "passed" attribute of the appropriate hint to true.
+
+Note: Do not put anything else in the hints object - it will be taken as a hint
+
+## Naming hints
+
+Personally I like to prefix hints with h\_, so the names are unique and it is obvious what it is.
+
+
+## Stage-gates?
+
+So what are stage-gates? A stage-gate is where the player goes from one stage of the game, where one hint is relevant, to the next, where the next hint applies. Presumably that first hint indicates how to achieve that. It could be killing the goblin, getting to a certain room, unlocking a door, finding a vital item.
+
+
+## Progressive hints
+
+You can set up a series of hints for one stage gate, so that each time the player types HINT he gets a more obvious clue. To set this up, you obviously need a hint object for each hint. Each of these hints except the last should have a "look at" *script*, rather than text, and in the script, as well as displaying a message, should set its "passed" value to true.
+
+The script might look like this:
+
+```quest
+msg ("Now you have the key, what can you do with it?")
+this.passed = true
+```
+
+In addition, the stage-gate itself must set the "passed" value to true for all the hints.
+
+```quest
+msg ("You unlock the door")
+h_unlock1.passed = true
+h_unlock2.passed = true
+exit_to_room3.locked = false
+
+```
+## Local hints
+
+You can have local hints. If a room has a script called "hint" this will be run. If the script sets the attribute hintflag on the room to true, then no other hints are given. The script should test a condition - is the hint still relevant? If the condition is met, a hint is given, and the hintflag set. If the condition is not met, the flag is not set and the standard hint system comes into play.
+
+Here is an example:
+
+```quest
+if (exit_to_room3.locked) {
+ msg ("Try unlocking the door in room2")
+ this.hintflag = true
+}
+```
+
+If the player is in the room, and types HINT this script will run. If the door is locked, an appropriate message is displayed, and hintflag is set to stop any further processing of the hint system. If the door is not locked, the hint is not relevant. No message is displayed, and as hintflag is not set, the normal hint system comes into play.
+
+This would be useful in rooms off the main quest. Player can get a hint to solve the puzzle when in the room. However, if that puzzle is solved, the player gets a hint towards the main quest as usual.
+
+
+## Testing
+
+Do make sure that each and every hint works. It is easy to spell the hint name one way in one place and another elsewhere. The game will only throw an error when it comes to display it.
\ No newline at end of file
diff --git a/site/src/content/docs/other_guides/community_guides.md b/site/src/content/docs/other_guides/community_guides.md
new file mode 100644
index 000000000..0e979e2b6
--- /dev/null
+++ b/site/src/content/docs/other_guides/community_guides.md
@@ -0,0 +1,49 @@
+---
+title: Community recipes
+sidebar:
+ order: 4
+---
+
+This page is for "how to" guides which don't fit in to the tutorial (and may be out of date).
+
+
+
+
+## Simple
+
+Easy to implement using only the GUI.
+
+- [Time-limited puzzles](/other_guides/timelimitedpuzzles)
+- [Security code to unlock door](/other_guides/unlockdoor)
+- [Starting inventory](/other_guides/starting_inventory)
+- [Implementing components of an object](/other_guides/implementing_components_of_an_object)
+
+
+## Basic
+
+Be prepared to see code - but not write it. Most of these have blocks of code, but do not let that put you off; it is pretty easy to copy-and-paste a chunk of code straight into your game, then go back to the GUI view to look at it or change it.
+
+- [Character Creation](/howto/rpg/character_creation)
+- [Immobilise the player](/other_guides/immobilise_the_player)
+- [Use InvisiClues for Help](/other_guides/invisiclues)
+- [Port and starboard](/other_guides/port_and_starboard)
+
+
+## Advanced
+For those happy to use code.
+
+- [A Hint System](/other_guides/a_hint_system)
+- [Turn-based events](/other_guides/turn_based_events)
+- [How to use functions](/howto/tasks/about_functions)
+- [Random default messages](/other_guides/random_default_answers)
+- [Hyperlinks](/other_guides/hyperlinks)
+
+
+
+
+## See also
+
+More guides can be found here:
+
+[https://github.com/ThePix/quest/wiki](https://github.com/ThePix/quest/wiki)
+
diff --git a/site/src/content/docs/other_guides/hyperlinks.md b/site/src/content/docs/other_guides/hyperlinks.md
new file mode 100644
index 000000000..00e505893
--- /dev/null
+++ b/site/src/content/docs/other_guides/hyperlinks.md
@@ -0,0 +1,31 @@
+---
+title: Hyperlinks
+---
+
+Quest lets you embed clickable links directly in text, using the same `{...}` syntax as the rest of the [text processor](/howto/world/text_processor). There are three different kinds, and if you set up a room description to run a script, and have it print the following as a message, you can see all three:
+
+```xml
+Here is some text with an anchor link ,
+a link for {object:torch:the torch}
+and a link for a {command:wait:wait a moment}.
+```
+
+When the room description is displayed, there are three links. Clicking on the first will open up that web page in your browser, and is exactly the same as a link on a web page (so, yes, the HTML tag that lets you move to another web page is `a` for anchor, a nautical device for keeping you from moving somewhere else).
+
+Click on the second (the `{object:...}` link), and Quest gives a choice of verbs for the named object - whichever verbs apply to it (Look at, Take, and so on) - and when one is selected, it is sent as a command.
+
+Click on the third (the `{command:...}` link), and Quest sends the given command straight to the parser - there is no choice of verbs, it always sends the same thing.
+
+In fact, you can put any command together like this; say there is a command that recognises "jump up and down", this will give a link to that: Here is some text with a link for {command:jump up and down:jump about}.
+
+## Related functions
+
+*GetDisplayNameLink (object, type)* Gets the name/alias of the object. If type is not the empty string and game.enablehyperlinks then this is wrapped up as a link, using the `{object:...}` syntax above, unless type is "exit" (and the object has exactly one verb), in which case the `{exit:...}` syntax is used. Prefixes and suffixes are also added as required, outside the link.
+
+*ObjectLink (object)* Returns `{object:objectname}` - the link text for the given object, as used above.
+
+*CommandLink (cmd, text)* Creates the text for a command link, using the `{command:...}` syntax above.
+
+*DisplayMailtoLink (text, email)* Creates an email link.
+
+*DisplayHttpLink (text, url, https)* Creates a link to another web page.
diff --git a/site/src/content/docs/other_guides/immobilise_the_player.md b/site/src/content/docs/other_guides/immobilise_the_player.md
new file mode 100644
index 000000000..52421d285
--- /dev/null
+++ b/site/src/content/docs/other_guides/immobilise_the_player.md
@@ -0,0 +1,75 @@
+---
+title: Immobilise the player
+sidebar:
+ order: 4
+---
+
+Occasionally you want to stop the player moving to another room, say because he is sat down, and he has to stand first, or he is tied up or whatever. There are three ways (at least) to do this:
+
+## Move to another room
+
+The first way is to move him to another room with no exits. This might not be appropriate in some cases, but suppose the player logs on to a computer, and while logged on, all commands relate to the computer. The player does not need to interact or even see other objects in the room with the computer, so effectively moving the player inside the computer is a good solution.
+
+
+## Block the exit
+
+If this is something that happens in one specific room with a small number of exits, then the easiest solution is to just lock the exits while the player is immobilised, unlock them afterwards.
+
+
+## Rewrite the go command
+
+This is a little more complicated, but more general. What you do is set a string on the player when he is immobilised. The GO command checks to see if this string exists, and if it does, rather than moving the player, it prints the string.
+
+The way to do this is to rewrite the GO command.
+
+Create a new command, and set the pattern to be a regular expression. In the text box below, paste in this string:
+
+```regex
+^go to (?.*)$|^go (?.*)$|^(?north|east|south|west|northeast|northwest|southeast|southwest|in|out|up|down|n|e|s|w|ne|nw|se|sw|o|u|d)$
+```
+
+Then paste in this code.
+
+```quest
+if (HasString (player, "immobilisedmessage")) {
+ Print (player.immobilisedmessage)
+}
+else if (exit.visible) {
+ if (exit.locked) {
+ msg (exit.lockmessage)
+ }
+ else if (exit.runscript) {
+ if (HasScript(exit, "script")) {
+ do (exit, "script")
+ }
+ }
+ else if (exit.lookonly) {
+ msg ("You can't go there.")
+ }
+ else {
+ if (HasString(exit, "message")) {
+ if (not exit.message = "") {
+ msg (exit.message)
+ }
+ }
+ game.pov.parent = exit.to
+ }
+}
+else {
+ msg ("You can't go there.")
+}
+```
+
+As it has the same pattern as the built-in GO command, this will get used instead. The only difference is that before doing anything else, the script checks for the "immobilisedmessage" string on the player. If it is there, it gets printed. If not it proceeds as normal.
+
+Now to immobilise the player, your code needs to do something like this:
+
+```quest
+player.immobilisedmessage = "Cannot move while seated."
+```
+
+To allow him to move again:
+
+```quest
+player.immobilisedmessage = null
+```
diff --git a/site/src/content/docs/other_guides/implementing_components_of_an_object.md b/site/src/content/docs/other_guides/implementing_components_of_an_object.md
new file mode 100644
index 000000000..1d26584d8
--- /dev/null
+++ b/site/src/content/docs/other_guides/implementing_components_of_an_object.md
@@ -0,0 +1,15 @@
+---
+title: Implementing components of an object
+---
+
+Occasionally you would like the player to be able to interact with a component of an object, for example, a machine with a button on it. The player has to be able to press the button. If the machine cannot be moved, you can just have the button as scenery in the room, but what do we do for objects that can be carried around?
+
+Quest actually has this facility built-in, though it may not be obvious.
+
+Create your object first, let us say it is called "machine". Create the component, "button", as a child of that object (right click on machine, select "Add object", and choose "machine" from the dropdown at the bottom; alternatively you can drag an object on to another). In the object hierarchy it should look like this on the left.
+
+
+
+The master object, in this case "machine" has to be set up as a type of container called a surface, as shown above (click Container on the features tab first). The component, you can have as many as you need, should be set up as scenery.
+
+That is all there is to it.
\ No newline at end of file
diff --git a/site/src/content/docs/other_guides/invisiclues.md b/site/src/content/docs/other_guides/invisiclues.md
new file mode 100644
index 000000000..b00e7a82b
--- /dev/null
+++ b/site/src/content/docs/other_guides/invisiclues.md
@@ -0,0 +1,57 @@
+---
+title: Help with InvisiClues
+sidebar:
+ order: 5
+---
+
+It can be helpful to the player if your game has a help system - something she can access in game to get past that puzzle that seemed so simple to you, but is fiendishly complicated for the player. But how to let the player see how to solve this puzzle, but not inadvertently see the solution for the whole game?
+
+Back at the dawn of time, Infocom came up with the idea of [InvisiClues](https://en.wikipedia.org/wiki/InvisiClues) - and now you can do that too, in a virtual way.
+
+
+
+To get this to work, you need to create a new HELP command. For the command pattern, just type in "help;?", and for the name, "help2" (no quotes for both), as Quest already has a HELP command, and will object if you give your command the same name.
+
+For the script, paste in this:
+
+```quest
+if (HasAttribute(game, "defaultbackground")) {
+ bg = LCase (game.defaultbackground)
+}
+else {
+ bg = "white"
+}
+msg ("Drag your mouse over the text to reveal only the clues you need.")
+foreach (key, game.helpdict) {
+ msg ("" + key + " [" + StringDictionaryItem(game.helpdict, key) + " ]")
+}
+```
+
+The first 6 lines just get the background colour, the seven line is obvious just a message to the player. The important part is the loop at the end.
+
+The `foreach` command loops over game.helpdict. This is a dictionary, which is kind of like a list, but with strings (conventionally called keys) instead of numbers. When you do `foreach` with a dictionary, you get the key, rather than the index.
+
+The penultimate line prints the InvisiClue. The trick is that it changes the font colour to match the background, so first the key is printed in italics, then the value from the dictionary is printed in the background colour.
+
+
+### The hints
+
+Once you have the command, you need to put in the data. Go to the _Attributes_ tab of the game object, and create a new attribute, helpdict. Set it to be a string dictionary, and then put in your questions and answers.
+
+Alternatively, you can do it in a script instead. Go to the _Scripts_ tab of the game object, and modify the start script.
+
+Click to add a new script, and select "Set a variable or attribute" from the list of scripts. in the first box type `helpdict`, then select "New string dictionary" from the drop down.
+
+Click to add a new script again, and select "Add a value to a dictionary. In the first box, put in `helpdict`. The two dropdowns should be set to "String". In the second box put the question, and in the third the answer. You need to do this for every hint you wish to add to your game.
+
+
+
+This is what the code looks like:
+
+```quest
+game.helpdict = NewStringDictionary()
+dictionary add (game.helpdict, "How do I go north?", "Open the door!")
+dictionary add (game.helpdict, "How do I open the door?", "Type OPEN DOOR!")
+dictionary add (game.helpdict, "How do I kill the bugbear?", "There are allergic to jam...")
+dictionary add (game.helpdict, "How do I kill the bugbear with jam?", "Perhaps you could give him a sandwich?")
+```
diff --git a/site/src/content/docs/other_guides/port_and_starboard.md b/site/src/content/docs/other_guides/port_and_starboard.md
new file mode 100644
index 000000000..4a1820893
--- /dev/null
+++ b/site/src/content/docs/other_guides/port_and_starboard.md
@@ -0,0 +1,104 @@
+---
+title: Port and starboard
+sidebar:
+ order: 7
+---
+
+So you have this plan for a game, but it is set on a ship or a starship, and north and south do not make any sense. The standard for marine ships is to use forward, starboard, aft and port, so why not implement that for your game? This is actually pretty easy to do using Quest's built-in language support.
+
+One limitation of the shipwise directions is that you lose four directions. While "northeast" is well established, I think people will find "forwardport" rather odd. Remember that when adding exits to your game!
+
+So what do we need to do? All that is needed is to change about a dozen templates. Each of the templates below already exists in the English language library, so the simplest way to change them is to [override](/advanced-topics/overriding) each one individually through the GUI. Alternatively, you can paste them straight into code view as shown below; template overrides are matched by name, so it does not matter exactly whereabouts in the file you add them.
+
+In code view, at the very top, it will look like this:
+
+```xml
+
+
+
+
+```
+
+All the default templates are in English.aslx, and we want to override them. A template you add directly to your own game file will always win over the version in English.aslx, wherever in the file you put it - dynamic templates work the same way. The one place order still matters is if you move your overrides out into a separate library file of their own (see below); that file needs to be included after English.aslx, so its version of each template is the one that ends up registered.
+
+So let us add some templates.
+
+This set changes the directions Quest uses in description when it says "You can go"
+
+```xml
+forward
+port
+starboard
+aft
+```
+
+Also need to change the abbreviated versions.
+
+```xml
+f
+p
+s
+a
+```
+
+Quest uses these next two for pattern matching the player input. The specific direction is matched against the templates above.
+
+```xml
+.*)$|^go (?.*)$|^(?forward|port|starboard|aft|f|p|a|s|in|out|up|down|o|u|d)$]]>
+forward|port|starboard|aft|f|p|a|s|in|out|up|down|o|u|d)$]]>
+```
+
+You also need to change the help command. One way is to override the DefaultHelp template (which is not shown here, as it is a lot of text), but you might prefer to create your own help command and do it there. It may be useful to spell out the directions as people are somewhat less familiar with ship directions, and to point out they only have four directions instead of eight.
+
+So what does it look like now? The top of your code should now look like this (this is without the help template):
+
+```xml
+
+
+
+ forward
+ port
+ starboard
+ aft
+ f
+ p
+ s
+ a
+ .*)$|^go (?.*)$|^(?forward|port|starboard|aft|f|p|a|s|in|out|up|down|o|u|d)$]]>
+ forward|port|starboard|aft|f|p|a|s|in|out|up|down|o|u|d)$]]>
+
+```
+
+Wouldn't this be easier in a library?
+
+Well, go on then.
+
+[ShipwiseLib.aslx](https://raw.githubusercontent.com/ThePix/quest/refs/heads/master/ShipwiseLib.aslx)
+
+Save this file to your game's folder, and modify the code at the start of the file to this:
+
+```xml
+
+
+
+
+
+```
+
+One last note. After adding new templates, or a library with templates, you need to save the game, quit Quest, then open it up again to get the templates loaded up properly.
+
+* * * * *
+
+It might be a good idea to implement a command so your game responds to NORTH, EAST, etc., explaining the system. Here is an example of such a command, but you will probably want to tailor it to your game and style.
+
+```quest
+
+ w;e;s;n;se;ne;sw;ne;west;south;east;north;northeast;southeast;northwest;southwest
+
+
+```
+
+Note that this will not work if included in the library above. I suspect it has to be after the core library is loaded. Put it in the main file or a library that appears after Core.aslx in the list.
diff --git a/site/src/content/docs/other_guides/random_default_answers.md b/site/src/content/docs/other_guides/random_default_answers.md
new file mode 100644
index 000000000..712601c0b
--- /dev/null
+++ b/site/src/content/docs/other_guides/random_default_answers.md
@@ -0,0 +1,28 @@
+---
+title: Random default answers
+sidebar:
+ order: 6
+---
+
+The default answer of a command is defined in the language file. So if you want to change this text, you can copy the template(s) with the name “Default…” that you wish to modify into your game as described [here](/advanced-topics/overriding).
+
+
+```xml
+"You can't hit " + object.article + "."
+
+```
+
+## Random default answers
+
+If you want to return more than one default message you have to define additional dynamictemplates and call them from the main-template with the function [DynamicTemplate](/functions/string#dynamictemplate)
+
+**example for kill-command:**
+
+```xml
+DynamicTemplate ("DefaultKill" + ToString (GetRandomInt (1,3)) , object)
+"This would not be nice."
+"No, you won't do this."
+"You can't kill " + object.article + "."
+```
+
+So if you want to add two more answers you have to add the dynamictemplates with the name **DefaultKill4** and **DefaultKill5** and change the upper bound of the `GetRandomInt` function from 3 to 5
diff --git a/site/src/content/docs/other_guides/starting_inventory.md b/site/src/content/docs/other_guides/starting_inventory.md
new file mode 100644
index 000000000..8d399449f
--- /dev/null
+++ b/site/src/content/docs/other_guides/starting_inventory.md
@@ -0,0 +1,11 @@
+---
+title: Starting inventory
+sidebar:
+ order: 3
+---
+
+To have the player start with things in the inventory, simply drag the object in the Editor to the "player" object in the hierarchy, so the object is shown as a child of the player.
+
+Alternatively, in the game start script, use the "move object" command to move the object to the player.
+
+
diff --git a/site/src/content/docs/other_guides/timelimitedpuzzles.md b/site/src/content/docs/other_guides/timelimitedpuzzles.md
new file mode 100644
index 000000000..1d045034f
--- /dev/null
+++ b/site/src/content/docs/other_guides/timelimitedpuzzles.md
@@ -0,0 +1,37 @@
+---
+title: Time-limited puzzles
+sidebar:
+ order: 1
+---
+
+*This tutorial was originally published on the textadventures.co.uk blog.*
+
+When I was at Perins School last week, I was asked about puzzles with a time limit. For example, the player opens a cupboard, inside which is a hungry alien. How do you give the player 10 seconds to kill the alien, before the alien kills them instead?
+
+This is pretty straightforward to handle, because in Quest you can run scripts after a certain number of seconds. Here’s a quick how-to:
+
+First, add the cupboard and alien objects. The alien should be inside the cupboard. For the cupboard, go to the Container tab. Choose “Container” from the type list, and untick the “Is open” box so that the cupboard is closed when the game begins.
+
+
+
+Now we want to run a script when the player opens the object. We’ll tell the player they’ve surprised the sleeping (and hungry) alien, then give them 10 seconds to get rid of the alien before it kills them. To do this, scroll down to “After opening the object”, and add a “Print a message” script.
+
+
+
+Next, add another script – from the Timers section, choose “Run a script after a number of seconds”.
+
+
+
+You can now specify how many seconds to wait before something else happens. In this case, 10 seconds. After 10 seconds, we want to see if the “alien” object is still visible. If so, print a message and kill the player. If not, we don’t need to do anything.
+
+So, all we need to do is add an “If” inside the “After 10 seconds” script, as shown below:
+
+
+
+Finally, we just need to implement a way to solve the puzzle. Let’s add a flame thrower object. When the player uses the flame thrower on the alien, the alien bursts into flames.
+
+Add an object called “flame thrower”, then on the “Use/Give” tab scroll down to “Use this on (other object)”. Select “Handle objects individually”, add “alien”, and then edit the script. Add a “print a message” command to say something to the player, then add a “Remove object” command to remove the alien from play.
+
+The resulting script looks like this:
+
+
diff --git a/site/src/content/docs/other_guides/turn_based_events.md b/site/src/content/docs/other_guides/turn_based_events.md
new file mode 100644
index 000000000..80a4c9098
--- /dev/null
+++ b/site/src/content/docs/other_guides/turn_based_events.md
@@ -0,0 +1,185 @@
+---
+title: Turn-based events
+sidebar:
+ order: 9
+---
+
+If your game is only responding to what the player does, it feels dead. Bring it alive by having events occur that are not simply reacting to the player's actions (even if they are reacting to what he did four turns ago, it will still feel like it is not).
+
+Here then is a simple framework for turn-based events.
+
+## Set up the framework
+
+1. Set an int attribute on game called "turn".
+
+2. Create two dummy rooms, "active\_events" and "dead\_events". They should have no exits and should not be accessible by the player.
+
+3. Copy this turnscript and type into your game code. Insert it at the end, just before the last line (which will be " ")
+
+
+
+
+
+
+
+ false
+
+ -1
+
+ msg ("TODO")
+
+
+
+## Set up events
+
+Each event needs to be an object in one of the rooms, "active\_events" and "dead\_events". The room "active\_events" is for events that are counting down, while "dead\_events" is for events that have expired or are waiting in the wings to be used.
+
+For each event, set it to inherit "event\_type". You also need to set the "action" attribute; this is the script that will get run.
+
+For events in "active\_events", you need to set the "turn" attribute. The event will be fired in that number of turns, and will then get moved to the "dead\_events" room.
+
+For events in "dead\_events", you need to set up something to start them off. This needs to move the event into the "active\_events", and to set the "turn" attribute to game.turn plus the number of turns to wait. It might look something like this, when the event, event3, is set to occur in 2 turns:
+
+```quest
+event3.turn = 2 + game.turn
+event3.parent = active_events
+```
+
+## Chaining events
+
+You can set up one event to start the count down to another very easily. Just set the "next" attribute to the event to be started, and the "nextturn" attribute to the number of turns to wait.
+
+## Turn off auto
+
+By default, events are automatically moved to "dead\_events" and start the next chained event (if set) when they trigger. Setting the "auto" attribute to false stops that behaviour. This may be desirable if you want to wait until the player is in a certain room, for example. In that case, turn off auto, and the event will fire every turn. Each time it fires, you can have it test to see if the player is in the room; if she is, perform the special action, and then move the event to dead\_events in the script.
+
+## Note
+
+Quest counts each player input as a turn. If the player spending 10 turns typing commands that are not recognised, that is still 10 turns.
+
+
+## Example game
+
+There are only two rooms and three events. Event 1 initiates the countdown to event 2, which in turn sets off event 3. Event 2 has "auto" set to false, so it keeps going until a condition is met (player in room 2), and only then starts the countdown to event 3.
+
+```xml
+
+
+
+
+ DoObjectNotOpen (object)
+
+ 17fa10af-9205-4eba-a5ad-65d3166864e7
+ 1.0
+ 2013
+ 0
+
+ -
+
turn
+
+
+
+ A demonstration of simple turn-based events
+ The Pixie
+
+
+
+ First Room
+ false
+
+
+
+
+
+
+
+
+
+
+ Second Room
+ false
+
+
+
+
+
+
+
+
+
+ 3
+ 2
+ event2
+
+ msg ("Event 1")
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+ if (player.parent = room2) {
+ msg ("Event 2")
+ event3.turn = 2 + game.turn
+ event3.parent = active_events
+ this.parent = dead_events
+ }
+ else {
+ msg ("... waiting")
+ }
+
+
+
+
+
+
+ msg ("Event 3")
+
+
+
+
+ false
+
+ -1
+
+ msg ("TODO")
+
+
+
+```
\ No newline at end of file
diff --git a/site/src/content/docs/other_guides/unlockdoor.md b/site/src/content/docs/other_guides/unlockdoor.md
new file mode 100644
index 000000000..4bb6dd675
--- /dev/null
+++ b/site/src/content/docs/other_guides/unlockdoor.md
@@ -0,0 +1,43 @@
+---
+title: Unlock with combination
+sidebar:
+ order: 2
+---
+
+Here is an example of an exit that will be unlocked when the player inserts the right code.
+
+After creating an exit set the "Locked" checkbox and type something in the "Print message when locked" box. To unlock the exit later you must enter a name for your exit!
+
+
+
+Then add a keypad object to your room. Go to the _Features_ tab, and turn on "Use/Give", then go to the _Use/Give_ tab and add a script for when using the keypad on its own. This script is executed if the player types "use keypad". The player is asked to input the key and then the input is checked with an if-clause. If it is correct, the locked exit is unlocked.
+
+
+
+## Random code
+
+So now some wise guy has pasted the code on the internet, and everyone knows how to open the door already, without having to play part of your game. What we need is a code randomly generated each time the game is played.
+
+So, firstly we need that random code. Click on the game object, then on the "Script" tab. The top half is for a script to run when the game starts, which is what we want. All it has to do is assign a random number to game.code (i.e., the code attribute of the game object). GetRandomInt is a function that creates random numbers, so here is the code:
+
+
+
+There are a couple of tricks that are worth mentioning there. This is the expression used:
+
+```quest
+"" + GetRandomInt(1000, 9999)
+```
+
+We are getting a random number from 1000 to 9999. This ensures we have a four-figure number. This is then added to an empty string (that is the two double quotes). The end result, then is a string that contains exactly 4 digits.
+
+The second part is to modify the exit to use the random code. There is just one change, highlighted below:
+
+
+
+One last thing - you need a way to tell the player what the code is. Something somewhere has to do something like this:
+
+
+
+This is going to print an expression, and in that expression the plain text is in double quotes, the code is game.code, as before, and it is all joined together with + signs. Alternatively, you can use the text processor:
+
+> A note on the wall says the code is {game.code}.
diff --git a/site/src/content/docs/overview.md b/site/src/content/docs/overview.md
new file mode 100644
index 000000000..de326a8c2
--- /dev/null
+++ b/site/src/content/docs/overview.md
@@ -0,0 +1,57 @@
+---
+title: Quest Overview
+sidebar:
+ order: 2
+---
+
+Quest lets you create text adventure games, gamebooks, and other interactive fiction - no programming experience required. This page gives a quick tour of what Quest can do.
+
+## The editor
+
+Quest's point-and-click editor lets you build a game by describing rooms, adding objects and characters, and setting up interactions - all without writing any code. Everything is displayed in plain English. When you're ready to go further, a full scripting language is available underneath, but you can make a complete game without ever using it.
+
+
+
+## Text adventures and gamebooks
+
+Quest supports two styles of interactive fiction:
+
+**Text adventures** are location-based games where the player explores rooms, picks up objects, solves puzzles, and interacts with characters. This is the classic style, similar to _Zork_ or _Hitchhiker's Guide to the Galaxy_.
+
+
+
+**Gamebooks** are linear narrative experiences with branching choices - closer to a _Choose Your Own Adventure_ book. The player reads passages and picks from a set of options at the end of each one.
+
+
+
+## Multimedia
+
+Quest games are more than just text. You can add:
+
+- **Images** - displayed in the game pane, or alongside room and object descriptions
+- **Sounds and music** - background audio or triggered sound effects
+- **Video** - embedded from YouTube
+
+
+
+## Scripting
+
+Quest's scripting system gives you precise control over your game's behaviour. You can write scripts in the editor using a visual block interface, or switch to Code View to write Quest's scripting language directly. Scripts can use variables, conditionals, loops, and functions, and you can encapsulate reusable behaviour in object types and libraries.
+
+
+
+## Customising the interface
+
+The default player interface is clean and functional, but Quest gives you full control over it. You can add custom panes, change fonts and colours, rearrange the layout, and inject your own HTML, CSS and JavaScript to make the game look exactly how you want.
+
+
+
+## Publishing and sharing
+
+When your game is ready, you can publish it to [textadventures.co.uk](https://textadventures.co.uk), where players can find and play it directly in their browser without downloading anything. Games work on any device. You can also keep a game private and share just a direct link with friends.
+
+See the [Publishing](/publishing/publishing) section for full details on how to publish, file size limits, and competition entries.
+
+## What next?
+
+The **[Tutorial](/tutorial/tutorial_introduction)** is the best way to get started - it walks you through building your first game from scratch.
diff --git a/site/src/content/docs/publishing/competition_entry.md b/site/src/content/docs/publishing/competition_entry.md
new file mode 100644
index 000000000..2a4986038
--- /dev/null
+++ b/site/src/content/docs/publishing/competition_entry.md
@@ -0,0 +1,78 @@
+---
+title: Competition entry
+sidebar:
+ order: 1
+---
+
+Competitions are a good way to reach a wider audience for your Quest adventure, but you better be prepared to be judged harshly...
+
+Probably the most significant Interactive Fiction competition is [IfComp](http://www.ifcomp.org/), run during October each year, and this page is mostly geared towards that. It will not guarantee your game is a winner, but hopefully will improve its ranking to some degree.
+
+## Starting out
+
+Before you start creating, think about your game.
+
+### Time
+
+IFComp requires that games can be played in two hours. This is a practical necessity with around 30 games submitted each year, that adds up to 60 hours of playing times for the judges. They do not have time for longer games. Aim for a 1.5 to 2 hour play time.
+
+### Originality
+
+Try to create a game that stands out from the crowd, something with a novel hook to it. Perhaps easier said than done, but take a look at previous winners to see what I mean. Talking of which...
+
+### Easy puzzles
+
+Do not make the puzzles too tricky. With only two hours playing time, if a player gets stuck on one puzzle, she might not find half your game. Of course, you need *some* challenges, the trick is to get the balance right.
+
+### Compare to other entries
+
+Take a look at some other entries from previous years, and see what works and what does not. See what the standard is. Just as important, read the reviews and see what the judges think worked and what did not. Think if common criticisms might also apply to your own work, and modify it accordingly.
+
+## Implementation
+
+### Help, hints and walk-though
+
+Include some in-game system to help players get to the end. They only have two hours and if they are stuck on a puzzle with no way to cheat, they just will not see the end of your game. Make sure the clues are both good (easy to follow) and comprehensive (cover all possible problems).
+
+IFComp requires a walk-through to prove the game is winnable, but a long list of commands is actually pretty useless to the player. Providing a walk-through that tells the player what to do, rather than what to type, will ensure they can get to the end and hopefully still enjoy the trip.
+
+### About
+
+Include an "about" command, so you can tell people who wrote the game, and give thanks to anyone who helped you. Include a version number.
+
+Credit beta-testers here; you may be marked down otherwise.
+
+### Implement everything
+
+Every object mentioned in the text should be implemented as an object that can be looked at in the game. Also aim to implement all the common commands such as "jump", "xyzzy", etc., even if they are not relevant. Default and error responses are *bad*.
+
+Or submit a game without a command line, such as a CYOA or gamebook.
+
+### Feelies
+
+Some games include feelies. Back in the day, commercial adventure games included posters, comic books, scratch-and-sniff cards, etc. to limit piracy as much as anything. Nowadays, these feelies are virtual... so you cannot actually feel them. Nevertheless, they seem to be popular, and can help to give a game a profession touch.
+
+Unfortunately, it is easy for players to miss feelies; if they play on line, they just will not know they exist. Happily Quest handles this well, as you can insert images, videos and audio right into your game, and Quest has support for cover art built in.
+
+### The user interface
+
+Bear in mind that the vast majority of players will be playing on-line, so bear that in mind.
+
+Think carefully what elements of the user interface (UI) you want to include. By default, Quest includes a command line, hyperlinks in the text and the panes on the right. Are they all appropriate to your game?
+
+Turning off the command bar will make it much easier to build your game, as you very much limit what the player can do, but at the cost of destroying the illusion of freedom for the player. For a traditional game, you might prefer to have only the command line.
+
+Also think about the colours and the font. Be sure to pick a font that reflects the style of your game, and is easy to read.
+
+The important message here is to think about the UI, and make a choice for what is right for your game, and not just use the Quest defaults.
+
+
+## Testing
+
+Beta-testing is especially important for a competition entry, since you only get one shot at a good first impression from the judges. See [Beta-testing](/publishing/publishing#beta-testing) for the general process - before-testing checklist, how to publish a private test version, and crediting testers. A few things are specific to a competition entry:
+
+**Keep it "Unlisted", not just private.** The rules of IfComp mean your game will be disqualified if it is released publicly before the competition, so double-check its visibility stays "Unlisted" throughout testing.
+
+**Explain that it's a beta.** Have a statement at the start of your game explaining that this is a beta version, what version it is, and how testers can send you comments - update the text with each new version so testers can tell you which one they were looking at. Remember to remove or update this text before the real release.
+
+**Look beyond the Quest community for testers.** Ask on the Quest forum, but especially for a competition entry, it's worth asking people outside the Quest community too - [start here](http://www.intfiction.org/forum/viewforum.php?f=19).
\ No newline at end of file
diff --git a/site/src/content/docs/guides/hosting.md b/site/src/content/docs/publishing/hosting.md
similarity index 98%
rename from site/src/content/docs/guides/hosting.md
rename to site/src/content/docs/publishing/hosting.md
index 28ea5c735..6f85ff3a9 100644
--- a/site/src/content/docs/guides/hosting.md
+++ b/site/src/content/docs/publishing/hosting.md
@@ -46,4 +46,4 @@ A couple of things to know about this option:
## Host WebPlayer yourself
-This option requires a bit more setup, and is only recommended if you require that end users don't download your `.quest` file. For example, some people have used this option for running online treasure hunts - the `.quest` file stays on the server, so it can't be examined. See the separate [WebPlayer](/guides/webplayer/) guide.
\ No newline at end of file
+This option requires a bit more setup, and is only recommended if you require that end users don't download your `.quest` file. For example, some people have used this option for running online treasure hunts - the `.quest` file stays on the server, so it can't be examined. See the separate [WebPlayer](/publishing/webplayer/) guide.
\ No newline at end of file
diff --git a/site/src/content/docs/publishing/publishing.md b/site/src/content/docs/publishing/publishing.md
new file mode 100644
index 000000000..279a5bd1b
--- /dev/null
+++ b/site/src/content/docs/publishing/publishing.md
@@ -0,0 +1,94 @@
+---
+title: "Publishing"
+sidebar:
+ order: 16
+---
+
+To get your game playable on [textadventures.co.uk](https://textadventures.co.uk), you need to publish it.
+
+Note that once you have published it, your game will go into a queue for moderation. Games may be assigned to the "Sandpit" category if they are very basic, or to "Adult" if they sexual content, otherwise they will be assigned to the appropriate category, and will appear on the web site.
+
+Moderation can take a few days; please be patient.
+
+
+## Publishing your game
+
+In the editor, open the **File** menu in the toolbar and choose **Publish…**. This builds a `.quest` package (your game file plus its assets) and downloads it.
+
+On textadventures.co.uk, click on _Create_ at the top, then _Submit_ below that. Then follow the instructions to upload the `.quest` file you just downloaded.
+
+
+## The publish process
+
+What gets included in the `.quest` file, when you publish? Broadly two things.
+
+Firstly the game code. This is all the code from all the libraries, including the built-in libraries, from whatever folders on your PC, assembled into one big file. This means that if, in a few years, Quest's built-in libraries get radically updated, your game will not be affected.
+
+Secondly, any supporting files. This is any file Quest can find in your game folder with a certain name format, whether they are used in your game or not. Images and sounds that are not in this folder will not be included, images and sounds that are in it, but not used will be included. Note that when you select images and sounds through the Quest GUI, it will copy the file into the game folder, so in theory all these files should already be there.
+
+Quest grabs any file with a name that matches one of these formats
+
+ *.jpg;*.jpeg;*.png;*.gif;*.js;*.wav;*.mp3;*.htm;*.html;*.svg;*.ogg;*.ogv
+
+However, you can modify that by changing `game.publishfileextensions`; despite the name, it is not restricted to file extensions. If you have a text file you want included, but others you do not, you could set it like this:
+
+```quest
+*.jpg;*.jpeg;*.png;*.gif;*.js;*.wav;*.mp3;*.htm;*.html;*.svg;*.ogg;*.ogv;includeme.txt
+```
+
+The single code file plus all the supporting files are then compressed in a single archive file.
+
+
+## Size limitations
+
+textadventures.co.uk has a 50 Mb upload limit. This is the size of the published `.quest` file, and if your game is larger than that, the editor will give you a warning when you try to publish. In terms of game, that is a huge amount, and you will be doing well to build a game that is even 1 Mb. However, images, videos and sounds can seriously inflate the file size.
+
+If your game is too large, you can try:
+
+* Remove files that are not used from the game folder
+
+* Use smaller or lower quality clips
+
+* Host larger video/image/sound files on another web site
+
+* Host your game yourself instead - see [Hosting your game](/publishing/hosting) for several options, including one that's just a single file to upload
+
+
+## Announcing your game
+
+Once your game is live, tell people about it! You can post on:
+
+- the [textadventures Discord](https://textadventures.co.uk/community/discord) in the `#games` channel
+- the [intfiction.org forums](https://intfiction.org/c/playing/project-announcements/50) in Project Announcements
+- [IFDB](https://ifdb.org/)
+
+
+## Spell checking
+
+Your browser's built-in spell-checker will generally underline mistakes as you type into the editor's text fields, as long as you're using a browser that supports it.
+
+Another technique is to open the source code in a text editor that has a spell-checker, such as _Notepad++_ (which can be downloaded for free). The source code can look intimidating, and you need to be careful only to correct text that will be seen, not code or XML. With Notepad++ you can set the language to XML, which will help.
+
+Before doing this, it is best to save and close the game in the editor first, and to create a back-up of your file.
+
+
+## Beta-testing
+
+Beta-testing is getting other people to play your game so bugs and typos can be identified and corrected before release to the public. It is absolutely vital; with the best will in the world, testers are sure to find spelling mistakes, objects you have not implemented, verbs you have not thought of, and routes through the game you have not considered. Better these things are found during beta-testing than after release. If you do not know anyone who can do this for you, it is worth asking on the forum.
+
+### Before beta-testing
+
+It is tempting to get the game to testers fast, but you are really just wasting their time and yours if you know there are problems before sending it. So:
+
+1. Play the game through and correct any mistakes you can find.
+2. Spell check it - see [Spell checking](#spell-checking) above.
+3. Some things you might want to check, depending on your game: every room and object has an alias and a description; everything mentioned in a description is actually implemented; the appropriate display and inventory verbs are there, and inappropriate ones are absent.
+4. Play the game, then try to save it. When Quest saves it does some extra error checking it does not do any other time, so this is a quick test of your code - do not send testers a game that will not save. If it saved successfully, load it again and check the UI still looks the same and the various parts still work, since loading tends to be especially sensitive to errors in scripts.
+
+### Running beta-testing
+
+You can upload a game to Text Adventures in the normal way for beta-testing, but keep its visibility to private. There is an "Upload a new file" link on the _Edit_ page, so you can publish updates during the testing process by downloading a fresh `.quest` package and uploading it there.
+
+You should assume you will be releasing a few beta versions, each improving on the previous, and it may be a good idea to get new testers at each round.
+
+When it is ready for release, go to "View/Edit Game Listing", and change the visibility to public. Remember to thank your beta-testers - it's common to do this with an "about" command in the game itself.
\ No newline at end of file
diff --git a/site/src/content/docs/guides/webplayer.mdx b/site/src/content/docs/publishing/webplayer.mdx
similarity index 98%
rename from site/src/content/docs/guides/webplayer.mdx
rename to site/src/content/docs/publishing/webplayer.mdx
index f0d64700f..5c3f8c284 100644
--- a/site/src/content/docs/guides/webplayer.mdx
+++ b/site/src/content/docs/publishing/webplayer.mdx
@@ -8,7 +8,7 @@ import { Tabs, TabItem } from '@astrojs/starlight/components';
Quest Viva's **WebPlayer** lets you serve any Quest game to a web browser. The game code itself runs on the server, with the web browser sending the player's input and receiving the game's output.
-For most games it's recommended to use **WasmPlayer** instead, where the game code runs in the user's browser. See the [Hosting](/guides/hosting/) guide for more details about that.
+For most games it's recommended to use **WasmPlayer** instead, where the game code runs in the user's browser. See the [Hosting](/publishing/hosting/) guide for more details about that.
There are two ways of running WebPlayer:
- run using Docker
diff --git a/site/src/content/docs/scripts/index.md b/site/src/content/docs/scripts/index.md
new file mode 100644
index 000000000..c908340d9
--- /dev/null
+++ b/site/src/content/docs/scripts/index.md
@@ -0,0 +1,565 @@
+---
+title: Script commands
+sidebar:
+ order: 5
+---
+
+Scripts are created in a style similar to C, with script blocks denoted by braces. Unlike C, there is no character to mark the end of a line - each script command is simply on its own line.
+
+```quest
+if (someVariable = 3) {
+ msg ("Some text")
+}
+```
+
+Comments are denoted by //
+
+```quest
+// this line will be ignored
+```
+
+## Setting variables
+
+To set an object attribute to a value:
+
+```quest
+object.attribute = value
+```
+
+To set a variable to a value:
+
+```quest
+variable = value
+```
+
+To set an object attribute to a script:
+
+```quest
+object.attribute => { script }
+```
+
+To set a variable to a script:
+
+```quest
+variable => { script }
+```
+
+## ask
+```quest
+ask (string question) {script}
+```
+
+Pops up a prompt for the user to choose Yes or No as the answer to the specified question, and then runs the nested script.
+
+The nested script can check the "result" boolean variable to see the user's response - true for "yes", false for "no".
+
+```quest
+ask ("Do you want to eat an apple?") {
+ if (result) {
+ msg("Ahhh, very tasty")
+ } else {
+ msg("But you should eat your daily apple!")
+ }
+}
+```
+
+## create
+```quest
+create (string name)
+```
+
+or
+
+```quest
+create (string name, string type)
+```
+
+Creates an object with the specified name. You can subsequently access the object using the [GetObject](/functions/objects#getobject) function, or just use its name directly in an expression.
+
+If you specify a type, the object created will be of that type. The command only accepts one type name - if you want the new object to inherit multiple types, you could create one type which inherits all of those types, and specify that here.
+
+## create exit
+```quest
+create exit (string alias, object from, object to)
+```
+
+or
+
+```quest
+create exit (string alias, object from, object to, string type)
+```
+
+or
+
+```quest
+create exit (string name, string alias, object from, object to, string type)
+```
+
+Creates an exit with the specified alias (usually the direction, such as "north") between two objects/rooms.
+
+An initial type can be specified e.g. "northdirection". This will ensure that the correct [alt](/attributes#alt) names are applied to compass exits.
+
+```quest
+create exit ("northwest", fromRoom, toRoom, "northwestdirection")
+```
+
+You can also specify the object name to use. If not specified, an id will be automatically generated.
+
+```quest
+create exit ("exit_to_garden", "northwest", fromRoom, toRoom, "northwestdirection")
+```
+
+It is usually easier to make an exit in the normal way in the editor, but to set it so it is not visible; instead of then creating an exit during game play, you set this exit to be visible.
+
+## create timer
+```quest
+create timer (string name)
+```
+
+Creates a timer with the specified name. You can then use `GetObject` to get the timer, and assign values to it. Here is a trivial example that will produce a timer that will tell you its name every 10 seconds:
+
+```quest
+create timer ("test_timer")
+o = GetTimer ("test_timer")
+msg (TypeOf(o))
+o.script => {
+ msg ("timer=" + this.name)
+}
+o.interval = 10
+EnableTimer(o)
+```
+
+It is generally easier to create the timer in the editor, but have it disabled, and then enable it when required.
+
+## create turnscript
+```quest
+create turnscript (string name)
+```
+
+Creates a turnscript with the specified name. You can then use `GetObject` to get the turn script, and assign values to it. Here is a trivial example that will produce a turnscript that will tell you its name every turn:
+
+```quest
+create turnscript ("test_ts")
+o = GetObject("test_ts")
+o.script => {
+ msg ("turnscript=" + this.name)
+}
+o.enabled = true
+```
+
+It is generally easier to create the turn script in the editor, but have it disabled, and then enable it when required.
+
+## destroy
+```quest
+destroy (string name)
+```
+
+Destroys the specified object. Note that this takes the object's name, not the object itself, as a parameter.
+
+## dictionary add
+```quest
+dictionary add (dictionary, string key, any type item)
+```
+
+Adds an item to the specified dictionary.
+
+See [Using Dictionaries](/howto/scripting/using_dictionaries)
+
+## dictionary remove
+```quest
+dictionary remove (dictionary, string key)
+```
+
+Removes the specified item from the dictionary.
+
+See [Using Dictionaries](/howto/scripting/using_dictionaries)
+
+## do
+```quest
+do (object, string attribute name)
+```
+
+Runs an object's script attribute.
+
+```quest
+do (object, string attribute name, dictionary parameters)
+```
+
+Runs an object's script attribute, passing in parameters via dictionary. The key/value pairs in the dictionary will be turned into local variables for the script. The special variable "this" can be used in the script to reference the object.
+
+## error
+```quest
+error (string message)
+```
+
+Stops running the current script and raises the specified error message.
+
+## finish
+```quest
+finish
+```
+
+Finish the game.
+
+## firsttime
+```quest
+firsttime { script1 } [ otherwise { script2 } ]
+```
+
+runs **script1** if it is the first call, otherwise **script2** is executed
+
+## for
+```quest
+for (iterator variable, int from, int to) { script }
+```
+
+There is an optional "step" parameter:
+
+```quest
+for (iterator variable, int from, int to, int step) { script }
+```
+
+Run a script multiple times, incrementing the iterator variable between the specified limits. If a "step" parameter is specified, the iterator variable will be incremented by that amount each time (if not specified, the default step size is 1).
+
+Trandionally, i, j, k... are used as iterator varable names. This simple example runs from 1 to 5, printing each value in turn:
+
+```quest
+for (i, 1, 5) {
+ msg(game.i)
+}
+```
+
+Generally, `foreach` offers a neater way of going through a list, but `for` can be useful for iterating through a string. This example will print each character in the string, together with its position:
+
+```quest
+s = "Hello World!"
+for (i, 1, LengthOf(s)) {
+ msg(i + ": " + Mid(s, i, 1))
+}
+```
+
+_Note:_ The iterator variable should be a local variable, not an attribute. For example, consider this code, which uses an attribute of the game object:
+
+```quest
+for (game.i, 1, 5) {
+ msg(game.i)
+}
+```
+
+If `game.i` already exists, the loop will run 5 times as expected, but the value of `game.i` will keep its original value. If `game.i` does not exist, an error will be produced.
+
+See [Using Lists](/howto/scripting/using_lists)
+
+## foreach
+```quest
+foreach (iterator variable, list) { script }
+```
+
+Run a script for each item in a list. If the list is a dictionary, the loop iterates over the dictionary keys.
+
+_Note:_ Do not use an attribute as the iterator variable (see [here](#for)).
+
+For more on how and why to use `foreach`, see [Using Lists](/howto/scripting/using_lists)
+
+## get input
+```quest
+get input {script}
+```
+
+Waits for the user to type some text, then runs the nested script.
+
+The nested script can evaluate the "result" string variable to work with the user's input.
+
+Example:
+
+```quest
+msg ("What is your name?")
+get input {
+ msg ("Your name is " + result)
+}
+```
+
+For more information see [here](/howto/tasks/asking_a_question).
+
+## if
+```quest
+if (boolean expression) { script } [ else if ... ]* [ else { script } ]
+```
+
+Conditionally runs the script. If the condition fails, the `else` script is run, if present. Multiple `if/else`s can be put together. Some examples:
+
+```quest
+if (result > 10) {
+ msg("Great!")
+}
+```
+An `else` can be added (no need for a condition)
+```quest
+if (result > 10) {
+ msg("Great!")
+}
+else {
+ msg("Rubbish!")
+}
+```
+Or we can have a condition; nothing gets printed if result is between 2 and 10.
+```quest
+if (result > 10) {
+ msg("Great!")
+}
+else if result < 2) {
+ msg("Rubbish!")
+}
+```
+You can have as many `if/else` linked together as you need (but consider using [switch](#switch)).
+```quest
+if (result > 10) {
+ msg("Great!")
+}
+else if result > 2) {
+ msg("Meh...")
+}
+else {
+ msg("Rubbish!")
+}
+```
+
+Complex conditions can be used with Boolean arithmetic.
+
+```quest
+if (result > 10 and not player.is_female) {
+ msg("Good boy")
+}
+```
+
+## insert
+```quest
+insert (string filename)
+```
+
+Outputs the contents of the specified HTML file.
+
+**Not supported in Quest 5.4 or later.**
+
+## invoke
+```quest
+invoke (script)
+```
+
+Runs a script.
+
+```quest
+invoke (script, dictionary parameters)
+```
+
+Runs a script, passing in parameters via dictionary. The key/value pairs in the dictionary will be turned into local variables for the script. See also the [do](#do) script command.
+
+## list add
+```quest
+list add (list, any type item)
+```
+
+Adds an item to a list.
+
+See [Using Lists](/howto/scripting/using_lists)
+
+## list remove
+```quest
+list remove (list, any type item)
+```
+
+Removes an item from a list.
+
+See [Using Lists](/howto/scripting/using_lists)
+
+## msg
+```quest
+msg (string message)
+```
+
+Prints the specified text.
+
+## on ready
+```quest
+on ready { script }
+```
+
+Runs the nested script when any callbacks have finished.
+
+For example, when you use an [ask](#ask) or [get input](#get-input) script command, Quest will wait for a response from the player and then run the nested scripts from those commands. However, any other scripts at the same level will run immediately. If you don't want this to happen, use "on ready" to make the script only run after the user has entered a command or responded to the question.
+
+This is used by the Core library so that, for example, a room description is only displayed after any scripts which ask a question in "before enter" have run their nested scripts. This prevents the room description from being displayed while the question is still on-screen.
+
+Generally there should be no need to use this command in your own games, as of course if you want script to run after an "ask", you can just put it inside the "ask" script block.
+
+Note that this does not wait for scripts attached to functions to work (such as `Ask` and `ShowMenu`). see [here](/howto/scripting/blocks_and_scripts)
+
+## picture
+```quest
+picture (string filename)
+```
+
+Outputs the specified picture file.
+
+## play sound
+```quest
+play sound (string file, boolean wait, boolean loop)
+```
+
+Plays a sound file (WAV or MP3 format), which must be in the same directory as the game file. If the parameter **wait** is "true", the script will stop until the sound has finished. If the parameter **loop** is "true", the sound will loop.
+
+## request
+```quest
+request (request name, string parameter)
+```
+
+Raises a UI request. The request name must be specified directly - it is not a string expression. For example:
+
+```quest
+request(UpdateLocation, "The Kitchen")
+```
+
+The `request` script command is really a throw-back to the original Quest 5.0 interface, which, while it did use HTML, was not a fully-fledged browser. As of 5.3, the interface is a version of Chrome embedded in the software, and all interaction between the game world and the interface is done with JavaScript. Since then `request` has become increasingly obsolete, and it is recommended that the alternative is used. It is just possible `request` will be taken out of Quest at some date.
+
+Valid request names, what they do, and their modern alternative:
+
+| Request name | Effect | Use instead |
+|---|---|---|
+| `Background` | Sets the background to the specified HTML colour. | [SetBackgroundColour](/functions/user-interface#setbackgroundcolour) |
+| `ClearScreen` | Clears the screen. Parameter is ignored. | [ClearScreen](/functions/user-interface#clearscreen) |
+| `Foreground` | Sets the foreground to the specified HTML colour. | [SetForegroundColour](/functions/user-interface#setforegroundcolour) |
+| `GameName` | Sets the name of the game. | [JS.setGameName(name)](/js/#setgamename) |
+| `Hide` | Turns off an interface element. | [JS.uiHide(...)](/js/#uihide) |
+| `LinkForeground` | Sets the link foreground to the specified HTML colour. | [SetLinkForegroundColour](/functions/internal-core#setlinkforegroundcolour) |
+| `Log` | Logs the specified text. | [Log](/functions/general#log) |
+| `PanesVisible` | Shows/hides the side panes. "on"/"off" toggle them; "disabled" turns them off and removes the button to turn them back on (that button appears to no longer be available). | [JS.panesVisible(true / false)](/js/#panesvisible) |
+| `Pause` | Pauses the game for the specified number of milliseconds. | — |
+| `Quit` | Quits the game. Parameter is ignored. | [finish](#finish) |
+| `RequestSave` | Requests the UI to save the game (may prompt a "Save As" dialog). Parameter is ignored. | `requestsave()` |
+| `RunScript` | Runs the specified JavaScript function. | the [JS](/js/) object, e.g. `JS.myCustomFunction(15, "some string")` |
+| `SetCompassDirections` | Assigns compass direction names from a semicolon-separated list. | [JS.setCompassDirections(...)](/js/#setcompassdirections) |
+| `SetInterfaceString` | Sets UI text via an `"ElementName=Value"` parameter. | [JS.setInterfaceString(...)](/js/#setinterfacestring) |
+| `SetPanelContents` | Sets the static panel HTML contents. | [SetFramePicture](/functions/user-interface#setframepicture) and [ClearFramePicture](/functions/user-interface#clearframepicture) |
+| `SetStatus` | Sets the status area text (right of screen, under "Inventory"); blank removes it. | [status attributes](/status_attributes) |
+| `Show` | Turns on an interface element ("Panes", "Location" or "Command"). | [JS.uiShow(...)](/js/#uishow) |
+| `ShowPicture` | Shows the specified picture file from the game directory. | [picture](#picture) |
+| `UpdateLocation` | Updates the location bar with the parameter text. | [JS.updateLocation(location)](/js/#updatelocation) |
+| `Wait` | Waits for the player to press a key. Parameter is ignored. | [wait](#wait) script command |
+
+`FontName` and `FontSize` aren't listed above: they now raise an error rather than do anything, so use [SetFontName](/functions/user-interface#setfontname) and [SetFontSize](/functions/user-interface#setfontsize) instead.
+
+## return
+```quest
+return (any type result)
+```
+
+Sets the return value of a function, and stops execution of the function immediately.
+
+This command should only be used within a [\ element](/elements#function).
+
+## rundelegate
+```quest
+rundelegate (object, string attribute name, any type parameters ... )
+```
+
+Runs an object's delegate implementation script attribute, with the specified parameters.
+
+See [Using delegates](/advanced-topics/using_delegates)
+
+## set
+```quest
+set (object, string attribute name, any type value)
+```
+
+Sets a named attribute on the object.
+
+Note that you can also use this syntax to do the same thing:
+
+```quest
+object.attribute = value
+```
+
+You only need to use the "set" command if you are constructing the attribute name using an expression.
+
+## show menu
+```quest
+show menu (string caption, stringdictionary or stringlist options, boolean allow cancel) {script}
+```
+
+Shows a popup menu of options and then runs the nested script. The script can access the variable "result" which contains the result of the user selection - if a dictionary of options is passed in, the key is returned. If a list of options is passed in, the list item is returned.
+
+If the "allow cancel" parameter is set to **true**, the Cancel button is available. If "cancel" is pressed, the variable "result" returns [null](/types#null).
+
+For an in-line menu, use the [ShowMenu](/functions/user-interface#showmenu) function.
+
+**example:**
+
+```quest
+menulist = NewStringList()
+list add (menulist, "first entry")
+list add (menulist, "second entry")
+list add (menulist, "third entry")
+show menu ("please choose now", menulist, true) {
+ msg ("--" + result + "--")
+ if (result<>null) {
+ msg ("You have chosen the " + result)
+ }
+ else {
+ msg ("You have chosen to press cancel")
+ }
+}
+```
+
+## start transaction
+```quest
+start transaction (string command)
+```
+
+Starts a transaction in the undo-logger for the specified command, and ends the previous transaction (if one was open).
+
+## stop sound
+```quest
+stop sound
+```
+
+Stops playing sounds.
+
+## switch
+```quest
+switch (any type value) { case (any type value) { script } [ default { script } ] }
+```
+
+Switch is used with one or more `case` statements and an optional `default` statement. It is used to test a variable or object attribute against 2 or more possible values; a shortcut instead of writing many `if` statements.
+
+For more, see [here](/howto/tasks/multiple_choices_using_a_switch_script)
+
+## undo
+```quest
+undo
+```
+
+Moves the game state backwards one transaction.
+
+## wait
+```quest
+wait {script}
+```
+
+Waits for the user to press a key or click on a "Continue" link, and then runs the nested script. Each successive part needs to be nested inside the one before, like this:
+
+```quest
+msg ("First bit")
+wait {
+ msg ("Second bit")
+ wait {
+ msg ("Third bit")
+ }
+}
+```
+
+## while
+```quest
+while (expression) { script }
+```
+
+Run a script while the given expression returns true.
diff --git a/site/src/content/docs/status_attributes.md b/site/src/content/docs/status_attributes.md
new file mode 100644
index 000000000..c1c9005d2
--- /dev/null
+++ b/site/src/content/docs/status_attributes.md
@@ -0,0 +1,83 @@
+---
+title: Status attributes
+sidebar:
+ order: 3
+---
+
+Status attributes are a great way to keep the player continuously informed of her progress. You might want to display the player's current score or health or money or any number of other values.
+
+Status attributes have their values displayed in their own pane on the right side of the screen. The pane will not be present if you have no status attributes in your game (or if you have the panes on the right turned off).
+
+You can display as many as you want, but are limited to string, int, double and Boolean values. No objects, lists or dictionaries!
+
+
+
+Status attributes are just attributes that you tell Quest to display - there is nothing special about the attribute itself. Quest has two lists of status attributes, one for the game object and one for the player. If the player object can change, then each one gets its own list, but only the current one will be used. Therefore you should use the game one to hold game-wide values such as the time and score, and the player one for those that relate to the player, such as health and money.
+
+
+## Setting up
+
+You can set up status attributes on the _Attributes_ tab of the player or game objects.
+
+First create the attribute as normal. Let us suppose we want a "score" attribute; click the plus sign by attributes, and type in the name. Then select it to be an "int". It can also be given an initial value, but zero is fine for here.
+
+Status attributes are at the top. Click on the plus sign for status attributes to create a new one. It will ask you for the attribute name; this will be "score" (note that this must match exactly).
+
+You will then be asked for the format - this is how the attribute will be displayed. You can leave it blank for the default, other options are discussed later.
+
+
+
+
+
+## Formatting
+
+If you leave the format blank, the default display will be the attribute name with a capital at the start, a colon, and then the value.
+
+ Score: 0
+
+This will often be fine, but occasionally you will want more control. A status attribute format is just the text you want displayed with an exclamation mark when the value will appear.
+
+Perhaps you decide to call it "Total score", but do not want to change the name of the attribute, as that is used dozens of times already in your game. Use this format:
+```
+Total score: !
+```
+It will then be displayed:
+
+ Total score: 0
+
+Perhaps you want to show the total. You might use this format:
+
+```
+Total score: !/10
+```
+It will then be displayed:
+
+ Total score: 0/10
+
+Status attributes do not support the text processor, but they are displayed in HTML, so you add fancy formatting.
+```xml
+Total score: !/10
+```
+
+
+## Advanced options
+
+Occasionally you want to do something more complicated for a status attribute - for example, you might want to show both the current and the maximum ammo in a gun. The trick is to create a new string attribute that holds both, and to update that whenever either value changes. The code might looking like this, where `player.ammonote` is a string to display the values (this needs to be run whenever the values change):
+
+```quest
+player.ammonote = player.ammo + "/" + player.ammomax
+```
+
+The attribute can be added to the list of status attributes as before (so in the game start script:
+
+```quest
+dictionary add (player.statusattributes, "ammonote", "Ammo: !")
+```
+
+So how do we ensure `player.ammonote` always gets updated? With [change scripts](change_scripts.md)...
+
+
+
+## Behind the scenes
+
+Core.aslx updates the status attributes using an UpdateStatusAttributes function. It populates a string and then sends it to the UI using a SetStatus [request](/scripts#request).
diff --git a/site/src/content/docs/tutorial/anatomy_of_a_quest_game.md b/site/src/content/docs/tutorial/anatomy_of_a_quest_game.md
new file mode 100644
index 000000000..d2ec60f45
--- /dev/null
+++ b/site/src/content/docs/tutorial/anatomy_of_a_quest_game.md
@@ -0,0 +1,68 @@
+---
+title: Anatomy of a Quest game
+sidebar:
+ order: 4
+---
+
+Every Quest game is made up of the following parts. Here are the main ones:
+
+## Elements
+
+There are various types of element:
+
+### Objects
+
+Objects are the basic building blocks of the game. Everything "physical" in the game is an object - that includes rooms and the player themselves. So in a simple game where the player is in a lounge, and there is a cat and a table in the lounge, there are four objects in total - the lounge, the player, the cat and the table.
+
+Objects can contain other objects. This is done by setting the "parent" attribute. In our simple example, the lounge has no parent - it stands alone. The player is in the lounge, so the player's parent is "lounge". If the cat is sitting on the table in the lounge, then the cat's parent is "table", and the table's parent is "lounge".
+
+### Exits
+
+Exits connect objects (usually rooms) together. The exit has a parent, so our simple game might have an exit from the lounge by setting the exit's parent to "lounge". Exits also have a "to" direction, so this exit might point to another room, such as the kitchen. Or, it might point to an object inside the same room, such as a cupboard - this would allow the player to go inside a cupboard in the room.
+
+### Commands and verbs
+
+Commands handle player input. They can exist globally, in which case the command will work everywhere. Commands can also exist inside a particular room, in which case that command will only work in that room. Commands have a pattern, such as `look at #object#`. When the player types something, it is compared to all the available command patterns. The best match is then used to process what the player typed in. So if the player typed `LOOK AT CAT`, the "look at" command is matched, and it performs whatever action is necessary to print the description of the cat.
+
+Verbs are a shortcut for commands. Many commands follow the same pattern, and it is easier to have the verb mechanism handle that for us, so we can concentrate on what makes ours special.
+
+### Game
+
+
+The game itself is a special kind of object - it contains attributes such as the name of the game, options such as how to print room descriptions, and display settings.
+
+## Attributes
+
+All element data (that is, all information about objects, commands, etc.) is stored in **attributes**. An element can have an unlimited number of attributes. Attributes can store things such as the object description, alternative object names, the behaviour when an object is taken, which objects can be used on the object, and much more. The attribute can be of many types:
+
+### String
+
+
+A sequence of letters/numbers, for example "The cat is sitting quietly on the table". Obviously, strings are very common in text adventure games!
+
+### Integer
+
+A whole number, such as 1, 2, -3, 42 or 1 billion.
+
+### Script
+
+
+One or more script commands, which are instructions for Quest to carry out. Everything that happens in a game is controlled by script commands. Script commands can print messages, move objects around, show videos, start timers, change attributes, and much more.
+
+Scripts can be created by adding script commands using the user interface, or by typing code in "code view". Behind the scenes, it is all the same, so you can flip between the two as you like.
+
+### Object
+
+Objects can be attributes too. The "to" attribute of an exit holds an object, the destination of the exit. The "parent" attribute is also an object.
+
+### List
+
+A list is an ordered sequence of things. Lists can contain strings, scripts or objects (though lists of objects are rarely attributes).
+
+### Dictionary
+
+A dictionary is a look-up table of strings, scripts or objects. That is, a set of data where each item can be accessed by a string.
+
+## Libraries
+
+Libraries are used to include common functionality in a game. There is a standard "Core" library that is included by default with all Quest games. This is made up of the elements above - commands, scripts and so on - and provides a lot of the standard functionality that players will expect in your game, such as the "look at" command, printing room descriptions, and so on.
diff --git a/site/src/content/docs/tutorial/creating_a_gamebook.md b/site/src/content/docs/tutorial/creating_a_gamebook.md
new file mode 100644
index 000000000..683869add
--- /dev/null
+++ b/site/src/content/docs/tutorial/creating_a_gamebook.md
@@ -0,0 +1,51 @@
+---
+title: Creating a gamebook
+sidebar:
+ order: 16
+---
+
+## Creating a blank game
+
+This tutorial guides you through creating your first gamebook game. If you want to create a text adventure instead, see [the main Quest tutorial](/tutorial/creating_a_simple_game).
+
+Open the editor - either in your browser, or the desktop app - and you'll see a "Create new game" section. Ensure that "Gamebook" is selected as the game type, and enter a name like "Tutorial Game".
+
+Click "Create local draft" (or "Save to folder..." if you'd rather store the game file yourself), and you'll see the main Editor screen.
+
+On the left is a tree showing you the pages in the gamebook, and a place to set options about the game itself. "Game" is currently selected, so that's what we can see in the pane on the right.
+
+Quest has created three example pages for us, and inside Page1 is the "player" object, which is where the game begins. You can test the game by clicking the "Preview" button towards the top right.
+
+As you'll see, it's a pretty empty game at the moment. We can navigate to pages 2 and 3, but that's it.
+
+You can go back to the Editor by closing the preview, or typing `QUIT`.
+
+## Editing pages
+
+To create your game, edit the text for Page1. Underneath the text, the "Options" list shows which pages a player can get to from here. You can add new pages directly from here, or create links to other pages which already exist.
+
+## Page types
+
+### Text
+
+This is the standard page type. It simply shows a paragraph of text, followed by the list of options.
+
+### Picture
+
+This is the same as the Text type, but you can also choose a picture to display at the top of the screen.
+
+### YouTube
+
+This is the same as the Text type, but you can also choose a YouTube video to display at the top of the screen. You will need the YouTube id of the video - an easy way to get this for a YouTube video is to find the video you want and click Share. The id will be displayed at the end of a URL like `https://youtu.be/8jPyg2pK11M` where `8jPyg2pK11M` is the id you want.
+
+### External link
+
+This is a special page type which takes the player directly to another website. It doesn't display any text of its own.
+
+## Playing sounds
+
+You can play a sound when a player reaches a page. Go to the Action tab and browse for a sound file.
+
+## Releasing your game
+
+To publish your game, follow the same steps as listed in [Releasing your game](/tutorial/releasing_your_game) in the main text adventure tutorial.
diff --git a/site/src/content/docs/tutorial/creating_a_simple_game.md b/site/src/content/docs/tutorial/creating_a_simple_game.md
new file mode 100644
index 000000000..9890fa71f
--- /dev/null
+++ b/site/src/content/docs/tutorial/creating_a_simple_game.md
@@ -0,0 +1,128 @@
+---
+title: Creating a simple game
+sidebar:
+ order: 2
+---
+
+This tutorial guides you through creating your first text adventure game. If you want to create a gamebook instead, see [Creating a gamebook](/tutorial/creating_a_gamebook).
+
+## Creating a blank game
+
+Open the editor - either in your browser, or the desktop app - and you'll see a "Create new game" section. Enter a name like "Tutorial Game", ensure that "Text Adventure" is selected as the game type, and choose a language from the list - this tutorial will focus on creating a game in English, but the editor itself will look mostly the same whichever language you pick here.
+
+Click "Create local draft" (or "Save to folder..." if you'd rather store the game file yourself), and you'll see the main Editor screen.
+
+
+## The editor screen
+
+On the left is a tree showing you every element of the game. The "game" element is currently selected, so that's what we can see in the pane on the right.
+
+Quest has created a room called "room" for us, and inside this room is the "player" object, so that's where the player will begin when you run the game. You can test the game by clicking the "Preview" button towards the top right.
+
+As you'll see, it's a pretty empty game at the moment. We can type some standard commands such as `INVENTORY` to see that Quest comes up with some default responses, but that's about all we can do at the moment.
+
+You can go back to the Editor by closing the preview, or typing `QUIT`.
+
+## Setting up rooms
+
+Quest created a room called "room", which isn't a very good name. In this tutorial game, we want to start in a lounge, so select "room" from the tree and change its name.
+
+
+
+To create a room description, click the _Room_ tab. Enter a Description in the text editor - something like "This is quite a plain lounge with an old beige carpet and peeling wallpaper."
+
+
+
+Let's add a second room to the game. Click "+ Add" on the toolbar, then "Add Room" (or use the "..." menu on an existing element in the tree, which also offers "Add Room").
+
+Add a room called "kitchen", and give it a description - use your imagination!
+
+If you play the game at this point, you'll see the player is still trapped in the lounge, with no way out. To be able to get to the kitchen from the lounge, you need to add an exit.
+
+### Adding an exit
+
+To do this, click back to the "lounge" room and go to the _Exits_ tab. Then click the "South" exit:
+
+
+
+To create the exit, choose "kitchen" from the drop-down list on the right. Ensure that "Also create exit in the other direction" is selected, then click the "Create" button.
+
+
+
+When you click the "Create" button, actually *two* exits are created - one exit south from to lounge to the kitchen, and another exit north from the kitchen back to the lounge. You can see both exits in the tree.
+
+It is helpful to think of exits as "one way". Each exit is "in" only one parent room (the "from" room), and points "to" one other room. That is why we have one exit in the lounge, pointing to the kitchen. A separate exit is in the kitchen, pointing to the lounge.
+
+Exits, like every object in Quest, can have an alias, which is simply a way of displaying a particular name to the player. Notice how the two exits we just created have aliases of "south" and "north". (We could give our exits any alias - it doesn't have to be a compass direction. If we were setting a game on a ship for example, we might have exits with aliases like "port" and "starboard".)
+
+Play the game and verify that the player can go south and north between the lounge and kitchen.
+
+## Adding objects
+
+Now let's add some objects to the lounge, to give the player something to do.
+
+A lounge is barely a lounge without a TV in it, so let's add one now. With the lounge selected, you have a couple of ways of adding an object to the room. You can:
+
+- Click "+ Add" on the toolbar, then "Add Object in 'lounge'"
+- Use the "..." menu on "lounge" in the tree, and choose "Add Object here"
+
+Use one of these methods to add an object to the lounge. A prompt will appear asking you to enter a name for the object. Enter "TV". Leave the parent as "lounge" and click OK.
+
+### Object names and aliases
+
+It is important to note the distinction between:
+
+- the names that *players* can see and use to refer to objects
+- the names that your Quest scripts use
+
+**Name:** In order to avoid confusion, each object must have a unique name. So, if you have multiple televisions in your game, they must be given different names – like "TV1", "TV2" and so on.
+
+**Alias:** Of course, this wouldn’t sound natural if these were the names that players saw, which is why Quest lets you set an alias. This is the name of the object that the player sees. In the example of multiple televisions, each of your TV objects could have an alias of "TV".
+
+If you don't set an alias, players will see the object name – so you only usually need to worry about this if you want different objects to have the same displayed name.
+
+So for now, we can leave the Alias box blank for the TV.
+
+### Other names
+
+If you go to the _Object_ tab, you'll see an "Other Names" box. This lets you specify additional names that players can use to refer to this object. It is important to note that different players will have different ways of interacting with your game – many players prefer to use hyperlinks, but some prefer to type. You want to make it easy for Quest to understand what players type in, so you can add additional, alternative object names to ensure that happens. For example, for our TV object, some players might type in `LOOK AT TELEVISION`, and would reasonably expect that to work.
+
+So, add "television" to the list of Other Names for this object. This will ensure that players can type in either `LOOK AT TV` or `LOOK AT TELEVISION` to look at this object.
+
+As an exercise, add any other alternative names you think that players might want to use.
+
+
+
+### Description
+
+If you run the game and look at the TV, you’ll see that Quest doesn’t have much to say on the subject - it says "Nothing out of the ordinary".
+
+That's a bit boring - it's a sign of a bad game if you can't even be bothered to come up with descriptions for all your objects. We don't want to make a bad game, so let's add a description for this object. To do this, go to the Description drop-down in the bottom half of the object's _Setup_ tab and select "Text".
+
+
+
+Now enter the description. You could write something like "The TV is an old model, possibly 20 years old. It is currently showing an old western."
+
+Launch the game again and verify that it now shows you the description when you look at the TV.
+
+### Adding a verb
+
+It is a good idea to think about what kinds of things players might try to do to any objects in your game. In our example of the TV, it seems likely that a player might try to type `WATCH TV`, so it would be good if our game came up with a good response, rather than just saying it didn't understand.
+
+To do this, let's add the verb "watch" to our TV object. As you should remember from school, verbs are "doing words", and that's what they are in Quest – verbs let you say what things can be "done" to your object.
+
+Go to the _Verbs_ tab, click the "Add" button and type "watch". You can choose either to print a message or run a script when the player watches the TV. Enter a message. For example, "You watch for a few minutes. As your will to live slowly ebbs away, you remember that you’ve always hated watching westerns."
+
+
+
+### Exercises
+
+As an exercise, add the following objects to the lounge:
+
+- A sofa. Give it a sensible description. Add a verb "sit on" so that the player can type `SIT ON SOFA`. This should print a message like "There’s no time for lounging about now."
+- A table. Enter a sensible description.
+- A newspaper. Enter a description, and add a verb "read" which will print an appropriate message.
+
+Launch the game and verify that the objects you've just created have been set up and are working correctly – check that you can watch the TV, try to sit on the sofa, and read the newspaper.
+
+We're now on our way to making our first text adventure game. You may have noticed that, so far, we've only been walking around the game world and looking at things – we've not yet managed to interact with it and change it. We'll start to do that in the next section, where we look at taking and dropping objects.
diff --git a/site/src/content/docs/tutorial/custom_attributes.md b/site/src/content/docs/tutorial/custom_attributes.md
new file mode 100644
index 000000000..970b483f7
--- /dev/null
+++ b/site/src/content/docs/tutorial/custom_attributes.md
@@ -0,0 +1,55 @@
+---
+title: Custom attributes
+sidebar:
+ order: 6
+---
+
+We'll now start creating things in the kitchen, where we'll look at some more of Quest's features.
+
+Enter a description like "The kitchen is cold and the stench of the overflowing bin makes you feel somewhat faint." As an exercise, add a scenery object called "bin" and give it a sensible description.
+
+We're now going to look at **attributes**. Every time we've edited any aspect of an object or room so far, we've actually been editing an attribute. The prefix, description, "take" behaviour and so on are all attributes of an object. Whenever something changes in the game, it is a change in an objects attribute. When the TV is turned on or off, the "switchedon" attribute is changing. Even when the player moves, this is actually just changing an attribute of the player object called "parent".
+
+In this example, we'll store the weights of various objects by creating a new "weight" attribute. Later we will create a "weigh" command which will tell us the weight of **any** object.
+
+First, let us create a few objects we can weigh. Create three objects – flour, eggs and sugar. Make sure the object types are set correctly (either "inanimate object" or "inanimate object (plural)"). We'll use units of grams, so we'll say the flour has a weight of 500, the eggs have a weight of 250, and the sugar has a weight of 1000.
+
+## The attributes tab
+
+Click an object and select the Attributes tab - you'll see all the underlying data for the object. We can also use the Attributes tab to add our own custom data to any object.
+
+So let's give the new objects weights. First we'll set the flour's "weight" attribute to 500. To do this, select the flour object and go to the Attributes tab. We'll look at "Inherited Types" later - for now, go to the Attributes table and click the Add button. Enter the name "weight". We want to to use whole numbers for weight values, so select "Integer" from the list and enter the value "500".
+
+
+
+## Reading attributes
+
+You can read an attribute from any script command by using an **expression**. Expressions let you perform calculations, run functions, and read the values of variables and attributes. To read an attribute, you use this form:
+
+```quest
+object.attribute
+```
+
+For example, to read the weight of the eggs, you would type:
+
+```quest
+eggs.weight
+```
+
+Let's update the "look at" description of the eggs, as an example. Select the eggs object, and then under the "Setup" tab, change the "Look at" description to "Run script". Add the "print a message" command. Now, instead of printing a normal message (which would never change, and can't read attributes), we want to print the result of an expression. So, click the "message" drop-down and select "expression" instead.
+
+
+
+Enter this expression exactly, including the quotation marks in the correct place:
+
+```quest
+"A box of eggs, weighing " + eggs.weight + " grams."
+```
+
+This will insert the value of the "weight" attribute in our text.
+
+Always make sure that your object and attribute names match the expression exactly - it is recommended that you always use lower-case object and attribute names, as these names are case-sensitive.
+
+Launch the game and verify that the correct response is displayed – it should read "A box of eggs, weighing 250 grams."
+
+Of course, we could have just manually entered this into the description of the eggs anyway – we didn't really need to use an attribute. The real power of this, though, is that you can easily change attributes while the game is running. We could change the "weight" attribute of the eggs for example, if the player used some of them to bake a cake (but, er, hopefully you'll have some slightly more exciting ideas for things that players can do in *your* game). After the attribute is updated, our "look" description would automatically reflect the current weight of the eggs.
diff --git a/site/src/content/docs/tutorial/custom_commands.md b/site/src/content/docs/tutorial/custom_commands.md
new file mode 100644
index 000000000..7807fe570
--- /dev/null
+++ b/site/src/content/docs/tutorial/custom_commands.md
@@ -0,0 +1,120 @@
+---
+title: Custom commands
+sidebar:
+ order: 7
+---
+
+In this section, we will add a **command** that lets the player "say" something, and another to "weigh" an object.
+
+Note that we will *not* be using a **verb** here, as we have done before. Why not? Verbs are good when you want to have a *separate* response for each object, what we want is *one* script that will return information about *any object* in the case of "weigh", or that has no object in the case of "say". For this, we need to use a **command**.
+
+## Adding a simple command
+
+Let's add a simple command - "say". This will let the player type conversation prefixed with the command "say," for example `SAY HELLO`. Quest will respond with "You say 'hello.' We will also add the contextual text of "but nobody replies" as no-one is present in the game at this point.
+
+To add a command, select "Commands" in the tree (underneath "game"), then click "+ Add" and choose "Add Command" (or use the "..." menu on "Commands").
+
+Enter the following text into the command pattern box:
+
+ say #text#
+
+This pattern handles the player typing the command "say" followed by any text. The text following the "say" command is then put into a string variable called "text".
+
+For example:
+
+- If the player types `SAY HELLO`, the "text" string variable will contain "hello"
+- If the player types `SAY WHAT A LOVELY DAY`, the "text" string variable will contain "what a lovely day"
+
+We can read string variables within an expression, in exactly the same way as we read object attributes in the previous section.
+
+Whenever the player types in a command that matches the command template, the command's script will be run. Let's now add a script to print the required response. Add a "print a message" command and choose "expression". Then enter this expression:
+
+```quest
+"You say \"" + text + "\", but nobody replies."
+```
+
+What's with the backslashes in there? They let us use quotation marks inside an expression - if you put a backslash before a quote character, that quote character won't be interpreted as the end of a string.
+
+But if you don't like the backslashes, you could use single quotes quite safely instead:
+
+```quest
+"You say '" + text + "', but nobody replies."
+```
+
+
+
+Launch the game and type in a few `SAY` commands to see that Quest responds correctly.
+
+## Alternative command patterns
+
+You can easily add alternatives to a command pattern by separating them with semicolons. For example, we could adapt our `say #text#` command to deal with "shout" and "yell" by modifying the pattern to read:
+
+ say #text#; shout #text#; yell #text#
+
+## Adding a "Weigh" command
+
+We now know how to add a command that will process any kind of text the player enters. However, a lot of the time, our commands will be dealing with objects that the player can see. To handle objects correctly, just use the variable name "object". So the "weigh" command's pattern should be:
+
+ weigh #object#
+
+If you want to create a command that uses multiple objects, you can call your variables "object1", "object2" etc. - in fact anything starting with "object" will work.
+
+The script we enter for the "weigh" command should respond "It weighs X grams", where X is the weight of the item – as reported by its "weight" attribute.
+
+We can read the weight attribute in the same way as before:
+
+```quest
+object.weight
+```
+
+Previously, we put an actual object name before the dot. This time, we're putting a variable name there - so we'll read the "weight" attribute of the object that the player entered.
+
+Add the "weigh" command using the command pattern above, and add a "print a message" command to print this expression:
+
+```quest
+"It weighs " + object.weight + " grams."
+```
+
+
+
+Launch the game and go to the kitchen. See what happens when you type `WEIGH FLOUR`, `WEIGH SUGAR` etc.
+
+Now go back to the lounge. What happens when you weigh Bob?
+
+Quest responds with "It weighs grams." Why? Because he doesn't have a "weight" attribute. Since we don't want to have to enter a weight for every single object in the game, we'll need to update our command so it checks for the existence of the "weight" attribute, and then prints the appropriate response.
+
+## Checking for an attribute
+
+Go back to the script for the "weigh" command, select the existing "Print a message" command and click the "Cut" button on the Script Editor to move this to the clipboard.
+
+Now, add a new "if" command. From the dropdown, select "object has attribute". Enter the object expression "object". For the attribute, leave "name" selected and type in "weight".
+
+Expand the "Then" script, and click Paste to restore the previous "print a message" script. For the "Else" script, print a message like "You can't weigh that".
+
+The script should now look like this:
+
+
+
+Launch the game and verify that you now get a sensible response for `WEIGH BOB` and `WEIGH SOFA` in the lounge (it should say "You can't weigh that") and that you can still weigh the items in the kitchen.
+
+## Additional example (advanced)
+
+Quest can handle text and objects in the same command. Here the say command is extended to allow the player to specify who she is talking to.
+
+
+
+The pattern you are using is this:
+
+ say #text_talk# to #object_one#
+
+Quest will attempt to match `#object_one#` to an object present, and if it does then an object variable called "object_one" will be set to that object (if it cannot, Quest will output whatever you typed in the "Unresolved object text" box). The text part will match any text at all, just as before.
+
+Suppose the player types:
+
+ SAY HI TO TROLL
+
+Quest matches "say" and "to" directly. It then matches "hi" to the text, so now the string variable "text_talk" is set to "hi". Then it matches the object, as long as the troll is here, and sets the object variable "object_one" to the troll.
+
+The script uses a switch command so you get a different response for different characters, and a default too.
+
+You can set up commands with multiple objects just by giving them each their own name between the `#` marks.
diff --git a/site/src/content/docs/tutorial/interacting_with_objects.md b/site/src/content/docs/tutorial/interacting_with_objects.md
new file mode 100644
index 000000000..bd70679c6
--- /dev/null
+++ b/site/src/content/docs/tutorial/interacting_with_objects.md
@@ -0,0 +1,109 @@
+---
+title: Interacting with objects
+sidebar:
+ order: 3
+---
+
+## Object types
+
+An object's _Setup_ tab lets you choose the type of object. Select the "TV" object we created in the last section.
+
+The first "Type" dropdown at the top of the screen lets you select from:
+
+- Room
+- Object
+- Object and/or room
+
+Rooms and objects are really the same thing in Quest - the option you select here simply lets the editor show you only what's relevant for the current object. If you wanted to create a cupboard inside a room that the player could get in, you might want to select "object and/or room" here - but in this tutorial, just leave this option set to the default.
+
+The second "Type" dropdown lets you select from the following types:
+
+- Inanimate object
+- Male character
+- Female character
+- Inanimate objects (plural)
+- Male characters (plural)
+- Female characters (plural)
+
+Whichever type you select, the object will behave in pretty much the same way, except that Quest's default responses will make a lot more sense if you set this correctly. This is because setting the type will update the Gender and Article (you can also override these manually).
+
+- **Gender**: Usually "it", "he", "she" or "they". Quest uses this for sentences such as "*It* is closed", "*He* says nothing" and so on.
+- **Article**: Usually "it", "him", "her" or "them". Quest uses this for sentences such as "You pick *it* up", "You can't move *her*" and so on.
+
+## Scenery
+
+The "Scenery" option means that the object won't be displayed automatically in the room description, or the "Places and Objects" on the right of the screen.
+
+Why might we want to do this? Well, when we created our "lounge" description in the previous section, we wrote "This is quite a plain lounge with an old beige carpet and peeling wallpaper". What if the player types `LOOK AT WALLPAPER`? Quest will reply "I can't see that here", which will be a bit strange.
+
+Although the wallpaper isn't an important object, we should still have a response for `LOOK AT WALLPAPER`. If we make it a scenery object, it's "in the background" as far as the game goes, as it won't appear in the "Places and Objects" list, or in the list of objects in the description of the room. We won't be cluttering things unnecessarily, but we will still be providing responses for anything the player might reasonably type in.
+
+So, create a new object called "wallpaper" and tick the Scenery box. Enter a description like "The horrible beige wallpaper hangs loosely on the walls."
+
+Launch the game and verify that although the wallpaper doesn't explicitly appear in the description, you can still get a sensible response by typing `LOOK AT WALLPAPER`.
+
+## Exercise
+
+Remember that we also mentioned the carpet in the room description as well. As an exercise, add this as another scenery object, and give it a sensible description.
+
+## Creating a character
+
+Let's create our first character. He'll be about as basic a character as you can get, and he won't be the most talkative. This is because he's dead. Well, you've got to start somewhere.
+
+Create a new object called "Bob" and change his type to "Male character". Give him a "look" description of "Bob is lying on the floor, a lot more still than usual."
+
+There is one other thing we need to do. If you run the game now, you'll see the room description says "You can see a TV, a sofa and *a* Bob". There's only one Bob - well, in this game anyway - so we want to get rid of that "a". Where did that even come from? The answer is the prefix.
+
+## Prefix and suffix
+
+A prefix and a suffix let you insert text before and after the object name when it's displayed in a room description. When you leave "Use default prefix and suffix" checked, the suffix is blank, and the prefix (for English games) is either "a" or "an" depending on whether the object name (or alias) begins with a vowel.
+
+You can specify your own text by unchecking the box. Two new textboxes will appear, and for our "Bob" character you can just leave them blank, as we don't want any text added around our object name.
+
+A quicker way of doing this is to select "Male character (named)" from the types list.
+
+We will come back to Bob later in the tutorial, where we will make him a little more animated.
+
+## Taking the newspaper
+
+You should have added a newspaper object as an exercise at the end of the previous section. If you didn't, add one now. We're going to make this an object that the player can take.
+
+This is very easy to do - simply go to the _Inventory_ tab. You have a couple of different options for "Take":
+
+- Default behaviour: You'll want to use this for most of your take-able objects. This option gives you the "Object can be taken" checkbox, and the ability to specify a "take message" which is printed when the player takes (or attempts to take) the object.
+
+- Run script: If you want full control over what happens when the player attempts to take the object, choose this option. We will cover scripts later on in the tutorial, so don't choose this for now.
+
+To let the player take the newspaper, we just need to tick the "Object can be taken" box. If you don't specify a message, you'll get a default message - "You pick it up". If you want something a bit more imaginative, you can enter a Take Message such as "You fold the newspaper and place it neatly under your arm".
+
+
+
+The "Object can be dropped" box is ticked by default, so the player can take and drop the object as many times as they like. The options are the same as for "Take", so you can specify your own drop message or completely customise the behaviour with a script if you like.
+
+## Switching the TV on and off
+
+Let's make it possible to turn the TV off.
+
+Quest has a whole bunch of features built in that you can add to your objects. To keep them manageable, objects have a _Features_ tab, and you can select the ones you want for each object. Ticking a feature here will make the appropriate tab display, and you can then go to that tab to turn the feature on, and set it up as you like. Settings on the _Features_ tab only determine what other tabs are shown, they have no effect themselves on the object when playing the game.
+
+Switchable is one such feature, so the first step is to go to the _Features_ tab, and tick "Switchable". This will display the _Switchable_ tab for the TV.
+
+Now go to the _Switchable_ tab. You'll see a dropdown labelled "Switchable" - select "Can be switched on/off" and various options will appear.
+
+The options should be fairly self-explanatory - you can choose whether the object is switched on when the game begins, and the text to print when switching on/off. Finally, you can choose some extra text to add to the object description. This lets you have show text depending on whether the object is switched on or off.
+
+Enter some sensible text for these, for example as shown below.
+
+
+
+Go back to the Setup tab and change the "Look at" description so it just reads "The TV is an old model, possibly 20 years old."
+
+Now when you play the game, you get sensible behaviour for `SWITCH ON TV`, `SWITCH OFF TV`, and alternative forms of the command.
+
+
+
+Notice that by setting this object up as "Can be switched on/off", two new options "switch on" and "switch off" have automatically appeared in the TV hyperlink menu.
+
+## Exercise
+
+Add a "switchable" lamp to the lounge that is switched on at the start of the game.
diff --git a/site/src/content/docs/tutorial/more_things_to_do_with_objects.md b/site/src/content/docs/tutorial/more_things_to_do_with_objects.md
new file mode 100644
index 000000000..4fca19459
--- /dev/null
+++ b/site/src/content/docs/tutorial/more_things_to_do_with_objects.md
@@ -0,0 +1,112 @@
+---
+title: More things to do with objects
+sidebar:
+ order: 9
+---
+
+## Giving and using objects
+
+After a player has taken an object, they can give that object to, or use it on, other objects in the game.
+
+For example, after picking up some flowers, a player can give them to a character called "Susan" by typing `GIVE FLOWERS TO SUSAN`.
+
+"Give" and "Use" are set up in exactly the same way – the only difference is whether the player types "give ... to ..." or "use ... on ...".
+
+In this example, we are going to revive the corpse of Bob in the lounge, using a [heart defibrillator](http://en.wikipedia.org/wiki/Defibrillation). First, we need to alter the setup of Bob so that we give a correct description whether he is dead or alive. To do this, we are going to use an **object flag**.
+
+An object flag is simply a way of accessing a boolean attribute of an object (a boolean attribute can only be either "true" or "false"). You can use it to mark certain things as "done", and is a common way to track the progress of a game.
+
+It is good practice to give flags meaningful names, and to have them start off (or false). For Bob, we will call the flag "alive".
+
+So, using the defibrillator on Bob is going to add a flag called "alive" to Bob. In the "look at" description, we'll check whether Bob has his "alive" flag set. If so, we'll print "Bob is sitting up, appearing to feel somewhat under the weather", and if not, we'll print the old description "Bob is lying on the floor, a lot more still than usual."
+
+## Updating the description
+
+Getting the correct description to display is very similar to the "watch" example in the [Using scripts](/tutorial/using_scripts) section.
+
+Change Bob's "look at" description to "Run script", and add an "if" command.
+
+For the expression, choose "object has flag". Then select "Bob" and enter the flag name "alive". Add the descriptions above for "then" and "else" messages.
+
+
+
+## Using the defibrillator
+
+Now, add a "defibrillator" object to the lounge. Enter a description like "A heart defibrillator can magically revive a dead person, if all those hospital dramas are to be believed."
+
+Go to the _Inventory_ tab and tick "Object can be taken".
+
+Now go back to Bob. We need to turn on "Use/Give", so on his "Features" tab, tick the "Use/Give:..." check box. His _Use/Give_ tab should now appear.
+
+Go to the "Use (other object) on this" section of the new "Use/Give" tab, and choose "Handle objects individually".
+
+
+
+A list will appear in the "Use" section. Here you can add the objects that can be used on Bob, with a script for each. Click "Add" and type "defibrillator".
+
+A Script Editor window will now pop up. In this script, we want to print a message to tell the player what's happening, and also set the "alive" flag on Bob.
+
+So, add a "print a message" script and type something like "Miraculously, the defibrillator lived up to its promise, and Bob is now alive again. He says his head feels kind of fuzzy."
+
+Now add a "Set object flag" command. Choose Bob from the objects list, and enter the flag name "alive".
+
+
+
+Close the Script Editor window. Now run the game. Look at Bob, then type `USE DEFIBRILLATOR ON BOB`, then look at Bob again. Verify that you see the correct text in each case.
+
+Notice what happens when you type `USE DEFIBRILLATOR ON BOB` a second time - you get the same response again. You should know how to fix this now - update your "use defibrillator on bob" script to check for the "alive" flag. Update this now (if you are struggling, just move on to the next section, where it will be revealed!).
+
+## Using functions
+
+It would be good if we could get the same effect just by typing `USE DEFIBRILLATOR`. There are a couple of things we could do here, from the defibrillator _Use/Give_ tab, under "Use (on its own)":
+
+- we can tick "Display menu of objects this can be used on". This will allow the player to select Bob to use the defibrillator on.
+- we can choose "Run script" from the Action list, to automatically defibrillate Bob.
+
+The first option is definitely the easiest, but the second option allows us to demonstrate the use of functions.
+
+Of course, we could simply copy the script we created in the section above, and paste it into the defibrillator's "use this object (on its own)" script. However, if we then wanted to make an update to one script, we would have to update the other one as well.
+
+The best way to resolve this is to make both our existing `USE DEFIBRILLATOR ON BOB`, and our new `USE DEFIBRILLATOR` point to the same script. The way to do this is to set up a **function**.
+
+Functions provide a way for you to set up scripts that can be called from anywhere in your game, so you don't have to keep copying and pasting or re-entering the script.
+
+Let's create one now to store the script commands we use to resuscitate Bob. We can then set both `USE DEFIBRILLATOR ON BOB` and `USE DEFIBRILLATOR` to call this function.
+
+First, go to Bob's _Use/Give_ tab, and double click "defibrillator" in the "Use" table to bring up the Script Editor. Hold down the shift key and select all script lines, then click the Cut button to move this script to the clipboard. Now close the window.
+
+Now add a new function (right click the tree, or use the Add menu), and call it "revive bob". For the script, click the Paste button.
+
+
+
+Now we just need to update `USE DEFIBRILLATOR ON BOB` and create `USE DEFIBRILLATOR`, to make them call this function. Go back to Bob's use/give tab, and double-click to edit the defibrillator script again.
+
+Add a "call function" script, and enter the name "revive bob". Now close the window.
+
+Now we're kind of back where we started - if you run the game and type `USE DEFIBRILLATOR ON BOB`, it calls the "revive bob" function and so we get exactly the same behaviour as before.
+
+We just need to make `USE DEFIBRILLATOR` call the same function now, so go to the defibrillator's use/give tab, and in the "use (on its own)" section choose "Run a script". Add a "call function" script, so that it also calls the "revive bob" function.
+
+
+
+Launch the game now and verify you get the same response whether you type `USE DEFIBRILLATOR ON BOB` or just `USE DEFIBRILLATOR`.
+
+Note that if you pick up the defibrillator and go to the kitchen, `USE DEFIBRILLATOR` will still work. It would be pretty remarkable for a defibrillator to work at such a long range, so consider adding an "if" command to the "revive bob" procedure. You can select "player is in room" from the list of conditions to check whether the player is in the lounge before carrying out the defibrillation. If they're not in the lounge, print a suitably sarcastic message.
+
+## Giving objects
+
+Giving an object to a character works in exactly the same way as using objects on a character. Look at the "Give" sections - notice that you get the same options as for "Use". You can add objects to it in the same way.
+
+## Ask and tell
+
+Ask and Tell work in the same way, so we'll only cover "ask" here.
+
+Click on Bob and go to the "Ask/tell" tab. As with "Use/Give" this will have to be turned on, but it is done globally, which means you only need to do it once for your whole game, and that it is done from the _Features_ tab of the "game" at the top of the list on the left.
+
+On Bob's "Ask/Tell" tab you can add subjects to the list, and give a script for each subject. You can also give a script to run when the player asks Bob about something he doesn't know about.
+
+Let's make Bob respond to a question about the massive heart attack he's just amazingly recovered from. Click the "Add"  button and enter some topic keywords, for example "heart attack cardiac arrest". When the player asks Bob about anything, this list of keywords is checked for matches in the player's command. So the player could type `ASK BOB ABOUT HEART` or `ASK BOB ABOUT CARDIAC ARREST`, and that will match this topic.
+
+A Script Editor will appear. It would make sense that the player can only ask questions of Bob when he's been brought back to life, so the first thing to do is add an "if" command and check that Bob's "alive" flag is set. If it is, he can say something like "Well, one moment I was sitting there, feeling pretty happy with myself after eating my afternoon snack - a cheeseburger, pizza and ice cream pie, smothered in bacon, which I'd washed down with a bucket of coffee and six cans of Red Bull - when all of a sudden, I was in terrible pain, and then everything was peaceful. Then you came along."
+
+
diff --git a/site/src/content/docs/tutorial/moving_objects_during_the_game.md b/site/src/content/docs/tutorial/moving_objects_during_the_game.md
new file mode 100644
index 000000000..edc9bc742
--- /dev/null
+++ b/site/src/content/docs/tutorial/moving_objects_during_the_game.md
@@ -0,0 +1,46 @@
+---
+title: Moving objects during the game
+sidebar:
+ order: 12
+---
+
+As your game unfolds and the player interacts with your world, you may want to bring additional objects into play, or remove others. In this example, we'll add a window to the kitchen. When the player opens it, a bee flies in. In the next section we'll make this bee quite irritating.
+
+## Creating a hidden object
+
+First, let's create the "bee" object. We don't want this object to appear anywhere when the game starts, so create it outside of a room. It's best to create a special room, perhaps called "nowhere" or "limbo" or "offstage", and keep all your hidden objects there. Give the bee a suitable description.
+
+## Bringing the object into play
+
+Now, add a window object to the kitchen and give it a sensible description. We want to make this window openable, but it's not a container, as you can't put things in a window. We can't add "open" as a verb though, because the "open" command is handled by Quest's container logic. The solution is to go to the _Container_ tab (via the _Features_ tab, of course) and select "Openable/Closable" from the Container Type list. This provides basic functionality for opening and closing an object, but it doesn't do anything else.
+
+Choose "Openable/Closable", and now add script commands to the "Script to run when opening object":
+
+- Open object: window
+- Print a message: "You open the window and a bee flies into the kitchen."
+- Move object "bee" to "kitchen"
+
+For the close script, you just need to add:
+
+- Print a message: "You close the window."
+- Close object: window
+
+Launch the game and go to the kitchen. Open the window and verify that you can now look at the bee.
+
+## Checking if the object is already there
+
+What if the player closes the window and then opens it again? They'll be told that the bee has flown in again, which doesn't make sense as it is already there.
+
+One way to get around this might be to use an object flag, as we've done before. However it's even simpler just to check if the bee is in the kitchen. Add an "if" command and choose "object contains". Now you can select "kitchen" as the parent and "bee" as the child.
+
+For the "then" script, print a message such as "You open the window. Not much happens."
+
+Now cut and paste the existing "print a message" ("a bee flies in...") and "move object" to the "Else".
+
+
+
+## Removing an object during play
+
+As well as bringing an object into play, you can also remove an object from play using the "Remove object" command from the Objects category. This will set the object's parent to "null", so you can always bring it back into play again later. To destroy an object entirely, use the "Destroy an object" command - the object will be completely removed from the game. It is more efficient to simply remove the object from play though - it is less work for Quest to simply unset the object's parent than it is to remove *all* the object's attributes and destroy it - so it is recommended that you use "remove" in preference to "destroy".
+
+As an exercise, add an "apple" object, with a sensible description. Add an "eat" verb to the object which will print a message saying "You eat the apple. Tasty." and then remove the apple from play (though it is worth noting that items can be set to be edible via the Edible tab).
diff --git a/site/src/content/docs/tutorial/releasing_your_game.md b/site/src/content/docs/tutorial/releasing_your_game.md
new file mode 100644
index 000000000..35c4ea209
--- /dev/null
+++ b/site/src/content/docs/tutorial/releasing_your_game.md
@@ -0,0 +1,62 @@
+---
+title: Releasing your game
+sidebar:
+ order: 15
+---
+
+There are five stages to releasing a Quest game.
+
+1. Before release testing
+2. Upload as an unlisted game
+3. Upload testing
+4. Public upload
+5. Announcement
+
+
+## Before release testing
+
+Before you even think about releasing your game, you need to thoroughly check to make sure it works properly, and that it has sensible responses for things that a player might reasonably type while playing it. To create a good game is a lot of hard work, and while it might be tempting to release your first efforts after a minimal amount of testing, your players won’t thank you for it.
+
+And no matter how tempting it might be, do not even *think* about releasing the game you’ve created while working your way through this tutorial!
+
+Here are some things to think about before unleashing your game on an unsuspecting public:
+
+- Think about all the objects a player might refer to – make sure everything you refer to in your descriptions is at least set up as a scenery object.
+- Think about all the different things a player might reasonably try to do with an object – set up verbs, even if they just tell the player that they can't do that.
+- Think about all the different ways a player might type a command, and make sure you have enough verb alternatives set up.
+- Think about the different ways a player might refer to the same thing, and set up alternative names.
+- Make sure you **test** your game thoroughly.
+- Spell check!
+
+
+
+
+## Upload as an unlisted game
+
+By default your game will be unlisted; leave it like that for now.
+
+In the editor, open the **File** menu in the toolbar and choose **Publish…**. This builds a `.quest` package (your game file plus its assets) and downloads it.
+
+On textadventures.co.uk, click on _Create_ at the top, then _Upload_ game below that. Then follow the instructions.
+
+For more on the Publish tool, including size limitations and what gets included in the .quest file, see [Publishing](/publishing/publishing).
+
+
+## Upload testing
+
+Now play the uploaded version of your game. I would recommend saving, and ensuring a saved game can be loaded and looks okay, as saving and loading tend to be especially sensitive to errors in scripts!
+
+Now get some other people to test it – you'll be surprised at all the things they pick up that you would never have thought of. This is called beta-testing, and while it can be a pain, especially as you are keen to get your game out there fast, it is well worth it in the long run. A couple of bugs in your game will quickly lead to bad reviews.
+
+
+## Public upload
+
+Once all the bugs are sorted, upload your game again, just as before. Check the game listing text is fine, and set who can access the game to everyone. Congratulations, your game is now live!
+
+
+## Announcement
+
+Now all you have to do is tell people about it! See [Publishing](/publishing/publishing) for a list of places you can announce your game.
+
+
+
diff --git a/site/src/content/docs/tutorial/status_attributes.md b/site/src/content/docs/tutorial/status_attributes.md
new file mode 100644
index 000000000..7c4251c3d
--- /dev/null
+++ b/site/src/content/docs/tutorial/status_attributes.md
@@ -0,0 +1,24 @@
+---
+title: Status attributes
+sidebar:
+ order: 13
+---
+
+Often you will want the player to be able to see how they are doing at a glance, perhaps to see the score or health, or how much cash they have. This can be done with status attributes.
+
+Status attributes must be set up as ordinary attributes first. You must then tell Quest that you want these particular ones to be shown in the interface. You can do this with attributes of the player or of the game object, but not anything else in the game. We will set up a score attribute on the player object.
+
+
+## Status attributes
+
+Go to the _Attributes_ tab of the player object. In the lower box, click "Add", then type "score" and set it to an integer. Then go to the upper box, marked "Status Attributes", click Add.
+
+We can going to give Quest two bits of information. The first is the name of the attribute, and the second is how to display it, so again type "score" for the first bit (this must be exactly as you did it before, because Quest will need to match this to the attribute). You can leave the second bit blank, and Quest will decide how to display it, but we try to do it a bit more fancy. Paste in this:
+```
+Score: !/10
+```
+The exclamation mark is a stand-in for the actual number, so when the score is zero, the player will see "Score: 0/10".
+
+Start the game, and find that a new panel has a appeared on the right, with the score displayed!
+
+You can use status attributes with any type of attribute (on the game or player), but it works best with numbers and strings.
diff --git a/site/src/content/docs/tutorial/tutorial_introduction.md b/site/src/content/docs/tutorial/tutorial_introduction.md
new file mode 100644
index 000000000..3cb22d024
--- /dev/null
+++ b/site/src/content/docs/tutorial/tutorial_introduction.md
@@ -0,0 +1,46 @@
+---
+title: Tutorial introduction
+sidebar:
+ order: 1
+---
+
+## Introduction
+
+[Quest](https://textadventures.co.uk/quest) is a program for writing text adventure games and gamebooks (both of which are sometimes referred to as [Interactive Fiction](http://en.wikipedia.org/wiki/Interactive_fiction)).
+
+You can use it in two ways:
+
+- in your web browser (Chrome, Edge, Safari, Firefox), without downloading any software
+- as a downloadable desktop app
+
+Both give you the same editor, so this tutorial applies either way.
+
+## What is a text adventure?
+
+Text adventure games were the earliest type of computer game, from a time when computers could only display text - there were no graphics, so everything was described with text. You would play the game by typing commands with the keyboard such as `GO NORTH` or `HIT TROLL`. Quest lets you make this kind of game - you can include graphics now though, and play the game by clicking hyperlinks instead of having to type everything.
+
+## Why create text adventures
+
+Here are some reasons why interactive text games are great:
+
+### Interactive text games are easy to create
+You don't need to have a team of people creating graphics, music and sound effects. You don't even need any programming experience. If you've never created a game before, a text game is the easiest and quickest way to start. This doesn't mean that it's trivial - creating a good game, like creating a good novel, takes a lot of effort - but you don't need to have any special tools or expertise to start.
+
+### Interactive text games are accessible
+You don't need fast reactions to play a text-based game. In fact, you don't even need to be able to see - text-based games are one of the few types of games that the visually impaired can enjoy, using a screen reader to speak the text aloud. You don't need to have a particular type of computer - you can play a text game using nothing more than a web browser. All of this means that a text game can be played by just about anybody.
+
+Using Quest, you can play and create text-based games, which can include pictures, sounds and video. To play some games which people have created already, see [textadventures.co.uk](https://textadventures.co.uk/).
+
+If you have some time to spare, it's well worth watching the documentary [Get Lamp](http://www.youtube.com/watch?v=LRhbcDzbGSU) - it's a brilliant telling of the history of text adventure games.
+
+You can find another great introduction for beginners at [Brass Lantern](http://www.brasslantern.org/beginners/).
+
+## Programming without programming
+
+Quest is a powerful system with a gentle learning curve - you can get started very easily without doing any programming at all, and build up from there. The point and click editor means there's no need to remember syntax, type in strange punctuation or even remember commands. But there is a lot of power underneath - a full programming language in fact. You never need to see any code to access the full power of Quest, but it includes a "Code View" feature so it's there if you need it.
+
+## Let's begin
+
+[Next: Creating a simple text adventure](/tutorial/creating_a_simple_game)
+
+[Next: Creating a simple game book](/tutorial/creating_a_gamebook)
diff --git a/site/src/content/docs/tutorial/using_containers.md b/site/src/content/docs/tutorial/using_containers.md
new file mode 100644
index 000000000..c5f0b356a
--- /dev/null
+++ b/site/src/content/docs/tutorial/using_containers.md
@@ -0,0 +1,97 @@
+---
+title: Using containers
+sidebar:
+ order: 11
+---
+
+Containers are objects that can contain other objects. In this example, we'll create a "fridge" object in the kitchen, which contains several items of food and drink. The fridge is initially closed, so these items will only be visible once the player has opened the fridge.
+
+## Creating the fridge
+
+Create a "fridge" object in the kitchen and give it a description like "A big old refrigerator sits in the corner, humming quietly."
+
+Now let's set the fridge up as a container. This is a feature, so first go to the _Features_ tab, and tick "Container". Click the _Container_ tab. By default, "Not a container" is selected. Change this to "Closed container". The Container options will now appear.
+
+
+
+By default, the player can open and close the fridge. We're going to add some objects to the fridge in a moment, and it would be good if the contents were listed when the player opened the fridge, so tick the "List children when object is looked at or opened" option.
+
+## Adding objects to the fridge
+
+Now let's create some objects inside the fridge. To do this, we just create these objects as normal, but on the "Add object" window we set the parent to "fridge". Alternatively you can create the objects first and then move them - use "Move to..." to move an object into the fridge.
+
+Add the following objects: milk, cheese, beer. Give each object a sensible description. The prefix for each object should be "some", so that the room description sounds natural. Allow each object to be taken.
+
+Now run the game and go to the kitchen. Notice that you can't see the milk, and if you type something like `LOOK AT MILK`, Quest will tell you that it's not here. Now open the fridge, and the objects inside it will be revealed.
+
+By setting the "List prefix" you can change the "It contains" text which appears before the list of objects.
+
+
+
+Run the game and open the fridge again, and you'll see the contents listed with your custom prefix:
+
+
+
+## Updating the description
+
+In your "look at" description, you can check if the object is open by running a script. Add an "if" command and choose "object is open" - then you can print a different message depending on whether the fridge is open or closed.
+
+When the fridge is open, you might print "The fridge is open, casting its light out into the gloomy kitchen". When it is closed, you might print "A big old refrigerator sits in the corner, humming quietly".
+
+As an exercise, add a closed cupboard to the kitchen. Add a few items to the cupboard such as a tin of beans, a packet of rice etc. The player should be able to open and close the cupboard. When Quest lists the contents of the cupboard, it should say something like "The cupboard is bare except for ..."
+
+## Transparency
+
+When you set the "Transparent" option, the player can see what objects are inside the container, even if it is closed.
+
+Although the player can see what's inside a transparent container, they still can't take objects from it or put objects in it unless it is open.
+
+## Surfaces
+
+Surfaces act very much like containers - they act as an always-open container, and objects that are on a surface are visible in a room description even before the player has looked at the surface. For this reason they’re a good choice for implementing things like tables. As an exercise, change the table object in the lounge to make it a surface (or create it if you haven't already). Then move the newspaper so that it is on the table.
+
+## Lockable containers
+
+What if you don't want the container to be immediately openable? If it's part of a puzzle, you may want the player to have a particular "key" object before they can open it. To implement this, you can make the container lockable.
+
+Let's create a small (and, admittedly, tedious) puzzle - we're going to put the defibrillator in a locked box. The player must get the key from the kitchen, unlock the box, and then take the defibrillator from the box before they can revive Bob.
+
+Please bear in mind this is probably the most boring puzzle imaginable. It is just an example. Don't use it as a guide for something that would make your game more exciting - it's up to you to think of interesting puzzles!
+
+First, set up the objects:
+
+- create a "box" object in the lounge. Make it a closed container.
+- move the "defibrillator" object to the box (select the defibrillator and use "Move to...")
+- in the kitchen, add a "key" object, and make it takeable.
+
+Now, make the box lockable. Go to the Container tab and in the "Locking" section, choose "Lockable" from the lock types list. This will display the lock options. You can now choose the "key" object from the list.
+
+
+
+By default we have the "Automatically unlock if player has the key" and "Automatically open when unlocked" options turned on. This is out of politeness to players really, as there's no need to force them to jump through hoops and perform additional steps - if they've unlocked the object, it's a fair bet they want to open it, and if they type `OPEN BOX` before unlocking it, then if they have the key, there's no point in forcing them to type `UNLOCK BOX` first.
+
+It might be a good idea to tick the "List children when object is looked at or opened" option, in the main Container options. Now your game output will look like something this:
+
+```
+> OPEN BOX
+It is locked.
+
+> UNLOCK BOX
+You do not have the key.
+
+> S
+You are in a kitchen.
+[rest of kitchen description snipped...]
+
+> TAKE KEY
+You pick it up.
+
+> N
+You are in a lounge.
+[rest of lounge description snipped...]
+
+> UNLOCK BOX
+Unlocked.
+You open it.
+It contains a defibrillator.
+```
diff --git a/site/src/content/docs/tutorial/using_pages.md b/site/src/content/docs/tutorial/using_pages.md
new file mode 100644
index 000000000..d2d43136a
--- /dev/null
+++ b/site/src/content/docs/tutorial/using_pages.md
@@ -0,0 +1,52 @@
+---
+title: Using Pages
+sidebar:
+ order: 10
+---
+
+Bob is alive, and thanks to Ask/Tell he'll tell you about his heart attack if you ask him directly. But Ask/Tell only works if the player already knows what to ask about. Sometimes you want to offer the player a menu of things to say, and have Bob's replies lead on to further choices - a proper branching conversation.
+
+You could build this with `ShowMenu` (see [Handling SPEAK TO](/howto/npcs/speak_to)), but a menu-based conversation has a drawback: while the menu is open, the game is waiting on that one callback, so the player can't save or undo until they've picked an option. **Pages** solve this by turning every choice into a normal, complete turn - the same mechanism gamebooks use for their branching passages (see [Creating a gamebook](/tutorial/creating_a_gamebook)), but usable in a Text Adventure room. Nothing is "pending" between choices, so save, load and undo all work mid-conversation.
+
+## Creating a page
+
+A page is a special kind of object: instead of a room description, it has some text to show the player and a list of options leading to other pages.
+
+Right-click the tree (or use "+ Add") and choose "Add Page". Call it `bob_chat`. On its "Page" tab, leave "Page type" set to "Text", and enter a description like `Bob rubs his chest gingerly. "What do you want to know?" he asks.`
+
+Now add some options. In the "Options" list, click "Add", and when prompted for the page name enter `bob_defib` - this creates a new page for you - and for the link text enter "Ask about the defibrillator". Add a second option pointing to a new page called `bob_heart`, with the link text "Ask about his heart attack".
+
+Now fill in the two pages you just created:
+
+- **bob_defib**: a description like `"That thing? No idea how it works, but I'm glad you had it handy," he says.` Leave its options list empty.
+- **bob_heart**: a description like `"One moment I was enjoying a cheeseburger, the next everything went dark. Then you showed up," he says.` Add one option, back to `bob_chat`, with the link text "Ask something else".
+
+A page with no options automatically ends the conversation once it's shown - that's why `bob_defib` doesn't need anything special to close things off. `bob_heart` instead loops back round to `bob_chat`, so the player can keep asking things.
+
+## Starting the conversation
+
+Pages need something to kick them off. Go to Bob's Verbs tab and add a "speak" verb (Quest will match `TALK TO BOB` and `SPEAK TO BOB` to it - see [Handling SPEAK TO](/howto/npcs/speak_to) for more on this). For its script, switch to Code View and enter:
+
+```quest
+ShowPage (bob_chat, true, false)
+```
+
+The three parameters are: the page to start at; `allowCancel`, which if true means typing anything other than an option ends the conversation and runs normally (if false, the player is told to pick an option); and `runTurnScripts`, which controls whether turn scripts fire for each choice made - normally you want this off, so a hunger daemon or similar doesn't tick on every line of dialogue.
+
+## Trying it out
+
+Launch the game and type `TALK TO BOB`. You'll see Bob's greeting, followed by a numbered list of options - you can either type the number or click the link. Follow the "heart attack" branch a couple of times, then ask about the defibrillator to end the conversation. Try saving mid-conversation, then loading again - you'll find yourself right back in the chat where you left off.
+
+## Varying page text
+
+As with the gamebook page type, you can check whether a page has already been shown to the player with `HasSeenPage`, so a returning visit doesn't just repeat itself word for word. As an exercise, set `bob_heart`'s "Page type" to "Script + Text" - this adds a script that runs just before its description is shown - and enter:
+
+```quest
+if (HasSeenPage (bob_heart)) {
+ msg ("Bob sighs, clearly expecting the question this time.")
+}
+```
+
+Ask about his heart attack twice in a row, and you'll see the extra remark appear on the second visit, on top of the usual description underneath.
+
+For more on building larger dialogue trees - including how to add and remove options while the game is running - see [Building a conversation with Pages](/howto/npcs/dialogue_pages).
diff --git a/site/src/content/docs/tutorial/using_scripts.md b/site/src/content/docs/tutorial/using_scripts.md
new file mode 100644
index 000000000..ec9096dd8
--- /dev/null
+++ b/site/src/content/docs/tutorial/using_scripts.md
@@ -0,0 +1,41 @@
+---
+title: Using scripts
+sidebar:
+ order: 5
+---
+
+We'll now start to play with the real power behind Quest – scripts. Scripts let you do things within the game, change the game world, show pictures and more. With a script, you can print different messages or run other actions depending on the state of any object in the game.
+
+In this example, we'll use a script to customise the "watch" verb we added to the TV in the previous section. We want to update it to provide a sensible response depending on whether the TV is switched on or not.
+
+Select the TV object and go to the _Verbs_ tab. If you've been following all the steps in this tutorial, you should already have a "watch" verb which prints a message. If you've already got a "watch" verb, change it from "Print a message" to "Run a script" (or add a new "watch" verb if you don't already have one).
+
+Click the "Add new script" header and you'll see a list of all the commands you can add to a script. The commands are in broad categories - Output, Objects, Variables and so on - but you can also find a command by typing in the Search box, if you don't know the category.
+
+Go to the Scripts category and add the "If" command (you can click the "Add" button, or just double-click the command).
+
+
+
+The "if" command is hugely powerful, because it lets us choose which script to run depending on a condition that we set.
+
+After adding the command, you'll see the following editor:
+
+
+
+First, we need to add a condition. If you click the "expression" dropdown list next to the "If" label, you'll see a list of conditions that you can add. Select "object is switched on".
+
+The editor template will then change, and next to the condition you will now see two more drop-down lists. Leave the first one set to "object", and you'll be able to choose an object from the second list. Select "TV".
+
+
+
+That's our condition added - now we just need to say what happens when the condition is met. Click the "Then" header and you'll see that you can add script commands here too. These script commands will *only* be run *if* the TV is switched on. Add a "Print a message" command.
+
+This will be the text that will appear when the player types `WATCH TV` while the TV is switched on, so enter a message like "You watch for a few minutes. As your will to live slowly ebbs away, you remember that you’ve always hated watching westerns."
+
+We're not done yet - what if the TV is *not* switched on? Fortunately we don't need to add a whole other condition - we can just add an "Else" script to the one we're working on. Click the "Add Else" button, then expand the "Else" header that appears. Add a "Print a message" command again, and this time add a message like "You watch for a few minutes, thinking that the latest episode of ‘Big Brother’ is even more boring than usual. You then realise that the TV is in fact switched off."
+
+Your screen should now look like this:
+
+
+
+Now would be a good time to play the game to test that it works properly. Switch the TV on and off, and verify that you get a sensible response when you type `WATCH TV`.
diff --git a/site/src/content/docs/tutorial/using_timers_and_turn_scripts.md b/site/src/content/docs/tutorial/using_timers_and_turn_scripts.md
new file mode 100644
index 000000000..889c38a56
--- /dev/null
+++ b/site/src/content/docs/tutorial/using_timers_and_turn_scripts.md
@@ -0,0 +1,89 @@
+---
+title: Using timers and turn scripts
+sidebar:
+ order: 14
+---
+
+You can use timers to make something happens every so many seconds, whilst with a turn script you can make it happen every turn.
+
+
+## Timers
+
+
+In a previous section, we made a bee fly into the kitchen after the player opened a window. We'll now make that bee a bit more annoying as it flies around the kitchen – every 20 seconds, it will buzz past the player.
+
+To do this, we will use a timer. This timer will only be activated when the player opens the window in the kitchen. When the timer is activated, every 20 seconds it will print the message "The bee buzzes past you. Pesky bee". This message will only be printed if the player is in the kitchen.
+
+First, let's set up the timer. After we have done this, we will add the script command to activate it at the right time.
+
+### Setting up the timer
+
+Add the timer using the "+ Add" button on the toolbar, or the "..." menu on an element in the tree.
+
+Enter the name "bee timer".
+
+The timer editor will now be displayed. The Interval specifies how often the timer fires – in this case, we want it to fire every 20 seconds, so enter "20". Leave the box "Start timer when the game begins" unticked.
+
+For the timer script, add a "print a message" command to display "The bee buzzes past you. Pesky bee."
+
+
+
+### Activating the timer
+
+Go back to the "window" object and edit the script which runs when the bee enters the kitchen - this will be the "Else" script if you've followed the tutorial so far. Add a command after the "move object" command - from the Timers category, choose "Enable timer". Select "bee timer" from the list.
+
+Launch the game, go to the kitchen and open the window. Wait for a while and verify that the message is printed every 20 seconds.
+
+Now go north to the lounge. You'll see that we still get the message about the bee flying around. Woops! That bee is only in the kitchen. We'll need to update the timer script so that it only prints the message if the player is in the kitchen.
+
+You've already seen how to do this - an "if" command can check "player is in room". So add a check to the "bee timer" script - if the player is in the kitchen, print the message. If not, then do nothing.
+
+
+
+## Turn scripts
+
+In this section, we'll look at running a script after each turn in the game - a **turn script**. We'll store the number of turns a player has taken in an attribute called "turns" on the player object.
+
+### Setting up the turn counter as a status attribute
+
+We created a status attribute on the last page, this is just the same.
+
+To set up our "turns" attribute, select the "player" object and go to the Attributes tab. Click the Add button next to the Attributes list at the bottom of the screen, enter the name "turns" and make this an integer. To make this into a status attribute, we need to add it to the Status Attributes list at the top of the screen, so click Add there and add "turns" to the list.
+
+
+
+If you launch the game now, you should see the turns variable displayed on the right-hand side of the Quest window. We've not yet added the script to increase the value of this though, so it will always say “Turns: 0” no matter how many turns we take. Let's add this script now.
+
+### Increasing the turn counter after each turn
+
+A turn script can apply to a specific room, or it can apply to the entire game. To make a turn script apply for just one room, you simply need to create it in that room. If you create a turn script outside of a room, it will apply to the entire game. So, use the "..." menu on the tree, or the "+ Add" button, to create a turn script.
+
+To move it outside of all rooms, use "Move to..." and select the "Objects" label at the top of the tree.
+
+You can optionally specify a name for your turn script. You can use this if you want to be able to switch your turn script on and off using script commands, in a similar way to how we switched a timer on and off in the previous section. You can leave the name blank for this turn script, as this will always be running.
+
+Make sure the "Enabled when the game begins" box is ticked.
+
+We're going to add a script command which will increase the value of the player's "turns" attribute by 1 each time it is called.
+
+To do this, add the "Set a variable or attribute" command.
+
+In the left box, type:
+
+```quest
+player.turns
+```
+
+Then in the box on the right, type
+
+```quest
+player.turns + 1
+```
+
+This will add 1 each time the script is called.
+
+
+
+Launch the game now and verify that whenever you type a command, the "Turns" value is automatically updated.
+
+Congratulations, you now know the basics of using Quest. There is much more to it, but you are probably best learning that as you need it. Now go make that great game! The last part of the tutorial is about how to release your masterpiece.
diff --git a/site/src/content/docs/tutorial/verbs_in_depth.md b/site/src/content/docs/tutorial/verbs_in_depth.md
new file mode 100644
index 000000000..9283cde85
--- /dev/null
+++ b/site/src/content/docs/tutorial/verbs_in_depth.md
@@ -0,0 +1,40 @@
+---
+title: Verbs in depth
+sidebar:
+ order: 8
+---
+
+We've been using verbs since the very first section - "watch" on the TV, "sit on" on the sofa, "read" on the newspaper. Each is a "doing word" attached to one object, giving a single, specific response. Now that we also know how to build commands, it's worth looking at how the two fit together, and going a bit deeper on what a verb actually is.
+
+## Verbs are just script attributes
+
+When you add a verb to an object, Quest stores its response as a script attribute on that object, named after the verb. You can run that same script from anywhere - not just when the player types the verb directly - using `do (object, "attributename")`.
+
+If your verb is more than one word, Quest usually squashes it into one word for the attribute name - "look under" would become `lookunder`, for example. Built-in verbs sometimes use a shorter name instead: our sofa's "sit on" verb is one of these. Even though we typed "sit on" into the Add Verb box, the script is actually stored in an attribute called `sit`. If you're ever not sure what a verb's real attribute is called, check the object's Attributes tab.
+
+## Combining verbs and commands
+
+Right now, if the player types `SIT ON SOFA`, they get our custom response - but if they just type `SIT`, they get Quest's own generic reply, even with the sofa right there in the room. Let's use what we learned about commands to fix that.
+
+Select "Commands" in the tree (underneath "game"), click "+ Add", and choose "Add Command". For the command pattern, enter:
+
+ sit
+
+Switch to Code View for the script, and enter:
+
+```quest
+if (sofa.parent = player.parent) {
+ do (sofa, "sit")
+}
+else {
+ msg ("There's nothing to sit on here.")
+}
+```
+
+`sofa.parent = player.parent` checks whether the sofa is in the same room as the player - if so, we run the sofa's own "sit" verb script directly, giving exactly the same response as `SIT ON SOFA`. Quest already has a generic built-in response for a plain `SIT`, but a command you add yourself takes priority over one built into Quest, so ours is the one that runs.
+
+Launch the game, go to the lounge, and try both `SIT` and `SIT ON SOFA` - you should get an identical response either way. Try `SIT` from the kitchen too, and check you get the "nothing to sit on" message instead.
+
+## Going further
+
+Verbs can also involve a second object - for example, handling `ATTACK GOBLIN WITH KNIFE` - and the pattern text a verb matches against can be edited directly, with semicolon-separated synonyms or even a regular expression, in exactly the same way as the command patterns from the last section. See [How to use verbs](/howto/commands/using_verbs) for both of these in depth.
diff --git a/site/src/content/docs/types/index.md b/site/src/content/docs/types/index.md
new file mode 100644
index 000000000..1f34ddf6a
--- /dev/null
+++ b/site/src/content/docs/types/index.md
@@ -0,0 +1,366 @@
+---
+title: Attribute Types
+sidebar:
+ order: 5
+---
+
+Variables and object attributes can be any of the following types.
+
+## Null
+
+If Booleans seem limited in have only two possible values, null can have only one!
+
+In fact, null is a special value for attributes that says the attribute does not exist (which is different to local variables, which can be assigned a value of `null`, but do still exist). Setting an attribute to null is the same as deleting it (when the game is saved, null attributes are not written).
+
+You can check if an attribute is null using the "null" keyword:
+
+```quest
+if (someobject.parent = null) { ... }
+```
+
+There is a "gotcha" lurking here. If your object is of a type that sets an attribute to some value, and your object sets it to another value, what happens when you set that attribute on the object to null? The attribute is removed from the object, and so reverts to being the value from the type. This may not be what you expect!
+
+## String
+
+A string is a piece of text (string literal), a string variable is variable that holds text.
+
+```quest
+myStingVar = "World"
+```
+
+Strings can be added together in any combination of string literal (enclosed in quotes) or variables.
+
+```quest
+myNewStringVar = "Hello " + myStringVar
+```
+
+They can also be used to show messages to the user with the *msg* command.
+
+```quest
+msg (myNewStringVar)
+```
+
+Also see String Functions
+
+## Script
+
+A script attribute contains code for Quest to run, i.e., a list of instructions for Quest to carry out. Everything that happens in a game is controlled by script commands. Script commands can print messages, move objects around, show videos, start timers, change attributes, and much more.
+
+Example:
+
+```quest
+
+ if (not fridge.isopen) {
+ msg ("The fridge is open, casting its light out into the gloomy kitchen.")
+ }
+ else {
+ msg ("A big old refrigerator sits in the corner, humming quietly.")
+ }
+
+
+
+```
+Scripts can be created by adding script commands using the user interface, or by typing code in "code view". Behind the scenes, it is all the same, so you can flip between the two as you like.
+
+You can use [do](/scripts#do) or [invoke](/scripts#invoke) to have Quest run a script.
+
+Let us suppose the above script is attached to an object called "fridge". You could run the script:
+
+```quest
+do(fridge, "look")
+
+invoke(fridge.look)
+```
+
+If you use the `do` command, your script will have access to a local variable called `this`, which points to the object the script belongs to. This is very useful when making generic scripts; one script can be added to numerous objects, and when the script runs it can find out what it belongs to.
+
+You can send other values to a script by adding them to a dictionary. For each name-value pair you add to the dictionary, a local variable will be available the name being the key, and the value being the value.
+
+```quest
+dict = NewDictionary()
+dictionary add (dict, "npc", mary)
+dictionary add (dict, "obj", sandwich)
+do(fridge, "look", dict)
+```
+
+Now the "look" script will have access to local variables called "npc" and "obj", as well as "this". There is a shortcut to do that:
+
+```quest
+do(fridge, "look", QuickParams("npc", mary, "obj", sandwich))
+```
+
+The `QuickParams` function can take either 2, 4 or 6 parameters, allowing you to add 1, 2 or 3 variables.
+
+You can use the `IsDefined` function within a script to determine if it has access to a certain variable. Note that it takes a string.
+
+```quest
+if (IsDefined("npc")) {
+```
+
+There is no way to convert a string to a script during play, by the way (though you can do something similar with the [Eval](/functions/general#eval) function).
+
+## Boolean
+
+A Boolean can be either `true` or `false`. When using the GUI to create a script, they are called flags, and can be on or off. Boolean attributes are extremely use as they can tell us the current state of an object. It the torch on or off? Is the hat worn or not? Has the room been visited?
+
+Note that you do not need to compare a Boolean to `true` or `false`. It is already one of the other. Instead of:
+
+```quest
+if (player.is_successful = true) {
+```
+
+Just do:
+
+```quest
+if (player.is_successful) {
+```
+
+If you want to test that it is not true, just add the `not` keyword:
+
+```quest
+if (not player.is_successful) {
+```
+
+Also note that to do any of the you need to ensure the Boolean is initialised (i.e., it has a value at the start of the game). If `player.is_successful` has not been set, then when you do one of the comparisons above you will get an error message.
+
+Alternatively, use `GetBoolean`, which returns `true` if the attribute is `true`, or `false` if it is `false` or `null` (i.e., has not been set).
+
+```quest
+if (GetBoolean(player, "is_successful")) {
+```
+
+Or:
+
+```quest
+if (not GetBoolean(player, "is_successful")) {
+```
+
+## Int
+
+An "int" (integer) attribute represents a whole number (which can be positive or negative).
+
+Examples: 1, 2, -167, 37835685, 0.
+
+An "int" attribute is represented internally as a signed 32-bit variable, which means it can range from -2147483648 to 2147483647 (so up to just over 2 billion, which is probably high enough for most games). Going outside that range will lead to some funny effects, as numbers wrap around - if you add 1 to 2147483647 you will get -2147483648!
+
+## Double
+
+A "double" attribute represents a number with a decimal point. It can be positive or negative.
+
+Examples: 1.23, 5.8214, -0.12421, 0.0.
+
+More [here](/howto/scripting/using_doubles).
+
+## Object
+
+An object attribute points to another object by name.
+
+For example:
+
+```xml
+lounge
+```
+
+would be another way of setting the [parent](/attributes#parent) attribute of an object, if you didn't want to nest the XML definition.
+
+## Stringlist
+
+A stringlist is a [list](#list) that can contain a number of elements, all have to be of type [string](#string).
+
+For Quest 5.3 and earlier, the format in an ASLX file is this:
+
+```xml
+one; two; three
+```
+
+The same list is expressed like this:
+
+```xml
+
+ one
+ two
+ three
+
+```
+
+In Quest 5.4, you can still use the older semi-colon separate format with "simplestringlist":
+
+```xml
+one; two; three
+```
+
+See [Using Lists](/howto/scripting/using_lists).
+
+## Objectlist
+
+An objectlist is a [list](#list) that can contain any number of elements, all of which have to be of type [object](#object).
+
+The format in an ASLX file is:
+
+```xml
+player; object1; thing
+```
+
+ See [Using Lists](/howto/scripting/using_lists) for more information.
+
+## List
+
+"list" is a sequence of any attribute type. The format is in the ASLX file:
+
+```xml
+
+ a string value
+ 123
+
+```
+
+Usually it is better to use a [stringlist](#stringlist) (if all elements in the list will be strings) or an [objectlist](#objectlist) (if all elements in the list will be objects) instead.
+
+There is more on lists [here](/howto/scripting/using_lists).
+
+## Objectdictionary
+
+An objectdictionary is a dictionary where keys are [strings](#string) and values are [objects](#object).
+
+The format is "key = value", separated by semicolons.
+
+For example, for Quest 5.3 and earlier the format looks like this:
+
+```xml
+first = player; second = lounge
+```
+
+For Quest 5.4 and later the format is:
+
+```xml
+
+ -
+
first
+ player
+
+ -
+
second
+ lounge
+
+
+```
+
+In Quest 5.4, you can still use the old semicolon-separated format by specifying "simpleobjectdictionary":
+
+```xml
+first = player; second = lounge
+```
+
+This defines:
+
+|key|value|
+|---|-----|
+|first|player|
+|second|lounge|
+
+See [Using Dictionaries](/howto/scripting/using_dictionaries)
+
+## Scriptdictionary
+
+A scriptdictionary is a dictionary which has [string](#string) keys and [script](#script) values.
+
+It is defined with nested \- keys for each key/value pair.
+
+For example:
+
+```quest
+
+ -
+ msg ("you use object1")
+
+ -
+ msg ("you use object2")
+
+
+```
+
+See [Using Dictionaries](/howto/scripting/using_dictionaries)
+
+## Dictionary
+
+"dictionary" is a mapping of string keys to values of any attribute type.
+
+Usually it is better to use a more specific dictionary type if you can, if you know that all the values will be of the same type. These more specific types are [stringdictionary](#stringdictionary), [objectdictionary](#objectdictionary) and [scriptdictionary](#scriptdictionary).
+
+Here is an example dictionary containing a variety of different types:
+
+```quest
+
+ -
+
key1
+ A string value.
+
+ -
+
key2
+ 12
+
+ -
+
key3
+
+ msg ("This is a script")
+
+
+ -
+
key4
+
+ -
+
subkey1
+ This is a string inside a dictionary inside another dictionary.
+
+
+
+
+```
+
+See [Using Dictionaries](/howto/scripting/using_dictionaries)
+
+## Stringdictionary
+
+A stringdictionary is a dictionary where both keys and values are [strings](#string).
+
+The format is "key = value", separated by semicolons.
+
+For example (for Quest 5.3 and earlier):
+
+```xml
+turns = You have taken ! turns; health = Health !%
+```
+
+For Quest 5.4 and later the format is:
+
+```xml
+
+ -
+
turns
+ You have taken ! turns
+
+ -
+
health
+ Health !%
+
+
+```
+
+In Quest 5.4, you can still use the old semicolon-separated format using "simplestringdictionary":
+
+```xml
+turns = You have taken ! turns; health = Health !%
+```
+
+This defines:
+
+|key|value|
+|---|-----|
+|turns|You have taken ! turns|
+|health|Health !%|
+
+See [Using Dictionaries](/howto/scripting/using_dictionaries)
+
+## Command pattern
+
+Quest uses regular expressions to compare commands with what the player typed, and the regular expression is converted from a string in the background (see [here](/howto/commands/pattern_matching) for more on that). However, it also offers a simplified version, a "command pattern". This is essentially a string (such as "tie #object1# to #object2"), which Quest will convert to another string when the game start (in this case "^tie (?.*) to (?.*)$"), which can then be converted to a regular expression when required. There is not much point to command patterns outside of commands.
diff --git a/site/src/quest-grammar.mjs b/site/src/quest-grammar.mjs
new file mode 100644
index 000000000..9af8db373
--- /dev/null
+++ b/site/src/quest-grammar.mjs
@@ -0,0 +1,57 @@
+// TextMate grammar for Quest's line-oriented script DSL (e.g. `msg ("Hello")`,
+// `if (x = 1) { ... }`), used to syntax-highlight ```quest code fences via Shiki.
+// This is a from-scratch port, not an import: the editor's own highlighter
+// (src/AppShell/src/lib/quest-script-lang.ts) is a CodeMirror StreamParser, which
+// Shiki/expressive-code can't consume directly. Keep the keyword lists below in
+// sync with that file by hand if either changes.
+const MULTI_WORD_KEYWORDS = [
+ "create exit", "create timer", "create turnscript",
+ "dictionary add", "dictionary remove",
+ "list add", "list remove",
+ "get input", "on ready",
+ "play sound", "stop sound",
+ "else if",
+];
+
+const SINGLE_WORD_KEYWORDS = [
+ "if", "else", "otherwise", "for", "foreach", "do", "msg", "create", "destroy",
+ "ask", "insert", "invoke", "finish", "error", "picture", "requestsave", "firsttime",
+];
+
+export const questGrammar = {
+ name: "quest",
+ scopeName: "source.quest",
+ patterns: [
+ { include: "#comment" },
+ { include: "#keywords" },
+ { include: "#strings" },
+ { include: "#numbers" },
+ { include: "#punctuation" },
+ ],
+ repository: {
+ comment: {
+ match: "//.*$",
+ name: "comment.line.double-slash.quest",
+ },
+ keywords: {
+ patterns: [
+ { match: `\\b(${MULTI_WORD_KEYWORDS.join("|")})\\b`, name: "keyword.control.quest" },
+ { match: "\\bJS\\.", name: "keyword.other.quest" },
+ { match: "@failed\\b", name: "keyword.other.quest" },
+ { match: `\\b(${SINGLE_WORD_KEYWORDS.join("|")})\\b`, name: "keyword.control.quest" },
+ ],
+ },
+ strings: {
+ match: '"(?:[^"\\\\\\n]|\\\\.)*"?',
+ name: "string.quoted.double.quest",
+ },
+ numbers: {
+ match: "\\b\\d+(?:\\.\\d+)?\\b",
+ name: "constant.numeric.quest",
+ },
+ punctuation: {
+ match: "[{}()\\[\\]]",
+ name: "punctuation.quest",
+ },
+ },
+};
diff --git a/site/src/styles/custom.css b/site/src/styles/custom.css
new file mode 100644
index 000000000..095ed5c51
--- /dev/null
+++ b/site/src/styles/custom.css
@@ -0,0 +1,19 @@
+/* Small inline badge used to flag hard-coded functions in the Language Reference. */
+.qv-badge {
+ display: inline-block;
+ font-family: var(--sl-font-system-mono);
+ font-size: var(--sl-text-xs);
+ line-height: normal;
+ padding: 0.1rem 0.4rem;
+ border-radius: 0.25rem;
+ border: 1px solid var(--sl-color-gray-5);
+ color: var(--sl-color-gray-2);
+ background-color: var(--sl-color-gray-6);
+ text-decoration: none;
+ margin: 0.25rem 0;
+}
+
+a.qv-badge:hover {
+ border-color: var(--sl-color-text-accent);
+ color: var(--sl-color-text-accent);
+}
diff --git a/tests/e2e/docs-screenshots/capture-adding-sounds-audio-controls.mjs b/tests/e2e/docs-screenshots/capture-adding-sounds-audio-controls.mjs
new file mode 100644
index 000000000..dd647a902
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-adding-sounds-audio-controls.mjs
@@ -0,0 +1,30 @@
+// Regenerates the 1 in-game-player screenshot embedded in
+// site/src/content/docs/howto/multimedia/adding_sounds.md (audio_controls.jpg) - deferred out
+// of capture-adding-sounds.mjs since it needs a WasmPlayer preview, not editor chrome. The
+// native HTML5 widget renders regardless of whether the src actually resolves
+// to a playable file, so no real audio asset is needed for this screenshot. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addScriptCommand, setScriptCodeView,
+ openPreview, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await page.getByRole('button', { name: 'Scripts', exact: true }).click();
+ await page.waitForSelector('text=Start script:', { timeout: 10000 });
+ const startAddBtn = page.getByText('Start script:', { exact: true }).locator('xpath=following::button[contains(.,"+ Add script")][1]');
+ await addScriptCommand(page, startAddBtn);
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(),
+ `msg (" ")`);
+ await page.waitForSelector('xpath=//span[text()="Print"]', { timeout: 5000 });
+
+ const playerPage = await openPreview(page);
+ await capture(playerPage, out('audio_controls.jpg'), { untilLocator: playerPage.locator('#txtCommand') });
+});
diff --git a/tests/e2e/docs-screenshots/capture-adding-sounds.mjs b/tests/e2e/docs-screenshots/capture-adding-sounds.mjs
new file mode 100644
index 000000000..d70581357
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-adding-sounds.mjs
@@ -0,0 +1,157 @@
+// Regenerates the 8 editor screenshots embedded in site/src/content/docs/adding_sounds.md
+// (audio_controls.jpg is an in-game HTML-audio-tag screenshot, not editor chrome — out of
+// scope for this script, deferred separately). See .claude/skills/docs-screenshots/SKILL.md.
+//
+// "play sound (filename, wait, loop)" / "stop sound" syntax confirmed from
+// src/Engine/Core/CoreEditorScriptsOutput.aslx's blocks for these two commands.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, addVerb, addScriptCommand, setScriptCodeView, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Scripts');
+
+ // --- play_a_sound.jpg: the "Add Script Command" dialog on "Play a sound" ---
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command', { timeout: 10000 });
+ await page.getByRole('option', { name: /^●\s*Play a sound$/ }).click();
+ await capture(page, out('play_a_sound.jpg'), {
+ untilLocator: page.locator('[role="dialog"]'),
+ });
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+
+ // --- play_a_sound_GUI.jpg: the resulting command, filename filled, wait+loop shown ---
+ await page.waitForSelector('text=Play sound', { timeout: 5000 });
+ const soundFilenameField = page.locator('xpath=//span[text()="Play sound"]/following::input[1]');
+ await soundFilenameField.fill('ambient sound.mp3');
+ const waitSelect = page.getByText('Wait for sound to finish before continuing:', { exact: true })
+ .locator('xpath=following::select[1]');
+ await waitSelect.selectOption({ label: 'no' });
+ const loopSelect = page.getByText('Loop:', { exact: true }).first().locator('xpath=following::select[1]');
+ await loopSelect.selectOption({ label: 'no' });
+ await capture(page, out('play_a_sound_GUI.jpg'), { untilLocator: loopSelect, padding: 40 });
+
+ // --- stop_sound.jpg: the "Stop sound" command (no fields) ---
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `stop sound`);
+ await page.waitForSelector('text=Stop sound', { timeout: 5000 });
+ await capture(page, out('stop_sound.jpg'), {
+ untilLocator: page.getByText('Stop sound', { exact: true }),
+ padding: 40,
+ });
+ // Clear the Start script back out before moving on to room/object scripts.
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), ``);
+
+ // --- Set up the objects the rest of the page's examples reference ---
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Door');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'button');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Room in "room"', 'Hall of Silence');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Room in "room"', 'Sound Effects Room');
+
+ // Rename the default room to "Hub" to match the doc's narrative.
+ await selectTreeNode(page, 'room');
+ await page.locator('span:has-text("Name:")').locator('..').locator('input').fill('Hub');
+ await openTab(page, 'Room');
+ await page.waitForSelector('[data-value="Hub"]', { timeout: 10000 });
+ // Renaming collapsed the room's tree node — re-expand so its children (Door, button) are
+ // clickable again. Scope to Hub specifically: "game" also has its own Expand button (it
+ // always has Verbs/Commands subgroups), so a page-wide .first() would grab the wrong one.
+ await page.locator('[data-value="Hub"][data-part="branch-control"]')
+ .getByRole('button', { name: 'Expand' }).click();
+
+ // --- Door: Container feature, Openable/Closable, open/close scripts ---
+ await selectTreeNode(page, 'Door');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.waitForSelector('text=Container type:', { timeout: 10000 });
+ await page.locator('text=Container type:').locator('xpath=following::select[1]').selectOption({ label: 'Openable/Closable' });
+ await page.waitForSelector('text=Script to run when opening object:', { timeout: 10000 });
+ const doorOpenCodeView = page.getByText('Script to run when opening object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, doorOpenCodeView, `play sound ("door_creak.mp3", false, false)`);
+ await page.waitForSelector('text=Script to run when closing object:', { timeout: 10000 });
+ const doorCloseCodeView = page.getByText('Script to run when closing object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, doorCloseCodeView, `stop sound`);
+ await page.waitForSelector('text=Play sound', { timeout: 5000 });
+ await capture(page, out('play_audio_example4_door.jpg'), {
+ untilLocator: page.getByText('Script to run when closing object:', { exact: true }).locator('xpath=following::input[1]'),
+ padding: 200,
+ });
+
+ // --- Hub room: after entering / after leaving scripts (ambient loop tied to Door) ---
+ await selectTreeNode(page, 'Hub');
+ await openTab(page, 'Scripts');
+ await page.waitForSelector('text=After entering the room:', { timeout: 10000 });
+ const hubEnterCodeView = page.getByText('After entering the room:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, hubEnterCodeView, `if (Door.isopen) {
+play sound ("ambient sound.mp3", false, true)
+}`);
+ await page.waitForSelector('text=After leaving the room:', { timeout: 10000 });
+ const hubLeaveCodeView = page.getByText('After leaving the room:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, hubLeaveCodeView, `stop sound`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ await capture(page, out('play_audio_example1_loop.jpg'), {
+ untilLocator: page.getByText('After leaving the room:', { exact: true }).locator('xpath=following::input[1]'),
+ padding: 200,
+ });
+
+ // --- Hall of Silence: before entering the room -> stop sound ---
+ await selectTreeNode(page, 'Hall of Silence');
+ await openTab(page, 'Scripts');
+ await page.waitForSelector('text=Before entering the room:', { timeout: 10000 });
+ const hallCodeView = page.getByText('Before entering the room:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, hallCodeView, `stop sound`);
+ await page.waitForSelector('text=Stop sound', { timeout: 5000 });
+ await capture(page, out('stop_audio_example1.jpg'), {
+ untilLocator: page.getByText('Stop sound', { exact: true }),
+ padding: 60,
+ });
+
+ // --- Sound Effects Room: before entering the room -> message + play sound with wait=yes ---
+ await selectTreeNode(page, 'Sound Effects Room');
+ await openTab(page, 'Scripts');
+ await page.waitForSelector('text=Before entering the room:', { timeout: 10000 });
+ const sfxCodeView = page.getByText('Before entering the room:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, sfxCodeView, `msg ("A strange sound echoes through the corridor.")
+play sound ("effect.mp3", true, false)`);
+ await page.waitForSelector('text=Play sound', { timeout: 5000 });
+ await capture(page, out('play_audio_example2_sync.jpg'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').first(),
+ padding: 200,
+ });
+
+ // --- button: "press" verb, run script -> play a click sound ---
+ await selectTreeNode(page, 'button');
+ await openTab(page, 'Verbs');
+ await page.waitForSelector('text=No verbs added yet', { timeout: 10000 });
+ const verbInput = page.locator('button:has-text("Add Verb")').locator('..').locator('input');
+ await verbInput.fill('press');
+ await page.click('button:has-text("Add Verb")');
+ await page.waitForSelector('td:has-text("press")', { timeout: 10000 });
+ await page.click('td:has-text("press")');
+ const behaviourTypeSelect = page.locator('text=Type').first().locator('xpath=following::select[1]');
+ await behaviourTypeSelect.selectOption({ label: 'Run a script' });
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `play sound ("click.mp3", false, false)`);
+ await page.waitForSelector('text=Play sound', { timeout: 5000 });
+ await capture(page, out('play_audio_example3_button.jpg'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').last(),
+ padding: 40,
+ });
+});
diff --git a/tests/e2e/docs-screenshots/capture-ask-about.mjs b/tests/e2e/docs-screenshots/capture-ask-about.mjs
new file mode 100644
index 000000000..106894d2d
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-ask-about.mjs
@@ -0,0 +1,44 @@
+// Regenerates the 1 editor screenshot embedded in site/src/content/docs/ask_about.md
+// (Asktell3.png) — was blocked on ScriptDictionaryEditor having no rename/edit-key
+// affordance; now fixed upstream (PR #2091). See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, toggleFeature, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Ask/Tell:');
+
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Mary');
+ await openTab(page, 'Ask/Tell');
+ await page.waitForSelector('text=ASK', { timeout: 10000 });
+
+ const askEntryInput = page.getByPlaceholder('Add entry key…').first();
+ await askEntryInput.fill('dr black');
+ await askEntryInput.locator('xpath=following-sibling::button[1]').click();
+ await page.waitForSelector('text=dr black', { timeout: 10000 });
+
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command', { timeout: 5000 });
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ await page.waitForSelector('xpath=//span[text()="Print"]', { timeout: 5000 });
+ const msgInput = page.locator('xpath=//span[text()="Print"]/following::input[1]');
+ await msgInput.fill("'Terrible business,' says Mary. 'The poor doctor never stood a chance.'");
+
+ await page.click('button:has-text("Edit Key")');
+ const keyInput = page.locator('text=Ask about:').locator('xpath=following::input[1]');
+ await keyInput.click({ clickCount: 3 });
+ await keyInput.fill('dr doctor black');
+ await page.keyboard.press('Enter');
+ await page.waitForSelector('text=dr doctor black', { timeout: 5000 });
+
+ await page.waitForSelector('button:has-text("Edit Key")', { timeout: 5000 });
+ await capture(page, out('Asktell3.png'), { untilLocator: msgInput, padding: 60 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-ask-simple-question.mjs b/tests/e2e/docs-screenshots/capture-ask-simple-question.mjs
new file mode 100644
index 000000000..38cd626b2
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-ask-simple-question.mjs
@@ -0,0 +1,165 @@
+// Regenerates the 7 editor screenshots embedded in
+// site/src/content/docs/ask_simple_question.md. See .claude/skills/docs-screenshots/SKILL.md.
+//
+// Every image is a progressively-built-up version of the same Cindy-the-flower-seller "speak"
+// verb script, built by typing raw quest-script into its Code view at each stage (much faster
+// and more reliable than reconstructing show-menu/switch nesting via addScriptCommand). menu4a.png
+// captures the full 4-case switch rather than "only the lower half" like the old Quest 5
+// screenshot — capture() always crops from the top of the panel, and there's no reason to hide
+// the upper cases; showing the whole thing is strictly more informative.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, setScriptCodeView, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const lastAddScript = page => page.locator('button:has-text("+ Add script")').last();
+
+// Switch cases render collapsed ("▶") by default — expand every one so the doc's screenshot
+// shows the actual per-case script content, not just the case labels.
+async function expandAllSwitchCases(page) {
+ const toggles = page.getByRole('button', { name: '▶' });
+ while (await toggles.count() > 0) {
+ await toggles.first().click();
+ }
+}
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ for (const name of ['Cindy', 'roses', 'lavender', 'lilies', 'orchids']) {
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', name);
+ }
+
+ await selectTreeNode(page, 'Cindy');
+ await openTab(page, 'Verbs');
+ await page.waitForSelector('text=No verbs added yet', { timeout: 10000 });
+ const verbInput = page.locator('button:has-text("Add Verb")').locator('..').locator('input');
+ await verbInput.fill('speak');
+ await page.click('button:has-text("Add Verb")');
+ await page.waitForSelector('td:has-text("speak")', { timeout: 10000 });
+ await page.click('td:has-text("speak")');
+ const behaviourTypeSelect = page.getByRole('combobox').filter({ hasText: 'Print a message' });
+ await behaviourTypeSelect.selectOption({ label: 'Run a script' });
+
+ const codeViewBtn = () => page.locator('button:has-text("Code view")').first();
+
+ // --- menu1.png: options list built from three fixed entries ---
+ await setScriptCodeView(page, codeViewBtn(), `options = NewStringList()
+list add (options, "Red roses")
+list add (options, "Lavender")
+list add (options, "Lilies")`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ await capture(page, out('menu1.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- menu1a.png: + conditional Orchids option ---
+ await setScriptCodeView(page, codeViewBtn(), `options = NewStringList()
+list add (options, "Red roses")
+list add (options, "Lavender")
+list add (options, "Lilies")
+if (GetBoolean(Cindy, "orchids in stock")) {
+list add (options, "Orchids")
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ await capture(page, out('menu1a.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- menu2.png: + Show a menu (empty response body so far) ---
+ await setScriptCodeView(page, codeViewBtn(), `options = NewStringList()
+list add (options, "Red roses")
+list add (options, "Lavender")
+list add (options, "Lilies")
+if (GetBoolean(Cindy, "orchids in stock")) {
+list add (options, "Orchids")
+}
+ShowMenu ("What flowers do you want to buy?", options, true) {
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await capture(page, out('menu2.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- menu3.png: + switch(result) with the first case (Red roses) filled in ---
+ await setScriptCodeView(page, codeViewBtn(), `options = NewStringList()
+list add (options, "Red roses")
+list add (options, "Lavender")
+list add (options, "Lilies")
+if (GetBoolean(Cindy, "orchids in stock")) {
+list add (options, "Orchids")
+}
+ShowMenu ("What flowers do you want to buy?", options, true) {
+switch (result) {
+case ("Red roses") {
+msg ("You buy some red roses from Cindy.")
+MoveObject (roses, player)
+}
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await expandAllSwitchCases(page);
+ await capture(page, out('menu3.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- menu4.png: + second case (Lavender), third still missing ---
+ await setScriptCodeView(page, codeViewBtn(), `options = NewStringList()
+list add (options, "Red roses")
+list add (options, "Lavender")
+list add (options, "Lilies")
+if (GetBoolean(Cindy, "orchids in stock")) {
+list add (options, "Orchids")
+}
+ShowMenu ("What flowers do you want to buy?", options, true) {
+switch (result) {
+case ("Red roses") {
+msg ("You buy some red roses from Cindy.")
+MoveObject (roses, player)
+}
+case ("Lavender") {
+msg ("You buy some lavender from Cindy.")
+MoveObject (lavender, player)
+}
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await expandAllSwitchCases(page);
+ await capture(page, out('menu4.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- menu4a.png: all four cases, including the conditional Orchids one ---
+ await setScriptCodeView(page, codeViewBtn(), `options = NewStringList()
+list add (options, "Red roses")
+list add (options, "Lavender")
+list add (options, "Lilies")
+if (GetBoolean(Cindy, "orchids in stock")) {
+list add (options, "Orchids")
+}
+ShowMenu ("What flowers do you want to buy?", options, true) {
+switch (result) {
+case ("Red roses") {
+msg ("You buy some red roses from Cindy.")
+MoveObject (roses, player)
+}
+case ("Lavender") {
+msg ("You buy some lavender from Cindy.")
+MoveObject (lavender, player)
+}
+case ("Lilies") {
+msg ("You buy some lilies from Cindy.")
+MoveObject (lilies, player)
+}
+case ("Orchids") {
+msg ("You buy some orchids from Cindy.")
+MoveObject (orchids, player)
+}
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await expandAllSwitchCases(page);
+ await capture(page, out('menu4a.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- menu5.png: separate simpler "Are you sure?" yes/no example (same verb slot) ---
+ await setScriptCodeView(page, codeViewBtn(), `ShowMenu ("Are you sure?", Split("Yes;No", ";"), false) {
+if (result = "Yes") {
+msg ("You buy some red roses from Cindy.")
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await capture(page, out('menu5.png'), { untilLocator: lastAddScript(page), padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-asking-a-question.mjs b/tests/e2e/docs-screenshots/capture-asking-a-question.mjs
new file mode 100644
index 000000000..c1223a3eb
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-asking-a-question.mjs
@@ -0,0 +1,76 @@
+// Regenerates the 4 editor screenshots embedded in
+// site/src/content/docs/asking_a_question.md. See .claude/skills/docs-screenshots/SKILL.md.
+// Like character_creation.md, these all use "get input" — no longer offered by the Add Script
+// Command picker (superseded by the GetInput() expression form) but still fully editable once
+// present — so each is built by typing raw quest-script into the Start script's Code view.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, setScriptCodeView, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Scripts');
+
+ const codeViewBtn = () => page.locator('button:has-text("Code view")').first();
+ const lastAddScript = () => page.locator('button:has-text("+ Add script")').last();
+
+ // --- Question1.png ---
+ await setScriptCodeView(page, codeViewBtn(), `msg ("What is your name?")
+get input {
+player.alias = CapFirst(result)
+}`);
+ await page.waitForSelector('text=Get input, then');
+ await capture(page, out('Question1.png'), { untilLocator: lastAddScript(), padding: 40 });
+
+ // --- Question2.png ---
+ await setScriptCodeView(page, codeViewBtn(), `msg ("What is your name?")
+get input {
+player.alias = CapFirst(result)
+msg ("How old are you?")
+get input {
+player.age = result
+}
+}`);
+ await page.waitForSelector('text=Get input, then');
+ await capture(page, out('Question2.png'), { untilLocator: lastAddScript(), padding: 40 });
+
+ // --- Question3.png ---
+ await setScriptCodeView(page, codeViewBtn(), `msg ("'Hello. Can you answer my riddle? What walks on four legs in the morning, two in the afternoon, and three in the evening?'")
+get input {
+if (result = "man") {
+msg ("'Is it a man?' you ask.")
+msg ("'How come everyone knows the answer?'")
+msg ("'We have this thing called the internet nowadays...'")
+}
+else {
+msg ("'Is it \\"" + result + "\\"?' you ask.")
+msg ("'No!'")
+}
+}`);
+ await page.waitForSelector('text=Get input, then');
+ await capture(page, out('Question3.png'), { untilLocator: lastAddScript(), padding: 40 });
+
+ // --- Question4.png ---
+ await setScriptCodeView(page, codeViewBtn(), `msg ("'Hello. Can you answer my riddle? What walks on four legs in the morning, two in the afternoon, and three in the evening?'")
+JS.eval("$('#txtCommand').attr('placeholder', 'Your answer');")
+JS.panesVisible(false)
+get input {
+if (IsRegexMatch ("^(a )?(man|lady|woman|human|person)$", LCase (result))) {
+msg ("'Is it a man?' you ask.")
+msg ("'How come everyone knows the answer?'")
+msg ("'We have this thing called the internet nowadays...'")
+}
+else {
+msg ("'Is it \\"" + result + "\\"?' you ask.")
+msg ("'No!'")
+}
+JS.eval("$('#txtCommand').attr('placeholder', 'Type here...');")
+JS.panesVisible(true)
+}`);
+ await page.waitForSelector('text=Get input, then');
+ await capture(page, out('Question4.png'), { untilLocator: lastAddScript(), padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-blocks-and-scripts.mjs b/tests/e2e/docs-screenshots/capture-blocks-and-scripts.mjs
new file mode 100644
index 000000000..7b61756ab
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-blocks-and-scripts.mjs
@@ -0,0 +1,43 @@
+// Regenerates the 1 editor screenshot embedded in site/src/content/docs/blocks_and_scripts.md
+// (nested_switch.png) — was blocked on the Switch command's case-list editor
+// (task_082ae91c); now fixed upstream (PR #2090). See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, setScriptCodeView, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Scripts');
+
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `options = Split("Red;Green;Blue;Yellow", ";")
+ShowMenu ("What is your favourite colour?", options, false) {
+switch (result) {
+case ("Red") {
+msg ("You must be very passionate. Or like a team that play in red.")
+}
+case ("Yellow") {
+msg ("What a bright, cheerful colour!.")
+}
+case ("Green", "Blue") {
+msg (result + "? Seriously?")
+}
+}
+options = Split("Dog;Turtle;Duck;Newt;Trout", ";")
+ShowMenu ("Okay, and what is your favourite animal?", options, false) {
+msg ("Really? Big fan of " + result + "s, are you?")
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ const caseToggles = page.getByRole('button', { name: '▶' });
+ while (await caseToggles.count() > 0) {
+ await caseToggles.first().click();
+ }
+ await capture(page, out('nested_switch.png'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').last(),
+ padding: 40,
+ });
+});
diff --git a/tests/e2e/docs-screenshots/capture-changing-templates.mjs b/tests/e2e/docs-screenshots/capture-changing-templates.mjs
new file mode 100644
index 000000000..74c122970
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-changing-templates.mjs
@@ -0,0 +1,23 @@
+// Regenerates 1 of the 2 editor screenshots embedded in
+// site/src/content/docs/changing_templates.md (Templates.png). Showlibraryelements.png
+// already shows the current AppShell UI correctly and doesn't need regenerating.
+// See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await page.click('[title="Tree view options"]');
+ await page.getByText('Show Library Elements').click();
+ await page.waitForTimeout(200);
+ await page.locator('[data-value="_advanced"][data-part="branch-control"]').locator('..')
+ .locator('[data-part="branch-indicator"], svg').first().click();
+ await page.waitForTimeout(200);
+ await page.getByText('Templates', { exact: true }).click();
+ await page.waitForTimeout(200);
+ await capture(page, out('Templates.png'), { untilLocator: page.locator('button:has-text("+ Add Template")'), padding: 600 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-changing-the-player-object.mjs b/tests/e2e/docs-screenshots/capture-changing-the-player-object.mjs
new file mode 100644
index 000000000..929d01921
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-changing-the-player-object.mjs
@@ -0,0 +1,21 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/changing_the_player_object.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, toggleFeature, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Bob');
+ await selectTreeNode(page, 'Bob');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Player:');
+ await openTab(page, 'Player');
+ await page.locator('select').first().selectOption({ label: 'Can be a player' });
+ await page.waitForTimeout(200);
+ await capture(page, out('Pov1.png'), { untilLocator: page.locator('select').first(), padding: 400 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-character-creation.mjs b/tests/e2e/docs-screenshots/capture-character-creation.mjs
new file mode 100644
index 000000000..7238043e5
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-character-creation.mjs
@@ -0,0 +1,50 @@
+// Regenerates the 2 editor screenshots embedded in
+// site/src/content/docs/howto/rpg/character_creation.md. See .claude/skills/docs-screenshots/SKILL.md.
+//
+// "get input" is intentionally no longer offered by the Add Script Command picker (superseded
+// by the synchronous GetInput() expression form, rendered in the Visual editor as the
+// "player's typed input" value template - see CoreEditorScriptsOutput.aslx's "Removed from
+// adder" comments) but the old callback form remains fully editable once present. Both scripts
+// here are built by typing raw quest-script into the Start script's Code view and switching
+// back to Visual editor, rather than via addScriptCommand.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, setScriptCodeView, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Scripts');
+
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `msg ("Let's generate a character...")
+msg ("First, what is your name?")
+player.alias = GetInput()
+msg ("Hi, " + player.alias)`);
+ await page.waitForSelector('text=Set variable');
+ const lastRow1 = page.locator('button:has-text("+ Add script")').last();
+ await capture(page, out('Creation1.png'), { untilLocator: lastRow1, padding: 40 });
+
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `msg ("Let's generate a character...")
+msg ("First, what is your name?")
+player.alias = GetInput()
+msg ("Hi, " + player.alias)
+show menu ("Your gender?", Split ("Male;Female", ";"), false) {
+ player.gender = result
+ show menu ("Your character class?", Split ("Warrior;Wizard;Priest;Thief", ";"), false) {
+ player.class = result
+ msg (" ")
+ msg (player.alias + " was a " + LCase (player.gender) + " " + LCase (player.class) + ".")
+ msg (" ")
+ msg ("Now press a key to begin...")
+ wait {
+ ClearScreen
+ }
+ }
+}`);
+ await page.waitForSelector('text=Set variable');
+ const lastRow2 = page.locator('button:has-text("+ Add script")').last();
+ await capture(page, out('Creation2.png'), { untilLocator: lastRow2, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-cloak-of-darkness.mjs b/tests/e2e/docs-screenshots/capture-cloak-of-darkness.mjs
new file mode 100644
index 000000000..72cdf32fd
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-cloak-of-darkness.mjs
@@ -0,0 +1,17 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/cloak_of_darkness.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, fieldByLabel, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Cloak of Darkness');
+ await fieldByLabel(page, 'Author:').fill('The Pixie');
+ const descInput = fieldByLabel(page, 'Description:');
+ await descInput.fill('From the specification here:\nhttp://www.firthworks.com/roger/cloak/');
+ await page.waitForTimeout(200);
+ await capture(page, out('cod01.png'), { untilLocator: descInput, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-commands.mjs b/tests/e2e/docs-screenshots/capture-commands.mjs
new file mode 100644
index 000000000..ee7a1d435
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-commands.mjs
@@ -0,0 +1,77 @@
+// Regenerates the 2 editor screenshots embedded in
+// site/src/content/docs/commands.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addScriptCommand, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const addScriptButtons = page => page.locator('button:has-text("+ Add script")');
+
+async function addCommand(page) {
+ await page.click('button[title="Add element"]');
+ await page.click('button:has-text("Add Command to")', { timeout: 5000 });
+ await page.waitForSelector('text=Command:', { timeout: 10000 });
+}
+
+function patternInput(page) {
+ const patternRow = page.getByText('Pattern:', { exact: true }).locator('xpath=../..');
+ return patternRow.locator(':scope > input[type=text]');
+}
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+
+ // --- CommandHelp.png: "help" command, print message ---
+ await addCommand(page);
+ await patternInput(page).fill('help');
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const helpMsg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await helpMsg.fill("You're on your own with this one.");
+ await helpMsg.evaluate(el => { el.scrollLeft = 0; });
+ await capture(page, out('CommandHelp.png'), { untilLocator: helpMsg, padding: 40 });
+
+ // --- CommandAttack.png: "attack #object#;strike #object#;hit #object#" command,
+ // nested if/else if/else checklist ---
+ await selectTreeNode(page, 'room');
+ await addCommand(page);
+ await patternInput(page).fill('attack #object#;strike #object#;hit #object#');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'If...' });
+ const ifExpr = page.locator('xpath=(//span[text()="if"]/following::input[@type="text"])[1]');
+ await ifExpr.fill('not HasAttribute(object, "enemy")');
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const msg1 = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await msg1.fill('You should not attack that.');
+ await msg1.evaluate(el => { el.scrollLeft = 0; });
+
+ await page.getByRole('button', { name: '+ else if', exact: true }).click();
+ // Anchor from the specific "else if" span occurrence (parens around the span selector
+ // itself), not from the union of all "else if" spans' following inputs - the latter
+ // silently resolves to a stale index into an earlier branch once more than one
+ // "else if" exists (see project memory: same class of bug as ShowMenu.png).
+ const elseIf1Expr = page.locator('xpath=(//span[text()="else if"])[1]/following::input[@type="text"][1]');
+ await elseIf1Expr.fill('not object.alive');
+ await addScriptCommand(page, addScriptButtons(page).nth(1));
+ const msg2 = page.locator('xpath=(//span[text()="Print"])[2]/following-sibling::input[1]');
+ await msg2.fill('It is already dead.');
+ await msg2.evaluate(el => { el.scrollLeft = 0; });
+
+ await page.getByRole('button', { name: '+ else if', exact: true }).click();
+ const elseIf2Expr = page.locator('xpath=(//span[text()="else if"])[2]/following::input[@type="text"][1]');
+ await elseIf2Expr.fill('not HasObject(player, "weapon")');
+ await addScriptCommand(page, addScriptButtons(page).nth(2));
+ const msg3 = page.locator('xpath=(//span[text()="Print"])[3]/following-sibling::input[1]');
+ await msg3.fill('Not advisable without a weapon.');
+ await msg3.evaluate(el => { el.scrollLeft = 0; });
+
+ await page.getByRole('button', { name: '+ else', exact: true }).click();
+ await addScriptCommand(page, addScriptButtons(page).nth(3));
+ const msg4 = page.locator('xpath=(//span[text()="Print"])[4]/following-sibling::input[1]');
+ await msg4.fill('You attack it with all your might.');
+ await msg4.evaluate(el => { el.scrollLeft = 0; });
+ await page.waitForTimeout(200);
+ await capture(page, out('CommandAttack.png'), { untilLocator: msg4, padding: 60 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-containers.mjs b/tests/e2e/docs-screenshots/capture-containers.mjs
new file mode 100644
index 000000000..f09a0d80e
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-containers.mjs
@@ -0,0 +1,164 @@
+// Regenerates the 8 editor screenshots embedded in site/src/content/docs/containers.md. See
+// .claude/skills/docs-screenshots/SKILL.md.
+//
+// Note: the doc's lockandkey.png section describes a "Require all keys" checkbox
+// ("As of Quest 5.8, you can untick..."), but the current engine's container_lockable type
+// (src/Engine/Core/CoreTypes.aslx) always calls AllKeysAvailable() unconditionally — there is
+// no "require any key" code path and no such checkbox in the current Container tab. Captured
+// faithfully without it (not a capture bug — the feature isn't present to capture).
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, setScriptCodeView, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const codeViewBtn = page => page.locator('button:has-text("Code view")').first();
+const lastAddScript = page => page.locator('button:has-text("+ Add script")').last();
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'chest');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'key');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'pixie');
+
+ // --- container2.png: chest, Container feature on, "Container" type, default options ---
+ await selectTreeNode(page, 'chest');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.waitForSelector('text=Container type:', { timeout: 10000 });
+ await page.locator('text=Container type:').locator('xpath=following::select[1]').selectOption({ label: 'Container' });
+ await page.waitForSelector('text=Script to run when trying to add an object:', { timeout: 10000 });
+ await capture(page, out('container2.png'), {
+ untilLocator: page.getByText('Script to run when trying to add an object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]'),
+ padding: 40,
+ });
+
+ // --- lockandkey.png: Closed container, Lockable, 1 key = "key" ---
+ await page.locator('text=Container type:').locator('xpath=following::select[1]').selectOption({ label: 'Closed container' });
+ await page.waitForSelector('text=LOCKING', { timeout: 10000 });
+ await page.locator('text=Lock type:').locator('xpath=following::select[1]').selectOption({ label: 'Lockable' });
+ await page.waitForSelector('text=Number of keys to unlock container:', { timeout: 10000 });
+ const keyCountField = page.locator('text=Number of keys to unlock container:')
+ .locator('xpath=following::input[1]');
+ await keyCountField.click();
+ await keyCountField.fill('1');
+ await keyCountField.press('Tab');
+ await page.waitForSelector('text=Key:', { timeout: 10000 });
+ const keyCombobox = page.locator('text=Key:').first().locator('xpath=following::input[1]');
+ await keyCombobox.click();
+ await keyCombobox.fill('key');
+ await page.waitForSelector('[role="option"]:has-text("key")', { timeout: 5000 });
+ await page.click('[role="option"]:has-text("key")');
+ await page.waitForSelector('text=Automatically unlock if player has the key(s)', { timeout: 10000 });
+ await capture(page, out('lockandkey.png'), {
+ untilLocator: page.getByText('Automatically open when unlocked', { exact: true }),
+ padding: 60,
+ });
+
+ // --- unlock.png: pixie's "talk" verb, run script, chest.locked = false ---
+ await selectTreeNode(page, 'pixie');
+ await openTab(page, 'Verbs');
+ await page.waitForSelector('text=No verbs added yet', { timeout: 10000 });
+ const verbInput = page.locator('button:has-text("Add Verb")').locator('..').locator('input');
+ await verbInput.fill('talk');
+ await page.click('button:has-text("Add Verb")');
+ await page.waitForSelector('td:has-text("talk")', { timeout: 10000 });
+ await page.click('td:has-text("talk")');
+ const behaviourTypeSelect = page.locator('text=Type').first().locator('xpath=following::select[1]');
+ await behaviourTypeSelect.selectOption({ label: 'Run a script' });
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `msg ("The pixie waves her wand, and you hear a click from the chest.")
+chest.locked = false`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ await capture(page, out('unlock.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- limitbycount.png / limitbyvolume.png: a "backpack" Limited container ---
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Inventory limits:');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'backpack');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.waitForSelector('text=Container type:', { timeout: 10000 });
+ await page.locator('text=Container type:').locator('xpath=following::select[1]').selectOption({ label: 'Limited container' });
+ await page.waitForSelector('text=Maximum number of objects:', { timeout: 10000 });
+ const maxObjectsField = page.getByText('Maximum number of objects:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await maxObjectsField.fill('5');
+ const countMessageField = page.getByText('Full container message (leave blank for default):', { exact: true }).first()
+ .locator('xpath=following::input[1]');
+ await countMessageField.fill("You can't fit anything else in the backpack.");
+ await capture(page, out('limitbycount.png'), { untilLocator: countMessageField, padding: 60 });
+
+ await maxObjectsField.fill('1000000');
+ const volumeField = page.getByText('Maximum volume of objects:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await volumeField.fill('50');
+ await capture(page, out('limitbyvolume.png'), { untilLocator: volumeField, padding: 60 });
+
+ // --- containeropenscript.png: chest, "After opening the object" trap script ---
+ await selectTreeNode(page, 'chest');
+ await openTab(page, 'Container');
+ await page.waitForSelector('text=Container type:', { timeout: 10000 });
+ await page.locator('text=Container type:').locator('xpath=following::select[1]').selectOption({ label: 'Container' });
+ await page.waitForSelector('text=After opening the object:', { timeout: 10000 });
+ const openScriptCodeView = page.getByText('After opening the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, openScriptCodeView, `firsttime {
+if (not GetBoolean(this, "disarmed")) {
+msg ("As you open the chest, there is a sudden explosion! It was trapped.")
+DecreaseHealth (20)
+}
+}`);
+ await page.waitForSelector('text=The first time,', { timeout: 5000 });
+ // Anchor to the "After closing the object:" label, not a global .last() "+ Add script" —
+ // the page also has Locking-section "+ Add script" buttons further down, so .last() would
+ // pull in the whole Locking section instead of cropping right after this script.
+ await capture(page, out('containeropenscript.png'), {
+ untilLocator: page.getByText('After closing the object:', { exact: true }),
+ padding: 40,
+ });
+
+ // --- containerfussy.png: chest, "Script to run when trying to add an object" — clothing only ---
+ await page.waitForSelector('text=Script to run when trying to add an object:', { timeout: 10000 });
+ const addScriptCodeView = page.getByText('Script to run when trying to add an object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, addScriptCodeView, `if (DoesInherit(object, "wearable")) {
+MoveObject (object, this)
+msg ("You put " + object.article + " in the chest.")
+}
+else {
+msg ("You can't put " + object.article + " in the chest; it only likes clothing!")
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ await capture(page, out('containerfussy.png'), {
+ untilLocator: page.getByText('Locking', { exact: true }),
+ padding: 40,
+ });
+
+ // --- containercounter.png: chest, same script slot — count items, finish at 3+ ---
+ const addScriptCodeView2 = page.getByText('Script to run when trying to add an object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, addScriptCodeView2, `MoveObject (object, this)
+msg ("You put " + object.article + " in the chest.")
+if (ListCount(GetAllChildObjects(this)) > 2) {
+msg ("Congratulations, you filled the chest, and completed your quest.")
+finish
+}`);
+ await page.waitForSelector('text=Move object', { timeout: 5000 });
+ await capture(page, out('containercounter.png'), {
+ untilLocator: page.getByText('Locking', { exact: true }),
+ padding: 40,
+ });
+});
diff --git a/tests/e2e/docs-screenshots/capture-conversations.mjs b/tests/e2e/docs-screenshots/capture-conversations.mjs
new file mode 100644
index 000000000..f65578e26
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-conversations.mjs
@@ -0,0 +1,35 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/conversations.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, addScriptCommand, fieldByLabel, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Ask/Tell:');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Philippa');
+ await selectTreeNode(page, 'Philippa');
+ await page.getByRole('button', { name: 'Ask/Tell', exact: true }).click();
+ const askInput = fieldByLabel(page, 'Ask about:');
+ await askInput.fill('key lock');
+ await askInput.locator('xpath=../..').locator('button:has-text("Add")').click();
+ await page.waitForTimeout(300);
+
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first());
+ const line1 = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await line1.fill("'Hi,' you say to Philippa, 'can you help me find the key to this door?'");
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first());
+ const line2 = page.locator('xpath=(//span[text()="Print"])[2]/following-sibling::input[1]');
+ await line2.fill("'Sure, you need to look in the bedroom.'");
+ await page.waitForTimeout(200);
+ await capture(page, out('Talk3.png'), { untilLocator: line2, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-convert.mjs b/tests/e2e/docs-screenshots/capture-convert.mjs
new file mode 100644
index 000000000..1ad360782
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-convert.mjs
@@ -0,0 +1,85 @@
+// Regenerates the 2 distinct editor screenshots embedded in site/src/content/docs/convert.md
+// (make1.png is reused 3 times in the doc for closely related states — this captures the
+// primary, most-referenced one: the CmdMakeBow command's own script). See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ setScriptCodeView, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Convert Test');
+
+ await addElement(page, 'Add Room', 'nowhere');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'string');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'branch');
+ await selectTreeNode(page, 'nowhere');
+ await addElement(page, 'Add Object in "nowhere"', 'bow');
+
+ // --- make1.png: the CmdMakeBow command's pattern + script ---
+ await selectTreeNode(page, 'room');
+ await page.click('button[title="Add element"]');
+ await page.getByRole('button', { name: 'Add Command to "room"', exact: true }).click();
+ await page.waitForSelector('text=Pattern:', { timeout: 10000 });
+ // Scope away from the tree's own "Filter..." textbox (input[type="text"] index 0 on the
+ // whole page) by anchoring off the "Command" properties-panel tab, not a bare .first().
+ const patternField = page.getByRole('button', { name: 'Command', exact: true })
+ .locator('xpath=following::input[@type="text"]').first();
+ await patternField.fill('make bow;construct bow');
+ const nameField = page.getByText('Name:', { exact: true }).locator('..').locator('input[type="text"]');
+ await nameField.fill('CmdMakeBow');
+
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `if (not Got(branch)) {
+msg ("You need some wood to make a bow.")
+}
+else if (not Got(string)) {
+msg ("You need some string to make a bow.")
+}
+else {
+MoveObject(bow, player)
+MoveObject(string, nowhere)
+MoveObject(branch, nowhere)
+msg("You fasten the string to each end of the branch. Now you have a bow! Of sorts...")
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ const lastRow = page.locator('button:has-text("+ Add script")').last();
+ await capture(page, out('make1.png'), { untilLocator: lastRow, padding: 40 });
+
+ // --- make2.png: string object's Use/Give "Use (other object) on this" section, branch
+ // added, script "do (CmdMakeBow, \"script\")" ---
+ // Note: the "Run object" picker this renders as (the generic named-parameter form for the
+ // Do() builtin) only lists true Object elements, not Commands — so it can't show
+ // "CmdMakeBow" selected even though the script attribute ("script") is correctly parsed.
+ // This is consistent with every other object picker in the editor (Commands, like
+ // Functions/Timers, aren't Objects), not a capture bug — captured faithfully as-is.
+ await selectTreeNode(page, 'string');
+ await openTab(page, 'Features');
+ // "Use/Give:" is a checkbox row whose full label is a long sentence starting with
+ // "Use/Give:" — match by prefix, not exact, since the checkbox's accessible container
+ // wraps that entire sentence as one text node.
+ await page.locator('text=/^Use\\/Give:/').locator('..').locator('input[type="checkbox"]').check();
+ await openTab(page, 'Use/Give');
+ await page.waitForSelector('text=USE (OTHER OBJECT) ON THIS', { timeout: 10000 });
+ const useOtherActionSelect = page.locator('text=USE (OTHER OBJECT) ON THIS')
+ .locator('xpath=following::select[1]');
+ await useOtherActionSelect.selectOption({ label: 'Handle objects individually' });
+ const addObjectSelect = page.locator('text=USE (OTHER OBJECT) ON THIS')
+ .locator('xpath=following::select').nth(1);
+ await addObjectSelect.selectOption({ label: 'branch' });
+ await page.locator('text=USE (OTHER OBJECT) ON THIS').locator('xpath=following::button[contains(., "Add")]').first().click();
+ await page.waitForSelector('button:has-text("branch")', { timeout: 10000 });
+
+ const branchCodeView = page.locator('button:has-text("branch")')
+ .locator('xpath=following::button[contains(., "Code view")]').first();
+ await setScriptCodeView(page, branchCodeView, `do (CmdMakeBow, "script")`);
+ await page.waitForSelector('text=Run object', { timeout: 5000 });
+ const lastRow2 = page.locator('button:has-text("+ Add script")').last();
+ await capture(page, out('make2.png'), { untilLocator: lastRow2, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-copy-and-paste-code.mjs b/tests/e2e/docs-screenshots/capture-copy-and-paste-code.mjs
new file mode 100644
index 000000000..6c82aaf15
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-copy-and-paste-code.mjs
@@ -0,0 +1,32 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/howto/scripting/copy_and_paste_code.md — the TV's "look" script shown
+// in Code View, reusing its "Look at" description text from
+// tutorial/interacting_with_objects.md ("The TV is an old model, possibly 20 years old.") as a
+// script rather than plain text, matching the doc's own framing. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ selectLabeledField, addScriptCommand, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'TV');
+ await selectTreeNode(page, 'TV');
+ await openTab(page, 'Setup');
+ await selectLabeledField(page, 'object description:', 'script');
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first());
+ const msgInput = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await msgInput.fill('The TV is an old model, possibly 20 years old.');
+
+ await page.click('button:has-text("Code view")');
+ const cm = page.locator('.cm-editor .cm-content').first();
+ await cm.waitFor({ timeout: 5000 });
+ await capture(page, out('codeview_web.png'), { untilLocator: cm, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-custom-panes.mjs b/tests/e2e/docs-screenshots/capture-custom-panes.mjs
new file mode 100644
index 000000000..f48a5c32c
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-custom-panes.mjs
@@ -0,0 +1,58 @@
+// Regenerates the 1 in-game-player screenshot embedded in
+// site/src/content/docs/howto/ux/custom_panes.md (indicator-bar.png) - a custom status pane
+// showing a graphical hit-points bar, built via the game object's "User interface
+// initialisation script" (Advanced Scripts tab) and "Start script" (Scripts tab), captured
+// against the resulting WasmPlayer preview. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, openTab, toggleFeature,
+ setScriptCodeView, openPreview, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const checkboxFor = (page, label) => page.getByText(label, { exact: true }).locator('xpath=..').locator('input[type="checkbox"]');
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Show advanced scripts for the game object');
+
+ await openTab(page, 'Interface');
+ await checkboxFor(page, 'Show a custom status pane (use JS.setCustomStatus to set)').check();
+
+ await openTab(page, 'Advanced Scripts');
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `s = ""
+s = s + " Hit points: "
+s = s + " --- "
+s = s + " "
+s = s + " "
+s = s + " "
+s = s + " "
+s = s + " "
+s = s + " "
+s = s + "
"
+
+JS.setCustomStatus (s)
+if (HasScript(player, "changedhitpoints")) {
+ do (player, "changedhitpoints")
+}`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+
+ await openTab(page, 'Scripts');
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `player.changedhitpoints => {
+ JS.eval ("$('#hits-span').html('" + game.pov.hitpoints + "/" + game.pov.maxhitpoints + "');")
+ JS.eval ("$('#hits-indicator').css('padding-right', '" + (200 * game.pov.hitpoints / game.pov.maxhitpoints) + "px');")
+}
+
+player.maxhitpoints = 70
+player.hitpoints = 70`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+
+ const playerPage = await openPreview(page);
+ await capture(playerPage, out('indicator-bar.png'), { untilLocator: playerPage.locator('#txtCommand') });
+});
diff --git a/tests/e2e/docs-screenshots/capture-debugging-your-game.mjs b/tests/e2e/docs-screenshots/capture-debugging-your-game.mjs
new file mode 100644
index 000000000..39f180efe
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-debugging-your-game.mjs
@@ -0,0 +1,24 @@
+// Regenerates the shared Debugger.png embedded in both
+// site/src/content/docs/debugging_your_game.md and site/src/content/docs/about_types.md.
+// The Debugger is a WasmPlayer feature (opened via #cmdDebug), not an editor feature -
+// only reachable through an editor-preview session (openPreview), same as a player
+// screenshot. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, openPreview, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ const playerPage = await openPreview(page);
+
+ await playerPage.click('#cmdDebug');
+ await playerPage.waitForSelector('#questVivaDebugger[open]', { timeout: 5000 });
+ await playerPage.click('#qv-debugger-tabs button:text("Objects")');
+ await playerPage.waitForSelector('#qv-debugger-list [data-item]');
+ await playerPage.click('#qv-debugger-list [data-item="player"]');
+ await playerPage.waitForSelector('[data-attr-row]');
+ await capture(playerPage, out('Debugger.png'), { untilLocator: playerPage.locator('#questVivaDebugger') });
+});
diff --git a/tests/e2e/docs-screenshots/capture-editor-user-interface-elements.mjs b/tests/e2e/docs-screenshots/capture-editor-user-interface-elements.mjs
new file mode 100644
index 000000000..ad5daaf29
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-editor-user-interface-elements.mjs
@@ -0,0 +1,35 @@
+// Regenerates the 2 editor screenshots embedded in
+// site/src/content/docs/editor_user_interface_elements.md. See
+// .claude/skills/docs-screenshots/SKILL.md.
+//
+// The doc's example XML block (adding "Enable timer" to a "Timers"
+// category) is not hypothetical library-author sample code that needs to be
+// injected — it's a verbatim copy of the real, already-shipped registration in
+// src/Engine/Core/CoreEditorScriptsTimers.aslx (confirmed: pasting the doc's
+// snippet as a duplicate top-level via the raw XML view throws
+// Argument_AddingDuplicateWithKey, (function)EnableTimer). So this just opens
+// the real Add Script Command dialog and navigates to the real Timers category.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Scripts');
+
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('option', { name: 'Timers', exact: true }).click();
+ await page.waitForSelector('text=ADVANCED');
+ await capture(page, out('Editorui1.png'), { untilLocator: page.locator('[role="dialog"]') });
+
+ await page.getByRole('option', { name: /^●\s*Enable timer$/ }).click();
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ await page.waitForSelector('text=Enable timer');
+ const timerRow = page.locator('text=Enable timer').locator('..');
+ await capture(page, out('Editorui2.png'), { untilLocator: timerRow, padding: 60 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-exits.mjs b/tests/e2e/docs-screenshots/capture-exits.mjs
new file mode 100644
index 000000000..89f22a312
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-exits.mjs
@@ -0,0 +1,97 @@
+// Regenerates the 4 editor screenshots embedded in site/src/content/docs/exits.md. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ setLabeledField, setScriptCodeView, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ // Rename the default room to "kitchen" (matches the doc's narrative continuing from the
+ // main tutorial's kitchen), add "garden" (the locked-door destination) and "room2" (the
+ // portal destination used later in the Exit script examples).
+ await selectTreeNode(page, 'room');
+ await setLabeledField(page, 'Name:', 'kitchen');
+ await openTab(page, 'Room');
+ await page.waitForSelector('[data-value="kitchen"]', { timeout: 10000 });
+ await addElement(page, 'Add Room', 'garden');
+ await addElement(page, 'Add Room', 'room2');
+
+ // "door" (the object whose "unlock" verb the doc describes) and "talisman" (the object
+ // checked by Got() in the conditional-move example) both live in the kitchen.
+ await selectTreeNode(page, 'kitchen');
+ await addElement(page, 'Add Object in "kitchen"', 'door');
+ await selectTreeNode(page, 'kitchen');
+ await addElement(page, 'Add Object in "kitchen"', 'talisman');
+
+ // --- Create the south exit to garden ---
+ await selectTreeNode(page, 'kitchen');
+ await openTab(page, 'Exits');
+ await page.getByRole('button', { name: 'south', exact: true }).click();
+ const destCombobox = page.locator('[role="combobox"]');
+ await destCombobox.click();
+ await destCombobox.fill('garden');
+ await page.waitForSelector('[role="option"]:has-text("garden")', { timeout: 5000 });
+ await page.click('[role="option"]:has-text("garden")');
+ await page.click('button:has-text("Create exit")');
+ await page.waitForSelector('text=south → garden', { timeout: 10000 });
+
+ // --- Lockedexit.png: named + locked, on the Exit tab ---
+ // Select the newly created (still auto-named "Exit: garden") exit via its tree row, not
+ // the "south → garden" summary link on the Exits tab — that link selects the destination
+ // room ("garden"), not the exit itself.
+ await page.getByText('Exit: garden', { exact: true }).click();
+ await page.waitForSelector('button:has-text("Exit")', { timeout: 10000 });
+ await setLabeledField(page, 'Name:', 'garden exit');
+ await page.getByText('Locked', { exact: true }).locator('..').locator('input[type="checkbox"]').check();
+ const lockedField = page.getByText('Print message when locked:', { exact: true });
+ await capture(page, out('Lockedexit.png'), { untilLocator: lockedField, padding: 60 });
+
+ // --- Exit script examples: uncheck Locked (independent narrative section), enable
+ // "Run a script" and type each example directly into the script's Code view ---
+ await page.getByText('Locked', { exact: true }).locator('..').locator('input[type="checkbox"]').uncheck();
+ await page.getByText('Run a script (instead of moving the player automatically)', { exact: true })
+ .locator('..').locator('input[type="checkbox"]').check();
+ await page.waitForSelector('text=Script to run:', { timeout: 5000 });
+
+ // --- exitscript1.png: conditional move based on Got(talisman) ---
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `if (Got(talisman)) {
+msg ("The talisman hums as you pass through the portal.")
+MoveObject (player, room2)
+}
+else {
+msg ("For some reason you cannot get through the portal.")
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ const lastScript1Row = page.locator('button:has-text("+ Add script")').last();
+ await capture(page, out('exitscript1.png'), { untilLocator: lastScript1Row, padding: 40 });
+
+ // --- exitscript2.png: firsttime + MoveObject ---
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `firsttime {
+msg ("As you walk down the path, the sky darkens alarmingly ")
+SetObjectFlagOn (player, "apocolyse started")
+}
+MoveObject (player, room2)`);
+ await page.waitForSelector('text=The first time,', { timeout: 5000 });
+ const lastScript2Row = page.locator('button:has-text("+ Add script")').last();
+ await capture(page, out('exitscript2.png'), { untilLocator: lastScript2Row, padding: 40 });
+
+ // --- exitscript3.png: room script locking every exit via foreach/ScopeExits ---
+ // Placed on the kitchen room's own "Before entering the room" script, the natural home for
+ // "trap the player in a room" logic the doc's Room Scripts section describes.
+ await selectTreeNode(page, 'kitchen');
+ await openTab(page, 'Scripts');
+ await page.waitForSelector('text=Before entering the room:', { timeout: 10000 });
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `foreach (ext, ScopeExits ()) {
+ext.locked = true
+}`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ const foreachRow = page.locator('button:has-text("+ Add script")').last();
+ await capture(page, out('exitscript3.png'), { untilLocator: foreachRow, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-implementing-components-of-an-object.mjs b/tests/e2e/docs-screenshots/capture-implementing-components-of-an-object.mjs
new file mode 100644
index 000000000..a26ee44cf
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-implementing-components-of-an-object.mjs
@@ -0,0 +1,24 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/other_guides/implementing_components_of_an_object.md.
+// See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, toggleFeature, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images', 'other_guides');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'machine');
+ await selectTreeNode(page, 'machine');
+ await addElement(page, 'Add Object in "machine"', 'button');
+ await selectTreeNode(page, 'machine');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.locator('select').first().selectOption({ label: 'Surface' });
+ await page.waitForTimeout(200);
+ await capture(page, out('Component.png'), { untilLocator: page.locator('select').first(), padding: 300 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-invisiclues.mjs b/tests/e2e/docs-screenshots/capture-invisiclues.mjs
new file mode 100644
index 000000000..383db5ffb
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-invisiclues.mjs
@@ -0,0 +1,53 @@
+// Regenerates the 2 editor screenshots embedded in
+// site/src/content/docs/other_guides/invisiclues.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, setScriptCodeView, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images', 'other_guides');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ // --- invisiclues.png: a "help2" Command, pattern "help;?", the InvisiClues foreach script ---
+ await selectTreeNode(page, 'room');
+ await page.click('button[title="Add element"]');
+ await page.getByRole('button', { name: 'Add Command to "room"', exact: true }).click();
+ await page.waitForSelector('text=Pattern:', { timeout: 10000 });
+ const patternField = page.getByRole('button', { name: 'Command', exact: true })
+ .locator('xpath=following::input[@type="text"]').first();
+ await patternField.fill('help;?');
+ const nameField = page.getByText('Name:', { exact: true }).locator('..').locator('input[type="text"]');
+ await nameField.fill('help2');
+
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `if (HasAttribute(game, "defaultbackground")) {
+bg = LCase (game.defaultbackground)
+}
+else {
+bg = "white"
+}
+msg ("Drag your mouse over the text to reveal only the clues you need.")
+foreach (key, game.helpdict) {
+msg ("" + key + " [" + StringDictionaryItem(game.helpdict, key) + " ]")
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ await capture(page, out('invisiclues.png'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').last(),
+ padding: 40,
+ });
+
+ // --- setup_hints.png: game's Start script builds game.helpdict as a string dictionary ---
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Scripts');
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `game.helpdict = NewStringDictionary()
+dictionary add (game.helpdict, "How do I go north?", "Open the door!")
+dictionary add (game.helpdict, "How do I open the door?", "Type OPEN DOOR!")
+dictionary add (game.helpdict, "How do I kill the bugbear?", "There are allergic to jam...")
+dictionary add (game.helpdict, "How do I kill the bugbear with jam?", "Perhaps you could give him a sandwich?")`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ await capture(page, out('setup_hints.png'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').first(),
+ padding: 40,
+ });
+});
diff --git a/tests/e2e/docs-screenshots/capture-memory-or-wiki.mjs b/tests/e2e/docs-screenshots/capture-memory-or-wiki.mjs
new file mode 100644
index 000000000..1ecca371f
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-memory-or-wiki.mjs
@@ -0,0 +1,80 @@
+// Regenerates the 2 editor screenshots embedded in
+// site/src/content/docs/memory_or_wiki.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, addScriptCommand, fieldByLabel, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const addScriptButtons = page => page.locator('button:has-text("+ Add script")');
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Ask/Tell:');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'memory');
+ await selectTreeNode(page, 'memory');
+ await page.getByRole('button', { name: 'Ask/Tell', exact: true }).click();
+ const askInput = fieldByLabel(page, 'Ask about:');
+ await askInput.fill('weddle hoots');
+ await askInput.locator('xpath=../..').locator('button:has-text("Add")').click();
+ await page.waitForTimeout(300);
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const topicMsg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await topicMsg.fill('The Weddle-Hoots are an old aristocratic family.');
+ await topicMsg.evaluate(el => { el.scrollLeft = 0; });
+
+ // "Script to run when asked about an unknown topic:" lives behind the bottom-level
+ // "ADVANCED" expander on the Ask/Tell tab (a plain /, distinct from
+ // the tree's own "Advanced" node).
+ await page.locator('summary', { hasText: 'Advanced' }).click();
+ const unknownLabel = page.getByText('Script to run when asked about an unknown topic:', { exact: true });
+ const unknownAddBtn = unknownLabel.locator('xpath=following::button[contains(., "+ Add script")][1]');
+ await addScriptCommand(page, unknownAddBtn);
+ const unknownType = page.locator('xpath=(//span[text()="Print"])[last()]/following-sibling::select[1]');
+ await unknownType.selectOption('expression');
+ const unknownExpr = page.locator('xpath=(//span[text()="Print"])[last()]/following::input[1]');
+ await unknownExpr.fill('"You remember nothing about " + text + "."');
+ await unknownExpr.evaluate(el => { el.scrollLeft = 0; });
+ await page.waitForTimeout(200);
+ await capture(page, out('memory1.png'), { untilLocator: unknownExpr, padding: 40 });
+
+ // --- memory2.png: "remember #text#" command, Call function DoAskTell with 5 params ---
+ await selectTreeNode(page, 'room');
+ await page.click('button[title="Add element"]');
+ await page.click('button:has-text("Add Command to")', { timeout: 5000 });
+ await page.waitForSelector('text=Command:', { timeout: 10000 });
+ const patternRow = page.getByText('Pattern:', { exact: true }).locator('xpath=../..');
+ await patternRow.locator(':scope > input[type=text]').fill('remember #text#');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'Call function' });
+ const callFnLabel = page.getByText('Call function', { exact: true });
+ const callFnInput = callFnLabel.locator('xpath=following::input[@type="text"][1]');
+ await callFnInput.fill('DoAskTell');
+ // Selecting the "Library functions" autocomplete suggestion (rather than just filling
+ // the text) is what makes AppShell recognise the function and swap the generic
+ // "+ param" list for named fields matching its actual signature (object/text/property/
+ // defaultscript/defaulttemplate) - fill those directly instead of adding params.
+ await page.getByText('DoAskTell', { exact: true }).last().click();
+ await page.waitForTimeout(300);
+ const defaultTemplateInput = page.locator('xpath=(//*[contains(text(),"defaulttemplate")]/following::input)[1]');
+ await defaultTemplateInput.waitFor({ state: 'visible', timeout: 5000 });
+ const propertyInput = page.locator('xpath=(//span[text()="property:"]/following::input)[1]');
+ const defaultScriptInput = page.locator('xpath=(//span[text()="defaultscript:"]/following::input)[1]');
+ await page.locator('xpath=(//span[text()="object:"]/following::input)[1]').fill('memory');
+ await page.locator('xpath=(//span[text()="text:"]/following::input)[1]').fill('text');
+ await propertyInput.fill('"ask"');
+ await defaultScriptInput.fill('"askdefault"');
+ await defaultTemplateInput.fill('"DefaultAsk"');
+ for (const el of [propertyInput, defaultScriptInput, defaultTemplateInput]) {
+ await el.evaluate(node => { node.scrollLeft = 0; });
+ }
+ await page.waitForTimeout(200);
+ await capture(page, out('memory2.png'), { untilLocator: defaultTemplateInput, padding: 60 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-multiple-choices-using-a-switch-script.mjs b/tests/e2e/docs-screenshots/capture-multiple-choices-using-a-switch-script.mjs
new file mode 100644
index 000000000..d57a01f5c
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-multiple-choices-using-a-switch-script.mjs
@@ -0,0 +1,80 @@
+// Regenerates the 3 editor screenshots embedded in
+// site/src/content/docs/howto/tasks/multiple_choices_using_a_switch_script.md — was blocked
+// on the Switch case-list editor (task_082ae91c); now fixed upstream (PR #2090). See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab, addVerb,
+ setScriptCodeView, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const expandAllCases = async page => {
+ const toggles = page.getByRole('button', { name: '▶' });
+ while (await toggles.count() > 0) {
+ await toggles.first().click();
+ }
+};
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Mary');
+ await selectTreeNode(page, 'Mary');
+ await openTab(page, 'Setup');
+ await page.getByText('Type:', { exact: true }).first().locator('xpath=following::select[1]').selectOption({ label: 'Female character (named)' });
+ await openTab(page, 'Verbs');
+ await addVerb(page, 'Speak to');
+ await page.locator('select').first().selectOption('script');
+ const codeViewBtn = page.locator('button:has-text("Code view")').first();
+
+ // --- switch01.png: ShowMenu with a freshly-added, empty "Switch (result)" ---
+ await setScriptCodeView(page, codeViewBtn, `options = Split ("The weather;Her hair;The Lost Key of Arenbos", ";")
+ShowMenu ("Talk about?", options, true) {
+switch (result) {
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await capture(page, out('switch01.png'), { untilLocator: page.locator('button:has-text("+ Add script")').last(), padding: 40 });
+
+ // --- switch02.png: one case, "The weather", printing Mary's reply ---
+ await setScriptCodeView(page, codeViewBtn, `options = Split ("The weather;Her hair;The Lost Key of Arenbos", ";")
+ShowMenu ("Talk about?", options, true) {
+switch (result) {
+case ("The weather") {
+msg ("'Hasn't it been awful,' says Mary.")
+}
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await expandAllCases(page);
+ await capture(page, out('switch02.png'), { untilLocator: page.locator('button:has-text("+ Add script")').last(), padding: 40 });
+
+ // --- switch04.png: all three cases plus a default, to show the "now there are three
+ // Add new script buttons" state described just before this image ---
+ await setScriptCodeView(page, codeViewBtn, `options = Split ("The weather;Her hair;The Lost Key of Arenbos", ";")
+ShowMenu ("Talk about?", options, true) {
+switch (result) {
+case ("The weather") {
+msg ("'Hasn't it been awful,' says Mary.")
+}
+case ("Her hair") {
+msg ("'Do you like it this colour?' she asks.")
+}
+case ("The Lost Key of Arenbos") {
+msg ("'Oh, I suppose you want it back.'")
+MoveObject (The Lost Key of Arenbos, player)
+}
+default {
+msg ("That was not even an option!")
+}
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ await expandAllCases(page);
+ await page.waitForSelector('text=Default:', { timeout: 5000 });
+ await capture(page, out('switch04.png'), { untilLocator: page.locator('button:has-text("+ Add script")').last(), padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-overview.mjs b/tests/e2e/docs-screenshots/capture-overview.mjs
new file mode 100644
index 000000000..0474ed034
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-overview.mjs
@@ -0,0 +1,127 @@
+// Regenerates the 6 screenshots embedded in site/src/content/docs/overview.md - a broad,
+// illustrative tour of the editor/player rather than tutorial-specific steps, so the exact
+// scenes below are representative rather than transcribed from prose. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab, addVerb,
+ toggleFeature, addScriptCommand, ifExpressionSelect, ifObjectSelect,
+ setScriptCodeView, openPreview, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+const loungePicture = '/private/tmp/claude-501/-Users-alexwarren-Code-quest/ab5b1524-2956-4e1f-a0ed-ddffb535b27f/scratchpad/lounge.png';
+
+const checkboxFor = (page, label) => page.getByText(label, { exact: true }).locator('xpath=..').locator('input[type="checkbox"]');
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'TV');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Bob');
+ // Bob defaults to the generic "Inanimate object" type, which prints "a Bob" in room
+ // descriptions (the default "a"/"an" prefix) - switch to "Male character (named)" so he
+ // reads as a proper name instead, same as the tutorial's own "Creating a Character" step
+ // (interacting_with_objects.md) instructs. The Setup tab has two "Type" dropdowns with the
+ // same caption (Room/Object/Object-and-or-room, then Inanimate/Male/Female character) - the
+ // second one is the only one offering a "namedmale" option value, so select on that instead
+ // of relying on label text order.
+ await selectTreeNode(page, 'Bob');
+ await openTab(page, 'Setup');
+ await page.locator('select:has(option[value="namedmale"])').selectOption('namedmale');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Room', 'kitchen');
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Exits');
+ await page.getByRole('button', { name: 'east', exact: true }).click();
+ const destCombobox = page.locator('[role="combobox"]');
+ await destCombobox.click();
+ await destCombobox.fill('kitchen');
+ await page.waitForSelector('[role="option"]:has-text("kitchen")', { timeout: 5000 });
+ await page.click('[role="option"]:has-text("kitchen")');
+ await page.click('button:has-text("Create exit")');
+ await page.waitForSelector('text=east → kitchen', { timeout: 10000 });
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Setup');
+
+ // --- overview-editor.png: room selected, Setup tab, objects/exit visible in the tree ---
+ await capture(page, out('overview-editor.png'), { untilLocator: page.locator('text=Use default prefix and suffix'), padding: 220 });
+
+ // --- overview-textadventure.png: initial player view of that same room ---
+ const p1 = await openPreview(page);
+ await capture(p1, out('overview-textadventure.png'), { untilLocator: p1.locator('#txtCommand') });
+ await p1.close();
+
+ // --- overview-multimedia.png: room picture (Picture frame feature) shown in the game pane ---
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Interface');
+ await toggleFeature(page, 'Picture frame:');
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Room');
+ await page.waitForSelector('text=Room picture:', { timeout: 10000 });
+ const uploadInput = page.getByText('Room picture:', { exact: true }).locator('xpath=following::input[@type="file"][1]');
+ await uploadInput.setInputFiles(loungePicture);
+ await page.waitForTimeout(500);
+ const p2 = await openPreview(page);
+ await capture(p2, out('overview-multimedia.png'), { untilLocator: p2.locator('#txtCommand') });
+ await p2.close();
+
+ // --- overview-script.png: Bob's "speak to" verb, an if/else script ---
+ await selectTreeNode(page, 'Bob');
+ await openTab(page, 'Verbs');
+ await addVerb(page, 'speak to');
+ await page.locator('select').first().selectOption('script');
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first(), { category: 'Scripts', item: 'If...' });
+ await ifExpressionSelect(page).selectOption('object has flag');
+ await ifObjectSelect(page).selectOption({ label: 'Bob' });
+ const flagNameInput = page.locator('xpath=(//span[text()="if"]/following::input)[1]');
+ await flagNameInput.fill('alive');
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first());
+ const thenMsg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await thenMsg.fill('Bob says he feels kind of fuzzy.');
+ await page.getByRole('button', { name: '+ else', exact: true }).click();
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').nth(1));
+ const elseMsg = page.locator('xpath=(//span[text()="Print"])[2]/following-sibling::input[1]');
+ await elseMsg.fill('Bob stares at you blankly.');
+ await capture(page, out('overview-script.png'), { untilLocator: elseMsg, padding: 40 });
+
+ // --- overview-customui.png: a distinct atmospheric custom pane style ---
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Show advanced scripts for the game object');
+ await openTab(page, 'Advanced Scripts');
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `backandborder = "border: 1px solid #4a5568;background:#1a202c"
+text = "color:#e2e8f0;font-family:georgia, serif"
+JS.setCss ("body", "background:#0f1117")
+JS.setCss (".ui-accordion-header", "border-radius: 3px;" + backandborder)
+JS.setCss (".ui-accordion-content", "border-radius: 3px;" + backandborder + ";border-top:none")
+JS.setCss (".accordion-header-text", text)
+JS.setCss (".ui-icon", "display:none")
+JS.setCss ("#gamePanes", "margin-top: 16px")`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ const p3 = await openPreview(page);
+ await capture(p3, out('overview-customui.png'), { untilLocator: p3.locator('#txtCommand') });
+ await p3.close();
+});
+
+await runCapture(async ({ page, baseUrl }) => {
+ // --- overview-gamebook.png: a fresh Gamebook draft's default Page1, unmodified - its
+ // scaffolded content ("This is page 1... This link goes to page 2 / And this link goes to
+ // page 3") already matches what the old screenshot showed, so no editing is needed ---
+ await createLocalDraft(page, baseUrl, 'Gamebook Example', { gameType: 'Gamebook' });
+ // Gamebooks have no #txtCommand box (link-driven, not command-driven) - lib.mjs's
+ // openPreview() would hang forever waiting for it, same class of issue as the command-bar-
+ // off case in capture-ui-style.mjs. #location also never gets populated for a gamebook
+ // (no room concept), confirmed live it stays whitespace-only forever, so wait for the
+ // page's own scaffolded text to actually appear instead.
+ const context = page.context();
+ const [p] = await Promise.all([
+ context.waitForEvent('page', { timeout: 15000 }),
+ page.click('button:has-text("Preview")'),
+ ]);
+ await p.waitForFunction(() => document.body?.innerText.includes('This is page 1'), { timeout: 20000 });
+ await capture(p, out('overview-gamebook.png'), { untilLocator: p.getByText('And this link goes to page 3'), padding: 60 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-patrolling-npcs.mjs b/tests/e2e/docs-screenshots/capture-patrolling-npcs.mjs
new file mode 100644
index 000000000..1fe1fe2e6
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-patrolling-npcs.mjs
@@ -0,0 +1,85 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/patrolling_npcs.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, addScriptCommand, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const addScriptButtons = page => page.locator('button:has-text("+ Add script")');
+
+async function addAttribute(page, name, type) {
+ const addAttrInput = page.locator('input[placeholder="Add attribute..."]');
+ await addAttrInput.fill(name);
+ await addAttrInput.locator('..').locator('button:has-text("Add")').click();
+ await page.waitForTimeout(300);
+ if (type) {
+ const typeSelect = page.locator('select').filter({ hasText: 'String' }).first();
+ await typeSelect.selectOption(type);
+ await page.waitForTimeout(200);
+ }
+}
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Mary');
+ await selectTreeNode(page, 'Mary');
+ await openTab(page, 'Attributes');
+ await addAttribute(page, 'route', 'String List');
+ await addAttribute(page, 'patrolstate', 'Integer');
+ await addAttribute(page, 'takeaturn', 'Script');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Set a variable or attribute' });
+ const label1 = page.getByText('Set variable', { exact: true }).nth(0);
+ const inputs1 = label1.locator('xpath=following::input[@type="text"]');
+ await inputs1.nth(0).fill('oldroom');
+ await inputs1.nth(1).fill('this.parent');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Set a variable or attribute' });
+ const label2 = page.getByText('Set variable', { exact: true }).nth(1);
+ const inputs2 = label2.locator('xpath=following::input[@type="text"]');
+ await inputs2.nth(0).fill('this.patrolstate');
+ await inputs2.nth(1).fill('(this.patrolstate + 1) % ListCount(this.route)');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Set a variable or attribute' });
+ const label3 = page.getByText('Set variable', { exact: true }).nth(2);
+ const inputs3 = label3.locator('xpath=following::input[@type="text"]');
+ await inputs3.nth(0).fill('this.parent');
+ await inputs3.nth(1).fill('GetObject(StringListItem(this.route, this.patrolstate))');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'If...' });
+ const ifExprInput = page.locator('xpath=(//span[text()="if"]/following::input[@type="text"])[1]');
+ await ifExprInput.fill('not oldroom = this.parent');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'Call function' });
+ await page.waitForTimeout(200);
+ const callFnLabel1 = page.getByText('Call function', { exact: true }).nth(0);
+ const callFnInput1 = callFnLabel1.locator('xpath=following::input[@type="text"][1]');
+ await callFnInput1.fill('PrintIfHere');
+ const paramBtn1 = callFnLabel1.locator('xpath=following::button[contains(., "+ param")][1]');
+ await paramBtn1.click();
+ await page.waitForTimeout(200);
+ await paramBtn1.click();
+ await page.waitForTimeout(200);
+ const allInputs1 = callFnLabel1.locator('xpath=following::input[@type="text"]');
+ await allInputs1.nth(1).fill('oldroom');
+ await allInputs1.nth(2).fill('"Mary leaves the room."');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'Call function' });
+ await page.waitForTimeout(200);
+ const callFnLabel2 = page.getByText('Call function', { exact: true }).nth(1);
+ const callFnInput2 = callFnLabel2.locator('xpath=following::input[@type="text"][1]');
+ await callFnInput2.fill('PrintIfHere');
+ const paramBtn2 = callFnLabel2.locator('xpath=following::button[contains(., "+ param")][1]');
+ await paramBtn2.click();
+ await paramBtn2.click();
+ await page.waitForTimeout(200);
+ const allInputs2 = callFnLabel2.locator('xpath=following::input[@type="text"]');
+ await allInputs2.nth(1).fill('this.parent');
+ await allInputs2.nth(2).fill('"Mary enters the room."');
+ await page.waitForTimeout(200);
+ const lastParamInput = allInputs2.nth(2);
+ await capture(page, out('patrol1.png'), { untilLocator: lastParamInput, padding: 60 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-score-health-money.mjs b/tests/e2e/docs-screenshots/capture-score-health-money.mjs
new file mode 100644
index 000000000..780f1e6c5
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-score-health-money.mjs
@@ -0,0 +1,47 @@
+// Regenerates the 3 editor screenshots embedded in
+// site/src/content/docs/score_health_money.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, addScriptCommand, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Features');
+ await page.getByText('Score', { exact: true }).locator('..').locator('input[type="checkbox"]').check();
+ await page.getByText('Health', { exact: true }).locator('..').locator('input[type="checkbox"]').check();
+ await page.getByText('Money', { exact: true }).locator('..').locator('input[type="checkbox"]').check();
+
+ // --- increase_decrease.png: Add Script Command dialog, "Player" category, the 6
+ // score/health/money commands ---
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Scripts');
+ await page.locator('button:has-text("+ Add script")').first().click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('option', { name: 'Player', exact: true }).click();
+ const dialog = page.locator('[role="dialog"]').filter({ hasText: 'Add Script Command' });
+ await capture(page, out('increase_decrease.png'), { untilLocator: dialog });
+
+ // --- increase.png: "Increase score" command, set to add 5 ---
+ await page.getByRole('option', { name: /^●\s*Increase score$/ }).click();
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ await page.waitForTimeout(200);
+ await page.screenshot({ path: '/tmp/spike-increase-added.png' });
+ const amountInput = page.locator('xpath=(//span[contains(text(), "Increase")]/following::input)[1]');
+ await amountInput.fill('5');
+ await page.waitForTimeout(200);
+ await capture(page, out('increase.png'), { untilLocator: amountInput, padding: 40 });
+
+ // --- you_died.png: game's Player tab, "when health goes to zero" script ---
+ await selectTreeNode(page, 'game');
+ await page.getByRole('button', { name: 'Player', exact: true }).click();
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first());
+ const diedMsg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await diedMsg.fill('You died!');
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first(), { category: 'Game State', item: 'Finish the game' });
+ await page.waitForTimeout(200);
+ await capture(page, out('you_died.png'), { untilLocator: page.locator('button:has-text("+ Add script")').first(), padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-shop.mjs b/tests/e2e/docs-screenshots/capture-shop.mjs
new file mode 100644
index 000000000..8e6541eb6
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-shop.mjs
@@ -0,0 +1,60 @@
+// Regenerates the 2 editor screenshots embedded in
+// site/src/content/docs/howto/tasks/shop.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, openTab, toggleFeature,
+ addAdvancedElement, setScriptCodeView, addScriptCommand, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ // --- SetUpShop.png: a Function, params "shop"/"stock" (in that order), no return type ---
+ await addAdvancedElement(page, 'Function', 'SetUpShop');
+ const paramInput = page.locator('input[placeholder^="Add parameter name"]');
+ await paramInput.fill('shop');
+ await paramInput.locator('xpath=following-sibling::button[1]').click();
+ await page.waitForSelector('text=shop', { timeout: 5000 });
+ await paramInput.fill('stock');
+ await paramInput.locator('xpath=following-sibling::button[1]').click();
+ await page.waitForSelector('text=stock', { timeout: 5000 });
+
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `shop.stock = stock
+foreach (o, GetDirectChildren(stock)) {
+ SetUpMerchandise (o)
+}`);
+ await page.waitForSelector('text=For each: loop variable', { timeout: 5000 });
+ await capture(page, out('SetUpShop.png'), { untilLocator: page.locator('button:has-text("+ Add script")').last(), padding: 40 });
+
+ // --- StartShop.png: game's Start script, a single SetUpShop(Cake Shop, Cake Shop Stock)
+ // call - built through the picker (not Code view) so that SetUpShop, being a real function
+ // already defined in this same draft, gets recognised and rendered with named shop/stock
+ // fields rather than the generic +param form ---
+ await selectTreeNode(page, 'game');
+ await page.getByRole('button', { name: 'Scripts', exact: true }).click();
+ await page.waitForSelector('text=Start script:', { timeout: 10000 });
+ const startAddBtn = page.getByText('Start script:', { exact: true }).locator('xpath=following::button[contains(.,"+ Add script")][1]');
+ await addScriptCommand(page, startAddBtn, { category: 'Scripts', item: 'Call function' });
+ const callFnInput = page.locator('xpath=//*[contains(text(), "Call function")]/following::input[@type="text"][1]');
+ await callFnInput.fill('SetUpShop');
+ await page.waitForSelector('text=SetUpShop', { timeout: 5000 });
+ // Select the autocomplete suggestion (not just typing the name) so the generic +param UI
+ // swaps to named shop/stock fields matching the function's real signature.
+ const suggestion = page.getByRole('option', { name: 'SetUpShop', exact: true });
+ if (await suggestion.count() > 0) {
+ await suggestion.click();
+ }
+ await page.waitForSelector('text=shop:', { timeout: 5000 });
+ const shopInput = page.getByText('shop:', { exact: true }).locator('xpath=following::input[1]');
+ await shopInput.fill('Cake Shop');
+ const stockInput = page.getByText('stock:', { exact: true }).locator('xpath=following::input[1]');
+ await stockInput.fill('Cake Shop Stock');
+ // Narrow field shows the fill()'d text's tail, not its start - scroll back to the beginning
+ // before capturing (see docs-screenshots skill's known gotcha).
+ await stockInput.evaluate(el => { el.scrollLeft = 0; });
+ await capture(page, out('StartShop.png'), { untilLocator: stockInput, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-showing-a-map.mjs b/tests/e2e/docs-screenshots/capture-showing-a-map.mjs
new file mode 100644
index 000000000..466e9e052
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-showing-a-map.mjs
@@ -0,0 +1,474 @@
+// Regenerates all 7 images from site/src/content/docs/howto/tasks/showing_a_map.md.
+// See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab, toggleFeature,
+ sendCommand,
+} from './lib.mjs';
+
+// The Map tab's "Width"/"Length" labels are exact-text substrings of "Border width", so use
+// an exact match rather than lib.mjs's fieldByLabel (a plain substring match).
+const mapField = (page, label) => page.getByText(label, { exact: true }).locator('xpath=..').locator('input, select, textarea');
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+function saveTrimmed(gridPanelScreenshotPath, { chopTop } = {}) {
+ // #gridPanel is a large fixed-size canvas mostly empty around the drawn rooms - the old
+ // screenshots were tightly cropped to just the map itself, so trim the whitespace border
+ // ImageMagick leaves after drawing (matches this repo's other post-processing conventions).
+ const args = [gridPanelScreenshotPath];
+ if (chopTop) {
+ // The panel has its own thin coloured top border, only reachable by -trim (and thus
+ // wrongly kept as "content") on a tall enough canvas/layout that nothing else reaches
+ // close to true y=0 - confirmed only needed for map6.png's up/down-level layout.
+ args.push('-chop', `0x${chopTop}+0+0`);
+ }
+ args.push('-trim', '+repage', '-bordercolor', 'white', '-border', '15', gridPanelScreenshotPath);
+ execFileSync('magick', args);
+ console.log(`SAVED: ${gridPanelScreenshotPath}`);
+}
+
+// Creates a same-named exit from the currently-selected room's Exits tab (must already be open)
+// to `destName`, with an optional grid `length` (0 = rooms drawn adjacent, no connecting line).
+async function createExit(page, roomName, direction, destName, { length } = {}) {
+ await page.getByRole('button', { name: direction, exact: true }).click();
+ const destCombobox = page.locator('[role="combobox"]');
+ await destCombobox.click();
+ await destCombobox.fill(destName);
+ await page.waitForSelector(`[role="option"]:has-text("${destName}")`, { timeout: 5000 });
+ await page.click(`[role="option"]:has-text("${destName}")`);
+ await page.click('button:has-text("Create exit")');
+ await page.waitForSelector(`text=${direction} → ${destName}`, { timeout: 10000 });
+ if (length !== undefined) {
+ await setExitLength(page, roomName, destName, length);
+ }
+}
+
+// Sets the grid length on a specific exit, selected via its own TREE ROW ("Exit: "),
+// not the "direction → destName" summary link on the Exits tab - that link selects the
+// *destination room*, not the exit itself (see capture-exits.mjs's Lockedexit.png note). The
+// tree row lookup is scoped to `roomName`'s own treeitem (via a `has:` filter on its
+// [data-value] node), confirmed live via read_page that the tree really does nest each room's
+// exits as DOM descendants of that room's own treeitem/group - a page-wide text search breaks
+// as soon as two different rooms have an exit to the same destination name, since both render
+// an identically-labelled "Exit: " row.
+async function setExitLength(page, roomName, destName, length) {
+ // A room's `[data-value]` is shared by 3 different elements: the real ARIA treeitem
+ // (data-part="branch"), the clickable label row selectTreeNode() targets
+ // (data-part="branch-control"), and the (possibly hidden) content container holding its
+ // children (data-part="branch-content") - confirmed live via the strict-mode error listing
+ // all three. Selecting the room (branch-control) does NOT itself expand it - aria-expanded
+ // stayed "false" even once selected/re-created, so the branch-content stayed hidden and
+ // unclickable. Expand explicitly via its own chevron button first.
+ await selectTreeNode(page, roomName);
+ const branchControl = page.locator(`[data-value="${roomName}"][data-part="branch-control"]`);
+ if ((await branchControl.getAttribute('data-state')) !== 'open') {
+ await branchControl.getByRole('button', { name: 'Expand' }).click();
+ }
+ const branchContent = page.locator(`[data-value="${roomName}"][data-part="branch-content"]`);
+ await branchContent.getByText(`Exit: ${destName}`, { exact: true }).click();
+ await page.getByRole('button', { name: 'Map', exact: true }).click();
+ await mapField(page, 'Length:').fill(String(length));
+}
+
+// Sets the grid length on an exit's own reciprocal side - e.g. after creating "east" from
+// roomA to roomB, this selects roomB and its own auto-created exit back to roomA, and sets its
+// length too. Exit length is per-direction, not shared, so the doc's "remember to change both
+// directions" instruction has to be done as two separate edits.
+async function setReciprocalExitLength(page, roomName, destName, length) {
+ await selectTreeNode(page, roomName);
+ await page.getByRole('button', { name: 'Exits', exact: true }).click();
+ await setExitLength(page, roomName, destName, length);
+}
+
+// The player dot (and, after it, the view's pan/recentre offset) animate toward their new
+// position over several requestAnimationFrame ticks rather than snapping instantly - see
+// src/PlayerCore/Resources/grid.js's onFrame()/gridApi.drawPlayer (playerVector/offsetVector
+// are plain top-level `var`s in a non-module script, so genuinely reachable as window globals).
+// sendCommand() only waits for the game-logic turn to finish, not this separate canvas
+// animation, so a screenshot taken right after the last movement command can catch the dot
+// mid-slide or the view mid-pan. Wait for both vectors to null out (onFrame's own "arrived"
+// signal) before every grid screenshot in this file.
+async function waitForGridAnimation(playerPage) {
+ await playerPage.waitForFunction(
+ () => window.playerVector == null && window.offsetVector == null,
+ { timeout: 10000 },
+ );
+}
+
+async function freshPreview(context, page) {
+ const [playerPage] = await Promise.all([
+ context.waitForEvent('page', { timeout: 15000 }),
+ page.click('button:has-text("Preview")'),
+ ]);
+ await playerPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 60000 });
+ await playerPage.waitForFunction(() => window.canSendCommand === true, { timeout: 30000 });
+ return playerPage;
+}
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Interface');
+ await toggleFeature(page, 'Map and Drawing Grid:');
+
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Room', 'kitchen');
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Exits');
+ await page.getByRole('button', { name: 'south', exact: true }).click();
+ const destCombobox = page.locator('[role="combobox"]');
+ await destCombobox.click();
+ await destCombobox.fill('kitchen');
+ await page.waitForSelector('[role="option"]:has-text("kitchen")', { timeout: 5000 });
+ await page.click('[role="option"]:has-text("kitchen")');
+ await page.click('button:has-text("Create exit")');
+ await page.waitForSelector('text=south → kitchen', { timeout: 10000 });
+
+ // openPreview() itself waits for #txtCommand, which is fine here (command bar is on) -
+ // inlined rather than imported since we need the raw context/page anyway for a screenshot
+ // of just the grid canvas afterwards.
+ const context = page.context();
+ const [playerPage] = await Promise.all([
+ context.waitForEvent('page', { timeout: 15000 }),
+ page.click('button:has-text("Preview")'),
+ ]);
+ await playerPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 60000 });
+ await playerPage.waitForFunction(() => window.canSendCommand === true, { timeout: 30000 });
+ await sendCommand(playerPage, 'south');
+
+ const gridPanel = playerPage.locator('#gridPanel');
+ await gridPanel.waitFor({ state: 'visible', timeout: 10000 });
+ await waitForGridAnimation(playerPage);
+ const mapPath = out('Map.png');
+ await gridPanel.screenshot({ path: mapPath });
+ saveTrimmed(mapPath);
+ await playerPage.close();
+
+ // --- Map2.png: same two rooms, resized/coloured/labelled (5x3 yellow lounge, 2x2 sky
+ // blue kitchen) ---
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Map');
+ await mapField(page, 'Width:').fill('5');
+ await mapField(page, 'Length:').fill('3');
+ await mapField(page, 'Fill colour:').fill('Yellow');
+ await mapField(page, 'Label:').fill('Lounge');
+ await selectTreeNode(page, 'kitchen');
+ await openTab(page, 'Map');
+ await mapField(page, 'Width:').fill('2');
+ await mapField(page, 'Length:').fill('2');
+ await mapField(page, 'Fill colour:').fill('SkyBlue');
+ await mapField(page, 'Label:').fill('Kitchen');
+
+ const [playerPage2] = await Promise.all([
+ context.waitForEvent('page', { timeout: 15000 }),
+ page.click('button:has-text("Preview")'),
+ ]);
+ await playerPage2.waitForSelector('#txtCommand', { state: 'visible', timeout: 60000 });
+ await playerPage2.waitForFunction(() => window.canSendCommand === true, { timeout: 30000 });
+ await sendCommand(playerPage2, 'south');
+ const gridPanel2 = playerPage2.locator('#gridPanel');
+ await gridPanel2.waitFor({ state: 'visible', timeout: 10000 });
+ await waitForGridAnimation(playerPage2);
+ const map2Path = out('Map2.png');
+ await gridPanel2.screenshot({ path: map2Path });
+ saveTrimmed(map2Path);
+});
+
+await runCapture(async ({ page, baseUrl }) => {
+ // --- map7.png: a huge lobby split into two locations, "Path" border types make them read
+ // as one continuous room on the map despite being separate rooms with a zero-length exit
+ // between them; border size 3 on both to show the effect (per the doc's own instructions).
+ // Rebuilt against the actual original screenshot (site/public/images/map7.png as it existed
+ // before this track started, pulled from commit 9c6f2cc5) after the first pass here missed
+ // several details the user caught: Lobby needs BOTH directions of its exit to Lobby E set to
+ // length 0 (only one side was set before, leaving a stray visible connector - the doc's own
+ // "remember to change both directions" lesson, missed here even after applying it correctly
+ // elsewhere on this same page); Lobby/Lounge/Kitchen all have fill colours in the original;
+ // the Lobby E -> Lounge exit is diagonal (northeast), not straight north; Lounge and Kitchen
+ // are labelled; and Lounge/Kitchen/Lobby W each have one extra stray exit to an offscreen
+ // room, which is why the original shows extra line stubs trailing off the crop edges. Own
+ // fresh draft (not the Map.png/Map2.png one above) so no leftover kitchen/exit pollutes this
+ // map. ---
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Interface');
+ await toggleFeature(page, 'Map and Drawing Grid:');
+
+ await selectTreeNode(page, 'room');
+ await page.locator('span:has-text("Name:")').locator('..').locator('input').fill('Lobby W');
+ await openTab(page, 'Room');
+ await page.waitForSelector('[data-value="Lobby W"]', { timeout: 10000 });
+ await addElement(page, 'Add Room', 'Lobby E');
+ await addElement(page, 'Add Room', 'Lounge');
+ await addElement(page, 'Add Room', 'Kitchen');
+ // Stub destinations for the original's extra stray exit stubs (Lobby W north, Lounge east,
+ // Kitchen east/south) - never visited long enough to matter what's in them, just need to
+ // exist so their connecting line has somewhere to point.
+ await addElement(page, 'Add Room', 'Corridor');
+ await addElement(page, 'Add Room', 'Study');
+ await addElement(page, 'Add Room', 'Pantry');
+ await addElement(page, 'Add Room', 'Cellar');
+
+ await selectTreeNode(page, 'Lobby W');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Lobby W', 'east', 'Lobby E', { length: 0 });
+ await setReciprocalExitLength(page, 'Lobby E', 'Lobby W', 0);
+ await selectTreeNode(page, 'Lobby W');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Lobby W', 'north', 'Corridor');
+ await selectTreeNode(page, 'Lobby E');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Lobby E', 'northeast', 'Lounge');
+ await selectTreeNode(page, 'Lobby E');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Lobby E', 'east', 'Kitchen');
+ await selectTreeNode(page, 'Lounge');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Lounge', 'east', 'Study');
+ await selectTreeNode(page, 'Kitchen');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Kitchen', 'east', 'Pantry');
+ await selectTreeNode(page, 'Kitchen');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Kitchen', 'south', 'Cellar');
+
+ // Sized generously (not the default 1x1) so the "merged" combined rectangle the Path border
+ // types produce is actually visible as a wide room, matching the doc's own illustration.
+ for (const [room, borderType, label, colour] of [
+ ['Lobby W', 'Path East', 'Lobby W', 'PeachPuff'],
+ ['Lobby E', 'Path West', 'Lobby E', 'PeachPuff'],
+ ]) {
+ await selectTreeNode(page, room);
+ await openTab(page, 'Map');
+ await mapField(page, 'Width:').fill('3');
+ await mapField(page, 'Length:').fill('2');
+ await mapField(page, 'Border width:').fill('3');
+ await mapField(page, 'Border type:').selectOption({ label: borderType });
+ await mapField(page, 'Label:').fill(label);
+ await mapField(page, 'Fill colour:').fill(colour);
+ }
+ for (const [room, colour] of [['Lounge', 'Yellow'], ['Kitchen', 'SkyBlue']]) {
+ await selectTreeNode(page, room);
+ await openTab(page, 'Map');
+ await mapField(page, 'Width:').fill('2');
+ await mapField(page, 'Length:').fill('2');
+ await mapField(page, 'Label:').fill(room);
+ await mapField(page, 'Fill colour:').fill(colour);
+ }
+
+ const context = page.context();
+ const playerPage3 = await freshPreview(context, page);
+ await sendCommand(playerPage3, 'north');
+ await sendCommand(playerPage3, 'south');
+ await sendCommand(playerPage3, 'east');
+ await sendCommand(playerPage3, 'northeast');
+ await sendCommand(playerPage3, 'east');
+ await sendCommand(playerPage3, 'west');
+ await sendCommand(playerPage3, 'southwest');
+ await sendCommand(playerPage3, 'east');
+ await sendCommand(playerPage3, 'east');
+ await sendCommand(playerPage3, 'west');
+ await sendCommand(playerPage3, 'south');
+ await sendCommand(playerPage3, 'north');
+ await sendCommand(playerPage3, 'west');
+ const gridPanel3 = playerPage3.locator('#gridPanel');
+ await gridPanel3.waitFor({ state: 'visible', timeout: 10000 });
+ await waitForGridAnimation(playerPage3);
+ const map7Path = out('map7.png');
+ await gridPanel3.screenshot({ path: map7Path });
+ saveTrimmed(map7Path);
+});
+
+await runCapture(async ({ page, baseUrl }) => {
+ // --- map3/map4/map5: a growing loop. map3 is a 3-room diagonal loop (Lounge/Lobby/Kitchen,
+ // all 2x2, "so the diagonal is fine") plus Garden hanging off Kitchen, not yet part of any
+ // loop. map4 adds a second loop back through Garden->Gazebo->Garage->Kitchen, but with
+ // mismatched distances so the two new exits don't visually meet ("the lengths do not
+ // match"). map5 is the fixed version, matching the doc's own worked horizontal arithmetic
+ // (Garden half-width 3 + exit 1 + Gazebo half-width 1 = 5; Kitchen half-width 1 + exit +
+ // Garage half-width, adjusting Garage to width 2 and the exit to length 3, gives the same
+ // 5) - the vertical pairing (Kitchen-Garden, already existing, vs the new Garage-Gazebo) is
+ // matched too, by sizing Garage/Gazebo the same 2x2 as Kitchen so the *default* exit length
+ // already lines up on that axis without needing its own explicit adjustment. ---
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Interface');
+ await toggleFeature(page, 'Map and Drawing Grid:');
+ // Default grid canvas height (300px) clips the top of this layout (Lounge ends up out of
+ // frame above Kitchen) - confirmed live by screenshotting the raw player page, not just the
+ // cropped #gridPanel. Give it more room.
+ await mapField(page, 'Height (pixels):').fill('550');
+
+ // Player starts here - rename the default room to "Kitchen" to match where the doc's own
+ // map3.png shows the player dot.
+ await selectTreeNode(page, 'room');
+ await page.locator('span:has-text("Name:")').locator('..').locator('input').fill('Kitchen');
+ await openTab(page, 'Room');
+ await page.waitForSelector('[data-value="Kitchen"]', { timeout: 10000 });
+ await addElement(page, 'Add Room', 'Lounge');
+ await addElement(page, 'Add Room', 'Lobby');
+ await addElement(page, 'Add Room', 'Garden');
+
+ for (const [room, size] of [['Kitchen', [2, 2]], ['Lounge', [2, 2]], ['Lobby', [2, 2]], ['Garden', [6, 2]]]) {
+ await selectTreeNode(page, room);
+ await openTab(page, 'Map');
+ await mapField(page, 'Width:').fill(String(size[0]));
+ await mapField(page, 'Length:').fill(String(size[1]));
+ await mapField(page, 'Label:').fill(room);
+ }
+
+ await selectTreeNode(page, 'Kitchen');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Kitchen', 'north', 'Lounge');
+ await selectTreeNode(page, 'Kitchen');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Kitchen', 'west', 'Lobby');
+ await selectTreeNode(page, 'Kitchen');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Kitchen', 'south', 'Garden');
+ await selectTreeNode(page, 'Lobby');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Lobby', 'northeast', 'Lounge');
+
+ const context = page.context();
+
+ // --- map3.png: the 3-room loop plus Garden, player ends back in Kitchen ---
+ const playerPage3a = await freshPreview(context, page);
+ await sendCommand(playerPage3a, 'north');
+ await sendCommand(playerPage3a, 'south');
+ await sendCommand(playerPage3a, 'west');
+ await sendCommand(playerPage3a, 'east');
+ await sendCommand(playerPage3a, 'south');
+ await sendCommand(playerPage3a, 'north');
+ const gridPanel3a = playerPage3a.locator('#gridPanel');
+ await gridPanel3a.waitFor({ state: 'visible', timeout: 10000 });
+ await waitForGridAnimation(playerPage3a);
+ const map3Path = out('map3.png');
+ await gridPanel3a.screenshot({ path: map3Path });
+ saveTrimmed(map3Path);
+ await playerPage3a.close();
+
+ // --- map4.png: add Gazebo/Garage closing a second loop (Garden-Gazebo-Garage-Kitchen),
+ // Garage left at its original 1x1 default size and both new exits at default length, so the
+ // distances don't match and the exits don't meet ---
+ await addElement(page, 'Add Room', 'Gazebo');
+ await addElement(page, 'Add Room', 'Garage');
+ await selectTreeNode(page, 'Gazebo');
+ await openTab(page, 'Map');
+ await mapField(page, 'Width:').fill('2');
+ await mapField(page, 'Length:').fill('2');
+ await mapField(page, 'Label:').fill('Gazebo');
+
+ await selectTreeNode(page, 'Garden');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Garden', 'east', 'Gazebo');
+ await selectTreeNode(page, 'Kitchen');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Kitchen', 'east', 'Garage');
+ await selectTreeNode(page, 'Gazebo');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Gazebo', 'north', 'Garage');
+
+ const playerPage4 = await freshPreview(context, page);
+ await sendCommand(playerPage4, 'north');
+ await sendCommand(playerPage4, 'south');
+ await sendCommand(playerPage4, 'west');
+ await sendCommand(playerPage4, 'east');
+ await sendCommand(playerPage4, 'south');
+ await sendCommand(playerPage4, 'east');
+ await sendCommand(playerPage4, 'north');
+ const gridPanel4 = playerPage4.locator('#gridPanel');
+ await gridPanel4.waitFor({ state: 'visible', timeout: 10000 });
+ await waitForGridAnimation(playerPage4);
+ const map4Path = out('map4.png');
+ await gridPanel4.screenshot({ path: map4Path });
+ saveTrimmed(map4Path);
+ await playerPage4.close();
+
+ // --- map5.png: fixed - Garage resized to 2x2 (half-width 1, matching the doc's "we will
+ // make the garage 2 units wide") and the Kitchen<->Garage exit set to length 3 on both
+ // sides (1 + 3 + 1 = 5, matching Garden<->Gazebo's 3 + 1 + 1 = 5) ---
+ await selectTreeNode(page, 'Garage');
+ await openTab(page, 'Map');
+ await mapField(page, 'Width:').fill('2');
+ await mapField(page, 'Length:').fill('2');
+ await mapField(page, 'Label:').fill('Garage');
+ await setReciprocalExitLength(page, 'Kitchen', 'Garage', 3);
+ await setReciprocalExitLength(page, 'Garage', 'Kitchen', 3);
+
+ const playerPage5 = await freshPreview(context, page);
+ await sendCommand(playerPage5, 'north');
+ await sendCommand(playerPage5, 'south');
+ await sendCommand(playerPage5, 'west');
+ await sendCommand(playerPage5, 'east');
+ await sendCommand(playerPage5, 'south');
+ await sendCommand(playerPage5, 'east');
+ await sendCommand(playerPage5, 'north');
+ const gridPanel5 = playerPage5.locator('#gridPanel');
+ await gridPanel5.waitFor({ state: 'visible', timeout: 10000 });
+ await waitForGridAnimation(playerPage5);
+ const map5Path = out('map5.png');
+ await gridPanel5.screenshot({ path: map5Path });
+ saveTrimmed(map5Path);
+});
+
+await runCapture(async ({ page, baseUrl }) => {
+ // --- map6.png: up/down exits - going up a level shows the room(s) on the previous level
+ // faded in the background. Own fresh draft. Kitchen (ground floor, coloured to match
+ // Map2.png's own Kitchen/Lounge scheme, since the doc's own map6.png appears to carry that
+ // styling over) is the starting room; "up" leads to Landing, which has Bedroom (east) and
+ // Lounge (north, same colour scheme again) on the new level - player ends in Bedroom,
+ // directly over where Kitchen was, so Kitchen should render faded behind it. ---
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Interface');
+ await toggleFeature(page, 'Map and Drawing Grid:');
+ await mapField(page, 'Height (pixels):').fill('450');
+
+ await selectTreeNode(page, 'room');
+ await page.locator('span:has-text("Name:")').locator('..').locator('input').fill('Kitchen');
+ await openTab(page, 'Room');
+ await page.waitForSelector('[data-value="Kitchen"]', { timeout: 10000 });
+ await addElement(page, 'Add Room', 'Landing');
+ await addElement(page, 'Add Room', 'Bedroom');
+ await addElement(page, 'Add Room', 'Lounge');
+
+ // Only Kitchen/Lounge are labelled+coloured here (matching Map2.png's own scheme) -
+ // confirmed live that adding Landing/Bedroom into this same loop, even unlabelled, somehow
+ // knocks out Kitchen's faded-behind-Bedroom rendering entirely (and the crop framing) in a
+ // way not worth chasing further; the whole point of this capture is the faded-level effect,
+ // so keep the sequence that reliably produces it.
+ for (const [room, colour] of [['Kitchen', 'SkyBlue'], ['Lounge', 'Yellow']]) {
+ await selectTreeNode(page, room);
+ await openTab(page, 'Map');
+ await mapField(page, 'Fill colour:').fill(colour);
+ await mapField(page, 'Label:').fill(room);
+ }
+
+ await selectTreeNode(page, 'Kitchen');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Kitchen', 'up', 'Landing');
+ await selectTreeNode(page, 'Landing');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Landing', 'east', 'Bedroom');
+ await selectTreeNode(page, 'Landing');
+ await openTab(page, 'Exits');
+ await createExit(page, 'Landing', 'north', 'Lounge');
+
+ const context = page.context();
+ const playerPage6 = await freshPreview(context, page);
+ await sendCommand(playerPage6, 'up');
+ await sendCommand(playerPage6, 'north');
+ await sendCommand(playerPage6, 'south');
+ await sendCommand(playerPage6, 'east');
+ const gridPanel6 = playerPage6.locator('#gridPanel');
+ await gridPanel6.waitFor({ state: 'visible', timeout: 10000 });
+ await waitForGridAnimation(playerPage6);
+ const map6Path = out('map6.png');
+ await gridPanel6.screenshot({ path: map6Path });
+ saveTrimmed(map6Path, { chopTop: 6 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-showing-a-menu.mjs b/tests/e2e/docs-screenshots/capture-showing-a-menu.mjs
new file mode 100644
index 000000000..fe54e316e
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-showing-a-menu.mjs
@@ -0,0 +1,91 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/showing_a_menu.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addScriptCommand, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const addScriptButtons = page => page.locator('button:has-text("+ Add script")');
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+ await page.getByRole('button', { name: 'Scripts', exact: true }).click();
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Set a variable or attribute' });
+ await page.waitForTimeout(200);
+ const varNameInput = page.getByText('Set variable', { exact: true }).locator('xpath=following-sibling::input[1]');
+ await varNameInput.fill('menulist');
+ const varTypeSelect = page.getByText('Set variable', { exact: true }).locator('xpath=following-sibling::select[1]');
+ await varTypeSelect.selectOption('new string list');
+ await page.waitForTimeout(200);
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Add a value to a list' });
+ const addToListLabel = page.getByText('Add to list', { exact: true });
+ const addToListTextInputs = addToListLabel.locator('xpath=following::input[@type="text"]');
+ await addToListTextInputs.nth(0).fill('menulist');
+ await addToListTextInputs.nth(1).fill('female');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Add a value to a list' });
+ const addToListLabel2 = page.getByText('Add to list', { exact: true }).nth(1);
+ const addToListTextInputs2 = addToListLabel2.locator('xpath=following::input[@type="text"]');
+ await addToListTextInputs2.nth(0).fill('menulist');
+ await addToListTextInputs2.nth(1).fill('male');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Output', item: 'Show a menu' });
+ await page.waitForTimeout(200);
+ const captionInput = page.getByText('Show menu with caption', { exact: true }).locator('xpath=following::input[@type="text"][1]');
+ await captionInput.fill('please choose now');
+ const optionsInput = page.getByText('Options from list/dictionary:', { exact: true }).locator('xpath=following::input[@type="text"][1]');
+ await optionsInput.fill('menulist');
+ const ignoreSelect = page.getByText('Allow player to ignore the menu:', { exact: true }).locator('xpath=following::select[1]');
+ await ignoreSelect.selectOption('no');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'If...' });
+ await page.waitForTimeout(200);
+ const ifExprInput = page.locator('xpath=(//span[text()="if"]/following::input[@type="text"])[1]');
+ await ifExprInput.fill('result="male"');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Set a variable or attribute' });
+ const thenLabel1 = page.getByText('Set variable', { exact: true }).nth(1);
+ const thenInputs1 = thenLabel1.locator('xpath=following::input[@type="text"]');
+ await thenInputs1.nth(0).fill('playername');
+ await thenInputs1.nth(1).fill('"Ken"');
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Variables', item: 'Set a variable or attribute' });
+ const thenLabel2 = page.getByText('Set variable', { exact: true }).nth(2);
+ const thenInputs2 = thenLabel2.locator('xpath=following::input[@type="text"]');
+ await thenInputs2.nth(0).fill('gender');
+ await thenInputs2.nth(1).fill('"male"');
+
+ await page.getByRole('button', { name: '+ else', exact: true }).click();
+ await page.waitForTimeout(200);
+ const elseLabelSpan = page.locator('xpath=(//span[text()="else"])[1]');
+ const elseAddScriptBtn = elseLabelSpan.locator('xpath=following::button[contains(., "+ Add script")][1]');
+ await addScriptCommand(page, elseAddScriptBtn, { category: 'Variables', item: 'Set a variable or attribute' });
+ const elseInputs1 = elseLabelSpan.locator('xpath=following::input[@type="text"]');
+ await elseInputs1.nth(0).fill('playername');
+ await elseInputs1.nth(1).fill('"Barbie"');
+
+ const elseAddScriptBtn2 = elseLabelSpan.locator('xpath=following::button[contains(., "+ Add script")][1]');
+ await addScriptCommand(page, elseAddScriptBtn2, { category: 'Variables', item: 'Set a variable or attribute' });
+ const elseInputs2 = elseLabelSpan.locator('xpath=following::input[@type="text"]');
+ await elseInputs2.nth(2).fill('gender');
+ await elseInputs2.nth(3).fill('"female"');
+
+ // The After-choosing script's own outer "+ Add script" (a sibling of the if-block, not
+ // nested in it) is the first one whose position in the DOM comes after the if-block's
+ // own closing "+ else if" button - anchor from that button instead of the if-block itself.
+ const elseIfBtn = page.locator('xpath=(//button[contains(., "+ else if")])[1]');
+ const afterChoosingAddBtn = elseIfBtn.locator('xpath=following::button[contains(., "+ Add script")][1]');
+ await addScriptCommand(page, afterChoosingAddBtn);
+ const finalPrintType = page.locator('xpath=(//span[text()="Print"])[last()]/following-sibling::select[1]');
+ await finalPrintType.selectOption('expression');
+ const finalPrintInput = page.locator('xpath=(//span[text()="Print"])[last()]/following::input[1]');
+ await finalPrintInput.fill('"You have chosen the " + result');
+ await finalPrintInput.evaluate(el => { el.scrollLeft = 0; });
+ await page.waitForTimeout(200);
+ await capture(page, out('ShowMenu.png'), { untilLocator: finalPrintInput, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-speak-to.mjs b/tests/e2e/docs-screenshots/capture-speak-to.mjs
new file mode 100644
index 000000000..6064e96ed
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-speak-to.mjs
@@ -0,0 +1,49 @@
+// Regenerates both editor screenshots embedded in site/src/content/docs/speak_to.md.
+// Talk2.png was blocked on the Switch command's case-list editor (task_082ae91c); now fixed
+// upstream (PR #2090). See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, addVerb, addScriptCommand, setScriptCodeView, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Boris');
+ await selectTreeNode(page, 'Boris');
+ await openTab(page, 'Verbs');
+ await addVerb(page, 'speak');
+ await page.locator('select').first().selectOption('script');
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first());
+ const msg1 = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await msg1.fill("'Hi,' you say to Boris, 'can you help me find the key to this door?'");
+ await addScriptCommand(page, page.locator('button:has-text("+ Add script")').first());
+ const msg2 = page.locator('xpath=(//span[text()="Print"])[2]/following-sibling::input[1]');
+ await msg2.fill("'Sure, you need to look in the bedroom.'");
+ await page.waitForTimeout(200);
+ await capture(page, out('Talk1.png'), { untilLocator: msg2, padding: 40 });
+
+ // --- Talk2.png: same "speak" verb, replaced with a topic menu + switch ---
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `topics = Split ("Where is key;Who is the Queen;How do I defeat the troll", ";")
+ShowMenu ("Talk to Boris about...", topics, true) {
+switch (result) {
+case ("Where is key") {
+msg ("'You need to look in the bedroom.'")
+}
+case ("Who is the Queen") {
+msg ("'Just some girl.'")
+}
+case ("How do I defeat the troll") {
+msg ("'Use fire to stop it regenerating.'")
+}
+}
+}`);
+ await page.waitForSelector('text=Show menu with caption', { timeout: 5000 });
+ const caseToggles = page.getByRole('button', { name: '▶' });
+ while (await caseToggles.count() > 0) {
+ await caseToggles.first().click();
+ }
+ await capture(page, out('Talk2.png'), { untilLocator: page.locator('button:has-text("+ Add script")').last(), padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-starting-inventory.mjs b/tests/e2e/docs-screenshots/capture-starting-inventory.mjs
new file mode 100644
index 000000000..b53ec5c12
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-starting-inventory.mjs
@@ -0,0 +1,17 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/other_guides/starting_inventory.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images', 'other_guides');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'player');
+ await addElement(page, 'Add Object in "player"', 'torch');
+ await selectTreeNode(page, 'player');
+ await page.waitForTimeout(200);
+ await capture(page, out('Howto_startinventory.jpg'), { untilLocator: page.locator('[data-value="torch"]').first(), padding: 300 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-status-attributes.mjs b/tests/e2e/docs-screenshots/capture-status-attributes.mjs
new file mode 100644
index 000000000..1bc0cf4b6
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-status-attributes.mjs
@@ -0,0 +1,20 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/status_attributes.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'player');
+ await openTab(page, 'Attributes');
+ const attrNameInput = page.getByPlaceholder('Attribute', { exact: true });
+ await attrNameInput.fill('score');
+ const formatInput = page.getByPlaceholder('Format string (optional)');
+ await formatInput.click();
+ await page.waitForTimeout(200);
+ await capture(page, out('status2.png'), { untilLocator: formatInput, padding: 200 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-switchable.mjs b/tests/e2e/docs-screenshots/capture-switchable.mjs
new file mode 100644
index 000000000..d84f0ce3b
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-switchable.mjs
@@ -0,0 +1,137 @@
+// Regenerates the 7 editor screenshots embedded in site/src/content/docs/switchable.md. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, selectLabeledField, setScriptCodeView, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const codeViewBtn = page => page.locator('button:has-text("Code view")').first();
+const lastAddScript = page => page.locator('button:has-text("+ Add script")').last();
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'machine');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'crystal ball');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'generator');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'light');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'rabbit');
+
+ // --- switchbasic.png: machine, Switchable feature turned on, "Can be switched on/off" ---
+ await selectTreeNode(page, 'machine');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Switchable:');
+ await openTab(page, 'Switchable');
+ await selectLabeledField(page, 'Switchable:', 'switchable');
+ await page.waitForSelector('text=Switched on at the start of the game', { timeout: 10000 });
+ const extraOnField = page.getByText('Extra object description when switched on:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await extraOnField.fill(' It is chugging away to itself.');
+ await capture(page, out('switchbasic.png'), {
+ untilLocator: page.getByText('After switching off the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]'),
+ padding: 40,
+ });
+
+ // --- switchlookat.png: machine's "Look at" description set to Run script ---
+ await openTab(page, 'Setup');
+ await page.getByText('"Look at" object description:', { exact: true })
+ .locator('xpath=following::select[1]').selectOption({ label: 'Run script' });
+ await setScriptCodeView(page, codeViewBtn(page), `if (this.switchedon) {
+msg ("A funny looking machine chugging away.")
+}
+else {
+msg ("A funny looking machine.")
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ await capture(page, out('switchlookat.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- switchpower.png: a "power" Command setting machine.cannotswitchon = null ---
+ await selectTreeNode(page, 'room');
+ await page.click('button[title="Add element"]');
+ await page.getByRole('button', { name: 'Add Command to "room"', exact: true }).click();
+ await page.waitForSelector('text=Pattern:', { timeout: 10000 });
+ const patternField = page.getByRole('button', { name: 'Command', exact: true })
+ .locator('xpath=following::input[@type="text"]').first();
+ await patternField.fill('power');
+ await setScriptCodeView(page, codeViewBtn(page), `machine.cannotswitchon = null`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ await capture(page, out('switchpower.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- switchstate.png: crystal ball's "Use (on its own)" script checking machine.switchedon ---
+ await selectTreeNode(page, 'crystal ball');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Use/Give:');
+ await openTab(page, 'Use/Give');
+ await page.waitForSelector('text=USE (ON ITS OWN)', { timeout: 10000 });
+ const useOwnActionSelect = page.locator('text=USE (ON ITS OWN)').locator('xpath=following::select[1]');
+ await useOwnActionSelect.selectOption({ label: 'Run script' });
+ await setScriptCodeView(page, codeViewBtn(page), `if (machine.switchedon) {
+msg ("You consult the crystal ball, and learn all sorts of stuff.")
+}
+else {
+msg ("The crystal ball is dark for some reason.")
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ await capture(page, out('switchstate.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- switchgenerator.png: generator, Switchable on, both turn-on/turn-off scripts filled ---
+ await selectTreeNode(page, 'generator');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Switchable:');
+ await openTab(page, 'Switchable');
+ await selectLabeledField(page, 'Switchable:', 'switchable');
+ await page.waitForSelector('text=After switching on the object:', { timeout: 10000 });
+ const onCodeView = page.getByText('After switching on the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, onCodeView, `light.lightsource = true
+light.look = "A light, shining brightly."
+machine.cannotswitchon = null`);
+ await page.waitForSelector('text=After switching off the object:', { timeout: 10000 });
+ const offCodeView = page.getByText('After switching off the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, offCodeView, `light.lightsource = false
+light.look = "A light."
+machine.cannotswitchon = "No power!"
+if (machine.switchedon) {
+msg("The machine stops when the power fails.")
+}
+machine.switchedon = false`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ await capture(page, out('switchgenerator.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- switchmoment.png: machine's "After switching on" script cloning a rabbit + SwitchOff ---
+ await selectTreeNode(page, 'machine');
+ await openTab(page, 'Switchable');
+ await page.waitForSelector('text=After switching on the object:', { timeout: 10000 });
+ const machineOnCodeView = page.getByText('After switching on the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, machineOnCodeView, `CloneObjectAndMove (rabbit, player.parent)
+SwitchOff (machine)`);
+ await page.waitForSelector('text=Clone object', { timeout: 5000 });
+ await capture(page, out('switchmoment.png'), { untilLocator: lastAddScript(page), padding: 40 });
+
+ // --- switchdisplayverbs.png: generator's "After switching on" script, extended with
+ // this.displayverbs = Split(...) ---
+ await selectTreeNode(page, 'generator');
+ await openTab(page, 'Switchable');
+ await page.waitForSelector('text=After switching on the object:', { timeout: 10000 });
+ const generatorOnCodeView2 = page.getByText('After switching on the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, generatorOnCodeView2, `light.lightsource = true
+light.look = "A light, shining brightly."
+machine.cannotswitchon = null
+this.displayverbs = Split("Look at;Switch off", ";")`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ await capture(page, out('switchdisplayverbs.png'), { untilLocator: lastAddScript(page), padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-text-processor.mjs b/tests/e2e/docs-screenshots/capture-text-processor.mjs
new file mode 100644
index 000000000..44376bf54
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-text-processor.mjs
@@ -0,0 +1,21 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/text_processor.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Room');
+ const descInput = page.locator('.cm-content, textarea, [contenteditable="true"]').first();
+ await descInput.click();
+ await descInput.fill('This is a small dungeon. {once:There is a bad smell in here.}').catch(async () => {
+ await descInput.pressSequentially('This is a small dungeon. {once:There is a bad smell in here.}');
+ });
+ await page.waitForTimeout(200);
+ await capture(page, out('text_processor_text.png'), { untilLocator: descInput, padding: 100 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-timelimitedpuzzles.mjs b/tests/e2e/docs-screenshots/capture-timelimitedpuzzles.mjs
new file mode 100644
index 000000000..06a8f8ffb
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-timelimitedpuzzles.mjs
@@ -0,0 +1,92 @@
+// Regenerates the 5 editor screenshots embedded in
+// site/src/content/docs/other_guides/timelimitedpuzzles.md (previously part of the deleted
+// helpsheets/ track, kept because this page still references them). See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, setScriptCodeView, addScriptCommand, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images', 'helpsheets');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'cupboard');
+ await selectTreeNode(page, 'cupboard');
+ await addElement(page, 'Add Object in "cupboard"', 'alien');
+
+ // --- Hsbaddy11.jpg: cupboard, Container feature on, type "Container", "Is open" unticked ---
+ await selectTreeNode(page, 'cupboard');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.waitForSelector('text=Container type:', { timeout: 10000 });
+ await page.locator('text=Container type:').locator('xpath=following::select[1]').selectOption({ label: 'Container' });
+ const isOpenCheckbox = page.getByText('Is open', { exact: true }).locator('xpath=..').locator('input[type="checkbox"]');
+ await isOpenCheckbox.uncheck();
+ await capture(page, out('Hsbaddy11.jpg'), { untilLocator: isOpenCheckbox, padding: 40 });
+
+ // --- Hsbaddy12.jpg: "After opening the object" -> Print a message ---
+ const openScriptCodeView = page.getByText('After opening the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, openScriptCodeView,
+ `msg ("You have surprised the sleeping (and hungry) alien!")`);
+ await page.waitForSelector('xpath=//span[text()="Print"]', { timeout: 5000 });
+ const printInput = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await capture(page, out('Hsbaddy12.jpg'), { untilLocator: printInput, padding: 40 });
+
+ // --- Hsbaddy13.jpg: same script, second sibling command "Run script after a number of
+ // seconds" (Timers) added, freshly-added with an empty nested Run script - the doc fills
+ // in "10" for the wait only after this screenshot, so add the command via the UI (not code
+ // view) to capture its just-added default state before setting the value. ---
+ const secondAddBtn = page.getByText('After opening the object:', { exact: true })
+ .locator('xpath=following::button[contains(.,"+ Add script")][1]');
+ await addScriptCommand(page, secondAddBtn, { category: 'Timers', item: 'Run script after a number of seconds' });
+ await page.waitForSelector('xpath=//span[text()="After"]', { timeout: 5000 });
+ const afterInput = page.getByText('After', { exact: true }).locator('xpath=following::input[1]');
+ await capture(page, out('Hsbaddy13.jpg'), { untilLocator: afterInput, padding: 90 });
+
+ // --- Hsbaddy14.jpg: fill "10" seconds, add an "If" inside the timer's own nested script
+ // checking whether the alien is still visible, printing a message and finishing the game ---
+ await afterInput.fill('10');
+ const timerCodeView = page.getByText('After', { exact: true })
+ .locator('xpath=following::button[contains(.,"Code view")][1]');
+ await setScriptCodeView(page, timerCodeView,
+ `if (GetBoolean(alien, "visible")) {
+ msg ("The alien is still hungry, and finishes you off before you can react.")
+ finish
+}`);
+ await page.waitForSelector('xpath=//span[text()="if"]', { timeout: 5000 });
+ const ifLastInput = page.locator('xpath=(//span[text()="if"]/following::input)[last()]');
+ await capture(page, out('Hsbaddy14.jpg'), { untilLocator: ifLastInput, padding: 60 });
+
+ // --- Hsbaddy15.jpg: new "flame thrower" object, Use/Give -> "Use this on (other object)"
+ // handled individually for "alien" -> print message + Remove object ---
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'flame thrower');
+ await selectTreeNode(page, 'flame thrower');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Use/Give:');
+ await openTab(page, 'Use/Give');
+ const useonSelect = page.locator('select').filter({ hasText: 'None' }).first();
+ await useonSelect.selectOption('scriptdictionary');
+ const useonSection = useonSelect.locator('xpath=../..');
+ await page.waitForTimeout(200);
+ const objectPicker = useonSection.locator('select').filter({ hasText: /alien/ }).first();
+ await objectPicker.selectOption({ label: 'alien' });
+ await useonSection.locator('button:has-text("Add")').click();
+ await page.waitForTimeout(300);
+
+ const flameCodeView = useonSection.locator('button:has-text("Code view")').first();
+ await setScriptCodeView(page, flameCodeView,
+ `msg ("You blast the alien with the flame thrower. It bursts into flames and is destroyed.")
+RemoveObject (alien)`);
+ await page.waitForSelector('xpath=//span[text()="Remove object"]', { timeout: 5000 });
+ const removeObjectSelect = page.locator('xpath=//span[text()="Remove object"]/following-sibling::select[1]');
+ await capture(page, out('Hsbaddy15.jpg'), { untilLocator: removeObjectSelect, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-creating-a-simple-game.mjs b/tests/e2e/docs-screenshots/capture-tutorial-creating-a-simple-game.mjs
new file mode 100644
index 000000000..b516150a5
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-creating-a-simple-game.mjs
@@ -0,0 +1,94 @@
+// Regenerates the 7 editor screenshots embedded in
+// site/src/content/docs/tutorial/creating_a_simple_game.md, which previously showed
+// the old Quest 5 desktop/web editor. Walks through the same steps the tutorial
+// prose itself describes, using the current AppShell editor, and saves each shot
+// over the existing filename in site/public/images/ so no markdown changes are
+// needed. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ setLabeledField, selectLabeledField, addVerb, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ // --- Renameroom.png: rename the default "room" to "lounge" ---
+ await selectTreeNode(page, 'room');
+ await setLabeledField(page, 'Name:', 'lounge');
+ const defaultPrefixCheckbox = page.locator('text=Use default prefix and suffix');
+ await capture(page, out('Renameroom.png'), { untilLocator: defaultPrefixCheckbox });
+
+ // Commit the rename by switching tabs before continuing.
+ await openTab(page, 'Room');
+ await page.waitForSelector('[data-value="lounge"]', { timeout: 10000 });
+
+ // --- Editroomdescription.png: enter the room description ---
+ const roomDescription = page.locator('textarea');
+ await roomDescription.fill(
+ 'This is quite a plain lounge with an old beige carpet and peeling wallpaper.'
+ );
+ await capture(page, out('Editroomdescription.png'), { untilLocator: roomDescription });
+
+ // --- Add a second room, "kitchen" ---
+ await addElement(page, 'Add Room', 'kitchen');
+
+ // --- Addexit1.png: lounge's Exits tab, South cell clicked ---
+ await selectTreeNode(page, 'lounge');
+ await openTab(page, 'Exits');
+ await page.getByRole('button', { name: 'south', exact: true }).click();
+ const lookExitLink = page.locator('text=Create a look exit instead');
+ await capture(page, out('Addexit1.png'), { untilLocator: lookExitLink });
+
+ // --- Addexit2.png: kitchen chosen as destination, ready to create ---
+ const combobox = page.locator('[role="combobox"]');
+ await combobox.click();
+ await combobox.fill('kitchen');
+ await page.waitForSelector('[role="option"]:has-text("kitchen")', { timeout: 5000 });
+ await page.click('[role="option"]:has-text("kitchen")');
+ await capture(page, out('Addexit2.png'), { untilLocator: lookExitLink });
+ await page.click('button:has-text("Create exit")');
+ await page.waitForSelector('text=south → kitchen', { timeout: 10000 });
+
+ // --- Add the TV object to the lounge ---
+ await addElement(page, 'Add Object in "lounge"', 'TV');
+
+ // --- Objectdescription.png: Setup tab, "Look at" description set to Text ---
+ await openTab(page, 'Setup');
+ await selectLabeledField(page, 'object description:', 'string');
+ const objectDescription = page.locator('textarea');
+ await objectDescription.fill(
+ 'The TV is an old model, possibly 20 years old. It is currently showing an old western.'
+ );
+ await capture(page, out('Objectdescription.png'), { untilLocator: objectDescription });
+
+ // --- Othernames.png: Object tab, "television" added to Other names ---
+ await openTab(page, 'Object');
+ await page.waitForSelector('text=Other names:', { timeout: 15000 });
+ // Placeholder uses a real ellipsis character (…), not three periods — match by prefix instead.
+ const otherNameInput = page.locator('input[placeholder^="Add additional name"]');
+ await otherNameInput.fill('television');
+ await otherNameInput.locator('..').locator('button:has-text("Add")').click();
+ await page.waitForSelector('text=television', { timeout: 10000 });
+ // Crop below the input row — the "Hyperlink options"/"Display verbs" sections
+ // further down aren't relevant to what this screenshot is illustrating.
+ await capture(page, out('Othernames.png'), { untilLocator: otherNameInput });
+
+ // --- Addverb.png: Verbs tab, "watch" verb with a print-message response ---
+ await openTab(page, 'Verbs');
+ await page.waitForSelector('text=No verbs added yet', { timeout: 15000 });
+ // Not a plain `text=watch` wait+click - the verbs table renders a second, CSS-hidden
+ // "Verb: watch" label (a responsive mobile-card duplicate) earlier in the DOM than the
+ // visible table cell, and a bare text locator matches that first, invisible element and
+ // times out. addVerb() already waits on the specific `td:has-text(...)` cell instead.
+ await addVerb(page, 'watch');
+ const verbValue = page.locator('textarea');
+ await verbValue.fill(
+ "You watch for a few minutes. As your will to live slowly ebbs away, you remember that you've always hated watching westerns."
+ );
+ await capture(page, out('Addverb.png'), { untilLocator: verbValue });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-custom-attributes.mjs b/tests/e2e/docs-screenshots/capture-tutorial-custom-attributes.mjs
new file mode 100644
index 000000000..8c4c6b0d0
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-custom-attributes.mjs
@@ -0,0 +1,46 @@
+// Regenerates the 2 editor screenshots embedded in
+// site/src/content/docs/tutorial/custom_attributes.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ setLabeledField, selectLabeledField, fieldByLabel, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+
+ // --- Weightflour.png: flour object, Attributes tab, "weight" = Integer 500 ---
+ await addElement(page, 'Add Object in "room"', 'flour');
+ await openTab(page, 'Attributes');
+ const addAttrInput = page.locator('input[placeholder="Add attribute..."]');
+ await addAttrInput.fill('weight');
+ await addAttrInput.locator('..').locator('button:has-text("Add")').click();
+ await page.waitForSelector('text=ASSIGNMENT');
+ await addAttrInput.fill('');
+ const typeSelect = page.locator('select').filter({ hasText: 'String dictionary' });
+ await typeSelect.selectOption('Integer');
+ // Rendered as "Value" — uppercase is CSS-only (text-transform), not the actual DOM text.
+ const valueInput = page.getByText('Value', { exact: true }).locator('xpath=following::input[1]');
+ await valueInput.fill('500');
+ await capture(page, out('Weightflour.png'), { untilLocator: valueInput });
+
+ // --- Printexpression.png: eggs object, Setup tab, "Look at" -> Run script -> Print (expression) ---
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'eggs');
+ await openTab(page, 'Setup');
+ await selectLabeledField(page, 'object description:', 'script');
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ const printTypeSelect = page.locator('xpath=//span[text()="Print"]/following-sibling::select[1]');
+ await printTypeSelect.selectOption('expression');
+ const exprInput = page.locator('xpath=//span[text()="Print"]/following::input[1]');
+ await exprInput.fill('"A box of eggs, weighing " + eggs.weight + " grams."');
+ await exprInput.evaluate(el => { el.scrollLeft = 0; });
+ await capture(page, out('Printexpression.png'), { untilLocator: exprInput, cursorAt: printTypeSelect });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-custom-commands.mjs b/tests/e2e/docs-screenshots/capture-tutorial-custom-commands.mjs
new file mode 100644
index 000000000..8fcab845d
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-custom-commands.mjs
@@ -0,0 +1,113 @@
+// Regenerates all 4 editor screenshots embedded in
+// site/src/content/docs/tutorial/custom_commands.md (Commandsay.png, Commandweigh.png,
+// Checkforattribute.png, Say_to_troll.png). Say_to_troll.png (the "Additional Example
+// (Advanced)" using a Switch script command with per-object cases) was blocked on the Switch
+// case-list editor (task_082ae91c); now fixed upstream (PR #2090). The doc gives no exact code
+// for this example beyond the pattern and "switch command ... different response for different
+// characters, and a default too" — the case/message content here is a reasonable invented
+// match for that description, not transcribed from the doc.
+// See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, addScriptCommand, setScriptCodeView, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+// Unlike "Add Object", "Add Command" creates the element immediately with no name modal
+// (see tests/e2e/verify-appshell-multi-control-editors.mjs, which this script's Pattern-field
+// selectors are copied from).
+async function addCommand(page) {
+ await page.click('button[title="Add element"]');
+ await page.click('button:has-text("Add Command to")', { timeout: 5000 });
+ await page.waitForSelector('text=Command:', { timeout: 10000 });
+}
+
+// Pattern field has no id/aria-label - scoped to its own row two levels up from the
+// "Pattern:" label (span -> label/select row -> flex-col wrapper) so a plain
+// input[type=text].first() doesn't instead match the sidebar's "Filter..." box.
+function patternInput(page) {
+ const patternRow = page.getByText('Pattern:', { exact: true }).locator('xpath=../..');
+ return patternRow.locator(':scope > input[type=text]');
+}
+
+const addScriptButtons = page => page.locator('button:has-text("+ Add script")');
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+
+ // --- Commandsay.png: "say #text#" command, print (expression) with escaped quotes ---
+ await addCommand(page);
+ await patternInput(page).fill('say #text#');
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const sayPrintType = page.locator('xpath=//span[text()="Print"]/following-sibling::select[1]');
+ await sayPrintType.selectOption('expression');
+ const sayExprInput = page.locator('xpath=//span[text()="Print"]/following::input[1]');
+ await sayExprInput.fill('"You say \\"" + text + "\\", but nobody replies."');
+ await sayExprInput.evaluate(el => { el.scrollLeft = 0; });
+ await capture(page, out('Commandsay.png'), { untilLocator: sayExprInput });
+
+ // --- Commandweigh.png: "weigh #object#" command, print (expression) reading object.weight ---
+ await selectTreeNode(page, 'room');
+ await addCommand(page);
+ await patternInput(page).fill('weigh #object#');
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const weighPrintType = page.locator('xpath=//span[text()="Print"]/following-sibling::select[1]');
+ await weighPrintType.selectOption('expression');
+ const weighExprInput = page.locator('xpath=//span[text()="Print"]/following::input[1]');
+ await weighExprInput.fill('"It weighs " + object.weight + " grams."');
+ await weighExprInput.evaluate(el => { el.scrollLeft = 0; });
+ await capture(page, out('Commandweigh.png'), { untilLocator: weighExprInput });
+
+ // --- Checkforattribute.png: same "weigh" command, flat print replaced by an
+ // if(object has attribute)/then/else wrapping it ---
+ await page.locator('button[title="Delete"]').first().click();
+ await page.waitForTimeout(300);
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'If...' });
+ const ifSelect = page.locator('xpath=(//span[text()="if"]/following-sibling::select[1])[1]');
+ await ifSelect.selectOption('object has attribute');
+ // Two inputs follow: the object expression (defaults to "object", left as-is) and the
+ // attribute name (defaults to an empty quoted string) — take the second by position.
+ const attrNameInput = page.locator('xpath=//span[text()="if"]/following-sibling::input[2]');
+ await attrNameInput.fill('weight');
+
+ // The "Then" block's own "+ Add script" is the first on screen at this point (the if's
+ // else-branch doesn't exist yet, so the only other one is the outer command-level button).
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const thenPrintType = page.locator('xpath=//span[text()="Print"]/following-sibling::select[1]');
+ await thenPrintType.selectOption('expression');
+ const thenExprInput = page.locator('xpath=//span[text()="Print"]/following::input[1]');
+ await thenExprInput.fill('"It weighs " + object.weight + " grams."');
+ await thenExprInput.evaluate(el => { el.scrollLeft = 0; });
+
+ await page.getByRole('button', { name: '+ else', exact: true }).click();
+ // Now 3 "+ Add script" buttons exist: Then's, the newly-created Else's, and the outer
+ // command-level one - the Else block's is the middle one.
+ await addScriptCommand(page, addScriptButtons(page).nth(1));
+ const elseMsgInput = page.locator('xpath=//span[text()="else"]/following::input[@type="text"][1]');
+ await elseMsgInput.fill("You can't weigh that.");
+ await capture(page, out('Checkforattribute.png'), { untilLocator: elseMsgInput });
+
+ // --- Say_to_troll.png: "say #text_talk# to #object_one#" command, switch(object_one)
+ // with a case for the troll and a default for anyone/anything else ---
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'troll');
+ await selectTreeNode(page, 'room');
+ await addCommand(page);
+ await patternInput(page).fill('say #text_talk# to #object_one#');
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `switch (object_one) {
+case (troll) {
+msg ("'Ugh,' grunts the troll, only half listening.")
+}
+default {
+msg (GetDisplayName(object_one) + " doesn't seem interested in talking about that.")
+}
+}`);
+ await page.waitForSelector('text=Switch:', { timeout: 5000 });
+ const trollCaseToggles = page.getByRole('button', { name: '▶' });
+ while (await trollCaseToggles.count() > 0) {
+ await trollCaseToggles.first().click();
+ }
+ await capture(page, out('Say_to_troll.png'), { untilLocator: addScriptButtons(page).last(), padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-interacting-with-objects.mjs b/tests/e2e/docs-screenshots/capture-tutorial-interacting-with-objects.mjs
new file mode 100644
index 000000000..bb15b3c8d
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-interacting-with-objects.mjs
@@ -0,0 +1,60 @@
+// Regenerates the 3 editor/player screenshots embedded in
+// site/src/content/docs/tutorial/interacting_with_objects.md
+// (Takedrop.png, Switchonoff.png, Switchonoffplay.png).
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, selectLabeledField, openPreview, sendCommand, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+
+ // --- Takedrop.png: newspaper object, Inventory tab, "Object can be taken" ticked ---
+ await addElement(page, 'Add Object in "room"', 'newspaper');
+ await openTab(page, 'Inventory');
+ const takeCheckbox = page.getByText('Object can be taken', { exact: true }).locator('..').locator('input[type="checkbox"]');
+ await takeCheckbox.check();
+ const takeMessageInput = page.getByText('Take message (leave blank for default):', { exact: true })
+ .locator('xpath=following::input[1]');
+ await takeMessageInput.fill('You fold the newspaper and place it neatly under your arm.');
+ await capture(page, out('Takedrop.png'), { untilLocator: takeMessageInput });
+
+ // --- Switchonoff.png: TV object, Switchable feature, "Can be switched on/off" ---
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'TV');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Switchable:');
+ await openTab(page, 'Switchable');
+ await page.locator('select').first().selectOption({ label: 'Can be switched on/off' });
+ const onMessageInput = page.getByText('Message to print when switching on (leave blank for default):', { exact: true })
+ .locator('xpath=following::input[1]');
+ await onMessageInput.fill('You switch it on.');
+ const offMessageInput = page.getByText('Message to print when switching off (leave blank for default):', { exact: true })
+ .locator('xpath=following::input[1]');
+ await offMessageInput.fill('You switch it off.');
+ const extraOnInput = page.getByText('Extra object description when switched on:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await extraOnInput.fill('It is currently showing an old western.');
+ const extraOffInput = page.getByText('Extra object description when switched off:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await extraOffInput.fill('It is currently switched off.');
+ await capture(page, out('Switchonoff.png'), { untilLocator: extraOffInput });
+
+ // --- Switchonoffplay.png: playing the game, switching the TV on ---
+ // Doc text: "Go back to the Setup tab and change the 'Look at' description
+ // so it just reads 'The TV is an old model, possibly 20 years old.'"
+ await openTab(page, 'Setup');
+ await selectLabeledField(page, 'object description:', 'string');
+ await page.locator('textarea').fill('The TV is an old model, possibly 20 years old.');
+ const playerPage = await openPreview(page);
+ await sendCommand(playerPage, 'look at tv');
+ await sendCommand(playerPage, 'switch on tv');
+ await sendCommand(playerPage, 'look at tv');
+ await capture(playerPage, out('Switchonoffplay.png'), { untilLocator: playerPage.locator('#txtCommand') });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-more-things-to-do-with-objects.mjs b/tests/e2e/docs-screenshots/capture-tutorial-more-things-to-do-with-objects.mjs
new file mode 100644
index 000000000..18fce749f
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-more-things-to-do-with-objects.mjs
@@ -0,0 +1,127 @@
+// Regenerates all 7 editor screenshots embedded in
+// site/src/content/docs/tutorial/more_things_to_do_with_objects.md. Add.png is a tiny inline
+// icon crop (the Ask/Tell "Add" button referenced mid-sentence, not a full screenshot) - it
+// uses locator.screenshot() directly instead of this file's other capture() calls.
+// See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, addAdvancedElement, openTab,
+ toggleFeature, addScriptCommand, selectLabeledField, fieldByLabel,
+ ifExpressionSelect, ifObjectSelect, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const addScriptButtons = page => page.locator('button:has-text("+ Add script")');
+
+// Fills the flag-name text input on a just-added "if" row (the field immediately following
+// the object picker , once "object has flag" is selected as the expression type) —
+// it's the first plain input after the "if" label in document order.
+function ifFlagNameInput(page) {
+ return page.locator('xpath=(//span[text()="if"]/following::input)[1]');
+}
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'Bob');
+ await selectTreeNode(page, 'Bob');
+
+ // --- Use1.png: Bob's "look at" description -> if(object has flag, Bob, alive)/then/else ---
+ await openTab(page, 'Setup');
+ await selectLabeledField(page, 'object description:', 'script');
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'If...' });
+ await ifExpressionSelect(page).selectOption('object has flag');
+ await ifObjectSelect(page).selectOption({ label: 'Bob' });
+ await ifFlagNameInput(page).fill('alive');
+
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const use1ThenMsg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await use1ThenMsg.fill('Bob is sitting up, appearing to feel somewhat under the weather.');
+ await page.getByRole('button', { name: '+ else', exact: true }).click();
+ await addScriptCommand(page, addScriptButtons(page).nth(1));
+ const use1ElseMsg = page.locator('xpath=(//span[text()="Print"])[2]/following-sibling::input[1]');
+ await use1ElseMsg.fill('Bob is lying on the floor, a lot more still than usual.');
+ await capture(page, out('Use1.png'), { untilLocator: use1ElseMsg });
+
+ // --- Use2.png: Bob's Use/Give tab, "Use (other object) on this" -> Handle objects individually ---
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Use/Give:');
+ await openTab(page, 'Use/Give');
+ const useonSelect = page.locator('select').filter({ hasText: 'None' }).first();
+ await useonSelect.selectOption('scriptdictionary');
+ await capture(page, out('Use2.png'), { untilLocator: useonSelect });
+
+ // --- Use3.png: add "defibrillator" to the per-object list, fill its script ---
+ const useonSection = useonSelect.locator('xpath=../..');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'defibrillator');
+ await selectTreeNode(page, 'Bob');
+ await openTab(page, 'Use/Give');
+ const objectPicker = useonSection.locator('select').filter({ hasText: /defibrillator/ }).first();
+ await objectPicker.selectOption({ label: 'defibrillator' });
+ await useonSection.locator('button:has-text("Add")').click();
+ await page.waitForTimeout(300);
+
+ await addScriptCommand(page, useonSection.locator('button:has-text("+ Add script")').first());
+ const use3Msg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await use3Msg.fill('Miraculously, the defibrillator lived up to its promise, and Bob is now alive again. He says his head feels kind of fuzzy.');
+ await addScriptCommand(page, useonSection.locator('button:has-text("+ Add script")').last(), { category: 'Variables', item: 'Set object flag' });
+ const use3FlagObject = page.locator('xpath=//span[text()="Set flag"]/following-sibling::select[2]');
+ await use3FlagObject.selectOption({ label: 'Bob' });
+ const use3FlagName = page.locator('xpath=//span[text()="Set flag"]/following-sibling::input[1]');
+ await use3FlagName.fill('alive');
+ await capture(page, out('Use3.png'), { untilLocator: use3FlagName });
+
+ // --- Functionrevive.png: new Function "revive bob" with the same print+set-flag script ---
+ await addAdvancedElement(page, 'Function', 'revive bob');
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const funcMsg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await funcMsg.fill('Miraculously, the defibrillator lived up to its promise, and Bob is now alive again. He says his head feels kind of fuzzy.');
+ await addScriptCommand(page, addScriptButtons(page).last(), { category: 'Variables', item: 'Set object flag' });
+ const funcFlagObject = page.locator('xpath=//span[text()="Set flag"]/following-sibling::select[2]');
+ await funcFlagObject.selectOption({ label: 'Bob' });
+ const funcFlagName = page.locator('xpath=//span[text()="Set flag"]/following-sibling::input[1]');
+ await funcFlagName.fill('alive');
+ await capture(page, out('Functionrevive.png'), { untilLocator: funcFlagName });
+
+ // --- Functionrevive2.png: defibrillator's own "Use (on its own)" -> Run script -> Call function ---
+ await selectTreeNode(page, 'defibrillator');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Use/Give:');
+ await openTab(page, 'Use/Give');
+ const useOwnActionSelect = page.locator('select').first();
+ await useOwnActionSelect.selectOption({ label: 'Run script' });
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'Call function' });
+ const callFnInput = page.locator('xpath=//*[contains(text(), "Call function")]/following::input[@type="text"][1]');
+ await callFnInput.fill('revive bob');
+ // Dismiss the function-name autocomplete popover (which would otherwise overlap the crop)
+ // by clicking the section heading instead of pressing Escape - Escape clears the field.
+ await page.getByText('Use (on its own)', { exact: true }).click();
+ await capture(page, out('Functionrevive2.png'), { untilLocator: callFnInput });
+
+ // --- Asktell.png: global Ask/Tell feature, Bob's Ask/Tell tab, one topic with a guarded script ---
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Ask/Tell:');
+ await selectTreeNode(page, 'Bob');
+ await page.getByRole('button', { name: 'Ask/Tell', exact: true }).click();
+ const askInput = fieldByLabel(page, 'Ask about:');
+ await askInput.fill('heart attack cardiac arrest');
+ const askAddButton = askInput.locator('xpath=../..').locator('button:has-text("Add")');
+ await askAddButton.screenshot({ path: out('Add.png') });
+ await askAddButton.click();
+ await page.waitForTimeout(300);
+
+ await addScriptCommand(page, addScriptButtons(page).first(), { category: 'Scripts', item: 'If...' });
+ await ifExpressionSelect(page).selectOption('object has flag');
+ await ifObjectSelect(page).selectOption({ label: 'Bob' });
+ await ifFlagNameInput(page).fill('alive');
+ await addScriptCommand(page, addScriptButtons(page).first());
+ const askMsg = page.locator('xpath=//span[text()="Print"]/following-sibling::input[1]');
+ await askMsg.fill("Well, one moment I was sitting there, feeling pretty happy with myself after eating my afternoon snack - a cheeseburger, pizza and ice cream pie, smothered in bacon, which I'd washed down with a bucket of coffee and six cans of Red Bull - when all of a sudden, I was in terrible pain, and then everything was peaceful. Then you came along.");
+ await askMsg.evaluate(el => { el.scrollLeft = 0; });
+ await capture(page, out('Asktell.png'), { untilLocator: askMsg });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-moving-objects-during-the-game.mjs b/tests/e2e/docs-screenshots/capture-tutorial-moving-objects-during-the-game.mjs
new file mode 100644
index 000000000..d28f1626b
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-moving-objects-during-the-game.mjs
@@ -0,0 +1,62 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/tutorial/moving_objects_during_the_game.md. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab, toggleFeature,
+ ifExpressionSelect, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'bee');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'window');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.locator('select').first().selectOption({ label: 'Openable/Closable' });
+
+ // --- Bee.png: "if object contains kitchen/bee" with then/else print+move scripts, as the
+ // "script to run when opening object" ---
+ const openLabel = page.getByText('Script to run when opening object:', { exact: true });
+ const addScriptButton = n => openLabel.locator(`xpath=following::button[text()="+ Add script"][${n}]`);
+ await addScriptButton(1).click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('option', { name: 'Scripts', exact: true }).click();
+ await page.getByRole('option', { name: 'If...' }).click();
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ await page.waitForSelector('text=then');
+ await ifExpressionSelect(page).selectOption('object contains');
+ const objectSelects = page.locator('xpath=//span[text()="if"]/following-sibling::select');
+ await objectSelects.nth(1).selectOption('room');
+ await objectSelects.nth(2).selectOption('bee');
+
+ // then: print a message
+ await addScriptButton(1).click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ const thenInput = page.locator('xpath=//span[text()="then"]/following::input[@type="text"][1]');
+ await thenInput.fill('You open the window. Not much happens.');
+
+ // else: print a message + move the bee
+ await page.getByRole('button', { name: '+ else', exact: true }).click();
+ await addScriptButton(2).click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ const elseInput = page.locator('xpath=//span[text()="else"]/following::input[@type="text"][1]');
+ await elseInput.fill('You open the window and a bee flies into the kitchen.');
+ await addScriptButton(2).click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('option', { name: 'Objects', exact: true }).click();
+ await page.getByRole('option', { name: /^●\s*Move object$/ }).click();
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+
+ const elseIfButton = page.locator('button:has-text("+ else if")');
+ await capture(page, out('Bee.png'), { untilLocator: elseIfButton, padding: 4 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-using-containers.mjs b/tests/e2e/docs-screenshots/capture-tutorial-using-containers.mjs
new file mode 100644
index 000000000..aae904bb9
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-using-containers.mjs
@@ -0,0 +1,66 @@
+// Regenerates the 3 editor screenshots plus 1 player screenshot embedded in
+// site/src/content/docs/tutorial/using_containers.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, setLabeledField, openPreview, sendCommand, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+
+ // --- Container.png: fridge object, Features -> Container -> Closed container ---
+ await addElement(page, 'Add Object in "room"', 'fridge');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.locator('select').first().selectOption({ label: 'Closed container' });
+ const closeMessageInput = page.locator('text=Message to print when closing').locator('..').locator('input');
+ await capture(page, out('Container.png'), { untilLocator: closeMessageInput });
+
+ // --- Containerfridge.png: contents prefix customised (shown before "It contains ..." text) ---
+ await selectTreeNode(page, 'fridge');
+ await addElement(page, 'Add Object in "fridge"', 'milk');
+ await selectTreeNode(page, 'fridge');
+ await addElement(page, 'Add Object in "fridge"', 'cheese');
+ await selectTreeNode(page, 'fridge');
+ await addElement(page, 'Add Object in "fridge"', 'beer');
+ await selectTreeNode(page, 'fridge');
+ await openTab(page, 'Container');
+ // "List children when object is looked at or opened" is in CoreEditorObjectContainer.aslx,
+ // so it's folded into the collapsed "Advanced" expander at the bottom of the tab — without checking
+ // it, "Contents prefix" has no effect on the actual game output (see capture-tutorial-using-containers
+ // spike notes / project memory: the prefix field itself still renders and accepts input even while
+ // this checkbox is off, which silently produces a screenshot that doesn't match the player transcript).
+ await page.locator('summary', { hasText: 'Advanced' }).click();
+ await page.locator('text=List children when object is looked at or opened').locator('..').locator('input[type="checkbox"]').check();
+ const contentsPrefixInput = page.locator('text=Contents prefix').locator('..').locator('input');
+ await contentsPrefixInput.fill('It contains');
+ await capture(page, out('Containerfridge.png'), { untilLocator: contentsPrefixInput });
+
+ // --- Containerfridgeplayer.png: playing the game, opening the fridge to see the custom prefix ---
+ const playerPage = await openPreview(page);
+ await sendCommand(playerPage, 'open fridge');
+ await capture(playerPage, out('Containerfridgeplayer.png'), { untilLocator: playerPage.locator('#txtCommand') });
+
+ // --- Lockedcontainer.png: box object, Locking section, Lockable ---
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'key');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'box');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.locator('select').first().selectOption({ label: 'Closed container' });
+ const lockTypeSelect = page.getByText('Lock type:', { exact: true }).locator('xpath=following::select[1]');
+ await lockTypeSelect.selectOption({ label: 'Lockable' });
+ const keyCountInput = page.getByText('Number of keys to unlock container:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await keyCountInput.fill('1');
+ await capture(page, out('Lockedcontainer.png'), { untilLocator: keyCountInput });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-using-scripts.mjs b/tests/e2e/docs-screenshots/capture-tutorial-using-scripts.mjs
new file mode 100644
index 000000000..3a406891f
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-using-scripts.mjs
@@ -0,0 +1,68 @@
+// Regenerates the 4 editor screenshots embedded in
+// site/src/content/docs/tutorial/using_scripts.md, showing the current AppShell's
+// if/then/else script editor. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab, addVerb,
+ ifExpressionSelect, ifObjectSelect, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+// Script-editor text inputs share no id/placeholder, but the fixed set that precedes them
+// on this page (tree filter box, then the verb-name combobox) means the then/else "Print a
+// message" value boxes are always input[type="text"] indices 2 and 3 — confirmed live
+// against the running editor before relying on it here.
+const thenMessageInput = page => page.locator('input[type="text"]').nth(2);
+const elseMessageInput = page => page.locator('input[type="text"]').nth(3);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'TV');
+ await openTab(page, 'Verbs');
+ await addVerb(page, 'watch');
+ // Only one exists in the BEHAVIOUR panel before any script command is added.
+ await page.locator('select').first().selectOption('script');
+
+ // --- Addif.png: "Add Script Command" dialog, Scripts category, "If…" highlighted ---
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('option', { name: 'Scripts', exact: true }).click();
+ // Every command row's accessible name is prefixed with a "●" marker, so match by
+ // substring rather than exact — "If..." alone would never match.
+ const ifOption = page.getByRole('option', { name: 'If...' });
+ await ifOption.click();
+ const dialog = page.locator('[role="dialog"]').filter({ hasText: 'Add Script Command' });
+ await capture(page, out('Addif.png'), { untilLocator: dialog, cursorAt: { locator: ifOption, at: 'left' } });
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+
+ // --- Addif2.png: freshly-added if/then/else editor, condition not yet set ---
+ await page.waitForSelector('text=then');
+ const elseIfButton = page.locator('button:has-text("+ else if")');
+ await capture(page, out('Addif2.png'), { untilLocator: elseIfButton });
+
+ // --- Addif3.png: condition set to "object is switched on", object "TV" ---
+ await ifExpressionSelect(page).selectOption('object is switched on');
+ await ifObjectSelect(page).selectOption('TV');
+ await capture(page, out('Addif3.png'), { untilLocator: elseIfButton, cursorAt: ifObjectSelect(page) });
+
+ // --- Addif4.png: Then/Else print-message scripts filled in ---
+ await page.locator('button:has-text("+ Add script")').first().click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ await thenMessageInput(page).fill(
+ "You watch for a few minutes. As your will to live slowly ebbs away, you remember that you've always hated watching westerns."
+ );
+
+ await page.getByRole('button', { name: '+ else', exact: true }).click();
+ await page.locator('button:has-text("+ Add script")').nth(1).click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ await elseMessageInput(page).fill(
+ "You watch for a few minutes, thinking that the latest episode of ‘Big Brother’ is even more boring than usual. You then realise that the TV is in fact switched off."
+ );
+ await capture(page, out('Addif4.png'), { untilLocator: elseIfButton, padding: 2 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-tutorial-using-timers-and-turn-scripts.mjs b/tests/e2e/docs-screenshots/capture-tutorial-using-timers-and-turn-scripts.mjs
new file mode 100644
index 000000000..caa595346
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-tutorial-using-timers-and-turn-scripts.mjs
@@ -0,0 +1,84 @@
+// Regenerates the 4 editor screenshots embedded in
+// site/src/content/docs/tutorial/using_timers_and_turn_scripts.md. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addAdvancedElement, openTab,
+ ifExpressionSelect, ifObjectSelect, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ // --- TimerBee1.png: "bee timer", interval 20, print-message script ---
+ await addAdvancedElement(page, 'Timer', 'bee timer');
+ const intervalInput = page.getByText('Interval (sec):', { exact: true }).locator('xpath=following::input[1]');
+ await intervalInput.fill('20');
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ const messageInput = page.locator('input[type="text"]').last();
+ await messageInput.fill('The bee buzzes past you. Pesky bee.');
+ await messageInput.evaluate(el => { el.scrollLeft = 0; });
+ await capture(page, out('TimerBee1.png'), { untilLocator: messageInput });
+
+ // --- TimerBee2.png: a second timer whose script is "if player is in room, print" —
+ // a fresh element rather than editing the first, so there's no existing row to remove ---
+ await addAdvancedElement(page, 'Timer', 'bee timer checked');
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('option', { name: 'Scripts', exact: true }).click();
+ await page.getByRole('option', { name: 'If...' }).click();
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ await page.waitForSelector('text=then');
+ await ifExpressionSelect(page).selectOption('player is in room');
+ await ifObjectSelect(page).selectOption('room');
+ await page.locator('button:has-text("+ Add script")').first().click();
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ const thenMessageInput = page.locator('xpath=//span[text()="then"]/following::input[@type="text"][1]');
+ await thenMessageInput.fill('The bee buzzes past you. Pesky bee.');
+ const elseIfButton = page.locator('button:has-text("+ else if")').first();
+ await capture(page, out('TimerBee2.png'), { untilLocator: elseIfButton, padding: 4 });
+
+ // --- Turncounter1.png: player's Attributes tab, "turns" attribute added as Integer,
+ // added to the Status attributes list ---
+ await selectTreeNode(page, 'player');
+ await openTab(page, 'Attributes');
+ const addAttrInput = page.locator('input[placeholder="Add attribute..."]');
+ await addAttrInput.fill('turns');
+ await addAttrInput.locator('..').locator('button:has-text("Add")').click();
+ await page.waitForSelector('text=ASSIGNMENT');
+ const typeSelect = page.locator('select').filter({ hasText: 'String dictionary' });
+ await typeSelect.selectOption('Integer');
+ const statusAttrInput = page.locator('input[placeholder="Attribute"]');
+ await statusAttrInput.fill('turns');
+ await statusAttrInput.locator('..').locator('button:has-text("Add")').click();
+ await page.waitForSelector('table >> text=turns', { timeout: 10000 });
+ // Rendered "INHERITED TYPES" — uppercase is CSS-only, actual DOM text is title case.
+ const inheritedTypesHeading = page.getByText('Inherited types', { exact: true });
+ await capture(page, out('Turncounter1.png'), { untilLocator: inheritedTypesHeading, padding: 250 });
+
+ // --- Turnscript.png: a turn script setting player.turns to player.turns + 1 ---
+ await selectTreeNode(page, 'room');
+ await page.click('button[title="Add element"]');
+ await page.getByRole('button', { name: 'Add Turn Script to "room"', exact: true }).click();
+ await page.waitForSelector('button:has-text("+ Add script")');
+ await page.click('button:has-text("+ Add script")');
+ await page.waitForSelector('text=Add Script Command');
+ await page.getByRole('option', { name: 'Variables', exact: true }).click();
+ await page.getByRole('option', { name: /^●\s*Set a variable or attribute$/ }).click();
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+ // input[type="text"] index 0 is always the tree's own "Filter..." box; index 1 is the
+ // turn script's own optional "Name:" field (left blank, per the tutorial).
+ const exprInputs = page.locator('input[type="text"]');
+ const nameInput = exprInputs.nth(2);
+ const valueInput = exprInputs.nth(3);
+ await nameInput.fill('player.turns');
+ await valueInput.fill('player.turns + 1');
+ await capture(page, out('Turnscript.png'), { untilLocator: valueInput });
+});
diff --git a/tests/e2e/docs-screenshots/capture-ui-custom.mjs b/tests/e2e/docs-screenshots/capture-ui-custom.mjs
new file mode 100644
index 000000000..e30aacd4d
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-ui-custom.mjs
@@ -0,0 +1,67 @@
+// Regenerates the 4 in-game-player screenshots embedded in
+// site/src/content/docs/howto/ux/ui-custom.md via the game object's "User interface
+// initialisation script" (Advanced Scripts tab), captured against the resulting WasmPlayer
+// preview. See .claude/skills/docs-screenshots/SKILL.md.
+//
+// Note: the doc's first JS.setCss call targets "#qv-status" - src/PlayerCore/Resources/
+// playercore.htm's top bar was renamed "status" -> "qv-status" in commit 42cdd9b8, and
+// Core.aslx's own InitInterface was retargeted to match in textadventures/quest#2169 (not
+// yet merged into this branch as of writing). Regenerating these 4 screenshots before that
+// fix has landed here will still show the top bar's default styling rather than the doc's
+// border/background - re-run this capture once #2169 is merged into main and merged/rebased
+// into this branch.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, openTab, toggleFeature,
+ setScriptCodeView, openPreview, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const checkboxFor = (page, label) => page.getByText(label, { exact: true }).locator('xpath=..').locator('input[type="checkbox"]');
+
+const scriptFor = (backandborder, button) => `backandborder = "${backandborder}"
+button = "${button}"
+text = "color:black;font-family:georgia, serif"
+JS.setCss ("#qv-status", backandborder)
+JS.setCss (".ui-accordion-header", "border-radius: 0px;" + backandborder)
+JS.setCss (".ui-accordion-content", "border-radius: 0px;" + backandborder + ";border-top:none")
+JS.setCss (".accordion-header-text", text)
+JS.setCss (".ui-icon", "display:none")
+JS.setCommands ("Look;Wait", "black")
+JS.setCss ("#commandPane", text + ";" + backandborder)
+JS.setCss ("#verblinkwait", button)
+JS.setCss ("#verblinklook", button)
+JS.setCss ("#gamePanes", "margin-top: 16px")
+JS.eval ("$('#gamePanes').width(227);")`;
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Show advanced scripts for the game object');
+
+ await openTab(page, 'Interface');
+ await checkboxFor(page, 'Show a command pane (use JS.setCommands to set)').check();
+
+ await openTab(page, 'Advanced Scripts');
+ const codeViewBtn = page.locator('button:has-text("Code view")').first();
+
+ const variants = [
+ { file: 'interface1.png', backandborder: 'border: chocolate ridge 6px;background:sandybrown', button: 'padding:5px;background:BurlyWood;border:ridge chocolate 1px;' },
+ { file: 'interface2.png', backandborder: 'border: darkblue double 6px;background:dodgerblue', button: 'padding:5px;background:skyblue;border:double darkblue 1px;' },
+ { file: 'interface3.png', backandborder: 'border: darkgrey outset 6px;background:grey', button: 'padding:5px;background:silver;border:outset darkgrey 1px;' },
+ { file: 'interface4.png', backandborder: 'border: Indigo dotted 6px;background:MediumPurple', button: 'padding:5px;background:Violet;border:dotted Indigo 1px;' },
+ ];
+
+ for (const { file, backandborder, button } of variants) {
+ await setScriptCodeView(page, codeViewBtn, scriptFor(backandborder, button));
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ const playerPage = await openPreview(page);
+ await capture(playerPage, out(file), { untilLocator: playerPage.locator('#txtCommand') });
+ await playerPage.close();
+ }
+});
diff --git a/tests/e2e/docs-screenshots/capture-ui-style.mjs b/tests/e2e/docs-screenshots/capture-ui-style.mjs
new file mode 100644
index 000000000..42519a5bf
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-ui-style.mjs
@@ -0,0 +1,62 @@
+// Regenerates the 3 in-game-player screenshots embedded in
+// site/src/content/docs/howto/ux/ui-style.md — game.aslx Display/Interface tab settings,
+// captured against the resulting WasmPlayer preview rather than the editor itself. See
+// .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, openTab, openPreview, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+const checkboxFor = (page, label) => page.getByText(label, { exact: true }).locator('xpath=..').locator('input[type="checkbox"]');
+
+// Like lib.mjs's openPreview, but for a game with the command bar turned off (ui-no-cursor.png)
+// - #txtCommand never becomes visible in that state, so wait on window.canSendCommand alone.
+async function openPreviewNoCommandBar(page) {
+ const context = page.context();
+ const [playerPage] = await Promise.all([
+ context.waitForEvent('page', { timeout: 15000 }),
+ page.click('button:has-text("Preview")'),
+ ]);
+ await playerPage.waitForFunction(() => window.canSendCommand === true, { timeout: 30000 });
+ // #txtCommand never becomes visible with the command bar off, so wait for the location bar
+ // to actually show the starting room name instead - confirmed live that the boot sequence
+ // fully *removes* #gameTitle (its "Loading..." placeholder ) once real content replaces
+ // it, rather than just changing its text, so waiting for its text to change never resolves.
+ await playerPage.waitForFunction(() => (document.getElementById('location')?.textContent ?? '').trim() !== '', { timeout: 20000 });
+ return playerPage;
+}
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'game');
+
+ // --- ui-classic.png: default, unmodified interface ---
+ const playerClassic = await openPreview(page);
+ await capture(playerClassic, out('ui-classic.png'), { untilLocator: playerClassic.locator('#txtCommand') });
+ // Close each player tab once captured before opening the next - each openPreview() boots a
+ // fresh WASM runtime from scratch (no caching across tabs in dev mode), and leaving prior
+ // tabs open while a new one boots was observed to starve it enough to stall indefinitely.
+ await playerClassic.close();
+
+ // --- ui-no-cursor.png: subtle colour-blend background, command line turned off ---
+ await openTab(page, 'Display');
+ await checkboxFor(page, 'Colour blend for background?').check();
+ const topInput = page.getByText('Colour at top:', { exact: true }).locator('xpath=following::input[1]');
+ await topInput.fill('AliceBlue');
+ const bottomInput = page.getByText('Colour at bottom:', { exact: true }).locator('xpath=following::input[1]');
+ await bottomInput.fill('LightSteelBlue');
+ await openTab(page, 'Interface');
+ await checkboxFor(page, 'Show command bar').uncheck();
+ const playerNoCursor = await openPreviewNoCommandBar(page);
+ await capture(playerNoCursor, out('ui-no-cursor.png'), { untilLocator: playerNoCursor.locator('body') });
+ await playerNoCursor.close();
+
+ // --- ui-cursor.png: panes off, command bar replaced with a plain cursor, minimalist look ---
+ await checkboxFor(page, 'Show command bar').check();
+ await checkboxFor(page, 'Use a cursor instead of a box for commands?').check();
+ await checkboxFor(page, 'Show panes (Inventory, Places and Objects, Compass)').uncheck();
+ const playerCursor = await openPreview(page);
+ await capture(playerCursor, out('ui-cursor.png'), { untilLocator: playerCursor.locator('#txtCommand') });
+});
diff --git a/tests/e2e/docs-screenshots/capture-unlockdoor.mjs b/tests/e2e/docs-screenshots/capture-unlockdoor.mjs
new file mode 100644
index 000000000..122ed433a
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-unlockdoor.mjs
@@ -0,0 +1,107 @@
+// Regenerates the 5 editor screenshots embedded in
+// site/src/content/docs/other_guides/unlockdoor.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ runCapture, createLocalDraft, selectTreeNode, addElement, openTab,
+ toggleFeature, setScriptCodeView, capture,
+} from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images', 'other_guides');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Room', 'vault');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'keypad');
+
+ // --- Unlockdoor1.jpg: south exit, Locked + a message, named "vault door" ---
+ await selectTreeNode(page, 'room');
+ await openTab(page, 'Exits');
+ await page.getByRole('button', { name: 'south', exact: true }).click();
+ const destCombobox = page.locator('[role="combobox"]');
+ await destCombobox.click();
+ await destCombobox.fill('vault');
+ await page.waitForSelector('[role="option"]:has-text("vault")', { timeout: 5000 });
+ await page.click('[role="option"]:has-text("vault")');
+ await page.click('button:has-text("Create exit")');
+ await page.waitForSelector('text=south → vault', { timeout: 10000 });
+ await page.getByText('Exit: vault', { exact: true }).click();
+ await page.waitForSelector('button:has-text("Exit")', { timeout: 10000 });
+ await page.locator('span:has-text("Name:")').locator('..').locator('input').fill('vault door');
+ await page.getByText('Locked', { exact: true }).locator('..').locator('input[type="checkbox"]').check();
+ const lockedMsgField = page.getByText('Print message when locked:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await lockedMsgField.fill("The vault door won't budge. It needs a code.");
+ await capture(page, out('Unlockdoor1.jpg'), { untilLocator: lockedMsgField, padding: 60 });
+
+ // --- Unlockdoor2.jpg: keypad, Use/Give "Use (on its own)" -> get input + if fixed code ---
+ await selectTreeNode(page, 'keypad');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Use/Give:');
+ await openTab(page, 'Use/Give');
+ await page.waitForSelector('text=USE (ON ITS OWN)', { timeout: 10000 });
+ const useOwnActionSelect = page.locator('text=USE (ON ITS OWN)').locator('xpath=following::select[1]');
+ await useOwnActionSelect.selectOption({ label: 'Run script' });
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `msg ("Enter the code:")
+get input {
+if (result = "1234") {
+msg ("The door unlocks.")
+vault door.locked = false
+}
+else {
+msg ("Wrong code.")
+}
+}`);
+ await page.waitForSelector('text=Get input, then', { timeout: 5000 });
+ await capture(page, out('Unlockdoor2.jpg'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').last(),
+ padding: 40,
+ });
+
+ // --- Unlockdoor4.png: game's Start script, game.code = random 4-digit string ---
+ await selectTreeNode(page, 'game');
+ await openTab(page, 'Scripts');
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `game.code = "" + GetRandomInt(1000, 9999)`);
+ await page.waitForSelector('text=Set variable', { timeout: 5000 });
+ await capture(page, out('Unlockdoor4.png'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').first(),
+ padding: 40,
+ });
+
+ // --- Unlockdoor3.png: keypad script, fixed "1234" swapped for game.code ---
+ await selectTreeNode(page, 'keypad');
+ await openTab(page, 'Use/Give');
+ await page.waitForSelector('text=USE (ON ITS OWN)', { timeout: 10000 });
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `msg ("Enter the code:")
+get input {
+if (result = game.code) {
+msg ("The door unlocks.")
+vault door.locked = false
+}
+else {
+msg ("Wrong code.")
+}
+}`);
+ await page.waitForSelector('text=Get input, then', { timeout: 5000 });
+ await capture(page, out('Unlockdoor3.png'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').last(),
+ padding: 40,
+ });
+
+ // --- Randomcode3.png: a "note" object's "Look at" description tells the player the code ---
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'note');
+ await openTab(page, 'Setup');
+ await page.getByText('"Look at" object description:', { exact: true })
+ .locator('xpath=following::select[1]').selectOption({ label: 'Run script' });
+ await setScriptCodeView(page, page.locator('button:has-text("Code view")').first(), `msg ("A note on the wall says the code is " + game.code + ".")`);
+ await page.waitForSelector('text=Print', { timeout: 5000 });
+ await capture(page, out('Randomcode3.png'), {
+ untilLocator: page.locator('button:has-text("+ Add script")').first(),
+ padding: 40,
+ });
+});
diff --git a/tests/e2e/docs-screenshots/capture-using-inherited-types.mjs b/tests/e2e/docs-screenshots/capture-using-inherited-types.mjs
new file mode 100644
index 000000000..f9a8253e3
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-using-inherited-types.mjs
@@ -0,0 +1,21 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/using_inherited_types.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, toggleFeature, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'chest');
+ await openTab(page, 'Features');
+ await toggleFeature(page, 'Container:');
+ await openTab(page, 'Container');
+ await page.locator('select').first().selectOption({ label: 'Closed container' });
+ await openTab(page, 'Attributes');
+ await page.waitForTimeout(200);
+ await capture(page, out('type_attributes.png'), { untilLocator: page.locator('text=Inherited Types').first(), padding: 300 });
+});
diff --git a/tests/e2e/docs-screenshots/capture-using-verbs.mjs b/tests/e2e/docs-screenshots/capture-using-verbs.mjs
new file mode 100644
index 000000000..2422f60fd
--- /dev/null
+++ b/tests/e2e/docs-screenshots/capture-using-verbs.mjs
@@ -0,0 +1,27 @@
+// Regenerates the 1 editor screenshot embedded in
+// site/src/content/docs/using_verbs.md. See .claude/skills/docs-screenshots/SKILL.md.
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { runCapture, createLocalDraft, selectTreeNode, addElement, openTab, addVerb, capture } from './lib.mjs';
+
+const imagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'site', 'public', 'images');
+const out = name => join(imagesDir, name);
+
+await runCapture(async ({ page, baseUrl }) => {
+ await createLocalDraft(page, baseUrl, 'Tutorial Game');
+ await selectTreeNode(page, 'room');
+ await addElement(page, 'Add Object in "room"', 'dial');
+ await openTab(page, 'Verbs');
+ await addVerb(page, 'rotate');
+ await selectTreeNode(page, 'game');
+ await page.locator('[data-value="game"][data-part="branch-control"]').locator('..').locator('[data-part="branch-indicator"], svg').first().click();
+ await page.waitForTimeout(200);
+ await page.getByText('Verbs', { exact: true }).click();
+ await page.waitForTimeout(200);
+ await page.getByRole('button', { name: 'Verb: rotate' }).click();
+ await page.getByRole('button', { name: 'Dismiss' }).click().catch(() => {});
+ await page.waitForTimeout(200);
+ const lastField = page.getByText('If no objects available, show this message:', { exact: true })
+ .locator('xpath=following::input[1]');
+ await capture(page, out('verb_element.png'), { untilLocator: lastField, padding: 40 });
+});
diff --git a/tests/e2e/docs-screenshots/lib.mjs b/tests/e2e/docs-screenshots/lib.mjs
new file mode 100644
index 000000000..8b6cbc16f
--- /dev/null
+++ b/tests/e2e/docs-screenshots/lib.mjs
@@ -0,0 +1,366 @@
+// Shared helpers for capturing AppShell editor screenshots for the docs site
+// (site/src/content/docs/). Mirrors the DOM patterns already proven out in the
+// tests/e2e/verify-appshell-*.mjs scripts — see .claude/skills/verify/SKILL.md
+// and .claude/skills/docs-screenshots/SKILL.md for context and conventions.
+import { chromium } from 'playwright';
+
+export const DEFAULT_BASE_URL = process.argv[2] || 'http://localhost:5174';
+
+// Fixed viewport so every capture is the same size/framing. Kept fairly narrow
+// (well above AppShell's mobile breakpoint, but well below 1280) because Starlight
+// scales images down to its content column width (~700-800px) — a narrower source
+// image means less downscaling, so on-screen text stays legible. Height is a
+// generous ceiling; capture() crops down to actual content height per shot, so it
+// doesn't matter that most states don't fill it.
+const VIEWPORT = { width: 960, height: 800 };
+
+export async function launch(baseUrl = DEFAULT_BASE_URL) {
+ const browser = await chromium.launch();
+ const context = await browser.newContext({ viewport: VIEWPORT });
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+ const page = await context.newPage();
+ page.on('pageerror', err => console.log('[pageerror]', err.message));
+ page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()); });
+ return { browser, page, baseUrl };
+}
+
+// Types raw quest-script text directly into a script's "Code view" CodeMirror editor and
+// switches back to "Visual editor", letting the visual tree render whatever the script parses
+// to — much faster and more reliable than reconstructing a nested script via addScriptCommand
+// for every level, and the only way to reach a script command that's been intentionally removed
+// from the Add Script Command picker but is "still fully editable if already present" (e.g.
+// "get input", superseded in the picker by the GetInput() expression form — see
+// CoreEditorScriptsOutput.aslx). `codeViewButtonLocator` must resolve to the specific script's
+// own "Code view" button (there can be several on screen — Start script, room-entry script,
+// turn scripts, ...). Paste (not keyboard.type) avoids CodeMirror's closeBrackets/indentOnInput
+// extensions desyncing from synthetic keystrokes on longer text (confirmed interactively: typed
+// content over a few hundred characters can come out corrupted) — see
+// tests/e2e/verify-appshell-code-view.mjs's setCmContent for the same pattern.
+const selectAllKey = process.platform === 'darwin' ? 'Meta+A' : 'Control+A';
+const pasteKey = process.platform === 'darwin' ? 'Meta+V' : 'Control+V';
+
+export async function setScriptCodeView(page, codeViewButtonLocator, scriptText) {
+ await codeViewButtonLocator.click();
+ const cm = page.locator('.cm-editor .cm-content').first();
+ await cm.waitFor({ timeout: 5000 });
+ await cm.click();
+ await page.keyboard.press(selectAllKey);
+ await page.evaluate(t => navigator.clipboard.writeText(t), scriptText);
+ await page.keyboard.press(pasteKey);
+ await page.waitForTimeout(200);
+ await page.click('button:has-text("Visual editor")');
+}
+
+// Creates a local (browser-only) draft game with a fixed, human-readable name and
+// waits for the editor to load. The AppShell title bar shows this name in captured
+// screenshots, so it needs to read cleanly (not e.g. "Tutorial Game 1786726809698"),
+// which means reusing the same name across every run — so any existing draft with
+// that name is deleted first, keeping repeat runs idempotent.
+export async function createLocalDraft(page, baseUrl, name, { gameType } = {}) {
+ await page.goto(`${baseUrl}/open`);
+ await page.waitForSelector('button:has-text("Create local draft")', { timeout: 30000 });
+
+ const existingDraft = page.locator(`text=${name}.aslx`);
+ if (await existingDraft.count() > 0) {
+ await existingDraft.locator('..').locator('button[title="Delete draft"]').click();
+ await page.getByRole('button', { name: 'Delete', exact: true }).click();
+ await existingDraft.waitFor({ state: 'detached', timeout: 10000 });
+ }
+
+ await page.fill('input[placeholder="Game name"]', name);
+ // Game type defaults to Text Adventure once the name field triggers the extra
+ // fields to appear — no need to explicitly select it for a text-adventure capture.
+ await page.waitForSelector('text=Text Adventure', { timeout: 10000 });
+ if (gameType === 'Gamebook') {
+ await page.getByText('Gamebook', { exact: true }).click();
+ }
+ await page.click('button:has-text("Create local draft")');
+ await page.waitForSelector('button[title="Manage assets"]', { timeout: 30000 });
+}
+
+// Selects a node in the left tree by its element name. A leaf's clickable row is
+// [data-part="item"]; once it gains children it becomes a branch whose own
+// [role="treeitem"] wrapper carries the same data-value on [data-part="branch-control"]
+// instead — match both (see tests/e2e/verify-appshell-exits-editor.mjs).
+export async function selectTreeNode(page, name) {
+ await page.locator(`[data-value="${name}"][data-part="item"], [data-value="${name}"][data-part="branch-control"]`).first().click();
+}
+
+// Adds a new element via the toolbar "+ Add" menu. `menuLabel` must match the
+// visible menu item text exactly, e.g. "Add Room", `Add Object in "lounge"`,
+// `Add Exit from "lounge"`. Uses exact role matching, not has-text substring
+// matching — "Add Room" is itself a substring of "Add Room in \"lounge\"", so a
+// plain :has-text() click here is ambiguous once a room is selected.
+export async function addElement(page, menuLabel, name) {
+ await page.click('button[title="Add element"]');
+ await page.getByRole('button', { name: menuLabel, exact: true }).click({ timeout: 5000 });
+ await page.waitForSelector('#element-name');
+ await page.fill('#element-name', name);
+ await page.click('[role="dialog"] button:has-text("Add")');
+ await page.waitForSelector(`text=${name}`, { timeout: 10000 });
+}
+
+// Switches the properties panel to the given tab (Setup/Room/Exits/Object/Verbs/...).
+// Exact match matters: an object's tab bar has both "Object" and "Objects" tabs, so
+// a has-text("Object") click would be ambiguous between the two.
+export async function openTab(page, tabLabel) {
+ await page.getByRole('button', { name: tabLabel, exact: true }).click({ timeout: 10000 });
+}
+
+// Most single-line property fields render as a `label ` immediately
+// followed by the input/select, inside a shared flex-row parent — no id/name/
+// aria-label to hook into directly. See tests/e2e/verify-appshell-exits-editor.mjs's
+// `Name:` lookup for the pattern this generalizes.
+export function fieldByLabel(page, labelText) {
+ return page.locator(`span:has-text("${labelText}")`).locator('..').locator('input, select, textarea');
+}
+
+export async function setLabeledField(page, labelText, value) {
+ await fieldByLabel(page, labelText).fill(value);
+}
+
+export async function selectLabeledField(page, labelText, optionValue) {
+ await fieldByLabel(page, labelText).selectOption(optionValue);
+}
+
+// Adds a verb to the currently-selected object/room and selects it in the Verbs table,
+// which opens its BEHAVIOUR panel on the right (defaults to "Print a message").
+export async function addVerb(page, verbName) {
+ const addVerbButton = page.locator('button:has-text("Add Verb")');
+ await addVerbButton.locator('..').locator('input').fill(verbName);
+ await addVerbButton.click();
+ const row = page.locator(`td:has-text("${verbName}")`);
+ await row.waitFor({ timeout: 10000 });
+ await row.click();
+}
+
+// Adds an element that lives under the "Advanced" tree node (Timer, Function, ...) —
+// these aren't in the toolbar "+ Add element" menu (see Toolbar.svelte's ADVANCED_ADDERS
+// comment), instead the node's own properties panel has one "+ Add " button per type.
+export async function addAdvancedElement(page, elementType, name) {
+ await selectTreeNode(page, '_advanced');
+ await page.getByRole('button', { name: `+ Add ${elementType}`, exact: true }).click();
+ await page.waitForSelector('#element-name');
+ await page.fill('#element-name', name);
+ await page.click(`[role="dialog"] button:has-text("Add ${elementType}")`);
+ await page.waitForSelector(`text=${name}`, { timeout: 10000 });
+}
+
+// Opens the "Add Script Command" modal from a given "+ Add script" button (there can be
+// several on screen at once — root level plus one per nested then/else block — so pass the
+// specific Locator for the one you mean, e.g. `page.locator('button:has-text("+ Add
+// script")').first()` for the outermost one). `category` navigates the left sidebar
+// (Output/Scripts/Objects/...); `item` picks a specific command by its exact visible label.
+// Both are optional — omitting them accepts the dialog's default ("Print a message").
+export async function addScriptCommand(page, addButtonLocator, { category, item } = {}) {
+ await addButtonLocator.click();
+ await page.waitForSelector('text=Add Script Command');
+ // Category sidebar and command list are both plain buttons with role="option"
+ // (a custom listbox, not a native ) — see AddScriptModal.svelte. Command rows
+ // (not categories) render with a leading "●" marker baked into the accessible name, and
+ // some labels are prefixes of others (e.g. "Move object" / "Move object to the current
+ // room") — anchor a regex against the bullet-prefixed full name instead of a plain
+ // substring match, which would hit both.
+ if (category) {
+ await page.getByRole('option', { name: category, exact: true }).click();
+ }
+ if (item) {
+ await page.getByRole('option', { name: new RegExp(`^●\\s*${item.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) }).click();
+ }
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+}
+
+// Locators for the two dropdowns on an "if" (or "else if") condition row: the expression
+// type (e.g. "object is switched on") and, once that's chosen, the object it applies to.
+// Found via the literal "if"/"else if" label span rather than a container class, since
+// ScriptEditor.svelte nests these blocks recursively with no other stable hook — the
+// expression select is the label's first following sibling, the object picker
+// (when the chosen expression takes a simple object argument) is the second. Pass `nth`
+// (0-based, in document order) when a page has more than one if-block on screen at once.
+export function ifExpressionSelect(page, { label = 'if', nth = 0 } = {}) {
+ return page.locator(`xpath=(//span[text()="${label}"]/following-sibling::select[1])[${nth + 1}]`);
+}
+export function ifObjectSelect(page, { label = 'if', nth = 0 } = {}) {
+ return page.locator(`xpath=(//span[text()="${label}"]/following-sibling::select[2])[${nth + 1}]`);
+}
+
+// Ticks a Features-tab checkbox by its row's leading label text (e.g. "Container:",
+// "Switchable:") to reveal that feature's own properties tab.
+export async function toggleFeature(page, labelPrefix) {
+ await page.locator(`text=${labelPrefix}`).locator('..').locator('input[type="checkbox"]').check();
+}
+
+// Clicks the toolbar Preview button and returns the WasmPlayer tab it opens, once booted
+// and ready to accept a command. Requires `baseUrl` (the page's own origin, i.e. the
+// AppShell dev server, not WasmPlayer's own port) to proxy '/player' — see vite.config.ts's
+// "Proxy WasmPlayer through the same origin so BroadcastChannel works between editor and
+// player tabs" comment; that same-origin requirement is also why this returns a second
+// Playwright `page` in the same browser context rather than navigating in place — Preview
+// always opens via `window.open`, and the original editor tab has to stay alive throughout
+// (it's the BroadcastChannel sender: it answers WasmPlayer's "ready" with the current game
+// bytes, and again on every subsequent "ready" if the player reloads) — closing or
+// navigating it away mid-capture strands the player on its boot screen.
+// `capture()` (this module's own) works unchanged against the returned page — pass
+// `page.locator('#txtCommand')` as `untilLocator` to crop to the bottom of the transcript.
+export async function openPreview(page) {
+ const context = page.context();
+ const [playerPage] = await Promise.all([
+ context.waitForEvent('page', { timeout: 15000 }),
+ page.click('button:has-text("Preview")'),
+ ]);
+ await playerPage.waitForSelector('#txtCommand', { state: 'visible', timeout: 60000 });
+ await playerPage.waitForFunction(() => window.canSendCommand === true, { timeout: 30000 });
+ return playerPage;
+}
+
+// Types a command into a WasmPlayer tab (as returned by openPreview) and submits it,
+// waiting for the previous command's turn to fully finish first — sendCommand() in
+// player.js silently drops a command while canSendCommand is still false from the last
+// one's round-trip, so a fixed sleep between commands would be flaky.
+export async function sendCommand(playerPage, command) {
+ await playerPage.waitForFunction(() => window.canSendCommand === true, { timeout: 10000 });
+ await playerPage.fill('#txtCommand', command);
+ await playerPage.press('#txtCommand', 'Enter');
+ await playerPage.waitForFunction(() => window.canSendCommand === true, { timeout: 10000 });
+}
+
+const CURSOR_ELEMENT_ID = '__docs-capture-cursor';
+
+// Classic arrow-cursor glyph, hotspot (the point it's "pointing at") at the path's own
+// (0,0) — so positioning the wrapper's top-left corner at a target coordinate puts the
+// tip exactly there, the same convention as a real OS cursor image. White fill with a
+// black outline keeps it legible over both light and dark editor chrome.
+const CURSOR_SVG = `
+
+ `;
+
+// Injects a synthetic cursor image into the page at a target locator's position — real
+// page.screenshot() never captures the actual OS mouse cursor, so this fakes one in for
+// screenshots that need to show the reader where to click. `at` picks the point within
+// the target's box the cursor tip lands on: 'center' (default), or a corner/edge name
+// ('left' = vertical-center of the left edge, etc.) matching typical "pointing at a
+// dropdown/button" framing. Returns a cleanup function — call it after the screenshot is
+// taken so the fake cursor never leaks into later captures or lingers in the live DOM.
+async function injectCursor(page, targetLocator, at = 'center') {
+ const box = await targetLocator.boundingBox();
+ if (!box) return async () => {};
+ const points = {
+ center: { x: box.x + box.width / 2, y: box.y + box.height / 2 },
+ left: { x: box.x + 6, y: box.y + box.height / 2 },
+ right: { x: box.x + box.width - 6, y: box.y + box.height / 2 },
+ top: { x: box.x + box.width / 2, y: box.y + 6 },
+ bottom: { x: box.x + box.width / 2, y: box.y + box.height - 6 },
+ };
+ const { x, y } = points[at] ?? points.center;
+ await page.evaluate(({ id, left, top, svg }) => {
+ const el = document.createElement('div');
+ el.id = id;
+ el.style.cssText = `position:fixed; left:${left}px; top:${top}px; z-index:2147483647; pointer-events:none; margin:0; padding:0; line-height:0;`;
+ el.innerHTML = svg;
+ document.body.appendChild(el);
+ }, { id: CURSOR_ELEMENT_ID, left: x, top: y, svg: CURSOR_SVG });
+ return () => page.evaluate((id) => document.getElementById(id)?.remove(), CURSOR_ELEMENT_ID);
+}
+
+// Captures a screenshot at the current state and reports the save path. Most editor
+// states only use the top portion of the fixed VIEWPORT height, leaving a lot of
+// blank space below (e.g. a short "rename this room" form) — pass `untilLocator` for
+// the last element relevant to what this particular screenshot is illustrating (the
+// field just filled in, the button about to be clicked, ...) and the capture crops to
+// that element's bottom edge + padding instead of the full viewport height. Omit it
+// for states where the full viewport genuinely is the content (rare).
+//
+// Pass `cursorAt` to draw a synthetic cursor pointing at a specific control — a locator
+// on its own (tip lands centered on it), or `{ locator, at }` for one of the named
+// points `injectCursor` supports (e.g. `{ locator: dropdown, at: 'left' }` to point at a
+// dropdown about to be opened, without the cursor covering its label text). Useful for
+// "click here" framing where the surrounding prose alone doesn't make the target obvious
+// (e.g. a screenshot showing a still-closed native ``, which can't be captured
+// mid-open — see docs-screenshots/README or ask the docs-screenshots skill for why).
+// Clicks the toolbar's "Unsaved" pill (save-chip-unsaved) if present and visible, so
+// captures never show it — it's the real isDirty/isEditingField indicator (see
+// Toolbar.svelte), not a capture artifact, but every doc screenshot is meant to show a
+// clean "just did this one thing" state, not mid-edit save-pending chrome. The pill is
+// itself the save button (onclick={handleSaveNow}), so no extra selector is needed
+// beyond it. Only present on editor pages — WasmPlayer preview tabs have no toolbar, so
+// this is a harmless no-op there (count() is 0).
+async function saveIfDirty(page) {
+ // Some captures intentionally show an open modal (e.g. the Add Script Command dialog) -
+ // its full-viewport backdrop intercepts clicks on the toolbar behind it, and forcing a
+ // save isn't the point of those screenshots anyway. Leave the pill as-is when a dialog
+ // is up rather than hanging on a blocked click.
+ if (await page.locator('[role="dialog"]').count() > 0) return;
+ const unsavedChip = page.locator('.save-chip-unsaved');
+ if (await unsavedChip.count() === 0) return;
+ if (!(await unsavedChip.isVisible())) return;
+ await unsavedChip.click();
+ // Wait past the transient "Saving..." chip too (editor-store.ts's MIN_SAVING_VISIBLE_MS
+ // holds it for a minimum stretch even for an instant save) - waiting only for the unsaved
+ // chip to detach would resolve mid-save, capturing "Saving..." instead of the settled state.
+ await page.waitForFunction(() => {
+ return !document.querySelector('.save-chip-unsaved') && !document.querySelector('.save-chip-saving');
+ }, { timeout: 15000 }).catch(() => {});
+}
+
+// Dismisses the "This game is only stored in this browser" backup nudge (BackupBanner.svelte)
+// if it's showing. Checked after saveIfDirty(), not just once up front - editor-store.ts's
+// doPersist() re-evaluates shouldShowBackupBanner() on every save once a local draft crosses
+// its activity threshold, so the banner can reappear mid-script after the very save this
+// module's own saveIfDirty() just triggered. Dismissing goes through the adapter's own
+// markBackupBannerResolved(), not the save/dirty path, so no follow-up save is needed here.
+async function dismissBackupBannerIfShown(page) {
+ if (await page.locator('[role="dialog"]').count() > 0) return;
+ const dismissButton = page.getByRole('button', { name: 'Dismiss', exact: true });
+ if (await dismissButton.count() === 0) return;
+ if (!(await dismissButton.isVisible())) return;
+ await dismissButton.click();
+ await dismissButton.waitFor({ state: 'detached', timeout: 5000 }).catch(() => {});
+}
+
+export async function capture(page, outputPath, { untilLocator, padding = 24, cursorAt } = {}) {
+ await saveIfDirty(page);
+ await dismissBackupBannerIfShown(page);
+ let removeCursor;
+ if (cursorAt) {
+ const { locator, at } = typeof cursorAt.boundingBox === 'function' ? { locator: cursorAt, at: 'center' } : cursorAt;
+ removeCursor = await injectCursor(page, locator, at);
+ }
+ let clip;
+ let resized = false;
+ if (untilLocator) {
+ const box = await untilLocator.boundingBox();
+ if (box) {
+ const neededHeight = Math.ceil(box.y + box.height + padding);
+ // Deeply nested scripts (e.g. a get-input/show-menu/show-menu/wait chain) can be
+ // taller than the fixed VIEWPORT — grow the viewport to fit rather than silently
+ // truncating (the old min() against VIEWPORT.height did the latter). Widening only
+ // the height leaves layout/wrapping (driven by width) unaffected, so box.x/box.y
+ // stay valid after the resize.
+ if (neededHeight > VIEWPORT.height) {
+ await page.setViewportSize({ width: VIEWPORT.width, height: neededHeight });
+ resized = true;
+ }
+ clip = { x: 0, y: 0, width: VIEWPORT.width, height: neededHeight };
+ }
+ }
+ await page.screenshot({ path: outputPath, ...(clip ? { clip } : {}) });
+ if (removeCursor) await removeCursor();
+ if (resized) await page.setViewportSize(VIEWPORT);
+ console.log(`SAVED: ${outputPath}`);
+}
+
+// Standard try/run/finally wrapper matching the existing verify-*.mjs convention:
+// screenshot to /tmp on failure, always close the browser, non-zero exit on error.
+export async function runCapture(fn) {
+ const { browser, page, baseUrl } = await launch();
+ try {
+ await fn({ page, baseUrl });
+ console.log('PASS: all captures saved');
+ } catch (err) {
+ console.error('FAIL:', err.message);
+ await page.screenshot({ path: '/tmp/docs-screenshot-failure.png' });
+ process.exitCode = 1;
+ } finally {
+ await browser.close();
+ }
+}