A modern Tic-Tac-Toe game built with Godot 4, featuring an AI opponent and shader-based rendering for crisp graphics.
Play Online: https://tic-tac-toe.michael4d45.com/
The project is organized as a Godot 4 game with the following key components:
src/
├── Game.tscn # Main game scene
├── gameboard.gd # Core game logic and AI
├── gameboard.gdshader # 2D shader for rendering game board
├── background.gdshader # Animated background shader
├── AI.gd # UI controls for AI/Human selection
├── Enums.gd # Game state and player type enums
├── RichTextLabel.gd # Game status display logic
└── project.godot # Godot project configuration
gameboard.gd: Contains the main game logic, AI implementation, win detection, and coordinate conversion between mouse clicks and grid positionsgameboard.gdshader: 2D GLSL shader that renders the grid lines, X symbols, and O symbols directly on the GPUAI.gd: Manages the UI controls for switching between human and AI playersEnums.gd: Defines game states (X_TURN, O_TURN, X_WON, O_WON, TIE) and player types (HUMAN, AI)
The AI uses a simple but effective random placement strategy:
- Random Selection: The AI randomly selects from all available (empty) grid positions
- Bitwise Masking: Uses bitwise operations for efficient position tracking:
- Each grid position is represented as a bit in an integer mask
o_masktracks O player positions,x_masktracks X player positions- Combined mask (
o_mask | x_mask) identifies occupied positions
func ai():
# Get current board state from shader parameters
var grid_size = material.get_shader_parameter("grid_size")
var o_mask = material.get_shader_parameter("o_mask")
var x_mask = material.get_shader_parameter("x_mask")
# Find random empty position
var random_placement = get_random(o_mask | x_mask, int(grid_size * grid_size))
# Make move and update game state
if game_state == Enums.GameState.X_TURN:
x_mask |= random_placement
elif game_state == Enums.GameState.O_TURN:
o_mask |= random_placement
process_game(x_mask, o_mask, grid_size)The get_random() function recursively selects positions until it finds an unoccupied spot, ensuring valid moves.
The game uses Godot's 2D shaders to render everything directly on the GPU, providing smooth, scalable graphics regardless of grid size.
File: gameboard.gdshader
The shader system works by:
- Mathematical Grid Generation: Creates grid lines using modulo operations
- Symbolic Rendering: Draws X's and O's using mathematical functions
- Bitwise Position Checking: Uses integer masks to determine what to draw where
float horizontal_line = abs(mod((UV.y + offset) * grid_size, 1.0)) < line_thickness ? 1.0 : 0.0;
float vertical_line = abs(mod((UV.x + offset) * grid_size, 1.0)) < line_thickness ? 1.0 : 0.0;- Uses UV coordinates and modulo operations to create evenly spaced lines
grid_sizeparameter allows for dynamic grid sizes (3x3, 5x5, etc.)- Line thickness is adjustable via uniform parameters
bool is_circle(vec2 uv, vec2 pos) {
float circle_size = 0.00002518629 + (1.168574 - 0.00002518629)/(1.0 + pow((grid_size/0.4903008),2.213454));
float circle_thickness = (37764.0)/(1.0 + pow((grid_size/0.0002911425),1.8));
translate_pos(uv, pos);
bool outer_circle = abs((pos.y * pos.y) + (pos.x * pos.x)) < circle_size;
bool inner_circle = abs((pos.y * pos.y) + (pos.x * pos.x)) > circle_size - circle_thickness;
return outer_circle && inner_circle;
}bool is_cross(vec2 uv, vec2 pos) {
translate_pos(uv, pos);
float cross_thickness = 0.01;
// Define bounds for the cross
float bottom = -0.45 / grid_size;
float top = 0.45 / grid_size;
float left = -0.45 / grid_size;
float right = 0.45 / grid_size;
// Calculate diagonal lines using linear equations
bool forward_slash = (pos.y + pos.x > -cross_thickness) && (pos.y + pos.x < cross_thickness);
bool back_slash = (pos.y - pos.x > -cross_thickness) && (pos.y - pos.x < cross_thickness);
return (forward_slash || back_slash) && in_bounds;
}The shader receives integer masks from the game logic and uses bitwise operations to determine which symbols to render:
bool check_pos_in_mask(vec2 pos, int marksMask) {
int posMask = 1 << int(pos.y) * int(grid_size) + int(pos.x);
return (marksMask & posMask) != 0;
}This system allows for:
- Efficient State Management: Board state stored as simple integers
- Dynamic Grid Sizes: Works with any NxN grid (currently set to 5x5)
- Scalable Rendering: Symbols automatically scale with grid size
- GPU Performance: All rendering calculations happen on the GPU
-
Fragment Shader Execution: For each pixel, the shader determines:
- Is this pixel part of a grid line?
- Is this pixel part of an O symbol in an occupied position?
- Is this pixel part of an X symbol in an occupied position?
-
Position Translation: Converts UV coordinates to grid positions
-
Mask Checking: Checks if the current grid cell should contain a symbol
-
Color Assignment: Assigns appropriate colors based on what should be rendered
- Engine: Godot 4.2
- Languages: GDScript (game logic), GLSL (shaders)
- Rendering: OpenGL ES 3.0 / WebGL 2.0 compatible
- Deployment: Netlify (supports required headers for Godot 4 web export)
- Prerequisites: Install Godot 4.2 or later
- Open Project: Open
src/project.godotin Godot - Run Game: Press F5 or use the Play button
- Web Export: Use Godot's HTML5 export template for web deployment
- Variable Grid Size: Currently 5x5, easily configurable
- AI vs Human: Toggle each player between AI and human control
- Shader-Based Graphics: Smooth, scalable rendering
- Automatic Game Reset: AI vs AI games reset automatically
- Win Detection: Detects wins, ties, and game states
- Responsive Design: Works on desktop and web browsers
Deployed on Netlify because GitHub Pages doesn't support the custom headers required for Godot 4 web rendering.