Random Numbers
RND() internals and techniques
RND() internals, techniques, and patterns for game development
Random Numbers
Random numbers are the lifeblood of games—enemy behaviour, loot drops, terrain generation, dice rolls, card shuffles. The C64’s RND() function is deceptively simple on the surface but has quirks worth understanding.
This article explains how RND() actually works, why it’s not truly random, and practical techniques for using it effectively in games.
How RND() Works
RND() is a pseudo-random number generator (PRNG). It’s not genuinely random—it’s a mathematical formula that produces a sequence of numbers that appears random but is completely predictable if you know the starting point.
The Basic Call
x=rnd(1) rem returns 0.0 to 0.999999
Every call to RND(1) returns a floating-point number between 0 (inclusive) and 1 (exclusive). The distribution is uniform—every value equally likely across the range.
The Three Modes
RND() accepts different arguments that change its behaviour:
| Call | Behavior |
|---|---|
RND(1) |
Generate next number in sequence |
RND(0) |
Generate a number from the hardware timers, outside the sequence |
RND(-TI) |
Reseed generator from system clock |
Any positive argument behaves like RND(1); the value itself is ignored. The Programmer’s Reference Guide puts it the same way — “the <numeric> argument is a dummy, except for its sign” — and recommends the positive form’s repeatability directly: “having a known sequence of ‘random’ numbers is useful in testing programs.” (The manual describes RND(0) as reading “a free-running hardware clock (the system ‘jiffy clock’)”. The ROM routine at $E097 reads four bytes from CIA#1 — timer A at $DC04-$DC05 and the time-of-day clock’s tenths and seconds at $DC08-$DC09 — so it is a hardware clock, but not the jiffy counter TI reports.)
RND(1) — Normal use:
10 for i=1 to 10
20 print rnd(1)
30 next i
Each call advances the sequence, giving you a new “random” value.
RND(0) — Read the hardware instead of the sequence:
10 print rnd(1)
20 print rnd(0)
30 print rnd(0)
40 print rnd(0)
On a cold C64 that prints four different numbers:
.185564016
.085938096
.0585945249
.496094823
RND(0) does not repeat anything and does not advance the sequence. It builds a value from the CIA timers, so it changes every call and depends on when you called it. That makes it the one form of RND that is genuinely unpredictable from a cold start — and also the one that will not reproduce, which matters if you are debugging and want the same numbers twice.
RND(-TI) — Reseed:
10 x=rnd(-ti) rem reseed from clock
20 a=rnd(1) rem now unpredictable
Seeds the generator with the system clock (TI = 60Hz ticks since power-on). This introduces unpredictability by making the sequence start point depend on when the program runs.
The Predictability Problem
Turn your C64 on. Type:
print rnd(1)
You’ll get .185564016 every time. Power cycle, run again — same number, then the same sequence after it.
Why? The generator’s seed lives at zero-page addresses $8B-$8F (139-143 decimal) as a 5-byte BASIC float. Power-on leaves it holding 80 4F C7 52 58, the copy BASIC’s cold-start routine takes from ROM, so every reset starts the sequence at the same point.
For games, this is terrible. Imagine a space invaders game where enemies always move the same way. Boring.
Solution: Reseed from Clock
10 rem game initialization
20 x=rnd(-ti)
30 rem now RND(1) is unpredictable
TI is a BASIC pseudo-variable that returns the value of the 3-byte jiffy clock at $A0-$A2 as a 5-byte float. The jiffy counter increments every 1/60 sec (NTSC) or 1/50 sec (PAL) under the IRQ handler. Using -TI as the seed makes your random sequence depend on when the player starts the game, effectively randomizing the starting point.
Common pattern:
10 rem title screen
20 print "press space to start"
30 get k$:if k$="" then 30
40 x=rnd(-ti) rem player delay = randomness
50 gosub 1000 rem start game
The longer the player waits to press space, the more TI advances, changing the seed. This introduces genuine unpredictability.
Converting to Useful Ranges
RND(1) gives you 0.0 to 0.999999. Games need dice rolls, grid positions, enemy types—integers in specific ranges.
The Universal Formula
result = INT(RND(1) * RANGE) + START
Examples:
| Range | Formula | Use Case |
|---|---|---|
| 0-9 | INT(RND(1)*10) |
Single digit |
| 1-10 | INT(RND(1)*10)+1 |
Counting numbers |
| 1-6 | INT(RND(1)*6)+1 |
Dice roll |
| 0-255 | INT(RND(1)*256) |
Byte value |
| 50-99 | INT(RND(1)*50)+50 |
Score bonus |
| -10 to 10 | INT(RND(1)*21)-10 |
Velocity offset |
Why INT() Is Required
Without INT(), you get floating-point numbers:
print rnd(1)*6+1 rem 3.847291 (useless for dice)
print int(rnd(1)*6)+1 rem 4 (proper dice roll)
INT() truncates the decimal, converting to a whole number. Combined with multiplication and addition, you can generate any integer range.
Common Mistake: Off-by-One Errors
rem WRONG - gives 0-5, not 1-6
dice=int(rnd(1)*6)
rem RIGHT - gives 1-6
dice=int(rnd(1)*6)+1
Rule: Multiply by RANGE (how many values), add START (first value).
Want 10-20? That’s 11 values (10, 11, 12…20), starting at 10:
n=int(rnd(1)*11)+10
Performance considerations
RND(1) costs about 4ms a call on a PAL C64 — roughly 245 calls a second. A FOR I=1 TO 1000: X=RND(1): NEXT loop takes 6.9 seconds; the same loop with X=1 in place of the RND call takes 2.8 seconds, so the generator itself accounts for 4.1 of those seconds and BASIC’s loop scaffolding for the rest.
What that means in a game loop. A PAL frame is 20ms. One RND(1) eats a fifth of it before you have done anything with the number, and the INT(), multiply and add that turn it into a usable range cost more on top. Two or three random values per frame and the interpreter has no frame left.
This is why the pregeneration advice below is not a micro-optimisation. It is the difference between a game that holds 50fps and one that does not.
Optimization tips:
- Pregenerate random values
rem During initialization:
100 dim rn(100)
110 for i=0 to 100
120 rn(i)=int(rnd(1)*100)
130 next i
rem During game loop:
1000 enemy_x=rn(frame and 127) rem use pregenerated value
- Use lookup tables for complex distributions
rem Instead of IF chains:
100 r=int(rnd(1)*100)
110 if r<50 then item=1 rem 50% common
120 if r>=50 and r<80 then item=2 rem 30% uncommon
130 if r>=80 then item=3 rem 20% rare
rem Use array:
100 dim loot(99)
110 for i=0 to 49:loot(i)=1:next i rem 50% common
120 for i=50 to 79:loot(i)=2:next i rem 30% uncommon
130 for i=80 to 99:loot(i)=3:next i rem 20% rare
1000 drop=loot(int(rnd(1)*100)) rem instant lookup
Weighted Random Selection
Not all outcomes should be equally likely. Rare items, critical hits, special events—these need weighted probability.
Simple Weighted Selection
10 rem 60% enemy type 1, 30% type 2, 10% type 3
20 r=int(rnd(1)*100)
30 if r<60 then type=1:goto 60
40 if r<90 then type=2:goto 60
50 type=3
60 rem continue with selected type
How it works:
- 0-59 (60 values) → type 1
- 60-89 (30 values) → type 2
- 90-99 (10 values) → type 3
Lookup Table Approach
For complex distributions, precompute a weighted array:
100 dim spawn(99)
110 for i=0 to 59:spawn(i)=1:next i rem 60% goblins
120 for i=60 to 84:spawn(i)=2:next i rem 25% orcs
130 for i=85 to 94:spawn(i)=3:next i rem 10% trolls
140 for i=95 to 99:spawn(i)=4:next i rem 5% dragons
1000 enemy=spawn(int(rnd(1)*100))
This is faster than IF chains and easier to tune.
Dynamic Probability
Difficulty ramping, loot luck, adaptive AI—sometimes probability needs to change during play.
10 luck=0 rem player's luck stat
100 rem loot drop with luck bonus
110 r=int(rnd(1)*100)+luck
120 if r<50 then item=1 rem common
130 if r>=50 and r<80 then item=2 rem uncommon
140 if r>=80 then item=3 rem rare (easier with higher luck)
As luck increases, the random roll gets a bonus, shifting outcomes toward better results.
Shuffling and Permutations
Card games, randomized levels, unpredictable enemy spawn orders—sometimes you need to shuffle a list.
Fisher-Yates Shuffle
The standard algorithm for shuffling arrays:
100 rem initialize deck
110 dim deck(51)
120 for i=0 to 51:deck(i)=i:next i
200 rem shuffle
210 for i=51 to 1 step -1
220 j=int(rnd(1)*(i+1))
230 t=deck(i):deck(i)=deck(j):deck(j)=t
240 next i
300 rem deck is now shuffled
310 for i=0 to 51:print deck(i);:next i
How it works:
- Start at end of array
- Pick random element from 0 to current position
- Swap current element with random element
- Move to previous position, repeat
This guarantees every possible permutation equally likely.
Partial Shuffle
Don’t need the entire deck shuffled? Stop early:
200 rem shuffle first 10 cards only
210 for i=51 to 42 step -1
220 j=int(rnd(1)*(i+1))
230 t=deck(i):deck(i)=deck(j):deck(j)=t
240 next i
Faster than full shuffle, good for “draw 5 cards” scenarios.
Random Without Repetition
Sometimes you need random values that don’t repeat—unique enemy spawn positions, non-repeating background music tracks, etc.
Small Sets: Boolean Array
100 dim used(9)
110 for i=0 to 9:used(i)=0:next i rem all unused
200 rem pick unique value
210 x=int(rnd(1)*10)
220 if used(x)=1 then 210 rem already used, try again
230 used(x)=1 rem mark as used
240 print "spawning at position ";x
Limitation: As set fills up, finding unused values takes longer. Works well for small sets (<20 items).
Large Sets: Shuffle Once, Iterate
100 dim spawn_x(99)
110 for i=0 to 99:spawn_x(i)=i:next i
200 rem shuffle
210 for i=99 to 1 step -1
220 j=int(rnd(1)*(i+1))
230 t=spawn_x(i):spawn_x(i)=spawn_x(j):spawn_x(j)=t
240 next i
1000 ptr=0
1100 rem spawn enemy at spawn_x(ptr)
1110 x=spawn_x(ptr)
1120 ptr=ptr+1:if ptr>99 then ptr=0 rem wrap around
Precompute shuffled order, iterate through it. No repetition until you’ve used all values.
Seeded Random for Procedural Generation
Procedural generation—infinite levels, consistent worlds, reproducible terrain—requires controlled randomness.
The trick: Save and restore the RND seed.
Saving the Seed
The RND seed is stored at memory locations $8B-$8F (139-143 decimal):
100 rem save current seed
110 for i=0 to 4
120 seed(i)=peek(139+i)
130 next i
200 rem restore seed
210 for i=0 to 4
220 poke 139+i,seed(i)
230 next i
Procedural Level Generation
100 dim seed(4)
1000 rem generate level based on level number
1010 input "level";level
1020 x=rnd(-level) rem seed from level number
1030 gosub 2000 rem generate terrain
1040 gosub 3000 rem generate enemies
1050 rem play level
2000 rem terrain generation
2010 for x=0 to 39
2020 height=int(rnd(1)*20)+5
2030 for y=0 to height
2040 poke 1024+y*40+x,160 rem draw column
2050 next y
2060 next x
2070 return
Key insight: Seeding with level number means level 3 always generates the same terrain. Players can revisit levels, share level codes, and the world stays consistent.
Testing and Debugging Random Behavior
Random bugs are the worst—hard to reproduce, intermittent, unpredictable.
Forced Seed for Testing
10 rem DEBUGGING - fixed seed
20 for i=0 to 4:poke 139+i,0:next i rem zero seed
30 x=rnd(1) rem now predictable
100 rem game code
With a fixed seed, behaviour becomes reproducible. You can debug the same sequence every time.
Remember to remove or comment out forced seeds before release!
Logging Random Values
1000 rem enemy spawn
1010 x=int(rnd(1)*40)
1020 print "spawn x=";x rem DEBUG
1030 rem spawn enemy at x
Print random values as they’re generated. Helps spot patterns, bias, or bugs in your formulas.
Common patterns
Random Walk (Drunk Sailor)
100 x=20:y=12 rem start center screen
110 d=int(rnd(1)*4)
120 if d=0 then x=x-1 rem left
130 if d=1 then x=x+1 rem right
140 if d=2 then y=y-1 rem up
150 if d=3 then y=y+1 rem down
160 poke 1024+y*40+x,81 rem draw
170 goto 110
Unpredictable movement, organic-looking paths.
Random Event Chance
100 if rnd(1)<0.05 then gosub 5000 rem 5% chance per frame
Creates occasional events—power-up spawns, weather changes, random encounters.
Noise and Variation
100 base_speed=5
110 speed=base_speed+int(rnd(1)*3)-1 rem 4-6 range
Adds variation to predictable values—enemies with slightly different speeds, damage with random variance, etc.
Critical Hits
100 damage=10
110 if rnd(1)<0.1 then damage=damage*2:print "critical!"
10% chance to double damage. Classic RPG mechanic.
The underlying algorithm
C64 BASIC’s RND() is a multiplicative congruential generator working on the 5-byte FAC float at $8B-$8F. Each call multiplies the seed by a fixed constant and adds another, then normalises the result back to the [0, 1) range. The exact constants are baked into the BASIC ROM and aren’t intended to be cryptographically strong — they’re tuned for compactness and “looks random enough for games”.
Period: Long enough that no game will exhaust it. Practical reseeding from TI is what matters for replay variety, not the raw period.
Quality: Adequate for game development — enemy AI, loot tables, dice rolls. Not suitable for cryptography, statistical simulation, or anything where adversarial prediction matters. The other random source on the machine is the SID: register $D41B exposes “the upper 8 output bits of Oscillator 3”, and with the noise waveform selected it “will produce a series of random numbers, therefore, this register can be used as a random number generator for games” — Commodore’s data sheet says so itself.
See also
- BASIC V2 Reference — complete command reference
- Variables and Memory — where RND seed lives
- Screen Memory — for visualizing random output
Randomness makes games replayable. Master RND() and you control chaos.