about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorGriffin Smith <root@gws.fyi>2019-07-06T02·45-0400
committerGriffin Smith <root@gws.fyi>2019-07-06T02·45-0400
commitde081d7b1d0b791b2e61f9cde7369ea11647e0ae (patch)
tree687236ac6ca2d094053fa43112d967554531de86 /src
an @-sign in a box
Diffstat (limited to 'src')
-rw-r--r--src/cli.yml14
-rw-r--r--src/display/draw_box.rs205
-rw-r--r--src/display/mod.rs9
-rw-r--r--src/display/utils.rs9
-rw-r--r--src/entities/character.rs15
-rw-r--r--src/entities/mod.rs1
-rw-r--r--src/game.rs118
-rw-r--r--src/main.rs73
-rw-r--r--src/settings.rs61
-rw-r--r--src/types/command.rs23
-rw-r--r--src/types/direction.rs9
-rw-r--r--src/types/mod.rs296
12 files changed, 833 insertions, 0 deletions
diff --git a/src/cli.yml b/src/cli.yml
new file mode 100644
index 000000000000..7c374e102048
--- /dev/null
+++ b/src/cli.yml
@@ -0,0 +1,14 @@
+name: xanthous
+version: "0.0"
+author: Griffin Smith <root@gws.fyi>
+about: hey, it's a terminal game
+args:
+  - config:
+      short: c
+      long: config
+      value_name: FILE
+      help: Sets a custom config file
+      takes_value: true
+subcommands:
+  - debug:
+      about: Writes debug information to the terminal and exits
diff --git a/src/display/draw_box.rs b/src/display/draw_box.rs
new file mode 100644
index 000000000000..986f09a49f7c
--- /dev/null
+++ b/src/display/draw_box.rs
@@ -0,0 +1,205 @@
+use crate::display::utils::clone_times;
+use crate::display::utils::times;
+use crate::types::Dimensions;
+use itertools::Itertools;
+use proptest::prelude::Arbitrary;
+use proptest::strategy;
+use proptest_derive::Arbitrary;
+
+// Box Drawing
+//  	    0 	1 	2 	3 	4 	5 	6 	7 	8 	9 	A 	B 	C 	D 	E 	F
+// U+250x 	─ 	━ 	│ 	┃ 	┄ 	┅ 	┆ 	┇ 	┈ 	┉ 	┊ 	┋ 	┌ 	┍ 	┎ 	┏
+// U+251x 	┐ 	┑ 	┒ 	┓ 	└ 	┕ 	┖ 	┗ 	┘ 	┙ 	┚ 	┛ 	├ 	┝ 	┞ 	┟
+// U+252x 	┠ 	┡ 	┢ 	┣ 	┤ 	┥ 	┦ 	┧ 	┨ 	┩ 	┪ 	┫ 	┬ 	┭ 	┮ 	┯
+// U+253x 	┰ 	┱ 	┲ 	┳ 	┴ 	┵ 	┶ 	┷ 	┸ 	┹ 	┺ 	┻ 	┼ 	┽ 	┾ 	┿
+// U+254x 	╀ 	╁ 	╂ 	╃ 	╄ 	╅ 	╆ 	╇ 	╈ 	╉ 	╊ 	╋ 	╌ 	╍ 	╎ 	╏
+// U+255x 	═ 	║ 	╒ 	╓ 	╔ 	╕ 	╖ 	╗ 	╘ 	╙ 	╚ 	╛ 	╜ 	╝ 	╞ 	╟
+// U+256x 	╠ 	╡ 	╢ 	╣ 	╤ 	╥ 	╦ 	╧ 	╨ 	╩ 	╪ 	╫ 	╬ 	╭ 	╮ 	╯
+// U+257x 	╰ 	╱ 	╲ 	╳ 	╴ 	╵ 	╶ 	╷ 	╸ 	╹ 	╺ 	╻ 	╼ 	╽ 	╾ 	╿
+
+static BOX: char = '☐';
+
+static BOX_CHARS: [[char; 16]; 8] = [
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '─', '━', '│', '┃', '┄', '┅', '┆', '┇', '┈', '┉',
+        // 10
+        '┊', '┋', '┌', '┍', '┎', '┏',
+    ],
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '┐', '┑', '┒', '┓', '└', '┕', '┖', '┗', '┘', '┙',
+        '┚', '┛', '├', '┝', '┞', '┟',
+    ],
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '┠', '┡', '┢', '┣', '┤', '┥', '┦', '┧', '┨', '┩',
+        '┪', '┫', '┬', '┭', '┮', '┯',
+    ],
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '┰', '┱', '┲', '┳', '┴', '┵', '┶', '┷', '┸', '┹',
+        '┺', '┻', '┼', '┽', '┾', '┿',
+    ],
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '╀', '╁', '╂', '╃', '╄', '╅', '╆', '╇', '╈', '╉',
+        '╊', '╋', '╌', '╍', '╎', '╏',
+    ],
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '═', '║', '╒', '╓', '╔', '╕', '╖', '╗', '╘', '╙',
+        '╚', '╛', '╜', '╝', '╞', '╟',
+    ],
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '╠', '╡', '╢', '╣', '╤', '╥', '╦', '╧', '╨', '╩',
+        '╪', '╫', '╬', '╭', '╮', '╯',
+    ],
+    [
+        // 0    1    2    3    4    5    6    7    8    9
+        '╰', '╱', '╲', '╳', '╴', '╵', '╶', '╷', '╸', '╹',
+        '╺', '╻', '╼', '╽', '╾', '╿',
+    ],
+];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum BoxStyle {
+    Thin,
+    Thick,
+    Dotted,
+    ThickDotted,
+    Dashed,
+    ThickDashed,
+    Double,
+}
+
+impl Arbitrary for BoxStyle {
+    type Parameters = ();
+    type Strategy = strategy::Just<Self>;
+    fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
+        // TODO
+        strategy::Just(BoxStyle::Thin)
+    }
+}
+
+trait Stylable {
+    fn style(self, style: BoxStyle) -> char;
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+enum Corner {
+    TopRight,
+    TopLeft,
+    BottomRight,
+    BottomLeft,
+}
+
+impl Stylable for Corner {
+    fn style(self, style: BoxStyle) -> char {
+        use BoxStyle::*;
+        use Corner::*;
+
+        match (self, style) {
+            (TopRight, Thin) => BOX_CHARS[1][0],
+            (TopLeft, Thin) => BOX_CHARS[0][12],
+            (BottomRight, Thin) => BOX_CHARS[1][8],
+            (BottomLeft, Thin) => BOX_CHARS[1][4],
+            _ => unimplemented!(),
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+enum Line {
+    H,
+    V,
+}
+
+impl Stylable for Line {
+    fn style(self, style: BoxStyle) -> char {
+        use BoxStyle::*;
+        use Line::*;
+        match (self, style) {
+            (H, Thin) => BOX_CHARS[0][0],
+            (V, Thin) => BOX_CHARS[0][2],
+            _ => unimplemented!(),
+        }
+    }
+}
+
+#[must_use]
+pub fn make_box(style: BoxStyle, dims: Dimensions) -> String {
+    if dims.h == 0 || dims.w == 0 {
+        "".to_string()
+    } else if dims.h == 1 && dims.w == 1 {
+        BOX.to_string()
+    } else if dims.h == 1 {
+        times(Line::H.style(style), dims.w)
+    } else if dims.w == 1 {
+        (0..dims.h).map(|_| Line::V.style(style)).join("\n\r")
+    } else {
+        let h_line: String = times(Line::H.style(style), dims.w - 2);
+        let v_line = Line::V.style(style);
+        let v_walls: String = clone_times(
+            format!(
+                "{}{}{}\n\r",
+                v_line,
+                times::<_, String>(' ', dims.w - 2),
+                v_line
+            ),
+            dims.h - 2,
+        );
+
+        format!(
+            "{}{}{}\n\r{}{}{}{}",
+            Corner::TopLeft.style(style),
+            h_line,
+            Corner::TopRight.style(style),
+            v_walls,
+            Corner::BottomLeft.style(style),
+            h_line,
+            Corner::BottomRight.style(style),
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+
+    #[test]
+    fn make_thin_box() {
+        let res = make_box(BoxStyle::Thin, Dimensions { w: 10, h: 10 });
+        assert_eq!(
+            res,
+            "┌────────┐
+\r│        │
+\r│        │
+\r│        │
+\r│        │
+\r│        │
+\r│        │
+\r│        │
+\r│        │
+\r└────────┘"
+        );
+    }
+
+    proptest! {
+        #[test]
+        fn box_has_height_lines(dims: Dimensions, style: BoxStyle) {
+            let res = make_box(style, dims);
+            prop_assume!((dims.w > 0 && dims.h > 0));
+            assert_eq!(res.split("\n\r").count(), dims.h as usize);
+        }
+
+        #[test]
+        fn box_lines_have_width_length(dims: Dimensions, style: BoxStyle) {
+            let res = make_box(style, dims);
+            prop_assume!(dims.w == 0 && dims.h == 0 || (dims.w > 0 && dims.h > 0));
+            assert!(res.split("\n\r").all(|l| l.chars().count() == dims.w as usize));
+        }
+    }
+}
diff --git a/src/display/mod.rs b/src/display/mod.rs
new file mode 100644
index 000000000000..5dba48b44d9a
--- /dev/null
+++ b/src/display/mod.rs
@@ -0,0 +1,9 @@
+pub mod draw_box;
+pub mod utils;
+pub use draw_box::{make_box, BoxStyle};
+use std::io::{self, Write};
+use termion::{clear, cursor, style};
+
+pub fn clear<T: Write>(out: &mut T) -> io::Result<()> {
+    write!(out, "{}{}{}", clear::All, style::Reset, cursor::Goto(1, 1))
+}
diff --git a/src/display/utils.rs b/src/display/utils.rs
new file mode 100644
index 000000000000..acd4416cb884
--- /dev/null
+++ b/src/display/utils.rs
@@ -0,0 +1,9 @@
+use std::iter::FromIterator;
+
+pub fn times<A: Copy, B: FromIterator<A>>(elem: A, n: u16) -> B {
+    (0..n).map(|_| elem).collect()
+}
+
+pub fn clone_times<A: Clone, B: FromIterator<A>>(elem: A, n: u16) -> B {
+    (0..n).map(|_| elem.clone()).collect()
+}
diff --git a/src/entities/character.rs b/src/entities/character.rs
new file mode 100644
index 000000000000..e40b7b988e8d
--- /dev/null
+++ b/src/entities/character.rs
@@ -0,0 +1,15 @@
+use crate::types::{Position, Speed};
+
+const DEFAULT_SPEED: Speed = Speed(100);
+
+pub struct Character {
+    position: Position,
+}
+
+impl Character {
+    pub fn speed(&self) -> Speed {
+        Speed(100)
+    }
+}
+
+positioned!(Character);
diff --git a/src/entities/mod.rs b/src/entities/mod.rs
new file mode 100644
index 000000000000..78891226662a
--- /dev/null
+++ b/src/entities/mod.rs
@@ -0,0 +1 @@
+pub mod character;
diff --git a/src/game.rs b/src/game.rs
new file mode 100644
index 000000000000..a41d7f73fd75
--- /dev/null
+++ b/src/game.rs
@@ -0,0 +1,118 @@
+use std::thread;
+use crate::settings::Settings;
+use crate::types::{BoundingBox, Dimensions, Position};
+use std::io::{self, StdinLock, StdoutLock, Write};
+use termion::cursor;
+use termion::input::Keys;
+use termion::input::TermRead;
+use termion::raw::RawTerminal;
+
+use crate::display;
+use crate::types::command::Command;
+
+/// The full state of a running Game
+pub struct Game<'a> {
+    settings: Settings,
+
+    /// The box describing the viewport. Generally the size of the terminal, and
+    /// positioned at 0, 0
+    viewport: BoundingBox,
+
+    /// An iterator on keypresses from the user
+    keys: Keys<StdinLock<'a>>,
+
+    stdout: RawTerminal<StdoutLock<'a>>,
+
+    /// The position of the character
+    character: Position,
+}
+
+impl<'a> Game<'a> {
+    pub fn new(
+        settings: Settings,
+        stdout: RawTerminal<StdoutLock<'a>>,
+        stdin: StdinLock<'a>,
+        w: u16,
+        h: u16,
+    ) -> Game<'a> {
+        Game {
+            settings: settings,
+            viewport: BoundingBox::at_origin(Dimensions { w, h }),
+            keys: stdin.keys(),
+            stdout: stdout,
+            character: Position { x: 1, y: 1 },
+        }
+    }
+
+    /// Returns true if there's a collision in the game at the given Position
+    fn collision_at(&self, pos: Position) -> bool {
+        !pos.within(self.viewport.inner())
+    }
+
+    /// Run the game
+    pub fn run(mut self) {
+        info!("Running game");
+        write!(
+            self,
+            "{}{}@{}",
+            display::make_box(
+                display::BoxStyle::Thin,
+                self.viewport.dimensions
+            ),
+            cursor::Goto(2, 2),
+            cursor::Left(1),
+        )
+        .unwrap();
+        self.flush().unwrap();
+        loop {
+            let mut character_moved = false;
+            match Command::from_key(self.keys.next().unwrap().unwrap()) {
+                Some(Command::Quit) => {
+                    info!("Quitting game due to user request");
+                    break;
+                }
+
+                Some(Command::Move(direction)) => {
+                    let new_pos = self.character + direction;
+                    if !self.collision_at(new_pos) {
+                        self.character = new_pos;
+                        character_moved = true;
+                    }
+                }
+                _ => (),
+            }
+
+            if character_moved {
+                debug!("char: {:?}", self.character);
+                write!(
+                    self,
+                    " {}@{}",
+                    cursor::Goto(self.character.x + 1, self.character.y + 1,),
+                    cursor::Left(1)
+                )
+                .unwrap();
+            }
+            self.flush().unwrap();
+        }
+    }
+}
+
+impl<'a> Drop for Game<'a> {
+    fn drop(&mut self) {
+        display::clear(self).unwrap();
+    }
+}
+
+impl<'a> Write for Game<'a> {
+    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+        self.stdout.write(buf)
+    }
+
+    fn flush(&mut self) -> io::Result<()> {
+        self.stdout.flush()
+    }
+
+    fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
+        self.stdout.write_all(buf)
+    }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 000000000000..24d1bbba29ca
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,73 @@
+extern crate termion;
+#[macro_use]
+extern crate log;
+extern crate config;
+extern crate log4rs;
+#[macro_use]
+extern crate serde_derive;
+#[macro_use]
+extern crate clap;
+#[macro_use]
+extern crate prettytable;
+
+mod display;
+mod game;
+#[macro_use]
+mod types;
+mod entities;
+mod settings;
+
+use clap::App;
+use game::Game;
+use prettytable::format::consts::FORMAT_BOX_CHARS;
+use settings::Settings;
+
+use std::io::{self, StdinLock, StdoutLock};
+
+use termion::raw::IntoRawMode;
+use termion::raw::RawTerminal;
+
+fn init(
+    settings: Settings,
+    stdout: RawTerminal<StdoutLock<'_>>,
+    stdin: StdinLock<'_>,
+    w: u16,
+    h: u16,
+) {
+    let game = Game::new(settings, stdout, stdin, w, h);
+    game.run()
+}
+
+fn main() {
+    let yaml = load_yaml!("cli.yml");
+    let matches = App::from_yaml(yaml).get_matches();
+    let settings = Settings::load().unwrap();
+    settings.logging.init_log();
+    let stdout = io::stdout();
+    let stdout = stdout.lock();
+
+    let stdin = io::stdin();
+    let stdin = stdin.lock();
+
+    let termsize = termion::terminal_size().ok();
+    // let termwidth = termsize.map(|(w, _)| w - 2).unwrap_or(70);
+    // let termheight = termsize.map(|(_, h)| h - 2).unwrap_or(40);
+    let (termwidth, termheight) = termsize.unwrap_or((70, 40));
+
+    match matches.subcommand() {
+        ("debug", _) => {
+            let mut table = table!(
+                [br->"termwidth", termwidth],
+                [br->"termheight", termheight],
+                [br->"logfile", settings.logging.file],
+                [br->"loglevel", settings.logging.level]
+            );
+            table.set_format(*FORMAT_BOX_CHARS);
+            table.printstd();
+        }
+        _ => {
+            let stdout = stdout.into_raw_mode().unwrap();
+            init(settings, stdout, stdin, termwidth, termheight);
+        }
+    }
+}
diff --git a/src/settings.rs b/src/settings.rs
new file mode 100644
index 000000000000..06f0d4e9d7de
--- /dev/null
+++ b/src/settings.rs
@@ -0,0 +1,61 @@
+use config::{Config, ConfigError};
+use log::LevelFilter;
+use log4rs::append::file::FileAppender;
+use log4rs::config::{Appender, Root};
+use log4rs::encode::pattern::PatternEncoder;
+
+#[derive(Debug, Deserialize)]
+pub struct Logging {
+    #[serde(default = "Logging::default_level")]
+    pub level: LevelFilter,
+
+    #[serde(default = "Logging::default_file")]
+    pub file: String,
+}
+
+impl Default for Logging {
+    fn default() -> Self {
+        Logging {
+            level: LevelFilter::Off,
+            file: "debug.log".to_string(),
+        }
+    }
+}
+
+impl Logging {
+    pub fn init_log(&self) {
+        let logfile = FileAppender::builder()
+            .encoder(Box::new(PatternEncoder::new("{d} {l} - {m}\n")))
+            .build(self.file.clone())
+            .unwrap();
+
+        let config = log4rs::config::Config::builder()
+            .appender(Appender::builder().build("logfile", Box::new(logfile)))
+            .build(Root::builder().appender("logfile").build(self.level))
+            .unwrap();
+
+        log4rs::init_config(config).unwrap();
+    }
+
+    fn default_level() -> LevelFilter {
+        Logging::default().level
+    }
+
+    fn default_file() -> String {
+        Logging::default().file
+    }
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Settings {
+    pub logging: Logging,
+}
+
+impl Settings {
+    pub fn load() -> Result<Self, ConfigError> {
+        let mut s = Config::new();
+        s.merge(config::File::with_name("Config").required(false))?;
+        s.merge(config::Environment::with_prefix("XAN"))?;
+        s.try_into()
+    }
+}
diff --git a/src/types/command.rs b/src/types/command.rs
new file mode 100644
index 000000000000..86f83a12c181
--- /dev/null
+++ b/src/types/command.rs
@@ -0,0 +1,23 @@
+use super::Direction;
+use super::Direction::*;
+use termion::event::Key;
+use termion::event::Key::Char;
+
+pub enum Command {
+    Quit,
+    Move(Direction),
+}
+
+impl Command {
+    pub fn from_key(k: Key) -> Option<Command> {
+        use Command::*;
+        match k {
+            Char('q') => Some(Quit),
+            Char('h') | Char('a') | Key::Left => Some(Move(Left)),
+            Char('k') | Char('w') | Key::Up => Some(Move(Up)),
+            Char('j') | Char('s') | Key::Down => Some(Move(Down)),
+            Char('l') | Char('d') | Key::Right => Some(Move(Right)),
+            _ => None,
+        }
+    }
+}
diff --git a/src/types/direction.rs b/src/types/direction.rs
new file mode 100644
index 000000000000..5ab660f19317
--- /dev/null
+++ b/src/types/direction.rs
@@ -0,0 +1,9 @@
+use proptest_derive::Arbitrary;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+pub enum Direction {
+    Left,
+    Up,
+    Down,
+    Right,
+}
diff --git a/src/types/mod.rs b/src/types/mod.rs
new file mode 100644
index 000000000000..331aa236e324
--- /dev/null
+++ b/src/types/mod.rs
@@ -0,0 +1,296 @@
+use std::cmp::Ordering;
+use std::ops;
+pub mod command;
+pub mod direction;
+pub use direction::Direction;
+pub use direction::Direction::{Down, Left, Right, Up};
+use proptest_derive::Arbitrary;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+pub struct Dimensions {
+    #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")]
+    pub w: u16,
+
+    #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")]
+    pub h: u16,
+}
+
+pub const ZERO_DIMENSIONS: Dimensions = Dimensions { w: 0, h: 0 };
+pub const UNIT_DIMENSIONS: Dimensions = Dimensions { w: 1, h: 1 };
+
+impl ops::Sub<Dimensions> for Dimensions {
+    type Output = Dimensions;
+    fn sub(self, dims: Dimensions) -> Dimensions {
+        Dimensions {
+            w: self.w - dims.w,
+            h: self.h - dims.h,
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+pub struct BoundingBox {
+    pub dimensions: Dimensions,
+    pub position: Position,
+}
+
+impl BoundingBox {
+    pub fn at_origin(dimensions: Dimensions) -> BoundingBox {
+        BoundingBox {
+            dimensions,
+            position: ORIGIN,
+        }
+    }
+
+    pub fn lr_corner(self) -> Position {
+        self.position
+            + (Position {
+                x: self.dimensions.w,
+                y: self.dimensions.h,
+            })
+    }
+
+    /// Returns a bounding box representing the *inside* of this box if it was
+    /// drawn on the screen.
+    pub fn inner(self) -> BoundingBox {
+        self + UNIT_POSITION - UNIT_DIMENSIONS - UNIT_DIMENSIONS
+    }
+}
+
+impl ops::Add<Position> for BoundingBox {
+    type Output = BoundingBox;
+    fn add(self, pos: Position) -> BoundingBox {
+        BoundingBox {
+            position: self.position + pos,
+            ..self
+        }
+    }
+}
+
+impl ops::Sub<Dimensions> for BoundingBox {
+    type Output = BoundingBox;
+    fn sub(self, dims: Dimensions) -> BoundingBox {
+        BoundingBox {
+            dimensions: self.dimensions - dims,
+            ..self
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+pub struct Position {
+    /// x (horizontal) position
+    #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")]
+    pub x: u16,
+
+    #[proptest(strategy = "std::ops::Range::<u16>::from(0..100)")]
+    /// y (vertical) position
+    pub y: u16,
+}
+
+pub const ORIGIN: Position = Position { x: 0, y: 0 };
+pub const UNIT_POSITION: Position = Position { x: 1, y: 1 };
+
+impl Position {
+    /// Returns true if this position exists within the bounds of the given box,
+    /// inclusive
+    pub fn within(self, b: BoundingBox) -> bool {
+        (self > b.position - UNIT_POSITION) && self < (b.lr_corner())
+    }
+}
+
+impl PartialOrd for Position {
+    fn partial_cmp(&self, other: &Position) -> Option<Ordering> {
+        if self.x == other.x && self.y == other.y {
+            Some(Ordering::Equal)
+        } else if self.x > other.x && self.y > other.y {
+            Some(Ordering::Greater)
+        } else if self.x < other.x && self.y < other.y {
+            Some(Ordering::Less)
+        } else {
+            None
+        }
+    }
+}
+
+/// Implements (bounded) addition of a Dimension to a position.
+///
+/// # Examples
+///
+/// ```
+/// let pos = Position { x: 1, y: 10 }
+///
+/// let left_pos = pos + Direction::Left
+/// assert_eq!(left, Position { x: 0, y: 10 })
+///
+/// let right_pos = pos + Direction::Right
+/// assert_eq!(right_pos, Position { x: 0, y: 10 })
+/// ```
+impl ops::Add<Direction> for Position {
+    type Output = Position;
+    fn add(self, dir: Direction) -> Position {
+        match dir {
+            Left => {
+                if self.x > 0 {
+                    Position {
+                        x: self.x - 1,
+                        ..self
+                    }
+                } else {
+                    self
+                }
+            }
+            Right => {
+                if self.x < std::u16::MAX {
+                    Position {
+                        x: self.x + 1,
+                        ..self
+                    }
+                } else {
+                    self
+                }
+            }
+            Up => {
+                if self.y > 0 {
+                    Position {
+                        y: self.y - 1,
+                        ..self
+                    }
+                } else {
+                    self
+                }
+            }
+            Down => {
+                if self.y < std::u16::MAX {
+                    Position {
+                        y: self.y + 1,
+                        ..self
+                    }
+                } else {
+                    self
+                }
+            }
+        }
+    }
+}
+
+impl ops::Add<Position> for Position {
+    type Output = Position;
+    fn add(self, pos: Position) -> Position {
+        Position {
+            x: self.x + pos.x,
+            y: self.y + pos.y,
+        }
+    }
+}
+
+impl ops::Sub<Position> for Position {
+    type Output = Position;
+    fn sub(self, pos: Position) -> Position {
+        Position {
+            x: self.x - pos.x,
+            y: self.y - pos.y,
+        }
+    }
+}
+
+pub trait Positioned {
+    fn x(&self) -> u16 {
+        self.position().x
+    }
+
+    fn y(&self) -> u16 {
+        self.position().y
+    }
+
+    fn position(&self) -> Position {
+        Position {
+            x: self.x(),
+            y: self.y(),
+        }
+    }
+}
+
+macro_rules! positioned {
+    ($name:ident) => {
+        positioned!($name, position);
+    };
+    ($name:ident, $attr:ident) => {
+        impl crate::types::Positioned for $name {
+            fn position(&self) -> Position {
+                self.$attr
+            }
+        }
+    };
+}
+
+/// A number of ticks
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+pub struct Ticks(pub u16);
+
+/// A number of tiles
+///
+/// Expressed in terms of a float to allow moving partial tiles in a number of
+/// ticks
+#[derive(Clone, Copy, Debug, PartialEq, Arbitrary)]
+pub struct Tiles(pub f32);
+
+/// The speed of an entity, expressed in ticks per tile
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Arbitrary)]
+pub struct Speed(pub u32);
+
+impl Speed {
+    pub fn ticks_to_tiles(self, ticks: Ticks) -> Tiles {
+        Tiles(ticks.0 as f32 / self.0 as f32)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+
+    proptest! {
+        #[test]
+        fn position_partialord_lt_transitive(
+            a: Position,
+            b: Position,
+            c: Position
+        ) {
+            if a < b && b < c {
+                assert!(a < c)
+            }
+        }
+
+        #[test]
+        fn position_partialord_eq_transitive(
+            a: Position,
+            b: Position,
+            c: Position
+        ) {
+            if a == b && b == c {
+                assert!(a == c)
+            }
+        }
+
+        #[test]
+        fn position_partialord_gt_transitive(
+            a: Position,
+            b: Position,
+            c: Position,
+        ) {
+            if a > b && b > c {
+                assert!(a > c)
+            }
+        }
+
+        #[test]
+        fn position_partialord_antisymmetric(a: Position, b: Position) {
+            if a < b {
+                assert!(!(a > b))
+            } else if a > b {
+                assert!(!(a < b))
+            }
+        }
+    }
+}