How Celeste Works
Celeste is a really cool game made in 4 days that spurred the eventual creation of another title by the same name: Celeste.
This game has really easy to read code, and is pretty fully featured as far as pico8 titles go. These notes break down some of the patterns used in the game that can be easily extended to any platformer game.
Collectables
Ok, so you have things on the map that the player needs to 'collect' (read: interact with). So generally, you keep an array of those items and manage them in some code. Consider the below example for collecting money or something:
function _init()
money={}
player=m_player()
end
function _draw()
-- draw the objects
player:update()
for o in all(money) do
o:draw()
end
end
function _update()
-- update the objects
player:draw()
for o in all(money) do
o:update()
end
end
function m_money()
add(money, {
init=function(this)...end,
update=function(this)...end,
draw=function(this)...end,
})
end
-- more code needed to actually drive interactions
So this setup is probably okay if you only have a few entities that interact with the player, but there are two drawbacks to this approach:
- Adding a new object class, like
fruitrequires adding a new table to keep track of those objects and subsequent interactions - Suppose you have two objects that have common interactions with the player, but have a minor distinction (e.g. two types of powerups move, but in different ways). This setup to the game suggests that each should have logic in the update function of each object to handle this, but it can lead to a lot of similar logic that would be better off being shared.
What Celeste Does For Organizing Game Objects
Celeste classis has a pretty clean way of handling this. It's basically inheiritance, with a base class, and each other object 'class' inheiriting all of the methods defined. Here's the relevant code from celeste:
function init_object(type,x,y)
  if type.if_not_fruit~=nil and got_fruit[1+level_index()] then
    return
  end
  local obj = {}
  obj.type = type
  obj.collideable=true
  obj.solids=true
  obj.spr = type.tile
  obj.flip = {x=false,y=false}
  obj.x = x
  obj.y = y
  obj.hitbox = { x=0,y=0,w=8,h=8 }
 Â
  obj.spd = {x=0,y=0}
  obj.rem = {x=0,y=0}
  obj.is_solid=function(ox,oy)
  if oy>0 and not obj.check(platform,ox,0) and
obj.check(platform,ox,oy) then
return true
end
return solid_at(obj.x+obj.hitbox.x+ox,obj.y+obj.hitbox.y+oy,obj.hitbox.w,obj.hitbox.h)
or obj.check(fall_floor,ox,oy)
or obj.check(fake_wall,ox,oy)
end
  obj.is_ice=function(ox,oy)
    return ice_at(obj.x+obj.hitbox.x+ox,obj.y+obj.hitbox.y+oy,obj.hitbox.w,obj.hitbox.h)
end
  obj.collide=function(type,ox,oy)
    local other
    for i=1,count(objects) do
      other=objects[i]
      if other ~=nil and other.type == type and other != obj and other.collideable and
        other.x+other.hitbox.x+other.hitbox.w > obj.x+obj.hitbox.x+ox and
        other.y+other.hitbox.y+other.hitbox.h > obj.y+obj.hitbox.y+oy and
        other.x+other.hitbox.x < obj.x+obj.hitbox.x+obj.hitbox.w+ox and
        other.y+other.hitbox.y < obj.y+obj.hitbox.y+obj.hitbox.h+oy then
        return other
      end
    end
    return nil
  end
  obj.check=function(type,ox,oy)
    return obj.collide(type,ox,oy) ~=nil
  end
  obj.move=function(ox,oy)
    local amount
    -- [x] get move amount
  obj.rem.x += ox
    amount = flr(obj.rem.x + 0.5)
    obj.rem.x -= amount
    obj.move_x(amount,0)
    -- [y] get move amount
    obj.rem.y += oy
    amount = flr(obj.rem.y + 0.5)
    obj.rem.y -= amount
    obj.move_y(amount)
  end
  obj.move_x=function(amount,start)
    if obj.solids then
      local step = sign(amount)
      for i=start,abs(amount) do
        if not obj.is_solid(step,0) then
          obj.x += step
        else
          obj.spd.x = 0
          obj.rem.x = 0
          break
        end
      end
    else
      obj.x += amount
    end
  end
  obj.move_y=function(amount)
    if obj.solids then
      local step = sign(amount)
      for i=0,abs(amount) do
      if not obj.is_solid(0,step) then
          obj.y += step
        else
          obj.spd.y = 0
          obj.rem.y = 0
          break
        end
      end
    else
      obj.y += amount
    end
  end
  add(objects,obj)
  if obj.type.init~=nil then
    obj.type.init(obj)
  end
  return obj
end
Now there is a lot going on here, but these are the bullet points:
- The function takes a
typevarable that corresonds to a lua table withupdate,draw, andinitfunctions. - Some shared functionality is declared on the base table that is accessible in each of the
typetables. - Functionality declared on the base table can be safely called on any other object in the project and used consistently.
Here's an example of an object defined in this system:
rare_coin={
tile=12,
init=function(this)
end,
update=function(this)
local hit = this.collide(player,0,8)
if hit~=nil then
-- handle collision here!
-- hit: player object
-- this: rare_coin
end
end,
}
add(types, rare_coin)
Generating Game Objects from Map Data
Notice how the above type has a tile property? This is used by Celeste to initialize objects from the map data. Basically, everytime a new level is loaded, all the tiles are iterated over and checked against existing types in the types table. When a tile matches a type, that type is passed into the above init_object(type,x,y) function with the tiles coordinates
From here, the game loop keeps track of the objects state and handles the interactions of the object with other objects in the game. It's a pretty neat system.