Kagari
Type to search documentation.

Syntax

Numbers

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.

TypeValuesHeld at runtime by
Int<lo, hi>the integers from lo to hi, inclusiveevery kind
u8 … u128, i8 … i128, usize, isizeas in Rustfn, const fn
Decimalexact decimal numbersfn, const fn
Ratexact fractionsfn, 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>

kis
static mut AFFECTION_MIO: Int<0, 5> = 0;

Int<lo, hi> is a primitive type: an integer in a declared, inclusive range.

  • lo and hi are type-level constants that fit in i64, with lo <= hi (E2004).
  • There is no default range: every Int type 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:

kis
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.

kis
let a: Int<0, 5> = 3; // Int<0, 5>
let b: u32 = 3; // u32
let c = 3u8; // u8
let d: Int<0, 5> = 9; // E2013: 9 does not fit

Operations 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 + bInt<l1 + l2, h1 + h2>
a - bInt<l1 - h2, h1 - l2>
a * bthe smallest range containing the four corner products
a / bthe smallest range containing the four corner quotients
a % ba range bounded by the dividend and by the largest divisor magnitude minus one
-aInt<-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 a match.
  • The bitwise operators & | ^ << >> and ! are not defined on Int (E2015).
  • Int and 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.

kis
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>()):

MethodResult
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
kis
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.

OperatorsMeaning
+ - * / %arithmetic; both operands have the same type
& | ^bitwise and, or, exclusive or
<< >>shifts; the amount may have any integer type
-xnegation, signed types only (E2015)
!xbitwise 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.

OperatorsMeaning
+ - *exact
/computed exactly, then cut to at most 7 digits after the point, truncating toward zero: 1.0 / 3.0 is 0.3333333
-xnegation
== != < <= > >=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).

kis
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

ErrorDuring compile-time evaluationAt runtime
Overflow, result outside the type, bad shift amountE4001VM panic
Division by zero, Rat::new(_, 0)E4002VM 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