Intro

The Noodle Engine lets you make cool 3D PICO-8 pictures with little effort. All you need to do is download the Noodle Engine cart, add a small amount of code to the end, and let the engine handle the rendering to get stuff like this:

PICO-8 Tree

Using the cart

Every Noodle Engine cart stores multiple pictures.

[Left], [right] and the [O] button (Z on the keyboard) can be used to switch between pictures.

The [X] button (X on the keyboard) will restart the current picture. If the picture definition uses randomness, you'll get different results each time.

Pressing [down] hides/shows the information bar on the bottom of the screen.

Anatomy of a picture

To create new pictures with the engine, you'll have to write the code that defines them. Here is an example of a simple Noodle Engine picture:

function spiral()
  move({fwd=1}, {ry=0.01}, {sz=0.999}) -- move the cursor forward, rotate left, scale the Z axis down a bit
  tube(4,10)                           -- draw a tube from the previous position to the current one
  shape(spiral)                        -- continue the spiral from here, calling this function again
end
-- let the engine know what it should draw and where
picture("spiral",{
 shape=spiral,
 origin={70,0,70}
})

There are four basic parts to this, which I will explain in order.

  move({fwd=1}, {ry=0.01}, {sz=0.999})

You can think of the Noodle Engine as having a cursor in 3D space. This cursor stores a position, rotation, scale and color, which you can change by issuing move() commands. Move applies all transformations passed to it as parameters, in the order you specify them. Here, we move forward one unit ({fwd=1}), rotate a bit to the left ({ry=0.01}) and shorten the Z-axis a bit ({sz=0.999}), defining what a single segment of a tightening spiral does.

  tube(4,10)

Now that we moved the cursor to a new place, we can use it to draw a primitive. The tube() primitive is what gave the Noodle Engine it's name. It makes a solid tube from the previous position of the cursor to the current one - if you do this repeatedly, the result is a long noodle. Here, we make a squarish tube with 4 sides and 10 units in width.

  shape(spiral)

This is the final piece of the puzzle - the shape() function spawns new shapes at the position of the cursor.

The newly-spawned shape will inherit the state of the cursor (position, rotation, etc.) of the current one. However, the new shape gets an independent cursor of its own. This means that any transformation the new shape applies when its function is called will be limited to itself (and the shapes it spawns).

In our example, we give shape() the name of the function we're currently in, making the engine run it over and over (since each new run adds another). We apply transformations between each run, so the result will be an infinitely regressing spiral, like so:

Spiral Example

But to actually get the engine to draw something, we have to tell it about the function we decalred. We do it by calling picture() with the name of our composition, and some parameters in a table, pointing to how it's to be drawn:

picture("spiral",{
 shape=spiral,
 origin={70,0,70}
})

This tells the engine to start by calling the spiral() function we defined, and to start at the point [x=70,y=0,z=70] in 3D space. There are more parameters you can pass to picture() - they are described further down in the "Initial state" section.

Transformations

The basic building blocks we use to make pictures are transformations. We can use them in two ways.

move({fwd=10}, {ry=0.01})

Passing them to move() affects everything that will be done in the future. This changes the current 3D cursor, so all future shapes drawn in this function or spawned by it will be affected.

tube(4,40, {scl=2})
shape(spiral, {scl=0.99})

We can also pass them to other functions after all the required arguments. This will make these transformations apply only to the function call they're given to, and won't modify the cursor. Future shapes won't be affected.

Here is a full list of transformations you can pass along with their effects:

{fwd = distance}, {bck=...}, {lft=...}, {rgt=...}, {up=...}, {dn=...}

Moves the cursor distance units in the specified direction. This is relative to the cursor, so previously used rotations and scaling will apply. {fwd} is the most useful one, but the other can also be used to tweak the shape.

{fwdg = distance}, {bckg=...}, {lftg=...}, {rgtg=...}, {upg=...}, {dng=...}

These also move the cursor, but along the global coordinate system. This means that the state of the cursor does not affect the direction. {fwdg} always moves into the screen, for example.

{rx = angle}, {ry=...}, {rz=...}

These rotate the cursor around the specified axis. {rx} rotates up-down (pitch), {ry} rotates left-right (yaw), {rz} rotates clockwise-counterclockwise (roll). These are all from the perspective of the cursor - after you rotate, this changes the axes used for the next transformation. The angles are PICO-8 angles, with 1 meaning 360°.

{rxg = angle}, {ryg=...}, {rzg=...}

These rotate around the global X, Y or Z axis. They are unaffected by what was happening to the cursor beforehand.

{scl = scale}, {sx=...}, {sy=...}, {sz=...}

These scale all future operations be a given amount. {scl=2} makes everything 2x bigger. {sx}, {sy} and {sz} apply the scaling only along one axis. {sx} can be used to make everything wider/narrower, {sy} taller/shorter, {sz} longer/shorter.

{hue = change}, {sat=...}, {lgt=...}

These modify the color of the primitives that you draw. The engine uses the HSL color model, which lets you manipulate the hue, saturation and lightness separately. The values you pass to the transformations are relative changes: {sat=0.01} makes the color a bit more saturated (+0.01), it doesn't set saturation to 0.01. All values, including hue, are in the range <0-1>.

{clr = {h,s,l}}

This one sets a new HSL color to use. This is absolute, not a relative change: {clr={0.3,1.0,0.5}} will make all future shapes be drawn in a fully-saturated green.

{t = number}

This one resets the internal t counter that counts how many shapes were drawn before the current one. See "Shape functions" below for more information on this counter.

Primitives

All the transformations are useless if you don't draw anything. These are the functions you can use to do that. They all respect the current position, rotation, scale and color of the cursor.

tube(faces, width, ...)

Draws a tube from the previous position of the cursor to the current one. The tube will have the specified number of faces and the specified width (scaled by the cursor).

strip(width, ...)

Draws a flat strip from the previous position of the cursor to the current one. The strip will have the specified width.

sphere(radius, ...)

Draws a sphere at the current position of the cursor with the specified radius.

cube(size, ...)

Draws a cube at the current position of the cursor at the specified size ("radius" of the cube, meaning the sides will be 2*size units in length).

disc(radius, ...)

Draws a flat circle of the specified radius.

cap()

Draws a polygon that caps the end of the last tube drawn.

Additional functions

Here are some other useful functions that don't actually draw anything, but modify state used by other functions.

lock()

Overrides the global coordinate system with the coordinate system of the current cursor. This changes the axes used by {fwdg}, {rxg} and other global transformations. This is similar to move() in that it applies to all future shapes spawned by the function it's called from.

reset()

Resets the rotation/scale of the cursor to the state it had the last time lock() was called. Position and color will be unaffected. If lock() was never called, the reset will restore the cursor to the default state (again, except for position).

This is useful mostly for spawning a new shape without having to take into account all the transformations that happened so far.

Shape functions

Shape functions are the functions you declare to tell the engine what to do. They are pass to picture() (to let the engine know where to start) and to shape() (to spawn new shapes where the cursor is).

You should never call them directly - the engine has to have control over the recursion so it can draw things a few at a time without hanging the PICO-8.

Shape functions can take parameters. One such parameter, called t, is provided automatically by the engine. It starts at 0 and is incremented every time you spawn a new shape - this means that t is always the number of shapes that preceded you in the chain that spawned you.

You can reset the t parameter by using a {t=0} transformation.

Custom parameters

You can also pass parameters of your own. Writing shape(my_function, 1,2,3) means that the engine will call my_function(t,1,2,3) - adding the standard t parameter in.

Here is the spiral example again, using a parameter instead of scaling:

function spiral(t,distance)
  move({fwd=distance},{ry=0.01})
  tube(4,10)
  shape(spiral,distance*0.998)
end

Initial state

There are also some parameters that you can pass to picture() to influence the initial state of the cursor. You pass them all inside a table like this:

picture("name_of_your_picture",{
 parameters...
})

Here are all the possibilities. Only shape=some_function is mandatory, the rest has reasonable defaults.

shape = shape_function

This is the only mandatory parameter. It points to the function that will be used to start the drawing process.

origin = {x, y, z}

Make the cursor start at position [x,y,z] in 3D space.

clr = {h, s, l}

Make the cursor start with the specified HSL color.

light = {x, y, z}

Defines the direction the picture is lit from.

bg = color index

Changes the background to the specified PICO-8 color.

fog = {start-z, end-z}

Define how deep into the screen the distance fog used for rendering will start and end. Everything beyond end-z will be invisible, and everything closer than start-z will be unaffected by the fog. Points in between will be partially affected by the fog.

fogc = color index

Defines the color of the fog.

backface_cull = true/false

By default, the engine does backface culling - it does not draw things that face away from the screen. You can change this behavior by passing this parameter.

Closing words

That concludes our whirlwind tour of what The Noodle Engine can do. I tried to explain it as best I could, but if you have any questions, I'm happy to answer them on Twitter or via e-mail. The Noodle Engine cart also has some examples that you can play with to get you started.

Have fun, and if you make something cool with the engine, do let me know!