Vous n'êtes pas le sujet. – abstract movement platformer

preview builds; password is aregamesart

Vnêpls. is created around a tool-first mindset. i create the tools i need inside the game itself, to minimize the friction and create a more coherent whole. i minimize the dependency on art assets, privileging procedural art created in engine.

this project is therefor heavily code based. i chose to use Zig and SDL.zig for confort and my own mental health: i love Zig and i’m getting a bit burnt out on C lately. as usual, i’m also holding myself to high quality standards on this project, all errors should be handled cleanly (thankfully that’s quite easy with Zig’s error system), and it should run Fast even on slow/obsolete hardware.

i started by writing a 1-bit 2D software rendering system, with cool bliting options such as xoring sprites with the canvas. you can see it in use in my latest release, abstract (MIT license).

Untitled

after that was done, i needed to make some work on texture generation. i generate at runtime basic texture shapes. i intend on using simple blocks as a basis for complex visual compositions in level design.

all the texture generation code:

pub fn solidSquare(allocator: std.mem.Allocator) !Self {
    var b = try Self.init(allocator);
    b.solid.fill(.on);
    return b;
}

pub fn slopeLeftDown(allocator: std.mem.Allocator) !Self {
    var b = try Self.init(allocator);

    for (0..cfg.tsize) |y|
        for (0..y) |x|
            b.solid.point(.on, x, y);

    return b;
}

pub fn slopeRightDown(allocator: std.mem.Allocator) !Self {
    var b = try slopeLeftDown(allocator);
    b.solid.flip(.x);
    return b;
}

pub fn slopeLeftUp(allocator: std.mem.Allocator) !Self {
    var b = try slopeLeftDown(allocator);
    b.solid.flip(.y);
    return b;
}

pub fn slopeRightUp(allocator: std.mem.Allocator) !Self {
    var b = try slopeLeftDown(allocator);
    b.solid.flip(.d);
    return b;
}

i then worked a lot on writing the in-game level editor. i won’t give details about the entire process, but the notable points is than i spent a lot of times on ergonomics to automate basic tasks and make editing very fast. the usage of the editor is described on the projects private itch page.

today i wrote an input manager, which makes for very pleasant to read player code.

pub fn update(self: *Self) void {
    const dir: f32 =
        @as(f32, (if (self.input.down("right")) 1 else 0)) -
        @as(f32, (if (self.input.down("left")) 1 else 0));

    const grounded = self.collide(Vec2.init(0, 1));

    self.vel.X().* = appr(self.vel.x(), maxRunSpeed * dir, acceleration);
    self.vel.Y().* = appr(
        self.vel.y(),
        @as(f32, if (grounded) 0 else maxFallSpeed),
        gravity,
    );

    if (grounded and self.input.down("jump"))
        self.vel.Y().* = jumpSpeed;

    self.move(0);
    self.move(1);
}

this is the entire placeholder player logic so far.

i will work on the player movement, and i hope soon lock everything that’s here and start spilling out levels. this is what truly interests me here.

if i wasn’t so tired i would have recorded a video for those that don’t want to download, sorry about that. might come later.

i found this a fascinating little piece! such a tiny bucket of code and such a neat little playground as a result. i like how the position of your mouse on the screen is automatically the spawn point for it in the level.

i can’t pretend i know quite enough about programming to understand how exactly its made and the deeper significance, but then again, it is past 1 AM here right now - i will take another look at this soon, but until then, i was very happy i have it a shot >:)

Hell yeah. It took me a while to figure out that I could jump by clicking, which feels a little weird to me on a laptop, but that’s no big issue.

The player code does look really nice. One thing I don’t understand, how does climbing diagonal ramps work?

on my local copy i added bindings to jump with X and J, should be way better on a laptop. i’ll publish a preview once i’m happy with the new player code

the movement is a bit of a hack, when the player collides with a solid i check if there’s an empty space sliding it along the other axis, and if so, i teleport it. when walking downwards i added a condition if the player started on ground to do a vertical check even when not colliding

i know it can be a bit of a pain to read, but here’s the movement code if you’re curious

pub fn move(self: *Self, comptime axis: u1) void {
    if (self.collide(.{})) return;

    const sign = std.math.sign(self.vel.a[axis]);
    if (sign == 0) return;

    const steps: usize = @intFromFloat(@abs(self.vel.a[axis] * 256));

    var dir = Vec2{};
    dir.a[axis] = sign * 1.0 / 256;

    const oaxis = axis ^ 1;
    var perp = dir.swap().mul(256 * sign);

    const canSlide = axis == 0 or sign < 0;
    const grounded = axis == 0 and self.collide(Vec2.init(0, 1));

    for (0..steps) |_| { // range loop
        if (self.collide(dir)) {
            if (canSlide and !self.collide(dir.add(perp))) {
                self.pos.a[axis] += dir.a[axis];
                self.pos.a[oaxis] += 1;
            } else if (canSlide and !self.collide(dir.add(perp.negate()))) {
                self.pos.a[axis] += dir.a[axis];
                self.pos.a[oaxis] -= 1;
            } else {
                self.vel.a[axis] = 0;
                break;
            }
        }
        self.*.pos.a[axis] += dir.a[axis];
        if (grounded and
            !self.collide(Vec2.init(0, 1)) and
            self.collide(Vec2.init(0, 2)))
        {
            self.pos.a[oaxis] += 1;
        }
    }
}

it’s called once for each axis: self.move(0); self.move(1);

feel like sliding behavior in platformers is a fun sort of personality test
back a while ago now, i put together some platformer physics based on the article on celeste’s collision - great read if i somehow haven’t shared this link on here: Celeste & TowerFall Physics

the article doesn’t go over how to handle slopes so the below is how i figured it out myself. i can’t remember how i came to the exact solution for this but notably i handle sliding when moving along the x axis differently than y - if memory serves this handled things much better when trying to go at non-45deg slopes - it makes use of an incrementing value that tracks your position in subpixels to allow you to climb slopes without technically ‘teleporting’

/!\ warning: typescript syntax /!\
/**
	 * Move the actor the provided amount of pixels horizontally
	 * @param distance distance to move in pixels
	 * @param on_collide Callback to call if a collision occurs while moving
	 */
	moveX(distance: number, on_collide?: () => any) {
		// remainder tracks position in subpixels, for example if x = 1.5, it sets x to 1 and x_remainder to .5
		this.#x_remainder += distance
		let movement = Math.round(this.#x_remainder)

		if (movement != 0) {
			this.#x_remainder -= movement
			let sign = Math.sign(movement)

			while (movement != 0) {
				if (!this.collidesAt(this.pos.x + sign, this.pos.y)) {
					this.pos.x += sign
					movement -= sign
				} else {
					//this.pos.x -= sign
					movement = 0
					//this.#x_remainder = 0
					if (on_collide) {
						on_collide()
					}
					this.#checkZoneChanges()
					return
				}
			}
			this.#checkZoneChanges()
		}
	}

	/**
	 * Move the actor the provided amount of pixels horizontally
	 * @param distance distance to move in pixels
	 * @param on_collide Callback to call if a collision occurs while moving
	 */
	moveY(distance: number, on_collide?: () => any) {
		this.#y_remainder += distance
		let movement = Math.round(this.#y_remainder)

		if (movement != 0) {
			this.#y_remainder -= movement
			let sign = Math.sign(movement)

			while (movement != 0) {
				if (!this.collidesAt(new Vec2(this.pos.x, this.pos.y + sign))) {
					this.pos.y += sign
					movement -= sign
				} else {
					//this.pos.y -= sign
					movement = 0
					//this.#y_remainder = 0
					if (on_collide) {
						on_collide()
					}
					this.#checkZoneChanges()
					return
				}
			}
			this.#checkZoneChanges()
		}
	}

	/**
	 * Attempts to move the Actor the provided number of pixels. If a collision occurs,
	 * the actor will attempt to 'slide' along the surface to keep moving in the intended direction 
	 * @param distance distance to move, in pixels.
	 * @param callback callback to call if collision occurs.
	 */
	slideX(distance: number, callback = () => {return null}) {
		let sign = Math.sign(distance)
		const slide = () => {			
			callback()
			this.moveY(1.1)
			this.moveX(sign)
			this.moveY(-1.1)
			this.moveX(sign)
		}
		// sends standard movement command, only attempts to slide during collisin (via callback)
		this.moveX(distance, slide.bind(this))
	}

	/**
	 * Attempts to move the Actor the provided number of pixels. If a collision occurs,
	 * the actor will attempt to 'slide' along the surface to keep moving in the intended direction
	 * @param distance distance to move, in pixels.
	 * @param callback callback to call if collision occurs.
	 */
	slideY(distance: number, callback = () => {return null}) {
		let sign = Math.sign(distance)
		const slide = () => {
			let move = 0
			for (let x of [ -1, 1]) {
				if (!this.collidesAt(new Vec2(this.worldPosition.x + x, this.worldPosition.y))) {
					move += x * 1
				}
			}
			callback()
			this.moveX(move)
		}
		// sends standard movement command, only attempts to slide during collisin (via callback)
		this.moveY(distance, slide.bind(this))
	}

	/**
	 * Moves this actor in the direction and distance specified by a vector2.
	 * @param distance direction and distance to move, in pixels.
	 * @param callback callback to call if collision occurs.
	 */
	move(distance: Vec2, callback = () => {return null}) {
		if (Math.abs(distance.x % 1) < 0) {this.#x_remainder += (Math.abs(distance.x)% 1) * Math.sign(distance.x)}
		if (Math.abs(distance.y % 1) < 0) {this.#y_remainder += (Math.abs(distance.y)% 1) * Math.sign(distance.y)}
		
		distance.x = Math.trunc(distance.x)
		distance.y = Math.trunc(distance.y)

		if (distance.length == 0) {this.moveX(0); this.moveY(0); return}
		// simple bresenham algo, gets all x/y points in pixel space along a line
		let line = getLinePoints(Vec2.ZERO, distance)
		let last = Vec2.ZERO
		for (let pt of line) {
			last.x -= pt.x
			last.y -= pt.y
			if (last) {
				let dir = new Vec2(pt.x - last.x, pt.y - last.y)
				this.moveX(Math.sign(dir.x), callback)
				this.moveY(Math.sign(dir.y), callback)
			}
			last.x += pt.x
			last.y += pt.y
		}
	}

	/**
	 * Moves this actor in the direction and distance specified by a vector2, in pixels.
	 * If a collision occurs, attempts to slide the actor along the surface.
	 * @param distance direction and distance to move, in pixels.
	 * @param callback callback to call if collision occurs.
	 */
	 slide(distance: Vec2, callback = () => {return null}) {
		this.#x_remainder += distance.x - Math.trunc(distance.x)
		this.#y_remainder += distance.y - Math.trunc(distance.y)
		distance.x = Math.trunc(distance.x)
		distance.y = Math.trunc(distance.y)
		
		if (distance.length == 0) {this.moveX(0); this.moveY(0); return}
		// simple bresenham algo, gets all x/y points in pixel space along a line
		let line = getLinePoints(Vec2.ZERO, distance)
		let last = Vec2.ZERO
		for (let pt of line) {
			last.x -= pt.x
			last.y -= pt.y
			if (last) {
				let dir = new Vec2(pt.x - last.x, pt.y - last.y)
				this.slideX(Math.sign(dir.x), callback)
				this.slideY(Math.sign(dir.y), callback)
			}
			last.x += pt.x
			last.y += pt.y
		}
	}

yeah there’s a lot of ways to implement slopes, i had something more similar to yours in the past but i like the idea of moving faster (limited teleporting) exploiting slopes. i had slope code that used subpixels a few versions before (quite buggy but i was just testing), and i didn’t like the slower feel for this particular game. i also considered making slopes rotate the velocity axis of the player, but i don’t think it would look great with a 1x1 pixel player

the Celeste & Towerfall article is really good, one of my favorite physics engine ever is the one they made for Celeste CLASSIC (i highly recommand reading the sources, they’re relatively easy to understand). celeste not implementing slopes is not an accident, it’s something you need to plan from the start and want to design around imo

some of my favorite slope archetypes are from Super Mario Bros. 3, Kirby’s Adventure, N++ and Geometry Dash
slopes tier list when?

ah i forgot to say but when i launched your demo above, the zoomed out view with so many slopes around edges did recall n++ very directly for me

i love N++! the main references for the ethos of this project are the original N, LOVE and a old exploration platformer project of mine from 2021, i’m not trying to do like them but i can’t refute my inspirations

i’m leaking footage from the canceled project for illustration
https://vimeo.com/918176755
my movement is pretty poor, i haven’t played to it in years but hey it’s still cool

hey, i’m almost done working on the physics, they are close to what i’d like them to be but they’ll def’ need some tweaking. i also added bindings information for laptop to the itch page

the player code is now quite a lot longer, but still rather easy to understand imo:

pub fn update(self: *Self) void {
    const closeWall =
        @as(f32, (if (self.collide(Vec2.init(wallJumpMargin, 0))) 1 else 0)) -
        @as(f32, (if (self.collide(Vec2.init(-wallJumpMargin, 0))) 1 else 0));
    const dir: f32 =
        @as(f32, (if (self.input.down("right")) 1 else 0)) -
        @as(f32, (if (self.input.down("left")) 1 else 0));

    const grounded = self.collide(Vec2.init(0, 1));

    if (grounded)
        self.jumpState = .neutral;
    if (self.jumpState.airborn() and self.vel.y() > 0)
        self.jumpState = .neutral;
    if (self.jumpState == .rising and self.input.up("jump"))
        self.jumpState = .falling;

    if (dir != 0 or grounded)
        self.vel.X().* = appr(self.vel.x(), maxRunSpeed * dir, acceleration);
    self.vel.Y().* = appr(
        self.vel.y(),
        @as(f32, if (grounded) 0 else maxFallSpeed),
        @as(f32, if (self.jumpState == .falling) gravity * 3 else gravity),
    );

    if (self.input.press("jump"))
        self.buffer = true;
    if (self.input.release("jump"))
        self.buffer = false;

    if (!self.buffer) {
        // pass
    } else if (grounded and @abs(closeWall) > 0) { // slope
        self.jumpState = .rising;
        self.buffer = false;
        self.vel.Y().* = jumpSpeed + -@abs(self.vel.x() / 8);
        if (std.math.sign(dir) == std.math.sign(self.vel.x()))
            self.vel.X().* += dir * slopeAcceleration
        else if (std.math.sign(dir) != 0)
            self.vel.X().* *= -1;
    } else if (@abs(closeWall) > 0) { // wall
        self.jumpState = .rising;
        self.buffer = false;
        self.vel.Y().* += wallJumpSpeed;
        if (std.math.sign(dir) == -std.math.sign(closeWall))
            self.vel.X().* = -closeWall * wallJumpKickback
        else if (std.math.sign(dir) == std.math.sign(closeWall))
            self.vel.X().* = -closeWall * wallJumpLightKick
        else
            self.vel.X().* = -closeWall * wallJumpNeutralKick;
    } else if (grounded) { // ground
        self.jumpState = .rising;
        self.buffer = false;
        self.vel.Y().* = jumpSpeed;
    }

    self.move(0);
    self.move(1);
}

yes i don’t use a lot of comments in my code

version 2; password is aregamesart (link is the same than in main post)