← WorkshopBuild your
lantern expedition.
50 lessons across ten chapters. Each lesson: predict, code, run, check, explain and extend. Basic JavaScript variables, functions and arrays are prerequisites. The supplied engine supports focused practice; the final projects combine its rules into designed levels.
1. A canvas is a drawing surface
A canvas displays pixels drawn by JavaScript. Game entities are not separate HTML elements: the engine stores their state and redraws their appearance. This workshop supplies a small, readable engine so each lesson can focus on one rule. You can export the complete engine with your work. Begin by giving the maze its own title and player color.
Build: Set title to Lantern Quest and player color to #ffcc66.
Worked example
game.title='Lantern Quest';game.theme.player='#ffcc66';
Key idea: Drawing reads game state; changing pixels does not change the rules.
Extend: Change the floor and wall colors while keeping the player readable.
2. Draw an overlay rectangle
Canvas methods draw immediately using the context’s current state. drawExtra runs after the maze, making it useful for an overlay or annotation. fillRect receives x, y, width and height in logical pixels. Save and restore styles so the extra drawing does not accidentally change the renderer’s next operation.
Build: Draw a #ffcc66 rectangle at 10,10 with size 60×8 in game.drawExtra.
Worked example
game.drawExtra=ctx=>{ctx.save();ctx.fillStyle='#ffcc66';ctx.fillRect(10,10,60,8);ctx.restore();};Key idea: Drawing commands use current context settings.
Extend: Add a second marker without changing the first.
3. Draw a circular marker
Paths let canvas draw shapes beyond rectangles. beginPath starts a fresh path, arc adds a circular segment and fill paints it. Without beginPath, a previous path can become part of your shape. A full circle uses a start angle of zero and end angle of two times PI.
Build: Draw a circle centered at 100,60 with radius 12 using drawExtra.
Worked example
game.drawExtra=ctx=>{ctx.beginPath();ctx.arc(100,60,12,0,Math.PI*2);ctx.fillStyle='#90dfbe';ctx.fill();};Key idea: Start a new path before defining a separate shape.
Extend: Use stroke instead of fill and compare the outline.
4. Label a drawing
Canvas text is useful for diagrams and labels, but essential status should also be available as ordinary page text. The engine already provides a text HUD. This lesson adds a decorative label using fillText and an explicit font. Text alignment belongs to drawing state and should be restored when finished.
Build: Draw the text MAP at 20,30 in a 16px sans-serif font.
Worked example
game.drawExtra=ctx=>{ctx.save();ctx.font='16px sans-serif';ctx.textAlign='left';ctx.fillStyle='#fff';ctx.fillText('MAP',20,30);ctx.restore();};Key idea: Essential information should not depend on canvas pixels alone.
Extend: Add the same meaning to the visible HUD.
5. Separate logical size from display size
The maze uses a logical tile size, while CSS scales the canvas to fit the page. Coordinates and collisions remain in tiles. Changing tileSize changes drawing resolution, not the number of rows or columns. Real production canvases may also account for devicePixelRatio for sharper rendering; that is separate from game rules.
Build: Set tileSize=48 and enable showGrid and showCoordinates.
Worked example
game.tileSize=48;game.showGrid=true;game.showCoordinates=true;
Key idea: Display scaling and logical game coordinates are separate concerns.
Extend: Compare tile labels at 32 and 48 pixels.
6. Convert a tile to pixels
A grid coordinate becomes a pixel position by multiplying by tile size. The tile’s top-left and center are different locations. This helper returns the top-left position. Keeping the conversion in one function avoids scattered hard-coded numbers when the tile size changes.
Build: Implement game.tileToPixel(x,y) returning {x:x*tileSize,y:y*tileSize}.
Worked example
game.tileToPixel=(x,y)=>({x:x*game.tileSize,y:y*game.tileSize});game.showCoordinates=true;Key idea: Multiply grid coordinates by the current tile size.
Extend: Write a center conversion using x+.5 and y+.5.
7. Convert pointer pixels to a tile
Floor division maps a logical pixel position to the containing tile. CSS scaling must be undone before this conversion; the visual editor handles that for its own controls. A point on the next tile’s left edge belongs to that next tile. This exercise accepts logical coordinates only.
Build: Implement game.pixelToTile(x,y) with Math.floor for each coordinate.
Worked example
game.pixelToTile=(x,y)=>({x:Math.floor(x/game.tileSize),y:Math.floor(y/game.tileSize)});Key idea: Use floor division to find the tile containing a logical point.
Extend: Explain why Math.round selects the wrong tile near its right edge.
8. Read tiles safely
A map is an array of strings, one per row. Reading rows[y][x] without guards can throw outside the map. Treating out-of-bounds as a wall is useful for movement rules. This does not replace validating the map itself. Explicit boundaries make errors easier to diagnose.
Build: Implement game.readTile(x,y) returning the symbol or # outside the map.
Worked example
game.readTile=(x,y)=>game.rows[y]?.[x]??'#';
Key idea: Boundary behavior should be explicit and consistent.
Extend: Try negative coordinates and an excessively large column.
9. Write a tile without changing the row size
Strings are immutable. To change one character, combine the prefix, new character and suffix, then assign the new row. Validate coordinates first so accidental writes do not create malformed levels. The engine already has setTile; this exercise writes an equivalent named helper.
Build: Implement game.paint(x,y,t) replacing one in-bounds character and ignoring out-of-bounds writes.
Worked example
game.paint=(x,y,t)=>{if(y<0||y>=10||x<0||x>=12)return;game.rows[y]=game.rows[y].slice(0,x)+t+game.rows[y].slice(x+1);};Key idea: Replace one character while preserving the map’s dimensions.
Extend: Restrict t to one allowed symbol.
10. Name positions consistently
A position key such as x,y can identify a visited cell in a Set. Use a separator to avoid collisions between coordinates such as 1,23 and 12,3. A stable representation helps pathfinding, undo and debugging. It should not depend on screen scale.
Build: Implement game.cellKey(x,y) returning x,y as a comma-separated string.
Worked example
game.cellKey=(x,y)=>x+','+y;game.message=game.cellKey(3,4);
Key idea: A stable coordinate key must distinguish different pairs.
Extend: Use keys to count unique cells visited.
11. Allow cardinal moves only
This is a turn-based maze. Each accepted action moves one cell and advances patrols once. A valid movement vector has Manhattan length one: exactly one axis changes by one. Reject diagonal, zero and long jumps so callers cannot bypass collision rules.
Build: Implement game.validStep(dx,dy): integers only and abs(dx)+abs(dy)===1.
Worked example
game.validStep=(dx,dy)=>Number.isInteger(dx)&&Number.isInteger(dy)&&Math.abs(dx)+Math.abs(dy)===1;
Key idea: Validate the action before applying its effects.
Extend: List all four accepted vectors.
12. Walls block movement
A wall rule should answer whether a destination is blocked without moving the player. Keeping this test separate from movement makes it reusable for patrols and map analysis. This helper only considers walls; doors and gates will add separate conditions later.
Build: Implement game.isWall(x,y) returning whether game.tile(x,y) is #.
Worked example
game.isWall=(x,y)=>game.tile(x,y)==='#';
Key idea: Collision queries should not mutate position.
Extend: Explain why a locked door is not the same map symbol as a wall.
13. Check before you commit
A movement attempt first computes the proposed destination, then checks it. Changing the player before checking can cause invalid positions or require complicated rollback. This lesson’s tryStep ignores dynamic objects to focus on wall collisions. It increments moves only after a successful action.
Build: Implement game.tryStep(dx,dy) for cardinal movement through non-wall cells; return success and increment moves only on success.
Worked example
game.tryStep=(dx,dy)=>{if(!Number.isInteger(dx)||!Number.isInteger(dy)||Math.abs(dx)+Math.abs(dy)!==1)return false;const x=game.player.x+dx,y=game.player.y+dy;if(game.tile(x,y)==='#')return false;game.player={x,y};game.moves++;return true;};Key idea: Compute, validate, then commit a move.
Extend: Connect successful movement to the onEnter hook.
14. Track unique exploration
Moves and unique visited cells measure different things. Walking back and forth increases moves but should not repeatedly increase unique exploration. A Set naturally stores each coordinate key once. This creates a useful learning overlay or exploration score without a separate duplicate check.
Build: Implement game.visit(x,y) adding a coordinate key to game.visited.
Worked example
game.visit=(x,y)=>game.visited.add(x+','+y);
Key idea: A Set counts unique positions rather than repeated visits.
Extend: Compare move count with visited.size after retracing a route.
15. Make blocked actions informative
Feedback should explain a failed action without charging a turn. The original move method already rejects walls. Wrap it to add a useful message when it returns false, while preserving its behavior and this context. Wrapping requires keeping the old function reference before assigning the replacement.
Build: Wrap game.move: preserve its result, and set message to Blocked: choose another path when it returns false.
Worked example
const originalMove=game.move;game.move=(dx,dy)=>{const ok=originalMove(dx,dy);if(!ok)game.message='Blocked: choose another path';return ok;};Key idea: Failed actions should explain the problem without silently consuming progress.
Extend: Use different feedback for a locked door and an ordinary wall.
16. Collect a key exactly once
A key is both a map item and inventory. After collecting it, replace the map tile with floor and increment keys. Repeating the pickup on the same cell should not grant another key. This rule is a small transaction: inventory and world state must change together.
Build: Implement game.pickKey(x,y): if tile is K, increment keys and replace it with floor.
Worked example
game.pickKey=(x,y)=>{if(game.tile(x,y)==='K'){game.keys++;game.setTile(x,y,'.');}};Key idea: Inventory and the collected item must update together.
Extend: Add a score award that also happens only once.
17. A locked door needs inventory
A door’s accessibility depends on game state. A query should return true when the player has at least one key and leave inventory unchanged. Consuming the key belongs to the unlock operation, not the query, because path previews may call the query many times.
Build: Implement game.canUnlock() returning keys>0 without changing keys.
Worked example
game.canUnlock=()=>game.keys>0;
Key idea: Queries should not consume resources.
Extend: Explain why a path preview must not use up keys.
18. Unlock atomically
Unlocking requires both a door tile and a key. Consume exactly one key and replace the door with floor. Repeating the action should do nothing. Return true only when a door actually changed, which allows the caller to provide appropriate feedback.
Build: Implement game.unlock(x,y): consume one key and clear D only when keys>0; return Boolean success.
Worked example
game.unlock=(x,y)=>{if(game.tile(x,y)!=='D'||game.keys<=0)return false;game.keys--;game.setTile(x,y,'.');return true;};Key idea: Validate every prerequisite before consuming a resource.
Extend: Add different door types with separate inventory counts.
19. Create a clear exit rule
An exit is an outcome, not simply another colored tile. Check the player’s current position and transition to won with a meaningful message. Keep this separate from rendering so tests can verify it directly. In the supplied game, entering E already wins; this helper lets you practice the same rule.
Build: Implement game.checkExit(): set mode=won and message=Exit reached when the player stands on E.
Worked example
game.checkExit=()=>{if(game.tile(game.player.x,game.player.y)==='E'){game.mode='won';game.message='Exit reached';}};Key idea: Outcome transitions should depend on explicit world conditions.
Extend: Require all keys to be collected before the exit opens.
20. Inventory needs a readable HUD
Inventory stored in memory is invisible until the interface explains it. Derive text from current state instead of maintaining duplicate counters. Labels and numbers communicate information independently of color. The engine places HUD text outside the canvas so it is also available as ordinary page content.
Build: Implement hud() returning Keys: N | Moves: M.
Worked example
game.hud=()=>`Keys: ${game.keys} | Moves: ${game.moves}`;Key idea: Derive interface values from the authoritative state.
Extend: Add hearts while keeping the labels concise.
21. Toggle a switch
A switch stores a Boolean state. Toggling reverses it once per activation. In this maze, entering a T cell is an activation; a frame-based trigger would flicker repeatedly while standing still. This turn-based design makes cause and effect easy to inspect.
Build: Implement game.toggleSwitch() that reverses switchOn and returns its new value.
Worked example
game.toggleSwitch=()=>{game.switchOn=!game.switchOn;return game.switchOn;};Key idea: Trigger a switch on an action, not on every rendered frame.
Extend: Change the switch into a one-way activation and explain the design difference.
22. Connect a gate to a switch
A gate is blocked when its controlling switch is off. Ordinary floor remains passable. Keep this logic distinct from drawing the gate’s appearance. Otherwise a green gate may still block the player or a closed-looking gate may allow passage.
Build: Implement game.gateOpen(tile): true for non-A tiles, otherwise use switchOn.
Worked example
game.gateOpen=t=>t!=='A'||game.switchOn;
Key idea: Visual state and collision state should come from the same rule.
Extend: Combine wall, door and gate conditions into one passability query.
23. Require multiple objectives
An AND condition requires all objectives to be true. This can create a puzzle where the player needs both a key and an activated switch. Name the helper by its intention so the rule is easy to read in other code. Do not consume either state merely while checking.
Build: Implement game.readyForVault() requiring keys>=1 and switchOn.
Worked example
game.readyForVault=()=>game.keys>=1&&game.switchOn;
Key idea: AND requires every prerequisite, not just one.
Extend: Draw a truth table for the four possible combinations.
24. Offer alternative routes
An OR rule allows either of two conditions to open a route. For example, a key or a switch could provide different ways through a puzzle. Alternative solutions can support player choice, but the interface must explain them. Test each option separately and the neither case.
Build: Implement game.hasRoute() returning true when keys>0 or switchOn.
Worked example
game.hasRoute=()=>game.keys>0||game.switchOn;
Key idea: OR supports alternative prerequisites.
Extend: Design two routes with different costs instead of one obviously superior choice.
25. Explain puzzle state
A status message should tell the player what remains to do without exposing every solution. Derive it from switch state: closed or open. This is better than saying only Success, because it names the consequence. In a full game, combine a visual gate change with this text.
Build: Implement game.gateMessage() returning Gate open when on, otherwise Find the switch.
Worked example
game.gateMessage=()=>game.switchOn?'Gate open':'Find the switch';game.message=game.gateMessage();
Key idea: Feedback should explain the next action or the consequence.
Extend: Write a hint that helps without specifying the exact route.
26. Advance a patrol by one turn
Turn-based enemies move in response to player actions. That lets a learner predict danger before moving. This helper moves an enemy horizontally by dx. It intentionally has no collision handling yet; the next lesson adds that boundary rule. Keep one-turn movement distinct from time-based movement.
Build: Implement game.patrolStep(enemy) adding enemy.dx to enemy.x.
Worked example
game.patrolStep=e=>{e.x+=e.dx;};Key idea: A turn-based update advances once per accepted action.
Extend: Predict the patrol position after three alternating player moves.
27. Reverse a patrol at a wall
A patrol should check its proposed destination before moving. If that cell is a wall, reverse direction. This helper changes only direction; the engine can then try the new destination. Separating the decision from the move makes corner cases easier to test.
Build: Implement game.turnAtWall(enemy): negate dx if tile(x+dx,y) is #.
Worked example
game.turnAtWall=e=>{if(game.tile(e.x+e.dx,e.y)==='#')e.dx=-e.dx;};Key idea: Inspect a destination before committing an enemy move.
Extend: Also treat locked doors and closed gates as obstacles.
28. Detect a shared grid cell
Grid collision can be simpler than rectangle collision. Two entities occupy the same cell when both coordinates match. Comparing only one axis produces false hits across a row or column. Use this pure helper for contact tests and keep damage in another function.
Build: Implement game.sameCell(a,b) comparing x and y.
Worked example
game.sameCell=(a,b)=>a.x===b.x&&a.y===b.y;
Key idea: Both grid coordinates must match for cell contact.
Extend: Discuss whether swapping cells with a guard should count as a collision.
29. Clamp damage and defeat
Damage should not leave negative lives in the HUD. Clamp to zero and transition to lost when depleted. The maze charges one hit per accepted turn, not every animation frame. This rule does not decide when contact happens; it handles the consequence once called.
Build: Implement game.damage(amount): reduce lives to zero minimum and set mode=lost at zero.
Worked example
game.damage=n=>{game.lives=Math.max(0,game.lives-Math.max(0,n));if(game.lives===0)game.mode='lost';};Key idea: Damage and outcome transitions need explicit lower bounds.
Extend: Add a clear failure message and a retry option.
30. Predict danger without changing it
A warning can show the next patrol position before the player commits. Prediction must not move the actual enemy. Return a new coordinate object using the same wall rule. This is a small example of separating simulation from preview; inaccurate previews undermine trust.
Build: Implement game.predict(enemy): return its next x/y after reversing for a wall, without changing enemy.
Worked example
game.predict=e=>{let dx=e.dx;if(game.tile(e.x+dx,e.y)==='#')dx=-dx;return {x:e.x+dx,y:e.y};};Key idea: Prediction should not mutate the state it describes.
Extend: Draw a ghost marker on the predicted cell with a distinct symbol.
31. Describe each tile
A tile legend turns symbols into understandable rules. Keep descriptions in a lookup table and provide a fallback for unknown symbols. The visual editor includes names on its palette and grid buttons. Descriptive text should explain behavior rather than only name a color.
Build: Implement game.describe(tile) with #=Wall, K=Key, D=Locked door, E=Exit, and fallback Floor.
Worked example
game.describe=t=>({'#':'Wall',K:'Key',D:'Locked door',E:'Exit'})[t]||'Floor';Key idea: A legend should communicate behavior, not rely only on color.
Extend: Add every palette symbol and a short instruction for each.
32. Make progress visible
A percentage can describe exploration when the denominator is meaningful. Clamp the result to 0..100 and return zero for a nonpositive total. This helper does not claim that exploring every cell is required to win. Choose progress measures that match the actual goal instead of adding arbitrary points.
Build: Implement game.percent(done,total): rounded percentage clamped 0..100, or zero when total<=0.
Worked example
game.percent=(done,total)=>total>0?Math.max(0,Math.min(100,Math.round(done/total*100))):0;
Key idea: Progress calculations need meaningful denominators and bounds.
Extend: Display explored floor cells separately from the exit objective.
33. Count objects from the map
A derived count avoids maintaining an extra total that can get out of sync after editing. Scan rows and count a chosen symbol. For this small map a simple loop is clear and fast enough. Do not optimize before measuring a real problem.
Build: Implement game.countTile(symbol) counting matching characters in rows.
Worked example
game.countTile=s=>game.rows.reduce((sum,row)=>sum+[...row].filter(t=>t===s).length,0);
Key idea: Derive small counts from current data rather than duplicating state.
Extend: Show counts for keys, hazards and exits in a level summary.
34. Use a limited text log
A short event log can help learners reconstruct what happened. Bound its length so it does not grow forever. Store the newest messages at the end and remove the oldest excess items. The log supplements current feedback; it should not be required for understanding basic controls.
Build: Implement game.logEvent(text) keeping only the latest five entries in game.log.
Worked example
game.log=[];game.logEvent=text=>{game.log.push(text);if(game.log.length>5)game.log.splice(0,game.log.length-5);};Key idea: Diagnostic history should have a predictable size limit.
Extend: Log pickups, door unlocks and outcomes with clear descriptions.
35. Keep drawing options separate
Grid lines and coordinate labels are learning aids. Turning them off should not alter collision or puzzle state. Treat display settings as presentation preferences, not gameplay data. This separation also makes exports easier to adapt from a teaching view to a polished player view.
Build: Set showGrid=false and showCoordinates=false without changing player position or keys.
Worked example
game.showGrid=false;game.showCoordinates=false;
Key idea: Presentation preferences should not silently change game rules.
Extend: Compare the learning view with a clean view in the editor.
36. Validate dimensions
A level editor needs a contract. This workshop expects ten strings, each twelve characters long. Shape validation is separate from checking allowed symbols and required start/exit tiles. Reject malformed data early so movement and drawing do not fail later with confusing errors.
Build: Implement game.validShape(rows) requiring exactly 10 strings of length 12.
Worked example
game.validShape=r=>Array.isArray(r)&&r.length===10&&r.every(row=>typeof row==='string'&&row.length===12);
Key idea: Validate the level’s structure before interpreting its contents.
Extend: Return a helpful message naming the first malformed row.
37. Validate the symbol vocabulary
A map legend defines a small language. Unknown characters may be typos and should not silently become floor. This helper checks that every row contains only the supported symbols. Shape and required entity checks remain separate responsibilities.
Build: Implement game.validSymbols(rows) allowing only # . S K D E T G A P.
Worked example
game.validSymbols=r=>r.every(row=>/^[#.SKDETGAP]+$/.test(row));
Key idea: Reject unknown symbols instead of inventing their meaning.
Extend: Explain how you would version the format when adding a new tile.
38. Require one start and one exit
A map with no start cannot initialize predictably; multiple starts make the intended spawn ambiguous. The same reasoning applies to the exit for this game’s simple contract. Count exactly one of each. More advanced games can support multiple exits, but their rules must be explicit.
Build: Implement game.validEndpoints(rows) requiring exactly one S and one E.
Worked example
game.validEndpoints=r=>{const text=r.join('');return [...text].filter(t=>t==='S').length===1&&[...text].filter(t=>t==='E').length===1;};Key idea: Required entities need explicit cardinality checks.
Extend: Add descriptive messages for missing versus duplicate endpoints.
39. Find neighboring cells
Pathfinding explores neighboring positions. For a four-direction maze there are four candidate neighbors. Returning candidates without filtering keeps this helper pure and reusable; another function decides which candidates are passable. Never mutate the input position while building the list.
Build: Implement game.neighbors(x,y) returning right,left,down,up coordinate objects.
Worked example
game.neighbors=(x,y)=>[{x:x+1,y},{x:x-1,y},{x,y:y+1},{x,y:y-1}];Key idea: Separate candidate generation from passability rules.
Extend: Filter the candidates using game.passable.
40. Understand reachability limits
A flood-fill can find cells reachable under current passability. It does not automatically solve a puzzle that changes inventory, opens doors or toggles gates. The editor therefore labels its wall-only route check as a structural check. Treat diagnostics as evidence for a specific property, not proof of full solvability.
Build: Implement game.wallOpen(x,y): true for every in-bounds non-wall cell, ignoring dynamic locks.
Worked example
game.wallOpen=(x,y)=>x>=0&&y>=0&&x<12&&y<10&&game.tile(x,y)!=='#';
Key idea: A structural route check is not a full puzzle solver.
Extend: Construct a level with a key behind its own locked door and explain why wall-only analysis misses it.
41. Snapshot mutable state
Undo requires a previous state that does not change with the current one. Copy the player object and rows array. Strings inside rows are immutable, so a shallow array copy is sufficient for them. Nested mutable objects such as guards need their own copies. This lesson practices the small core snapshot.
Build: Implement game.smallSnapshot() returning copied player, copied rows and keys.
Worked example
game.smallSnapshot=()=>({player:{...game.player},rows:game.rows.slice(),keys:game.keys});Key idea: Copy mutable state when recording history.
Extend: List every additional field a complete undo must preserve.
42. Restore the whole turn
A complete turn can change position, inventory, map tiles and patrols. The supplied snapshot/restore methods include those fields. Undo pops the most recent snapshot and restores it. Testing only the player position would miss errors such as restoring a collected key without removing it from inventory.
Build: Implement game.undoTurn(): pop and restore the latest history snapshot; return false if history is empty.
Worked example
game.undoTurn=()=>{if(!game.history.length)return false;game.restore(game.history.pop());return true;};Key idea: Undo must restore every state change belonging to the action.
Extend: Test undo after a door unlock and after a patrol hit.
43. Bound undo memory
Unlimited history can grow indefinitely. Keep the most recent snapshots and discard the oldest beyond a limit. A clear limit is better than a hidden performance problem. This helper uses a limit of twenty; the live engine uses its own larger bounded history.
Build: Implement game.remember(snapshot) keeping at most 20 history entries.
Worked example
game.remember=s=>{game.history.push(s);if(game.history.length>20)game.history.shift();};Key idea: Bounded histories make memory use predictable.
Extend: Decide how the interface should explain that older turns cannot be undone.
44. Version a level file
Exported level data should identify its format. A version lets future code reject incompatible shapes instead of guessing. Export the rows as a copy so later edits do not alter the in-memory payload you prepared. The editor’s JSON export uses this same simple contract.
Build: Implement game.levelData() returning {version:1,rows: copied rows}.
Worked example
game.levelData=()=>({version:1,rows:game.rows.slice()});Key idea: A versioned data contract supports safer future changes.
Extend: Describe how version 2 could add level title and difficulty.
45. Keep restart and undo distinct
Restart returns to the initial level, while undo restores the previous action. A fresh run resets inventory, score, moves, lives, mode and history. The engine’s reset implements this. A named wrapper makes a UI action intention clear and gives you a place to add a start message.
Build: Implement game.newRun() calling reset and setting message to New expedition.
Worked example
game.newRun=()=>{game.reset();game.message='New expedition';};Key idea: Restart and undo have different, documented state targets.
Extend: Compare the result of undo and restart after collecting a key.
46. Build a key-door route
Your first custom puzzle should teach its rule safely. Put a key before a door on a clear route to the exit. The player should encounter the prerequisite before the obstacle that requires it. Use the visual editor to compare this linear tutorial with a branching version. The rule is supplied; this lesson creates level data.
Build: Load a map whose first interior row is #S.K.D....E# and remaining interior rows are floor enclosed by walls.
Worked example
game.reset(['############','#S.K.D....E#',...Array(7).fill('#..........#'),'############']);game.title='The first key';Key idea: A tutorial level should introduce a prerequisite before requiring it.
Extend: Add a side path with a spike and compare whether the choice is meaningful.
47. Build a switch-gate route
A second tutorial can introduce switching without adding every hazard at once. Place a switch before a gate and leave a clear path to the exit. The player can step off and back onto the switch to toggle it again, so consider whether that is desirable. This lesson keeps the existing toggle behavior.
Build: Load first interior row #S.T.A....E# with enclosed empty rows below.
Worked example
game.reset(['############','#S.T.A....E#',...Array(7).fill('#..........#'),'############']);game.title='Power the gate';Key idea: Introduce one new rule at a time before combining mechanics.
Extend: Design a route where turning the switch off is useful rather than a mistake.
48. Design a patrol crossing
A moving hazard changes the cost of a route. Give players room to observe and a way around it. A corridor without an escape may be frustrating rather than interesting. The turn-based patrol advances only after successful player movement, so the player can think before acting.
Build: Load a valid map with one G at (5,3), one key and one exit, then set title to Patrol crossing.
Worked example
game.reset(['############','#S........E#','#..........#','#..K.G.....#',...Array(5).fill('#..........#'),'############']);game.title='Patrol crossing';Key idea: A hazard should create a readable decision with room to respond.
Extend: Use the editor to add cover and test whether there is still a safe route.
49. Give a level a design brief
A level needs an intended experience, not just a pile of tiles. Store a short objective, one mechanic and a playtest question. This data can guide what you observe when another person plays. A brief is not proof of quality; compare it with actual player behavior and revise the level.
Build: Set game.brief to an object with objective="Reach the exit", mechanic="Key and door", question="Did the player notice the key?".
Worked example
game.brief={objective:'Reach the exit',mechanic:'Key and door',question:'Did the player notice the key?'};game.message=game.brief.objective;Key idea: A design brief connects level choices to observable player behavior.
Extend: Write a second question about fairness without leading the tester toward your preferred answer.
50. Capstone: your lantern expedition
Combine canvas presentation, map data, inventory, switching and a patrol into a level you can explain. The supplied engine handles those mechanics; your job is to compose and test them, then export a playable game. Use the visual editor to iterate. Check basic structure, play from start to finish, test undo and restart, and ask someone else to try without coaching.
Build: Create a valid level with at least one K,D,T,A,G and E; set title to Lantern expedition and showCoordinates=false.
Worked example
game.reset(['############','#S.K.D....E#','#.##.#.##..#','#..T.A.....#','##.#.###.#.#','#..#...#...#','#....G.#.P.#','#..##......#','#..........#','############']);game.title='Lantern expedition';game.showCoordinates=false;
Key idea: A complete game needs a testable loop and real playtesting, not only valid data.
Extend: Export your game and record three observations from another player before revising it.