Skip to content

Instantly share code, notes, and snippets.

@planetis-m
Last active October 30, 2025 19:56
Show Gist options
  • Select an option

  • Save planetis-m/af31ebb65d739e8dfa1456320255d127 to your computer and use it in GitHub Desktop.

Select an option

Save planetis-m/af31ebb65d739e8dfa1456320255d127 to your computer and use it in GitHub Desktop.

Nimony + raylib Tic-Tac-Toe

A minimal, single-file Tic-Tac-Toe game in Nim using raylib. It demonstrates a multi-threaded setup with a custom FFI wrapper for the shared library.

Prerequisites

  • Nimony compiler
  • A C compiler and raylib development libraries. See the official raylib wiki for platform-specific setup instructions.

Build & Run

The game requires the raylib shared library (.so, .dll, or .dylib). The easiest way to run the game is to place the raylib shared library file next to the executable.

Linux (from source):

git clone --depth 1 https://github.com/raysan5/raylib.git raylib
cd raylib/src/
make PLATFORM=PLATFORM_DESKTOP GLFW_LINUX_ENABLE_WAYLAND=TRUE GLFW_LINUX_ENABLE_X11=FALSE RAYLIB_LIBTYPE=SHARED
cp libraylib.so* ../../
cd ../..
nimony c -r tictactoe.nim

Other Platforms: Download the correct shared library for your OS from the raylib releases and run nim c -r tictactoe.nim.

# raylib.nim
# ----------------------------
# Dynamic Library Path
# ----------------------------
when defined(windows):
const raylibDll = "./raylib.dll"
elif defined(macosx):
const raylibDll = "./libraylib.dylib"
elif defined(linux):
const raylibDll = "./libraylib.so"
else:
{.error: "Unsupported OS for Raylib shared library."}
# Define the pragma for importing C functions from the dynamic library.
{.pragma: importRaylib, cdecl, dynlib: raylibDll.}
# ----------------------------
# Type Definitions
# ----------------------------
type
Color* {.bycopy.} = object
r*: uint8
g*: uint8
b*: uint8
a*: uint8
Vector2* {.bycopy.} = object
x*: float32
y*: float32
# ----------------------------
# Constant Definitions
# ----------------------------
# We define the constants directly instead of importing them.
# This makes the wrapper self-contained.
# Colors
let
Black* = Color(r: 0, g: 0, b: 0, a: 255)
RayWhite* = Color(r: 245, g: 245, b: 245, a: 255)
DarkGray* = Color(r: 80, g: 80, b: 80, a: 255)
# Keyboard Keys
const
KeyR*: int32 = 82 # Key code for 'R'
# Mouse Buttons
const
MouseButtonLeft*: int32 = 0 # Mouse button code for left
# ----------------------------
# Function Wrappers
# ----------------------------
# Window-related functions
proc initWindow*(width: int32, height: int32, title: cstring) {.importc: "InitWindow", importRaylib.}
proc windowShouldClose*(): bool {.importc: "WindowShouldClose", importRaylib.}
proc closeWindow*() {.importc: "CloseWindow", importRaylib.}
proc setTargetFPS*(fps: int32) {.importc: "SetTargetFPS", importRaylib.}
# Drawing-related functions
proc beginDrawing*() {.importc: "BeginDrawing", importRaylib.}
proc endDrawing*() {.importc: "EndDrawing", importRaylib.}
proc clearBackground*(color: Color) {.importc: "ClearBackground", importRaylib.}
# Input-related functions
proc isKeyPressed*(key: int32): bool {.importc: "IsKeyPressed", importRaylib.}
proc isMouseButtonPressed*(button: int32): bool {.importc: "IsMouseButtonPressed", importRaylib.}
proc getMousePosition*(): Vector2 {.importc: "GetMousePosition", importRaylib.}
# Shape/Text drawing functions
proc drawLine*(startPos: Vector2, endPos: Vector2, thick: float32, color: Color) {.importc: "DrawLineEx", importRaylib.}
proc drawCircle*(centerX: int32, centerY: int32, radius: float32, color: Color) {.importc: "DrawCircle", importRaylib.}
proc drawText*(text: cstring, posX: int32, posY: int32, fontSize: int32, color: Color) {.importc: "DrawText", importRaylib.}
proc measureText*(text: cstring, fontSize: int32): int32 {.importc: "MeasureText", importRaylib.}
# Nimony + raylib Tic-Tac-Toe
# - Main thread: rendering + player input
# - Worker thread: computes AI move when signaled (mutex + condition variable)
# - Only the main thread touches raylib drawing calls (BeginDrawing/EndDrawing)
import raylib
import std/[syncio, locks, rawthreads]
# ----------------------------
# Small helpers and game model
# ----------------------------
type
Cell = enum
Empty, # 0
X, # 1 (human)
O # 2 (AI)
Board = array[9, Cell]
GameState = enum
None, # Game ongoing
XWins, # X wins
OWins, # O wins
Draw # Draw
const
ScreenWidth: int32 = 480
ScreenHeight: int32 = 520
Margin = 30
TopBar = 60
CellSize = (ScreenWidth - Margin * 2) div 3
GridSize = CellSize * 3
GridX = Margin
GridY = TopBar
# All winning triples (indices)
Wins = [
[0,1,2], [3,4,5], [6,7,8], # rows
[0,3,6], [1,4,7], [2,5,8], # cols
[0,4,8], [2,4,6] # diagonals
]
func vec2(x, y: float32): Vector2 =
Vector2(x: x, y: y)
proc checkWinner(b: Board): GameState =
# Returns: 0 = none, 1 = X, 2 = O, 3 = draw
for line in Wins:
let a = b[line[0]]
if a != Empty and b[line[1]] == a and b[line[2]] == a:
if a == X: return XWins
else: return OWins
for i in 0..8:
if b[i] == Empty: return None
return Draw
# ----------
# Minimax AI
# ----------
proc minimax(b: var Board, player: Cell, depth: int): int =
proc scoreState(b: Board, depth: int): int =
# Favor quick wins (10 - depth) and delay losses (-10 + depth)
case checkWinner(b)
of OWins: 10 - depth # O (AI) wins
of XWins: depth - 10 # X (human) wins
of Draw: 0 # draw
of None: 0 # non-terminal; not used directly
let state = checkWinner(b)
if state != GameState.None:
return scoreState(b, depth)
if player == O: # AI's turn: maximize
var best = -1_000
for i in 0..8:
if b[i] == Empty:
b[i] = O
let val = minimax(b, X, depth + 1)
b[i] = Empty
if val > best: best = val
return best
else: # Human's turn: minimize
var best = 1_000
for i in 0..8:
if b[i] == Empty:
b[i] = X
let val = minimax(b, O, depth + 1)
b[i] = Empty
if val < best: best = val
return best
proc aiChooseMove(b: Board): int =
# Pick the move with the best Minimax score for O (AI)
var work = b
var bestScore = -1_000
var bestMove = -1
for i in 0..8:
if work[i] == Empty:
work[i] = O
let sc = minimax(work, X, 1)
work[i] = Empty
if sc > bestScore:
bestScore = sc
bestMove = i
return bestMove
# ----------------------------
# Shared state for AI thread
# ----------------------------
var
gLock: Lock # protects the shared state below
gCond: Cond # AI sleeps here until signaled
aiHasJob = false
aiHasResult = false
aiInput: Board
aiOutput: int = -1
quitting = false
proc aiWorker(arg: pointer) {.nimcall.} =
# Worker AI thread:
# - Sleeps on condition var
# - Wakes only when main thread posts a job
# - Computes move and posts result back
while true:
acquire(gLock)
while not aiHasJob and not quitting:
wait(gCond, gLock) # releases lock while waiting, re-acquires on wake
if quitting:
release(gLock)
break
let b = aiInput # copy job locally
aiHasJob = false
release(gLock)
let move = aiChooseMove(b) # compute AI move
acquire(gLock)
aiOutput = move
aiHasResult = true
release(gLock)
# ----------------------------
# Drawing helpers (main thread)
# ----------------------------
proc drawGrid() =
let t = 6.0'f32
for i in 1..2:
let x = (GridX + i * CellSize).float32
let y = (GridY + i * CellSize).float32
drawLine(vec2(x, GridY.float32), vec2(x, (GridY + GridSize).float32), t, DarkGray) # vertical
drawLine(vec2(GridX.float32, y), vec2((GridX + GridSize).float32, y), t, DarkGray) # horizontal
proc drawX(cx, cy: float32, size: float32, thick: float32) =
let h = size * 0.45'f32
drawLine(vec2(cx - h, cy - h), vec2(cx + h, cy + h), thick, Black)
drawLine(vec2(cx - h, cy + h), vec2(cx + h, cy - h), thick, Black)
proc drawO(cx, cy: float32, radius: float32, thick: float32) =
# Draw a ring: outer filled circle - inner filled circle (background color)
drawCircle(cx.int32, cy.int32, radius, Black)
drawCircle(cx.int32, cy.int32, radius - thick, RayWhite)
proc drawBoard(b: Board) =
drawGrid()
for i in 0..8:
let r = i div 3
let c = i mod 3
let cx = (GridX + c * CellSize + CellSize div 2).float32
let cy = (GridY + r * CellSize + CellSize div 2).float32
if b[i] == X:
drawX(cx, cy, CellSize.float32, 8.0'f32)
elif b[i] == O:
drawO(cx, cy, CellSize.float32 * 0.40'f32, 8.0'f32)
proc idxFromMouse(mx, my: float32): int =
if mx < GridX.float32 or mx >= (GridX + GridSize).float32: return -1
if my < GridY.float32 or my >= (GridY + GridSize).float32: return -1
let col = int((mx - GridX.float32) / CellSize.float32)
let row = int((my - GridY.float32) / CellSize.float32)
return row * 3 + col
# ----------------------------
# Main program
# ----------------------------
proc main =
initLock(gLock)
initCond(gCond)
var worker: RawThread
try: create(worker, aiWorker, nil)
except: quit("Failed to start AI thread")
var board = default(Board)
var current = X # X = player, O = AI
initWindow(ScreenWidth, ScreenHeight, "Nimony + raylib: Tic-Tac-Toe")
setTargetFPS(60)
while not windowShouldClose():
# Input (main thread only)
let state = checkWinner(board)
if state == None and current == X:
if isMouseButtonPressed(MouseButtonLeft):
let pos = getMousePosition()
let idx = idxFromMouse(pos.x, pos.y)
if idx >= 0 and board[idx] == Empty:
board[idx] = X
# If game isn't over, ask AI to move (wake worker thread)
if checkWinner(board) == None:
acquire(gLock)
aiInput = board
aiHasJob = true
aiHasResult = false
signal(gCond) # wake the worker
release(gLock)
current = O
# Collect AI result (non-blocking; main loop keeps rendering)
if current == O:
acquire(gLock)
if aiHasResult:
let move = aiOutput
aiHasResult = false
release(gLock)
if move >= 0 and board[move] == Empty:
board[move] = O
current = X
else:
release(gLock)
# (Optional) restart with R
if isKeyPressed(KeyR):
board = default(Board)
current = X
acquire(gLock)
aiHasJob = false
aiHasResult = false
release(gLock)
# Rendering (main thread only)
beginDrawing()
clearBackground(RayWhite)
drawBoard(board)
var msg: cstring = ""
case checkWinner(board)
of XWins: msg = "X wins! Press R to restart."
of OWins: msg = "O wins! Press R to restart."
of Draw: msg = "Draw! Press R to restart."
of None:
if current == X: msg = "Your turn (X)."
else: msg = "AI is thinking..."
let fontSize: int32 = 24
let tw = measureText(msg, fontSize)
drawText(msg, (ScreenWidth - tw) div 2, 16, fontSize, Black)
endDrawing()
# Cleanup
acquire(gLock)
quitting = true
signal(gCond)
release(gLock)
join(worker)
deinitCond(gCond)
deinitLock(gLock)
closeWindow()
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment