Unit 4 · Lesson 11 Intermediate ⏱ ~90 minutes

Building a Side-Scroller

This is the final lesson of the tutorial series — and the most ambitious. We're going to build a complete side-scrolling platformer from scratch: a scrolling tilemap level, a player with gravity and jumping, an enemy to avoid, and a goal to reach. By the end you'll have a real playable game and the knowledge to extend it into something much bigger.

⚠️ The Sprite Engine is a separate download — grab it from the HyperXTalk community forums and add it to your stack before using any of the commands in this lesson. From v0.9.16 onwards it will be available directly in HyperXTalk via Tools → Package Manager → Sprite Engine.
🎯 Learning Objectives
  • Build a tile-based level with solid platforms using defineTile and loadTilemap
  • Add a scrolling background layer
  • Implement gravity and jumping with a physics loop
  • Use spriteOnGround, snapSpriteToGround, and spriteHitsCeiling
  • Add an enemy that patrols back and forth
  • Detect a win condition and reset the game
  • Explore the full sample game included with HyperXTalk

🎮 What We're Building

Our game will have:

🗺️ A tile map level with ground and platforms the player can run and jump on.

🏃 A player character controlled with A (left), D (right), and W (jump).

🌊 Gravity that pulls the player down and tile collision that stops them falling through the floor.

👾 A patrolling enemy that bounces back and forth — touching it resets the level.

⭐ A goal sprite — reaching it shows a win message.

We'll build it step by step. Each section adds one new piece. All code goes in the card script unless stated otherwise.

💡 This lesson uses placeholder coloured rectangles instead of image files so you can get the game working without any art assets. Once it's running, swap in your own images at any point using setSpriteImage.

🗺️ Tilemaps

A tilemap describes a level as a grid of characters. Each character maps to a tile image. The engine renders the tiles and handles collision automatically for any tile you mark as solid.

Set up your tiles and load the map in openCard:

card script — openCard
-- Define what each map character looks like -- (pass empty for a coloured placeholder) defineTile "G", empty -- Ground tile (green placeholder) defineTile "P", empty -- Platform tile -- Mark which tiles block movement defineSolidTile "G", true defineSolidTile "P", true -- Set tile dimensions in pixels setTileSize 64, 64 -- Build the level as a string -- Each character = one tile. Space = empty air. -- Each line = one row, top to bottom. local tMap put "................................................" & return into tMap put "................................................" & return after tMap put "......PPPP......................................P" & return after tMap put "................................................" & return after tMap put "...............PPPP............................." & return after tMap put "................................................" & return after tMap put "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG" after tMap loadTilemap tMap

Dots (.) and spaces both represent empty air. Any other character you define becomes a tile. The map above creates a ground row at the bottom and two floating platforms — a classic platformer layout.

🟢 When using a tile sheet image, call defineTileSheet "tileset.png", 16, 16 first, then reference tiles by column and row: defineTile "G", 0, 0. This is more efficient than one image per tile and is how the sample game works.

🌅 Scrolling Backgrounds

A background layer tiles and scrolls behind all sprites. Link its scroll velocity to the camera so it moves as the player explores the level — creating a parallax effect:

-- Set a background image (stretched to fill the canvas) setSpriteBackgroundImage "sky.png" setSpriteBackgroundStretch true -- Or scroll it for a parallax effect setSpriteBackgroundImage "hills.png" setSpriteBackgroundVelocity -1, 0 -- scroll left slowly -- Multi-layer parallax addSpriteBackground "sky", "sky.png", -0.5, 0 addSpriteBackground "hills", "hills.png", -1.5, 0 addSpriteBackground "trees", "trees.png", -3.0, 0

🏃 The Player

Create the player sprite, set up the camera to follow it, and track the movement keys. The physics variables that control jumping and falling live as script-local variables at the top of the card script:

card script — top of script
local gPlayerVY -- vertical velocity (we manage this ourselves) local gOnGround -- true when player is standing on a solid tile local gKeyRight, gKeyLeft, gJumpPressed
card script — openCard (after loadTilemap)
-- Create the player and set a tight hitbox createSprite "player", empty, 100, 320, 40, 56 setSpriteHitbox "player", 5, 4, 30, 52 -- Lock the camera to follow the player setCameraFollow "player" -- Initialise physics state put 0 into gPlayerVY put false into gOnGround put false into gKeyRight put false into gKeyLeft put false into gJumpPressed resumeSprites
card script — key handlers
on rawKeyDown pKey if pKey is 100 then put true into gKeyRight -- D if pKey is 97 then put true into gKeyLeft -- A if pKey is 119 then put true into gJumpPressed -- W end rawKeyDown on rawKeyUp pKey if pKey is 100 then put false into gKeyRight if pKey is 97 then put false into gKeyLeft end rawKeyUp

⚡ Physics and Jumping

The physics loop runs inside spriteTick. Each tick it applies gravity, checks whether the player is on the ground, handles jumping, and applies horizontal movement. The key insight is that we manage vertical velocity ourselves — gPlayerVY accumulates gravity each tick, and we set it into the sprite at the end:

card script — spriteTick
on spriteTick local kTileSize, kGravity, kMaxFall, kSpeed, kJumpPower put 64 into kTileSize put 0.5 into kGravity put 10 into kMaxFall put 3 into kSpeed put -9 into kJumpPower -- Jump if W was pressed and player is on the ground if gJumpPressed and gOnGround then put kJumpPower into gPlayerVY end if put false into gJumpPressed -- Ground check and gravity if spriteOnGround("player") then snapSpriteToGround "player", kTileSize put 0 into gPlayerVY put true into gOnGround else add kGravity to gPlayerVY if gPlayerVY > kMaxFall then put kMaxFall into gPlayerVY put false into gOnGround end if -- Ceiling check: cancel upward velocity if head hits a block if spriteHitsCeiling("player") and gPlayerVY < 0 then put 0 into gPlayerVY end if -- Horizontal movement local tVX put 0 into tVX if gKeyRight then put kSpeed into tVX if gKeyLeft then put -kSpeed into tVX -- Apply combined velocity setSpriteVelocity "player", tVX, gPlayerVY end spriteTick

spriteOnGround probes three points along the bottom of the sprite's hitbox. If any of them touch a solid tile, it returns true. snapSpriteToGround then nudges the sprite up so it sits flush on the tile surface rather than slightly overlapping it.

⚠️ Cap your fall speed (kMaxFall) to less than the tile height. If a sprite is moving faster than one tile height per tick it can "tunnel" straight through a platform without the ground check firing.

👾 Adding an Enemy

A patrolling enemy is straightforward — create it, give it a horizontal velocity, and in spriteTick reverse that velocity when it hits a wall. If it collides with the player, restart the level:

card script — openCard (after player setup)
createSprite "enemy", empty, 500, 320, 40, 40 setSpriteVelocity "enemy", 2, 0
card script — inside spriteTick
-- Bounce the enemy off walls if spriteHitsWall("enemy", "right") then setSpriteVelocity "enemy", -2, 0 end if if spriteHitsWall("enemy", "left") then setSpriteVelocity "enemy", 2, 0 end if -- Player touches enemy = restart if spritesCollide("player", "enemy") then pauseSprites answer "Oh no! Try again." send "openCard" to this card end if

⭐ Win Condition

Place a goal sprite at the end of the level. When the player reaches it, pause the engine and show a win message:

card script — openCard
createSprite "goal", empty, 2800, 320, 40, 40
card script — inside spriteTick
-- Win condition if spritesCollide("player", "goal") then pauseSprites answer "You win! 🎉" send "openCard" to this card end if -- Fall-off-the-world kill zone if spriteY("player") > 700 then send "openCard" to this card end if
🟢 send "openCard" to this card re-fires the openCard handler, which destroys all sprites, resets all variables, and rebuilds the level from scratch — a simple and clean way to restart the game.

🎮 The Sample Game

Everything in this lesson is a simplified version of the full side-scroller included with HyperXTalk. The sample game adds sprite sheet animation, ladders, multiple enemies with death animations, an attack mechanic, a scrolling parallax background, and a full tileset — but it's built on exactly the same foundations you've just learned.

📦
SideScrollerSample.hyperxtalk is available to download from the HyperXTalk community forums. Open it in HyperXTalk to play the game and read the full commented card script. Every technique in this lesson — and many more — is demonstrated there in production-quality code.

Some highlights from the sample game worth studying:

Sprite sheet animation — the player and enemies use sprite sheets with multiple animation states (idle, run, jump, attack). setSpriteSheet configures the sheet and setSpriteAnimation switches between frame ranges.

from SideScrollerSample
-- Load a 10-column, 17-row sheet (170 total frames, 8 ticks per frame) setSpriteSheet "player", 10, 17, 170, 8 -- Switch animation range based on player state if pAnim is "run" then setSpriteAnimation "player", 90, 97, 4 if pAnim is "jump" then setSpriteAnimation "player", 110, 113, 8 if pAnim is "idle" then setSpriteAnimation "player", 70, 74, 8 if pAnim is "attack" then setSpriteAnimation "player", 140, 145, 8

Enemy groups — enemies are tracked in a comma-separated list and an associative array stores their state ("alive", "dying", "dead"). This pattern scales cleanly to any number of enemies.

Ladders — ladder hitbox sprites are generated automatically from the tilemap using a scan that finds vertical runs of ladder characters, then builds invisible sprites to handle the climb collision.

Horizontal flipping — setSpriteFlipX mirrors the player sprite when they change direction, so you only need one set of animation frames.

📝 Review Questions
Question 1
In the tilemap string, what does a dot (.) or space represent?
Empty air — nothing is drawn and there is no collision. Any other character you define with defineTile renders as a tile, and any tile you mark with defineSolidTile "X", true blocks sprite movement.
Question 2
Why do we track vertical velocity ourselves in gPlayerVY rather than just using setSpriteVelocity with a fixed Y value?
Because gravity must accumulate over time — each tick we add a small gravity value to gPlayerVY, making the player fall faster and faster until they hit the ground. A fixed Y velocity would mean the player falls at constant speed with no sense of weight or acceleration.
Question 3
What does snapSpriteToGround do, and why is it needed?
It nudges the sprite upward so its bottom edge sits exactly flush with the tile surface. Without it, the sprite may be a few pixels inside the tile (depending on how fast it was falling that tick), causing it to visually overlap the ground or drift downward on slopes.
Question 4
How does the enemy patrol back and forth without any complex logic?
By checking spriteHitsWall each tick and reversing the X velocity when it returns true. The enemy starts with a positive velocity (moving right), and whenever it hits a wall the velocity is flipped to negative (moving left), and vice versa.
Question 5
What is the simplest way to restart the game when the player dies or wins?
send "openCard" to this card — this re-fires the openCard handler which destroys all sprites, resets all script-local variables, rebuilds the tilemap, and re-creates all sprites from scratch. It's a clean full reset with a single line of code.