| # | player | phone | best | ledges | runs | last run |
|---|
Stickyfoot is built from three separable parts: an editor that writes segment files, a runtime that links those segments into an endless chain, and a local player store. Here is how each one works, and what the files look like.
The world is a singly linked list of LevelNode objects. Every node is exactly 1024 × 576 world pixels — the same frame the editor draws in, sized for a small tablet — so nodes butt together with no seams and no scaling maths.
head → [node 12] → [node 13] → [node 14] → tail
x=12288 x=13312 x=14336
Each frame the chain does two things:
tail.x + 1024 < camX + viewWidth + 1024, a new node is appended: the next segment is chosen from the pool, its art is baked once into an offscreen canvas, and its collision boxes are translated into world space.head.x + 1024 < camX, the head node has fully left the screen. It is unlinked, its baked canvas is sized to 0 × 0 to release the bitmap, its box array is emptied, and its next/prev pointers are nulled so the garbage collector can take the whole object.Only three or four nodes exist at any moment, no matter how long the run lasts. Open the node monitor on the play screen (F3) to watch spawned, live, and destroyed counts plus the live bitmap budget.
The editor exports a pair of files that share an id and a frame size. Collision never mentions art, art never mentions collision; the runtime joins them by id.
{
"format": "stickyfoot.collision",
"version": 1,
"id": "seg_meadow_01",
"width": 1024,
"height": 576,
"entry": { "x": 96, "y": 380 },
"boxes": [
{ "x": 0, "y": 448, "w": 256, "h": 128, "type": "platform" },
{ "x": 448, "y": 320, "w": 192, "h": 64, "type": "platform" },
{ "x": 704, "y": 256, "w": 64, "h": 192, "type": "cling" },
{ "x": 448, "y": 288, "w": 192, "h": 32, "type": "hazard" }
]
}
{
"format": "stickyfoot.art",
"version": 1,
"id": "seg_meadow_01",
"width": 1024,
"height": 576,
"layers": [
{ "name": "background", "z": 0, "pieces": [ { "sprite": "fern", "x": 320, "y": 384, "w": 64, "h": 64 } ] },
{ "name": "midground", "z": 1, "pieces": [ { "sprite": "dirt", "x": 0, "y": 448, "w": 256, "h": 128 } ] },
{ "name": "foreground", "z": 2, "pieces": [] }
]
}
entry is where the gecko stands when a segment is the first node of a run, and the camera centres on it, so put it over a ledge. The editor draws a ghost gecko there. If a run starts on a segment whose entry is not above anything solid, the runtime falls back to the leftmost ledge.
Both files are validated on load: mismatched width/height, a missing id, or an unknown box type is rejected rather than silently drawn wrong. A segment with art but no collision is playable but empty; a segment with collision but no art renders as untextured hitboxes, which is useful while blocking out a layout.
platform — solid. Landed on from above, blocks from the sides and below.cling — a gecko surface. Stick to any face of it, including walls and ceilings, then launch again.hazard — touching it ends the run.bounce — springy. Reverses vertical speed at 80% and keeps you airborne.The palette takes imported PNG, GIF, WebP or JPEG files. One thing decides whether a piece looks right: its size in pixels. Every art pixel is drawn as a 4 × 4 block, and a piece repeats across whatever rectangle you drag out, so:
Every built-in sprite is a real PNG in assets/sprites/, listed in js/art/manifest.js. Nothing is drawn in code. To add one permanently:
1. save your PNG as assets/sprites/mossy_brick.png
2. add a line to js/art/manifest.js:
{ id:"mossy_brick", file:"mossy_brick.png", label:"mossy" },
3. reload. It is in the palette.
4. python3 build.py # only for the single-file build
The id is what .art.json files refer to, so renaming one breaks segments that already use it. label is the palette caption — leave it out and the sprite loads but stays out of the palette, which is what the gecko frames do. A cap field names another sprite to draw on the first row when the piece is tiled downward; that is how soil grows grass and stone grows moss:
{ id:"dirt", file:"dirt.png", label:"soil", cap:"dirt_top" },
{ id:"dirt_top", file:"dirt_top.png" },
Editing an existing piece is just editing its PNG — open assets/sprites/fern.png in any pixel editor, keep the canvas size, save over it.
The import art button is the other route, for one-offs and for people who cannot edit the files: it keeps the image in this browser and embeds a copy in anything you export. The starter file button hands you a correctly sized PNG to paint over: a 16 × 16 shaded block that repeats cleanly, a 32 × 32 version of the same, or a transparent 32 × 32 guide with corner ticks and a centre cross for lining up a prop. Paint over it, keep the canvas size, save, and import.
Imported pieces live in this browser under stickyfoot.art.v1 and appear in the palette with a dashed border. Exported art files carry a copy of every custom piece they use, in a sprites block keyed by sprite name:
{
"format": "stickyfoot.art",
"id": "seg_meadow_01",
"sprites": {
"art_mossy_brick": { "label": "mossy", "w": 16, "h": 16, "src": "data:image/png;base64,…" }
},
"layers": [
{ "name": "midground", "z": 1,
"pieces": [ { "sprite": "art_mossy_brick", "x": 0, "y": 448, "w": 256, "h": 128 } ] }
]
}
So a segment stays portable: importing it on another machine also installs the art it needs. Saved maps skip the copy, since the library already has the pixels. If a piece ever goes missing, the game draws a magenta box where it should be rather than quietly leaving a hole.
The editor edits a map: an ordered list of slides, where one slide is one node. The strip under the canvas is that list, left to right, in the order the runtime will link them. Click a slide to edit it; the + between two slides inserts there, either empty or as a copy of the slide you are on.
Saving a map keeps it in this browser under stickyfoot.maps.v1 and makes it the active map, so the world switch on the play screen can run it. “Play from this slide” pins the slides from the current one onward to the head of the chain and lets the endless generator take over after the last one. Loading the example gives you a copy of the opening six nodes of the built-in world, editable without touching the original.
A whole map exports as one file, which is just the pairs collected in order:
{
"format": "stickyfoot.map",
"version": 1,
"id": "map_fern_hollow",
"name": "Fern Hollow",
"width": 1024, "height": 576,
"nodes": [
{ "collision": { … }, "art": { … } },
{ "collision": { … }, "art": { … } }
]
}
Import takes either a .map.json (replaces the whole map) or one .collision.json + .art.json pair (replaces the current slide), so the per-segment files stay the unit you can hand to someone else.
Every placeable art piece carries a category as well as the sprite it was drawn with:
{ "sprite": "dirt", "role": "block_base", "x": 0, "y": 448, "w": 256, "h": 128 }
A biome is a table from category to sprite. When a slide names one, every piece with a role draws through that table instead of its own sprite — so one dropdown reskins an entire level, and swapping back is lossless because the role never changes.
{ "id":"snow", "name":"Hoarfrost", "assets": {
"block_base":"snow_base", "block_top":"snow_top", "stone_wall":"ice_wall",
"plant_fern":"frost_fern", "tree_trunk":"frost_trunk", ... } }
The sixteen categories are block_base, block_top, plant_shrub, plant_fern, tree_trunk, tree_leaves, food_1, food_2, sign, moss, thorn, spike, stone_wall, wood_plank, wood_door and cloud. A sprite declares which one it fills in js/art/manifest.js; the editor palette prints the category under each piece, and a piece with no category is skipped by swaps and says so on hover.
Two biomes ship: Fern Grove and Hoarfrost. The snow art is a recolour of the grove set so you can see the swap working — replace those PNGs with real art when you want it to look intentional. New biome… in the editor opens a form with a row per category, prefilled from whichever biome you choose to start from, and refuses to save until every category has a sprite. Your biomes are kept in this browser and appear in the same dropdown as the built-in ones, which are read-only.
Apply to whole map pushes the current biome across every slide. Generated nodes pick their biome from settings.json — biomes.order and biomes.nodesPerBiome — so an endless run drifts from grove to snow and back as you travel.
Nothing about collision, tokens or enemies changes with the biome. Re-skinning a level never changes how it plays.
Patrollers live in the collision file beside the tokens:
"enemies": [ { "x": 320, "y": 384, "w": 64, "h": 64, "kind": "spider" } ]
They are deliberately simple. On first sight an enemy looks for the platform it is standing on, snaps to its surface, and then walks from one edge of that ledge to the other, turning around at each end, forever. It never chases, never jumps, never notices you — it is an obstacle with a schedule. Touching one ends the run, with a little forgiveness: the killing box is inset from the sprite by eight pixels.
Speeds and kinds come from settings.json. Adding a kind takes a line under enemies.kinds, a PNG at assets/sprites/enemy_<kind>.png and a line in the manifest. Place them from the editor's enemies layer; while that layer is active the editor draws a red line along the ledge each one will patrol, and warns you when an enemy has no ledge under it.
Tokens are the collectible half of the score. They live in the collision file, not the art file, because they are gameplay rather than decoration:
"tokens": [
{ "x": 320, "y": 256, "w": 64, "h": 64, "kind": "fly" },
{ "x": 448, "y": 192, "w": 64, "h": 64, "kind": "grub" }
]
Each kind is worth whatever settings.json says it is worth:
"food": {
"weights": { "fly": 10, "moth": 25, "cricket": 50, "grub": 100 },
"defaultWeight": 10,
"spawnChance": 0.6,
"clusterMax": 3
}
Change a number there and it applies immediately — the game reads the weight at the moment a token is eaten. Adding a kind takes three steps: a line in weights, a PNG at assets/sprites/food_<kind>.png, and a line in js/art/manifest.js. It then appears in the editor's food tokens layer, which works like any other layer — click to place, drag to move, delete to remove.
Tokens are per node, not per segment, so a segment that comes round again later in the run is freshly stocked. Eating one adds its weight to the score, pops the number above the gecko, and marks it taken for the life of that node. The procedural generator scatters them along the arc between two ledges, so the points sit on the path you were going to fly anyway, and the rarer, heavier kinds come up less often.
Horizontally the camera behaves as it always has: the gecko sits centred until the first pull, then at camera.leadX from the left edge, the view never rewinds, and it creeps right a little faster with every ledge.
Vertically it follows the gecko, with a deadzone so small hops do not slosh the screen around. The gecko is kept at camera.height pixels down the frame; while it stays within camera.deadzone of that line nothing moves, and past it the camera eases after it, clamped between camera.minY and camera.maxY so you never drift far above or below the node. Set camera.followVertical to false for the old fixed-height behaviour.
At rest, a drag vector d is measured from where the pointer went down to where it is now. Launch velocity is the reverse of that drag, clamped and scaled:
power = min(|d|, 190) / 190 // 0 … 1
v = normalize(-d) × power × 23 // world px per tick
each tick: vy += 0.62 ; x += vx ; y += vy ; vx *= 0.9994
The simulation runs on a fixed 60 Hz accumulator so the arc is identical on any refresh rate. The dotted preview is the same integrator run forward, and it gets shorter as your score climbs — early ledges show you the whole arc, later ones make you read it.
Run game in the header launches the cabinet: fullscreen, no page chrome, no navigation. It is meant to be left running at an event, so it does not let go on its own — the browser back button is caught and pushed back, closing the tab asks for confirmation, and the only way out is the ⏻ button, which asks before it shuts down. Fullscreen is the one thing a browser will always take back: if someone presses Escape the cabinet keeps running filled to the window and the ⛶ button restores it.
The attract screen shows the top eight names and scores and offers one action: play. A cabinet run is anonymous while it is being played — there is no player to pick. When the run ends, the score is held aside and the player is asked for a name, an email and a phone number. All three are trimmed of surrounding and repeated whitespace, then checked: at least two characters of name, an address of the form name@host.tld, and 7 to 15 digits of phone. If any of them is missing or malformed the score is discarded — nothing is written. The same happens if the player presses discard, or walks away and the ninety-second timer runs out.
A score that passes is matched against the existing players by email or by phone digits, ignoring case and formatting, so a regular keeps one row on the board rather than collecting duplicates. Their best score is the highest they have ever posted, not the most recent.
The version you are looking at may be the built single file, but it is generated. The source is a plain folder of CSS and JavaScript — no bundler, no framework, no build step needed to work on it:
index.html markup, and the load order for everything below
css/ tokens · shell · game · ui · editor
settings.json every tunable number, in one place
js/core/ settings (generated) · constants · util · storage
js/art/ manifest (the sprite list) · sprites · biomes · custom (imports)
js/level/ format (the two files) · generate (procedural) · node (the chain)
js/game/ game (physics) · render (drawing) · input (slingshot)
js/editor/editor · filmstrip · biome-ui · io (export and import)
js/ui/ players · view (fullscreen) · cabinet · run
js/main.js router and boot
assets/sprites/*.png every sprite, as actual files
build.py inlines all of the above into dist/stickyfoot.html
Open index.html and edit any file; a reload picks it up. These are plain scripts rather than modules, so each file may use anything defined in a file above it in the list, and main.js runs last. When you want the portable one-file version again, run python3 build.py.
Every tunable number lives in one file at the root — food weights, camera height and deadzone, gravity, launch speed, drag limit, the gecko's collision box, scoring, the difficulty ramp, the cabinet's entry timer. build.py turns it into js/core/settings.js, and js/core/constants.js holds live references into it, so code reads PHYS.gravity and FOOD.weights rather than copying values into constants.
Served over http(s) the game also re-reads settings.json at boot, so editing it and reloading is enough while you work — no rebuild. From file://, and in the built single page, the baked-in copy is used instead, which is the same data. The one exception is anything under world: node size, tile size and the art pixel scale are part of the segment file format, so those need python3 build.py and invalidate segments authored at the old size.
Players, high scores and saved segments are kept in this browser's local storage under the stickyfoot.* keys. Nothing is sent anywhere, and clearing the browser's site data erases it. Exported segment files are plain JSON you can keep, edit by hand, or hand to someone else to import.