There are two families of numbers. Bounded integers Int<lo, hi> are the only numbers the
story may hold at runtime. The Turing-complete kinds also have Rust’s integers and two exact
non-integer types, Decimal and Rat. There are no floats: a float is not Eq (NaN != NaN),
and screens must be compared for equality. Decimal takes the place of floats, and Rat covers
exact fractions.
| Type | Values | Held at runtime by |
|---|---|---|
Int<lo, hi> | the integers from lo to hi, inclusive | every kind |
u8 … u128, i8 … i128, usize, isize | as in Rust | fn, const fn |
Decimal | exact decimal numbers | fn, const fn |
Rat | exact fractions | fn, const fn |
A constant expression of any of these types may still appear in a pushdown fn, for example
fade(ms(500)) inside a render, because its value is computed at compile time. What a
pushdown fn may not do is hold one at runtime: a local, parameter, return value or story
variable of such a type is an error (see Finite types).
Int<lo, hi>
static mut AFFECTION_MIO: Int<0, 5> = 0;Int<lo, hi> is a primitive type: an integer in a declared, inclusive range.
loandhiare type-level constants that fit ini64, withlo <= hi(E2004).- There is no default range: every
Inttype states its bounds. - The analyzer stores a story variable in as many bits as its range needs, so a counter from 0 to 5 costs 3 bits. Use the smallest range that fits.
Patterns match Int values with literals and ranges, which is how thresholds are written:
match AFFECTION_MIO { 3..=5 => MioSmiles(), _ => MioShrugs(),}Integer literals
An integer literal without a suffix has the flexible type “the integer n”. It takes the integer
type its context expects, Int<lo, hi> or a Rust integer, as long as that type’s range contains
n; otherwise it is E2013. When nothing determines the type,
the literal is Int<n, n>. A suffix fixes the type: 5u8 is a u8. Integer literals never
become Decimal or Rat: write 1.0, not 1.
let a: Int<0, 5> = 3; // Int<0, 5>let b: u32 = 3; // u32let c = 3u8; // u8let d: Int<0, 5> = 9; // E2013: 9 does not fitOperations on literals alone are computed on the literal ranges: 2 * 3 is “the integer 6”. The
compiler represents integers with 128 bits, so a u128 value above i128::MAX cannot be written
as a literal.
Arithmetic on Int
Tentative. Arithmetic on bounded integers is exact: the result type is the range of all possible results, so it never overflows at runtime.
Expression (a: Int<l1, h1>, b: Int<l2, h2>) | Type |
|---|---|
a + b | Int<l1 + l2, h1 + h2> |
a - b | Int<l1 - h2, h1 - l2> |
a * b | the smallest range containing the four corner products |
a / b | the smallest range containing the four corner quotients |
a % b | a range bounded by the dividend and by the largest divisor magnitude minus one |
-a | Int<-h1, -l1> |
a == b, a < b, … | bool |
- Division truncates toward zero, and
%takes the sign of the dividend, as in Rust. /and%need a divisor whose range does not contain 0 (E2016): narrow the type first, for example with amatch.- The bitwise operators
& | ^ << >>and!are not defined onInt(E2015). Intand a Rust integer cannot be mixed in one operation or comparison (E2014); convert one side first.- A result range that does not fit in 128 bits is
E2015.
Widening and compound assignment
A value converts implicitly to an Int type whose range contains its range: an Int<1, 6> can be
stored in an Int<0, 10>. To store a value whose range is wider, convert it with .saturate()
(see Conversions).
Compound assignment saturates. x += e, x -= e, x *= e, x /= e and x %= e on an
Int variable compute the exact result and clamp it to the bounds of x: x += 1 is
x = (x + 1).saturate(). The right side may be any Int. Saturation keeps every step total: it
is defined behaviour, never a runtime error. /= and %= still need a divisor range without 0,
and the bitwise compound assignments are not defined on Int.
static mut AFFECTION_MIO: Int<0, 5> = 0;
pushdown fn praise() { AFFECTION_MIO += 1; // stays 5 once it is 5}A counter that silently saturates is often a sign that its range is too small.
Conversions
Apart from literals and the widening of one Int type to another, integer types never convert
implicitly. The Integer trait of the standard library, implemented by Int<lo, hi> and every
Rust integer, gives two explicit conversions. The target type is inferred from context, or written
with a turbofish (x.into::<u32>()):
| Method | Result |
|---|---|
x.into() | The same value. A compile error unless every value of the source type fits in the target type (E2018) |
x.saturate() | The value clamped to the range of the target type |
const fn label_width(chars: Int<0, 40>) -> u32 { let n: u32 = chars.into(); // every Int<0, 40> fits in u32 n * 16}
fn set_level(level: u32) { let level: Int<0, 100> = level.saturate(); MUSIC.set(volume(level));}There are no as casts (E2019). There is no conversion
between integers and Decimal; Rat::new builds a Rat from two i128, and Rat::to_decimal
converts a Rat to a Decimal.
Rust integers
The Turing-complete kinds have Rust’s integer types: u8, u16, u32, u64, u128, i8,
i16, i32, i64, i128, usize and isize. usize and isize have 64 bits on every
platform.
| Operators | Meaning |
|---|---|
+ - * / % | arithmetic; both operands have the same type |
& | ^ | bitwise and, or, exclusive or |
<< >> | shifts; the amount may have any integer type |
-x | negation, signed types only (E2015) |
!x | bitwise not |
== != < <= > >= | comparison |
- Arithmetic is checked. Overflow, division by zero and a result outside the type are errors (see Errors); nothing wraps silently.
/truncates toward zero,%takes the sign of the dividend.- A shift amount must be smaller than the width of the type.
<<drops the bits shifted out;>>is arithmetic for signed types. - Two different Rust integer types cannot be mixed; convert with
.into()or.saturate().
Decimal
Decimal is an exact decimal number, for values that are naturally written in base ten, such as
opacities, volumes or positions in a layout. A literal with a decimal point, such as 0.5 or
1.0, is a Decimal. It replaces floats and is Eq: 0.1 + 0.2 == 0.3 is true.
| Operators | Meaning |
|---|---|
+ - * | exact |
/ | computed exactly, then cut to at most 7 digits after the point, truncating toward zero: 1.0 / 3.0 is 0.3333333 |
-x | negation |
== != < <= > >= | comparison |
% is not supported on Decimal. A Decimal holds a 128-bit significand; a result that does not
fit is an overflow error.
Rat
Rat is an exact rational number: a numerator and a positive denominator in lowest terms, both
i128. There is no Rat literal. A value is written Rat::new(num, den), which reduces the
fraction (Rat::new(2, -4) is -1/2); den == 0 is a division by zero.
It has the same operators as Decimal, and / is exact. Rat::to_decimal converts to a
Decimal: it keeps the integer part and at most 7 digits after the point, truncating toward zero
(Rat::new(1, 3).to_decimal() is 0.3333333, Rat::new(-2, 3).to_decimal() is -0.6666666).
const fn rats() -> Rat { let a = Rat::new(1, 3); a * a - a / Rat::new(2, 1) // 1/9 - 1/6 = -1/18}Errors at compile time and at runtime
| Error | During compile-time evaluation | At runtime |
|---|---|---|
| Overflow, result outside the type, bad shift amount | E4001 | VM panic |
Division by zero, Rat::new(_, 0) | E4002 | VM panic |
A VM panic is a defined stop of the game, handled by the runtime. Code
evaluated at compile time (constants, renders, const fn called from the story) reports these
errors as diagnostics at the call that needed the value, so the story itself never fails this
way. Compound assignment on Int<lo, hi> saturates and never fails.
Last updated Oct 29, 2026