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:
| Type | Approx. max magnitude | Significant digits |
|---|---|---|
decimal | 7.9 × 1028 | 28–29 |
double | 1.8 × 10308 | 15–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
GetAsyncreturns a string.json.parseturns 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 withjson.isNull. - It is
serialize, notstringify. - Numbers pass through
decimalon 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