Building a Worms Game with Lively
This tutorial will guide you through creating a Worms-style game using Lively, a Ruby framework for building real-time applications.
We'll build the game step by step, starting with simple concepts and gradually adding complexity.
What You'll Build
By the end of this tutorial, you'll have created:
- A grid-based game board that you can see in your browser.
- A simple worm that moves automatically.
- Manual control with keyboard input.
- Fruit collection mechanics.
- Real-time updates using WebSockets.
Prerequisites
- Basic knowledge of Ruby programming.
- Understanding of classes and objects.
- Familiarity with HTML/CSS basics.
- Lively framework installed (follow the getting started guide if needed).
Tutorial Approach
We'll build this game in stages:
- Static Board: First, we'll create a simple grid that displays in the browser.
- Dynamic Board: Add the ability to change cells and see updates.
- Simple Worm: Create a worm that moves automatically.
- User Control: Add keyboard input to control the worm.
- Game Mechanics: Add fruit, collision detection, and game rules.
Step 1: Setting Up the Project Structure
First, create a new directory for your Worms game:
$ mkdir my_worms_game
$ cd my_worms_game
Create the main application file:
$ touch application.rb
Create the CSS directory and file:
$ mkdir -p public/_static
$ touch public/_static/index.css
Step 2: Creating a Static Board (Your First View)
Let's start with the simplest possible thing: a grid that shows up in your browser. This will help you understand how Lively works.
Create your first file called static_view.rb
:
#!/usr/bin/env lively
# frozen_string_literal: true
# Reference-style static board: fruit as string, worm as colored segment (hash).
class StaticBoard
def initialize(width = 5, height = 5)
@width = width
@height = height
# Use an Array of Arrays to store a grid:
@grid = Array.new(@height) {Array.new(@width)}
# Place a fruit:
@grid[1][1] = "🍎"
# Place a worm segment (hash with color):
@grid[2][3] = {color: "hsl(120, 80%, 50%)", count: 3}
# Place another fruit:
@grid[3][2] = "🍌"
end
attr_reader :grid, :width, :height
end
class StaticView < Live::View
def initialize(...)
super
@board = StaticBoard.new
end
# Render the HTML grid:
def render(builder)
builder.tag("h1") {builder.text("My First Lively Game!")}
builder.tag("table") do
@board.grid.each do |row|
builder.tag("tr") do
row.each do |cell|
if cell.is_a?(Hash)
builder.tag("td", style: "background-color: #{cell[:color]};")
elsif cell.is_a?(String)
builder.tag("td") {builder.text(cell)}
else
builder.tag("td")
end
end
end
end
end
end
end
Application = Lively::Application[StaticView]
Now add some basic CSS to public/_static/index.css
:
body {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px;
font-family: Arial, sans-serif;
}
table {
border-collapse: collapse;
margin: 20px;
}
td {
width: 40px;
height: 40px;
border: 1px solid #ccc;
text-align: center;
vertical-align: middle;
font-size: 20px;
}
Test it now: Run lively static_view.rb
and open your browser. You should see a 5x5 grid with a few emoji scattered around!
What's happening here:
SimpleBoard
creates a 2D array representing our game gridStaticView
renders this grid as an HTML table- The CSS makes it look like a proper game board
- Everything is static - no movement or interaction yet
Step 3: Making the Board Interactive
Now let's make the board update when we click on it. This introduces the key concept of real-time updates in Lively.
Create a new file called interactive_view.rb
:
#!/usr/bin/env lively
# frozen_string_literal: true
# Reference-style interactive board: toggles between fruit and colored segment
class InteractiveBoard
def initialize(width = 5, height = 5)
@width = width
@height = height
@grid = Array.new(@height) {Array.new(@width)}
# Put a fruit in the center:
@grid[2][2] = "🍎"
end
attr_reader :grid, :width, :height
# Set a worm segment at the specified coordinates.
def set_segment(y, x)
@grid[y][x] = {color: "hsl(120, 80%, 50%)", count: 1}
end
# Set a fruit at the specified coordinates.
def set_fruit(y, x)
@grid[y][x] = "🍎"
end
# Clear a cell at the specified coordinates.
def clear_cell(y, x)
@grid[y][x] = nil
end
# Get the cell at the specified coordinates.
def get_cell(y, x)
@grid[y][x]
end
end
class InteractiveView < Live::View
def initialize(...)
super
@board = InteractiveBoard.new
end
# Handle input from the user.
def handle(event)
Console.info(self, "Received event:", event)
# Handle click events:
if event[:type] == "click"
# Get the x, y coordinates of the cell that was clicked:
y = event[:detail][:y].to_i
x = event[:detail][:x].to_i
# Get the current value of the cell:
cell = @board.get_cell(y, x)
# Toggle: empty → fruit → segment → empty
if cell.nil?
@board.set_fruit(y, x)
elsif cell.is_a?(String)
@board.set_segment(y, x)
else
@board.clear_cell(y, x)
end
self.update!
end
end
# Render the HTML grid, including event forwarding.
def render(builder)
builder.tag("h1") {builder.text("Interactive Board - Click the cells!")}
builder.tag("table") do
@board.grid.each_with_index do |row, y|
builder.tag("tr") do
row.each_with_index do |cell, x|
if cell.is_a?(Hash)
# lively.forwardEvent sends the event from the browser to the server, invoking the handle method above. Note that we include the x and y coordinates as extra details.
builder.tag("td", onclick: "live.forwardEvent('#{@id}', event, {y: #{y}, x: #{x}});", style: "cursor: pointer; background-color: #{cell[:color]};")
elsif cell.is_a?(String)
builder.tag("td", onclick: "live.forwardEvent('#{@id}', event, {y: #{y}, x: #{x}});", style: "cursor: pointer;") {builder.text(cell)}
else
builder.tag("td", onclick: "live.forwardEvent('#{@id}', event, {y: #{y}, x: #{x}});", style: "cursor: pointer;")
end
end
end
end
end
builder.tag("p") {builder.text("Click any cell to cycle: empty → fruit → worm segment → empty.")}
end
end
Application = Lively::Application[InteractiveView]
Test it now: Run lively interactive_view.rb
and click on the grid cells. You should see stars appear and disappear!
Key concepts you just learned:
handle(event)
receives user interactions from the browserself.update!
tells Lively to re-render the page with new data- JavaScript
onclick
events can send data back to Ruby - The board can be modified and the changes appear instantly
Try this: Click around the grid and watch the real-time updates. This is the foundation of all Lively applications!
Step 4: Adding a Simple Moving Worm
Now let's add something that moves automatically. This introduces the concept of background tasks and animation.
Create a new file called moving_worm.rb
:
#!/usr/bin/env lively
# frozen_string_literal: true
# Reference-style: worm as colored segment (hash), leaves a trail, bounces off walls.
class Board
def initialize(width = 8, height = 8)
@width = width
@height = height
@grid = Array.new(@height) {Array.new(@width)}
end
attr_reader :grid, :width, :height
def set_segment(y, x, color, count)
@grid[y][x] = {color: color, count: count}
end
def clear_segment(y, x)
if @grid[y][x].is_a?(Hash)
@grid[y][x] = nil
end
end
# For all values in the grid that are integers, decrement them by 1. If they become 0, clear the segment.
def decrement_segments
@grid.each do |row|
row.map! do |cell|
if cell.is_a?(Hash)
cell[:count] -= 1
cell[:count] > 0 ? cell : nil
else
cell
end
end
end
end
end
class SimpleWorm
def initialize(board, start_y, start_x, color = "hsl(120, 80%, 50%)")
@board = board
@y = start_y
@x = start_x
@direction = :right
@color = color
@length = 3
@board.set_segment(@y, @x, @color, @length)
end
attr_reader :y, :x, :direction
def move
# Calculate new position based on the movement direction:
new_y, new_x = @y, @x
case @direction
when :right
new_x += 1
when :left
new_x -= 1
when :up
new_y -= 1
when :down
new_y += 1
end
# Bounce off walls by changing direction:
if new_y < 0 || new_y >= @board.height || new_x < 0 || new_x >= @board.width
case @direction
when :right
@direction = :down
when :left
@direction = :up
when :up
@direction = :right
when :down
@direction = :left
end
# Don't move this tick
return
end
# Otherwise, update the worm's position:
@y, @x = new_y, new_x
@board.set_segment(@y, @x, @color, @length)
end
end
class MovingWormView < Live::View
def initialize(...)
super
@board = Board.new
@worm = SimpleWorm.new(@board, 4, 4)
# We will store the task responsible for updating the worm's position ont he server:
@animation = nil
end
# When the browser connects to the server, this method is invoked, and we set up the animation loop.
def bind(page)
super
@animation ||= Async do
# The animation loop repeats 2 times per second, updating the board, then moving the worm.
loop do
sleep(0.5)
@board.decrement_segments
@worm.move
# Regenerate the HTML and send it to the browser:
self.update!
end
end
end
# When the browser disconnects from the server, this method is invoked, and we stop the animation loop.
def close
if animation = @animation
@animation = nil
animation.stop
end
super
end
# Render the HTML grid, including event forwarding.
def render(builder)
builder.tag("h1") {builder.text("Automatic Moving Worm")}
builder.tag("table") do
@board.grid.each_with_index do |row, y|
builder.tag("tr") do
row.each_with_index do |cell, x|
if cell.is_a?(Hash)
builder.tag("td", style: "background-color: #{cell[:color]};")
else
builder.tag("td")
end
end
end
end
end
builder.tag("p") {builder.text("Watch the colored worm bounce around and leave a trail!")}
builder.tag("p") {builder.text("Current position: (#{@worm.y}, #{@worm.x})")}
end
end
Application = Lively::Application[MovingWormView]
Test it now: Run lively moving_worm.rb
and watch the worm move around the board automatically!
Step 5: Adding Keyboard Control
Now let's make the worm respond to your keyboard input. This is where it becomes a real game!
Create a new file called controllable_worm.rb
:
#!/usr/bin/env lively
# frozen_string_literal: true
# Reference-style: worm as colored segment (hash), keyboard control, colored trail.
class Board
def initialize(width = 10, height = 10)
@width = width
@height = height
@grid = Array.new(@height) {Array.new(@width)}
end
attr_reader :grid, :width, :height
def set_segment(y, x, color, count)
@grid[y][x] = {color: color, count: count}
end
def clear_segment(y, x)
if @grid[y][x].is_a?(Hash)
@grid[y][x] = nil
end
end
def decrement_segments
@grid.each do |row|
row.map! do |cell|
if cell.is_a?(Hash)
cell[:count] -= 1
cell[:count] > 0 ? cell : nil
else
cell
end
end
end
end
end
class ControllableWorm
attr_reader :y, :x, :direction
attr_writer :direction
def initialize(board, start_y, start_x, color = "hsl(120, 80%, 50%)")
@board = board
@y = start_y
@x = start_x
@direction = :right
@color = color
@length = 3
@board.set_segment(@y, @x, @color, @length)
end
def move
# Calculate new position:
new_y, new_x = @y, @x
case @direction
when :right
new_x += 1
when :left
new_x -= 1
when :up
new_y -= 1
when :down
new_y += 1
end
# Stay in bounds:
if new_y < 0 || new_y >= @board.height || new_x < 0 || new_x >= @board.width
return
end
@y, @x = new_y, new_x
@board.set_segment(@y, @x, @color, @length)
end
end
class ControllableView < Live::View
def initialize(...)
super
@board = Board.new
@worm = ControllableWorm.new(@board, 5, 5)
@movement = nil
end
def bind(page)
super
@movement ||= Async do
loop do
sleep(0.3)
@board.decrement_segments
@worm.move
self.update!
end
end
end
def close
if movement = @movement
@movement = nil
movement.stop
end
super
end
def handle(event)
Console.info(self, "Event:", event)
if event[:type] == "keypress"
key = event[:detail][:key]
case key
when "w"
@worm.direction = :up
when "s"
@worm.direction = :down
when "a"
@worm.direction = :left
when "d"
@worm.direction = :right
end
end
end
def render(builder)
builder.tag("h1") {builder.text("Controllable Worm - Use WASD!")}
builder.tag("table", tabindex: 0, autofocus: true, onkeypress: "live.forwardEvent('#{@id}', event, {key: event.key});") do
@board.grid.each_with_index do |row, y|
builder.tag("tr") do
row.each_with_index do |cell, x|
if cell.is_a?(Hash)
builder.tag("td", style: "background-color: #{cell[:color]};")
else
builder.tag("td")
end
end
end
end
end
# Log extra information about the game:
builder.tag("div") do
builder.tag("p") {builder.text("Controls: W (up), A (left), S (down), D (right)")}
builder.tag("p") {builder.text("Current direction: #{@worm.direction}")}
builder.tag("p") {builder.text("Position: (#{@worm.y}, #{@worm.x})")}
builder.tag("p") {builder.text("Click on the game board first, then use WASD keys!")}
end
end
end
Application = Lively::Application[ControllableView]
Test it now:
- Run
lively controllable_worm.rb
- Click on the game board to focus it
- Use W, A, S, D keys to control the worm!
What you just learned:
- How to capture keyboard events with
onkeypress
- Using
tabindex
andautofocus
to make elements keyboard-focusable - Separating input handling from movement logic
- Real-time control of game objects
Try this:
- Change direction while the worm is moving
- Try to "trap" the worm in a corner
- Experiment with different movement speeds
Step 6: The Complete Game
Let's put it all together to create the full Worms game! This includes trails, fruit collection, growing mechanics, and collision detection.
Create a new file called complete_game.rb
:
#!/usr/bin/env lively
# frozen_string_literal: true
# Reference-style board and rendering
class Board
FRUITS = ["🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍈", "🍒"]
def initialize(width = 15, height = 15)
@width = width
@height = height
@grid = Array.new(@height) {Array.new(@width)}
@fruit_count = 0
reset!
end
attr_reader :grid, :width, :height
def add_fruit!
10.times do
y = rand(@height)
x = rand(@width)
if @grid[y][x].nil?
@grid[y][x] = FRUITS.sample
@fruit_count += 1
return [y, x]
end
end
nil
end
def remove_fruit!(y, x)
if @grid[y][x].is_a?(String)
@grid[y][x] = nil
@fruit_count -= 1
end
end
def reset!
@grid.each {|row| row.fill(nil)}
@fruit_count = 0
add_fruit!
end
def set_segment(y, x, color, count)
@grid[y][x] = {color: color, count: count}
end
def clear_segment(y, x)
if @grid[y][x].is_a?(Hash)
@grid[y][x] = nil
end
end
def decrement_segments
@grid.each do |row|
row.map! do |cell|
if cell.is_a?(Hash)
cell[:count] -= 1
cell[:count] > 0 ? cell : nil
else
cell
end
end
end
end
end
class Player
attr_reader :head, :count, :color, :direction, :score
attr_writer :direction
def initialize(board, start_y, start_x, color)
@board = board
@head = [start_y, start_x]
@count = 3
@direction = :right
@color = color
@score = 0
@board.set_segment(@head[0], @head[1], @color, @count)
end
def move
# Calculate new head position:
y, x = @head
case @direction
when :up
y -= 1
when :down
y += 1
when :left
x -= 1
when :right
x += 1
end
# Check for wall collision:
if y < 0 || y >= @board.height || x < 0 || x >= @board.width
reset!
return :wall
end
cell = @board.grid[y][x]
case cell
when String # Fruit
@score += 10
@count += 2
@board.remove_fruit!(y, x)
@board.add_fruit!
when Hash # Self collision
reset!
return :self
end
@head = [y, x]
@board.set_segment(y, x, @color, @count)
nil
end
def reset!
# Remove all segments of this color
@board.grid.each_with_index do |row, y|
row.each_with_index do |cell, x|
if cell.is_a?(Hash) && cell[:color] == @color
@board.clear_segment(y, x)
end
end
end
@head = [@board.height/2, @board.width/2]
@count = 3
@direction = :right
@score = 0
@board.set_segment(@head[0], @head[1], @color, @count)
end
end
class WormsGameView < Live::View
def initialize(...)
super
@board = Board.new
@player = Player.new(@board, @board.height/2, @board.width/2, "hsl(120, 80%, 50%)")
end
def bind(page)
super
@game ||= Async do
loop do
sleep(0.18)
@board.decrement_segments
@player.move
self.update!
end
end
end
def close
@game&.stop
super
end
def handle(event)
if event[:type] == "keypress"
key = event[:detail][:key]
case key
when "w"
@player.direction = :up unless @player.direction == :down
when "s"
@player.direction = :down unless @player.direction == :up
when "a"
@player.direction = :left unless @player.direction == :right
when "d"
@player.direction = :right unless @player.direction == :left
end
end
end
def render(builder)
builder.tag("h1") {builder.text("Worms Game (Reference-style)")}
builder.tag("div") do
builder.tag("p") {builder.text("Score: #{@player.score}")}
builder.tag("p") {builder.text("Length: #{@player.count}")}
builder.tag("p") {builder.text("Direction: #{@player.direction}")}
end
builder.tag("table", tabindex: 0, autofocus: true, onkeypress: "live.forwardEvent('#{@id}', event, {key: event.key});") do
@board.grid.each do |row|
builder.tag("tr") do
row.each do |cell|
if cell.is_a?(Hash)
builder.tag("td", style: "background-color: #{cell[:color]};")
elsif cell.is_a?(String)
builder.tag("td") {builder.text(cell)}
else
builder.tag("td")
end
end
end
end
end
builder.tag("div") do
builder.text("Controls: W/A/S/D to move. Eat fruit to grow. Don't hit yourself or the wall!")
end
end
end
Application = Lively::Application[WormsGameView]
Test it now: Run lively complete_game.rb
and enjoy the full game!
What you've accomplished: You now have a fully functional Worms game that demonstrates all the key Lively concepts:
- Real-time web interfaces with growing trails
- Complete collision detection and game mechanics
- Score tracking and game over/restart functionality
- Polished user interface with CSS styling
Congratulations! You've built a complete game from the ground up, learning each concept step by step.
Next Steps and Customization Ideas
Now that you understand how Lively works, try these enhancements:
- Adjust Game Speed: Change the sleep time in the game loop.
- Bigger Board: Modify the width and height parameters.
- Different Fruits: Add new emoji to the
FRUITS
array. - Power-ups: Create special fruits with different effects.
- High Scores: Store and display the best scores.
- Sound Effects: Add audio feedback for actions.
- Multiplayer: Create separate game instances for different players.
- Touch Controls: Add swipe gestures for mobile devices.
Key Lively Concepts
This tutorial demonstrated the core concepts you need for any Lively application:
- Views: Ruby classes that generate HTML content.
- Event Handling: Processing user interactions from the browser.
- Real-time Updates: Using
self.update!
to refresh content. - Background Tasks: Using
Async
for continuous processes. - Resource Management: Cleaning up with the
close
method.