SCUA

Manual

Numbers

SCUA has four number kinds, and choosing between them is mostly one trade-off: range versus exactness. This page covers how each is stored and when to reach for it.

  • int — a signed 64-bit integer. Exact whole numbers up to about 9.2 × 10¹⁸. Scores, counts, IDs, in-game gold.
  • float — a 64-bit IEEE-754 double. Enormous range, but binary, so most decimal fractions aren't exact. Physics, ratios, anything continuous where a tiny rounding error is fine.
  • decimal — an exact base-10 number. No rounding error, ever. Money, tax rates, measurements.
  • big — an inexact base-10 number with effectively unbounded magnitude (about 15 significant figures). Idle-game counters that climb past what an int or float can hold.

int and float are the everyday pair; decimal and big are specialists at opposite ends — decimal buys exactness, big buys reach.

#How int and float are stored

An int is a signed 64-bit integer. It is exact for every whole number in its range — roughly −9.2 × 10¹⁸ to 9.2 × 10¹⁸ — which is plenty for scores, counts, identifiers, and currency-as-cents. When a value needs to grow past that range (idle games), use a big.

A float is a 64-bit IEEE-754 double — the same floating-point type C, JavaScript, and most languages use. It reaches up to about 1.8 × 10³⁰⁸, but it stores values in binary, so exact-looking decimals like 0.1 have no exact binary form and carry a tiny error. That's fine for physics and ratios; it is not fine for money (see decimals).

#A float always looks like a float

A whole float prints with its .0, so you can tell it from an int by looking at it:

print(1.0)
print(1)
print(2.0 * 3.0)
$ scua floats.scua
1.0
1
6.0

That matters most when a value leaves the program. json.encode writes 1.0, not 1, so decoding a document and re-encoding it gives back what you started with — before, a list of round numbers came back as JSON integers. toml.encode and scua schema do the same. Inside a vec, mat or color every component is a float by construction, so those render without the suffix: there is no int for them to be confused with.

#Negative zero

IEEE-754 floats have two zeros, and SCUA does not hide the second one. Dividing zero by a negative number gives -0.0:

print(0.0 / -4.0)
print(-0.0 == 0.0)
$ scua negzero.scua
-0.0
true

It compares equal to 0.0, so arithmetic and conditions behave as you would expect; it only shows up when you render the value or store it somewhere a reader will see it. If a -0.0 in your output would be confusing — a collision time, a delta, a report column — normalize it where you produce it, e.g. if x == 0.0 then x = 0.0 end.

#Integers and floats don't mix silently

An int and a float are different kinds, and arithmetic keeps them apart. Adding two ints gives an int; if a float is involved, the result is a float. There is no silent demotion from float to int.

print(2 + 3)
print(type(2 + 3))
print(7 / 2)
print(type(7 / 2))
print(7 // 2)
print(type(7 // 2))
print(2 + 3.0)
print(type(2 + 3.0))

Run it:

$ scua numbers.scua
5
int
3.5
float
3
int
5.0
float

Two things to take from this:

  • / is true division and always gives a float, even when it divides evenly. Use // for floor division that stays an int.
  • Mixing an int and a float in one expression produces a float (2 + 3.0 is 5 as a float). That is the only direction the conversion happens. An int never becomes a float on its own, and a float never silently rounds to an int.

There's one display quirk worth knowing. A float whose value happens to be whole prints without a decimal point, so it looks like an int even though type() says otherwise:

let half = 2.0
print(half)
print(type(half))
print(half == 2)
$ scua whole-float.scua
2.0
float
true

The value is still a float. It just prints as 2.

#Reading a number from text

Numbers arrive as text all the time — a config value, a form field, a command-line argument, a column of a CSV. There are two ways to turn that text into a number, and which one you want depends on where the text came from.

tonumber is the relaxed one. It trims surrounding whitespace, reads 0x/0b/0o prefixes, and gives back a float when the text has a decimal point in it. When it can't read the text at all it answers nil. That is the right tool for data you wrote yourself.

int.parse is for the other case: text you didn't write, where a near-enough answer is a bug. It accepts an optional sign, decimal digits, and _ separators — nothing else — and hands back a Result you match on, so a refusal arrives with a reason attached instead of a bare nil:

import int

for s in ["42", "-7", "1_000", " 42", "1.0", "0x10", "9223372036854775808"] do
  match int.parse(s)
    Ok(n) -> print(`{s} -> {n}`)
    Error(why) -> print(`{s} -> {why}`)
  end
end
$ scua parse-int.scua
42 -> 42
-7 -> -7
1_000 -> 1000
 42 -> not an integer — surrounding whitespace is not trimmed, pass str.trim(s)
1.0 -> not an integer — that is float syntax, use tonumber(s) if an inexact number will do
0x10 -> not an integer — a base prefix like 0x is not read here, use tonumber(s) for radix-aware parsing
9223372036854775808 -> integer out of range — an int is signed 64-bit, use big(s) for a larger magnitude

Read that last line twice: 9223372036854775808 is one past the largest int, and int.parse says so. It never wraps round to a negative number and never quietly hands back a float instead — the two ways a too-large number usually goes wrong. Both ends of the range parse exactly, -9223372036854775808 included.

Whitespace isn't trimmed for you on purpose: str.trim(s) is one call and says what you meant, where a parser that trims silently also accepts "4 2" from a mis-split line. Inside a function, ? hands the failure back to your caller, so validation reads as a straight line:

import int
import str

fn read_port(text)
  let n = int.parse(str.trim(text))?
  return Ok(n)
end

for text in ["  8080 ", "eighty"] do
  match read_port(text)
    Ok(n) -> print(`port {n}`)
    Error(why) -> print(`bad port: {why}`)
  end
end
$ scua read-port.scua
port 8080
bad port: not an integer

For an exact fractional value the shape is the same one function along: decimal.parse(s) returns Ok(decimal) or Error(message), and money.parse(s) does it at two decimal places. Both are covered below.

Decimals: exact, for money and anything else that can't round

Don't use a float for money. A float is binary floating point, so values like 0.1 and 0.2 aren't represented exactly, and the error shows up the moment you do arithmetic. SCUA has a separate decimal kind for this: an exact base-10 number. Write one with a d suffix.

print(0.1 + 0.2)            -- float: not exact
print(0.1d + 0.2d)          -- decimal: exact
print(type(0.1d))

Run it:

$ scua decimals.scua
0.30000000000000004
0.3
decimal

A decimal is its own kind, distinct from float. Addition, subtraction, and multiplication are exact; an int joins in freely (it widens to a decimal without losing anything). What you can't do is mix a decimal with a float — that's a compile-time error, on purpose, because silently blending an exact value with an inexact one is how a float's rounding error sneaks into an exact ledger:

let price = 19.99d
print(price * 3)            -- 59.97, exact
print(price + 1)            -- 20.99 (the int widens)
-- print(price + 0.5)       -- error: exact decimal and inexact float don't mix

Division can't always be exact (a third of ten isn't a finite decimal), so / rounds — half-to-even, the unbiased "banker's" rule — and keeps a sensible number of places. When you want a specific number of decimal places, say so:

import decimal
print(10.00d / 3d)              -- 3.333333
print(decimal.round(10.00d / 3d, 2))   -- 3.33
print(decimal.div(10d, 3d, 4))         -- 3.3333

An int is still the right tool for whole things — scores, counts, IDs, in-game gold — and it's exact 64-bit, so no rounding worries there either. Reach for a decimal when you need exact fractional values: tax rates, percentages, measurements, anything where a float's drift is unacceptable. For actual money there's a dedicated money type built on the same exact arithmetic: an amount that carries its currency (19.99 USD), so it prints with the right symbol, splits without losing a cent, and won't let you add two different currencies or a bare number by mistake.

#Big numbers: huge magnitudes for idle games

Idle and incremental games (Cookie Clicker and its kin) run on numbers that grow without bound — 1.2e456 cookies, and climbing. An int tops out around 9.2e18 and a float hits infinity past 1.8e308, so neither survives the late game. The big kind does: an inexact base-10 number with effectively unbounded magnitude. You build one with big(...), using a string for anything past the float range so the digits never pass through (and overflow) a float:

let cookies = big("1.2e456")
print(cookies)
print(big(10) ** big(100))   -- ** is the genre's core move
print(big("1e100") * big("1e50"))
print(type(cookies))

Run it:

$ scua big.scua
1.2e456
1e100
1e150
big

A big is the opposite trade from a decimal: magnitude is unbounded, precision is not. It carries about 15 significant figures, which is all a 400-digit cookie count needs — so adding a far-smaller value is a no-op by design:

print(big("1e100") + 1)   -- still 1e100; the +1 is 100 orders of magnitude too small to show

An int or float mixes into big arithmetic freely (both widen in), and **, *, /, +, -, and comparisons all work. What a big won't do is mix with a decimal — that's a compile-time error, because one is exact and the other isn't, and blending them is meaningless. The rule of thumb: big buys reach, decimal/int buy exactness. Never put money in a big (it would round away the cents); never put a cookie count in an int (it would overflow). The idle example shows the whole loop — clicking, upgrades, prestige multipliers — at idle-game scale.

#Which kind?

You need Use Why
Whole numbers — scores, counts, IDs, gold int exact 64-bit
Continuous values where small error is fine float huge range, fast
Exact fractional values — money, rates decimal no rounding error
Magnitudes past 10¹⁸ — idle/incremental games big unbounded range