lipu pi jan Niko


how I made Sheepspin (the NOVA 2022 wild compo winner)

2022-08-18

This last weekend was the NOVA 2022 demoparty, which was really fun and a great time. I was involved in 5 productions at the party (both solo and group prods). Most were shitposts but my TIC-80 intro, Sheepspin, turned out to win the wild compo. I really didn’t expect that but I got a few questions about how it worked and I figured I’d do a little writeup of it. This won’t be a complete deep dive into everything - there honestly isn’t all that much to dive into - so I’m going to go scene by scene1 and talk about what is most interesting in that scene, using code snippets and such. I’m assuming that you’ve seen the demo if you’re reading this, and have basic knowledge of TIC-80 already.

Watch sheepspin here!

The Scroller

The scroller itself is very simple and made by just printing text with the built in print() function in tic80. I print it a few times in different colors with slight offsets to make it a bit more interesting.

The background came from something I made while practising sizecoding, but I ended up liking it as a background. I couldn’t explain how it works because I pretty much made it by accident, but you can find the code that generates it in the source (the intro_bg() function). The wavy effect is done by changing the screen offset memory address ( 0x3ff9) each “scanline” in the SCN function:

function SCN(l)
	if scene == 0 or scene == 1 then
		poke(0x3ff9,math.sin(l/8)*4)
	end
end

The occlusion of the background in the first part of the scene is done by filling the screen in the OVR function (overlay) in color 1 and then drawing a circle in color 0. The OVR function replaces color 0 with whatever is being drawn in the TIC function and isn’t affected by SCN, so I can use this to occlude parts of the background with a bouncing circle (I also draw the scroller in the OVR layer to avoid getting affected by SCN changes). The transition from occluded to unoccluded is synchronised with the music by reading the memory address 0x13ffd which contains the currently playing pattern in the song (this is how I synchronise pretty much every synchronised effect in the demo).

The Rotating Sheep

I knew as soon as this became an intro rather than just a scroller that I wanted to have a rotating sheep on it, as rotating sheep became an unofficial theme of NOVA 2022 (thanks ToBach). I wanted the sheep to rotate in 3d so I had to build a 3d rendering system for tic-80 from scratch.

Rendering a 3d object starts with a mesh, a collection of triangles in 3d space. These then have to be projected (converted from 3d coordinates to 2d screen coordinates) and rasterised (actually drawn to the screen as triangles). There are other steps that most renderers take, like shading, but I completely ignored these for this intro. In TIC-80’s case, basic rasterising is already solved by the tri() function, so drawing 3d objects only requires new code for projection.

Projection is usually somewhat complex, and when I first started looking into 3d every guide I could find covered perspective projection, which involves some pretty gnarly math, but thanks to some helpful folks on the FieldFX discord I decided to use the far simpler orthographic projection.

I store a mesh as a nesting of lua tables, with a mesh containing tris and a tri containing x,y,z points and a color. Each point is in the range -1 to 1 in order to simplify transformation math. Projecting this using an orthographic projection is, essentially, just ignoring the z value and drawing all the triangles on the screen in z-order, so the triangles that are towards the back of the model are drawn over by those at the front.2. So projecting for the TIC-80 only requires scaling the points from -1 to 1 to (say) 1 to 100.

function project(v)
	local x = v.x 
	local y = -v.y 
	local z = v.z
	
 	local x = (x + 1)/2 * 100 + 40
 	local y = (y + 1)/2 * 100 
 	local z = (z + 1)/2 * 100 -- scaling for screen
	return x, y, z
end	

function z_order(a,b)
	 return (a.p1.z+a.p2.z+a.p3.z)<(b.p1.z+b.p2.z+b.p3.z)
end

function TIC()
	[...]
	table.sort(screenmesh,z_order)
	for i, tr in pairs(screenmesh) do
		x1,y1,z1 = project(tr.p1)
		x2,y2,z2 = project(tr.p2)
		x3,y3,z3 = project(tr.p3) 
		tri(x1,y1,x2,y2,x3,y3, tr.c)
	end
	[...]

This code snippet contains the entirety of the logic used to draw the sheep in sheepspin. Seriously.

To create models that this can render, I created them in blender and assigned materials named 1,2,3, etc for each tic80 palette color, and then used an obj converter I wrote to convert them. obj is a (fairly) simple format, so you can probably understand it from reading the source.

To rotate the sheep, I use this math, which I think is a pretty usual rotation matrix. I don’t know how it works. greets to dave84 who wrote it!

	tx = v.x * m.cos(angle) - v.z * m.sin(angle)
  	ty = v.y
  	tz = v.x * m.sin(angle) + v.z * m.cos(angle)

Space

The sheep then bounces off into space. The bouncing was a bit annoying to do but I ended up doing something that (and this is a theme of this post) I found while hacking around and don’t really understand. The background stars are a set of x and y positions stored in a table - each frame they are moved by 1 pixel and then any that go off-screen are redrawn at the other edge of the screen with a random y position, which was pretty simple. I was initially planning a proper starfield but found that the simpler setup worked better for the “story”.

The aliens are sprites, the alien ship is made of ellipses. I added a constant sine “bobbing” to the ship to make it more interesting. The abduction is just a triangle drawn from the ship and then scaling down the sheep model. It’s at a weird angle because then I didn’t have to do the math to actually scale the sheep around a point other than the 3d scene origin.

The circles in the outro are another thing I just made by hacking around and don’t quite understand, here they are:

for i=8,240,8 do
	for y=4,136,8 do
		circ(i+y/2,
			y,
			sin(i/16+(t/10))*4,
				(i%3)+9)
	end
end

So there we go! I made a compo winning demo while not really understanding half the source code, despite writing it. Huge thanks to everybody who helped me out with this as my first real demo production!


  1. note to anybody diving into the source, I’m talking about scenes in the sense of the flow of the demo, rather than in the sense of the scene variable. ↩︎

  2. this is very suboptimal for anything that has to run quickly as you are drawing many out of sight triangles, backface culling and other techniques are used to speed this up but I didn’t implement them for this, as I didn’t need to. ↩︎