feat(snake): implement snake game with pure game logic, renderer, and IPC-based main loop

This commit is contained in:
2026-10-18 04:00:09 +02:00
parent a7913ddeef
commit 0389c85108
8 changed files with 458 additions and 2 deletions

View File

@@ -4,3 +4,5 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
types = { workspace = true }
userspace = { workspace = true }

View File

@@ -0,0 +1,159 @@
pub const GRID_WIDTH: u16 = 20;
pub const GRID_HEIGHT: u16 = 15;
const MAX_SNAKE_LEN: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
pub x: u16,
pub y: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Up,
Down,
Left,
Right,
}
impl Direction {
pub fn opposite(&self) -> Direction {
match self {
Direction::Up => Direction::Down,
Direction::Down => Direction::Up,
Direction::Left => Direction::Right,
Direction::Right => Direction::Left,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameState {
Running,
GameOver,
}
pub struct Snake {
body: [Position; MAX_SNAKE_LEN],
len: usize,
direction: Direction,
next_direction: Direction,
food: Position,
state: GameState,
score: u16,
rng_state: u32,
}
impl Snake {
pub fn new() -> Self {
let start = Position { x: GRID_WIDTH / 2, y: GRID_HEIGHT / 2 };
let mut body = [Position { x: 0, y: 0 }; MAX_SNAKE_LEN];
body[0] = start;
body[1] = Position { x: start.x - 1, y: start.y };
body[2] = Position { x: start.x - 2, y: start.y };
let mut game = Self {
body,
len: 3,
direction: Direction::Right,
next_direction: Direction::Right,
food: Position { x: 5, y: 5 },
state: GameState::Running,
score: 0,
rng_state: 42,
};
game.place_food();
game
}
pub fn set_direction(&mut self, dir: Direction) {
if dir != self.direction.opposite() {
self.next_direction = dir;
}
}
pub fn tick(&mut self) {
if self.state != GameState::Running {
return;
}
self.direction = self.next_direction;
let head = self.body[0];
let new_head = match self.direction {
Direction::Up => {
if head.y == 0 { self.state = GameState::GameOver; return; }
Position { x: head.x, y: head.y - 1 }
}
Direction::Down => {
if head.y >= GRID_HEIGHT - 1 { self.state = GameState::GameOver; return; }
Position { x: head.x, y: head.y + 1 }
}
Direction::Left => {
if head.x == 0 { self.state = GameState::GameOver; return; }
Position { x: head.x - 1, y: head.y }
}
Direction::Right => {
if head.x >= GRID_WIDTH - 1 { self.state = GameState::GameOver; return; }
Position { x: head.x + 1, y: head.y }
}
};
if self.hits_self(new_head) {
self.state = GameState::GameOver;
return;
}
let ate_food = new_head == self.food;
if !ate_food {
for i in (1..self.len).rev() {
self.body[i] = self.body[i - 1];
}
} else {
if self.len < MAX_SNAKE_LEN {
for i in (1..self.len + 1).rev() {
self.body[i] = self.body[i - 1];
}
self.len += 1;
}
self.score += 1;
self.place_food();
}
self.body[0] = new_head;
}
fn hits_self(&self, pos: Position) -> bool {
for i in 0..self.len {
if self.body[i] == pos {
return true;
}
}
false
}
fn place_food(&mut self) {
loop {
self.rng_state ^= self.rng_state << 13;
self.rng_state ^= self.rng_state >> 17;
self.rng_state ^= self.rng_state << 5;
let x = (self.rng_state % GRID_WIDTH as u32) as u16;
let y = ((self.rng_state / GRID_WIDTH as u32) % GRID_HEIGHT as u32) as u16;
let pos = Position { x, y };
if !self.hits_self(pos) {
self.food = pos;
return;
}
}
}
pub fn head(&self) -> Position { self.body[0] }
pub fn body(&self) -> &[Position] { &self.body[..self.len] }
pub fn food(&self) -> Position { self.food }
pub fn state(&self) -> GameState { self.state }
pub fn score(&self) -> u16 { self.score }
pub fn direction(&self) -> Direction { self.direction }
}

View File

@@ -0,0 +1,8 @@
#![no_std]
pub mod game;
pub mod renderer;
#[cfg(test)]
#[path = "tests/mod.rs"]
mod tests;

View File

@@ -1,3 +1,100 @@
fn main() { #![no_std]
println!("Hello, world!"); #![no_main]
use snake::game::{Direction, GameState, Snake};
use snake::renderer::{render, RenderCommand};
const DISPLAY_EP: usize = 0;
const INPUT_EP: usize = 1;
const TICK_DELAY: usize = 2_000_000;
const MAX_RENDER_COMMANDS: usize = 192;
#[cfg(not(test))]
use core::arch::global_asm;
#[cfg(not(test))]
global_asm!(
r#"
.section .text.entry
.globl _start
_start:
call main
j _start
"#
);
#[cfg(not(test))]
#[unsafe(no_mangle)]
extern "C" fn main() -> ! {
run_game();
}
fn run_game() -> ! {
let mut snake = Snake::new();
let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; MAX_RENDER_COMMANDS];
loop {
handle_input(&mut snake);
snake.tick();
let count = render(&snake, &mut commands);
send_render_commands(&commands, count);
if snake.state() == GameState::GameOver {
userspace::syscall::exit();
}
wait_tick();
}
}
fn handle_input(snake: &mut Snake) {
// PollEvent request: tag=0
let _ = userspace::syscall::send(INPUT_EP, 0, 0, 0, 0);
let (tag, kind, _data, _) = userspace::syscall::receive(INPUT_EP);
// InputResponse::Event(KeyDown(key)): tag=1, key_kind in word 1
if tag == 1 {
match kind {
0 => snake.set_direction(Direction::Up),
1 => snake.set_direction(Direction::Down),
2 => snake.set_direction(Direction::Left),
3 => snake.set_direction(Direction::Right),
_ => {}
}
}
}
fn send_render_commands(commands: &[RenderCommand], count: usize) {
// Clear: DisplayRequest tag=1
let _ = userspace::syscall::send(DISPLAY_EP, 1, 0, 0, 0);
let _ = userspace::syscall::receive(DISPLAY_EP);
for i in 0..count {
let cmd = &commands[i];
// DrawRect: DisplayRequest tag=0, xy packed, wh packed, color
let xy = cmd.x as usize | ((cmd.y as usize) << 16);
let wh = cmd.w as usize | ((cmd.h as usize) << 16);
let _ = userspace::syscall::send(DISPLAY_EP, 0, xy, wh, cmd.color as usize);
let _ = userspace::syscall::receive(DISPLAY_EP);
}
// Flush: DisplayRequest tag=2
let _ = userspace::syscall::send(DISPLAY_EP, 2, 0, 0, 0);
let _ = userspace::syscall::receive(DISPLAY_EP);
}
fn wait_tick() {
let start = userspace::syscall::get_time();
let deadline = start + TICK_DELAY;
while userspace::syscall::get_time() < deadline {
userspace::syscall::yield_now();
}
}
#[cfg(not(test))]
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
userspace::syscall::exit();
} }

View File

@@ -0,0 +1,74 @@
use crate::game::{Snake, Position, GRID_WIDTH, GRID_HEIGHT};
const CELL_SIZE: u16 = 16;
const BG_COLOR: u32 = 0x1A1A2E;
const SNAKE_COLOR: u32 = 0x00FF41;
const HEAD_COLOR: u32 = 0x00CC33;
const FOOD_COLOR: u32 = 0xFF0040;
const BORDER_COLOR: u32 = 0x333355;
#[derive(Clone, Copy)]
pub struct RenderCommand {
pub x: u16,
pub y: u16,
pub w: u16,
pub h: u16,
pub color: u32,
}
pub fn render(snake: &Snake, commands: &mut [RenderCommand]) -> usize {
let mut count = 0;
// Background
if count < commands.len() {
commands[count] = RenderCommand {
x: 0, y: 0,
w: GRID_WIDTH * CELL_SIZE,
h: GRID_HEIGHT * CELL_SIZE,
color: BG_COLOR,
};
count += 1;
}
// Border
if count < commands.len() {
commands[count] = RenderCommand {
x: 0, y: 0,
w: GRID_WIDTH * CELL_SIZE,
h: 1,
color: BORDER_COLOR,
};
count += 1;
}
// Food
let food = snake.food();
if count < commands.len() {
commands[count] = cell_command(food, FOOD_COLOR);
count += 1;
}
// Snake body
let body = snake.body();
for (i, &pos) in body.iter().enumerate() {
if count >= commands.len() { break; }
let color = if i == 0 { HEAD_COLOR } else { SNAKE_COLOR };
commands[count] = cell_command(pos, color);
count += 1;
}
count
}
fn cell_command(pos: Position, color: u32) -> RenderCommand {
RenderCommand {
x: pos.x * CELL_SIZE + 1,
y: pos.y * CELL_SIZE + 1,
w: CELL_SIZE - 2,
h: CELL_SIZE - 2,
color,
}
}
pub fn grid_pixel_width() -> u16 { GRID_WIDTH * CELL_SIZE }
pub fn grid_pixel_height() -> u16 { GRID_HEIGHT * CELL_SIZE }

View File

@@ -0,0 +1,82 @@
use crate::game::*;
#[test]
fn new_game_is_running() {
let game = Snake::new();
assert_eq!(game.state(), GameState::Running);
}
#[test]
fn initial_snake_has_three_segments() {
let game = Snake::new();
assert_eq!(game.body().len(), 3);
}
#[test]
fn initial_score_is_zero() {
let game = Snake::new();
assert_eq!(game.score(), 0);
}
#[test]
fn tick_moves_head_right() {
let mut game = Snake::new();
let initial_head = game.head();
game.tick();
assert_eq!(game.head().x, initial_head.x + 1);
assert_eq!(game.head().y, initial_head.y);
}
#[test]
fn set_direction_changes_movement() {
let mut game = Snake::new();
game.set_direction(Direction::Down);
game.tick();
let head = game.head();
assert_eq!(head.y, GRID_HEIGHT / 2 + 1);
}
#[test]
fn cannot_reverse_direction() {
let mut game = Snake::new();
// moving right, try to go left — should be ignored
game.set_direction(Direction::Left);
game.tick();
assert_eq!(game.direction(), Direction::Right);
}
#[test]
fn wall_collision_ends_game() {
let mut game = Snake::new();
// Move right until hitting the wall
for _ in 0..GRID_WIDTH {
if game.state() != GameState::Running { break; }
game.tick();
}
assert_eq!(game.state(), GameState::GameOver);
}
#[test]
fn food_exists_on_grid() {
let game = Snake::new();
let food = game.food();
assert!(food.x < GRID_WIDTH);
assert!(food.y < GRID_HEIGHT);
}
#[test]
fn body_length_preserved_without_food() {
let mut game = Snake::new();
let initial_len = game.body().len();
game.tick();
assert_eq!(game.body().len(), initial_len);
}
#[test]
fn direction_up_moves_correctly() {
let mut game = Snake::new();
game.set_direction(Direction::Up);
let y_before = game.head().y;
game.tick();
assert_eq!(game.head().y, y_before - 1);
}

View File

@@ -0,0 +1,2 @@
mod game;
mod renderer;

View File

@@ -0,0 +1,32 @@
use crate::game::Snake;
use crate::renderer::{render, RenderCommand};
#[test]
fn render_produces_commands() {
let snake = Snake::new();
let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; 64];
let count = render(&snake, &mut commands);
// background + border + food + 3 body segments = 6
assert!(count >= 6);
}
#[test]
fn first_command_is_background() {
let snake = Snake::new();
let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; 64];
render(&snake, &mut commands);
assert_eq!(commands[0].x, 0);
assert_eq!(commands[0].y, 0);
assert!(commands[0].w > 0);
assert!(commands[0].h > 0);
}
#[test]
fn food_is_rendered() {
let snake = Snake::new();
let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; 64];
let count = render(&snake, &mut commands);
// Food is command index 2 (after bg and border)
assert!(count > 2);
assert_eq!(commands[2].color, 0xFF0040);
}