No release in over 3 years
A fully-featured Tic Tac Toe engine implemented in Rust with Ruby bindings via Magnus (tic-tac-toe-magnus-rb). Supports move validation, win/draw detection, ASCII board rendering, and an unbeatable minimax AI opponent. Exists mostly to practice Ruby + Rust gem packaging with Nix.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

~> 0.9
 Project Readme

tic-tac-toe-magnus-rb ๐Ÿฆ€๐Ÿ’Ž

Gem Version CI Nix

A Tic Tac Toe game engine with a Rust core and a clean Ruby API, packaged with Nix.

Exists mostly to practice Ruby + Rust gem packaging with Nix.

 X โ”‚ ยท โ”‚ O
โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
 ยท โ”‚ X โ”‚ ยท
โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
 ยท โ”‚ ยท โ”‚ X

Table of Contents

  • What's inside
  • Getting started
    • Install the gem
    • With Nix (recommended)
    • Without Nix
  • Try it in the REPL
  • Releasing a new version
  • Nix architecture
    • Vendoring Cargo dependencies
    • Gem pinning with bundix
  • Ruby API
  • Project layout
  • How the Rust extension works
  • Test suite
  • License

What's inside

Layer Technology Purpose
Game logic Rust Board state, move validation, win/draw detection, minimax AI
Ruby bindings Magnus 0.7 Zero-cost safe Rust โ†” Ruby bridge
Gem extension rb_sys + rake-compiler Drives cargo build from extconf.rb
Dev shell Nix (classical) Reproducible Ruby + Rust toolchain, no version drift
Gem deps bundlerEnv + bundix SHA-256-pinned Ruby gems inside the Nix sandbox

Getting started

Install the gem

gem install tic_tac_toe_magnus

With Nix (recommended)

git clone https://github.com/kisp/tic-tac-toe-magnus-rb
cd tic-tac-toe-magnus-rb

nix-shell                      # enter the reproducible dev shell
bundle install                 # install Ruby gems
bundix                         # pin gems โ†’ gemset.nix  (first time only)
bundle exec rake compile       # build the Rust extension
bundle exec rake test          # run the test suite (98 tests)

With direnv + nix-direnv:

direnv allow    # shell activates automatically on every cd into the project

Without Nix

You need Ruby โ‰ฅ 3.1 and Rust stable on your PATH:

bundle install
bundle exec rake compile
bundle exec rake test

Try it in the REPL

After compiling the extension, start an IRB session with the gem pre-loaded:

bundle exec rake irb
irb(main):001> TicTacToe::Game.new
=>
 ยท โ”‚ ยท โ”‚ ยท
โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
 ยท โ”‚ ยท โ”‚ ยท
โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
 ยท โ”‚ ยท โ”‚ ยท

  Current player : X
  State          : playing
  Valid moves    : 0, 1, 2, 3, 4, 5, 6, 7, 8
irb(main):002> g = TicTacToe::Game.new
=>
 ยท โ”‚ ยท โ”‚ ยท
...
irb(main):003> g.current_player
=> "x"
irb(main):004> g.valid_moves
=> [0, 1, 2, 3, 4, 5, 6, 7, 8]
irb(main):005> g.make_move 3
=> nil
irb(main):006> g
=>
 ยท โ”‚ ยท โ”‚ ยท
โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
 X โ”‚ ยท โ”‚ ยท
โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
 ยท โ”‚ ยท โ”‚ ยท

  Current player : O
  State          : playing
  Valid moves    : 0, 1, 2, 4, 5, 6, 7, 8
irb(main):007> exit

Releasing a new version

A single Rake task bumps the version, commits, tags, builds the gem, and prints the gem push command so you can review before publishing:

bundle exec rake release           # patch bump  (e.g. 0.1.0 โ†’ 0.1.1)
bundle exec rake release[minor]    # minor bump  (e.g. 0.1.0 โ†’ 0.2.0)
bundle exec rake release[major]    # major bump  (e.g. 0.1.0 โ†’ 1.0.0)

The task will:

  1. Increment the version in lib/tictactoe/version.rb.
  2. Create a git commit (Bump version to X.Y.Z) and an annotated tag (vX.Y.Z).
  3. Build the gem (tic_tac_toe_magnus-X.Y.Z.gem).
  4. Print the exact gem push command to publish it.

Nix architecture

shell.nix / default.nix
โ”‚
โ”œโ”€โ”€ shell.nix                  # `nix-shell`
โ”‚   โ”œโ”€โ”€ ruby_3_3               # from nixpkgs
โ”‚   โ”œโ”€โ”€ rustc / cargo          # stable Rust toolchain from nixpkgs
โ”‚   โ”œโ”€โ”€ bundlerEnv             # all Gemfile gems, SHA-256 locked
โ”‚   โ”œโ”€โ”€ bundix                 # gems โ†’ gemset.nix helper
โ”‚   โ””โ”€โ”€ shellHook              # sets RUBY_ROOT, RUBYLIB, greets you
โ”‚
โ””โ”€โ”€ default.nix                # `nix-build`
    โ””โ”€โ”€ mkDerivation           # compiles the Rust ext in the sandbox
                               # (requires vendored Cargo deps โ€” see below)

Vendoring Cargo dependencies

The Nix sandbox has no network access, so Cargo crates must be vendored before nix-build can compile the extension. Three approaches are documented in nix/vendor-cargo-deps.nix:

Approach How
importCargoLock Commit Cargo.lock; Nix fetches at eval time (easiest)
fetchCargoTarball Explicit SHA-256 hash in default.nix
Committed vendor dir cargo vendor + [source.vendored-sources] (fully offline)

Gem pinning with bundix

Gemfile  โ†’  bundle install  โ†’  Gemfile.lock  โ†’  bundix  โ†’  gemset.nix

Both Gemfile.lock and gemset.nix are committed โ€” they are the Nix equivalents of a lockfile. See nix/bundix-workflow.md for the full update procedure.


Ruby API

require "tic_tac_toe_magnus"

g = TicTacToe::Game.new

puts TicTacToe::Game.position_legend
# =>
#   0 โ”‚ 1 โ”‚ 2
#  โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
#   3 โ”‚ 4 โ”‚ 5
#  โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”ผโ”€โ”€โ”€
#   6 โ”‚ 7 โ”‚ 8

g.valid_moves       # => [0,1,2,3,4,5,6,7,8]
g.make_move(4)      # X takes centre
g.current_player    # => "o"
g.state             # => "playing" | "x_wins" | "o_wins" | "draw"
g.best_move         # => Integer (minimax) or nil if game over
puts g              # ASCII board
g.winner            # => :x | :o | nil
g.over?             # => true/false
g.reset             # reuse the same Ruby object

make_move raises ArgumentError on out-of-range positions, occupied cells, or moves after the game is over.


Project layout

tic-tac-toe-magnus-rb/
โ”œโ”€โ”€ shell.nix                  # Nix dev shell (nix-shell)
โ”œโ”€โ”€ default.nix                # Nix package derivation (nix-build)
โ”œโ”€โ”€ .envrc                     # direnv โ†’ nix-shell
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ nix/
โ”‚   โ”œโ”€โ”€ vendor-cargo-deps.nix  # how to vendor Cargo crates for nix-build
โ”‚   โ””โ”€โ”€ bundix-workflow.md     # gem pinning cookbook
โ”‚
โ”œโ”€โ”€ tic_tac_toe_magnus.gemspec
โ”œโ”€โ”€ Gemfile  /  Gemfile.lock   # committed โ€” Nix lockfile for Ruby deps
โ”œโ”€โ”€ gemset.nix                 # generated by bundix, also committed
โ”œโ”€โ”€ Rakefile                   # compile + test tasks
โ”‚
โ”œโ”€โ”€ ext/tictactoe/
โ”‚   โ”œโ”€โ”€ extconf.rb             # create_rust_makefile(...)
โ”‚   โ”œโ”€โ”€ Cargo.toml             # cdylib + magnus 0.7
โ”‚   โ””โ”€โ”€ src/lib.rs             # ALL game logic + Magnus bindings
โ”‚
โ”œโ”€โ”€ lib/
โ”‚   โ”œโ”€โ”€ tictactoe.rb           # loads .so, adds Ruby sugar
โ”‚   โ””โ”€โ”€ tictactoe/version.rb
โ”‚
โ”œโ”€โ”€ test/
โ”‚   โ”œโ”€โ”€ test_helper.rb
โ”‚   โ”œโ”€โ”€ test_initial_state.rb
โ”‚   โ”œโ”€โ”€ test_make_move.rb
โ”‚   โ”œโ”€โ”€ test_win_detection.rb  # all 8 winning lines ร— 2 players = 16 tests
โ”‚   โ”œโ”€โ”€ test_draw_detection.rb
โ”‚   โ”œโ”€โ”€ test_minimax_ai.rb
โ”‚   โ”œโ”€โ”€ test_rendering.rb
โ”‚   โ”œโ”€โ”€ test_reset.rb
โ”‚   โ””โ”€โ”€ test_integration.rb
โ”‚
โ”œโ”€โ”€ bin/console                # IRB session with gem pre-loaded
โ””โ”€โ”€ examples/demo.rb           # full feature showcase

How the Rust extension works

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Ruby caller                        โ”‚
โ”‚  TicTacToe::Game.new / make_move โ€ฆ  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
             โ”‚  Magnus โ€” safe Rust โ†” Ruby, no GC pressure
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Game (magnus::wrap โ†’ Mutex<Inner>) โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚  GameInner (pure Rust)       โ”‚   โ”‚
โ”‚  โ”‚  โ€ข board: [Cell; 9]          โ”‚   โ”‚
โ”‚  โ”‚  โ€ข current_player: Player    โ”‚   โ”‚
โ”‚  โ”‚  โ€ข valid_moves()             โ”‚   โ”‚
โ”‚  โ”‚  โ€ข make_move(pos)            โ”‚   โ”‚
โ”‚  โ”‚  โ€ข state() / check_winner()  โ”‚   โ”‚
โ”‚  โ”‚  โ€ข minimax() / best_move()   โ”‚   โ”‚
โ”‚  โ”‚  โ€ข to_ascii()                โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Mutex<GameInner> wrapper makes the Ruby object safe to share across Ractor/thread boundaries.


Test suite

bundle exec rake test

98 tests across 8 files โ€” initial state, move mechanics, all 8 winning lines for both players, draw detection, AI correctness (winning moves, blocking moves, perfect-play draw), rendering, reset, and integration scenarios including thread safety.


License

MIT