Building a Game with Zero Assets in Godot
This is the first game I’ve ever made.
I’m not a developer by trade, I’d never touched Godot before, and I leaned on AI to help me get over the learning curve.
But I gave myself one hard rule that ended up shaping the entire project:
Zero external assets.
No textures. No sprite sheets. No audio files. No music files.
The whole repository contains none of them.
Everything you see and hear in Reactor Panic – a small arcade game where you sort plasma cores before the reactor melts down – is generated at runtime in code.
Here’s how I did it, including the parts that went badly wrong.
Why do this to myself?
Two reasons.
First, I can’t draw or compose, so “make it all procedural” was weirdly easier than sourcing, creating, and licensing art assets.
Second, and this is the part I didn’t expect — when everything is code, everything can react to the game state for free.
More on that later.
Drawing the Reactor
All of the 2D art is rendered using Godot’s _draw() function.
The most involved piece is the containment dome. It isn’t a sprite at all – it’s shaded per cell like a tiny software renderer.
For each cell, I compute a hemisphere surface normal, perform Lambertian diffuse lighting with a specular hotspot, add Fresnel-style rim darkening, and then quantise the result into a handful of discrete steel bands so it reads as pixel art rather than a smooth gradient.
# Hemisphere surface normal
var sx := (mid_x - center_x) * inv_half_w
var sz := sqrt(maxf(0.0, 1.0 - sx * sx - sy_sq))
var norm := Vector3(-sx, sy, sz).normalized()
# Lambertian diffuse
var ndotl := maxf(0.0, norm.dot(light3))
var light_val := 0.1 + ndotl * 0.9
# Fresnel rim darkening (surface curving away from viewer goes dark)
light_val *= lerpf(0.4, 1.0, clampf(sz * 1.8, 0.0, 1.0))
# Quantise into discrete shade bands -> reads as pixel art
var band := clampi(int(round(light_val * max_band_f)), 0, num_bands - 1)
var col: Color = shades[band]
draw_rect(Rect2(px_f, row_y_f, x_end - px_f, y_bot - row_y_f), col)
That final draw_rect() call is important because it was the result of a mistake.
The Bug That Crashed a Flagship Phone
My first implementation shaded the dome per pixel using draw_line() for essentially every pixel.
On desktop, it worked fine.
On a Samsung Galaxy S25 Ultra, it hard-crashed the GPU.
More than 10,000 draw calls per frame was simply too much.
The fix was to stop thinking per pixel and start thinking in small grid cells (roughly 8×6 pixels) using draw_rect() instead.
The lighting calculations stayed exactly the same, but they were evaluated once per cell rather than once per pixel.
That reduced draw calls from more than 10,000 per frame to around 500.
The dithered band quantisation actually helped visually too. The chunkier cells looked intentional, like pixel art, rather than a low-resolution gradient.
Lesson learned: on mobile devices, draw-call count matters far more than I expected.
The full-screen effects – CRT scanlines, barrel distortion, chromatic aberration, an instability-reactive background with drifting dust motes, and heat shimmer – are handled by three small GLSL shaders.
That’s the one place where I let the GPU do the per-pixel work it is actually good at.
Synthesising the Audio
I followed the same philosophy for sound.
All ~15 sound effects are generated from raw waveform mathematics at runtime.
Here’s the entire generator for a simple sweep sound – a sine wave that glides between two frequencies with a linear fade-out.
func _gen_sweep(duration, freq_start, freq_end, volume) -> AudioStreamWAV:
var count = int(duration * SAMPLE_RATE)
var buf = PackedByteArray()
buf.resize(count * 2)
for i in range(count):
var t = float(i) / float(count)
var freq = lerpf(freq_start, freq_end, t)
var envelope = 1.0 - t
var sample = sin(TAU * freq * float(i) / SAMPLE_RATE) * envelope * volume
_put_sample(buf, i, sample)
return _make_stream(buf)
Swap the envelope for:
exp(-t * 8.0)
and you get a percussive blip.
Layer in noise and a low rumble and you get an explosion.
The background music is generated the same way, including a tension layer that ramps up as the reactor approaches meltdown.
A Second Mobile Gotcha
Generating all of those audio buffers up front (around 750,000 samples) blocked the main thread long enough for Android to throw an “Application Not Responding” warning on slower devices.
The fix was simple:
Generate the audio after the first frame renders.
That way the app appears responsive immediately before doing the heavier processing work.
The Payoff: Everything Reacts for Free
Here’s the upside I didn’t see coming.
Because the dome colour is derived from a lighting value, I can feed reactor instability directly into that calculation and the entire structure gradually heats towards red as you get closer to failure.
No separate damaged assets.
No alternate sprites.
It simply falls out of the same maths.
The music becomes more tense.
The CRT distortion intensifies.
The gauges climb.
One value drives the mood of the entire screen.
That’s the thing procedural generation gave me that pre-made assets wouldn’t have:
The game isn’t decorated – it’s driven.
Try It Yourself
Built with: Godot 4.6 / GDScript
Play in your browser (no install):
https://forcesensitivesaiyan.itch.io/reactor-panic
Available on Android and iOS:
Google Play: https://play.google.com/store/apps/details?id=com.aidoo.reactorpanic
App Store: https://apps.apple.com/us/app/reactor-panic/id6773935208
Happy to go deeper on any part of the implementation in the comments.
The dome shading and the audio synthesis were by far the most enjoyable parts to figure out.