Most creative coders meet Godot and bounce off it, because every tutorial opens with a player character and a tilemap. Underneath that there is a straightforward sketching environment with three things p5.js cannot give you: real shaders without plumbing, a compiled escape hatch, and a double-clickable binary at the end.
The smallest thing that works
Godot’s unit of organisation is the node, arranged in a scene tree. For a sketch you need almost none of that.
- New project, 2D renderer.
- Create a root node of type Node2D.
- Attach a script to it. You now have
_ready()and_process(delta).
That is your setup() and draw(). The difference is that _process receives delta — the seconds elapsed since the last frame — and you are expected to use it. Animate with t += delta, never with a frame counter. Godot will run at whatever rate the machine allows, and anything driven by frame count will run at a different speed on a different display.
To draw immediately, override _draw() and call queue_redraw() from _process():
extends Node2D
var t := 0.0
func _process(delta: float) -> void:
t += delta
queue_redraw()
func _draw() -> void:
var c := get_viewport_rect().size * 0.5
for i in 200:
var a := i * 0.1 + t
var r := 4.0 * i * 0.5
draw_circle(c + Vector2(cos(a), sin(a)) * r, 2.0, Color(1, 1, 1, 0.5))
That is a complete sketch. _draw() is immediate-mode and familiar, and it is also the slowest path in the engine — which is the first thing worth understanding about the tool.
The four real differences from p5.js
1. Nodes persist; _draw() does not. In p5.js you clear the canvas and redraw everything every frame, because there is nothing else. In Godot you can instead instantiate things and let the engine draw them: a Sprite2D, a Line2D, a MultiMeshInstance2D. A thousand nodes the engine manages will beat a thousand draw_circle calls comfortably, and ten thousand instances in a MultiMesh are not close. The habit to break is treating every frame as a blank page.
2. Shaders are first-class, not a bolt-on. Create a ShaderMaterial, assign it to a ColorRect sized to the viewport, and you have a full-screen fragment shader with no boilerplate:
shader_type canvas_item;
void fragment() {
vec2 uv = UV * 2.0 - 1.0;
float d = length(uv);
COLOR = vec4(vec3(sin(d * 20.0 - TIME * 2.0) * 0.5 + 0.5), 1.0);
}
TIME is provided. UV is provided. Godot’s shading language is GLSL-like with its own hooks — vertex(), fragment(), light() — and canvas_item is the 2D one. Pass values in with uniform float amount; and set them from script with material.set_shader_parameter("amount", 0.5).
The vertex shader is the under-used half. Displacing geometry in vertex() is how you animate shapes rather than recolour them — and it is what Worlds Upon The Wind uses to make static woodblock prints appear to move in wind.
3. There is an escape hatch. GDScript is comfortable and not fast. When one subsystem becomes the bottleneck — a noise field, a particle solver, a map generator — you can port that piece to C++ via GDExtension and leave everything else alone. This is the thing no browser-based environment offers, and it changes which projects are feasible.
4. Export is a file. One dialog produces a Windows, macOS, Linux or HTML5 build. For an installation, that means a binary that launches full-screen on boot with no browser, no address bar and no update that breaks it overnight.
A workable project layout
res://
main.tscn # the scene you run
main.gd
shaders/
field.gdshader
lib/
palette.gd # class_name Palette
Give reusable scripts a class_name at the top and they become globally available types, no imports:
class_name Palette
extends RefCounted
static func warm(i: int) -> Color:
return Color.from_hsv(fmod(0.08 + i * 0.03, 1.0), 0.7, 0.95)
Things that will cost you an afternoon
Randomness is seeded the same every run unless you call randomize() in _ready(). Conversely, for reproducible output, set seed(12345) and never call randomize() — which is what you want when a piece has to be re-exportable.
Window size versus viewport size. Project Settings → Display → Window sets the base resolution and the stretch mode. canvas_items with expand aspect is usually right for generative work; the default will letterbox you.
get_viewport_rect().size changes on resize. Read it every frame or connect to the resize signal. Caching it in _ready() is the most common reason a sketch is correct in the editor and wrong full-screen.
Colours are 0–1 floats, not 0–255. Color(1, 0.5, 0), not Color(255, 128, 0).
Anti-aliasing in _draw() is off by default. draw_circle has no AA parameter in all cases; for smooth 2D primitives either enable MSAA 2D in project settings or draw with a shader instead.
_process versus _physics_process. Use _process for visuals. _physics_process runs at a fixed rate and exists for simulation; putting draw logic there will look fine and then stutter.
Where to go next
Rebuild one sketch you already have, three ways: once in _draw(), once as instanced nodes, once entirely in a fragment shader. The timings will teach you more about the engine than any tutorial, and you will come out knowing which of the three to reach for — which is the only Godot knowledge a creative coder actually needs.
Sources
- Godot Engine documentation
- Godot shading language reference
- Custom drawing in 2D — Godot docs
- What is GDExtension — Godot docs
- Godot 4 : (Processing / p5js) creative coding — David Art on YouTube