Devlog: Rootin Tootin Tiltin Shootin, a Pulp-based tilty gun game

So I'm much better at starting things than I am at finishing them, so while development on Crystal Daze ticks along, I wanted to try out some other ideas in Pulp. One of those builds on the use of the accelerometer as an experimental input like in Lockspinner, but typing out accelerometer is hard so I'll just call it "tilty" sometimes.

Rootin Tootin Tiltin Shootin is an idea for an ongoing game that I can release and update in stages. The basic idea is to tilt the Playdate to control a crosshair on screen, and build up a set of shooting-style minigames that can be played through. (Caveat: The key word here is "experimental". I'm seeing this project in the manner of old lightgun-style technology which was fantastic when it worked, but often somewhat dubious when ported to newer technology such as Wiimotes. Sadly there's no "MotionPlus" adapter for the PD, yet, so let's expect some fairly iffy physics here...)

Hopefully this devlog won't just cover pulp and the tilty, but also my (amateur) approach to game design and (more professional) code structure in general.

Step 1. The Thinky Bit

All my favourite ideas start out away from the screen, and the challenge of "could you do this in Pulp?" is a question I tend to come back to when I'm daydreaming.

I wanted to try using tilty as an input mechanism, but the most obvious application of rolling a ball around was too obvious. (Check out MAZE for a fine implementation of this though.) I wanted something a bit more VR like, but also wanted to keep a fairly "tight" relationship between moving the PD, and moving something on screen - that is, a natural control feel where your movement on screen reflects your movement of the device, rather than the slower, more momentum-based approach of Maze, Super Monkey Ball, etc. A crosshair seemed like a good fit, so I went with this.

Two bits of Pulp were important for this:

  1. The accelerometer (tilty) code, obvs.
  2. Decimal drawing to get the crosshair to move pixel-by-pixel, instead of tile-by-tile.

Also I figured I would need:

  1. Maths. :scream:

In my head, it seemed feasible. Time to fire up Pulp.

Step 2. Basic Setup

With experimental stuff, I tend to start with the most basic setup possible, in order to test things out. On this project, that came down to:

  1. The player tile, which handles the input from the tilty and drawing the crosshair, as well as any other inputs such as shooting.
  2. The room tile, or "range" which sets up what the player can see, and then handles the effects of any player events.

For future expansion (ie different rooms for different stages), I want to set up a bunch of generic player code, but the player should not know anything about the room it's in. And conversely, the room should not care about what the player is doing, as much as possible.

But that's thinking ahead. For now, I just wanted to see if the building blocks could work or not.

First up, I just played around a bit with what the accelerometer events reported. For this, I dug out the old accelerometer info tool I'd put together before and ran it on device. This is really important, because a) the pulp and SDK simulators don't offer a complete range of device movements AFAIK - 3D space vs 2D space blah blah blah, and b) I really struggle to understand how the simulated Accelerometer control maps to real-world orientation anyway.

Looking at the info on device, I realised one important thing - that the forwards/backward tilt was really important. If the device is being held up, in front of your face, then tilting around the y axis (by moving your left/right hands forwards or back) is effectively ignored.

In other words, you need to tilt the screen away in order to get meaningful controls. (This is why you have to calibrate your Wiimote by putting it on a level surface all the time.) But you don't need to assume that the device is flat, in the way that a ball-rolling game equates the level of the device to the level of the floor - here, I wanted to sacrifice a little bit of accelerometer accuracy to feel like you're raising the screen up and looking ahead.

Accelerometer ranges measure from -1 to 1 but that's not useful - we want to keep the screen in view while playing! Using the tool to get an initial position for the centre of the screen, and to measure a suitable range of movement in each direction, I set up some basic variables to use in our code, along with some quick calculations to set our central pixel on screen. These could also be hardcoded easily, but hey, we only have to do it once and computers are good at these things.

// Player script:
on load do
	
	// Screen width is 25 tiles = 200px
	// Screen height is 13 tiles = 104px
	
	// Set player to centre. Note px and py refer to the centre spot - we draw
	// the target _around_ this, not from it, later on.
	centre_x = 25
	centre_x *= 8
	centre_x /= 2 // = 100
	
	centre_y = 15
	centre_y *= 8
	centre_y /= 2 // = 60
	
	// Accelerometer position that device is in to centre the crosshair.
	// In future, we might allow these to be calibrated, but let's keep it simple for now
	ax_centre = 0
	ay_centre = 0.7
	// az_centre = 0 // not needed
	
	// Now we set up our "field of play" - these map up/down/left/right movement to the screen edges
	// The ax movement range will run from -0.3 to 0 (centre) to 0.3
	// The ay movement will run from 0.5 to 0.7 (centre) to 0.9
	ax_range_right = 0.3
	ay_range_top = 0.9
end

In other words, for each tilty axis X and Y, we have a range of values reported by Pulp's events (event.ax and event.ay) and a number of pixels on screen, and we want to map the ax/ay inputs to pixel position on screen.

Using some quick maths we can then work out the relative ranges for reference. This is less useful for ax, which uses the tilty centre as the screen centre anyway. But can be useful for ay, which is offset as we're tilting the screen towards us initially. (I'm pretty sure some of this code is unnecessary, but it took a few goes to get it to work, and I'm reluctant to fix things if they're not broken :joy: )

// Still in player's load event:

	// Now we can calculate the relationship between the centre pixel and the ax/ay axiseses
	ax_range = ax_range_right
	ax_range -= ax_centre // = 0.3
	
	ay_range = ay_range_top
	ay_range -= ay_centre // = 0.2

At this point, we just have a bunch of centres and ranges set up. We still need to actually do something useful. We'll need a quick player icon to move around, which is easy. A quick double circle will do for now, noting the transparency.

image

For digital drawing, we'll also need a totally transparent player tile, to cover up previous draws.

Step 3. Actual movement

Next I just wanted to see if the idea would work, so I hooked up the code to check accelerometer data to the player's draw event. This took a few goes, because I was playing with which direction felt more natural for the x-axis tilting, and because there's a lot of conversion going on between tiles and pixels, between accelerometer ranges and screen ranges, and between absolute and relative offsets. The rough approach is:

  1. Convert the actual ax/ay data into a percentage of our maximum ranges worked out above. eg tilting the screen three-quarters of our maximum range needs to be turned into 0.75. If the tilt for our maximum range in one direction is 0.3, and the event data is reading 0.2, that gets converted to 0.6666 (ie. 0.2 / 0.3). For the y axis , this is slightly more complicated as we have to remove our "centre" point first.

  2. Apply these percentages to calculate the number of pixels, based on our centrepoint. For example, if we should be two-thirds in the right of the screen, we need to calculate (0.6666 * 100 = 66.66) as 100 is half the screen, and then add this to the centre (again, 100). When the accelerometer tilts to the left, two-thirds will be negative, so the calculation becomes ((-0.6666 * 100 = -66.66) + 100).

  3. Then we need to take off 4 pixels (half a tile) to account for our actual crosshair point being in the middle of our player tile, and convert our pixel figures to tile-based numbers so that draw works properly, which just means dividing by 8.

Or, as the code ends up:

on draw do
	hide
	
	// map accelerometer to target position
	// to do this, we compare the ax/ay figure with the range, eg if our maximum ax
	// to the right is 0.3, event.ax = 0.3 would be 100%, or 1.0 and event.ax = 0.15
	// would be 50%, or 0.5.
	ax_offset = event.ax
	ax_offset -= ax_centre
	ax_offset /= ax_range
	
	ay_offset = event.ay
	ay_offset -= ay_centre
	ay_offset /= ay_range
	
	// Now we need to flip the ay axis
	// Actually, no, keep it like ax - like a ball rolling
	// ay_offset *= -1
	// now calibrated to our intended ranges set on load
	
	// Convert this into screen x and y  for the target centre
	// This is the same as where the original centre was
	target_centre_x = ax_offset
	target_centre_x *= centre_x // This might be negative if left of centre
	target_centre_x += centre_x // Add or subtract from the centre point
	
	target_centre_y = ay_offset
	target_centre_y *= centre_y // This might be negative as above
	target_centre_y += centre_y // Add or subtract from the centre point
	
	// Now remove 4 for the actual drawing position
	target_draw_x = floor target_centre_x
	target_draw_x -= 4
	
	target_draw_y = floor target_centre_y
	target_draw_y -= 4
	
	target_draw_pixels_x = target_draw_x
	target_draw_pixels_y = target_draw_y
	
	// Finally convert pixels to tiles
	target_draw_x /= 8
	target_draw_y /= 8
	
	draw "player" at target_draw_x,target_draw_y
end

Hooray, we have something we can test! Running it up in Pulp shows that it does, in theory work:

rrts-devlog-1

Loading it up on device gives us something that does, technically work, but now we start to see some of the challenges in doing experimental work. In the real world, it turns out that the accelerometer info is as jittery as hell:

rrts-devlog-2

But I'm pleased with the progress as a proof of concept - we have device movement mapped to screen movement!

Step 4. A few tidies

The other issue is that there's nothing stopping us going off the screen. Let's address that first by just checking for some minimum and maximum pixels, remembering that our maximum needs to be one tile less than the overall screen, to keep space for the tile to be drawn.

// In player's load event just for convenience, but you could skip this and hardcode values later if you wanted
	// Max x and y in pixels = number of tiles * 8
	max_x = 24 // last tile - 1, to keep player tile on screen
	max_x *= 8
	
	max_y = 14 // as above
	max_y *= 8
// In player's draw event, before setting target_draw_pixels_x and target_draw_pixels_y

	// now check for being outside limits of screen
	if target_draw_x<min_x then
	  target_draw_x = min_x
	elseif target_draw_x>max_x then
	  target_draw_x = max_x
	end
	
	if target_draw_y<min_y then
	  target_draw_y = min_y
	elseif target_draw_y>max_y then
	  target_draw_y = max_y
	end

Better.

rrts-devlog-3

Then, to add in some tidying up of the digital drawing, we just draw the transparent player tile (called "player_blank") over the 4 tiles touching the crosshair where it was last drawn:

// At the top of player's draw event, before anything else happens

	// remove old draw
	target_draw_x = floor target_draw_x
	target_draw_y = floor target_draw_y
	draw "player_blank" at target_draw_x,target_draw_y
	target_draw_x += 1
	draw "player_blank" at target_draw_x,target_draw_y
	target_draw_y += 1
	draw "player_blank" at target_draw_x,target_draw_y
	target_draw_x -= 1
	draw "player_blank" at target_draw_x,target_draw_y

Better still.

rrts-devlog-4

OK, that's enough for one session. Next time we'll clear up the jittering, and start to add in something to shoot at...

7 Likes

This is cool. Honestly, I think a drawing program that used the tilty, while probably being a little frustrating, might be fun for someone like me.

1 Like

Yes, been wondering if that could be an alternative to all this violence! It would definitely be fun to play with some other interface ideas once I've got this initial idea out of my system.

Time to move on. This time, we can add a few more pieces of the puzzle and - very loosely - maybe start to call this a "game".

Step 5. Smoother movement.

We got movement working last time, but the Playdate's accelerometer ("tilty") is fairly inaccurate and the crosshair ends up jumping around the place a fair bit. We'll smooth it out by averaging the readings over a few cycles.

Got to be a bit careful here as we're translating between tilty data (event.ax and .ay), pixels, and tile measurements, so it's easy to get confused. I want to average out the raw tilty data just to get the hard work out the way and not have to think about it later in the code.

I'm going to average the last 5 frames out, which is pretty arbitrary but a good starting point. Pulp doesn't do arrays, so this means I'll need to take the average of 5 sets of x/y variables. (I could set up 4, and transfer event.ax/ay after taking an average, but I'll keep all the code together for cleanliness).

I also thought about moving the latest reading into set 5, after moving the previous set 5 into 4, 4 into 3, etc. But that seems inefficient, so instead I just set up a single "pointer" variable to move to the next set each frame.

When the player enters the room:

	// Our counter for storing current accelerometer readings in our samples "array"
	crosshair_sample = 0
	
	// Set up all samples for averaging, otherwise we'll start by averaging the
	// current position with 3 zeros
	crosshair_sample_1_x = event.ax
	crosshair_sample_1_y = event.ay
	
	crosshair_sample_2_x = event.ax
	crosshair_sample_2_y = event.ay
	
	crosshair_sample_3_x = event.ax
	crosshair_sample_3_y = event.ay
	
	crosshair_sample_4_x = event.ax
	crosshair_sample_4_y = event.ay
	
	crosshair_sample_5_x = event.ax
	crosshair_sample_5_y = event.ay

On the player's draw event, we "intercept" event.ax/ay, store them in one of our 5 variable sets, and take an average by adding them and dividing by 5:


	// Now get the current accelerometer reading
	ax_offset = event.ax
	ay_offset = event.ay
	
	// v0.2 - average out over a couple of frames
	crosshair_sample += 1
	if crosshair_sample==1 then
		crosshair_sample_1_x = ax_offset
		crosshair_sample_1_y = ay_offset
	elseif crosshair_sample==2 then
		crosshair_sample_2_x = ax_offset
		crosshair_sample_2_y = ay_offset
	elseif crosshair_sample==3 then
		crosshair_sample_3_x = ax_offset
		crosshair_sample_3_y = ay_offset
	elseif crosshair_sample==4 then
		crosshair_sample_4_x = ax_offset
		crosshair_sample_4_y = ay_offset
	elseif crosshair_sample==5 then
		crosshair_sample_5_x = ax_offset
		crosshair_sample_5_y = ay_offset
		// And reset the loop
		crosshair_sample = 0
	end
	
	// Calculate average from this and last 3 frames
	ax_offset = crosshair_sample_1_x
	ax_offset += crosshair_sample_2_x
	ax_offset += crosshair_sample_3_x
	ax_offset += crosshair_sample_4_x
	ax_offset += crosshair_sample_5_x
	ax_offset /= 5
	
	ay_offset = crosshair_sample_1_y
	ay_offset += crosshair_sample_2_y
	ay_offset += crosshair_sample_3_y
	ay_offset += crosshair_sample_4_y
	ay_offset += crosshair_sample_5_y
	ay_offset /= 5
	

(Side note: Understanding how averages work is one of the most useful things you can ever learn.)

This gives us something that feels less jumpy, at least (captured using device control in the Simulator):

rrts-devlog-5

Step 6. Setting up targets

An empty shooting gallery is pretty dull, so we need something to point our newly developed pistol / laser beam / magic wand at. What could be be less simple than a box? The plan is just to generate a random box on screen, set up code to handle the player pressing the "A" button, and increase a score variable if the cursor was inside the box at the time the button was pressed.

As mentioned before, I like to separate code out into tiles like I would classes in Object-Oriented Programming. (Tile-Oriented PulpScript, or TOPS?) In other words, the player should store the code to do with how the player acts, and the room should store the code to do with how the room acts, and they should communicate between themselves using custom events.

So the room, called "range" is responsible for setting up targets, handling how shots are received, and keeping track of score. Let's leave scoring aside for the moment. To set up targets, we'll need variables to track:

  • How long to wait between one target being shot, and the next being shown
  • Whether a target is being shown
  • The position and size of a target (x and y for top, bottom, left and right sides)
  • The maximum and minimum possible sizes for a target, and the range on the screen in which to show them

I set these up when the player enters the room, so that I can set up different things when I get round to adding other rooms.

In my "range" room:

on initRoom do
	// Is there a target o screen?
	target_shown = 0
	
	// Where is the target being shown?
	target_xt = 0
	target_yt = 0
	target_xb = 0
	target_yb = 0
	
	// How long til the next target gets shown?
	// Keep this consistent for now, to not be a factor in scores
	target_countdown = 3
	
	// These need to be inset slightly as the centre of the target can't be at
	// the edge of the screen
	target_min_x = 5
	target_min_y = 5
	
	target_min_w = 20
	target_min_h = 20
	target_max_w = 50
	target_max_h = 50
	target_w_range = target_max_w
	target_w_range -= target_min_w
	target_h_range = target_max_h
	target_h_range -= target_min_h
	
	target_max_x = 200
	target_max_x -= target_min_x // Make sure we don't go beyond the space that the centre of the target can't reach
	target_max_x -= target_max_w
	target_max_y = 120
	target_max_y -= target_min_y
	target_max_y -= target_max_h
	end

This then gets called from the enter event:

on enter do
  call "initRoom"

As I plan to use the fill command to show the target box for now, rather than using swap command to alter the tiles between frames (one for later), the target_shown variable is needed. Other rooms will set this all up differently.

Then we need a custom event to create a new random target box, and tell the room to show it:

on showTarget do
	target_x = random target_max_x
	target_y = random target_max_y
	target_w = random target_w_range
	target_w += target_min_w
	target_h = random target_w_range
	target_h += target_min_h
	
	target_x_right = target_x
	target_x_right += target_w
	target_y_bottom = target_y
	target_y_bottom += target_h
	
	target_shown = 1
end

This doesn't actually show it. To do that, we need to check target_shown when drawing the screen, and use fill. The draw event is called on the Player, so I tend to use a custom player_draw event to ask the current room to do anything at draw time:

In player's script:

on draw do
    ...	
	tell event.room to
		call "player_draw"
	end
	...

Then, in the room's script, we can finally draw our box:

on player_draw do
	if target_shown==1 then
		fill "black" at target_x,target_y,target_w,target_h
	end
end

This covers generating a random box target and showing it, but you'll notice we haven't called the showTarget event above yet. To do this the first time, we just have to wait the number of seconds set in target_countdown after entering the room. I put this into a separate custom event though, so that we can re-use it later:

on resetTargetCountdown do
	wait target_countdown then
		call "showTarget"
	end
end

And on enter becomes:

on enter do
	call "initRoom"
	call "resetTargetCountdown"
end

rrts-devlog-6

Step 7. Some actual rootin tootin shootin

Last bit for now. We have all the pieces in place, we just need to check the cursor's midpoint when the player presses A, increase a score if it was in the right place, and trigger a new target box.

First I'll set up a score variable. As the scoring system might change for each room, I'll just do this in the initRoom event.

  score = 0

Like the player passing the draw event to the room as player_draw above, I normally like to set up a player_confirm event as well. As this is a dedicated shooting gallery though, I'm renaming it to playerFired. This sets up some "parameter" variables (starring with p_) for the script that catches it, based on the crosshair's current position. For now, I'm converting the tile-based position back into pixels, but I could optimise this by re-using the pixel position from the draw event.

In player's script:

on confirm do
	p_crosshair_x_pixels = crosshair_draw_x
	p_crosshair_x_pixels *= 8
	p_crosshair_x_pixels += 4
	p_crosshair_y_pixels = crosshair_draw_y
	p_crosshair_y_pixels *= 8
	p_crosshair_y_pixels += 4
	tell event.room to
		call "playerFired"
	end
end

The room script then deals with this by playing a sound effect called "shot", and checking the passed in pixel x/y values against the current target's position (if a target is shown). If the position is on target, then we change target_shown to 0 to stop it being drawn, increase the score by 1, and request a new target in a few seconds:

on playerFired do
	sound "shot"
	
	if target_shown==1 then
		if p_crosshair_x_pixels>=target_x then
			if p_crosshair_x_pixels<=target_x_right then
				if p_crosshair_y_pixels>=target_y then
					if p_crosshair_y_pixels<=target_y_bottom then
						// A hit!
						target_shown = 0
						score++
						call "resetTargetCountdown"
					end
				end
			end
		end
	end
end

I then just show the current score using label in the player_draw event in the room:

label "{score}" at 0,14

Et voila:

rrts-devlog-7

Is it playable? Yes! Is it exciting and any good? Maybeeee. Is it a game? In theory, you can get someone to time you and see what score you can get in 60 seconds, so yes, it's a game! Is it catalogue-ready?

Well... Maybe it does need its own timer after all. But that's for another day.

Something like Ready Steady Draw perhaps? I had a few hours free, so took the core tilty code, added in some brush and mode selection, and now it's a sketching... thing. Hoping to add more brushes, fill mode and a screen wipe to it, but give it a go and send me any feedback!

Ooh, cool! I'll give it a try soon.