Liv is a small Ruby command-line application that dispatches movement instructions to one or more delivery vehicles on an infinite 2D integer plane and reports how many unique locations received a delivery.
Status: complete. All four components (
Vehicle,DeliveryTracker,Dispatcher,CLI) are implemented test-first, with a full RSpec suite and a clean RuboCop run.
A few things worth knowing about this solution:
- Scope. The brief is structurally Advent of Code 2015 Day 3. Rather than collapse it into a single script, the four real responsibilities — vehicle movement, instruction distribution, delivery aggregation, and the command-line interface — are modeled as separate collaborators with strictly one-way dependencies. The value of the exercise lives in the boundaries, not the arithmetic. Where the implementation deliberately stops short, the seam is named explicitly in Extensibility rather than pre-built.
- Test-first throughout. Every component was built red → green → refactor. The suite covers the documented edge cases: empty input, invalid characters (rejected atomically), uneven vehicle/instruction ratios, more vehicles than characters, file input, and duplicate vehicle names.
- Deliberate memory shape. Each vehicle keeps a cheap, always-on
stops_count, but only retains its fullcoordinate_logbreadcrumb trail when--debugis requested — so default runs stay bounded by vehicle count, not instruction length. In a production system the full trail would be persisted to a datastore rather than held in an ever-growing in-memory array; the log here is a reporting convenience, not the source of truth. - Validated against the provided sample.
data/sample.txt(8,192 instructions) reports 2,639 unique homes delivered to with two vehicles, and 2,565 with one.
Stack for 5+ years
- Ruby on Rails
- RSpec
- PostgreSQL
- GraphQL
- ERB
- HAML
- React
An AI coding assistant (GitHub Copilot) was used as a pair-programming aid: for test scaffolding, refactoring suggestions, an impartial code-review pass, and help drafting this documentation. The architectural decisions — the component boundaries, streaming-vs-batching into the tracker, where input validation and name-uniqueness policy live, the opt-in coordinate log, and the round-robin distribution strategy — were made and directed by me; the assistant accelerated implementation and review rather than driving design.
A vehicle is already out making deliveries. Wherever it currently sits counts as one delivery. The program then receives a string of single-character instructions and moves the vehicle one unit per character:
| Character | Direction |
|---|---|
< |
West |
^ |
North |
> |
East |
v |
South |
Every coordinate a vehicle occupies — including its starting point — counts as a delivery. The headline metric is the number of unique coordinates delivered to across all vehicles.
When more than one vehicle is dispatched, instructions are distributed round-robin: vehicle i receives the characters at indices where index % vehicle_count == i.
Liv → bin/liv --vehicle Maria -f data/sample.txt
=== Vehicle Report ===
Vehicle Maria created! Placed at [0, 0]
=== Delivery Summary ===
Vehicle Maria: 8193 stops, ending at [41, -87]
Unique homes delivered to: 2565Liv → bin/liv --vehicle Maria --vehicle "Clovis The Goat" -f data/sample.txt
=== Vehicle Report ===
Vehicle Maria created! Placed at [0, 0]
Vehicle Clovis The Goat created! Placed at [0, 0]
=== Delivery Summary ===
Vehicle Maria: 4097 stops, ending at [3, -91]
Vehicle Clovis The Goat: 4097 stops, ending at [38, 4]
Unique homes delivered to: 2639Requires Ruby 3.3 (pinned to 3.3.10 for reproducibility). Bundler must be available; it ships with modern Ruby installs.
Ruby is pinned to 3.3.10 in both .tool-versions (asdf) and .ruby-version (rbenv, chruby), so your version manager should pick it up when you cd in. Install 3.3.10 if you don't have it yet.
Then install deps and confirm a clean baseline:
bundle install # RSpec, RuboCop, etc.
bundle exec rspec # tests should pass
bundle exec rubocop # no lint offensesYou're ready to run the program.
bin/liv --vehicle Santa --vehicle Robo-Santa "<^>v><^"
bin/liv --vehicles 3 --file ./instructions.txt--vehicle NAMEis repeatable; pass it once per named vehicle. Names must be unique.--vehicles NaddsNdefault-named vehicles (e.g.Pizza Force 1,Pizza Force 2, ...). The two flags are mixable:--vehicle Santa --vehicles 2 "^v^v"dispatches three vehicles in declaration order.--file PATH(alias-f) reads the instruction string from a file instead of taking it as a positional arg. Trailing whitespace and newlines are tolerated. Mutually exclusive with the positional form.--debugadds an indentedPath:line under each vehicle's compact summary, showing the full coordinate log. Off by default so large inputs stay readable. Because the path is read fromvehicle.coordinate_lograther than from any distribution-internal state, the debug surface stays algorithm-agnostic.- With no
--vehicleor--vehiclesflags, a single default-named vehicle is used. - The instruction string must be quoted when passed inline —
<and>are shell redirects in zsh/bash and will otherwise be interpreted before reaching the program. Reading from--filesidesteps the quoting concern entirely. - Valid instruction characters are
<,^,>,v. Anything else is rejected up front with a clear error and a non-zero exit status.
=== Vehicle Report ===
Vehicle Santa created! Placed at [0, 0]
Vehicle Robo-Santa created! Placed at [0, 0]
=== Delivery Summary ===
Vehicle Santa: 6 stops, ending at [0, 5]
Vehicle Robo-Santa: 6 stops, ending at [0, -5]
Unique homes delivered to: 11
With --debug, a full coordinate path appears under each vehicle's summary:
=== Vehicle Report ===
Vehicle Santa created! Placed at [0, 0]
Vehicle Robo-Santa created! Placed at [0, 0]
=== Delivery Summary ===
Vehicle Santa: 6 stops, ending at [0, 5]
Path: [0, 0] -> [0, 1] -> [0, 2] -> [0, 3] -> [0, 4] -> [0, 5]
Vehicle Robo-Santa: 6 stops, ending at [0, -5]
Path: [0, 0] -> [0, -1] -> [0, -2] -> [0, -3] -> [0, -4] -> [0, -5]
Unique homes delivered to: 11
The program is composed of four small collaborators with strictly one-way dependencies: CLI → Dispatcher → { Vehicle, DeliveryTracker }. Vehicle and DeliveryTracker have no knowledge of each other.
Owns its own movement state. Exposes current_coordinates, an always-on stops_count (a cheap O(1) aggregate — the starting position counts as the first stop), and a move(directions) method that walks the supplied steps and returns only the coordinates newly visited by that call — i.e. a delivery event stream, not a history dump. The full coordinate_log breadcrumb trail is opt-in (keep_coordinate_log: true) because it grows with the instruction length; it's only requested under --debug.
The coordinator. Validates the instruction string atomically against /\A[<>^v]*\z/ before any vehicle moves, then splits the instructions round-robin and forwards each slice to the corresponding vehicle. As each vehicle returns its newly-visited coordinates, the dispatcher streams them straight into the delivery tracker. When a vehicle is created, the dispatcher records its starting position with the tracker immediately, so the "starting point counts as a delivery" rule lives in exactly one place. Vehicle-name uniqueness is enforced here (create_vehicle raises DuplicateVehicleNameError), so the invariant holds for any caller, not just the CLI.
A pure aggregator. Backed by a Hash.new(0) keyed by [x, y] arrays so repeat visits increment a counter cleanly. Answers total_unique_deliveries in O(1). Receives only fresh delivery events — it never re-parses a vehicle's full history.
Argument parsing, error formatting, and end-of-run summary printing. bin/liv is a thin shim that calls Liv::CLI.call(ARGV); the class itself takes injectable stdout/stderr, making it testable end-to-end. It builds the dispatcher, drives it, and reads back vehicle.stops_count (plus coordinate_log only under --debug) and tracker.total_unique_deliveries for output.
- Streaming, not batching, into the tracker. The dispatcher passes each move's return value to the tracker as it arrives. Treating a vehicle's full history as the tracker's input would be O(n²) over the run and would conflate "history" with "events."
- Lightweight aggregate vs. opt-in trail. Each vehicle always maintains a cheap
stops_countinteger, but only retains its fullcoordinate_logwhen asked (--debug). Default runs stay bounded by vehicle count rather than instruction length. In a real system the full trail would be persisted to a datastore; the in-memory log here is a reporting convenience, not the source of truth. - Starting-position rule lives in the dispatcher. When a vehicle is created, the dispatcher seeds the tracker with
[[0, 0]]— keepingVehicle#movehonest ("here are the coordinates I moved to") and the tracker dumb ("here are deliveries to record"). - Atomic validation. The instruction string is validated once, before dispatch. An invalid character cannot leave vehicles in a partially-moved state.
- Name uniqueness is a domain rule.
Dispatcher#create_vehiclerejects duplicate names so the invariant survives any future entry point. The CLI also checks at parse time, but only as a fail-fast UX guard that avoids printing partial output before the domain would reject the run. - Round-robin via
chars.group_by.with_index. The naiveeach_slice(n).to_a.transposeraises on uneven lengths;chars.group_by.with_index { |_, i| i % vehicles.count }.valueshandles every input length correctly. - The dispatcher is silent; the CLI owns all output. The dispatcher produces no user-facing text — it does the work and exposes
vehiclesandtrackerfor the caller to read. All formatting and printing live inLiv::CLI, which is the single place IO happens. This keeps the domain layer pure (and trivially testable without capturing output) and gives output policy — quiet mode, JSON, etc. — one obvious home.
The design has explicit seams for the most likely follow-up requirements:
- "Not every stop is a delivery." A validator collaborator can be injected into
DeliveryTrackerto filter coordinates before they are recorded. No other component needs to change. - More vehicles. Adding vehicles is already supported via the repeatable
--vehicleflag and the count-based--vehicles Nflag. Round-robin distribution scales to any count. - Per-vehicle starting position.
Liv::Vehiclecurrently treats[0, 0]as the universal origin via the privateSTARTING_COORDINATESconstant. Promoting that to astarting_coordinates:keyword onVehicle.new(with a sensible default) would let each vehicle begin where it actually is — useful if vehicles are mid-route when instructions arrive. The Dispatcher's "record the start with the tracker" rule would still hold; it already callstracker.add_deliveries([vehicle.current_coordinates])rather than hardcoding[0, 0]. - Per-vehicle step distance.
Liv::Vehicle::MOVEMENT_INCREMENTis currently a private constant fixed at1, so every instruction character moves the vehicle exactly one cell. Promoting it to a per-instance attribute lets vehicle types have different speeds — a delivery truck might move 2 cells per character while a scooter moves 1. TheDIRECTIONStable is built off the constant, so the change is local toVehicle; nothing else cares about magnitude. - Pluggable distribution algorithm. Round-robin is one strategy among many — a load-balanced split, a geographic partition, or a "send everyone the same instructions" broadcast are all reasonable alternatives. The seam is
Liv::Dispatcher's privateinstructions_per_vehiclehelper; it could move to a strategy collaborator (e.g.Liv::Distribution::RoundRobin) injected into Dispatcher, withDispatcher#runcalling@distribution.split(instruction, vehicles.count). The--debugsurface is intentionally decoupled from this seam — it readsvehicle.coordinate_lograther than the distribution's intermediate slices, so the path display works identically for any strategy. Kept inline today because there's only one strategy in scope; a hierarchy with one implementation is speculative architecture worth deferring until a second strategy actually shows up. - Structured or alternative output (JSON, quiet mode). All user-facing text is formatted in one place (
Liv::CLI); the dispatcher is silent. AReportercollaborator could be injected into the CLI to swap the rendering — plain text, JSON, machine-readable — without touching the domain layer. This is the most likely next request for a reporting tool, and the code is already shaped for it: theprint_*/announce_*methods are the only place that would move. - Vehicle-name uniqueness at persistence scale. The invariant is already enforced in the domain (
Dispatcher#create_vehicleraisesDuplicateVehicleNameError). Today that's a linear scan of the in-memory vehicle list — fine for a handful of vehicles. Once vehicles are persisted, this naturally becomes a databaseUNIQUEconstraint, which is both O(1) to enforce and closes the race window between concurrent creators that an in-memory check fundamentally can't.
Genuinely large fleets (thousands of vehicles, real-time dispatch) would warrant async workers and a persistent store — out of scope for this exercise, and intentionally not pre-built.
Ruby 3.3, Bundler, RSpec, RuboCop, GitHub Actions.
Project layout:
bin/liv
lib/liv/cli.rb
lib/liv/dispatcher.rb
lib/liv/vehicle.rb
lib/liv/delivery_tracker.rb
spec/liv/
spec/spec_helper.rb
.github/workflows/ci.yml
CI runs RSpec and RuboCop on every push to main and every pull request — see .github/workflows/ci.yml.
bundle install # install gems
bundle exec rspec # run the spec suite
bundle exec rubocop # lint
bundle exec rubocop -A # lint + auto-correct safe offenses
./bin/liv --vehicle Santa "<^>v><^" # run the program
./bin/liv --file ./data/sample.txt # read instructions from a file
./bin/liv --help # show usage