Toolbox
What a script computes, converts or formats between two calls to the game. Nothing here talks to the server: these are the functions that spare you writing import("math"), import("strconv") or import("encoding/json") at the top of every file.
Computing
| Function | Returns | |
|---|---|---|
Min(x, y) | 1: a number | The smaller of the two. |
Max(x, y) | 1: a number | The larger of the two. |
Abs(x) | 1: a number | The absolute value. |
Ceil(x) | 1: a number | The integer above. |
Floor(x) | 1: a number | The integer below. |
Round(x) | 1: a number | The nearest integer. |
Pow(x, y) | 1: a number | x to the power y. |
Sqrt(x) | 1: a number | The square root. |
Random(min, max) | 1: an integer | A random integer, bounds included. |
The first eight work in floating point. You can pass them integers without a second thought (you can indeed write Min(3, 5)), but what they return is a float, and that is what catches you out on screen.
Round rounds away from zero: Round(2.5) gives 3 and Round(-2.5) gives -3. Many languages round 2.5 down to 2; not this one.
The trap is in the printing, not in the computation
Print(Pow(10, 7)) // ✗ 1e+07, not 10000000
Print(Itoa(Pow(10, 7))) // ✓ 10000000: go through Itoa to print
Printf("%d", Min(3, 5)) // ✗ %!d(float64=3): %d does not take a float
Printf("%.0f", Min(3, 5)) // ✓ 3
Nothing goes wrong in the computation itself: Min(3, 5) + 1 is 4, l[Floor(1.9)] does index the second slot, for i = 0; i < Min(3, 5); i++ loops three times, and "total " + Min(3, 5) writes total 3. Only Print on a large number and Printf with %d give the real type away.
Random
If you swap the bounds, the function does not complain and returns the first one: Random(10, 1) is 10, always. A draw that stubbornly gives the same value almost always comes from that. Random(5, 5) is 5, no surprise.
Converting between a number and a string
| Function | Returns | |
|---|---|---|
Atoi(text) | 1: an integer | Reads an integer from text. |
Itoa(n) | 1: a string | Writes an integer as text. |
Dotify(n) | 1: a string | Formats a large number the way the game does: 1000000 gives 1.000.000. |
Bytes2Str does the reverse conversion, from bytes; it is described with the conveniences on the Output page.
The Atoi trap: text it cannot read is zero
Atoi does not return an error. What it fails to read is zero, silently.
Atoi("42") // 42
Atoi(" 42 ") // 42, the surrounding spaces are ignored
Atoi("+42") // 42
Atoi("12.5") // 0 ← this is not an integer
Atoi("42 000") // 0 ← the space in the middle, though, breaks everything
Atoi("abc") // 0
Atoi("") // 0
Atoi("9223372036854775808") // 0 ← too large for an integer
Atoi("3.3628068e+07") // 33628068 ← scientific notation is read
Atoi("1.5e+00") // 0 ← an exponent, but 1.5 is not an integer
Scientific notation is read because the engine itself produces it: a decimal number joined to text is written 1e+06 from one million up. It is only accepted when it denotes an exact integer, so 1.5e+00 is still zero, like 12.5.
This is deliberate, and it is Ninja's behaviour: the function is used in the middle of a computation, 1 + Atoi("2"), and a second return value would make the addition apply to a slice. The price to pay is that a zero can mean "zero" as much as "I could not read it". If the distinction matters, check the text before converting it.
Itoa truncates instead of rounding when you pass it a float:
Itoa(2.7) // "2"
Itoa(-2.7) // "-2", truncation goes towards zero on both sides
Itoa(Round(2.7)) // "3"
Encoding in base64
| Function | Returns | |
|---|---|---|
Base64(text) | 1: a string | Encodes. |
Base64Decode(text) | 2: the text and an error | Decodes. |
code = Base64("hello")
plain, err = Base64Decode(code) // ← TWO targets
if err != nil { LogError(err) }
Print(plain) // hello
Written with a single target, Base64Decode returns [hello <nil>], of length 2, and not the text. That is the first pitfall. An input that is not valid base64 returns an empty string and an error that starts with Base64Decode :.
Reading JSON
| Function | Returns | |
|---|---|---|
JsonDecode(data) | 2: the value and an error | Decodes JSON. |
This is the function that goes with FetchURL and PageContent, both of which return text.
body, err = FetchURL("https://example.net/api")
if err != nil { LogError(err); return }
o, err = JsonDecode(body) // ← TWO targets, again
if err != nil { LogError(err); return }
Print(o["name"])
Print(len(o["list"]))
Print(o["block"]["sub"][0]) // the levels chain together
A JSON object comes back as a map indexed by string, a list as a slice. A missing key returns nil with no error: test for it if its absence changes anything.
A decoded integer stays an integer, whatever its size. It compares, adds up, works as a map key and prints plainly:
o, err = JsonDecode(body)
Print(o["planet_id"]) // 33628068
Print(o["planet_id"] == 33628068) // true
Atoi("" + o["planet_id"]) // 33628068
This was not the case up to and including 1.82.239: every number came back as a float, and a float does not compare to an integer by value but by its written form. o["planet_id"] == 33628068 was therefore false above one million, with no error at all, and Atoi("" + o["planet_id"]) returned zero. A six-digit id survived, a seven-digit one vanished. If you worked around it, the workaround still holds.
Decimal numbers stay floats: o["price"] on 12.5 is 12.5.
The one place still worth watching: division
/ always yields a decimal number, even between two integers. Comparing it directly to an integer falls back into the trap above:
o["planet_id"] / 2 == 16814034 // false
Floor(o["planet_id"] / 2) == 16814034 // true
Floor, Ceil and Round return an integer when the result is one: that is the way out. The other operators - +, -, *, %, <, > - do not have this problem.
And the identifiers the bot returns are not plain integers
c.GetID() returns a celestial identifier, which is its own type. It compares to an integer without trouble, but as a map key it is not one:
t = {}
t[c.GetID()] = "found"
Print(t[33620526]) // nil <- the key is there, but not under that type
Print(t[c.GetID()]) // found
Measured in game on 2026-09-22. If you index by identifier, keep the same expression on both sides, or go through Itoa: a text key never has this problem.
The literal brace does not go through
Ninja gives JsonDecode({"a": 1, "b": 2.1}) as an example, with a brace written straight into the script. Here that fails.
o, err = JsonDecode({"a": 1}) // ✗ err: JsonDecode : json: unsupported
// type: map[interface {}]interface {}
// and o is nil
o, err = JsonDecode("{\"a\": 1}") // ✓ go through text
The error is plain, it does land in err, but a script that does not test it carries on with a nil o and stops further along on an index message that does not name the real culprit. A literal list, JsonDecode([1, 2, 3]), does work: it is the map that blocks.
Writing JSON
| Function | Returns | |
|---|---|---|
JsonEncode(value) | 2: the text and an error | Writes JSON. |
JsonEncodeIndent(value, indent) | 2: the text and an error | The same, laid out. |
The counterpart to JsonDecode, and it goes through where decoding blocks: the literal brace encodes without a word.
payload = {}
payload["account"] = "Vega"
payload["fleet"] = [204, 205, 206]
payload["empty"] = nil
text, err = JsonEncode(payload) // ← TWO targets
if err != nil { LogError(err); return }
Print(text)
// {"account":"Vega","empty":null,"fleet":[204,205,206]}
Tables, lists, numbers, strings, booleans and nil - as null - all go through, with the escaping they need: a quote, an apostrophe or a backslash in a value does not break the result. Keys come out as text, as JSON requires, so a table indexed by planet id gives {"33628068": ...}.
JsonEncodeIndent takes the indent second, written whichever way you like: JsonEncodeIndent(payload, " ") and JsonEncodeIndent(payload, 2) give the same thing. That is the form to use for writing to the log; to send to a service, take JsonEncode, which is shorter on the wire.
Neither escapes &, < or >: the result is JSON, not HTML, and any parser reads it back.
Walking a table
| Function | Returns | |
|---|---|---|
MapKeys(table) | 1: the list of keys | Sorted. |
MapValues(table) | 1: the list of values | In key order. |
Anko has no keys(), and for k in table does not compile. A table is written and read by key, but its contents cannot be enumerated - hence these two functions.
cache = {}
cache[33628068] = "Vega"
cache[33630201] = "Astrid"
for i = 0; i < len(MapKeys(cache)); i++ {
Print(MapKeys(cache)[i], MapValues(cache)[i])
}
// 33628068 Vega
// 33630201 Astrid
The order is stable from one call to the next, and that is what makes the pair usable: MapKeys(t)[i] and MapValues(t)[i] always name the same entry. Numeric keys sort as numbers - 2 before 10, not the other way round - and the rest by their written form.
An argument that is not a table returns an empty list, not an error: these two functions return one value only, so there is no room to signal one. Test len() if the difference matters.
Building a value to pass
Four functions build the values the others expect as an argument. None of them talks to the game.
| Function | Returns | |
|---|---|---|
NewShipsInfos() | 1: an empty fleet | To be filled with .Set(id, count), then passed to FlightTime, Speed or Cargo. |
NewResources(metal, crystal, deut) | 1: resources | The three quantities in one go. |
NewCoordinate(galaxy, system, position, type) | 1: a coordinate | The type is PLANET_TYPE, MOON_TYPE or DEBRIS_TYPE. |
ParseCoord(string) | 2: the coordinate and an error | Reads "1:2:3" or "M:1:2:3". |
fleet = NewShipsInfos()
fleet.Set(LARGECARGO, 20)
coord, err = ParseCoord("M:1:2:3") // TWO targets
if err != nil { LogError(err); return }
ParseCoord accepts the P, M or D prefix, upper case as well as lower case, and the brackets of a game display: "[1:2:3]" goes through. With no prefix, it returns a planet. A prefix that is none of those three letters returns an error.
Anywhere a function expects a coordinate, a string does the job just as well: ParseCoord is only of use when you want the coordinate itself, or want to check that an input is readable before using it.
Naming a game id
| Function | Returns | |
|---|---|---|
ID2Str(id) | 1: a string | The readable name of a ship, a defence, a building or a technology. |
Print(ID2Str(204)) // LightFighter
Print(ID2Str(LIGHTFIGHTER)) // LightFighter, the number and the constant both work
Print(ID2Str(METALMINE)) // MetalMine
Print(ID2Str(999)) // Invalid(999): an unknown id does not
// raise an error, it shows up in the log
It is only of use on a bare number. An id that comes from the game or from an array of constants already carries its name, and prints that way on its own:
for id in ShipsArr {
Print(id) // SmallCargo, LargeCargo, LightFighter, …
Print("ship " + id) // ship SmallCargo, even in concatenation
}
So keep ID2Str for a number you stored with Put, read back from JSON or computed: Print(202) writes 202, Print(ID2Str(202)) writes SmallCargo.
A word on the names returned: they are in English, as the game library writes them. They do not follow your account language, and GetLang changes nothing there.
The named constants and the four arrays are described in Constants and values.
Describing a fleet in one line
| Function | Returns | |
|---|---|---|
ShortShipsInfos(fleet) | 1: a string | Sums up a fleet: LightFighter: 120, SmallCargo: 40. |
ships, err = GetShips(planet)
if err == nil {
LogInfof("%s : %s", planet.Coordinate, ShortShipsInfos(ships))
}
It accepts either what GetShips returns or what NewShipsInfos returns, which are not of the same shape.
Only the ships present are written, zeros are left out, and the order is the game's: combat ships first, cargos after. So it is not the order of ShipsArr, which starts with the small cargo, and it can name SolarSatellite, which ShipsArr does not contain.
An empty fleet returns an empty string. An argument that is not a fleet also returns an empty string, and puts a warning line on the Logs page, not in the script console. If your message comes out with its ship list missing and nothing shows on screen, that is where to look.
Comparing two versions
| Function | Returns | |
|---|---|---|
VersionCompare(a, b) | 1: an integer | -1 if a is older, 0 if they are equal, 1 if a is newer. |
The comparison reads runs of digits wherever they appear, so 0.1.1, v2.0 and Kepler 0.1 all go through alike. Missing segments count as zero: 1.2 and 1.2.0 are equal.
Remove the version guards copied from elsewhere. Many Ninja scripts start by refusing to run on a bot that is too old:
if VersionCompare(VERSION, "0.91.8") == -1 { // ✗ VERSION is "Kepler 0.1"
Print("You need version 0.91.8 or higher")
Exit()
}
Here VERSION is Kepler 0.1, so that comparison returns -1 and the script stops on the spot, even though everything it needs is there. Delete the block, or compare against a Kepler number.
Print(VersionCompare("1.2", "1.2.0")) // 0: absent segments count as zero
One last limit, with no practical consequence: a pre-release suffix reads as one more segment, so 1.0.0-rc1 comes out as newer than 1.0.0.
Items, dark matter, auction and missiles
| Function | Returns | |
|---|---|---|
GetItems(celestial) | 2: the items and an error | Every item on the celestial. |
GetActiveItems(celestial) | 2: the items and an error | Only those currently in effect. |
ActivateItem(reference, celestial) | 1: an error | Switches on an item from the inventory. |
RecruitOfficer(officer, days) | 1: an error | Hires an officer. 2 commander, 3 admiral, 4 engineer, 5 geologist, 6 technocrat; 7 or 90 days. |
GetAuction() | 2: the auction and an error | The auction under way. |
BuyOfferOfTheDay() | 1: an error | Buys the merchant's offer. |
SendIPM(planet, target, count, defence) | 2: the missiles sent and an error | Fires missiles. |
c = GetCachedCelestial("1:2:3")
items, err = GetItems(c.GetID())
for o in items { Print(o) }
active, err = GetActiveItems(c.GetID())
Print("in effect:", len(active))
// The reference is the one GetItems returns, never the displayed name.
for o in items {
if o.Amount > 0 {
err = ActivateItem(o.Ref, c.GetID())
Print("switched on:", o.Name, err)
break
}
}
auction, err = GetAuction()
Print(auction, err)
Print(RecruitOfficer(5, 7)) // a geologist for seven days
Print(BuyOfferOfTheDay())
sent, err = SendIPM(c.GetID(), "1:1:1", 10, 0)
Print(sent, "missiles sent", err)
Dark matter is spent from a script, never on its own
BuyOfferOfTheDay and RecruitOfficer spend your dark matter. They are here because a script is an explicit act: you are the one writing the line, with the name of what it spends on it.
UseDM stays closed for now, and that is a deliberate caution: it drains a reserve in a handful of calls, without asking, and a loop with no guard would have gone through the whole of it before its author noticed.
The bot itself never touches it on its own initiative. No worker, no plan, no tutorial task can spend dark matter, and a test checks that by reading the whole of the bot's code. The only door is the one you open yourself, in your script.
One precaution worth writing down: these two spend on every call, without asking. Do not hire an officer inside a watch loop, and do not call the offer of the day anywhere but once a day.
ActivateItem is a case apart and costs nothing: it switches on an item already in your inventory, and the item is what gets spent. The game refuses to activate what you do not own, so nothing can be bought along the way.
Missiles, for their part, are paid for in resources: they never had anything to do with any of this.
What to know about GetAuction
A finished auction is not an error. GetAuction then returns HasFinished true, and Endtime counts the seconds until the next auction. While an auction runs, Endtime is the time it has left, in seconds, as the game rounds it to the minute.
An error means the page could not be read, never "no auction": its text quotes what the game answered. Up to 1.82.374, on version 13 servers, it always returned "failed to find end time approx", whether an auction was running or not: the game had moved the auctioneer to a new address.
Bidding from a script is not possible yet.
Destroying missiles
| Function | Returns | |
|---|---|---|
DestroyRockets(planet, abm, ipm) | 1: an error | Destroys missiles in your silo. |
Both numbers are quantities to DESTROY, not levels to reach. DestroyRockets(p, 5, 0) removes five interceptors, it does not leave five. That is the reference tool's shape, and the opposite reading would cost a whole silo.
Missiles live only on a planet: a moon passed in its place returns an error that names it, rather than the game's silent refusal.
What to know about SendIPM
It returns TWO values, and both matter. The game may send fewer missiles than asked — range, stock — without that being a failure. A script reading only the error would believe its whole salvo left. The fourth argument is the defence aimed at; zero lets the game choose.
Missiles only leave from a planet. A moon passed in its place returns an error that names it, rather than the game's silent refusal.
The officers are not here
RecruitOfficer does not exist, for the previous section's reason: officers are paid for in dark matter.
Logging in the middle of a computation
These four belong to the same family as the functions above. The levels in detail, and their twins without the f, are on the Output, log and storage page.
| Function | Returns | |
|---|---|---|
LogDebugf(format, ...) | nothing | The same, with a format. |
LogInfof(format, ...) | nothing | The normal course of things. |
LogWarnf(format, ...) | nothing | What is worth a look without being serious. |
LogErrorf(format, ...) | nothing | What failed. |
One point that matters: LogDebug and LogDebugf are logged at Info level, not Debug. That is deliberate, Ninja gives LogDebug as the exact equivalent of Print, and a bot set to Info would otherwise lose them. So you cannot make them disappear from the log by raising the level.
What computes without asking the game
| Function | Returns | |
|---|---|---|
ShipsAttackStrength(ships, researches) | 1: an integer | The firepower of a whole fleet. |
ShipsAttackStrengthUsingOwnResearches(ships) | 1: an integer | The same, with your own researches. |
NewTemperature(min, max) | 1: a temperature | Both bounds, as the game shows them. |
SolarSatelliteProduction(temperature, count) | 1: an integer | The energy satellites produce. |
GetRequirements(id) | 1: a map | The direct requirements of a build. |
ConvertIntoCoordinate(what) | 2: the coordinate and an error | Normalises anything naming a position. |
GetPlayerCoordinates(player) | 1: a list of coordinates | The scanned positions of a player. |
SetHomeWorld(celestial) | 1: an error | Remembers your base. |
GetHomeWorld() | 1: a celestial, or nothing | Reads it back. |
None of them costs a round trip. Some compute from what the account already holds in cache, the last two re-read what the scanner stored. Calling them in a loop over a hundred players costs nothing — which is true of no function on the celestials page.
ships, err = GetShips(GetCachedCelestials()[0].GetID())
Print("firepower:", ShipsAttackStrengthUsingOwnResearches(ships))
temp = NewTemperature(-12, 40)
Print("energy:", SolarSatelliteProduction(temp, 123))
Print(GetRequirements(LIGHTLASER)) // shipyard 2, laser technology 3
GetRequirements returns the DIRECT requirements, not the whole chain: the light laser needs shipyard level 2 and laser technology level 3, but what those two need in turn is not there. Walking the chain means calling the function again.
A difference from Ninja on SolarSatelliteProduction, and it works in your favour. There, the computation assumes an account with no class. The Collector class produces a tenth more energy, and Kepler reads it from your account: the figure returned is that of your satellites, not a theoretical account's. A copied script needs no change.
GetPlayerCoordinates reads the LOCAL survey, not the game. A player never scanned returns an empty list, which does not mean they have no planets — only that none were seen. Run the scanner first.
SetHomeWorld stores a value, and nothing else. The bot does nothing with it, no worker consults it, and changing it obviously moves nothing in the game. It exists because most scripts have a base — where flights leave from, where resources come back to — and hard-coding it means reopening the script every time it changes. Unlike Put/Get, it is shared: all your scripts read the same home world. GetHomeWorld returns nil if nothing was stored, or if what was stored is no longer yours.