Healthy Scripts
How to keep your creations smooth — and what poqpoq does when a script runs away.
Classic LSL scripts were written for a world where a region held a handful of objects and chat was quiet. poqpoq runs everyone's creations at once, so a pattern that was harmless on a sleepy sim can snowball here. The good news: a few simple habits keep your scripts fast and friendly, and poqpoq will gently tell you (and point you back here) if one starts to spin.
1. Chat feedback loops
For a complete menu example and current runtime behavior, see Events, Dialogs & Permissions. It covers listener cleanup, event ordering, animation grants and reset boundaries.
The most common runaway: a script that talks on a channel it also listens to, so its own message comes back and triggers it again — forever.
// ❌ The script hears its own message and loops
default {
state_entry() { llListen(5, "", "", ""); }
listen(integer chan, string name, key id, string msg) {
llSay(5, "pong"); // ← arrives back on channel 5 → listen fires again → ...
}
}
// ✅ Reply on a DIFFERENT channel, or filter by who spoke
default {
state_entry() { llListen(5, "", "", ""); }
listen(integer chan, string name, key id, string msg) {
if (id == llGetOwner()) llSay(6, "pong"); // different channel, only react to a real sender
}
}
id/message
before replying.
2. = vs == in an if
A single = inside a condition assigns instead of
comparing — and the assigned value is almost always "true", so the
block runs every single time. This one hides in plain sight because the
script still compiles.
// ❌ Always true: assigns resetChannel to chan, then runs the block every time
if (chan = resetChannel) { reloadConfig(); }
// ✅ Compare with two equals signs
if (chan == resetChannel) { reloadConfig(); }
=.
3. Re-reading notecards in hot paths
Reading a notecard is expensive — it streams line by line through
the dataserver event. Read your config once at
state_entry and keep the values in variables. Don't re-read it on every
touch, listen, collision, or timer.
// ❌ Re-reads the whole config every time someone touches it
touch_start(integer n) { loadConfig(); doThing(); }
// ✅ Read once; reuse the cached values
default {
state_entry() { loadConfig(); } // fills gEmail, gGreeting, gChannel once
touch_start(integer n) { doThing(); } // uses the cached values
}
changed event
with the inventory flag, or after an explicit reset — not on every interaction.
4. Fast timers doing heavy work
llSetTimerEvent with a tiny interval that does heavy work (notecard
reads, chat, scanning) runs that work dozens of times a second. Use the
slowest interval that still feels right, and stop the timer when
you're done (llSetTimerEvent(0.0)).
// ❌ Twenty heavy passes per second
llSetTimerEvent(0.05); timer() { rescanEverything(); }
// ✅ A pace a human can perceive, stopped when idle
llSetTimerEvent(0.5);
timer() { updateDisplay(); if (done) llSetTimerEvent(0.0); }
5. Talking too fast — and how to pace yourself
Chat is a shared space. Every line your script says rides the same message bus as everyone else's scripts and everyone's conversations, and lands in the chat of every person nearby. A script that talks many times a second floods that bus and no one can read the result. poqpoq lets a script say a quick burst (about 25 messages) and then holds it to a steady ~10 messages a second; anything past that is dropped (not queued for later) so the world stays responsive for everyone. A considerate script never gets near that line — and you have good tools to stay well under it.
- Say it once. Most "spam" is the same line emitted every loop or every frame. Emit it when something actually changes, not on every pass.
- Show changing status as hover text, not chat. A value that updates
constantly — health, score, a countdown — belongs in
llSetTextover the prim. It updates silently and only the people looking at it see it. - Pace a genuine batch with
llSleep. If you really do need to emit many lines (a report, a menu, a list), put a short sleep between them so they arrive at a human pace and stay under the limit. - Talk to yourself on a quiet channel. Debug or diagnostic output that
only you need shouldn't go to public chat (channel 0) at all. Use
llOwnerSay(just you) or a private debug channel so you don't fill the room.
// ❌ Machine-guns 100 lines onto public chat as fast as the loop runs
integer i;
for (i = 0; i < 100; i = i + 1) llSay(0, "result " + (string)i);
// ✅ Same output, paced and private — readable, and never throttled
integer i;
for (i = 0; i < 100; i = i + 1) {
llOwnerSay("result " + (string)i); // quiet: only you see it
llSleep(0.2); // ~5/s, comfortably under the limit
}
llSleep (in TypeScript: await this.delay(seconds)) is
cooperative here — a script that pauses to pace itself does
not freeze any other script, yours or anyone else's. Unlike classic SL, where
llSleep stalls that script's whole VM, there's no penalty for being polite.
Sleep between bursts freely.
Selecting columns from a strided list
llList2ListSlice extracts one column from repeated list records.
For example, a menu can store alternating button labels and destinations:
list menu = ["Beach", "beach-id", "Plaza", "plaza-id"];
list labels = llList2ListSlice(menu, 0, -1, 2, 0);
list destinations = llList2ListSlice(menu, 0, -1, 2, 1);
A negative column counts from the end of a stride. Missing entries in a final partial record are omitted. This implementation follows the documented Second Life start-relative and exclusion-range behavior, which differs from the pinned OpenSim implementation. See the source comparison and edge-case contract.
Recompile the original LSL with a compiler containing this function; an already generated script may still contain the older unsupported-function fallback. Selection runs inside the script in both World and Glitch, without a server request. A merged compiler change does not establish which build the hosted editor has deployed.
6. Cross-object cascades
Multi-object kits (vendors, game tokens, HUDs) signal each other over channels and link messages. Make sure a "reset" or "acknowledge" can't re-trigger itself: a reset that causes a message that causes another reset is an infinite loop spread across prims. Give every cascade a clear stopping condition — a state flag, a sequence number, or a "only act if something actually changed" check.
What poqpoq does when a script runs away
poqpoq watches for scripts repeating the same action far faster than any sim intends. When it sees one, it:
- Throttles the runaway action so the world stays smooth for everyone.
- Tells the owner once (not once per occurrence) which script, which object, and which pattern — with a link back to this page.
- Leaves everything else running. Your other scripts, and other people's, are unaffected.
Your script and its state keep running untouched — nothing is deleted or stopped. Only the runaway output itself is held back (excess chat is dropped, a hammered notecard is served from cache). Fix the pattern, re-run the script, and the guard releases immediately. The goal isn't to police you; it's to keep one stuck script from slowing down the whole world while you sort it out.
Reading a number from text
An LSL (float) cast reads the numeric prefix of text:
(float)"1.5 meters" produces 1.5.
Ordinary text without a numeric prefix produces zero, so a cast alone
cannot tell you whether a person supplied a valid number.
Validate input before using it for a size, timer, or movement speed.
The updated compiler also accepts hexadecimal fractions and Mono-style
nan/inf prefixes, and rounds explicit float casts
to 32-bit precision. These special values are not suitable for ordinary
building dimensions. This compiler change is pending deployment; once
available, transpile and save existing LSL again to use it.
List extraction and general arithmetic have separate compatibility limits.
Checking a condition
LSL stores comparison results as integers. To inspect a condition while building, print its result in your touch handler:
integer enabled = (2 > 1);
llOwnerSay((string)enabled); // expected: 1
A false numeric comparison prints 0. If an older compiler
prints true or false, the comparison-value fix
requires an updated compiler and retranspiling your script. Deployment
of that fix is pending; saving old generated code alone does not update it.
TRUE and FALSE in saved values
LSL uses 1 for TRUE and 0 for
FALSE, including when you store them in a variable or list.
After the updated compiler is deployed, (string)TRUE prints
1, and list searches and numeric statistics treat it as one.
Transpile and save existing LSL again to receive this correction.
Native TypeScript APIs continue to use true and false.
Read a notecard from native TypeScript
Add a notecard named config to the same object as this script. This example reads at most 256 lines and prints them privately to the owner. A blank line is data. The read count and individual reads are separate operations, so editing Contents during the read can change the result; this is not an atomic snapshot.
Native reads return values through await. LSL llGetNotecardLine instead returns a query key and delivers text through dataserver. Do not assign native line text to a query-key field. The direct count returns -1 for unavailable content and zero for a present empty card; an older or unwired host rejects it. Legacy getNumberOfNotecardLines returns zero for unavailable content and can return undefined when the optional host does not answer.
This example requires the updated SDK/editor declarations and a host supporting direct notecard counts. If the editor reports a missing method, update the SDK/editor. A read failure requires checking the notecard and host integration. Publication of this guide does not update your runtime.
import { WorldScript } from "poqpoq/types";
/** Put a notecard named config in this object's Contents before running. */
export default class ReadConfig extends WorldScript {
async onStateEntry() {
try {
// Direct count distinguishes unavailable (-1) from a present empty card (0).
const count = await this.world.getNotecardLineCountDirect("config");
if (!Number.isInteger(count) || count < 0 || count > 256) {
this.ownerSay("Config unavailable or too large (maximum 256 lines).");
return;
}
for (let index = 0; index < count; index++) {
const line = await this.world.getNotecardLine("config", index);
if (typeof line !== "string") throw new Error("Notecard host did not answer.");
// EOF can occur if Contents change while the reads are in flight.
if (line === "\n\n\n") {
this.ownerSay("Config ended before the expected line count.");
return;
}
// An empty string is a real blank line, not EOF.
this.ownerSay(`${index}: ${line}`);
}
this.ownerSay(`Read ${count} config lines.`);
} catch {
this.ownerSay("Config read failed. Check the notecard and inventory host.");
}
}
}
See also
- Function Reference — every LSL function and its TypeScript equivalent
- Event Reference —
listen,dataserver,timer, and the rest - Getting Started — how LSL becomes TypeScript on poqpoq