Writing Engineering & games

JSON in Polytoria, from tutorial to engine source

Hitting an HTTP endpoint from a Polytoria script is straightforward until the response arrives. The server sends JSON, you index into it, and you get nothing back — because nothing has parsed it yet.

The fix is one function. The reason it is worth a whole post is what turns up underneath it: the engine is open source, and reading the implementation explains two behaviours the documentation states without justifying, plus one design decision that can bite at the edges.

Part one: the tutorial

The async call gives you a string

This is the step that trips people up. HttpService:GetAsync() does not return a table. It returns a string — the raw response body. The server may have sent JSON, but as far as your script is concerned it is text until you do something about it.

local HttpService = game.HttpService

local raw = HttpService:GetAsync("https://api.example.com/thing")
print(type(raw))   --> string

So you parse it:

local data = json.parse(raw)
print(type(data))  --> table
print(data.name)

That is the whole fix. One call, and the response is a table you can index.

Note the lowercase json. It is a global module, not a service — you do not fetch it off game, it is simply there.

Objects become tables, arrays become lists

A JSON object turns into a string-keyed table. A JSON array turns into a normal sequential Luau array, indexed from 1.

local data = json.parse([[
  {
    "name": "Boat",
    "parts": 42,
    "afloat": true,
    "crew": ["ana", "bo", "cy"],
    "owner": { "id": 7, "name": "polk" }
  }
]])

print(data.name)          --> Boat
print(data.parts)         --> 42
print(data.afloat)        --> true
print(#data.crew)         --> 3
print(data.crew[1])       --> ana        (1-indexed, not 0)
print(data.owner.name)    --> polk

Going the other way is json.serialize:

local body = json.serialize({ name = "Boat", parts = 42 })
HttpService:PostAsync("https://api.example.com/thing", body)

Note the name. It is serialize, not stringify — if you have come from JavaScript, that is the one to remember.

The trap: null is not nil

Here is where people lose an hour. JSON null does not become nil. It becomes a special sentinel value, and you test for it with json.isNull:

local data = json.parse('{"nickname": null}')

print(data.nickname == nil)      --> false
print(json.isNull(data.nickname)) --> true

This looks like a wart until you think about what the alternative would be. In Lua, assigning nil to a table key removes the key. If JSON null became nil, then parsing {"nickname": null} would give you an empty table, and you would have no way to distinguish “the server sent nickname as null” from “the server did not send nickname at all.” For an API response those mean very different things.

And when you need to send a null back, you construct one:

json.serialize({ nickname = json.null() })  --> {"nickname":null}

Practical note: json.isNull(nil) also returns true. So a single isNull check covers both “explicitly null” and “missing entirely.” If you need to tell those apart, compare against nil separately first.

Part two: what the engine actually does

Polytoria is open source, so none of this has to be guesswork. The implementation is one file: Polytoria/scripts/scripting/languages/libraries/LuaLibJSON.cs.

It becomes a global through a registration table in the Luau provider, alongside the only other library registered the same way:

public static readonly Dictionary<string, Type> LuaLibraries = new()
{
    { "json", typeof(LuaLibJSON) },
    { "guid", typeof(LuaLibGUID) },
};

That is why it is lowercase and why it is not on game. It is injected as a global before your script runs.

Parsing is a recursive walk

parse hands the string to .NET’s System.Text.Json and then walks the resulting document, converting each node by kind:

case JsonValueKind.Object:   // → Dictionary<string, object>
case JsonValueKind.Array:    // → List<object>
case JsonValueKind.String:   // → element.GetString()
case JsonValueKind.Number:   // → element.GetDecimal()
case JsonValueKind.True:
case JsonValueKind.False:    // → element.GetBoolean()
case JsonValueKind.Null:     // → JSONNull.Value

The dictionary and list are what later become your Luau table and array. The JSONNull.Value is the sentinel from earlier — a singleton instance of a private class, which is why comparing it to nil fails and why isNull exists at all.

The walk is plainly recursive with no depth limit in the conversion itself. Nesting deep enough to matter is unlikely from a sane API, but it is worth knowing that the bound comes from the underlying parser rather than from this code.

The interesting one: numbers are decimals

Look at the number case again:

case JsonValueKind.Number:
    // Use GetInt64, GetDouble, etc., depending on your needs
    return element.GetDecimal();

Every JSON number becomes a .NET decimal. Not double, which is what Lua numbers actually are, and what you would reach for by default.

Follow the value one step further, into the code that pushes C# values across into Luau, and you find this:

else if (value is decimal decimalVal)
{
    state.PushNumber((double)decimalVal);
}

So the decimal is transient. It exists for the moment between parsing and the Lua boundary, then gets cast to a double anyway. Your script never sees a decimal, and gains none of the precision that decimal exists to provide.

What it does do is act as a gate. The two types have very different ranges:

TypeApprox. max magnitudeSignificant digits
decimal7.9 × 102828–29
double1.8 × 1030815–17

A JSON number such as 1e50 is perfectly legal JSON and sits comfortably inside a double. But it cannot be represented as a decimal, and GetDecimal() throws when a value will not fit. On the code path as written, a number that Lua could hold without complaint has to survive a narrower type on the way in.

I have not run this against a live server, so treat it as what the source implies rather than a measured result — but if you are consuming an API that emits very large magnitudes, scientific-notation values, or high-precision identifiers, that is the first place I would look when parsing fails on input that looks valid.

Practical note: if you control the API, send large identifiers as strings. That is good practice regardless of engine — JavaScript consumers lose integer precision above 253 anyway — and here it also routes around the conversion entirely.

Serialization is ahead-of-time compiled

The write path uses a source-generated serializer context rather than reflection:

[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(Dictionary<string, object>))]
[JsonSerializable(typeof(Dictionary<object, object>))]
[JsonSerializable(typeof(List<object>))]
...
internal partial class LuaJSONGenerationContext : JsonSerializerContext { }

This is the AOT-friendly approach: the serializer for each listed type is generated at compile time, so nothing depends on runtime reflection that a trimmed or natively-compiled build might have stripped away. For an engine that ships to desktop targets, that is the right call.

Two details in that list are worth noticing. Dictionary<object, object> is registered as well as the string-keyed one, which is what lets a Luau table with non-string keys serialize at all. And WriteIndented = false means output is always compact — there is no pretty-print option exposed.

The error message depends on the build

One last thing, which explains a discrepancy you may have hit:

catch
{
    if (Globals.IsBetaBuild)
        throw;                       // literal error, for debugging
    else
        throw new InvalidOperationException(
            "Tried to serialize an invalid JSON. Make sure your table only "
          + "contains the primitive types (Instances are not supported)");
}

On a beta build you get the raw .NET exception. On a release build you get a friendly message. Same bug, different text, depending on which client you are running — useful to know before you go looking for a difference that is not in your code.

What to take away

  • GetAsync returns a string. json.parse turns it into a table.
  • Arrays are 1-indexed, like every other Luau table.
  • JSON null becomes a sentinel, not nil, so that a null value and a missing key stay distinguishable. Test with json.isNull.
  • It is serialize, not stringify.
  • Numbers pass through decimal on the way in, which is narrower in range than the double you end up with. Send big identifiers as strings.

None of the second half required guessing. The engine is MPL-licensed and public, the implementation is about a hundred and thirty lines, and it took less time to read than this post takes to get through. When a runtime behaves in a way the documentation does not explain, reading it is usually faster than experimenting.

Source: LuaLibJSON.cs · json API docs

← All writing Get in touch →