skimi3d Grid Log in Visit the grid
All scripts
Signs and text

Skimi Events Beacon

Announce events on Skimi Grid with human review, and display live event countdowns.

Goes ina prim
Setup1 to set
Code350 lines
Open toOpen to everyone

What it does

This beacon lets you submit event details (name, description, category, time, duration) through a guided menu. Your submission goes to Skimi Grid for human review, and once approved, appears in the grid's event search. The beacon also polls the grid for upcoming events at its location and displays a visual countdown timer with glowing effects while events are live.

What you can build with it

You can build an event management system where beacons placed at event venues automatically advertise to a grid-wide audience, or create a grid-wide event board that shows visitors what is happening nearby and across the entire grid.

How to use it

1 Rez the beacon at your event location.
2 Touch it and select 'New Event' to open a menu-driven form (name, description, category, rating, start time, duration).
3 Submit your event for review; once approved it appears in Skimi Grid search and on the web.
4 The beacon will glow and show a countdown before and during your event; visitors can touch it to see what's on grid-wide.

Settings you can change

1

Edit these at the top of the script. Everything works on the defaults · change them only when you need to.

API The base URL of the Skimi Grid API endpoint (default: https://grid.skimi3d.com/api) ·
The code LSL · 350 lines · updated 28 Jul 2026 Download .lsl
// Skimi Events Beacon v1.0.2
// grid.skimi3d.com  -  announce your event on Skimi Grid, from any grid.
//
// Rez this beacon where your event happens, touch it, follow the menus.
// Your event is submitted for review; once approved it appears in the
// in-world viewer search (Search > Events) on Skimi Grid and on
// https://grid.skimi3d.com/events
//
// SECURITY BY DESIGN: this script holds NO secrets. It talks to a public
// API; the beacon receives its own access token at runtime when it
// registers, and every submission is reviewed by a human before it goes
// live. Copying or opening this script reveals nothing you could abuse.
//
// The beacon also shows a countdown before your approved event and lights
// up while it is running. Visitors can touch it to see what's on grid-wide.

string API = "https://grid.skimi3d.com/api";

// ---- persistent state (survives script reset via object description) ----
string gToken;      // runtime-issued access token
string gGridUrl;    // empty = beacon stands on Skimi Grid itself

// ---- draft of the event being entered ----
string dName; string dDesc; integer dCat; string dRating; string dStart; integer dDur;

// ---- runtime ----
integer gChan; integer gListen; string gStep;
key reqRegister; key reqSubmit; key reqList; key reqHere;
key gToucher; string gOwnerName; integer gPendingNew;
string nextName; integer nextStart; integer nextDur; integer gLive; integer gTick;

list CAT_LABELS = ["Discussion", "Sports", "Live Music", "Commercial",
    "Nightlife/Entertainment", "Games/Contests", "Pageants", "Education",
    "Arts and Culture", "Charity/Support Groups", "Miscellaneous"];
list CAT_IDS = [18, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29];

saveState() { llSetObjectDesc("SEB1|" + gToken + "|" + gGridUrl); }

loadState()
{
    list p = llParseStringKeepNulls(llGetObjectDesc(), ["|"], []);
    if (llList2String(p, 0) == "SEB1") {
        gToken = llList2String(p, 1);
        gGridUrl = llList2String(p, 2);
    }
}

openListen() { llListenRemove(gListen); gListen = llListen(gChan, "", NULL_KEY, ""); }

dialog(key who, string text, list buttons) { openListen(); llDialog(who, text, buttons, gChan); }

textbox(key who, string text) { openListen(); llTextBox(who, text, gChan); }

register()
{
    string json = llList2Json(JSON_OBJECT, [
        "owner_uuid", (string)llGetOwner(),
        "owner_name", gOwnerName,
        "region", llGetRegionName(),
        "grid_url", gGridUrl]);
    reqRegister = llHTTPRequest(API + "/beacon/register",
        [HTTP_METHOD, "POST", HTTP_MIMETYPE, "application/json"], json);
}

submitEvent()
{
    vector p = llGetPos();
    string json = llList2Json(JSON_OBJECT, [
        "token", gToken,
        "name", dName,
        "description", dDesc,
        "category", dCat,
        "rating", dRating,
        "start", dStart,
        "duration", dDur,
        "region", llGetRegionName(),
        "pos", (string)((integer)p.x) + "," + (string)((integer)p.y) + "," + (string)((integer)p.z),
        "grid_url", gGridUrl]);
    reqSubmit = llHTTPRequest(API + "/beacon/event",
        [HTTP_METHOD, "POST", HTTP_MIMETYPE, "application/json"], json);
}

pollHere()
{
    if (gToken == "") return;
    vector p = llGetPos();
    reqHere = llHTTPRequest(API + "/beacon/here?token=" + llEscapeURL(gToken)
        + "&region=" + llEscapeURL(llGetRegionName())
        + "&pos=" + llEscapeURL((string)((integer)p.x) + "," + (string)((integer)p.y)),
        [HTTP_METHOD, "GET"], "");
}

setIdle()
{
    gLive = FALSE;
    llSetText("", <1,1,1>, 1.0);
    llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_GLOW, ALL_SIDES, 0.0]);
    llParticleSystem([]);
}

showCountdown()
{
    if (nextStart == 0) { setIdle(); return; }
    integer now = llGetUnixTime();
    integer endT = nextStart + nextDur * 60;
    if (now > endT) { nextStart = 0; setIdle(); return; }
    if (now >= nextStart) {
        if (!gLive) {
            gLive = TRUE;
            llSetLinkPrimitiveParamsFast(LINK_THIS, [PRIM_GLOW, ALL_SIDES, 0.25]);
            llParticleSystem([PSYS_PART_FLAGS, PSYS_PART_EMISSIVE_MASK | PSYS_PART_INTERP_SCALE_MASK,
                PSYS_SRC_PATTERN, PSYS_SRC_PATTERN_ANGLE_CONE,
                PSYS_PART_START_COLOR, <0.6, 1.0, 0.9>, PSYS_PART_START_ALPHA, 0.6,
                PSYS_PART_START_SCALE, <0.08, 0.08, 0>, PSYS_PART_END_SCALE, <0.02, 0.02, 0>,
                PSYS_PART_MAX_AGE, 2.5, PSYS_SRC_BURST_RATE, 0.25,
                PSYS_SRC_BURST_PART_COUNT, 2, PSYS_SRC_BURST_RADIUS, 0.1,
                PSYS_SRC_BURST_SPEED_MIN, 0.2, PSYS_SRC_BURST_SPEED_MAX, 0.5,
                PSYS_SRC_ANGLE_BEGIN, 0.0, PSYS_SRC_ANGLE_END, 0.6]);
        }
        llSetText(nextName + "\nhappening NOW", <0.55, 1.0, 0.85>, 1.0);
        return;
    }
    if (gLive) setIdle();
    integer mins = (nextStart - now) / 60;
    string t;
    if (mins >= 120) t = (string)(mins / 60) + " hours";
    else if (mins >= 60) t = "1 hour " + (string)(mins % 60) + " min";
    else t = (string)mins + " min";
    llSetText(nextName + "\nstarts in " + t, <1.0, 0.95, 0.7>, 1.0);
}

mainMenu(key who)
{
    if (who == llGetOwner())
        dialog(who, "Skimi Events Beacon\n\nAnnounce an event at this spot, or see what's on across Skimi Grid.",
            ["New Event", "What's On", "Help"]);
    else {
        gToucher = who;
        reqList = llHTTPRequest(API + "/events/upcoming?format=txt&limit=8", [HTTP_METHOD, "GET"], "");
    }
}

startFlow(key who)
{
    if (gToken == "") {
        gPendingNew = TRUE;
        llOwnerSay("One moment, the beacon is registering itself...");
        register();
        return;
    }
    gStep = "name";
    textbox(who, "Event name?\n(short and catchy, this is the line people see in search)");
}

default
{
    state_entry()
    {
        gChan = -100000 - (integer)llFrand(1000000.0);
        loadState();
        llRequestAgentData(llGetOwner(), DATA_NAME);
        llSetTimerEvent(30.0);
        gTick = 18;   // erste Standort-Abfrage kurz nach dem Start
    }

    on_rez(integer p) { llResetScript(); }

    changed(integer c) { if (c & (CHANGED_REGION | CHANGED_OWNER)) llResetScript(); }

    dataserver(key q, string data)
    {
        gOwnerName = data;
        if (gToken == "") register();
    }

    touch_start(integer n) { mainMenu(llDetectedKey(0)); }

    listen(integer chan, string name, key who, string msg)
    {
        llListenRemove(gListen);
        if (who != llGetOwner()) return;

        if (gStep == "gridurl") {
            string a = llStringTrim(msg, STRING_TRIM);
            // Auf die Menue-Woerter darf diese Frage nicht reagieren, sonst
            // landet "New Event" als Grid-Adresse in der Datenbank.
            if (a == "Cancel" || a == "New Event" || a == "What's On" || a == "Help") {
                gStep = "";
                llOwnerSay("No problem · touch me again when you have the address.");
                return;
            }
            // Eine Adresse hat einen Punkt und kein Leerzeichen. Alles andere ist
            // vermutlich versehentlich hier gelandet.
            if (llSubStringIndex(a, ".") == -1 || llSubStringIndex(a, " ") != -1) {
                llOwnerSay("\"" + a + "\" does not look like a grid address. It is the part your "
                    + "viewer shows in the address bar, like yourgrid.example.com:8002 · touch me to try again.");
                gStep = "";
                return;
            }
            gGridUrl = a;
            saveState();
            register();
            llOwnerSay("Thanks. Registering with your grid address...");
            gStep = "";
            return;
        }
        if (msg == "Cancel") { gStep = ""; llOwnerSay("Cancelled."); return; }
        if (msg == "Help") {
            llOwnerSay("Rez this beacon where your event happens and touch it to announce the event."
                + " A human reviews every submission; approved events show up in Search > Events on Skimi Grid"
                + " and on https://grid.skimi3d.com/events . The beacon shows a countdown and lights up"
                + " while your event runs. Visitors who touch it see what's on.");
            return;
        }
        if (msg == "New Event") { startFlow(who); return; }
        if (msg == "What's On") {
            gToucher = who;
            reqList = llHTTPRequest(API + "/events/upcoming?format=txt&limit=8", [HTTP_METHOD, "GET"], "");
            return;
        }

        if (gStep == "name") {
            dName = llGetSubString(msg, 0, 199);
            gStep = "desc";
            textbox(who, "Describe your event.\n(what happens, who is it for?)");
        }
        else if (gStep == "desc") {
            dDesc = llGetSubString(msg, 0, 1999);
            gStep = "cat";
            dialog(who, "Pick a category:", CAT_LABELS + ["Cancel"]);
        }
        else if (gStep == "cat") {
            integer i = llListFindList(CAT_LABELS, [msg]);
            if (i == -1) { gStep = ""; return; }
            dCat = llList2Integer(CAT_IDS, i);
            gStep = "rating";
            dialog(who, "Content rating?", ["General", "Moderate", "Adult"]);
        }
        else if (gStep == "rating") {
            dRating = llGetSubString(msg, 0, 0);   // G / M / A
            gStep = "start";
            dialog(who, "When does it start?\n(times are grid time, PT)",
                ["In 1 hour", "In 2 hours", "In 3 hours",
                 "Tonight 8 PM", "Tomorrow noon", "Tomorrow 8 PM",
                 "Custom", "Cancel"]);
        }
        else if (gStep == "start") {
            if (msg == "In 1 hour") dStart = "rel:60";
            else if (msg == "In 2 hours") dStart = "rel:120";
            else if (msg == "In 3 hours") dStart = "rel:180";
            else if (msg == "Tonight 8 PM") dStart = "today:20";
            else if (msg == "Tomorrow noon") dStart = "tomorrow:12";
            else if (msg == "Tomorrow 8 PM") dStart = "tomorrow:20";
            else if (msg == "Custom") {
                gStep = "custom";
                textbox(who, "Start date and time in grid time (PT), format:\nYYYY-MM-DD HH:MM\n(24h or with AM/PM, e.g. 2026-07-23 2:00 PM)");
                return;
            }
            else { gStep = ""; return; }
            gStep = "dur";
            dialog(who, "How long does it run?", ["30 min", "1 hour", "2 hours", "3 hours"]);
        }
        else if (gStep == "custom") {
            dStart = "abs:" + llStringTrim(msg, STRING_TRIM);
            gStep = "dur";
            dialog(who, "How long does it run?", ["30 min", "1 hour", "2 hours", "3 hours"]);
        }
        else if (gStep == "dur") {
            if (msg == "30 min") dDur = 30;
            else if (msg == "1 hour") dDur = 60;
            else if (msg == "2 hours") dDur = 120;
            else if (msg == "3 hours") dDur = 180;
            else { gStep = ""; return; }
            gStep = "";
            llOwnerSay("Submitting \"" + dName + "\" for review...");
            submitEvent();
        }
    }

    http_response(key req, integer code, list meta, string body)
    {
        if (req == reqRegister) {
            // Achtung LSL-Eigenheit: JSON-Booleans kommen als JSON_TRUE/JSON_FALSE-Konstanten,
            // nie als String "true". Erfolg deshalb am Token festmachen.
            string tok = llJsonGetValue(body, ["token"]);
            if (code == 200 && tok != JSON_INVALID && tok != "") {
                gToken = tok;
                saveState();
                if (llJsonGetValue(body, ["need_grid"]) == JSON_TRUE) {
                    gStep = "gridurl";
                    textbox(llGetOwner(),
                        "This beacon is standing on a grid I do not know yet.\n\nWhat is THIS grid's address? It is what your viewer shows in the address bar, for example:\n  yourgrid.example.com:8002\n\n(This is asked once. It is not the event name.)");
                    return;
                }
                // Sagen, dass es geklappt hat · sonst steht man da und raet.
                if (gGridUrl != "") {
                    llOwnerSay("Registered. This beacon is linked to " + gGridUrl
                        + " · events from here will point back to your grid. Touch me to announce one.");
                } else {
                    llOwnerSay("Registered with Skimi Grid. Touch me to announce an event.");
                }
                showCountdown();
                if (gPendingNew) { gPendingNew = FALSE; startFlow(llGetOwner()); }
            }
            else llOwnerSay("Could not reach Skimi Grid right now. Touch the beacon to try again later.");
        }
        else if (req == reqSubmit) {
            string m = llJsonGetValue(body, ["message"]);
            if (m == JSON_INVALID) m = "";
            if (code == 200 && llJsonGetValue(body, ["id"]) != JSON_INVALID)
                llOwnerSay("Done. " + m);
            else if (m != "")
                llOwnerSay("Not accepted: " + m);
            else
                llOwnerSay("Something went wrong (" + (string)code + "). Please try again.");
        }
        else if (req == reqList) {
            if (code != 200 || llStringTrim(body, STRING_TRIM) == "") {
                llRegionSayTo(gToucher, 0, "Nothing on the grid calendar right now. Announce something at https://grid.skimi3d.com/events");
                return;
            }
            llRegionSayTo(gToucher, 0, "What's on across Skimi Grid:");
            list lines = llParseString2List(body, ["\n"], []);
            integer i;
            for (i = 0; i < llGetListLength(lines); i++) {
                list f = llParseStringKeepNulls(llList2String(lines, i), ["|"], []);
                llRegionSayTo(gToucher, 0, "* " + llList2String(f, 0) + " | " + llList2String(f, 1)
                    + " | " + llList2String(f, 2) + " | " + llList2String(f, 3));
            }
        }
        else if (req == reqHere) {
            if (code == 200 && body != "none" && llSubStringIndex(body, "|") != -1) {
                list f = llParseStringKeepNulls(body, ["|"], []);
                nextName = llList2String(f, 0);
                nextStart = (integer)llList2String(f, 1);
                nextDur = (integer)llList2String(f, 2);
            }
            else nextStart = 0;
            showCountdown();
        }
    }

    timer()
    {
        // alle 30 s Anzeige lokal aktualisieren, alle 10 min den Server fragen
        gTick++;
        if (gTick >= 20) { gTick = 0; pollHere(); }
        else showCountdown();
    }
}

Copy it, make a new script inworld, paste over what is there and save. Nothing here phones home.