-
Notifications
You must be signed in to change notification settings - Fork 0
Scripting
When built-in actions aren't enough, attach a JavaScript function to an event. No scripting mod is needed: Arcadia Studio includes its own JavaScript engine in Minecraft and in the editor.
| Kind | Runs where | Use for |
|---|---|---|
| Standard Client | The player's game | Instant screen updates, local logic, sounds, messages |
| Standard Server | The Minecraft server | Player data, commands, updates that must be trusted |
| KubeJS Server | The server, via KubeJS | Full KubeJS/Minecraft APIs. See [[KubeJS integration |
Scripts live in your project under scripts/client/… or scripts/server/…. The folder decides the side.
- Select a control, open Events, and pick the event (for example
click) and side (Client or Server). - Click New Script. A file and function are created for you.
- Write the function body and click Save & Assign. The script is now attached to that event.
- Test Event fires that event in Preview. Test Function in the script editor tests your unsaved draft.
Edit Script reopens the attached script, and Detach removes it from the event (the file stays in your project). To reuse an existing file, expand Use an existing script… and choose it with Attach existing script.
You can also write scripts in the Scripts tab (bottom panel) with New script, Import script and Save script. Help → Script API and snippets, or Ctrl+Space in the editor, inserts ready-made API calls.
The script dropdowns include ready-made [Template] scripts. Pick one, edit the constants at the top (such as ITEM_ID, COUNT, LIST_ID or COMMAND), and Save & Assign. Existing scripts are never overwritten.
| Template | Does |
|---|---|
| Give item — Standard Server / KubeJS Server | Grants an item (the Standard version uses /give, so it needs permission) |
| Run server command | Runs an existing command as the player |
| Populate inventory list | Fills an Item List |
| Open project | Opens another project's Main screen |
| Teleport — Standard Server | Teleports the player |
| Message player — Standard Server | Sends a chat message |
| Change text | Updates a label |
| Close screen | Closes the screen |
| Global reward / Open UI / command alias — KubeJS | Registers a server-wide command such as /<project>.reward
|
Your function receives ctx:
function engineStart(ctx) {
console.log('Clicked', ctx.elementId);
ctx.ui.setText('status', 'Starting engine...');
const visits = Number(ctx.state.get('visits') || '0') + 1;
ctx.state.set('visits', String(visits));
}-
ctx.elementId: which control fired the event. -
ctx.value: the event's input (text box text, slider value, row index, the key name for a screenkeyevent, and so on). - Scripts run separately from the page with the same 2-second limit as in Minecraft, so a script stuck in a loop is stopped instead of freezing Preview or the app.
- Sprites:
ui.play(id, 'run')switches clip. Sound controls:ui.setValue(id, 'play')or'stop'. - Web and desktop (advanced tools):
ui.animate(id),ui.stopAnimation(id),ui.setVelocity(id, vx, vy),ui.setPosition(id, x, y),ctx.input.isDown(name),ctx.input.axis(name),ctx.input.pointer()(x,y,down,presses: the latest finger or mouse press on the floor, for tap-to-move),ctx.physics.touching(id)(IDs it touches or overlaps),ctx.physics.isTouching(a, b), andctx.ui.getElement(id)withx,y,width,height,vx,vy. -
ctx.repeat:truewhen a screenkeyevent comes from a key being held down,falsefor a fresh press. Use it to ignore held keys for one-shot actions such as jump or drop. -
ctx.state.get(name)/ctx.state.set(name, value): screen variables (strings). They start over when the screen opens. Where else to keep things is under Where to keep state. -
ctx.save: saved games that survive closing the game (web and desktop); see Saved games. -
ctx.ui.open('screen'): opens another screen. In web and desktop projects any script can; in a project made for Minecraft (or both), only server scripts can, so use anopen_uiaction instead. Anopen_uiaction works on any event, includingtick,collideandtrigger_enter. -
console.log/warn/error: messages for Preview's console and the game log.
| Method | Does |
|---|---|
setText(id, text) |
Change text |
setValue(id, value) |
Change a value |
setVisible(id, bool) / setEnabled(id, bool)
|
Show/hide, enable/disable |
setItem(id, 'minecraft:diamond') |
Change an Item Icon (ID must be namespace:path) |
setItems(id, rows) |
Fill an Item List with [{item, count, name}]
|
getVariable(name) / setVariable(name, value)
|
Screen variables |
getElement(id) |
{id, text, setText(), setItem()} |
close() |
Close the screen |
changeTexture(id, resource) |
Client only: swap an image |
open(screenId) |
Server only: open a screen (this project, or project:screen) |
Client scripts also have ctx.client.playSound(id) and ctx.client.sendMessage(text).
Web and desktop only: ctx.client.playSound(id, volume) plays one sound effect at a volume from 0 to 1, and ui.setVolume(id, volume) sets a Sound control's volume, at once if it is playing. Together they make a volume menu: keep the player's levels in a screen variable and pass them along. Minecraft ignores the volume and has no setVolume, so check if (ctx.ui.setVolume) in a project that also targets Minecraft.
Each event runs a script. What happens to the script's own variables depends on the project:
-
Scripts keep their variables between events (Project settings; web & desktop projects, on for new projects). Each script runs from the top once. After that only the event's function is called, so a top-level
let score = 0keeps counting from one event to the next, and across screens, until the game starts over or you edit the script.ctxanduialways mean the current event's, even at the top level. -
Off, and always in Minecraft or "both" projects: every event runs the script from the top, so top-level variables start again each time. This matches Minecraft, where one server runs scripts for many players. Keep state in
ctx.state, or in a global object (globalThis.game = globalThis.game || { score: 0 }), which lasts for the session in web and desktop apps.
// With "Scripts keep their variables between events" on:
let score = 0; // runs once
const enemies = []; // a real array, not a string
function coin(ctx) { // runs on every coin event
score += 10;
ctx.ui.setText('score', 'Score ' + score);
}With the setting on, code at the top level that works the screen (ctx.ui.setText(...) outside any function) runs only on the first event. Validate points out such lines. Move them into a function, usually the screen's open event.
Which to use:
| Keep it in | Lasts | Shows in Preview's variables | Use for |
|---|---|---|---|
| Script variables (setting on) | Until the game starts over | No | Game logic: positions, lists, timers |
ctx.state |
Until the screen opens again | Yes | What controls show through ${name}, conditions, state graphs |
ctx.save |
Until the game clears it | No | Progress that should survive closing the game |
| Game variables (Project settings) | Until the game closes; Saved between visits ones come back next time | Yes | A score a Game over screen shows, lives across levels, a best score, settings |
Game variables are the whole game's variables. List them in Project settings → Game variables as name=value;name=value (their starting values). They work exactly like screen variables (${score} in a label, ctx.state.get('score'), conditions), but keep their value when another screen opens. Names listed under Saved between visits are also kept with the game's saved data, so a best score is still there next time. Web & desktop.
ctx.save keeps text by key in the player's browser (or the desktop app's own storage), under the game's ID, so it's still there next time the game opens:
function saveGame(ctx) {
ctx.save.set('level', String(level));
ctx.save.set('hero', JSON.stringify({ hp: hp, items: items })); // objects as JSON
}
function loadGame(ctx) {
if (!ctx.save.has('level')) return; // a new player
level = Number(ctx.save.get('level'));
const hero = JSON.parse(ctx.save.get('hero') || '{}');
}-
get(key)returns the text, or''when nothing is saved.has(key),keys(),remove(key)andclear()do what they say. - Text only: store objects with
JSON.stringify. Keys are 1–100 characters, and a game can keep up to 512 KB. - A script run that fails saves nothing, so a crash halfway through can't leave half a save.
- Each browser keeps its own saves: a player on a phone and a laptop has two. Clearing the browser's site data wipes them.
- Minecraft has no saved games; Validate reports
ctx.savein a project made for Minecraft. - To test a fresh start, call
ctx.save.clear()from a button or Preview's script box. - A site hosting the game can keep saves its own way with
save.load/save.storeinhost.js; see Web and desktop apps.
A game with crowds (enemies, bullets, pickups) doesn't need a control for each one. Make one control as a template, usually hidden, and spawn copies of it while the game runs. The engine draws each copy, moves it, collides it and removes it.
function wave(ctx) {
for (var i = 0; i < 20; i++) {
// A copy of 'bat' at x, y (its top-left, like setPosition), chasing the player at 60 px a second.
ctx.ui.spawn('bat', 40 * i, 0, { seek: 'player', speed: 60 });
}
}
function batTouched(ctx) { // the template's trigger_enter event, run by each copy
if (ctx.value === 'player') ctx.ui.despawn(ctx.elementId); // ctx.elementId is the copy's own ID
}| Method | Does |
|---|---|
spawn(template, x, y, options) |
Adds a copy and returns its ID (bat~12). Options: vx, vy (a velocity), seek (a control's ID) with speed (px/s), path (a tilemap to find the way over), separate (px to keep from other copies), life (seconds, then it's removed), clip (a sprite clip), texture. template can also be a component
|
despawn(id) |
Removes a copy (design controls can only be hidden) |
seek(id, target, speed, { path }) |
Keep moving toward another control, over a tilemap if path names one; seek(id, '') stops |
separate(id, px) |
Keep a copy at least px from other copies, centre to centre. Given a template, every live copy and every later one |
instancesOf(template) |
The IDs of a template's live copies, oldest first |
What a copy gets:
-
Everything the template has. Its size, picture, sprite clips, body and collider, trigger setting, tags and
events. Its events run with the copy's ID as
ctx.elementId. - Its place in the draw order. Copies draw right after their template, so put the template where you want the crowd to appear (under the HUD, over the floor).
-
Movement. A copy that isn't a dynamic body moves by its velocity every frame. A dynamic body is moved by the
physics instead, with gravity and collisions.
setVelocity,setPositionandgetElementwork on copies like any control. -
When it exists. A copy exists once the script run that spawned it ends. Later calls in that same run can move
or despawn it, and the next run sees it with
getElementandinstancesOf.
Up to 5,000 copies can be alive on a screen at once; more are skipped with a warning. The engine measures 1,000 seeking copies at under 1 ms a frame to move and about 2 ms to draw, and physics with 1,000 overlapping trigger copies at about 4 ms. Minecraft has no spawned objects.
Seekers without a spacing all end up on the same spot. Give them one and they spread around their target instead, the way a swarm surrounds the player:
ctx.ui.separate('bat', 12); // every bat, now and later, keeps 12 px from the others
ctx.ui.spawn('slime', x, y, { seek: 'player', speed: 40, separate: 16 }); // or per copySpacing is only between copies (design controls and walls aren't pushed), and it isn't a collision: copies can still overlap for a moment when a crowd presses in, then ease apart. 1,000 copies seeking and keeping apart cost about 2 ms a frame.
A seeker heads straight for its target. With path it finds its way over a tilemap instead, round solid tiles and
through gaps, and never cuts a solid corner:
ctx.ui.spawn('ghoul', x, y, { seek: 'player', speed: 60, path: 'dungeon' });
ctx.ui.seek(id, 'exit', 80, { path: 'dungeon' }); // an existing seeker onto the mapAll the seekers following one map share one route map toward their target, rebuilt only when the target moves to another tile or a tile changes, so a crowd costs little more than one seeker (about 5 ms to rebuild on the largest map, 256 × 256 tiles). If there's no way through, a seeker goes straight.
For a route of your own, ctx.physics.findPath(map, x1, y1, x2, y2) gives the tile centres to walk through, the last
one being the goal's tile, or null if either end is off the map or on a solid tile or there's no way through.
Across a 256 × 256 maze it takes about 4 ms.
var hit = ctx.physics.raycast(x1, y1, x2, y2, { ignore: 'player' });
// null, or { id, x, y, distance, normal: { x, y } }: the first thing the line meets, where, and which side it hit
if (ctx.physics.canSee('guard', 'player')) alarm(ctx); // nothing solid between the two centresA ray stops at controls with a body (round colliders on their circle, polygons on their box) and at solid tiles.
Triggers and controls without a body don't stop it. Options: triggers: true to stop at triggers too, ignore (a
control's ID), tag (only controls with that tag). 200 rays in one script run, with 300 copies on screen, take
about 6 ms.
A component can be spawned like a template: everything in it, as one group.
var id = ctx.ui.spawn('enemy_card', 40, 60, { seek: 'player', speed: 30 }); // 'enemy_card~3'
ctx.ui.setText(id + '_name', 'Slime'); // its controls are the root's ID + '_' + their ID in the component
ctx.ui.despawn(id); // removes the whole group- The group gets a new, see-through root the size of the component, at x, y. Moving, seeking, spacing,
lifeanddespawnall go through the root, and its controls come with it. Despawning one control inside the group isn't allowed; despawn the root. - Each control keeps its look, body and events. Actions aimed at another control of the component (Set Text, Show, Change Texture…) are aimed at this group's copy, as when you place a component in the editor.
- The component's variables are added to the screen's state if it doesn't have them yet.
- Groups draw on top of everything, one after another, or right after the control named by
after({ after: 'floor' }). - At most 256 controls in a component you spawn. 500 groups of 4 controls spawn in about 40 ms and seek at about 0.5 ms a frame.
Components travel with web and desktop exports for this (with the scripts only they use); Minecraft packs still leave them out.
function refresh(ctx) {
ctx.ui.setItems('inventory', ctx.player.getInventory());
ctx.ui.setText('status', 'Hello ' + ctx.player.getName());
}
function portal(ctx) {
if (!ctx.player.hasPermission(2)) { ctx.message('Operators only.'); return; }
ctx.server.runCommand('portal');
}| API | Returns / does |
|---|---|
ctx.player.getName(), getUuid()
|
Text |
ctx.player.getPosition() |
{x, y, z, dimension} |
ctx.player.getInventory() |
Main-inventory items as [{item, count, name}]
|
ctx.player.hasPermission(level) |
true/false for levels 0–4 |
ctx.server.runCommand(cmd) |
Runs a command with the player's own permissions, always |
ctx.message(text) / ctx.server.sendMessage(text)
|
Chat message to the player |
These return plain values, never Minecraft or Java objects. UI changes from server scripts are sent to the player's open screen.
Server scripts can build commands from player input, so
runCommandalways uses the player's permissions, even if the server enablesrunCommandsAsServerfor built-in command actions.
- Preview runs Client scripts for real. Server scripts run in simulation: commands and server UI calls are logged, not applied. The simulated player is "Preview player" at the origin with an empty inventory and no permissions.
- KubeJS scripts only run in Minecraft; Test Event says so.
- Errors show the script file and line where available.
| In Minecraft | Web & desktop (and Preview) | |
|---|---|---|
| Script size | 256 KiB | 1 MiB (web & desktop projects) |
| Statements per call | 100,000 | No limit (only time) |
| Time per call | 2 seconds | 2 seconds |
| Screen changes per call | 128 | 100,000 (128 in projects made for Minecraft or both, so Preview matches the game) |
A screen change is anything a script asks the screen to do: every ctx.ui call and every ctx.state.set. Loops, maths and reading values are free. A call that asks for more than the limit is stopped, and none of its changes are applied; the error says which limit it hit.
How many is sensible: each change costs about 0.4–0.8 microseconds from the script to the screen (measured), so 10,000 take 4–8 ms and 100,000 take 40–70 ms. A tick script that runs every frame has about 16 ms at 60 fps, so keep it near 10,000–20,000 changes. A one-off event, such as building a level, can use the rest with only a brief pause. For many moving things, let the engine move them (velocities, seek, paths, animations) instead of calling setPosition for each one every tick.
Server scripts also share a per-player time budget (about 100 ms per second, with a 250 ms burst). If one player triggers scripts too fast, their server scripts are skipped until it refills, and the server log notes it.
Scripts can't access Java classes, files, the network, processes or the host system. They run inside Minecraft without a separate memory cap, so only install packs you trust; see Security and permissions.
Only scripts attached to an event are exported. Your project file keeps every script, including unfinished ones. Each script file is self-contained: import between files isn't supported, but helper functions in the same file work.
Arcadia Studio manual · Minecraft 1.21.1 / NeoForge · Home · Keyboard shortcuts · Troubleshooting
Getting started
Designing
- Projects and screens
- Controls
- Canvas editing
- Properties and appearance
- Layers and groups
- Panels and parenting
- Anchors and responsive
- Align and distribute
- Components
- Assets and items
- Pixel art and sprites
- Music and sound effects
- Item lists
Behavior
Shipping
- Exporting and installing
- Web and desktop apps
- Publishing to Arcadia
- Leaderboard pages
- Publishing to itch.io
- Advanced tools
- KubeJS
- Security
Extras