A few weeks ago I wrote a post about the Dragonruby game engine. I mentioned that there was a month-long game jam to make a 20 second game. This post is about my entry: Ghost Trap.
Try it out on itch. If the html version is a little wonky you can download your OS specific version. I tried it on 3 different computers and it was fine on 2 of them, but the older Windows machine could not handle it.
When I was a kid I really liked the Ghostbusters game for the Sega Master System. It's not a great game, but I liked stopping the ghosts from entering buildings, driving and catching the ghosts, and trapping the ghosts at the homes they were haunting. I decided I would make a game that was similar to trapping the ghosts.
In the Sega game, you get to your destination and you have 2 ghostbusters to move. You move one where you want him, then use the other to guide the ghosts between the two of them toward the trap.
I didn't want to make a clone, so I decided to make it so a single "ghostcatcher" catches ghosts in their beam, which get sucked into their backpack. The backpack can only hold 10 ghosts, so the ghostcatcher has to periodically deposit ghosts into the ghost disposal canister. The objective is to trap as many ghosts as you can in 20 seconds. You can earn a combo the more ghosts you catch on your beam at once and your beam energy depletes over time to keep the player from holding the button down the entire 20 seconds. You have to stop shooting to regenerate energy.
Oh yeah, this is a world of dogs instead of people, because why not? Truthfully I didn't want to draw a ghost that looked like a traditional emoji looking 👻 ghost, so I made a dog-ghost. Plus, this gave me an excuse to use my dog Pixel as a reference for my ghostcatcher.


Artwork
I made the art with a combination of Affinity Designer and Procreate on the iPad and Affinity Photo on the Mac. I never used the Affinity software before so I was learning as I went along. Affinity Designer is really cool and I enjoyed working with it once I started to get the hang of it, but I really miss having access to Photoshop which is something I'm quite comfortable with.

Code Organization
I broke my code down into 6 classes and a few helper methods. I think some of the code I wrote was possibly handled by Dragonruby, but since I'm still learning how it all works I didn't take advantage of all of its features.
Entity
The Entity
class is the parent for my other game object classes. It has properties for sprite dimensions and attributes, collision, and rendering.
# an excerpt of the Entity class class Entity attr_accessor :x, :y, :w, :h, :sprite_path, :flip, :alpha def initialize(x, y, w, h, sprite_path, flip) @w = w @h = h @x = x @y = y @sprite_path = sprite_path @flip = flip @alpha = 255 end def rect [x, y, w, h] end def render [ x, y, w, h, sprite_path, 0, # ANGLE self.alpha, # ALPHA 255, # RED SATURATION 255, # GREEN SATURATION 255, # BLUE SATURATION 0, # TILE X 0, # TILE Y self.w, # TILE W self.h, # TILE H self.flip, # FLIP HORIZONTALLY false # FLIP VERTICALLY ] end def is_colliding_with?(obj) self.rect.intersect_rect?(obj.rect) end #... etcc end
I made the rect
method to pass to the built-in intersect_rect
to check for collision, but I believe it might be something I could have handled with Dragonruby natively. That's something I have to research further.
Player
The Player
class is a subclass of Entity
and handles all the player specific stuff like moving, shooting, catching ghosts, etc.
# an excerpt of the Player class class Player < Entity attr_accessor :total_ghosts_held, :backpack_limit, :beam, :is_shooting, :ghosts_on_beam, :beam_power, :beam_cooldown, :speed, :is_walking, :sprite_frame, :shoot_sound_playing MAX_BEAM_POWER = 200 BEAM_COOLDOWN = 1 def initialize w = 114 h = 300 super($WIDTH/2-w/2, 90, w, h, "sprites/player_green_1.png", false) @total_ghosts_held = 0 # total ghosts in pack @backpack_limit = 10 @beam = {x: ((self.x + self.w)/2).to_i, y: self.y+h, h: 300, w: 23} @is_shooting = false @ghosts_on_beam = [] @beam_power = MAX_BEAM_POWER @beam_cooldown = 0 @speed = 6 @is_walking = false @sprite_frame = 0 @shoot_sound_playing = false end def set_sprite status_color = self.space_in_pack? ? "green" : "red" self.sprite_path = "sprites/player_#{status_color}_#{self.sprite_frame+1}.png" end def calc(outputs, tick_count) self.sprite_frame = self.is_walking ? tick_count.idiv(6).mod(2) : 0 if self.can_shoot? && self.is_shooting play_sound(:shoot) if !shoot_sound_playing self.shoot_sound_playing = true if !self.shoot_sound_playing self.shoot(outputs, tick_count) elsif self.has_ghosts_on_beam? self.shoot_sound_playing = false add_score(self.total_ghosts_on_beam) self.store_ghosts_from_beam_to_pack self.ghosts_on_beam.clear end self.refill_beam if !self.is_shooting && self.beam_power != MAX_BEAM_POWER end end
Ghost
The ghost
class handles spawning ghosts, and each instance can keep track of whether or not it has free will (if it's caught in the player's beam or can move freely) and if it's invulnerable. Ghosts flicker in and out and are invulnerable to being caught if they are transparent.
# an excerpt of the Ghost class class Ghost < Entity attr_accessor :is_flickering, :is_invulnerable, :has_free_will, :is_in_beam, :id def calc(tick_count) # use different sprite if ghost is on beam self.sprite_path = !self.is_in_beam ? "sprites/ghost80.png" : "sprites/ghost_on_beam.png" # keep in bounds self.y = $HEIGHT if self.y < 0 || self.y > $HEIGHT self.x = 0 if self.x < 0 self.x = $WIDTH - self.w if self.x + self.w > $WIDTH self.toggle_flickering if tick_count % 60 == 0 self.flicker if self.is_flickering self.move_freely(tick_count) if self.has_free_will end def stop_flickering # play_sound(:flicker_in) self.is_flickering = false self.is_invulnerable = false self.alpha = 255 end def start_flickering # play_sound(:flicker_out) if self.has_free_will self.is_flickering = true self.is_invulnerable = true end end def self.spawn x = random_int(20, $WIDTH - 100) y = random_int(400, $HEIGHT - 100) Ghost.new(x, y) end
Disposal
Disposal
is the canister where the player deposits ghosts. It keeps track of how many ghosts it contains and its open/closed status to handle which sprite should display.
# An excerpt of the Disposal class class Disposal < Entity attr_accessor :total_ghosts, :is_open, :timer def initialize super(566, 221, 99, 154, 'sprites/canister.png', false) @is_open = false @total_ghosts = 0 @timer = 1 end def deposit_ghosts(total_to_add) play_sound(:dispose) self.is_open = true self.total_ghosts += total_to_add end def calc(tick_count) self.timer -=1 if self.is_open self.is_open = false if timer <= 0 && tick_count % 10 == 0 end def open_canister self.sprite_path = OPEN[:sprite_path] self.w = OPEN[:w] self.h = OPEN[:h] end def close_canister self.timer = 1 self.sprite_path = CLOSED[:sprite_path] self.w = CLOSED[:w] self.h = CLOSED[:h] end def render # set sprite based on open status self.is_open ? open_canister : close_canister super end end
GhostTrap
Finally, there is the GhostTrap
class, which is the game class that handles all the game functionality and pieces everything together.
The defaults
method sets up all of the properties in state
. Normally I would use instance variables, but Dragonruby has a built-in variable called state
that is passed from tick to tick.
def defaults state.player ||= Player.new state.disposal ||= Disposal.new state.ghosts ||= MAX_GHOSTS.map { Ghost.spawn } state.mode ||= :title state.timer ||= 20 state.score ||= 0 state.countdown ||= 3 end
All rendering (drawing to the screen) are organized within the render
method, and all updates are handled within the calc
method. User inputs are handled in process_inputs
. I broke everything down further depending on what game screen the user is on.
def render render_title if state.mode == :title render_instructions if state.mode == :instructions render_credits if state.mode == :credits render_play if state.mode == :play render_game_over if state.mode == :game_over end
Problems
I had the most trouble working with sound effects. I was able to get basic sound effects working, but I couldn't figure out how to get an effect that wasn't music to loop and stop when I wanted it to. I wanted a whirring effect for the player's beam but I settled for an initial effect each time the player first presses the spacebar to shoot. I did this by adding an additional variable, shoot_sound_playing
which I set to true when the player shoots, as long as it's not already true. Then I set it to false when the player stops shooting.
The Dragonruby docs recommend you go through each sample game thoroughly to really learn the engine, so I went through what I could but I didn't have time to really dig into everything. I think if I go back and look through the code when I get a chance I can probably do some refactoring and make some improvements to my game.
if self.can_shoot? && self.is_shooting play_sound(:shoot) if !shoot_sound_playing self.shoot_sound_playing = true if !self.shoot_sound_playing self.shoot(outputs, tick_count) # ... etc
You can find the full game code on my github
Side note: I know I used self
sometimes and not other times. Personally I prefer to use it because then I know exactly where a method is coming from and to me that's more readable, but Dragonruby doesn't use it and I know Ruby is all about being free of clutter. I'm still in the process of cleaning everything up and making it consistent, but I wanted to get this post out asap. :)
Top comments (0)