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 isvalue / 1000^tierfollowed 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.1becomes tier 0 (no suffix),1000becomes tier 1 (K),1_000_000becomes tier 2 (M), and so on. Clamp to the suffix array's last index so values past10^36keep 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_numberas a static autoload method. A static function on aFormatautoload (or a global script) means every UI label callsFormat.format_number(value)without holding aFormatinstance. 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:
< 1000returns"%d" % int(value). Cooked-down rounding hides fractional Light at the early game (where1.5 Lightwould 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_452in 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^36in 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 read2.34Qain 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,452is 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.34Kis locale-sensitive — German players read2,34K. Godot'sStringformatting 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_numberis 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 ifformat_numberis 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:
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:
- 999 → log(999)/log(1000) ≈ 0.9996, floor = 0, but the early-return for < 1000 short-circuits this and prints 999.
- 1_000 → 1.0, floor = 1, displays as 1.00K.
- 1_000_000 → 2.0, floor = 2, displays as 1.00M.
- 999_999 → 1.9999..., floor = 1, displays as 1000K — wait, 999_999 / 1000 = 999.999 which is bucket 100–999, so it formats as %.0f%s → 1000K. 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. 123 ≈ 12.3 ≈ 1.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 >=100 → 123K.
- 12_345 → tier 1, scaled 12.345 → bucket >=10 → 12.3K.
- 1_234 → tier 1, scaled 1.234 → bucket <10 → 1.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.
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 uniformity — Format.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 expansion — Format.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 Format — format.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:
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)
logandpoware O(1) — one math call regardless of magnitude. A loop is O(tier) — slower for huge numbers. - B)
logis the only way to get a logarithm in Godot. - C) The loop version produces wrong results past 10^15.
- D) Godot does not allow
whileloops 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;
logis just better. - C is wrong: a correctly-written loop produces correct results at any tier.
- D is wrong: Godot allows
whilein 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
>=100bucket strips decimals. - C is wrong: 100,000 is not a million.
- D is wrong:
format_numberdoesn'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'sNodeinstance — and sinceformat_numberhas 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
SUFFIXESarray determining which short-form suffix to show. Tier 0 is no suffix (raw integer); tier 1 isK(thousands); tier 2 isM(millions); and so on up toDc(decillions). Computed asfloor(log(value) / log(1000))and clamped toSUFFIXES.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 bylog(1000)converts to base-1000. Floor produces the integer tier. The min-clamp againstSUFFIXES.size() - 1prevents array-bounds errors at values past the largest defined suffix. Edge case: tier is undefined forvalue = 0(log(0)is-inf) — handled by the< 1000early return. - precision bucket
- The three-way branch in
format_numberthat 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. Formatautoload- The autoload introduced in M8.1 that hosts pure utility functions like
format_number. Static methods can be called asFormat.format_number(x)from any code without holding aFormatinstance. Distinguished fromGameState(state-holding) andTick(signal-emitting):Formatis a namespace, no fields, no signals, no_readyneeded. Could equivalently be a globalclass_namewith no autoload entry, but the autoload form keeps the call site's autocomplete consistent acrossFormat,GameState,Tick.