Common Implementation Patterns
Short recipes for the most frequently needed game mechanics. Each pattern names the hook to override, shows a minimal implementation, and points to a production title that uses it.
Revenue Patterns
Hex revenue bonus
A route that visits a specific hex earns extra revenue.
# In game.rb
BONUSES = { 'F8' => 20, 'D4' => 30 }.freeze
def revenue_for(route, stops)
super + stops.sum { |s| BONUSES.fetch(s.hex.id, 0) }
end
Reference: lib/engine/game/g_18co/game.rb
East-west route bonus
A corporation earns a bonus when its route set collectively spans east and west edges.
EAST_HEXES = %w[A3 A5 A7].freeze
WEST_HEXES = %w[M3 M5 M7].freeze
def routes_revenue(routes)
base = routes.sum(&:revenue)
all_stops = routes.flat_map(&:stops).map { |s| s.hex.id }
bonus = (all_stops & EAST_HEXES).any? && (all_stops & WEST_HEXES).any? ? 80 : 0
base + bonus
end
Reference: lib/engine/game/g_1830/game.rb
Revenue multiplier for a special train
Double the revenue of E-trains or other high-tier trains.
def revenue_for(route, stops)
base = super
route.train.name == 'E' ? base * 2 : base
end
Route Validation Patterns
Mandatory home-city inclusion
Every route must pass through the corporation's home city.
def check_other(route)
home = route.corporation.coordinates
return if route.stops.any? { |s| s.hex.id == home }
raise GameError, "#{route.corporation.name} must include its home city"
end
Reference: lib/engine/game/g_18chesapeake/game.rb
Route must reach an off-map hex
REQUIRED_EXIT = %w[A1 A3].freeze # at least one route must end here
def check_route_combination(routes)
exits = routes.flat_map(&:stops).select { |s| s.hex.offboard? }.map { |s| s.hex.id }
return if (exits & REQUIRED_EXIT).any?
raise GameError, 'At least one route must reach an off-map space'
end
Limit on routes sharing the same city
Standard 18xx rules prohibit two of the same corporation's routes from counting the same city twice.
def check_route_combination(routes)
# Base engine already checks path overlap; add city-level check:
city_stops = routes.flat_map { |r| r.stops.select(&:city?) }
dupes = city_stops.group_by(&:itself).select { |_, v| v.size > 1 }.keys
raise GameError, "Route shares city #{dupes.first.hex.id}" if dupes.any?
end
Dividend Patterns
Half-pay dividend type
Add a "half" option alongside payout and withhold.
# In step/dividend.rb (or a title-specific subclass)
DIVIDEND_TYPES = %i[payout half withhold].freeze
def dividend_types
DIVIDEND_TYPES
end
def half(entity, revenue)
per_share = (revenue / 2 / entity.total_shares).floor
{ corporation: revenue - (per_share * entity.total_shares), per_share: per_share }
end
Then register your step in operating_round:
require_relative 'step/dividend'
def operating_round(round_num)
Round::Operating.new(self, [
# ... other steps ...
G8888::Step::Dividend,
# ...
], round_num: round_num)
end
Reference: lib/engine/game/g_1822/step/dividend.rb
Stock price holds on half-pay
# In the custom Dividend step
def share_price_change(entity, revenue)
case @dividends_paid
when :payout then { share_direction: :right, share_times: 1 }
when :half then {} # no movement
when :withhold then { share_direction: :left, share_times: 1 }
end
end
Tile Lay Patterns
Extra free tile lay via company ability
Give a private company the ability to lay one free tile on a specific hex. No custom Step needed — the tile_lay ability and Step::SpecialTrack handle it automatically.
# In entities.rb COMPANIES
{
name: 'Bavarian Central Railway',
sym: 'BCR',
value: 80,
revenue: 15,
abilities: [
{ type: 'blocks_hexes', owner_type: 'player', hexes: ['D8'] },
{
type: 'tile_lay',
owner_type: 'corporation',
hexes: ['D8'],
tiles: %w[3 4 58],
when: 'owning_corp_or_turn',
count: 1,
},
],
color: nil,
}
The owning corporation gets one free tile lay on D8 per OR. Step::SpecialTrack must be in your operating_round step list (it is in the default list).
tile_lays slot mechanics
tile_lays(entity) returns an ordered array of lay-slot hashes. Each slot is one available lay action, consumed in sequence as @round.num_laid_track is incremented.
# Slot hash keys:
{ lay: true, # true / false / :not_if_upgraded
upgrade: true, # true / false / :not_if_upgraded
cost: 0, # extra cost for yellow lays
upgrade_cost: 0, # extra cost for upgrades (defaults to :cost)
cannot_reuse_same_hex: false # prevents laying on same hex twice
}
ability.use! on a tile_lay ability routes through lay_tile_action when consume_tile_lay: true (consumes a slot) or through lay_tile directly when omitted (extra on top of the normal budget).
Corporation gets two tile lays per OR
# In game.rb
TILE_LAYS = [
{ lay: true, upgrade: true },
{ lay: :not_if_upgraded, upgrade: false, cost: 20 },
].freeze
The second element is the second lay: lay: :not_if_upgraded means the second action may only lay a new tile (not upgrade an existing one). cost: 20 charges $20 for the second lay.
Reference: lib/engine/game/g_1849/game.rb
Token Patterns
Free token placement via teleport ability
A private company grants the owning corporation a free token placement anywhere on the board, bypassing connectivity.
# In entities.rb COMPANIES
{
name: 'Steamboat Company',
sym: 'SBC',
value: 30,
revenue: 5,
abilities: [
{
type: 'teleport',
owner_type: 'corporation',
tiles: %w[57 14 15],
hexes: [], # empty = any hex
},
],
color: nil,
}
Step::SpecialToken must be in your operating_round step list.
Corporation starts with two home tokens
# In entities.rb CORPORATIONS
{
sym: 'PRR',
coordinates: 'H12',
second_city: 'F14', # second home token placed here at game start
# ...
}
Phase & Event Patterns
Custom event method
When a train fires an event, the engine calls game.send("event_#{type}!"). Define the method in game.rb:
# Fired when the first 5-train is purchased
def event_close_companies!
@log << '-- Event: Private companies close --'
super
end
Call super to run the base class logic (closing companies, etc.) before or after your additions.
Gating a train behind a purchase count (not just available_on)
available_on: makes a train visible in the depot after a named train tier is bought, but it does not enforce a purchase count. To require N copies of a prior train to be bought first, combine available_on: (for depot visibility) with a buyable_trains filter in a custom BuyTrain step:
# In TRAINS (game.rb) — depot surfaces the 8+8 once 7+7 phase is active
{ name: '8+8', available_on: '7+7', ... }
# In step/buy_train.rb — additional count gate
def buyable_trains(entity)
trains = super
trains = trains.reject { |t| t.name == '8+8' } unless @game.level8_train_available?
trains
end
# In game.rb
def level8_train_available?
sold = depot.trains.count { |t| t.name == '7+7' } - depot.upcoming.count { |t| t.name == '7+7' }
sold >= 4
end
Reference: lib/engine/game/g_18_oe/game.rb, lib/engine/game/g_18_oe/step/buy_train.rb
Injecting remainder cash into the bank on a train event
Some physical games set aside extra notes at setup and inject them when a specific train is bought, to prevent the bank from breaking prematurely during the final OR set. Model this with a one-shot event method guarded by an instance variable:
# In game.rb
REMAINDER_CASH = 100_000 # e.g. 20 × £5,000 notes (§13)
def event_remainder_cash!
return if @remainder_cash_added
@remainder_cash_added = true
@bank.instance_variable_set(:@cash, @bank.cash + self.class::REMAINDER_CASH)
@log << "-- Event: #{format_currency(self.class::REMAINDER_CASH)} remainder cash added to bank --"
end
Wire it via events: on the relevant train in TRAINS:
{ name: '8+8', events: [{ 'type' => 'remainder_cash' }], ... }
The guard (return if @remainder_cash_added) ensures only the first purchase fires the injection even though the event fires for every 8+8 bought.
Reference: lib/engine/game/g_18_oe/game.rb
Phase-gated action in a Step
Check the current phase status inside a Step's actions method to enable or disable options:
def actions(entity)
return [] unless @game.phase.status.include?('can_buy_companies')
super
end
Operating Order Patterns
Custom operating order
Override operating_order in game.rb:
# Corporations with destination tokens operate before others
def operating_order
without_dest, with_dest = super.partition { |c| !destination_reached?(c) }
without_dest + with_dest
end
Reference: lib/engine/game/g_1889/game.rb
Minors operate before majors
The default operating_order already does this. To reverse it:
def operating_order
floated_majors = corporations.select { |c| c.type == :major && c.floated? }
floated_minors = minors.select(&:floated?)
(floated_majors + floated_minors).sort_by { |c| [c.share_price.price, c.share_price.corporations.index(c)] }.reverse
end
Finding More Examples
The fastest way to find a production example of any pattern:
# Find titles that override revenue_for:
grep -rl "def revenue_for" lib/engine/game/
# Find titles that define a half-pay step:
grep -rl "half" lib/engine/game/*/step/
# Find all titles that use a specific ability type:
grep -rl "type: 'teleport'" lib/engine/game/
What's Next
- Revenue hook reference: Revenue & Routing
- Ability field reference: Abilities
- Writing a custom Step from scratch: Rounds & Steps
- Verifying patterns with fixtures: Testing Your Game
Version: 2026-05-08 — derived from production game titles in lib/engine/game/, lib/engine/step/, lib/engine/game/base.rb.