Skip to content

Number Formatting (K/M/B)

What you'll learn

  • The suffix-tier scheme: ["", "K", "M", "B", "T", "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc"]. Each tier is a factor of 1,000. The displayed number is value / 1000^tier followed by the suffix string. The list is the canonical ordering used in genre giants (AdVenture Capitalist, Realm Grinder, Cookie Clicker variants), so player familiarity carries.
  • The tier index calc: tier = floor(log(value) / log(1000)). Logarithm of the value, base 1,000, floored to an integer. 1 becomes tier 0 (no suffix), 1000 becomes tier 1 (K), 1_000_000 becomes tier 2 (M), and so on. Clamp to the suffix array's last index so values past 10^36 keep displaying with the largest suffix instead of crashing.
  • The decimal-precision rule by magnitude: 100–999 displays with no decimals (123K), 10–99 with one (12.3K), 1.0–9.9 with two (1.23K). The displayed string is always 3–5 characters of digits — predictable column width for UI alignment, predictable read time for the player.
  • format_number as a static autoload method. A static function on a Format autoload (or a global script) means every UI label calls Format.format_number(value) without holding a Format instance. No state, no per-call cost beyond the math, no autoload-resolution overhead beyond the first reference.
  • The threshold below which suffixes do not engage: < 1000 returns "%d" % int(value). Cooked-down rounding hides fractional Light at the early game (where 1.5 Light would just be visual noise) and matches the integer-only display of every comparable game's first hour.

How it applies

  • Idle games hit big numbers fast. A player six hours into Blood Knight Grove has billions of Light. Rendering 2_847_193_452 in a 60-pixel-wide Label either (a) overflows the row, (b) fontsize-shrinks until unreadable, or (c) clips silently. None of those are acceptable. Suffixed display fits any number from 0 to ~10^36 in five characters, no per-magnitude UI redesign needed. M8.1 is a one-time fix that scales for the rest of the game.
  • The genre conventions are real and worth respecting. AdVenture Capitalist established K, M, B, T, Qa, Qi, Sx, Sp, Oc, No, Dc (and beyond). Cookie Clicker uses the same. Players coming from those games read 2.34Qa in milliseconds. Inventing a new scheme (m, b, t, q, qq) saves zero work and confuses every player who has ever played another idler. Use the suffix list above.
  • Thousands separators are an alternative — and the wrong one for this genre. 2,847,193,452 is readable but visually noisy and gets longer as the number grows. By the trillions it's 17 characters; by the decillions, 41. The format is right for accounting software, wrong for an idle game where the number is a UI element refreshed multiple times per second.
  • Localization concern. The decimal point in 2.34K is locale-sensitive — German players read 2,34K. Godot's String formatting uses . regardless of OS locale. M8.1 ships with the period; localization (M8+ exercise) means swapping the format string per locale or falling back to language-aware number formatting. Worth flagging for a translation pass; not worth blocking on now.
  • Performance is sub-microsecond. format_number is one log, one pow, one float divide, one modulo string-format. Even called 60 times per frame across 30 different counters, total cost is well under 1 ms. The "format on every tick" worry from incremental.md is mostly relevant if format_number is called redundantly (every frame for an unchanged value). M8.2's tween calls it as the displayed value changes; not a hot loop concern.

Concepts

The suffix tier scheme

Every 1,000× factor advances one tier. A value of 2_500_000 lives in tier 2 (one to two M's worth) and displays as 2.50M. A value of 15_750_000_000_000 lives in tier 4 (between trillions and a quadrillion) and displays as 15.7T.

const SUFFIXES: Array[String] = [
    "", "K", "M", "B", "T",
    "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc"
]

suffix tier

The list runs out at Dc (decillion = 10^33). Values past this point — physically achievable in a game with prestige multipliers compounding past 10^33 — clamp to Dc and continue scaling within the displayed xx.x Dc slot. The number gets visibly bigger; the suffix freezes. M8+ extension: extend the array with UD, DD, TD… for the deep late game. Not needed for v1.

Example

A player has 47_283_519_002 Light. Tier calc: log(47_283_519_002) / log(1000) ≈ 3.49, floor to 3, suffix is B. Scaled value: 47_283_519_002 / 10^9 = 47.28.... Magnitude bucket: 10–99, so format with one decimal: 47.3B. Total characters: 5. The Label fits this in any reasonable UI column.

The tier index calculation

Computing the right tier for an arbitrary value:

var tier: int = int(floor(log(value) / log(1000.0)))
tier = mini(tier, SUFFIXES.size() - 1)

tier index calc

Three details.

First, Godot's log() is the natural logarithm (base e). Dividing by log(1000.0) converts to base-1000 by the change-of-base formula: log_1000(x) = ln(x) / ln(1000). The math is exact within float precision; for values far from tier boundaries, error is negligible.

Second, the floor is critical. 1000 should be tier 1 (K), not tier 0.999 (no suffix) or tier 1.0 silently truncated. int(floor(3.49)) = 3 and int(floor(2.999)) = 2 — values exactly at the boundary fall down, which is the player-facing-correct behavior (999K should not jump to 1.0M until you actually have 1,000,000).

Third, the mini(tier, SUFFIXES.size() - 1) clamp prevents the array indexer from raising on huge values. Without it, a 10^40 Light count produces tier = 13, indexes past the array end, and crashes the formatter mid-frame.

Example

Several values mapped to tiers: - 999log(999)/log(1000) ≈ 0.9996, floor = 0, but the early-return for < 1000 short-circuits this and prints 999. - 1_0001.0, floor = 1, displays as 1.00K. - 1_000_0002.0, floor = 2, displays as 1.00M. - 999_9991.9999..., floor = 1, displays as 1000K — wait, 999_999 / 1000 = 999.999 which is bucket 100–999, so it formats as %.0f%s1000K. The suffix-rounding edge case: at 999_999 the displayed value rounds up to 1000K, which is one tier off from a fresh 1.00M but reads identically. Visually fine; numerically correct.

Decimal-precision buckets

Once the scaled value (value / 1000^tier) is computed, the precision rule kicks in:

if scaled >= 100.0:
    return "%.0f%s" % [scaled, SUFFIXES[tier]]
elif scaled >= 10.0:
    return "%.1f%s" % [scaled, SUFFIXES[tier]]
else:
    return "%.2f%s" % [scaled, SUFFIXES[tier]]

precision bucket

The rule produces 3 significant figures regardless of magnitude. 12312.31.23 in information density — three digits the eye reads in the same fixation. This is the same trick used in scientific instruments and engineering displays: significant-figure preservation, not decimal-place preservation.

The %.0f (zero decimals) is required for the >= 100 case because %d with a float would truncate but also strip the decimal — %.0f rounds-to-nearest, which is the player-correct behavior. 199.7M should display as 200M, not 199M.

Example

The same value through different magnitudes: - 123_000 → tier 1, scaled 123.0 → bucket >=100123K. - 12_345 → tier 1, scaled 12.345 → bucket >=1012.3K. - 1_234 → tier 1, scaled 1.234 → bucket <101.23K.

In each case, three significant figures, three or four (with decimal) characters of digits, plus the suffix.

format_number as a static autoload method

The function is stateless: same input, same output, no fields involved. The right place is a static method on a global utility, not an instance method on a per-Label script.

extends Node

static func format_number(value: float) -> String:
    # ... body ...

Format autoload

Three reasons for static + autoload:

First, no state — no fields to initialize, no per-call object instance, no GC pressure from creating a Format instance every label refresh. Static methods are pure functions in disguise.

Second, call-site uniformityFormat.format_number(x) reads the same as GameState.add_to_resource(name, x) and Tick.connect(...). The autoload prefix is the discoverability signal; making Format a free-floating class_name would mean the call site is just format_number(x), ambiguous about provenance.

Third, future expansionFormat.format_time(seconds), Format.format_percent(ratio), Format.pluralize(n, "Initiate", "Initiates") are all single-method additions to the same autoload. Each needs no separate file.

Example

The CounterLabel from M2.3 currently does text = "Light: " + str(_resources["light"]). With Format: text = "Light: " + Format.format_number(_resources["light"]). One autoload, one method call, one-character delta from "Light: 47283519002" to "Light: 47.3B".

Walkthrough

You'll create a Format autoload, write format_number, register the autoload, and update one existing label (the M2.3 CounterLabel) to use it.

Step 1. In the FileSystem dock, right-click scripts/ → New Script. Name it format.gd. Set the parent class to Node (autoloads attach as Node by default).

Step 2. In the script, declare the base class and the suffixes constant. Note there is deliberately no class_name Formatformat.gd becomes the Format autoload in Step 4, and in Godot 4.6 a class_name matching an autoload's name raises "Class 'Format' hides an autoload singleton." The autoload name Format is itself the global accessor (Format.format_number(...)), which is exactly the call-site uniformity the concept section argued for.

extends Node

const SUFFIXES: Array[String] = [
    "", "K", "M", "B", "T",
    "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc"
]

Step 3. Add the format_number static method:

static func format_number(value: float) -> String:
    if value < 1000.0:
        return "%d" % int(value)
    var tier: int = int(floor(log(value) / log(1000.0)))
    tier = mini(tier, SUFFIXES.size() - 1)
    var scaled: float = value / pow(1000.0, tier)
    if scaled >= 100.0:
        return "%.0f%s" % [scaled, SUFFIXES[tier]]
    elif scaled >= 10.0:
        return "%.1f%s" % [scaled, SUFFIXES[tier]]
    else:
        return "%.2f%s" % [scaled, SUFFIXES[tier]]

The early return for < 1000 handles three edges in one line: zero, fractional values like 0.5, and the entire pre-suffix range. Without it, log(0) would produce -inf and the math would fall over.

Step 4. Register the autoload. Project → Project Settings → Globals → Autoload. Path: res://scripts/format.gd. Node Name: Format. Click Add. Order it before any UI-using autoload (place it near the top — it has no dependencies, every UI script depends on it).

Step 5. Open the script that drives the M2.3 CounterLabel (the one writing "Light: 0"). In the textbook's current shape, that update lives inside top_bar.gd's _on_light_changed handler — which writes counter_label.text = "Light: %d" % int(new_value). Replace the format spec with the new helper:

counter_label.text = "Light: " + Format.format_number(new_value)

If your CounterLabel has been refactored into its own script with a value field (a common organisational tweak in later passes), the change is the same shape but on that script's update path: text = "Light: " + str(value) becomes text = "Light: " + Format.format_number(value). The point is one find-and-replace at whichever script currently owns the label's text write — the textbook does not prescribe a specific extraction. Save.

Step 6. Run the game (F5). Click Train a few times. The counter shows Light: 5, Light: 23, etc. Buy an Initiate; passive income kicks in. Once the count exceeds 999, the format flips: 1.00K, 1.05K, 1.50K. Once past 1000K, 1.00M. Visually consistent.

Step 7. Test the boundary cases. Open the developer console (or add a temporary print in _recalculate_income):

print(Format.format_number(999))       # → "999"
print(Format.format_number(1000))      # → "1.00K"
print(Format.format_number(999_999))   # → "1000K" (the rounding edge)
print(Format.format_number(1_000_000)) # → "1.00M"

The 1000K artifact is acceptable; if it bothers your eye, the fix is a tier-bumping check (if scaled >= 1000 after the precision branch, recompute one tier higher), but that's a polish-on-polish move. Skip until a player complains.

Step 8. Apply to the Honor counter (M6.1) and the prestige-currency display (M6.3). Same one-line swap. Any future Label that displays a numeric resource gets the same treatment — the function is the canonical conversion path.

Step 9. Save. Commit. Move on to M8.2 where the displayed value will animate to its new value via Tween rather than snapping instantly.

Self-check quiz

Quiz

Q1. Why does format_number use floor(log(value) / log(1000)) instead of repeatedly dividing by 1000 in a loop?

  • A) log and pow are O(1) — one math call regardless of magnitude. A loop is O(tier) — slower for huge numbers.
  • B) log is the only way to get a logarithm in Godot.
  • C) The loop version produces wrong results past 10^15.
  • D) Godot does not allow while loops in static methods.

Reveal

Correct: A. A loop dividing by 1000 until the value is < 1000 runs tier iterations — for tier 12 (decillions) that's 12 divisions and 12 conditional checks. The log-based formula is exactly two math calls (one log, one pow) regardless of magnitude. For a function called per-frame across multiple labels, the constant-time version is the right choice. The loop also accumulates float error per division, while the log version computes once.

  • B is wrong: a manual loop would work; log is just better.
  • C is wrong: a correctly-written loop produces correct results at any tier.
  • D is wrong: Godot allows while in static methods.

Q2. A player has 100_000 Light. What does format_number return?

  • A) 100K
  • B) 100.00K
  • C) 1.00M
  • D) 100,000

Reveal

Correct: A. tier = floor(log(100_000) / log(1000)) = floor(1.666...) = 1. Scaled: 100_000 / 1000 = 100.0. Bucket: >= 100, format string %.0f%s, output 100K.

  • B is wrong: the >=100 bucket strips decimals.
  • C is wrong: 100,000 is not a million.
  • D is wrong: format_number doesn't use thousands separators; it uses suffixes.

Q3. Why is format_number declared static instead of as a regular instance method on the Format autoload?

  • A) Static methods can be called as Format.format_number(x) without resolving the autoload's Node instance — and since format_number has no fields to read, the instance is never needed.
  • B) Static methods are required by GDScript for autoload utilities.
  • C) Static methods run faster than instance methods due to bypassing the type system.
  • D) Instance methods cannot return String.

Reveal

Correct: A. format_number is a pure function: input → output, no fields, no side effects. Declaring it static makes the calling syntax Format.format_number(x) skip the instance lookup. Equivalent in result to an instance method (autoloads exist for the lifetime of the game, the instance is always available), but cleaner intent: "this method does not need self."

  • B is wrong: static is optional, instance methods on autoloads work fine.
  • C is wrong: the speed difference is measurable but irrelevant at idle-game call rates.
  • D is wrong: instance methods can return any type.

Integration question

The M2.3 CounterLabel previously called str(value) on the float. Now it calls Format.format_number(value). The displayed text is now several characters shorter for any value above 1000. What second-order UI changes does that imply, and what doesn't change despite the smaller string?

Reveal

Implications.

What gets shorter: the rendered text for values ≥ 1000. A trillion-Light counter that previously read 1_000_000_000_000 (13 chars) now reads 1.00T (5 chars). The Label's measured width drops accordingly.

What changes in the UI: - Container layout. A HBoxContainer row that auto-sized to the previous Label width re-computes; siblings to the Label gain breathing room. If the row was previously cramped, M8.1 alone fixes it without any explicit container tuning. - Visual hierarchy. The number is no longer the loudest element on the screen. Designers who want the Light counter to dominate (it's the primary stat) need to bump font size or accent color in the Label theme — M8.3 territory. - Read time. A 5-char string is read in one fixation, ~150 ms. A 13-char string is two fixations, ~300 ms. Player processing of "what just happened" speeds up.

What doesn't change: - Underlying value. _resources["light"] is still the float; M8.1 only touches the display. Cost-comparison logic (if light >= cost) reads the float, not the string. No risk of "displayed 1.00K but couldn't afford a 999-cost building." - Save format. The save still writes raw floats. JSON serialization is unaffected. - Tick math. Income, multipliers, prestige formula — all read floats. M8.1 is purely a presentation-layer transformation at the Label boundary.

The wider lesson: presentation-layer changes (formatters, themes, animations) should be added at the boundary, never plumbed through the gameplay state. M8.1 honors this. The same pattern repeats for M8.2 (tween) and M8.3 (style) — each is a wrapper on the read-out, not a mutation of the source.

Glossary

Glossary

suffix tier
The integer index into the SUFFIXES array determining which short-form suffix to show. Tier 0 is no suffix (raw integer); tier 1 is K (thousands); tier 2 is M (millions); and so on up to Dc (decillions). Computed as floor(log(value) / log(1000)) and clamped to SUFFIXES.size() - 1. Each tier represents a factor of 1,000 from the prior tier.
tier index calc
The expression floor(log(value) / log(1000)) producing the tier index for a given value. log() is the natural log (base e) in Godot; dividing by log(1000) converts to base-1000. Floor produces the integer tier. The min-clamp against SUFFIXES.size() - 1 prevents array-bounds errors at values past the largest defined suffix. Edge case: tier is undefined for value = 0 (log(0) is -inf) — handled by the < 1000 early return.
precision bucket
The three-way branch in format_number that selects display precision based on the scaled value's magnitude. >= 100: zero decimals (e.g., 123M). >= 10: one decimal (e.g., 12.3M). Otherwise: two decimals (e.g., 1.23M). Keeps the displayed digit count constant at 3 significant figures, which gives predictable visual width and predictable read time for the player.
Format autoload
The autoload introduced in M8.1 that hosts pure utility functions like format_number. Static methods can be called as Format.format_number(x) from any code without holding a Format instance. Distinguished from GameState (state-holding) and Tick (signal-emitting): Format is a namespace, no fields, no signals, no _ready needed. Could equivalently be a global class_name with no autoload entry, but the autoload form keeps the call site's autocomplete consistent across Format, GameState, Tick.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Walk me through what format_number(0.0) returns and why the early-return for < 1000 handles it.` - `If I want to show "999K" rounding up to "1.00M" instead of "1000K", what's the minimal change to format_number?` - `How would I localize format_number for German (comma as decimal separator)? Show the if-locale shape.`