Scenes, Cameras, and Game States
Table of Contents
1. Overview
A scene in Downstroke is the immutable record that answers the question "what should the engine simulate and draw right now?". It bundles the entity list, an optional tilemap (for TMX-backed levels), an optional tileset (for sprite-only games that still want tile-sheet rendering), a camera, a background clear color, and a choice of which physics pipeline to run. Each frame the engine reads the current scene from the game struct, runs an engine-update pass on its entities, lets your update: hook produce a new scene, snaps the camera to a tagged target if asked, and then hands the new scene to the renderer.
Everything about scenes is built around swapping one immutable value for another: constructors like make-scene and make-sprite-scene build them; accessors like scene-entities and scene-camera read them; functional transformers like scene-map-entities and scene-add-entity return new scenes; and game-scene-set! is the single point where the engine sees the new state. If you also want more than one "mode" — a title screen and a play screen, say, or a paused state and a gameplay state — Downstroke layers a lightweight game state system on top, so each state gets its own update and render hooks while sharing the same scene or building its own on demand.
2. The minimum you need
The smallest scene is a sprite-only one with a single entity, built inside the create: hook:
(import scheme
(chicken base)
(only (list-utils alist) plist->alist)
(downstroke engine)
(downstroke world)
(downstroke entity)
(downstroke scene-loader))
(define (make-player)
(plist->alist
(list #:type 'player #:x 100 #:y 100
#:width 32 #:height 32
#:color '(100 160 255))))
(game-run!
(make-game
title: "Minimal Scene"
width: 320 height: 240
create: (lambda (game)
(game-scene-set! game
(make-sprite-scene
entities: (list (make-player))
background: '(20 22 30))))))
That is all that is required to put a blue square on a dark background. make-sprite-scene fills in sensible defaults for everything you have not specified — no tilemap, a camera at (0, 0), no camera target, the default (physics) engine update — and game-scene-set! installs the result on the game. From here on, every section in this document describes a field you can fill in or a function you can compose to reshape what is on screen.
3. Core concepts
3.1. The scene record
scene is a record type defined with defstruct in (downstroke world). Its fields map one-to-one onto the things the engine needs to know about the current frame:
| Field | Type | Purpose |
|---|---|---|
entities |
list of entity alists | Every entity the engine simulates and draws this frame. |
tilemap |
tilemap struct or #f |
Optional TMX-loaded tilemap (layers + embedded tileset). #f for sprite-only scenes. |
tileset |
tileset struct or #f |
Optional tileset used for sprite rect math when tilemap is #f. |
camera |
camera struct |
The viewport offset (see Cameras below). |
tileset-texture |
SDL2 texture or #f |
GPU texture used to draw tiles and any entity with a #:tile-id. #f is allowed (see note). |
camera-target |
symbol or #f |
A tag the engine will auto-follow with the camera. #f disables auto-follow. |
background |
#f or (r g b) / (r g b a) |
Framebuffer clear color. #f means plain black. |
engine-update |
#f, procedure, or 'none |
Per-scene physics pipeline choice (see below). |
The raw make-scene constructor is auto-generated by defstruct and accepts every field as a keyword argument. It does not provide defaults — if you call make-scene directly you must pass every field, or wrap it with make-sprite-scene (below) which does the defaulting for you. The paired functional copier update-scene takes an existing scene and returns a new one with only the named fields replaced:
(update-scene scene entities: (list new-player) camera-target: 'player)
3.1.1. What each "optional" field actually means
tilemap: #f— sprite-only scenes. No tilemap rendering, no tile collisions (the physics pipeline tile-collision steps no-op when there is no tilemap). Used by all the shmup, topdown, tween, scaling, and sandbox demos when they want full control over layout.tileset: #f— fine when the scene has atilemap(the renderer falls back to(tilemap-tileset tilemap)for rect math) or when no entity has a#:tile-id. Set it explicitly only when you have sprites but no tilemap and still want tile-sheet rendering (theanimationandsandboxdemos do this).tileset-texture: #f— allowed. The renderer'sdraw-entityguards on the texture being present and falls back to#:colorrendering when it is missing, so sprite entities are not silently dropped on#f— they draw as solid colored rectangles. Tilemap layers are simply skipped if eithertilemaportileset-textureis#f.background: #f— the renderer clears the framebuffer with opaque black every frame. Any non-#fvalue must be a list of at least three 0–255 integers; a four-element list includes alpha.engine-update:— three forms are accepted:#f(the default) means "inherit": the engine runsdefault-engine-update, which is the built-in physics pipeline (tweens → acceleration → gravity → velocity → tile collisions → ground detection → entity collisions → group sync → animation).- A procedure
(lambda (game dt) ...)replaces the pipeline entirely for this scene. You are fully responsible for whatever transformations of(game-scene game)should happen each frame. - The symbol
'nonedisables engine-update altogether — nothing runs before yourupdate:hook. Used by the shmup and scaling demos which hand-roll all motion.
camera-target:— a symbol that will be looked up viascene-find-taggedin each entity's#:tagslist. When set, the engine centers the camera on that entity on every frame and clamps the result tox ≥ 0andy ≥ 0.#fdisables auto-follow (you can still movescene-camerayourself).
All eight fields are plain slots: read them with scene-entities, scene-tilemap, scene-camera, scene-camera-target, scene-background, scene-engine-update, and so on.
3.2. Constructing a scene
Downstroke offers three escalating ways to build a scene, from full manual control to "load a whole level off disk in one call".
3.2.1. make-scene — full control
The auto-generated constructor accepts every slot as a keyword and does no defaulting. You will use it when you want to build a scene by hand with some exotic combination of fields, most often because you are bypassing the physics pipeline or embedding a procedurally built tilemap:
(make-scene entities: (list (make-player)) tilemap: #f tileset: #f camera: (make-camera x: 0 y: 0) tileset-texture: #f camera-target: #f engine-update: 'none)
This is the form the shmup and scaling demos use to turn off the physics pipeline and drive everything from their own update: hook. If you omit a field it will be unbound on the struct; prefer make-sprite-scene below unless you actually need that degree of control.
3.2.2. make-sprite-scene — convenient sprite-only scenes
make-sprite-scene lives in (downstroke scene-loader) and wraps make-scene with sensible defaults for sprite-only games:
(make-sprite-scene entities: (list (make-player)) background: '(20 22 30))
It always passes tilemap: #f, and supplies these defaults for anything you leave out:
| Keyword | Default |
|---|---|
entities: |
'() |
tileset: |
#f |
tileset-texture: |
#f |
camera: |
(make-camera x: 0 y: 0) |
camera-target: |
#f |
background: |
#f |
engine-update: |
#f (inherit = run physics) |
Use this whenever your game has no TMX map — see demo/getting-started.scm for the canonical example.
3.2.3. game-load-scene! — load a TMX level
When you have a Tiled (TMX) map on disk, game-load-scene! wires up the entire pipeline in one call:
(game-load-scene! game "demo/assets/level-0.tmx")
Internally it performs four steps:
- Calls
game-load-tilemap!which invokesload-tilemapto parse the TMX (via expat) and the linked TSX tileset, also loading the tileset's PNG image. The resultingtilemapstruct is stored in the game's asset registry under the key'tilemap. - Calls
create-tileset-texture(a thin wrapper aroundcreate-texture-from-tileset) to upload the tileset image as an SDL2 texture via the game's renderer. - Builds a
scenewithentities: '(), the parsedtilemap, the fresh tileset texture, a camera at the origin, and no camera target. - Installs the scene on the game with
game-scene-set!and returns it.
Because it returns the new scene, you can chain additional transformations before the frame begins (see the topdown and platformer demos):
(let* ((s0 (game-load-scene! game "demo/assets/level-0.tmx"))
(s1 (scene-add-entity s0 (make-player)))
(s2 (update-scene s1 camera-target: 'player)))
(game-scene-set! game s2))
Note that game-load-scene! does not automatically populate entities from the TMX object layer. That conversion is available as a separate function:
(tilemap-objects->entities tilemap registry)
tilemap-objects->entities walks the parsed tilemap-objects and, for each TMX <object> whose type string matches a prefab in your registry, calls instantiate-prefab with the object's (x, y, width, height). Objects with no matching prefab are filtered out. You can call it yourself to build the initial entity list from the TMX objects and then feed the result to scene-add-entity or a fresh update-scene.
3.2.4. Related asset loaders
Three smaller helpers register assets into (game-assets game) keyed by a symbol, so other code can fetch them with game-asset:
(game-load-tilemap! game key filename)— parse a.tmxand store thetilemapstruct.(game-load-tileset! game key filename)— parse a.tsxand store thetilesetstruct.(game-load-font! game key filename size)— open a TTF font at a given point size and store it.
Each returns the loaded value so you can bind it directly:
(let* ((ts (game-load-tileset! game 'tileset "demo/assets/monochrome_transparent.tsx"))
(tex (create-texture-from-tileset (game-renderer game) ts)))
...)
This pattern — load once in preload: (or early in create:), retrieve many times via game-asset — is how the animation and sandbox demos share one tileset across multiple prefabs and scenes.
3.3. Manipulating scene entities
All scene transformers in (downstroke world) are functional: they take a scene and return a new one. Nothing is mutated; the idiomatic pattern inside an update: hook is always
(game-scene-set! game (<transformer> (game-scene game) ...))
or, when composing multiple transformations, a chain form from srfi-197.
3.3.1. scene-add-entity
(scene-add-entity scene entity) appends one entity to the entity list. Use it after game-load-scene! to drop the player into a freshly loaded TMX level:
(chain (game-load-scene! game "demo/assets/level-0.tmx") (scene-add-entity _ (make-player)) (update-scene _ camera-target: 'player))
3.3.2. scene-map-entities
(scene-map-entities scene proc ...) applies each proc in sequence to every entity in the scene. Each proc has the signature (lambda (scene entity) ...) → entity — it receives the current scene (read-only, snapshot at the start of the call) and one entity, and must return a replacement entity. Multiple procs are applied like successive map passes, in argument order.
This is the workhorse of the physics pipeline; see default-engine-update in (downstroke engine) for how it is chained to apply acceleration, gravity, velocity, and so on. In game code you use it for per-entity updates that do not need to see each other:
(scene-map-entities scene
(lambda (scene_ e)
(if (eq? (entity-type e) 'demo-bot)
(update-demo-bot e dt)
e)))
3.3.3. scene-filter-entities
(scene-filter-entities scene pred) keeps only entities for which pred returns truthy. Use it to remove dead/expired/off-screen entities each frame:
(scene-filter-entities scene (lambda (e) (or (eq? (entity-type e) 'player) (in-bounds? e))))
3.3.4. scene-transform-entities
(scene-transform-entities scene proc) hands the whole entity list to proc and expects a replacement list back. Use this when an operation needs to see all entities at once — resolving pairwise entity collisions, for instance, or synchronising grouped entities to their origin:
(scene-transform-entities scene resolve-entity-collisions) (scene-transform-entities scene sync-groups)
The engine's default pipeline uses scene-transform-entities for exactly these two steps, because they are inherently N-to-N.
3.3.5. Tagged lookups
Two helpers let you find entities by tag without iterating yourself:
(scene-find-tagged scene tag)returns the first entity whose#:tagslist containstag, or#f.(scene-find-all-tagged scene tag)returns every such entity as a list.
Both are O(n) scans and intended for coarse queries — finding the player, all enemies of a type, the camera target — not for every-frame loops over hundreds of entities. The engine itself uses scene-find-tagged internally to resolve camera-target.
3.3.6. The update idiom
Putting it together, the typical shape of an update: hook is either a single transformer:
update: (lambda (game dt)
(let* ((input (game-input game))
(scene (game-scene game))
(player (update-player (car (scene-entities scene)) input)))
(game-scene-set! game
(update-scene scene entities: (list player)))))
or a chain of them, when multiple pipelines compose:
(game-scene-set! game
(chain (update-scene scene entities: all)
(scene-map-entities _
(lambda (scene_ e) (if (eq? (entity-type e) 'player) e (move-projectile e))))
(scene-remove-dead _)
(scene-filter-entities _ in-bounds?)))
In both shapes, game-scene-set! is called exactly once per frame with the final scene. That single write is what the next frame's engine-update and renderer read from.
3.4. Cameras
A camera is a tiny record with two integer slots, x and y, holding the top-left pixel coordinate of the viewport in world space. make-camera is the constructor:
(make-camera x: 0 y: 0)
The renderer subtracts camera-x and camera-y from every sprite and tile's world position before drawing, so moving the camera east visually scrolls everything west.
3.4.1. camera-follow
(camera-follow camera entity viewport-w viewport-h) returns a new camera struct centered on the entity, clamped so neither x nor y goes below zero. It floors both results to integers (SDL2 wants integer rects). You can call it directly from an update: hook — for example to manually follow an entity without tagging it — but the engine provides an auto-follow mechanism built on top of it.
3.4.2. Auto-follow via camera-target:
If a scene has camera-target: set to a symbol, the engine runs update-camera-follow after your update: hook and before rendering. That function looks up the first entity whose #:tags list contains the target symbol via scene-find-tagged. If found, it replaces the scene's camera with one centered on that entity (using the game's width and height as the viewport size); if no tagged entity is found, the camera is left alone. The clamp to x ≥ 0 and y ≥ 0 is inherited from camera-follow, so the camera never reveals negative world coordinates.
To opt in, just tag the entity you want followed and set camera-target: to that tag:
(define (make-player)
(plist->alist
(list #:type 'player
#:x 100 #:y 50
#:tags '(player)
...)))
(update-scene scene camera-target: 'player)
Subsequent frames will keep the camera locked to the player. To stop following, set camera-target: back to #f (you keep whatever camera position was most recent).
If you need to animate the camera yourself (shake, parallax, cutscenes), leave camera-target: at #f and edit scene-camera via update-scene from inside your own update. The engine will not override what you write so long as no target is set.
3.5. Named game states
Many games have more than one "mode": a title menu, a playing state, a game-over screen, maybe a pause overlay. Downstroke's game-state system is a lightweight way to switch update and render hooks between modes without having to build a single mega-update.
A game state is just an alist of lifecycle hooks, built with make-game-state:
(make-game-state #:create main-menu-create
#:update main-menu-update
#:render main-menu-render)
Each of #:create, #:update, and #:render is optional (#f by default). The engine uses them as follows:
#:createis called bygame-start-state!when the state becomes active. Use it to lazily build the scene for that state (e.g. build the level the first time the player picks "Play").#:update, if present, replaces the game's top-levelupdate-hookwhile this state is active. The engine'sresolve-hooksprefers the state hook and falls back to the game-level hook when the state has no#:update.#:renderis handled the same way: state-level wins when present, otherwise the game-levelrender-hookruns.
Registration is done by name with game-add-state!:
(game-add-state! game 'main-menu
(make-game-state #:update main-menu-update
#:render main-menu-render))
(game-add-state! game 'playing
(make-game-state #:update playing-update
#:render playing-render))
And transitions happen through game-start-state!, which sets the active state and runs the new state's #:create hook if present:
(game-start-state! game 'main-menu) ... (when (input-pressed? input 'a) (game-start-state! game 'playing))
Only update and render are state-scoped; preload and create at the make-game level still fire once each at boot. The engine's built-in engine-update (the physics pipeline) still runs whenever a scene exists, regardless of which state is active — states only swap which user hook drives the frame.
4. Common patterns
4.1. Sprite-only scene with no tilemap
The default choice for small demos and menus: one make-sprite-scene call, no tilemap, a plain background color.
Getting started demo — run with bin/demo-getting-started, source in demo/getting-started.scm. A blue square you push around the screen with the arrow keys; canonical minimal sprite scene.
Tweens demo — run with bin/demo-tweens, source in demo/tweens.scm. A grid of easing curves applied to moving boxes; also sprite-only.
4.2. TMX-loaded scene with a player prefab
Load the level off disk, append the player, set up camera follow. One chain per create hook.
Platformer demo — run with bin/demo-platformer, source in demo/platformer.scm. Uses game-load-scene! for the TMX map and then scene-add-entity and update-scene to attach the player and enable camera follow.
Topdown demo — run with bin/demo-topdown, source in demo/topdown.scm. Same pattern, with #:gravity? #f on the player so the default engine-update gives four-direction motion.
4.3. Camera following a tagged entity
Tag the entity you want the viewport to track, set camera-target: to the tag, let the engine take care of the rest:
(define (make-player)
(plist->alist
(list #:type 'player
#:x 100 #:y 50
#:width 16 #:height 16
#:tags '(player)
...)))
;; In create:
(chain (game-load-scene! game "demo/assets/level-0.tmx")
(scene-add-entity _ (make-player))
(update-scene _ camera-target: 'player)
(game-scene-set! game _))
Any entity carrying the right tag will be followed, and the camera clamps to non-negative world coordinates every frame. The platformer and topdown demos both use camera-target: 'player exactly this way.
4.4. Switching between a title screen and a play state
Register two named states in create:, each with its own update and render hooks, and kick off the first one. Transitions happen anywhere you have a game handle, including from inside another state's update.
Menu demo — run with bin/demo-menu, source in demo/menu.scm. Registers main-menu and playing states, switches to playing when the player selects the menu entry, and switches back on Escape.
create: (lambda (game)
(game-add-state! game 'main-menu
(make-game-state #:update main-menu-update
#:render main-menu-render))
(game-add-state! game 'playing
(make-game-state #:update playing-update
#:render playing-render))
(game-start-state! game 'main-menu))
Each state owns its render surface — in the menu demo both states clear the screen themselves, since there is no scene installed on the game — but nothing stops a state from sharing a scene: the menu state's #:create could build it, and the play state's #:update could simply read and transform (game-scene game).
4.5. Loading multiple assets up front
The preload: hook is the one place guaranteed to run before the first scene is built, after SDL2 has opened a renderer. Use it to warm up the asset registry so create: and update: never block on I/O:
preload: (lambda (game)
(game-load-tileset! game 'tiles "demo/assets/monochrome_transparent.tsx")
(game-load-font! game 'title "demo/assets/DejaVuSans.ttf" 24)
(game-load-font! game 'body "demo/assets/DejaVuSans.ttf" 14)
(init-audio!)
(load-sounds! '((jump . "demo/assets/jump.wav")
(coin . "demo/assets/coin.wav"))))
Inside create: or update: you read them back with game-asset:
(let ((ts (game-asset game 'tiles))
(fnt (game-asset game 'title)))
...)
Because the registry is a plain hash table, you can store anything — parsed JSON, precomputed tables, your own structs — under any symbol key you like. The three game-load-*! helpers are there for the file formats the engine itself understands; everything else is game-asset-set! + game-asset.
5. See also
- guide.org — step-by-step construction of the getting-started demo, including the first
make-sprite-scenecall. - entities.org — the entity alist, conventional keys like
#:tagsand#:type, and how prefabs build entities that the scene-loader instantiates. - physics.org — what the default engine-update actually does, and how to skip or replace individual pipeline steps per entity.
- rendering.org — how
render-scene!walks the scene to draw tiles, sprites, and fallback colored rects. - animation.org — the animation pipeline step, run by
default-engine-updateon every entity. - tweens.org — per-entity declarative tweens, stepped as the first stage of the default pipeline.
- input.org — action mapping and polling, used inside
update:hooks to drive entity changes. - audio.org — loading and playing sound effects alongside your scene-loading hooks.