The library lljson

Quick start

If you want to store tables in linkset data or send tables to another script with linked messages, reading this section is enough:

We can encode any kind of table with its nested tables, no matter how complex the structure is, in a string and decode the string to the same table with:

  • to encode: myString = lljson.slencode(myTab)
  • to decode: myTab = lljson.sldecode(myString)

Example storing a table in linkset data:

-- storing a table in linkset data
local resort = {
    name = "Sunny Sands",
    activities = {"Sunbathing", "Jet skiing", "Frisbee competitions"},
    rating = 4.2,
    open = true,
    tips = {"Bring sunscreen", "Smile for photos", "Don't feed the seagulls"}
}

-- writing
ll.LinksetDataWrite("resort", lljson.slencode(resort))

-- reading
resort = lljson.sldecode(ll.LinksetDataRead("resort"))

-- checking that it has worked
for k, v in resort do
    print(k, if type(v) == "table" then table.concat(v, ", ") else v)
end
-- > name        Sunny Sands
-- > activities  Sunbathing, Jet skiing, Frisbee competitions
-- > rating      4.2
-- > open        true
-- > tips        Bring sunscreen, Smile for photos, Don't feed the seagulls

Example sending a table in a linked message:

-- script1 - sending a table with a linked message
local resort = {
    name = "Sunny Sands",
    activities = {"Sunbathing", "Jet skiing", "Frisbee competitions"},
    rating = 4.2,
    open = true,
    tips = {"Bring sunscreen", "Smile for photos", "Don't feed the seagulls"}
}

LLEvents:on("touch_start", function(events)
    -- sending
    ll.MessageLinked(LINK_THIS, 0, lljson.slencode(resort), "")
end)
-- script2 - receiving a table with a linked message
local resort = {}

LLEvents:on("link_message", function(sender_num, num, str, id)
    -- receiving
    resort = lljson.sldecode(str)
	
    -- checking that it has worked
    for k, v in resort do
        print(k, if type(v) == "table" then table.concat(v, ", ") else v)
    end
	-- > name        Sunny Sands
	-- > activities  Sunbathing, Jet skiing, Frisbee competitions
	-- > rating      4.2
	-- > open        true
	-- > tips        Bring sunscreen, Smile for photos, Don't feed the seagulls
end)

There is much more about the lljson library, go on reading…

What is JSON?

JSON, short for JavaScript Object Notation, is a data format used to store and exchange information.
It is simple and readable, using a clean, minimal structure that is easy to understand. It’s widely supported. It’s supported by nearly all programming languages and tools, making it ideal for exchanging data between systems.

SLua tables fit well in the JSON format, making the conversion much easier than in LSL.

JSON is useful, in general, to exchange complex multilevel structured data between:

  • external resources, using http.
  • scripts, using linked messages.
  • objects, using say and listen.
  • Linkset Data storage.

JSON organizes data into two basic structures:

  • Arrays: list of values, like the array tables in SLua:
    • [ “apple”, “banana”, “cherry” ]
    • The values are separated by commas (,) and the entire array is enclosed in brackets ([ and ]).
  • Objects: collections of (key, value) pairs, like the dictionary tables in SLua:
    • { “type” : “dog” , “name” : “Dufa”, “color” : “white” }
    • Keys and values are separated by colons (:), pairs are separated by commas (,), and the entire object is enclosed in curly braces ({ and }).
    • Keys must be strings.

There are 4 data types:

  • String: text enclosed in double quotes (“hello”).
  • Number: Numbers, either integers (42) or decimals (3.14).
  • Boolean: true or false.
  • Null: null, a special value indicating no data.

Objects and arrays can contain a mix of these types.
They also can contain other objects and arrays, in any depth and complexity.
We can have arrays of objects with keys that are arrays with more arrays and with more objects, etc. Up to 100 levels of depth.

Library lljson

The functions to work with JSON have their own library: lljson.

There are two pairs of functions:

  • lljson.encode() / lljson.decode() :
    • They generate or read standard JSON
    • They are useful to exchange data with external resources and other scripting languages.
    • SLua types without a JSON equivalent are encoded as strings.
  • lljson.slencode() / lljson.sldecode() :
    • They generate non-standard JSON-like code.
    • They are useful to exchange code with other scripts or objects or to store in linkset data.
    • They are decoded into the original SLua types.

encode()

It takes an SLua table and generates standard JSON to send to an external website or for use with another scripting language.

To work in SLua scripts with JSON strings that can be encoded and decoded as the same table use slencode()/sldecode().

-- array table, encodes to a JSON array
local fruits = { "apples", "bananas", "oranges" }
print(lljson.encode(fruits))
-- > ["apples","bananas","oranges"]
-- dictionary table, encodes to a JSON object
local fruitsQuantity = { Apple = 50, Banana = 30, Cherry = 20, Orange = 15 }
print(lljson.encode(fruitsQuantity))
-- > {"Apple":50,"Cherry":20,"Orange":15,"Banana":30}

encode() has a second parameter which is a table with named parameters.

  • allow_sparse: boolean, false by default, if true all sparse arrays are encoded as arrays, no matter how sparse they are, instead of throwing a “excessively sparse array” error.
  • skip_tojson: boolean, false by default, if true the metamethod __tojson is not called.
  • replacer: call back function used to transform values during encoding.
local myJson = lljson.encode(myTab, { allow_sparse = true, skip_tojson = true, replacer = myReplacerFunc })

Datatypes mapping with lljson.encode():

SLua type JSON type format
nil null
lljson.null null
boolean boolean
number number
string string
array table array []
dictionary table object {} keys encoded as strings
vector string "<25,50,10>"
rotation/quaternion string "<0.5,0.25,0,1>"
uuid string
buffer string encoded as base64 string
function run-time error
thread (coroutine) run-time error

lljson.null

lljson.null is a constant in the lljson library.
We can use it in dictionary tables when we want to export a key that has no value.

-- dictionary table (with null keys), encodes to a JSON object
local fruitsQuantity = { Apple = 50, Banana = 30, Cherry = 20, Orange = 15, Kiwi = lljson.null }
print(lljson.encode(fruitsQuantity))
-- > {"Kiwi":null,"Apple":50,"Cherry":20,"Orange":15,"Banana":30}

Empty tables

Empty tables are exported as arrays:

-- empty table as JSON array
local tab = {}
print(lljson.encode(tab))
--> []

We can use the table lljson.empty_object to generate an empty JSON object:

-- empty table as JSON object
local tab = lljson.empty_object
print(lljson.encode(tab))
--> {}

Sparse arrays

Moderately sparse arrays are exported as JSON objects.
nil values are exported as null.

-- sparse array as JSON array
local vegetables = { "Carrot", "Tomato", "Potato", "Onion", "Lettuce" }
vegetables[4] = nil
print(lljson.encode(vegetables))
-- > ["Carrot","Tomato","Potato",null,"Lettuce"]

If there are more nils than elements and the last index is bigger than 10, it throws the run-time error “Cannot serialise table: excessively sparse array”:

-- sparse array as JSON array, up to index 10 it works no matter how many nils
local tab = {}
tab[10] = "value 10"
print(lljson.encode(tab))
-- > [null,null,null,null,null,null,null,null,null,"value 10"]
tab[11] = "value 11"
print(lljson.encode(tab))
-- > Cannot serialise table: excessively sparse array
-- sparse array as JSON array, it works if there aren't more nils than values
local tab = {}
for i = 1, 15, 2 do
    tab[i] = "value " .. i
end
print(lljson.encode(tab))
-- > ["value 1",null,"value 3",null,"value 5",null,"value 7",null,"value 9",null,"value 11",null,"value 13",null,"value 15"]
tab[20] = "value 20"
print(lljson.encode(tab))
-- > Cannot serialise table: excessively sparse array

We can export a very sparse array with any number of nils as a JSON array passing the parameter allow_sparse = true:

-- very sparse array as JSON array, with allow_sparse = true
local tab = {}
tab[15] = "value 15"
print(lljson.encode(tab, { allow_sparse = true }))
-- > [null,null,null,null,null,null,null,null,null,null,null,null,null,null,"value 15"]

We can export a very sparse array as a JSON object with the lljson table object_mt as its metatable:

-- very sparse array as JSON object, with object_mt
local tab = {}
setmetatable(tab, lljson.object_mt)
tab[15] = "value 15"
print(lljson.encode(tab))
-- > {"15":"value 15"}

Mixed tables

Mixed tables are exported as JSON objects.
Numeric and uuid keys are exported as strings:

-- mixed table to JSON object with string keys
local vegetables = { "Carrot", "Tomato", "Potato", Lettuce = "favorite" }
print(lljson.encode(vegetables))
-- > {"1":"Carrot","2":"Tomato","3":"Potato","Lettuce":"favorite"}

Dictionary tables

Keys with data types other than strings, numbers and uuids throw the run-time error “Cannot serialise userdata: table key must be a number or string”:

-- dictionary table with other data types
local staff = { [vector(20, 50, 10)] = true }
print(lljson.encode(staff))
-- > Cannot serialise vector: table key must be a number, string, or uuid

inf and -inf

inf and -inf are exported as numbers with values 1e9999 and -1e9999:

-- inf and -inf to numbers
local bigNumbers = { math.huge, -math.huge }
print(lljson.encode(bigNumbers))
-- > [1e9999,-1e9999]

nan

nan is exported as null.

-- nan to null
local puffedNumbers = { 0/0 }
print(lljson.encode(puffedNumbers))
-- > [null]

Special characters

Characters with ASCII codes from 0 to 31 are encoded as JSON unicode:

-- special characters to JSON unicode
local idBytes = ll.GetOwner().bytes
print(idBytes)
-- > ??8NK_?Έm?;?A
print(lljson.encode(idBytes))
-- > "\u000f\u0016??8NK_?Έm?;?A"

The cursor control characters have their own escape codes:

-- cursor control characters to JSON escape codes
local s = string.char(8, 9 ,10, 12, 13)
print(lljson.slencode(s))
-- > "\b\t\n\f\r"

Indexing (0 vs 1)

JSON arrays do not store explicit index values in the file. They only define an ordered list of elements.

  • When a programming language decodes a JSON array received from SLua, it assigns indices according to its own array indexing rules. Most languages use 0-based indexing, so decoded arrays start at index 0.
  • When SLua decodes a JSON array received from an external source, it creates SLua tables using 1-based indexing, so the first element starts at index 1.

Examples

A longer example:

local shelter = {
    -- General info about the shelter
    info = {
        name = "Happy Tails Shelter",
        location = "Riverdale",
        open_hours = { "Mon-Fri", "10:00-18:00" },
        staff = {
            manager = "Alice",
            volunteers = { "Bob", "Clara", "Dylan" }
        }
    },
    -- Pets section: dictionary of species
    pets = {
        dogs = {
            count = 8,
            breeds = { "Labrador", "Beagle", "Poodle" },
            vaccinated = true
        },
        cats = {
            count = 6,
            breeds = { "Siamese", "Maine Coon" },
            vaccinated = true
        },
        ["tropical fish"] = {
            count = 15,
            species = { "Guppy", "Goldfish", "Betta" },
            vaccinated = false
        }
    },
    -- Adoption records (array of dictionaries)
    adoptions = {
        { pet = "dog", adopter = "Emma", date = "2025-10-01" },
        { pet = "cat", adopter = "Lucas", date = "2025-10-12" }
    }
}

print(lljson.encode(shelter))

There are several websites where you can copy-paste JSON and show it well formatted, like https://jsonlint.com/.

Generated JSON:

{
  "pets": {
    "dogs": {
      "breeds": [
        "Labrador",
        "Beagle",
        "Poodle"
      ],
      "count": 8,
      "vaccinated": true
    },
    "tropical fish": {
      "vaccinated": false,
      "species": [
        "Guppy",
        "Goldfish",
        "Betta"
      ],
      "count": 15
    },
    "cats": {
      "breeds": [
        "Siamese",
        "Maine Coon"
      ],
      "count": 6,
      "vaccinated": true
    }
  },
  "info": {
    "location": "Riverdale",
    "open_hours": [
      "Mon-Fri",
      "10:00-18:00"
    ],
    "name": "Happy Tails Shelter",
    "staff": {
      "volunteers": [
        "Bob",
        "Clara",
        "Dylan"
      ],
      "manager": "Alice"
    }
  },
  "adoptions": [
    {
      "date": "2025-10-01",
      "pet": "dog",
      "adopter": "Emma"
    },
    {
      "date": "2025-10-12",
      "pet": "cat",
      "adopter": "Lucas"
    }
  ]
}

An example with SLua data types:

local build = {
    build_id = uuid("a1b2c3d4-1111-2222-3333-abcdefabcdef"),
    info = {
        name = "Beach Hangout",
        owner = uuid("9f8e7d6c-5555-4444-3333-bbbbbbbbbbbb"),
        category = "Social"
    },
    props = {
        {
            name = "Palm Tree",
            position = vector(128, 64, 22.5),
            orientation = rotation(0, 0, 0, 1)
        },
        {
            name = "Campfire",
            position = vector(130.5, 63.2, 21.8),
            orientation = rotation(0, 0.3827, 0, 0.9239),
            effects = {
                sound = "fire-crackle",
                color = vector(1, 0.5, 0.2)
            }
        }
    }
}

print(lljson.encode(build))

Generated JSON:

{
  "props": [
    {
      "orientation": "<0,0,0,1>",
      "name": "Palm Tree",
      "position": "<128,64,22.5>"
    },
    {
      "orientation": "<0,0.3827,0,0.9239>",
      "effects": {
        "sound": "fire-crackle",
        "color": "<1,0.5,0.2>"
      },
      "name": "Campfire",
      "position": "<130.5,63.2,21.8>"
    }
  ],
  "info": {
    "owner": "9f8e7d6c-5555-4444-3333-bbbbbbbbbbbb",
    "name": "Beach Hangout",
    "category": "Social"
  },
  "build_id": "a1b2c3d4-1111-2222-3333-abcdefabcdef"
}

decode()

It takes standard JSON received from an external website and generates an SLua table.

To work in SLua scripts with JSON strings that can be encoded and decoded as the same table use slencode()/sldecode().

An example sending a request to a website that returns JSON data containing a random quote:

local url  = "https://zenquotes.io/api/random"

LLEvents:on("touch_start", function(events)
    ll.HTTPRequest(url, {}, "")
end)

LLEvents:on("http_response", function(request_id, status, metadata, body)
    if status == 200 then
        print("json: ", body)
        local quote = lljson.decode(body)
        print("quote:", quote[1].q)
        print("author:", quote[1].a)
    else
        print("Request failed.")
    end
end)

The received JSON:

[
  {
    "q": "Remember that sometimes not getting what you want is a wonderful stroke of luck.",
    "a": "Dalai Lama",
    "h": "
“Remember that sometimes not getting what you want is a wonderful stroke of luck.” —
Dalai Lama
" } ]

decode() has a second parameter which is a table with named parameters.

  • reviver: call back function used to transform values after they are parsed.
  • track_path: boolean, false by default, if true the reviver function will be passed an array table representing the traversal path across the nested tables from the root to the current value, allowing the reviver to know where it is in the tree.
local myTab = lljson.decode(myJson, { reviver = myReviverFunc, track_path = true })

Datatypes mapping with lljson.decode():

JSON type SLua type
null lljson.null
boolean boolean
number number
string string
array [] table
object {} table

All tables are decoded with its metatable set to array_mt or object_mt depending on the JSON type.

This is useful in case of re-encoding the data or to know the JSON type in a reviver function.

lljson.null

JSON nulls are decoded to lljson.null to preserve the keys with null values from JSON objects. These keys would disappear decoding nulls to nil.

Example

An example with encode() and decode(), sending a JSON to a website that returns the same JSON and information about the headers of the request:

local url = "https://postman-echo.com/post"

local function send()
    local sending = { task = "test", value = 123, object_name = ll.GetObjectName()}
    local json = lljson.encode(sending)
    ll.HTTPRequest(url, {HTTP_METHOD, "POST", HTTP_MIMETYPE, "application/json"}, json)
end

local function receive(json)
    local receiving = lljson.decode(json)
    for k, v in receiving do
        if type(v) == "table" then
            print(k)
            for k, v in v do
                print(" ", k, v)
            end
        else
            print(k, v)
        end
    end
end

LLEvents:on("http_response", function(request_id, status, metadata, body)
    if status == 200 then
        receive(body)
    else
        print("Request failed.")
    end
end)

send()

The received JSON:

{
  "args": {},
  "data": {
    "value": 123,
    "task": "test",
    "object_name": "JSON testing object"
  },
  "files": {},
  "form": {},
  "headers": {
    "host": "postman-echo.com",
    "x-secondlife-local-velocity": "(0.000000, 0.000000, 0.000000)",
    "accept-encoding": "gzip, br",
    "x-secondlife-local-position": "(104.017860, 190.604568, 22.250095)",
    "x-forwarded-proto": "https",
    "content-type": "application/json",
    "x-secondlife-region": "SLua Beta Nicoise (255744, 253952)",
    "x-secondlife-local-rotation": "(0.000000, 0.000000, 0.000000, 1.000000)",
    "x-secondlife-object-key": "db02d587-2bf9-3322-007b-5d80cf3f43fc",
    "x-secondlife-owner-key": "0f16c0e1-384e-4b5f-b7ce-886dda3bce41",
    "accept": "text/*, application/xhtml+xml, application/atom+xml, application/json, application/xml, application/llsd+xml, application/x-javascript, application/javascript, application/x-www-form-urlencoded, application/rss+xml",
    "content-length": "63",
    "x-secondlife-shard": "Production",
    "cache-control": "no-cache, max-age=0",
    "accept-charset": "utf-8;q=1.0, *;q=0.5",
    "user-agent": "Second-Life-LSL/2026-01-06.20757451310 (https://secondlife.com)",
    "x-secondlife-owner-name": "SuzannaLinn Resident",
    "pragma": "no-cache",
    "x-secondlife-object-name": "JSON testing object"
  },
  "json": {
    "value": 123,
    "task": "test",
    "object_name": "JSON testing object"
  },
  "url": "https://postman-echo.com/post"
}

replacer / reviver

Replacer

A replacer is a callback function that allows us to intercept and modify values during the serialization process. It gives us fine-grained control over the final JSON output, making it ideal for filtering data, formatting custom types, or censoring sensitive information.

replacer(key, value, parent): callback function to modify the contents before encoding them with encode() or slencode().

Parameters:

  • key: The key or index of the value being processed. The key is nil for the root value.
  • value: The value associated with the key.
  • parent: The table that contains the current value. The parent is nil for the root value.

Return value: The value we return from the replacer determines what gets written to the JSON string.

  • any value: The returned value will be serialized in place of the original value. This is used for transformation.
  • the constant lljson.remove: This constant instructs the encoder to completely omit the key-value pair from the final JSON. This is used for filtering.
  • nil: it’s a valid value to return. It will be serialized as null in encode() or “!n” in slencode.

A return value of lljson.remove for the root value evaluates to lljson.null and will be serialized as null.

If a table has a __tojson metamethod, it is called before passing the values to the replacer function.

Reviver

A reviver is a callback function that lets us inspect and transform data as it is being parsed from a JSON string. It is called for every key-value pair after it has been parsed but before it’s added to its parent container. This is useful for “reviving” data into custom types (like dates or vectors) or restructuring the data on the fly.

reviver(key, value, parent, ctx): callback function to modify the contents while decoding them with decode() or sldecode().

Parameters:

  • key: The key or index of the value being processed. The key is nil for the root value.
  • value: The value that was just parsed from the JSON.
  • parent: The table that the value will be placed into. The parent is nil for the root value.
  • ctx: A table containing metadata about the parse operation.
  • ctx.path: A table representing the traversal path to the current value. For a value at data.users[20], the path would be {“data”, “users”, 20}. This is only populated if we enable it with the { track_path = true } option.

Return value: The value we return from the reviver determines what gets written to the tables.

  • any value: The returned value will be inserted into the parent table instead of the original parsed value.
  • the constant lljson.remove: This constant prevents the value from being added to its parent. In an array the element is skipped, and subsequent elements are shifted down to fill the gap, resulting in a shorter array.

A return value of lljson.remove for the root value will return lljson.null.

The reviver processes nested structures from the inside out. The children of an object or array are revived before the parent object or array itself is revived. This means when we are processing a table, all of its nested tables have already been transformed by the reviver.

In sldecode() the reviver receives keys and values already converted to SLua datatypes from the internal format.

Example with replacer and reviver

This example converts datetimes stored as timestamps (returned by os.time()) into JSON objects with “date” and “hour”. The keys containing a timestamp are identified by their names starting with “time”.

-- a table for the replacer/reviver example with 5 timestamps
local shelter = {
    name = "Happy Tails",
    timeCreated = 1770112800,    
    animals = {
        {
            type = "dog",
            name = "Buddy",
            health = {
                vaccinated = true,
                timeLastCheckup = 1772275380 
            },
            adoption = {
                available = true,
                timeListed = 1771158400 
            }
        },
        {
            type = "cat",
            name = "Whiskers",
            health = {
                vaccinated = true,
                timeLastCheckup = 1772372000 
            },
            adoption = {
                available = false,
                timeAdopted = 1771658400 
            }
        }
    }
}
-- Converts numeric fields starting with "time" into a { date, hour } table.
local function timeReplacer(key, value, parent)
    if type(key) == "string" and string.sub(key, 1, 4) == "time" and type(value) == "number" then
        local d = os.date("!*t", value)
        return {
            date = string.format("%04d-%02d-%02d", d.year, d.month, d.day),
            hour = string.format("%02d:%02d:%02d", d.hour, d.min, d.sec)
        }
    end
    return value
end

local jsonShelter = lljson.encode(shelter, { replacer = timeReplacer })

Resulting JSON:

{
  "animals": [
    {
      "health": {
        "vaccinated": true,
        "timeLastCheckup": { "date": "2026-02-28", "hour": "10:43:00" }
      },
      "type": "dog",
      "name": "Buddy",
      "adoption": {
        "timeListed": { "date": "2026-02-15", "hour": "12:26:40" },
        "available": true
      }
    },
    {
      "health": {
        "vaccinated": true,
        "timeLastCheckup": { "date": "2026-03-01", "hour": "13:33:20" }
      },
      "type": "cat",
      "name": "Whiskers",
      "adoption": {
        "timeAdopted": { "date": "2026-02-21", "hour": "07:20:00" },
        "available": false
      }
    },
  "name": "Happy Tails",
  "timeCreated": { "date": "2026-02-03", "hour": "10:00:00" },
  ]
}
-- Looks for objects under keys starting with "time" and parses them back into timestamps.
local function timeReviver(key, value, parent, ctx)
    if type(key) == "string" and string.sub(key, 1, 4) == "time" and type(value) == "table" then
        if value.date and value.hour then
            local year, month, day = string.match(value.date, "(%d+)-(%d+)-(%d+)")
            local hour, min, sec = string.match(value.hour, "(%d+):(%d+):(%d+)")
            if year and month and day and hour and min and sec then
                return os.time({
                    year = tonumber(year), month = tonumber(month), day = tonumber(day),
                    hour = tonumber(hour), min = tonumber(min), sec = tonumber(sec)
                })
            end
        end
    end
    return value
end

local newShelter = lljson.decode(jsonShelter, { reviver = timeReviver })
-- comparing the resulting table with the original
local function deepCompare(t1, t2)
    if t1 == t2 then return true end
    if type(t1) ~= "table" or type(t2) ~= "table" then return false end
    for key, value in t1 do
        if not deepCompare(value, t2[key]) then return false end
    end
    for key in t2 do
        if t1[key] == nil then return false end
    end
    return true
end

print(deepCompare(shelter, newShelter))  -- > true

Metamethod __tojson

When there is a metamethod __tojson, lljson.encode() calls it and uses the returned data to generate the JSON representation of the table, instead of reading the table.

__tojson will not be called if we passed the parameter skip_tojson = true.

It’s useful to adapt the contents of the table to the format that the external language or website expects:

-- exporting a table formatted with __tojson
local fruitsQuantity = { Apple = 50, Banana = 30, Cherry = 20, Orange = 15 }
print(lljson.encode(fruitsQuantity))
-- > {"Apple":50,"Cherry":20,"Orange":15,"Banana":30}
	
local fruitsQuantity_mt = {
    __tojson = function(t)
        local jsonFruits = { title = "List of fruits and quantities", total = 0, fruits = {} }
        for k, v in t do
            table.insert(jsonFruits.fruits, { name = k, quantity = v })
            jsonFruits.total += v
        end
        return jsonFruits
    end
}
setmetatable(fruitsQuantity, fruitsQuantity_mt)
print(lljson.encode(fruitsQuantity))
-- > {"fruits":[{"name":"Apple","quantity":50},{"name":"Cherry","quantity":20},{"name":"Orange","quantity":15},{"name":"Banana","quantity":30}],"total":115,"title":"List of fruits and quantities"}

Generated JSON ready to export:

{
  "fruits": [
    {
      "name": "Apple",
      "quantity": 50
    },
    {
      "name": "Cherry",
      "quantity": 20
    },
    {
      "name": "Orange",
      "quantity": 15
    },
    {
      "name": "Banana",
      "quantity": 30
    }
  ],
  "total": 115,
  "title": "List of fruits and quantities"
}

Anything that we return in __tojson will be generated instead of the table:

-- exporting a table formated with __tojson
local fruitsQuantity = { Apple = 50, Banana = 30, Cherry = 20, Orange = 15 }
local fruitsQuantity_mt = {
    __tojson = function(t)
        return "non exportable table"
    end
}
setmetatable(fruitsQuantity, fruitsQuantity_mt)
print(lljson.encode(fruitsQuantity))
-- > "non exportable table"

We can typecast vectors to string, avoiding the “Cannot serialise userdata: table key must be a number or string” error:

-- vector keys as string to JSON object with __tojson
local tab = { [vector(20, 50, 10)] = true }
local mt = {
    __tojson = function(t)
        local jsonTab = {}
        for k, v in t do
            jsonTab[tostring(k)] = v
        end
        return jsonTab
    end
}
setmetatable(tab, mt)
print(lljson.encode(tab))
-- > {"<20, 50, 10>":true}

We can encode only the array part of a table:

-- array part of the table
local fruits = { "apples", "bananas", "oranges", ["n"] = 3 }
setmetatable(fruits, {
    __tojson = function(t)
        return table.move(t, 1, #t, 1, {})
    end
})
print(lljson.encode(fruits))
-- > ["apples","bananas","oranges"]

__tojson has a second parameter ctx which is a table providing information about the encoding mode. It contains two keys:

  • ctx.mode: A string, either “json” (for encode()) or “sljson” (for slencode()).
  • ctx.tight: A boolean indicating if the tight encoding option is enabled (only relevant for slencode()).

If __tojson returns a table that has a metamethod __tojson this other one is not called. We can return the same table without going into infinite recursion.

-- __tojson to format the table only for encode()
local tab = setmetatable({}, {
    __tojson = function(t, ctx)
        if ctx.mode == "sljson" then
            return t
        else
            -- return a formated table
        end
    end
})

Metafield __jsonhint for encode()

The __jsonhint metamfield is a special directive used during serialization to resolve ambiguity in SLua tables. Because a SLua table can represent both a key-value map (object) and a sequential list (array), the encoder must guess the user’s intent. The __jsonhint gives us explicit control over this process, hinting a table to be encoded as either a JSON object ({}) or a JSON array ([]).

We set __jsonhint as a key within a table’s metatable. The value must be one of the string values that “hint” at the desired JSON structure.

Valid Values for __jsonhint

  • “array”: Hints the table to be encoded as a JSON array. This is a soft hint. If the table contains keys that are not positive integers, it cannot be a valid JSON array. In this case, the encoder will ignore the hint and serialize it as a JSON object to preserve all the data.
  • “object”: Hints the table to be encoded as a JSON object. This is a strong hint. It will always produce a JSON object, converting any numeric keys into string keys in the final output.

This is useful for:

  • Empty tables: An empty SLua table {} could be either {} or [] in JSON. Without a hint they are encoded as JSON arrays.
  • Tables with only numeric keys: A table like { [1] = “a” } would normally become an array, but we might intend for it to be an object {“1”: “a”}.
  • Sparse array: An excessively sparse array will be encoded as a JSON array with __jsonhint = “array”, instead of throwing a “excessively sparse array” error.

For convenience, the lljson library provides pre-configured, read-only metatables that we can use directly:

  • lljson.array_mt: Use this to hint that a table should be an array.
  • lljson.object_mt: Use this to hint that a table should be an object.

__jsonhint is not used by slencode(). slencode() uses the encoding that is more efficient.

-- using __jsonhint
  
-- with array hint: array
local sparseData = { [1] = "Level 1", [5] = "Level 5", [12] = "Level 12" }
setmetatable(sparseData, { __jsonhint = "array" })
print(lljson.encode(sparseData))
-- > ["Level 1",null,null,null,"Level 5",null,null,null,null,null,null,"Level 12"]

-- with object hint: object
local sparseData = { [1] = "Level 1", [5] = "Level 5", [12] = "Level 12" }
setmetatable(sparseData, { __jsonhint = "object" })
print(lljson.encode(sparseData))
-- > {"1":"Level 1","5":"Level 5","12":"Level 12"}

-- without hint but allowing sparse: array
local sparseData = { [1] = "Level 1", [5] = "Level 5", [12] = "Level 12" }
print(lljson.encode(sparseData, { allow_sparse = true }))
-- > ["Level 1",null,null,null,"Level 5",null,null,null,null,null,null,"Level 12"]

-- with array hint but non-array keys: object
local sparseData = { [1] = "Level 1", [5] = "Level 5", [12] = "Level 12", ["others"] = "Other levels" }
setmetatable(sparseData, { __jsonhint = "array" })
print(lljson.encode(sparseData))
-- > {"1":"Level 1","12":"Level 12","5":"Level 5","others":"Other levels"}

-- without hint: too sparse error
local sparseData = { [1] = "Level 1", [5] = "Level 5", [12] = "Level 12" }
print(pcall(lljson.encode(sparseData)))
-- > runtime error: Cannot serialise table: excessively sparse array

Objects

A table is encoded as a JSON object if it meets any of the following conditions:

  • Explicit hint: It has a metatable with __jsonhint = “object”.
  • Array incompatible keys: It contains at least one key that is not a positive integer.

Arrays

An array table is encoded as a JSON array if it satisfies any of the following conditions, provided it is not explicitly forced to be an object via __jsonhint = “object”.

  • Explicit hint: It has a metatable with __jsonhint = “array” and contains only array-compatible keys.
  • Empty table default: It is an empty table ({}) and does not have an “object” hint.
  • Automatic Detection: If all its keys are positive integers and it’s not excessively sparse.

An excessively sparse array table is encoded as a JSON array if:

  • Explicit hint: It has a metatable with __jsonhint = “array”.
  • allow_sparse parameter: If we pass the allow_sparse = true option.

__jsonhint has priority over allow_sparse. With __jsonhint = “array” an excessively sparse array will be encoded as an array even if we have passed allow_sparse = false.

Encoding sequence of execution

encode() uses two different execution pipelines depending on whether we provided a replacer function in our options.

Without a Replacer

When no replacer is used, __jsonhint is read before __tojson is executed:

  • Read __jsonhint (Original Table): The encoder looks at the original table’s metatable and reads the __jsonhint. It saves this shape intent.
  • Execute __tojson (Original Table): Next, it checks if the original table has a __tojson metamethod. If it does, it calls it and takes the returned value. (The original table is now discarded).
  • Apply shape and serialize:
    • If __tojson returned a non-table (e.g., a string), it serializes it directly (ignoring hints).
    • If __tojson returned a table, the encoder takes the shape hint saved in the first step and applies it to this new table, forcing it to serialize as that shape.

Why this order?
It allows a table to dictate its content via __tojson, but dictate its shape via its own __jsonhint, without requiring us to attach a metatable to the temporary table returned by __tojson.

With a Replacer

When a replacer is provided, __tojson executes first, then the replacer function, and __jsonhint is read last:

  • Execute __tojson (Original Table): Before the replacer sees the value, the encoder checks for __tojson on the original table and executes it.
  • Execute replacer (on the resolved value): The replacer function is called. The value argument passed to the replacer is the result of the __tojson call from the first step.
  • Read __jsonhint (Final Table): After the replacer returns its final value, the encoder processes it. If the final value is a table, the encoder looks at the metatable of this final table for a __jsonhint.
  • Serialize: The table is serialized according to the hint found in the previous step (or auto-detected if no hint exists).

Why this order?
It ensures that our replacer function always receives the final data that an object intends to serialize, rather than forcing the replacer to try and understand the object’s complex internal structure.
It also guarantees strict compatibility with the JavaScript JSON.stringify specification, which requires an object’s toJSON method to fully resolve its serializable value before that value is passed to the replacer function.

lljson constants and tables

Asides from the 4 functions, there are 2 constants and 4 tables to give instructions for encoding and for information.

The 2 constants have type “lljson_constant” derived from “userdata” (internally a value type derived from light userdata). They can’t be confused with any other value of another type:

-- lljson constants have its own datatype
print( type(lljson.null), typeof(lljson.null), lljson.null )
-- > userdata    lljson_constant    lljson_constant: 0x0000000000000003
print( type(lljson.remove), typeof(lljson.remove), lljson.remove )
-- > userdata    lljson_constant    lljson_constant: 0x0000000000000006

The 4 shaping tables:

-- internal values of the shaping lljson tables

array_mt = { __jsonhint = "array" }
object_mt = { __jsonhint = "object" }

empty_array = setmetatable( {}, array_mt )
empty_object = setmetatable( {}, object_mt )

empty_array = setmetatable( {}, { __jsonhint = "array" } )
empty_object = setmetatable( {}, { __jsonhint = "object" } )

slencode() / sldecode()

These functions work with non standard JSON-like code that can be encoded and decoded as the same table. They are useful to exchange code with other scripts or objects or to store in linkset data:

-- encoding to internal JSON ready to be decoded unchanged
local tab = { 42, 3.14, "hello", true, ll.GetOwner(), vector(25, 50, 0), rotation(0.50, 0.25, 0, 1), "!vStringInVectorDisguise" }
local s = lljson.slencode(tab)
print(s)
-- > [42,3.14,"hello",true,"!u0f16c0e1-384e-4b5f-b7ce-886dda3bce41","!v<25,50,0>","!q<0.5,0.25,0,1>",
-- > "!!vStringInVectorDisguise"]
local tabs = lljson.sldecode(s)
for _, v in tabs do
    print(typeof(v), v)
end
-- > number     42
-- > number     3.14
-- > string     hello
-- > boolean    true
-- > uuid       0f16c0e1-384e-4b5f-b7ce-886dda3bce41
-- > vector     <25, 50, 0>
-- > quaternion <0.5, 0.25, 0, 1>
-- > string     !vStringInVectorDisguise

To send JSON data to external resources use encode(). To receive JSON data from external resources use decode().

The generated code doesn’t contain any special character so we can store it safely in linkset data.

A table’s metatable can’t be encoded. We have to set it again after decoding the table. If it is decoded in another script, the metatable has to be defined there too.

slencode() uses the metamethods __tojson, like encode(), but not the metafield ___jsonhint.

Tight encoding

slencode() has a second parameter for tight encoding, false by default. With true some data types are encoded with less characters:

local myJson = lljson.slencode(myTab, { tight = true })

Changes with tight encoding are:

  • vectors and rotations: encoded without “<” and “>” and coordinates with value 0 as empty.
    • ZERO_VECTOR and ZERO_ROTATION are encoded as “!v” and “!q”.
  • uuids : encoded in numeric format as base64 strings in 22 characters instead of 36 (plus the 2 characters of the “!u” tag).
-- encoding tight
local tab = { ll.GetOwner(), vector(25, 50, 0), rotation(0.50, 0.25, 0, 1) }
print(lljson.slencode(tab))
-- > ["!u0f16c0e1-384e-4b5f-b7ce-886dda3bce41","!v<25,50,0>","!q<0.5,0.25,0,1>"]
print(lljson.slencode(tab, true))
-- > ["!uDxbA4ThOS1+3zoht2jvOQQ","!v25,50,","!q0.5,0.25,,1"]

sldecode() identifies both formats automatically, so we don’t have to specify the format.

Codes

We don’t need to know how the internal JSON is encoded; slencode() and sldecode() manage it so we don’t have to worry about it.

The encoding is important only if we are scripting an external resource in another language that reads slencode() format and/or writes sldecode() format.

Encoding of data types, some of them change depending on wether they are used as a value or as a key:

SLua type Encoding Comments
string string if it starts with "!" then "!" .. string
number (value) number
number (key) "!f" string
boolean (value) boolean
boolean (key) "!b1" : true, "!b0" : false
table (value) array [] or object {}
table (key) not allowed
vector "!v" string "!v<25,50,0>"
vector (tight) "!v" string "!v25,50,"
rotation/quaternion "!q" string "!q<0.5,0.25,0,1>"
rotation/quaternion (tight) "!q" string "!q0.5,0.25,,1"
uuid "!u" string
uuid (tight) "!u" base64 string 22 chars (see note below)
buffer "!d" base64 string
function not allowed
thread (coroutine) not allowed
nil (value) "!n"
nil (key) not possible in SLua
lljson.null (value) null
lljson.null (key) not allowed
inf, -inf (value) 1e9999, -1e9999
inf, -inf (key) "!f1e9999", "!f-1e9999"
nan (value) "!fNaN"
nan (key) not possible in SLua

Encoding of uuid (tight)

A uuid in base64 consists of 24 characters, but only the first 22 are significant. The final two characters are padding and are not encoded:

-- uuid with tight encoding

local id = ll.GetOwner()

local json = lljson.slencode(id, true)
print(json)  -- > "!uDxbA4ThOS1+3zoht2jvOQQ"

local idEncoded = '"' .. "!u" .. llbase64.encode(id.bytes):sub(1, 22) .. '"'
print(idEncoded == json)  -- > true

local idDecoded = uuid(buffer.fromstring(llbase64.decode(idEncoded:sub(4, 25) .. "==")))
print(idDecoded == id)  -- > true

An empty uuid is encoded as “!u”:

-- empty uuid with tight encoding
local id = NULL_KEY
print(lljson.slencode(id, true))  -- > "!u"