Skip to main content

Events and scheduling

JavaScript exposes events through world.beforeEvents, world.afterEvents, system.beforeEvents, and system.afterEvents; Python uses world.before_events, world.after_events, system.before_events, and system.after_events. Subscribe with subscribe and remove the same handler with unsubscribe.

Before and After

Before Events run synchronously during the native Minecraft event. A cancelable event contains cancel. Keep these handlers small and schedule deferred work through system.

After Events report an outcome that has already happened and cannot cancel it. QuestScript captures bounded event input and delivers handlers on the server thread during the tick's final scripting phase. Events are grouped by type; occurrences and subscribers inside a group preserve enqueue and registration order.

The playerJoin snapshot contains stable playerId and playerName fields like Bedrock, but no live player object. If the player is still connected, resolve it separately through world.getPlayer(event.playerId) / world.get_player(event.player_id). Asynchronous playerBreakBlock also captures playerId and playerName; its player field is nullable because the player may disconnect before deferred delivery. A synchronous Before Event keeps a required player.

The JavaScript name system.beforeEvents.scriptUnload and Python name system.before_events.script_unload both denote one special Script Lifecycle Event rather than an ordinary native Before Event. The signal runs synchronously before final Script Run cleanup, cannot cancel teardown, and permits direct World Mutation. See Script lifecycle for its full contract and restrictions.

Scheduler

  • system.run(callback) runs in the next available final phase; a call made from a System task targets the following tick;
  • JavaScript system.runTimeout(callback, ticks) / Python system.run_timeout(callback, ticks) runs once after a delay; 0 may run again in the current tick, even from another System task;
  • JavaScript system.runInterval(callback, ticks) / Python system.run_interval(callback, ticks) repeats on an interval;
  • JavaScript system.runJob(generator) / Python system.run_job(generator) advances a generator incrementally;
  • JavaScript system.waitTicks(ticks) / Python system.wait_ticks(ticks) returns a Promise/awaitable that completes after the tick count.

If the previous interval occurrence is queued or running, the next schedule point is skipped. This is part of the QuestScript scheduler contract. Subscriptions and tasks created by a Script are cleaned when it unloads.

clearRun/clear_run and clearJob/clear_job also cancel a callback or generator step that has already moved into the executor queue when guest execution has not started. They do not interrupt a callback or step that is already running. Clearing an interval still removes all future schedule points, but it does not cancel detached asynchronous work that its callback already started.

Ordinary scheduler and event callbacks have a synchronous void/None contract. A JavaScript callback may start an async function, but QuestScript does not await the returned Promise, handle its rejection, or associate it with clearRun/clear_run or the subscription. The next interval invocation does not wait for that Promise. A continuation after system.waitTicks is serviced as detached work through the shared continuation queue. Handle failures inside the asynchronous task itself. A Before Event decision, including cancel, must be made before the handler returns synchronously. Only an API with an explicitly declared Promise/awaitable callback contract may await its result.

After ordinary game logic, the runtime services the bounded lifecycle queue, then ready runJob/run_job steps, Promise/awaitable continuations, System tasks, and grouped After Events. It checks continuations after every System task and completed event-type group. The phase and queues are bounded, so recursive runTimeout(..., 0)/run_timeout(..., 0) cannot obtain an unbounded tick.

An ordinary callback may be deferred when the tick's soft budget is exhausted. After waiting for 10 ticks it enters its work kind's overdue lane: no more than 8 such entries are admitted per tick, and only while the global soft budget remains. This raises priority without forcing the whole accumulated backlog into one tick. Future scheduler registrations and ready units together are bounded to 4,096 slots globally and 512 per Script Run; a pending interval occurrence or job step counts separately from its retained registration. New work beyond a limit is rejected explicitly. Unload, reload, shutdown, or quarantine completes every remaining outcome exceptionally exactly once.

Server-thread budget

The ordinary final-scripting-phase budget uses time already spent by the game before that phase and the median of the last 10 such measurements multiplied by 1.2. The target tick is 50 ms, ordinary Script work receives between 15 and 35 ms, and a fixed 1 ms reserve remains. If 40 ms has already elapsed before the final phase, no ordinary callback starts in that tick; lifecycle cleanup retains a separate 5 ms soft reserve.

The soft deadline controls only whether the next unit starts. An admitted JavaScript/Python callback is not suspended between ticks and may extend the tick until it completes or the hard watchdog interrupts it. These settings therefore do not guarantee a tick shorter than 50 ms. The selected prototype still recorded 18 ticks longer than 50 ms across 16,000 measured ticks; the team accepted that as residual risk. Callback-duration prediction and separate Script/entity budgets are deferred to post-release work.

Operators may override the starting values through JVM properties with the questscript.serverThreadBudget. prefix: targetTickMillis, minScriptBudgetMillis, maxScriptBudgetMillis, fixedReserveMillis, gameHeadroomMultiplier, historyWindowTicks, emergencyPreFinalCutoffMillis, lifecycleReserveMillis, overdueAfterTicks, maxOverdueEntriesPerKindPerTick, maxLifecycleQueue, maxOrdinaryQueue, maxOrdinaryQueuePerScript, maxAfterEventOccurrences, maxAfterEventOccurrencesPerScript, maxLifecycleEntriesPerTick, and maxGuestEntriesPerTick. Invalid combinations are rejected at startup. Scripts cannot change these values.

For routine inspection use /qs diagnostics threading [scriptId]. This permission-level-2 operator command returns a bounded JSON snapshot of the current budget, queues, overdue age, interval skips, overflow, watchdog, and lifecycle failures attributed to Script Runs. A global response includes at most 64 Script Runs and 64 pending-work samples, and each Script Run includes at most 32 intervals; *Truncated fields report clipping. The snapshot never enters a Graal Context and contains no Script source, arguments, event payloads, or results.

For deep recording enable -Dquestscript.optionA.diagnostics=true. The questscript.OptionAPhase, questscript.OptionAAdmission, and questscript.OptionAGuestUnit JFR events expose the calculated budget, input measurements, emergency state, queue depths, deferral age, overflow, Script Run, and watchdog outcome. Use the exact trace recorder to reconstruct complete tick timelines. Soft admission exhaustion defers work that has not started; the hard watchdog interrupts an already running unit and cannot reclaim time already spent in the current tick.

The global JavaScript setTimeout, setInterval, clearTimeout, and clearInterval functions are not available. All delays use ticks through JavaScript system.runTimeout, system.runInterval, system.waitTicks or Python system.run_timeout, system.run_interval, system.wait_ticks; the value selects the earliest eligible tick rather than an exact wall-clock instant. Waiting never blocks the server thread.

Script Events

/scriptevent <namespace>:<path> [message] delivers a broadcast event through JavaScript system.afterEvents.scriptEventReceive or Python system.after_events.script_event_receive. The namespace is an author-chosen messaging channel. It is not derived from a Script ID, does not identify the sender, and does not depend on how a Script was loaded. When the optional message is omitted, the handler receives an empty string.

At present only the operator /scriptevent command can publish these events; there is no Script-side JavaScript system.sendScriptEvent / Python system.send_script_event method yet.

The complete identifier remains in event.id, while namespaces compares only the portion before :. JavaScript passes an options object: signal.subscribe(handler, { namespaces: ["arena"] }); Python passes a keyword: signal.subscribe(handler, namespaces=["arena"]). This filter receives arena:round_start and arena:admin/reset, but not lobby:round_start. An omitted filter or namespaces: null / namespaces=None receives every valid Script Event; an explicit empty list receives none.

The JavaScript fields sourceEntity, sourceBlock, dimension and corresponding Python fields source_entity, source_block, dimension contain QuestScript Public Facades (Entity, Block, and Dimension) rather than native Minecraft Java objects. When a live source is unavailable, the corresponding field is null; stable JavaScript fields id, sourceKey, sourceEntityId, position and Python fields id, source_key, source_entity_id, position remain available. The entity ID field is the UUID of an Entity source and is null for other source types.

A Script Event ID uses the Minecraft <namespace>:<path> form with explicit non-empty parts. An invalid ID is rejected before the event is published.

See the API reference for the exact event and DTO inventory.