Skip to content

Commit 16e22d0

Browse files
authored
Add Game Demo: nu-niversal paperclips (#1108)
Inspired by @kiil, this PR adds another game for exploration & inspiration - this time, based on the seminal incremental idle game [Universal Paperclips](https://www.decisionproblem.com/paperclips/index2.html). This game implements a screen drawing loop with a roughly 60FPS framerate, asynchronous user interaction via the new `job send` and `job recv` commands, rudimentary deltatime calculations, and the ability to save and load state memory. The game does not feature any of the special mechanics that emerge from the original, though a future project may emerge to make a more 1:1 replica of the original. I have also updated the .gitignore to make sure no one accidentally pushes their game save to the repo after trying it out. 😃
1 parent 5e732da commit 16e22d0

File tree

2 files changed

+268
-0
lines changed

2 files changed

+268
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# ignore the git mailmap file
44
.mailmap
55

6+
# nu-specific
67
check-files.nu
8+
gamestate.nuon
79

810
.vscode/

games/paperclips/game.nu

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# nu-niversal paperclips - a (somewhat) faithful clone of Universal Paperclips
2+
# play the original at: https://www.decisionproblem.com/paperclips/index2.html
3+
4+
# NOTE: scalars are off cuz i'm counting by pennies instead of dollars and cents
5+
6+
const carriage_return = "\n\r"
7+
8+
# Resets cursor to home position and hides it to prevent visual distraction
9+
def reset-cursor [] {
10+
print -n "\e[H" # Move cursor to home position (top-left)
11+
print -n "\e[?25l" # Hide cursor
12+
}
13+
14+
# Clears all content from cursor position to end of screen
15+
def clear-to-end [] {
16+
print -n "\e[J" # Clear from cursor to end of screen
17+
}
18+
19+
# Updates the terminal display with current game state
20+
def refresh-game-view [state] {
21+
reset-cursor
22+
render-game-display $state
23+
}
24+
25+
def make-paperclip [state, amount: int] {
26+
mut new_state = $state
27+
$new_state.paperclips.total += $amount
28+
$new_state.paperclips.stock += $amount
29+
$new_state.wire.length -= $amount
30+
$new_state.paperclips.current_second_clips += $amount # Add this line
31+
$new_state
32+
}
33+
34+
def sell-paperclips [state] {
35+
if $state.paperclips.stock == 0 { return $state }
36+
# numbers picked by the OG, i haven't sussed out exactly why.
37+
if (random float 0.0..1.0) > ($state.market.demand / 100) { return $state }
38+
mut amount = 0.7 * ($state.market.demand ** 1.15) | math floor
39+
if $amount > $state.paperclips.stock { $amount = $state.paperclips.stock }
40+
mut new_state = $state
41+
let transaction = $amount * $state.paperclips.price
42+
$new_state.money += $transaction
43+
$new_state.paperclips.stock -= $amount
44+
$new_state
45+
}
46+
47+
def update-demand [state] {
48+
let marketing = 1.1 ** ($state.market.level - 1)
49+
mut new_state = $state
50+
$new_state.market.demand = ((.8 / $state.paperclips.price) * $marketing) * 1000
51+
$new_state
52+
}
53+
54+
def get-user-input [] {
55+
let key = (input listen --types [key])
56+
if ($key.code == 'c') and ($key.modifiers == ['keymodifiers(control)']) {
57+
print -n "\e[?25h" # Show cursor before exiting
58+
exit
59+
}
60+
$key.code | job send 0
61+
}
62+
63+
def format-text [prefix: string, value: number, --money --round] {
64+
mut value = $value
65+
if $money {
66+
$value = $value / 100.0 | math round --precision 2
67+
}
68+
if $round {
69+
$value = $value | math round --precision 2
70+
}
71+
# e.g. "Paperclips: XXX\n\r" or "Unsold Inventory: $X.XX\n\r"
72+
$"($prefix): (if $money { "$" } else { })($value | into string --group-digits)(if $round { "%" } else { })($carriage_return)"
73+
}
74+
75+
# Prints text after clearing the current line with ANSI escape code
76+
def clear-line [text] {
77+
print -n $"\e[2K($text)"
78+
}
79+
80+
def render-game-display [state] {
81+
# Game title and stats
82+
clear-line (format-text "Paperclips" $state.paperclips.total)
83+
clear-line $carriage_return
84+
85+
# Business section
86+
clear-line "Business:\n\r"
87+
clear-line "---------------\n\r"
88+
clear-line (format-text 'Available Funds' $state.money --money)
89+
clear-line (format-text 'Unsold Inventory' $state.paperclips.stock)
90+
clear-line (format-text 'Price Per Clip' $state.paperclips.price --money)
91+
clear-line (format-text 'Public Demand' $state.market.demand --round)
92+
clear-line $carriage_return
93+
clear-line (format-text 'Marketing Level' $state.market.level)
94+
clear-line (format-text 'Marketing cost' $state.market.cost --money)
95+
clear-line $carriage_return
96+
97+
# Manufacturing section
98+
clear-line "Manufacturing:\n\r"
99+
clear-line "---------------\n\r"
100+
clear-line (format-text 'Clips per Second' $state.paperclips.rate)
101+
clear-line $carriage_return
102+
103+
# Wire section
104+
if $state.wire.length == 1 { "inch" } else { "inches" }
105+
clear-line $"Wire: ($state.wire.length | into string -g) (
106+
if $state.wire.length == 1 { "inch" } else { "inches" }
107+
)\n\r"
108+
clear-line (format-text 'Wire cost' $state.wire.cost --money)
109+
clear-line $carriage_return
110+
111+
# Autoclippers (conditional)
112+
if $state.autoclippers.unlocked {
113+
clear-line (format-text "Autoclippers" $state.autoclippers.count)
114+
clear-line (format-text 'Autoclipper cost' $state.autoclippers.cost --money)
115+
clear-line $carriage_return
116+
}
117+
118+
# Controls
119+
clear-line $"($state.control_line)\n\r"
120+
121+
# Clean up any trailing content from previous longer displays
122+
clear-to-end
123+
}
124+
125+
def main [] {
126+
reset-cursor
127+
print "Welcome to nu-niversal paperclips!"
128+
clear-to-end
129+
sleep 2sec
130+
reset-cursor
131+
let table_name = "clip_message_queue"
132+
133+
mut state = {
134+
delta_time: 0sec
135+
paperclips: {
136+
total: 0
137+
stock: 0
138+
price: 25
139+
rate: 0 # Changed from -1 to 0: This will show last second's rate
140+
current_second_clips: 0 # Add: Accumulator for current second
141+
}
142+
last_second_timestamp: (date now) # Add: Track when current second started
143+
market: {
144+
demand: 0
145+
level: 1
146+
cost: 10000
147+
}
148+
money: 0
149+
control_line: "controls: [a]dd paperclip; buy [w]ire; price [u]p; price [d]own; [q]uit"
150+
wire: {
151+
length: 1000
152+
cost: 2000
153+
}
154+
autoclippers: {
155+
count: 0
156+
unlocked: false
157+
cost: 1000
158+
multiplier: 1.25
159+
}
160+
}
161+
let save_exists = ('./gamestate.nuon' | path exists)
162+
if $save_exists {
163+
$state = open './gamestate.nuon'
164+
}
165+
# initial setup
166+
$state = update-demand $state
167+
refresh-game-view $state
168+
get-user-input
169+
170+
loop {
171+
let seconds = $state.delta_time
172+
if ($seconds) > (1sec) {
173+
$state.delta_time -= $seconds
174+
# TODO: tweak numbers
175+
if (random bool --bias 0.5) {
176+
let deviation = (0.75 + (random float 0.0..0.50))
177+
$state.wire.cost *= $deviation
178+
if $state.wire.cost < 1200 {
179+
$state.wire.cost = 1200
180+
} else if $state.wire.cost > 3000 {
181+
$state.wire.cost = 3000
182+
}
183+
}
184+
$state = update-demand $state
185+
186+
# Calculate total autoclipper production for this time period
187+
let amount = (($seconds | into int | $in / 1000000000) * $state.autoclippers.count) | math round
188+
mut index = 0
189+
while $index < $amount {
190+
$state = make-paperclip $state 1
191+
$index += 1
192+
}
193+
194+
$state = sell-paperclips $state
195+
}
196+
let prev = date now
197+
198+
# Check if a second has passed and roll over counters
199+
let now = date now
200+
if ($now - $state.last_second_timestamp) >= 1sec {
201+
$state.paperclips.rate = $state.paperclips.current_second_clips
202+
$state.paperclips.current_second_clips = 0
203+
$state.last_second_timestamp = $now
204+
}
205+
206+
if (($state.paperclips.total > 9) and ($state.autoclippers.count < 1)) and not $state.autoclippers.unlocked {
207+
$state.autoclippers.unlocked = true
208+
$state.control_line += "; [b]uy autoclipper"
209+
}
210+
211+
# Update display with current game state
212+
refresh-game-view $state
213+
214+
try {
215+
let key = job recv --timeout 0sec
216+
match $key {
217+
"a" => {
218+
# TODO: add some way to grow amount?
219+
$state = make-paperclip $state 1
220+
}
221+
"b" => {
222+
if $state.money >= $state.autoclippers.cost {
223+
$state.money -= $state.autoclippers.cost
224+
$state.autoclippers.count += 1
225+
$state.autoclippers.cost = ($state.autoclippers.cost * 1.25 | math round)
226+
}
227+
}
228+
"d" => {
229+
if $state.paperclips.price > 1 {
230+
$state.paperclips.price -= 1
231+
$state = update-demand $state
232+
}
233+
}
234+
"q" => {
235+
if not $save_exists {
236+
$state | save gamestate.nuon
237+
} else {
238+
$state | save gamestate.nuon --force
239+
}
240+
print -n "\e[?25h" # Show cursor before exiting
241+
break
242+
}
243+
"u" => {
244+
$state.paperclips.price += 1
245+
$state = update-demand $state
246+
}
247+
"w" => {
248+
if $state.money >= $state.wire.cost {
249+
$state.wire.length += 1000
250+
$state.money -= $state.wire.cost
251+
}
252+
}
253+
_ => {
254+
""
255+
}
256+
}
257+
job spawn { get-user-input }
258+
}
259+
260+
# for 60fps framerate lock
261+
sleep 16.666ms
262+
let now = date now
263+
let delta = $now - $prev
264+
$state.delta_time += $delta
265+
}
266+
}

0 commit comments

Comments
 (0)