Recently, I decided I wanted to try my hand at game development. I thought: I am a developer, I like games, surely this will go smoothly and I'll make the next Balatro and then retire. As it turns out, having a day job gluing databases to APIs didn't fully prepare me to wear all the hats associated with developing a game.
The biggest hurdle so far has been trying to develop assets for the game. I was not blessed with 'the gift' for visual art. To get this out of the way: I think the vast majority of people aren't just born with innate artistic ability - it takes a huge amount of discipline and practice to achieve even 'pretty good' results, and I have immense respect for anyone who's put the time in to get there. What I mean when I say I lack 'the gift' in this case is that I find zero fulfillment in the learning process for this particular skill. In comparison, when I'm doing something like learning a new programming language, there's an underlying curiosity motivating progress. There are plenty of mundane moments in getting better at programming that I don't enjoy necessarily, but I never feel an apathy towards the practice like I do towards trying to do art. Anyway: I suck at conventional forms of visual art, and I'm fine with that, but in most cases, to make a good game, there needs to be something to look at. I had a couple of options:
All of these options have trade offs. If I limit myself to an art style I can achieve with my limited skill, I can animate some very basic spritesheets. HOWEVER, my visual expression is very limited if I go this route. Pictured above the best piece of pixel art I've ever created. I think I'll have a hard time producing something i'm satisfied with if this is the ceiling for the sprites. With that said, I did put this guy in a game jam I participated in while learning Odin, my language of choice for this project. It can be found here.
As far as hiring someone goes, I'd love to do this in a future project, but I'm currently unemployed and food is kinda expensive, so I need to get more creative.
Placeholder art or free assets are both excellent solutions, and in hindsight, these are the smart options. There's something itching in my brain though, something that makes me want to express my own style somehow.
And so, after finding weak excuses for why I can't possibly do the reasonable thing, I'm left with no choice but to do the silly thing.
Have you ever gone on Desmos and started putting in random equations and then adding the sliders and then hitting the play button on the slider animations and watching the shapes go back and forth and then showing up 15 minutes late to your next meeting?
Anyway, I think there's something charming about how even simple functions can wiggle and wave in unique patterns just by sliding different values into one of the parameters. I think the appeal comes from the way the patterns seem to just emerge from the function, and how certain parts can appear unpredictable. Of course I don't mean 'unpredictable' in a literal sense - these functions are deterministic. The computer can draw them exactly the same every time, and someone that's more familiar with the behaviors of different graphs would be able to intuit certain characteristics of an animation. But the more variables that get added, and the more each part interacts with the rest, the more likely it becomes that we see something that we wouldn't immediately anticipate, just from looking at the source function.
This 'pseudo-unpredictability' appealed to me because I could create something that I thought looked cool without having to have a pre-conceptualized visual of what I want the final result to look like. I can operate off vibes and curiosity, exploring how functions interact with each other, how different ranges of inputs behave, etc. As an aside, I think this is also why I own so many tie-dye shirts: experimenting with different folds and colors and then seeing the results all at once when I wash out the shirt satisfies some sort of primal need for exploration.
What I want is to be able to design game assets, with this same style of experimentation. The clear downside of this approach is that I might end up with a set of abstract looking entities, but I'll worry about making that fit the theme later.
My current stack has evolved a little bit beyond "graphing calculator." I'm currently working in:
To be clear, this is not what you want to do if you want to be immediately productive making a game. I'm going this route because I think it's fun - using an engine would likely be much faster. And as for developing an ECS (entity component system): this is probably overengineering. I strongly recommend this post by Karl Zylinski for a more sane 'no engine' setup. I'm only doing things this way because I saw Casey Muratori give a talk that involved ECS and thought: "neat." If you're got a stronger attention span than me (low bar), do the smart thing. I'll go into more detail about ECS implementation, what went well, what went poorly in a later post - it's not super relevant for this one though. All you need to know is that when I show a procedure like
draw_proc_anim :: proc(pos: ^Position, size: ^Size, animation: ^Procedural_Animation) -> Event { ... }
There's something behind the scenes that aggregates all the entities that have these components, and runs the procedure for each one - no need to have different structs for each type of entity.
The important part of the stack, for this post, is the Raylib part. Raylib is a library that provides an ergonomic way to draw things to the screen, work with textures, audio, handle inputs, etc. It's low level compared to using an engine, but higher level compared to working with OpenGL directly.
The Raylib API is obviously going to be a little different from drawing functions in Desmos. Raylib offers some basic shape drawing, including but not limited to:
This looks like plenty of stuff to play with to me! For a full list, check the rshapes module on the Raylib Cheatsheet
Ok time to actually put things on the screen. We'll start extremely basic just to get a sense of how Raylib works:
rl.DrawCircle(0, 0, 50.0, rl.WHITE)
Ok, and let's try a couple of other shapes too:
rl.DrawCircle(0, 0, 10.0, rl.WHITE) rl.DrawPoly(vec2(10.0, 10.0), 5, 8.0, 0.0, rl.RED) points := []rl.Vector2{{-20.0, 20.0}, {-15.0, -8.0}, {6.0, -3.0}, {8.0, 2.0}} rl.DrawSplineBezierCubic(raw_data(points), i32(len(points)), 2.0, rl.GREEN)
The splines API is a little bit wacky to work with in Odin - it's worth mentioning that Raylib is written in C and has some C quirks. For Odin to interface with it, it has bindings using language features that aren't super likely to come up in native implementations, like the multpointers in rl.DrawSplineBezierCubic
DrawSplineBezierCubic :: proc(points: [^]Vector2, pointCount: c.int, thick: f32, color: Color)
I'll probably end up wrapping spline functions a second time, to make them a little easier to work with.
So far, all we've done is draw a couple of shapes manually, and they don't even move. Next we need to establish a system for drawing our procedural textures a little more generically.
First a ground rule to keep this part of the project from spiraling out of control:
The representation of a procedural animation should be deterministic and (mostly) stateless
I don't want physics calculations happening in my render step, and I don't want to do any sort of complex memory management for drawing shapes. I say mostly stateless because I do want to be able to include information like 'how long has this entity existed' to be present and available to the animation procedure, because otherwise we'd be depending on a global clock and all instances of the same type would move in sync and that's not what I'm going for.
I'm getting a little ahead of myself - first I need to define a common structure to be able to reuse procedural animation definitions. I'm going to create a component with a constructor:
// silly name? maybe Procedural_Animation_Procedure :: proc(canvas_width: i32, canvas_height: i32, time: f32) Procedural_Animation :: struct { canvas: rl.RenderTexture2D, bounds: Vec2_i32, p: Procedural_Animation_Procedure, age: f32, } new_procedural_animation :: proc( width: i32, height: i32, p: Procedural_Animation_Procedure, age: f32 = 0.0, ) -> Procedural_Animation { canvas := rl.LoadRenderTexture(width, height) bounds := vec2(width, height) return Procedural_Animation{canvas, bounds, p, age} }
Next, I need to use this struct to do something - I'm going to split this up into 2 systems. The first will run the procedure to draw our texture onto the canvas, a RenderTexture2D. This gets our shape ready on the graphics card.
update_proc_animation :: proc(animation: ^Procedural_Animation) -> Event { animation.age += rl.GetFrameTime() rl.BeginTextureMode(animation.canvas) rl.ClearBackground(rl.BLANK) rl.BeginMode2D(center_camera(animation.bounds.x, animation.bounds.y)) animation.p(animation.bounds.x, animation.bounds.y, animation.age) rl.EndMode2D() rl.EndTextureMode() return nil }
The reason for doing this in a separate step is in the timing. Doing it this way lets me batch all the canvas updates in a prerender step, to avoid having to go in and out of texture mode while drawing to the world (this makes Raylib / OpenGL very unhappy)
Then drawing is as simple as
draw_proc_anim :: proc(pos: ^Position, size: ^Size, animation: ^Procedural_Animation) -> Event { bounds := get_bounds(size) src := rl.Rectangle{0, 0, f32(animation.bounds.x), -f32(animation.bounds.y)} // Negative height because Raylib has flipped coordinate systems dst := rl.Rectangle{pos.translation.x, pos.translation.y, bounds.x, bounds.y} rl.DrawTexturePro(animation.canvas.texture, src, dst, bounds / 2, pos.rotation, rl.WHITE) return nil }
If you're curious about the center_camera
procedure, that's just a little helper I made for myself so that when I'm making procedures to draw to the canvas, (0,0) is the center of the texture. This is not at all traditional in texture work, and there are plenty of reasons not to do this, but my brain is still a little bit in graphing calculator mode.
center_camera :: proc(canvas_width: i32, canvas_height: i32) -> rl.Camera2D { camera: rl.Camera2D camera.offset = vec2(f32(canvas_width) / 2.0, f32(canvas_height) / 2.0) camera.zoom = 1.0 return camera }
So now I can integrate this drawing into my makeshift ECS, and do something like this:
e1 := new_entity() add_component(e1, position(0, 0)) add_component(e1, Size{hitbox = Rectangle_Hitbox(vec2(100.0, 100.0))}) // same size as the canvas we'll use add_component( e1, new_procedural_animation( 100, 100, proc(w, h: i32, time: f32) { // we'll ignore the parameters entirely for now and just use constants. This should fit in a 100 x 100 canvas anyway rl.DrawCircle(0, 0, 10.0, rl.WHITE) rl.DrawPoly(vec2(10.0, 10.0), 5, 8.0, 0.0, rl.RED) points := []rl.Vector2{{-20.0, 20.0}, {-15.0, -8.0}, {6.0, -3.0}, {8.0, 2.0}} rl.DrawSplineBezierCubic(raw_data(points), i32(len(points)), 2.0, rl.GREEN) }, ), )
It worked! It's looking a little pixelated, but that's to be expected - we're telling the graphics card to draw this on a 100 x 100 pixel screen, and then drawing the texture out in the world.
To play with this some more, let's trade our anonymous function for a responsive, named one:
do_the_shapes :: proc(w, h: i32, time: f32) { // Scale factors based on canvas size vs original 100x100 scale_x := f32(w) / 100.0 scale_y := f32(h) / 100.0 // Scale the circle - position scales with canvas, radius scales with average of both dimensions rl.DrawCircle(0, 0, 10.0 * (scale_x + scale_y) * 0.5, rl.WHITE) // Scale the polygon - position and radius scale rl.DrawPoly( vec2(10.0 * scale_x, 10.0 * scale_y), 5, 8.0 * (scale_x + scale_y) * 0.5, 0.0, rl.RED, ) // Scale the bezier curve points and thickness points := []rl.Vector2 { {-20.0 * scale_x, 20.0 * scale_y}, {-15.0 * scale_x, -8.0 * scale_y}, {6.0 * scale_x, -3.0 * scale_y}, {8.0 * scale_x, 2.0 * scale_y}, } rl.DrawSplineBezierCubic( raw_data(points), i32(len(points)), 2.0 * (scale_x + scale_y) * 0.5, rl.GREEN, ) }
and create a procedure to help us spawn in more than 1 entity:
spawn_shapes :: proc(p: Position, size: Vec2_f32, canvas_size: Vec2_i32) -> Entity { e := new_entity() add_component(e, p) add_component(e, Size{hitbox = Rectangle_Hitbox(size)}) add_component(e, new_procedural_animation(canvas_size.x, canvas_size.y, do_the_shapes)) return e }
Now we can see the effect of messing with some of these numbers:
spawn_shapes(position(0, 0), vec2(100.0, 100.0), vec2(100, 100)) spawn_shapes(position(100, 0), vec2(50.0, 50.0), vec2(100, 100)) spawn_shapes(position(-100, 0), vec2(30.0, 300.0), vec2(100, 100)) spawn_shapes(position(0, -50), vec2(100.0, 100.0), vec2(2000, 2000))
It becomes easy to squash and stretch the texture, and tweak resolution and size as needed.
The last step to make this procedural animation and not just some procedure is to use the time variable in the procedure to influence how the shapes are drawn. Let's just throw the variable behind some math.sin() for values where approaching infinity is unacceptable, and then just pepper it in
do_the_shapes :: proc(w, h: i32, time: f32) { // Scale factors based on canvas size vs original 100x100 scale_x := f32(w) / 100.0 scale_y := f32(h) / 100.0 time_mod := math.sin(time * 15) // Scale the circle - position scales with canvas, radius scales with average of both dimensions rl.DrawCircle(0, 0, 10.0 * (scale_x + scale_y) * 0.5, rl.WHITE) // Scale the polygon - position and radius scale rl.DrawPoly( vec2(10.0 * scale_x, 10.0 * scale_y), 5, 8.0 * (scale_x + scale_y) * 0.5, time * 90.0, // raylib does rotation in degrees outside of raymath rl.Color{u8(math.pow(math.sin(time / 3), 2) * 255), 100, 100, 255}, ) // Scale the bezier curve points and thickness points := []rl.Vector2 { {-20.0 * scale_x, 20.0 * scale_y * time_mod}, {-15.0 * scale_x, -8.0 * scale_y}, {6.0 * scale_x * time_mod, -3.0 * scale_y}, {8.0 * scale_x, 2.0 * scale_y}, } rl.DrawSplineBezierCubic( raw_data(points), i32(len(points)), 2.0 * (scale_x + scale_y) * 0.5, rl.GREEN, ) }
Nice! I have no idea what it is, but I like looking at it. This is just some carelessly drawn shapes on a canvas, the next step is spending more time playing with the systems and seeing what kind of interesting patterns emerge. For now though, the main system is in place, so I'll wrap this up.
There's still plenty to do here, clearly. Even in a game where all the designs are highly abstract, it would still be beneficial to allow an entity to shift between different animation modes (e.g idle
, attacking
… etc). As I stated earlier, I am trying to minimize the amount of state the Procedural_Animation
struct itself holds in an attempt to mitigate complexity creep - I need to do some more thinking on what actually constitutes 'good design' in a wacky project like this.
I also want to explore shaders - these procedurally generated canvases are just textures behind the scenes, so I should be able to interact with them in a shader system the same way. That's beyond the scope of this post, as my initial explorations in shaders taught me that the learning curve is pretty steep - I've got some skill issues to overcome first.
That's it for this one, bye!