Allow me to preface this by saying that I'm new to Pico-8 and Lua, but have already fallen in love with it. I've already begun following the community on the BBS, subreddit, and Twitter, and I see a lot of questions about how to perform specific tasks that are necessary for almost every game. Anyone who has ever used a game engine previously may have had a lot of the basic things such as controls, animation, collision detection, or even AI handled for them, so they may not know how to roll their own code for such things.
I'm currently working on my first Pico-8 game, and have decided to document the process, and how I personally overcome various problems. Do know that my way is definitely not the only way - there may be solutions that work better, perform better, have smaller token counts, or are more elegant in general. These are just the solutions that I came up with. There isn't really any 'right' or 'wrong' per se, as long as it gets the job done, with the exception of 'lower token count = better' due to the limitations of the system. So, I'm going to try to solve these problems in the smallest possible token count that I can come up with.
Movement:
In almost every game, characters need to be able to move around the map. The most common control systems are 2-way (only up and down or left and right), 4-way (up, down, left, right), and 8-way (4-way + diagonals). Any of these are simple enough to do in Pico-8.
Here is a 2-way control system
function _init() player={} -- initialize the player object player.speed=1 -- set a property 'speed' to 1 in the player object player.x=0 -- set the initial x coordinate player.y=0 -- set the initial y coordinate end function _update() if(btn(0)) player.x-=player.speed -- move left at player.speed if(btn(1)) player.x+=player.speed -- move right at player.speed end |
Simple enough. Now 4-way:
function _init() player={} -- initialize the player object player.speed=1 -- set a property 'speed' to 1 in the player object player.x=0 -- set the initial x coordinate player.y=0 -- set the initial y coordinate end function _update() if(btn(0)) then player.x-=player.speed -- move left at player.speed elseif(btn(1)) then player.x+=player.speed -- move right at player.speed elseif(btn(2)) then player.y-=player.speed -- move up at player.speed elseif(btn(3)) then player.y+=player.speed -- move down at player.speed end end |
Using the elseif statements ensures that no more than one of these conditions can be met at a time, so it disallows diagonal movement. To allow diagonals for 8-way controls, simply change it to:
function _update() if(btn(0)) player.x-=player.speed -- move left at player.speed if(btn(1)) player.x+=player.speed -- move right at player.speed if(btn(2)) player.y-=player.speed -- move up at player.speed if(btn(3)) player.y+=player.speed -- move down at player.speed end |
Now multiple conditions are allowed to be met, allowing movement diagonally up+left, up+right, etc.
Animation:
Pico-8 doesn't currently supply any functions for animation, so you have to roll your own code for handling them. So, I wrote an animation function that anyone is free to use for their games, and it should be pretty simple to use and handle most use cases (currently it doesn't support sprites larger than 8x8 or any kind of sprite scaling, but if you need those things then it should be easy enough to modify).
-- object, starting frame, number of frames, animation speed, flip function anim(o,sf,nf,sp,fl) if(not o.a_ct) o.a_ct=0 if(not o.a_st) o.a_st=0 o.a_ct+=1 if(o.a_ct%(30/sp)==0) then o.a_st+=1 if(o.a_st==nf) o.a_st=0 end o.a_fr=sf+o.a_st spr(o.a_fr,o.x,o.y,1,1,fl) end |
To use it, simply do: anim(player,0,3,10), where the player is what we're animating, 0 is the animation starting frame (basically the offset), 3 is the number of frames in the animation, and 10 is the speed. The 'fl' (or flip) parameter is optional and defaults to false. If set to true, it'll flip the sprite horizontally. You can find more information about it here, and here it is in action:
Collision:
To handle collisions with map tiles, you probably want to set a flag on the tile's sprite itself (those are the little colored buttons in the sprite editor under the scale slider - they start at 0 and go up to 7, so there are 8 possible flags in total). In this example, we're going to check for flag 0 using fget() and we're going to use mget() to see if the player's bounding box overlaps with a solid tile's bounding box.
We're additionally going to check for collisions with the world bounds as well, so that the player can't move outside of the world. Also, I wanted a way to be able to toggle collisions on or off with the player, either with map tiles, world bounds or both. So let's look at the setup:
function _init() w=128 -- width of the game map h=128 -- height of the game map player={} player.x=0 player.y=0 -- collide with map tiles? player.cm=true -- collide with world bounds? player.cw=true end |
Here's we're defining the width and height of the map so that we can check for wold bounds collisions, and then we're setting up 'player.cm' and 'player.cw' to define whether they should collide with map tiles and world bounds. Now let's look at the collision detection code itself:
function cmap(o) local ct=false local cb=false -- if colliding with map tiles if(o.cm) then local x1=o.x/8 local y1=o.y/8 local x2=(o.x+7)/8 local y2=(o.y+7)/8 local a=fget(mget(x1,y1),0) local b=fget(mget(x1,y2),0) local c=fget(mget(x2,y2),0) local d=fget(mget(x2,y1),0) ct=a or b or c or d end -- if colliding world bounds if(o.cw) then cb=(o.x<0 or o.x+8>w or o.y<0 or o.y+8>h) end return ct or cb end |
Basically all this does is check if we want to collide with map tiles, and then looks for an overlap. It does the same thing with world bounds as well. If there's a collision, it returns true, and if not, then it's false. To use it, we'll add it to our movement code:
function move(o) local lx=o.x -- last x local ly=o.y -- last y -- 8-way movement if(btn(0)) o.x-=o.speed if(btn(1)) o.x+=o.speed if(btn(2)) o.y-=o.speed if(btn(3)) o.y+=o.speed -- collision, move back to last x and y if(cmap(o)) o.x=lx o.y=ly end |
What we're doing is allowing the player to move into the tile, but since they will get moved back if there's a collision before the frame is drawn, you won't see them appear to 'bounce back out'. Here it is in action:
You can read more about the collision function here.
AI:
AI is a more complex problem, and the solution really depends on your needs. For this case, we're going to be talking about Pacman AI, or how the pathfinding works and how to move the enemy around to chase the player. This is going to be rather complicated for me to try and articulate, even as simple as it is, but I'm going to do my best, so bear with me.
First, we need to start by thinking about what the rules are that the AI needs to follow. Here are the rules for my example AI:
- Enemy cannot reverse directions (so it can't be moving left and then immediately turn around and start moving right).
- Our target is the player, so it needs to find the shortest path to the player.
- Enemy must keep moving, so if it reaches a target it shouldn't stop, but instead keep moving, looping around the shortest path around the target.
We could get into complex pathfinding algorithms such as A* or jump points, but I want to keep it oldschool. It's not going to be perfect like the more complex algorithms, but will find the correct path to the player the vast majority of the time. Look up "Pacman hiding spots", and you'll see that the algorithm we're about to use isn't perfect 100% of the time.
So here's how we're going to do it: whenever the AI has multiple directions it can move to (such as reaching an intersection), it will evaluate which tile that it has the option to move to is the shortest distance from the player. To do this, we use the Pythagorean Theorem, which is the square root of (from x - to x) squared + (from y - to y) squared. In Lua, that's sqrt((fx-tx)^2 + (fy-ty)^2), which returns the distance from a starting point to an ending point in a line. We can make a function out of this since we'll be using it to evaluate all of our optional directions:
function dst(fx,tx,fy,ty) return sqrt((fx-tx)^2+(fy-ty)^2) end |
But how do we know if we're at an intersection? We can use our collision code to check if the tiles around us are open!
local cl=colm(ex-1,ex-1,ey,ey+7) local cr=colm(ex+8,ex+8,ey,ey+7) local ct=colm(ex,ex+7,ey-1,ey-1) local cb=colm(ex,ex+7,ey+8,ey+8) |
It's messy, I know, but it works. "cl" means 'collision left', "cr" means 'collision right', etc., so we're adding 1 to our current position in each direction in order to see if we'd collide with that tile, and if not, it's open. Now that we know which tiles are open, we check their distances to the target using our dst() function:
local ld=dst(ex-4,tx+4,ey+4,ty+4)
local rd=dst(ex+11,tx+4,ey+4,ty+4)
local td=dst(ex+4,tx+4,ey-4,ty+4)
local bd=dst(ex+4,tx+4,ey+11,ty+4)
More messy code. The reason for the +/- 4's is that instead of using the top-left of our potential tiles that we can move to, I'm using the center point of them, so it's just an x/y offset. I'm not sure if it helps make it more accurate at all, but in my head it seems like it would help slightly.
The last thing we need to do to evaluate which direction to move in is to make sure that we aren't about to reverse directions. For that, I've set a property of my enemy object that tells me which direction it's moving in. So... the opposite of that direction is invalid, then.
local lo=not cl and e.m!=1 -- "left open" is true if there's no collision left and we're not moving right local ro=not cr and e.m!=0 -- "right open" is true if there's no collision right and we're not moving left local to=not ct and e.m!=3 local bo=not cb and e.m!=2 |
Now we need to know which of our valid directions is the shortest, using what we found for "ld", "rd", etc.:
-- shortest distance, I set it to map width here just to make --it a number larger than than the possible distance between enemy and player local sd=w if(lo) sd=ld if(ro and rd<sd) sd=rd if(to and td<sd) sd=td if(bo and bd<sd) sd=bd |
Now we need to set his moving direction for the next time this code runs, so he won't reverse directions on us. We'll also go ahead and move him:
if(lo and ld==sd) e.m=0 if(ro and rd==sd) e.m=1 if(to and td==sd) e.m=2 if(bo and bd==sd) e.m=3 if(e.m==0) e.x-=e.speed if(e.m==1) e.x+=e.speed if(e.m==2) e.y-=e.speed if(e.m==3) e.y+=e.speed |
Here it is in action:
Whew! I hope that all made sense, but if not, let me know! I'll be happy to answer any questions, and amend the post as necessary to correct or clarify things.
I plan to keep documenting my progress and solutions to other problems as I find them throughout the development process of my game. Since this post is so huge, I will probably make continuations of it in new posts instead of adding further onto this monstrosity.
Here's some stuff I'll be trying to delve into for my game:
- Procedural map generation
- Multiple AI-controlled enemies, each having different targets (one might target the player's exact center, one might target the tile behind the player, one might target in front of the player, etc. to vary it up a bit and keep them from grouping together too much).
- Changing between game states, such as title screen, menu, winning and losing, etc.
- Implementing a demo/attract mode in which the player is AI-controlled
- Managing a player inventory and item pickups
- Multiple AI modes (chase, scared/fleeing)
- If the AI has multiple path options that are equidistant, choose a random one to make it less predictable
- Increasing the difficulty of the game as players progress through levels
- Scoring and keeping a hiscore
- Adding sound effects and music (although I likely won't get into how to use these tools, as there are already great tutorials out there for that)
- More as I begin to flesh out my game a little better
Thanks for reading, and I hope this helps someone! Happy coding :)
Great Scathe, thanks for that.
I was interested especially in AI.
I look forward to reading your articles.
I'm trying to advance my game, but I have very little free time.
Hey man, I'm glad you liked it! I feel like I did a terrible job explaining how the AI works though (it's really not at all complex, it's just hard to explain), so if you need clarification on anything, feel free to ask. Also, if you want to use any of that code, go right ahead - hopefully it saves you enough time to get some progress going on your projects!
sorry, this is off topic, i'm also from ohio, are do you have any plans to go to CCAG this summer? i feel like we should have some sort of pico-8 meetup around this show. later.
I actually had to look up what CCAG was, embarrassingly enough. No plans at this time, but I'll keep it in mind closer to the date and see if I can make it out there! Thanks for bringing it to my attention!
hi! thanks for posting this article, it was great to get me started off!
i'm using your two-way control scheme, and i was wondering if where was a way to get it to rest using a flipped sprite? i want it to be able to fact in the direction of the button that was last pressed (left or right) but right now, it will only face right.
how would i correct this?
Awesome post man!
I don't have any experiencie in Lua nor i'm a experiencied coder either.
I made some games in FENIX/BENNUGD and love the idea of PICO8.
Your post is gold for the beginers like me! Thank you!
PD: Sorry for my bad english
The explanations are very useful.
Just a little remark: could you use self explanatory variables names?
player or speed are clear, but o,sf,nf,sp,fl are not ;)
great knowledge sharing
If you're gonna do 4-way, and the game is grid-based, I'd recommend setting the perpindicular value to a multiple of 4/8 (whatever constitutes a "half tile") to make it easier to navigate your map space. It's practically the difference between old-school Zelda and it's many imitators. (design talk)
Otherwise, fecking splendid! Being new to LUA, this helps me a lot, too. I'm also doing this funky thing where my "map" is split into chunks/scenes, and I'm using multiple instances of these scenes with scripts to make for bigger adventures than you'd fit in a 8-screen x 4-screen map (IE: using 16 arguments per "world/level" as a logical sequence of scene IDs, along with variables to differenciate enemies/widgets placed in them)... translating the idea to code is a bit more harrowing, though. And really, I've got maybe an hour a day to work on this stuff.
Hi, I'm pretty new to Pico-8 and this was a super helpful guide for some of the things I wanted to do. Thanks!
[Please log in to post a comment]