[{"data":1,"prerenderedAt":6027},["ShallowReactive",2],{"post-/posts":3,"posts-/posts":29},{"id":4,"title":5,"body":6,"created":20,"description":21,"extension":22,"meta":23,"navigation":24,"path":25,"seo":26,"stem":27,"updated":20,"__hash__":28},"posts/2.posts/index.md","Blog posts",{"type":7,"value":8,"toc":16},"minimark",[9,13],[10,11,12],"p",{},"Here you can find some things that I wrote.\nI write mostly about small side-projects, or surprising technical things I run into.",[14,15],"post-list",{},{"title":17,"searchDepth":18,"depth":18,"links":19},"",2,[],null,"A list of my blog posts.","md",{},true,"/posts",{"title":5,"description":21},"2.posts/index","BX3skNdvfAPIfwraPNlm7VbffqGLcHNCWgRLOSqLTbg",[30,2111,3064,3970,4357,4536,5263,5808,5895,5960,5995],{"id":31,"title":32,"body":33,"created":2104,"description":2105,"extension":22,"meta":2106,"navigation":24,"path":2107,"seo":2108,"stem":2109,"updated":20,"__hash__":2110},"posts/2.posts/20250411.Bevy Behave.md","Modelling Agent Behaviour with Bevy Behave",{"type":7,"value":34,"toc":2091},[35,41,52,71,74,77,80,83,88,99,102,105,108,111,114,119,122,128,131,134,293,300,372,382,388,476,480,483,486,490,493,496,528,531,808,811,814,818,821,826,829,837,977,980,1002,1005,1016,1023,1070,1073,1424,1427,1430,1435,1438,1613,1616,1620,1623,1627,1630,1633,1637,1640,1643,1646,1651,1654,1753,1770,1774,1777,1784,1788,1791,1794,1799,1802,1995,1998,2006,2009,2013,2016,2024,2027,2042,2046,2087],[36,37,38],"full-screen-sticky",{},[39,40],"behave-demo",{},[10,42,43,44,51],{},"Recently, I've been building a game with ",[45,46,50],"a",{"href":47,"rel":48},"https://bevyengine.org",[49],"nofollow","Bevy"," in my spare time.",[10,53,54,55,60,61],{},"I was looking to add interesting behaviour to the agents in my game, and came across a crate called ",[45,56,59],{"href":57,"rel":58},"https://github.com/RJ/bevy_behave",[49],"Bevy Behave",". ",[62,63,64],"sup",{},[45,65,70],{"href":66,"ariaDescribedBy":67,"dataFootnoteRef":17,"id":69},"#user-content-fn-1",[68],"footnote-label","user-content-fnref-1","1",[10,72,73],{},"I thought I'd spend a few hours to look into how it works by creating a little interactive demo, and some explanatory writing to go along with it.",[10,75,76],{},"It quickly got out of hand, and the little demo I was planning to make became bigger than I intended.",[10,78,79],{},"It was a fun adventure, and I am glad to have it running interactively inside this very blog post.",[10,81,82],{},"The code examples assume familiarity with Rust and Bevy, but feel free to explore even if you're not — it's still entertaining to play around!",[84,85,87],"h2",{"id":86},"the-demo","The demo",[10,89,90,91],{},"Starting it requires fetching a bunch of WebAssembly (around 12 MB uncompressed), so take care if you're tight on data.",[62,92,93],{},[45,94,98],{"href":95,"ariaDescribedBy":96,"dataFootnoteRef":17,"id":97},"#user-content-fn-2",[68],"user-content-fnref-2","2",[10,100,101],{},"To start the demo, tap this button:",[103,104],"start-demo-button",{},[10,106,107],{},"If everything worked correctly (🤞) you should see a bunch of grey squares in the background.",[10,109,110],{},"This sets the stage for my demo.",[10,112,113],{},"To adjust the opacity of the demo, you can use the slider in the bottom right of the screen, in the toolbar.",[115,116,118],"h3",{"id":117},"spawn-an-agent","Spawn an agent",[10,120,121],{},"Now, let's spawn something in the world:",[123,124],"demo-button",{"id":125,"label":126,"icon":127},"spawn-agent","Spawn agent","lucide:user-plus",[10,129,130],{},"What just happened? A green square appeared in the middle of the screen.\nIf it's not visible, something might be in the way — just scroll a bit and it should pop into view.",[10,132,133],{},"It appeared because the following code was triggered:",[135,136,140],"pre",{"className":137,"code":138,"language":139,"meta":17,"style":17},"language-rust shiki shiki-themes one-dark-pro","// Spawn an entity with:\n//   - the `Agent` marker component,\n//   - a mesh (the rectangle),\n//   - and a colour (green)\ncommands.spawn((\n  Agent,\n  Mesh2d(r_meshes.add(Rectangle::new(0.9, 0.9))),\n  MeshMaterial2d(\n    r_materials.add(Color::from(tw::GREEN_600))\n  ),\n));\n","rust",[141,142,143,152,157,163,169,187,197,239,248,281,287],"code",{"__ignoreMap":17},[144,145,148],"span",{"class":146,"line":147},"line",1,[144,149,151],{"class":150},"sV9Aq","// Spawn an entity with:\n",[144,153,154],{"class":146,"line":18},[144,155,156],{"class":150},"//   - the `Agent` marker component,\n",[144,158,160],{"class":146,"line":159},3,[144,161,162],{"class":150},"//   - a mesh (the rectangle),\n",[144,164,166],{"class":146,"line":165},4,[144,167,168],{"class":150},"//   - and a colour (green)\n",[144,170,172,176,180,184],{"class":146,"line":171},5,[144,173,175],{"class":174},"sVyAn","commands",[144,177,179],{"class":178},"sn6KH",".",[144,181,183],{"class":182},"sVbv2","spawn",[144,185,186],{"class":178},"((\n",[144,188,190,194],{"class":146,"line":189},6,[144,191,193],{"class":192},"sU0A5","  Agent",[144,195,196],{"class":178},",\n",[144,198,200,203,206,209,211,214,216,219,222,225,227,231,234,236],{"class":146,"line":199},7,[144,201,202],{"class":182},"  Mesh2d",[144,204,205],{"class":178},"(",[144,207,208],{"class":174},"r_meshes",[144,210,179],{"class":178},[144,212,213],{"class":182},"add",[144,215,205],{"class":178},[144,217,218],{"class":192},"Rectangle",[144,220,221],{"class":178},"::",[144,223,224],{"class":182},"new",[144,226,205],{"class":178},[144,228,230],{"class":229},"sVC51","0.9",[144,232,233],{"class":178},", ",[144,235,230],{"class":229},[144,237,238],{"class":178},"))),\n",[144,240,242,245],{"class":146,"line":241},8,[144,243,244],{"class":182},"  MeshMaterial2d",[144,246,247],{"class":178},"(\n",[144,249,251,254,256,258,260,263,265,268,270,273,275,278],{"class":146,"line":250},9,[144,252,253],{"class":174},"    r_materials",[144,255,179],{"class":178},[144,257,213],{"class":182},[144,259,205],{"class":178},[144,261,262],{"class":192},"Color",[144,264,221],{"class":178},[144,266,267],{"class":182},"from",[144,269,205],{"class":178},[144,271,272],{"class":192},"tw",[144,274,221],{"class":178},[144,276,277],{"class":229},"GREEN_600",[144,279,280],{"class":178},"))\n",[144,282,284],{"class":146,"line":283},10,[144,285,286],{"class":178},"  ),\n",[144,288,290],{"class":146,"line":289},11,[144,291,292],{"class":178},"));\n",[10,294,295,296,299],{},"It appears on the screen because I've set up the ",[141,297,298],{},"Agent"," component like this:",[135,301,303],{"className":137,"code":302,"language":139,"meta":17,"style":17},"// `Agent` is a marker component that requires the\n// `Transform` and `GridCell` components\n#[derive(Component)]\n#[require(\n  Transform(|| Transform::from_xyz(0.0, 0.0, 0.1)),\n  GridCell\n)]\npub struct Agent;\n",[141,304,305,310,315,326,331,348,353,357],{"__ignoreMap":17},[144,306,307],{"class":146,"line":147},[144,308,309],{"class":150},"// `Agent` is a marker component that requires the\n",[144,311,312],{"class":146,"line":18},[144,313,314],{"class":150},"// `Transform` and `GridCell` components\n",[144,316,317,320,323],{"class":146,"line":159},[144,318,319],{"class":178},"#[derive(",[144,321,322],{"class":192},"Component",[144,324,325],{"class":178},")]\n",[144,327,328],{"class":146,"line":165},[144,329,330],{"class":178},"#[require(\n",[144,332,333,336,338,342,345],{"class":146,"line":171},[144,334,335],{"class":192},"  Transform",[144,337,205],{"class":178},[144,339,341],{"class":340},"sjrmR","||",[144,343,344],{"class":192}," Transform",[144,346,347],{"class":178},"::from_xyz(0.0, 0.0, 0.1)),\n",[144,349,350],{"class":146,"line":189},[144,351,352],{"class":192},"  GridCell\n",[144,354,355],{"class":146,"line":199},[144,356,325],{"class":178},[144,358,359,363,366,369],{"class":146,"line":241},[144,360,362],{"class":361},"seHd6","pub",[144,364,365],{"class":361}," struct",[144,367,368],{"class":192}," Agent",[144,370,371],{"class":178},";\n",[10,373,374,377,378,381],{},[141,375,376],{},"Transform"," is a Bevy component that makes the entity have a position in the world.\nFor the agent, I've configured it to have a slightly higher ",[141,379,380],{},"z"," value so that it appears above the grey cells.",[10,383,384,387],{},[141,385,386],{},"GridCell"," is a component I made:",[135,389,391],{"className":137,"code":390,"language":139,"meta":17,"style":17},"// `GridCell` represents a position on the grid.\n// It requires a `Transform` because it needs to\n// have a position in the Bevy world.\n#[derive(Component, Default)]\n#[require(Transform)]\npub struct GridCell {\n  pub x: isize,\n  pub y: isize,\n}\n",[141,392,393,398,403,408,421,430,442,458,471],{"__ignoreMap":17},[144,394,395],{"class":146,"line":147},[144,396,397],{"class":150},"// `GridCell` represents a position on the grid.\n",[144,399,400],{"class":146,"line":18},[144,401,402],{"class":150},"// It requires a `Transform` because it needs to\n",[144,404,405],{"class":146,"line":159},[144,406,407],{"class":150},"// have a position in the Bevy world.\n",[144,409,410,412,414,416,419],{"class":146,"line":165},[144,411,319],{"class":178},[144,413,322],{"class":192},[144,415,233],{"class":178},[144,417,418],{"class":192},"Default",[144,420,325],{"class":178},[144,422,423,426,428],{"class":146,"line":171},[144,424,425],{"class":178},"#[require(",[144,427,376],{"class":192},[144,429,325],{"class":178},[144,431,432,434,436,439],{"class":146,"line":189},[144,433,362],{"class":361},[144,435,365],{"class":361},[144,437,438],{"class":192}," GridCell",[144,440,441],{"class":178}," {\n",[144,443,444,447,450,453,456],{"class":146,"line":199},[144,445,446],{"class":361},"  pub",[144,448,449],{"class":174}," x",[144,451,452],{"class":178},": ",[144,454,455],{"class":192},"isize",[144,457,196],{"class":178},[144,459,460,462,465,467,469],{"class":146,"line":241},[144,461,446],{"class":361},[144,463,464],{"class":174}," y",[144,466,452],{"class":178},[144,468,455],{"class":192},[144,470,196],{"class":178},[144,472,473],{"class":146,"line":250},[144,474,475],{"class":178},"}\n",[115,477,479],{"id":478},"moving-the-agents","Moving the agents",[10,481,482],{},"Now, a green square and a few grey squares might entertain you for about three seconds before you realize you're staring at some motionless coloured boxes.",[10,484,485],{},"Let's make it a little more interesting by making the agent move around:",[123,487],{"id":488,"label":489},"walk-lr-naive","Walk left-right (naive)",[10,491,492],{},"The agent should be moving from left to right endlessly now.",[10,494,495],{},"It started moving because the following component was added to the agent entity:",[135,497,499],{"className":137,"code":498,"language":139,"meta":17,"style":17},"// walk to the left until out of bounds\nWalkInDirectionUntilOutOfBounds::new(-1, 0)\n",[141,500,501,506],{"__ignoreMap":17},[144,502,503],{"class":146,"line":147},[144,504,505],{"class":150},"// walk to the left until out of bounds\n",[144,507,508,511,513,515,518,520,522,525],{"class":146,"line":18},[144,509,510],{"class":192},"WalkInDirectionUntilOutOfBounds",[144,512,221],{"class":178},[144,514,224],{"class":182},[144,516,517],{"class":178},"(-",[144,519,70],{"class":229},[144,521,233],{"class":178},[144,523,524],{"class":229},"0",[144,526,527],{"class":178},")\n",[10,529,530],{},"I process the movement with this system:",[135,532,534],{"className":137,"code":533,"language":139,"meta":17,"style":17},"fn process_left_right_walk(\n  mut q_walkers: Query\u003C(\n      &mut GridCell,\n      &mut WalkInDirectionUntilOutOfBounds\n  ), With\u003CAgent>>,\n  r_grid_bounds: Res\u003CGridBounds>,\n) {\n  // loop over all grid cells & walk components that\n  // are attached to agents\n  for (mut grid_cell, mut walk) in q_walkers.iter_mut() {\n    // determine the next step, and update the agent's\n    // grid cell (make it move there)\n    *grid_cell = walk.step_from(&grid_cell);\n\n    // let's see if the next step will put us out of bounds\n    let next_target = walk.step_from(&grid_cell);\n    if !r_grid_bounds.contains(&next_target) {\n      // it would have, so we reverse (basically just flip\n      // -1 to +1 or vice versa)\n      walk.reverse();\n    }\n  }\n}\n",[141,535,536,546,562,574,583,599,617,622,627,632,668,673,679,706,712,718,741,765,771,777,791,797,803],{"__ignoreMap":17},[144,537,538,541,544],{"class":146,"line":147},[144,539,540],{"class":361},"fn",[144,542,543],{"class":182}," process_left_right_walk",[144,545,247],{"class":178},[144,547,548,551,554,556,559],{"class":146,"line":18},[144,549,550],{"class":361},"  mut",[144,552,553],{"class":174}," q_walkers",[144,555,452],{"class":178},[144,557,558],{"class":192},"Query",[144,560,561],{"class":178},"\u003C(\n",[144,563,564,567,570,572],{"class":146,"line":159},[144,565,566],{"class":178},"      &",[144,568,569],{"class":361},"mut",[144,571,438],{"class":192},[144,573,196],{"class":178},[144,575,576,578,580],{"class":146,"line":165},[144,577,566],{"class":178},[144,579,569],{"class":361},[144,581,582],{"class":192}," WalkInDirectionUntilOutOfBounds\n",[144,584,585,588,591,594,596],{"class":146,"line":171},[144,586,587],{"class":178},"  ), ",[144,589,590],{"class":192},"With",[144,592,593],{"class":178},"\u003C",[144,595,298],{"class":192},[144,597,598],{"class":178},">>,\n",[144,600,601,604,606,609,611,614],{"class":146,"line":189},[144,602,603],{"class":174},"  r_grid_bounds",[144,605,452],{"class":178},[144,607,608],{"class":192},"Res",[144,610,593],{"class":178},[144,612,613],{"class":192},"GridBounds",[144,615,616],{"class":178},">,\n",[144,618,619],{"class":146,"line":199},[144,620,621],{"class":178},") {\n",[144,623,624],{"class":146,"line":241},[144,625,626],{"class":150},"  // loop over all grid cells & walk components that\n",[144,628,629],{"class":146,"line":250},[144,630,631],{"class":150},"  // are attached to agents\n",[144,633,634,637,640,642,645,647,649,652,655,658,660,662,665],{"class":146,"line":283},[144,635,636],{"class":361},"  for",[144,638,639],{"class":178}," (",[144,641,569],{"class":361},[144,643,644],{"class":174}," grid_cell",[144,646,233],{"class":178},[144,648,569],{"class":361},[144,650,651],{"class":174}," walk",[144,653,654],{"class":178},") ",[144,656,657],{"class":361},"in",[144,659,553],{"class":174},[144,661,179],{"class":178},[144,663,664],{"class":182},"iter_mut",[144,666,667],{"class":178},"() {\n",[144,669,670],{"class":146,"line":289},[144,671,672],{"class":150},"    // determine the next step, and update the agent's\n",[144,674,676],{"class":146,"line":675},12,[144,677,678],{"class":150},"    // grid cell (make it move there)\n",[144,680,682,685,688,691,693,695,698,701,703],{"class":146,"line":681},13,[144,683,684],{"class":178},"    *",[144,686,687],{"class":174},"grid_cell",[144,689,690],{"class":340}," =",[144,692,651],{"class":174},[144,694,179],{"class":178},[144,696,697],{"class":182},"step_from",[144,699,700],{"class":178},"(&",[144,702,687],{"class":174},[144,704,705],{"class":178},");\n",[144,707,709],{"class":146,"line":708},14,[144,710,711],{"emptyLinePlaceholder":24},"\n",[144,713,715],{"class":146,"line":714},15,[144,716,717],{"class":150},"    // let's see if the next step will put us out of bounds\n",[144,719,721,724,727,729,731,733,735,737,739],{"class":146,"line":720},16,[144,722,723],{"class":361},"    let",[144,725,726],{"class":174}," next_target",[144,728,690],{"class":340},[144,730,651],{"class":174},[144,732,179],{"class":178},[144,734,697],{"class":182},[144,736,700],{"class":178},[144,738,687],{"class":174},[144,740,705],{"class":178},[144,742,744,747,750,753,755,758,760,763],{"class":146,"line":743},17,[144,745,746],{"class":361},"    if",[144,748,749],{"class":340}," !",[144,751,752],{"class":174},"r_grid_bounds",[144,754,179],{"class":178},[144,756,757],{"class":182},"contains",[144,759,700],{"class":178},[144,761,762],{"class":174},"next_target",[144,764,621],{"class":178},[144,766,768],{"class":146,"line":767},18,[144,769,770],{"class":150},"      // it would have, so we reverse (basically just flip\n",[144,772,774],{"class":146,"line":773},19,[144,775,776],{"class":150},"      // -1 to +1 or vice versa)\n",[144,778,780,783,785,788],{"class":146,"line":779},20,[144,781,782],{"class":174},"      walk",[144,784,179],{"class":178},[144,786,787],{"class":182},"reverse",[144,789,790],{"class":178},"();\n",[144,792,794],{"class":146,"line":793},21,[144,795,796],{"class":178},"    }\n",[144,798,800],{"class":146,"line":799},22,[144,801,802],{"class":178},"  }\n",[144,804,806],{"class":146,"line":805},23,[144,807,475],{"class":178},[10,809,810],{},"All in all, this works, but try implementing anything complex and you'll quickly find yourself wrestling with spaghetti code.",[10,812,813],{},"At this point, I should mention you can spawn additional agents either by tapping the spawn button again or by using the button in the toolbar (below the opacity slider).",[115,815,817],{"id":816},"moving-the-agent-with-bevy-behave","Moving the agent with Bevy Behave",[10,819,820],{},"Now, before doing more complex behaviour, let's first do the same behaviour with Bevy Behave:",[123,822],{"id":823,"label":824,"icon":825},"walk-lr","Walk left-right (behave)","lucide:arrow-left-right",[10,827,828],{},"Wow, other than maybe switching direction, nothing happened!",[10,830,831,832,836],{},"With Bevy Behave, I implemented the behaviour with a ",[833,834,835],"em",{},"behaviour tree",":",[135,838,840],{"className":137,"code":839,"language":139,"meta":17,"style":17},"let tree = behave! {\n  // repeat forever\n  Behave::Forever => {\n    Behave::Sequence => {\n      // walk left until success\n      Behave::spawn((\n        WalkInDirectionUntilOutOfBounds::new(-1, 0),\n      )),\n      // walk right until success\n      Behave::spawn((\n        WalkInDirectionUntilOutOfBounds::new(1, 0),\n      )),\n    }\n  }\n}\n",[141,841,842,857,862,875,887,892,903,923,928,933,943,961,965,969,973],{"__ignoreMap":17},[144,843,844,847,850,852,855],{"class":146,"line":147},[144,845,846],{"class":361},"let",[144,848,849],{"class":174}," tree",[144,851,690],{"class":340},[144,853,854],{"class":182}," behave!",[144,856,441],{"class":178},[144,858,859],{"class":146,"line":18},[144,860,861],{"class":150},"  // repeat forever\n",[144,863,864,867,869,872],{"class":146,"line":159},[144,865,866],{"class":192},"  Behave",[144,868,221],{"class":178},[144,870,871],{"class":192},"Forever",[144,873,874],{"class":178}," => {\n",[144,876,877,880,882,885],{"class":146,"line":165},[144,878,879],{"class":192},"    Behave",[144,881,221],{"class":178},[144,883,884],{"class":192},"Sequence",[144,886,874],{"class":178},[144,888,889],{"class":146,"line":171},[144,890,891],{"class":150},"      // walk left until success\n",[144,893,894,897,899,901],{"class":146,"line":189},[144,895,896],{"class":192},"      Behave",[144,898,221],{"class":178},[144,900,183],{"class":182},[144,902,186],{"class":178},[144,904,905,908,910,912,914,916,918,920],{"class":146,"line":199},[144,906,907],{"class":192},"        WalkInDirectionUntilOutOfBounds",[144,909,221],{"class":178},[144,911,224],{"class":182},[144,913,517],{"class":178},[144,915,70],{"class":229},[144,917,233],{"class":178},[144,919,524],{"class":229},[144,921,922],{"class":178},"),\n",[144,924,925],{"class":146,"line":241},[144,926,927],{"class":178},"      )),\n",[144,929,930],{"class":146,"line":250},[144,931,932],{"class":150},"      // walk right until success\n",[144,934,935,937,939,941],{"class":146,"line":283},[144,936,896],{"class":192},[144,938,221],{"class":178},[144,940,183],{"class":182},[144,942,186],{"class":178},[144,944,945,947,949,951,953,955,957,959],{"class":146,"line":289},[144,946,907],{"class":192},[144,948,221],{"class":178},[144,950,224],{"class":182},[144,952,205],{"class":178},[144,954,70],{"class":229},[144,956,233],{"class":178},[144,958,524],{"class":229},[144,960,922],{"class":178},[144,962,963],{"class":146,"line":675},[144,964,927],{"class":178},[144,966,967],{"class":146,"line":681},[144,968,796],{"class":178},[144,970,971],{"class":146,"line":708},[144,972,802],{"class":178},[144,974,975],{"class":146,"line":714},[144,976,475],{"class":178},[10,978,979],{},"Behave trees use control flow nodes:",[981,982,983,990,996],"ul",{},[984,985,986,989],"li",{},[141,987,988],{},"Behave::Forever"," makes its child node loop forever.",[984,991,992,995],{},[141,993,994],{},"Behave::Sequence"," processes the steps inside it in sequence.",[984,997,998,1001],{},[141,999,1000],{},"Behave::spawn"," spawns a behaviour entity with the specified components, and waits until it reports a successful or unsuccessful result.",[10,1003,1004],{},"So, what the tree does is:",[981,1006,1007,1010,1013],{},[984,1008,1009],{},"Walk left until success is reported.",[984,1011,1012],{},"Walk right until success is reported.",[984,1014,1015],{},"Repeat.",[10,1017,1018,1019,1022],{},"An important difference between Bevy Behave and my previous \"naive\" solution is that the behaviour components don't belong to the agent's entity—they're attached to a completely ",[833,1020,1021],{},"separate"," behaviour entity.\nSo, to spawn the tree you add it as a child entity of the agent that you want it to control:",[135,1024,1026],{"className":137,"code":1025,"language":139,"meta":17,"style":17},"commands\n  .spawn(BehaveTree::new(tree))\n  .set_parent(agent_entity);\n",[141,1027,1028,1033,1056],{"__ignoreMap":17},[144,1029,1030],{"class":146,"line":147},[144,1031,1032],{"class":174},"commands\n",[144,1034,1035,1038,1040,1042,1045,1047,1049,1051,1054],{"class":146,"line":18},[144,1036,1037],{"class":178},"  .",[144,1039,183],{"class":182},[144,1041,205],{"class":178},[144,1043,1044],{"class":192},"BehaveTree",[144,1046,221],{"class":178},[144,1048,224],{"class":182},[144,1050,205],{"class":178},[144,1052,1053],{"class":174},"tree",[144,1055,280],{"class":178},[144,1057,1058,1060,1063,1065,1068],{"class":146,"line":159},[144,1059,1037],{"class":178},[144,1061,1062],{"class":182},"set_parent",[144,1064,205],{"class":178},[144,1066,1067],{"class":174},"agent_entity",[144,1069,705],{"class":178},[10,1071,1072],{},"The system that processes the movement also changes slightly:",[135,1074,1076],{"className":137,"code":1075,"language":139,"meta":17,"style":17},"\nfn process_walk_in_direction(\n  q_walks: Query\u003C(\n    &WalkInDirectionUntilOutOfBounds,\n    &BehaveCtx,\n  )>,\n  mut q_agent_cells: Query\u003C&mut GridCell, With\u003CAgent>>,\n  r_bounds: Res\u003CGridBounds>,\n  mut commands: Commands,\n) {\n  for (walk, ctx) in q_walks.iter() {\n    // retrieve the target entity from the `BehaveCtx`\n    // (usually the parent of the tree)\n    let Ok(mut agent_cell) = q_agent_cells\n      .get_mut(ctx.target_entity()) else {\n      // skip if entity is not found\n      continue;\n    };\n\n    // make the agent take the step\n    *agent_cell = walk.step_from(&agent_cell);\n\n    // see if we can take another step next time\n    let next_target = walk.step_from(&agent_cell);\n    if !r_bounds.contains(&next_target) {\n      // the next step would've put the agent out of bounds,\n      // so we report that this behaviour step was\n      // successfully completed\n      commands.trigger(ctx.success());\n    }\n  }\n}\n",[141,1077,1078,1082,1091,1102,1111,1120,1125,1153,1168,1182,1186,1214,1219,1224,1246,1271,1276,1283,1288,1292,1297,1318,1322,1327,1348,1368,1374,1380,1386,1409,1414,1419],{"__ignoreMap":17},[144,1079,1080],{"class":146,"line":147},[144,1081,711],{"emptyLinePlaceholder":24},[144,1083,1084,1086,1089],{"class":146,"line":18},[144,1085,540],{"class":361},[144,1087,1088],{"class":182}," process_walk_in_direction",[144,1090,247],{"class":178},[144,1092,1093,1096,1098,1100],{"class":146,"line":159},[144,1094,1095],{"class":174},"  q_walks",[144,1097,452],{"class":178},[144,1099,558],{"class":192},[144,1101,561],{"class":178},[144,1103,1104,1107,1109],{"class":146,"line":165},[144,1105,1106],{"class":178},"    &",[144,1108,510],{"class":192},[144,1110,196],{"class":178},[144,1112,1113,1115,1118],{"class":146,"line":171},[144,1114,1106],{"class":178},[144,1116,1117],{"class":192},"BehaveCtx",[144,1119,196],{"class":178},[144,1121,1122],{"class":146,"line":189},[144,1123,1124],{"class":178},"  )>,\n",[144,1126,1127,1129,1132,1134,1136,1139,1141,1143,1145,1147,1149,1151],{"class":146,"line":199},[144,1128,550],{"class":361},[144,1130,1131],{"class":174}," q_agent_cells",[144,1133,452],{"class":178},[144,1135,558],{"class":192},[144,1137,1138],{"class":178},"\u003C&",[144,1140,569],{"class":361},[144,1142,438],{"class":192},[144,1144,233],{"class":178},[144,1146,590],{"class":192},[144,1148,593],{"class":178},[144,1150,298],{"class":192},[144,1152,598],{"class":178},[144,1154,1155,1158,1160,1162,1164,1166],{"class":146,"line":241},[144,1156,1157],{"class":174},"  r_bounds",[144,1159,452],{"class":178},[144,1161,608],{"class":192},[144,1163,593],{"class":178},[144,1165,613],{"class":192},[144,1167,616],{"class":178},[144,1169,1170,1172,1175,1177,1180],{"class":146,"line":250},[144,1171,550],{"class":361},[144,1173,1174],{"class":174}," commands",[144,1176,452],{"class":178},[144,1178,1179],{"class":192},"Commands",[144,1181,196],{"class":178},[144,1183,1184],{"class":146,"line":283},[144,1185,621],{"class":178},[144,1187,1188,1190,1192,1195,1197,1200,1202,1204,1207,1209,1212],{"class":146,"line":289},[144,1189,636],{"class":361},[144,1191,639],{"class":178},[144,1193,1194],{"class":174},"walk",[144,1196,233],{"class":178},[144,1198,1199],{"class":174},"ctx",[144,1201,654],{"class":178},[144,1203,657],{"class":361},[144,1205,1206],{"class":174}," q_walks",[144,1208,179],{"class":178},[144,1210,1211],{"class":182},"iter",[144,1213,667],{"class":178},[144,1215,1216],{"class":146,"line":675},[144,1217,1218],{"class":150},"    // retrieve the target entity from the `BehaveCtx`\n",[144,1220,1221],{"class":146,"line":681},[144,1222,1223],{"class":150},"    // (usually the parent of the tree)\n",[144,1225,1226,1228,1231,1233,1235,1238,1240,1243],{"class":146,"line":708},[144,1227,723],{"class":361},[144,1229,1230],{"class":192}," Ok",[144,1232,205],{"class":178},[144,1234,569],{"class":361},[144,1236,1237],{"class":174}," agent_cell",[144,1239,654],{"class":178},[144,1241,1242],{"class":340},"=",[144,1244,1245],{"class":174}," q_agent_cells\n",[144,1247,1248,1251,1254,1256,1258,1260,1263,1266,1269],{"class":146,"line":714},[144,1249,1250],{"class":178},"      .",[144,1252,1253],{"class":182},"get_mut",[144,1255,205],{"class":178},[144,1257,1199],{"class":174},[144,1259,179],{"class":178},[144,1261,1262],{"class":182},"target_entity",[144,1264,1265],{"class":178},"()) ",[144,1267,1268],{"class":361},"else",[144,1270,441],{"class":178},[144,1272,1273],{"class":146,"line":720},[144,1274,1275],{"class":150},"      // skip if entity is not found\n",[144,1277,1278,1281],{"class":146,"line":743},[144,1279,1280],{"class":361},"      continue",[144,1282,371],{"class":178},[144,1284,1285],{"class":146,"line":767},[144,1286,1287],{"class":178},"    };\n",[144,1289,1290],{"class":146,"line":773},[144,1291,711],{"emptyLinePlaceholder":24},[144,1293,1294],{"class":146,"line":779},[144,1295,1296],{"class":150},"    // make the agent take the step\n",[144,1298,1299,1301,1304,1306,1308,1310,1312,1314,1316],{"class":146,"line":793},[144,1300,684],{"class":178},[144,1302,1303],{"class":174},"agent_cell",[144,1305,690],{"class":340},[144,1307,651],{"class":174},[144,1309,179],{"class":178},[144,1311,697],{"class":182},[144,1313,700],{"class":178},[144,1315,1303],{"class":174},[144,1317,705],{"class":178},[144,1319,1320],{"class":146,"line":799},[144,1321,711],{"emptyLinePlaceholder":24},[144,1323,1324],{"class":146,"line":805},[144,1325,1326],{"class":150},"    // see if we can take another step next time\n",[144,1328,1330,1332,1334,1336,1338,1340,1342,1344,1346],{"class":146,"line":1329},24,[144,1331,723],{"class":361},[144,1333,726],{"class":174},[144,1335,690],{"class":340},[144,1337,651],{"class":174},[144,1339,179],{"class":178},[144,1341,697],{"class":182},[144,1343,700],{"class":178},[144,1345,1303],{"class":174},[144,1347,705],{"class":178},[144,1349,1351,1353,1355,1358,1360,1362,1364,1366],{"class":146,"line":1350},25,[144,1352,746],{"class":361},[144,1354,749],{"class":340},[144,1356,1357],{"class":174},"r_bounds",[144,1359,179],{"class":178},[144,1361,757],{"class":182},[144,1363,700],{"class":178},[144,1365,762],{"class":174},[144,1367,621],{"class":178},[144,1369,1371],{"class":146,"line":1370},26,[144,1372,1373],{"class":150},"      // the next step would've put the agent out of bounds,\n",[144,1375,1377],{"class":146,"line":1376},27,[144,1378,1379],{"class":150},"      // so we report that this behaviour step was\n",[144,1381,1383],{"class":146,"line":1382},28,[144,1384,1385],{"class":150},"      // successfully completed\n",[144,1387,1389,1392,1394,1397,1399,1401,1403,1406],{"class":146,"line":1388},29,[144,1390,1391],{"class":174},"      commands",[144,1393,179],{"class":178},[144,1395,1396],{"class":182},"trigger",[144,1398,205],{"class":178},[144,1400,1199],{"class":174},[144,1402,179],{"class":178},[144,1404,1405],{"class":182},"success",[144,1407,1408],{"class":178},"());\n",[144,1410,1412],{"class":146,"line":1411},30,[144,1413,796],{"class":178},[144,1415,1417],{"class":146,"line":1416},31,[144,1418,802],{"class":178},[144,1420,1422],{"class":146,"line":1421},32,[144,1423,475],{"class":178},[10,1425,1426],{},"The beauty here is that system logic can focus solely on its specific task, then hand control back to the behaviour tree once it's finished.",[10,1428,1429],{},"With this setup, we can easily implement slightly more interesting behaviour:",[123,1431],{"id":1432,"label":1433,"icon":1434},"walk-clockwise","Walk clockwise","lucide:repeat-2",[10,1436,1437],{},"The agents now use this behaviour tree:",[135,1439,1441],{"className":137,"code":1440,"language":139,"meta":17,"style":17},"// repeat forever\nBehave::Forever => {\n  Behave::Sequence => {\n    Behave::spawn(\n      // walk left until success\n      WalkInDirectionUntilOutOfBounds((-1, 0)),\n    ),\n    Behave::spawn(\n      // walk up until success\n      WalkInDirectionUntilOutOfBounds((0, 1)),\n    ),\n    Behave::spawn(\n      // walk right until success\n      WalkInDirectionUntilOutOfBounds((1, 0)),\n    ),\n    Behave::spawn(\n      // walk down until success\n      WalkInDirectionUntilOutOfBounds((0, -1)),\n    ),\n  }\n}\n",[141,1442,1443,1448,1459,1469,1479,1483,1500,1505,1515,1520,1535,1539,1549,1553,1567,1571,1581,1586,1601,1605,1609],{"__ignoreMap":17},[144,1444,1445],{"class":146,"line":147},[144,1446,1447],{"class":150},"// repeat forever\n",[144,1449,1450,1453,1455,1457],{"class":146,"line":18},[144,1451,1452],{"class":192},"Behave",[144,1454,221],{"class":178},[144,1456,871],{"class":192},[144,1458,874],{"class":178},[144,1460,1461,1463,1465,1467],{"class":146,"line":159},[144,1462,866],{"class":192},[144,1464,221],{"class":178},[144,1466,884],{"class":192},[144,1468,874],{"class":178},[144,1470,1471,1473,1475,1477],{"class":146,"line":165},[144,1472,879],{"class":192},[144,1474,221],{"class":178},[144,1476,183],{"class":182},[144,1478,247],{"class":178},[144,1480,1481],{"class":146,"line":171},[144,1482,891],{"class":150},[144,1484,1485,1488,1491,1493,1495,1497],{"class":146,"line":189},[144,1486,1487],{"class":182},"      WalkInDirectionUntilOutOfBounds",[144,1489,1490],{"class":178},"((-",[144,1492,70],{"class":229},[144,1494,233],{"class":178},[144,1496,524],{"class":229},[144,1498,1499],{"class":178},")),\n",[144,1501,1502],{"class":146,"line":199},[144,1503,1504],{"class":178},"    ),\n",[144,1506,1507,1509,1511,1513],{"class":146,"line":241},[144,1508,879],{"class":192},[144,1510,221],{"class":178},[144,1512,183],{"class":182},[144,1514,247],{"class":178},[144,1516,1517],{"class":146,"line":250},[144,1518,1519],{"class":150},"      // walk up until success\n",[144,1521,1522,1524,1527,1529,1531,1533],{"class":146,"line":283},[144,1523,1487],{"class":182},[144,1525,1526],{"class":178},"((",[144,1528,524],{"class":229},[144,1530,233],{"class":178},[144,1532,70],{"class":229},[144,1534,1499],{"class":178},[144,1536,1537],{"class":146,"line":289},[144,1538,1504],{"class":178},[144,1540,1541,1543,1545,1547],{"class":146,"line":675},[144,1542,879],{"class":192},[144,1544,221],{"class":178},[144,1546,183],{"class":182},[144,1548,247],{"class":178},[144,1550,1551],{"class":146,"line":681},[144,1552,932],{"class":150},[144,1554,1555,1557,1559,1561,1563,1565],{"class":146,"line":708},[144,1556,1487],{"class":182},[144,1558,1526],{"class":178},[144,1560,70],{"class":229},[144,1562,233],{"class":178},[144,1564,524],{"class":229},[144,1566,1499],{"class":178},[144,1568,1569],{"class":146,"line":714},[144,1570,1504],{"class":178},[144,1572,1573,1575,1577,1579],{"class":146,"line":720},[144,1574,879],{"class":192},[144,1576,221],{"class":178},[144,1578,183],{"class":182},[144,1580,247],{"class":178},[144,1582,1583],{"class":146,"line":743},[144,1584,1585],{"class":150},"      // walk down until success\n",[144,1587,1588,1590,1592,1594,1597,1599],{"class":146,"line":767},[144,1589,1487],{"class":182},[144,1591,1526],{"class":178},[144,1593,524],{"class":229},[144,1595,1596],{"class":178},", -",[144,1598,70],{"class":229},[144,1600,1499],{"class":178},[144,1602,1603],{"class":146,"line":773},[144,1604,1504],{"class":178},[144,1606,1607],{"class":146,"line":779},[144,1608,802],{"class":178},[144,1610,1611],{"class":146,"line":793},[144,1612,475],{"class":178},[10,1614,1615],{},"As you can see, this makes the agents move around the grid's boundaries.",[115,1617,1619],{"id":1618},"a-more-challenging-environment","A more challenging environment",[10,1621,1622],{},"So far, our agent doesn't really have a goal, apart from moving.\nLet's make it a little more challenging by adding a hunger / eating system:",[123,1624],{"id":1625,"label":1626},"enable-hunger","Enable hunger & eating",[10,1628,1629],{},"Now, an agent will \"disappear\" when their hunger indicator runs out.",[10,1631,1632],{},"Quick! Let's spawn some fruit to sustain them:",[123,1634],{"id":1635,"label":1636},"spawn-fruit-spawner","Make fruit appear",[10,1638,1639],{},"Now, you'll see little red squares appearing that represent tasty fruit for the agents.\nWhen an agent is on the same cell as a piece of fruit, they eat the fruit.",[10,1641,1642],{},"Unless the agents are lucky and fruit keeps spawning on their predetermined path, they will still starve at some point. 😢",[10,1644,1645],{},"Let's give them smarter behaviour that makes them look for fruit and move to it:",[123,1647],{"id":1648,"label":1649,"icon":1650},"move-to-fruit","Move to fruit","lucide:cherry",[10,1652,1653],{},"The behaviour tree now looks like this:",[135,1655,1657],{"className":137,"code":1656,"language":139,"meta":17,"style":17},"Behave::Forever => {\n  Behave::Sequence => {\n    Behave::spawn(\n      // find fruit target\n      FindTarget::new(TargetKind::Fruit),\n    ),\n    Behave::spawn(\n      // go to target\n      GoToTarget,\n    ),\n  }\n}\n",[141,1658,1659,1669,1679,1689,1694,1715,1719,1729,1734,1741,1745,1749],{"__ignoreMap":17},[144,1660,1661,1663,1665,1667],{"class":146,"line":147},[144,1662,1452],{"class":192},[144,1664,221],{"class":178},[144,1666,871],{"class":192},[144,1668,874],{"class":178},[144,1670,1671,1673,1675,1677],{"class":146,"line":18},[144,1672,866],{"class":192},[144,1674,221],{"class":178},[144,1676,884],{"class":192},[144,1678,874],{"class":178},[144,1680,1681,1683,1685,1687],{"class":146,"line":159},[144,1682,879],{"class":192},[144,1684,221],{"class":178},[144,1686,183],{"class":182},[144,1688,247],{"class":178},[144,1690,1691],{"class":146,"line":165},[144,1692,1693],{"class":150},"      // find fruit target\n",[144,1695,1696,1699,1701,1703,1705,1708,1710,1713],{"class":146,"line":171},[144,1697,1698],{"class":192},"      FindTarget",[144,1700,221],{"class":178},[144,1702,224],{"class":182},[144,1704,205],{"class":178},[144,1706,1707],{"class":192},"TargetKind",[144,1709,221],{"class":178},[144,1711,1712],{"class":192},"Fruit",[144,1714,922],{"class":178},[144,1716,1717],{"class":146,"line":189},[144,1718,1504],{"class":178},[144,1720,1721,1723,1725,1727],{"class":146,"line":199},[144,1722,879],{"class":192},[144,1724,221],{"class":178},[144,1726,183],{"class":182},[144,1728,247],{"class":178},[144,1730,1731],{"class":146,"line":241},[144,1732,1733],{"class":150},"      // go to target\n",[144,1735,1736,1739],{"class":146,"line":250},[144,1737,1738],{"class":192},"      GoToTarget",[144,1740,196],{"class":178},[144,1742,1743],{"class":146,"line":283},[144,1744,1504],{"class":178},[144,1746,1747],{"class":146,"line":289},[144,1748,802],{"class":178},[144,1750,1751],{"class":146,"line":675},[144,1752,475],{"class":178},[10,1754,1755,1756,1759,1760,1763,1764,1769],{},"Of course, these behaviour steps (",[141,1757,1758],{},"FindTarget"," and ",[141,1761,1762],{},"GoToTarget",") also need to be processed in systems, but I won't bother you with those details here.\nInstead, take a look at ",[45,1765,1768],{"href":1766,"rel":1767},"https://github.com/HanKruiger/behave-blog-demo/blob/main/src/behaviours/target_finding.rs",[49],"the source code"," if you're interested.",[115,1771,1773],{"id":1772},"finding-coins-while-not-hungry","Finding coins while not hungry",[10,1775,1776],{},"This is very cool and all, but you can do much more with Bevy Behave.",[10,1778,1779,1780,1783],{},"Let's make the environment more interesting by adding ",[833,1781,1782],{},"coins","! 🤑",[123,1785],{"id":1786,"label":1787},"spawn-coin-spawner","Make coins appear",[10,1789,1790],{},"Now, when an agent is in the same cell as a coin, they fill up their point indicator.",[10,1792,1793],{},"With this environment, we can make the agents find coins while they're not really hungry, and find fruit otherwise:",[123,1795],{"id":1796,"label":1797,"icon":1798},"move-hunger-based","Hunger-based targeting","lucide:brain",[10,1800,1801],{},"The behaviour tree looks like this:",[135,1803,1805],{"className":137,"code":1804,"language":139,"meta":17,"style":17},"Behave::Forever => {\n  Behave::Sequence => {\n    Behave::IfThen => {\n      // this check reports success if energy\n      // is below 40%\n      Behave::trigger(HungerCheck(0.4)),\n\n      // spawned if hunger check succeeded\n      Behave::spawn(\n        FindTarget::new(TargetKind::Fruit),\n      ),\n\n      // spawned if hunger check failed\n      Behave::spawn(\n        FindTarget::new(TargetKind::Coins),\n      ),\n    },\n\n    // go to the target we just found\n    Behave::spawn(\n      GoToTarget,\n    ),\n  }\n}\n",[141,1806,1807,1817,1827,1838,1843,1848,1868,1872,1877,1887,1906,1911,1915,1920,1930,1949,1953,1958,1962,1967,1977,1983,1987,1991],{"__ignoreMap":17},[144,1808,1809,1811,1813,1815],{"class":146,"line":147},[144,1810,1452],{"class":192},[144,1812,221],{"class":178},[144,1814,871],{"class":192},[144,1816,874],{"class":178},[144,1818,1819,1821,1823,1825],{"class":146,"line":18},[144,1820,866],{"class":192},[144,1822,221],{"class":178},[144,1824,884],{"class":192},[144,1826,874],{"class":178},[144,1828,1829,1831,1833,1836],{"class":146,"line":159},[144,1830,879],{"class":192},[144,1832,221],{"class":178},[144,1834,1835],{"class":192},"IfThen",[144,1837,874],{"class":178},[144,1839,1840],{"class":146,"line":165},[144,1841,1842],{"class":150},"      // this check reports success if energy\n",[144,1844,1845],{"class":146,"line":171},[144,1846,1847],{"class":150},"      // is below 40%\n",[144,1849,1850,1852,1854,1856,1858,1861,1863,1866],{"class":146,"line":189},[144,1851,896],{"class":192},[144,1853,221],{"class":178},[144,1855,1396],{"class":182},[144,1857,205],{"class":178},[144,1859,1860],{"class":182},"HungerCheck",[144,1862,205],{"class":178},[144,1864,1865],{"class":229},"0.4",[144,1867,1499],{"class":178},[144,1869,1870],{"class":146,"line":199},[144,1871,711],{"emptyLinePlaceholder":24},[144,1873,1874],{"class":146,"line":241},[144,1875,1876],{"class":150},"      // spawned if hunger check succeeded\n",[144,1878,1879,1881,1883,1885],{"class":146,"line":250},[144,1880,896],{"class":192},[144,1882,221],{"class":178},[144,1884,183],{"class":182},[144,1886,247],{"class":178},[144,1888,1889,1892,1894,1896,1898,1900,1902,1904],{"class":146,"line":283},[144,1890,1891],{"class":192},"        FindTarget",[144,1893,221],{"class":178},[144,1895,224],{"class":182},[144,1897,205],{"class":178},[144,1899,1707],{"class":192},[144,1901,221],{"class":178},[144,1903,1712],{"class":192},[144,1905,922],{"class":178},[144,1907,1908],{"class":146,"line":289},[144,1909,1910],{"class":178},"      ),\n",[144,1912,1913],{"class":146,"line":675},[144,1914,711],{"emptyLinePlaceholder":24},[144,1916,1917],{"class":146,"line":681},[144,1918,1919],{"class":150},"      // spawned if hunger check failed\n",[144,1921,1922,1924,1926,1928],{"class":146,"line":708},[144,1923,896],{"class":192},[144,1925,221],{"class":178},[144,1927,183],{"class":182},[144,1929,247],{"class":178},[144,1931,1932,1934,1936,1938,1940,1942,1944,1947],{"class":146,"line":714},[144,1933,1891],{"class":192},[144,1935,221],{"class":178},[144,1937,224],{"class":182},[144,1939,205],{"class":178},[144,1941,1707],{"class":192},[144,1943,221],{"class":178},[144,1945,1946],{"class":192},"Coins",[144,1948,922],{"class":178},[144,1950,1951],{"class":146,"line":720},[144,1952,1910],{"class":178},[144,1954,1955],{"class":146,"line":743},[144,1956,1957],{"class":178},"    },\n",[144,1959,1960],{"class":146,"line":767},[144,1961,711],{"emptyLinePlaceholder":24},[144,1963,1964],{"class":146,"line":773},[144,1965,1966],{"class":150},"    // go to the target we just found\n",[144,1968,1969,1971,1973,1975],{"class":146,"line":779},[144,1970,879],{"class":192},[144,1972,221],{"class":178},[144,1974,183],{"class":182},[144,1976,247],{"class":178},[144,1978,1979,1981],{"class":146,"line":793},[144,1980,1738],{"class":192},[144,1982,196],{"class":178},[144,1984,1985],{"class":146,"line":799},[144,1986,1504],{"class":178},[144,1988,1989],{"class":146,"line":805},[144,1990,802],{"class":178},[144,1992,1993],{"class":146,"line":1329},[144,1994,475],{"class":178},[10,1996,1997],{},"I've introduced a new control flow node:",[981,1999,2000],{},[984,2001,2002,2005],{},[141,2003,2004],{},"Behave::IfThen"," runs the first child. If that child reports success, it runs the second child. Otherwise it runs the third child.",[10,2007,2008],{},"With this behaviour tree, the agent first checks if they're hungry.\nIf they are, they'll go look for food. If not, they'll look for coins instead!",[84,2010,2012],{"id":2011},"conclusion","Conclusion",[10,2014,2015],{},"We've only scratched the surface of what's possible.",[10,2017,2018,2019,1759,2021,2023],{},"I really like how you can define small building blocks like ",[141,2020,1758],{},[141,2022,1762],{},", and compose them into complex behaviour trees.",[10,2025,2026],{},"I look forward to building more behaviours with Bevy Behave!",[10,2028,2029,2030,2035,2036,2041],{},"You can follow me on ",[45,2031,2034],{"href":2032,"rel":2033},"https://mastodon.nl/@hankruiger",[49],"Mastodon"," or ",[45,2037,2040],{"href":2038,"rel":2039},"https://bsky.app/profile/hankruiger.com",[49],"Bluesky"," if you want to stay updated on my game development journey.",[115,2043,2045],{"id":2044},"footnotes","Footnotes",[2047,2048,2050,2054],"section",{"className":2049,"dataFootnotes":17},[2044],[84,2051,2045],{"className":2052,"id":68},[2053],"sr-only",[2055,2056,2057,2074],"ol",{},[984,2058,2060,2061,2066,2067],{"id":2059},"user-content-fn-1","Fun fact: crate maintainer ",[45,2062,2065],{"href":2063,"rel":2064},"https://www.metabrew.com",[49],"RJ"," founded Audioscrobbler, which evolved into Last.fm. ",[45,2068,2073],{"href":2069,"ariaLabel":2070,"className":2071,"dataFootnoteBackref":17},"#user-content-fnref-1","Back to reference 1",[2072],"data-footnote-backref","↩",[984,2075,2077,2078,2081,2082],{"id":2076},"user-content-fn-2","This is expected to be improved soon, as Bevy 0.16 will support ",[141,2079,2080],{},"no-std",", making it possible to ship much smaller binaries. ",[45,2083,2073],{"href":2084,"ariaLabel":2085,"className":2086,"dataFootnoteBackref":17},"#user-content-fnref-2","Back to reference 2",[2072],[2088,2089,2090],"style",{},"html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .sU0A5, html code.shiki .sU0A5{--shiki-default:#E5C07B}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}",{"title":17,"searchDepth":18,"depth":18,"links":2092},[2093,2100,2103],{"id":86,"depth":18,"text":87,"children":2094},[2095,2096,2097,2098,2099],{"id":117,"depth":159,"text":118},{"id":478,"depth":159,"text":479},{"id":816,"depth":159,"text":817},{"id":1618,"depth":159,"text":1619},{"id":1772,"depth":159,"text":1773},{"id":2011,"depth":18,"text":2012,"children":2101},[2102],{"id":2044,"depth":159,"text":2045},{"id":68,"depth":18,"text":2045},"2025-04-11","Interactive blog post about modelling agent behaviour with Bevy Behave",{},"/posts/bevy-behave",{"title":32,"description":2105},"2.posts/20250411.Bevy Behave","ao3W-BZ-n6ulJvCyzkJ00qy8WHrLaUXP6U88x3T1i9w",{"id":2112,"title":2113,"body":2114,"created":3057,"description":3058,"extension":22,"meta":3059,"navigation":24,"path":3060,"seo":3061,"stem":3062,"updated":20,"__hash__":3063},"posts/2.posts/20250319.Adding networked multiplayer to my game with Bevy Replicon.md","Adding networked multiplayer to my game with Bevy Replicon",{"type":7,"value":2115,"toc":3044},[2116,2123,2134,2138,2145,2160,2163,2166,2169,2261,2264,2267,2370,2373,2566,2578,2581,2698,2701,2709,2712,2715,2718,2725,2729,2738,2741,2744,2748,2751,2764,2767,2770,2785,2788,2795,2799,2802,2805,2808,2844,2847,2878,2881,2958,2961,2965,2968,2976,2979,2986,2995,2997,3000,3003,3006,3021,3041],[10,2117,2118,2119,2122],{},"Last weekend, I worked on adding networked multiplayer to a video game that I'm building in my spare time.\nMy game engine of choice, ",[45,2120,50],{"href":47,"rel":2121},[49],", has a lively ecosystem so there were options.",[10,2124,2125,2126,2133],{},"I went with ",[45,2127,2130],{"href":2128,"rel":2129},"https://docs.rs/bevy_replicon/latest/bevy_replicon/",[49],[141,2131,2132],{},"bevy_replicon"," for its high-level API and rock-solid documentation and maintenance.\nI was so impressed by it that I just had to share my enthusiasm and write a blog post about how cool I think it is.",[84,2135,2137],{"id":2136},"entity-component-systems","Entity Component Systems",[10,2139,2140,2141,2144],{},"Bevy is a game engine that uses the ",[833,2142,2143],{},"Entity Component System"," (ECS) pattern.",[10,2146,2147,2148,2151,2152,2155,2156,2159],{},"The ECS pattern encourages you to design your game logic around ",[833,2149,2150],{},"systems"," that manage ",[833,2153,2154],{},"entities"," and their ",[833,2157,2158],{},"components",".\nThat sounds quite abstract, so let's break it down and give some examples.",[10,2161,2162],{},"In the examples, I'll model an object that moves randomly across a grid. First, I'll define its components, then set up a system to spawn it as an entity, and finally create an update system to handle its random movement.",[115,2164,2165],{"id":2158},"Components",[10,2167,2168],{},"A component is simply a piece of data. That's it. The following Rust code defines two components:",[135,2170,2172],{"className":137,"code":2171,"language":139,"meta":17,"style":17},"/// This is a component that models a discrete 2D position.\n#[derive(Component)]\nstruct Position {\n  pub x: isize,\n  pub y: isize,\n}\n\n/// This is a *marker component* that does not in fact hold\n/// data, but can be used as a marker to distinguish the\n/// holder of the component from others.\n#[derive(Component)]\nstruct RandomWalker;\n",[141,2173,2174,2179,2187,2197,2209,2221,2225,2229,2234,2239,2244,2252],{"__ignoreMap":17},[144,2175,2176],{"class":146,"line":147},[144,2177,2178],{"class":150},"/// This is a component that models a discrete 2D position.\n",[144,2180,2181,2183,2185],{"class":146,"line":18},[144,2182,319],{"class":178},[144,2184,322],{"class":192},[144,2186,325],{"class":178},[144,2188,2189,2192,2195],{"class":146,"line":159},[144,2190,2191],{"class":361},"struct",[144,2193,2194],{"class":192}," Position",[144,2196,441],{"class":178},[144,2198,2199,2201,2203,2205,2207],{"class":146,"line":165},[144,2200,446],{"class":361},[144,2202,449],{"class":174},[144,2204,452],{"class":178},[144,2206,455],{"class":192},[144,2208,196],{"class":178},[144,2210,2211,2213,2215,2217,2219],{"class":146,"line":171},[144,2212,446],{"class":361},[144,2214,464],{"class":174},[144,2216,452],{"class":178},[144,2218,455],{"class":192},[144,2220,196],{"class":178},[144,2222,2223],{"class":146,"line":189},[144,2224,475],{"class":178},[144,2226,2227],{"class":146,"line":199},[144,2228,711],{"emptyLinePlaceholder":24},[144,2230,2231],{"class":146,"line":241},[144,2232,2233],{"class":150},"/// This is a *marker component* that does not in fact hold\n",[144,2235,2236],{"class":146,"line":250},[144,2237,2238],{"class":150},"/// data, but can be used as a marker to distinguish the\n",[144,2240,2241],{"class":146,"line":283},[144,2242,2243],{"class":150},"/// holder of the component from others.\n",[144,2245,2246,2248,2250],{"class":146,"line":289},[144,2247,319],{"class":178},[144,2249,322],{"class":192},[144,2251,325],{"class":178},[144,2253,2254,2256,2259],{"class":146,"line":675},[144,2255,2191],{"class":361},[144,2257,2258],{"class":192}," RandomWalker",[144,2260,371],{"class":178},[115,2262,2263],{"id":2150},"Systems",[10,2265,2266],{},"So what do you do with these components?\nYou use them in systems, where you can instantiate them, and attach them to entities:",[135,2268,2270],{"className":137,"code":2269,"language":139,"meta":17,"style":17},"/// This is a system that spawns an entity with a Position\n/// component and a RandomWalker component.\nfn setup_walker(mut commands: Commands) {\n  // spawn a new entity, and attach components to it\n  commands.spawn((\n    // attach a position\n    Position { x: 0, y: 0 },\n    // attach a RandomWalker marker\n    RandomWalker,\n  ));\n}\n",[141,2271,2272,2277,2282,2301,2306,2317,2322,2349,2354,2361,2366],{"__ignoreMap":17},[144,2273,2274],{"class":146,"line":147},[144,2275,2276],{"class":150},"/// This is a system that spawns an entity with a Position\n",[144,2278,2279],{"class":146,"line":18},[144,2280,2281],{"class":150},"/// component and a RandomWalker component.\n",[144,2283,2284,2286,2289,2291,2293,2295,2297,2299],{"class":146,"line":159},[144,2285,540],{"class":361},[144,2287,2288],{"class":182}," setup_walker",[144,2290,205],{"class":178},[144,2292,569],{"class":361},[144,2294,1174],{"class":174},[144,2296,452],{"class":178},[144,2298,1179],{"class":192},[144,2300,621],{"class":178},[144,2302,2303],{"class":146,"line":165},[144,2304,2305],{"class":150},"  // spawn a new entity, and attach components to it\n",[144,2307,2308,2311,2313,2315],{"class":146,"line":171},[144,2309,2310],{"class":174},"  commands",[144,2312,179],{"class":178},[144,2314,183],{"class":182},[144,2316,186],{"class":178},[144,2318,2319],{"class":146,"line":189},[144,2320,2321],{"class":150},"    // attach a position\n",[144,2323,2324,2327,2330,2333,2335,2337,2339,2342,2344,2346],{"class":146,"line":199},[144,2325,2326],{"class":192},"    Position",[144,2328,2329],{"class":178}," { ",[144,2331,2332],{"class":174},"x",[144,2334,452],{"class":178},[144,2336,524],{"class":229},[144,2338,233],{"class":178},[144,2340,2341],{"class":174},"y",[144,2343,452],{"class":178},[144,2345,524],{"class":229},[144,2347,2348],{"class":178}," },\n",[144,2350,2351],{"class":146,"line":241},[144,2352,2353],{"class":150},"    // attach a RandomWalker marker\n",[144,2355,2356,2359],{"class":146,"line":250},[144,2357,2358],{"class":192},"    RandomWalker",[144,2360,196],{"class":178},[144,2362,2363],{"class":146,"line":283},[144,2364,2365],{"class":178},"  ));\n",[144,2367,2368],{"class":146,"line":289},[144,2369,475],{"class":178},[10,2371,2372],{},"You can also add query arguments to systems.\nThis tells Bevy to inject the query into your system, giving you access to the query results at runtime.\nYou can even modify the values in the query, as shown here:",[135,2374,2376],{"className":137,"code":2375,"language":139,"meta":17,"style":17},"/// This is a system that iterates over ALL entities that\n/// have a Position component and are marked as RandomWalker.\nfn move_random_walkers(\n  mut q_walkers: Query\u003C&mut Position, With\u003CRandomWalker>>\n) {\n  for mut walker_position in q_walkers.iter_mut() {\n    // move the x and y position by a random amount\n    walker_position.x += pick_from(vec![-1, 0, 1]);\n    walker_position.y += pick_from(vec![-1, 0, 1]);\n\n    info!(\n      \"walker moved to ({}, {})\",\n      walker_position.x,\n      walker_position.y\n    );\n  }\n}\n",[141,2377,2378,2383,2388,2397,2425,2429,2450,2455,2490,2519,2523,2530,2538,2546,2553,2558,2562],{"__ignoreMap":17},[144,2379,2380],{"class":146,"line":147},[144,2381,2382],{"class":150},"/// This is a system that iterates over ALL entities that\n",[144,2384,2385],{"class":146,"line":18},[144,2386,2387],{"class":150},"/// have a Position component and are marked as RandomWalker.\n",[144,2389,2390,2392,2395],{"class":146,"line":159},[144,2391,540],{"class":361},[144,2393,2394],{"class":182}," move_random_walkers",[144,2396,247],{"class":178},[144,2398,2399,2401,2403,2405,2407,2409,2411,2413,2415,2417,2419,2422],{"class":146,"line":165},[144,2400,550],{"class":361},[144,2402,553],{"class":174},[144,2404,452],{"class":178},[144,2406,558],{"class":192},[144,2408,1138],{"class":178},[144,2410,569],{"class":361},[144,2412,2194],{"class":192},[144,2414,233],{"class":178},[144,2416,590],{"class":192},[144,2418,593],{"class":178},[144,2420,2421],{"class":192},"RandomWalker",[144,2423,2424],{"class":178},">>\n",[144,2426,2427],{"class":146,"line":171},[144,2428,621],{"class":178},[144,2430,2431,2433,2436,2439,2442,2444,2446,2448],{"class":146,"line":189},[144,2432,636],{"class":361},[144,2434,2435],{"class":361}," mut",[144,2437,2438],{"class":174}," walker_position",[144,2440,2441],{"class":361}," in",[144,2443,553],{"class":174},[144,2445,179],{"class":178},[144,2447,664],{"class":182},[144,2449,667],{"class":178},[144,2451,2452],{"class":146,"line":199},[144,2453,2454],{"class":150},"    // move the x and y position by a random amount\n",[144,2456,2457,2460,2463,2466,2469,2471,2474,2477,2479,2481,2483,2485,2487],{"class":146,"line":241},[144,2458,2459],{"class":174},"    walker_position",[144,2461,2462],{"class":178},".x ",[144,2464,2465],{"class":340},"+=",[144,2467,2468],{"class":182}," pick_from",[144,2470,205],{"class":178},[144,2472,2473],{"class":182},"vec!",[144,2475,2476],{"class":178},"[-",[144,2478,70],{"class":229},[144,2480,233],{"class":178},[144,2482,524],{"class":229},[144,2484,233],{"class":178},[144,2486,70],{"class":229},[144,2488,2489],{"class":178},"]);\n",[144,2491,2492,2494,2497,2499,2501,2503,2505,2507,2509,2511,2513,2515,2517],{"class":146,"line":250},[144,2493,2459],{"class":174},[144,2495,2496],{"class":178},".y ",[144,2498,2465],{"class":340},[144,2500,2468],{"class":182},[144,2502,205],{"class":178},[144,2504,2473],{"class":182},[144,2506,2476],{"class":178},[144,2508,70],{"class":229},[144,2510,233],{"class":178},[144,2512,524],{"class":229},[144,2514,233],{"class":178},[144,2516,70],{"class":229},[144,2518,2489],{"class":178},[144,2520,2521],{"class":146,"line":283},[144,2522,711],{"emptyLinePlaceholder":24},[144,2524,2525,2528],{"class":146,"line":289},[144,2526,2527],{"class":182},"    info!",[144,2529,247],{"class":178},[144,2531,2532,2536],{"class":146,"line":675},[144,2533,2535],{"class":2534},"subq3","      \"walker moved to ({}, {})\"",[144,2537,196],{"class":178},[144,2539,2540,2543],{"class":146,"line":681},[144,2541,2542],{"class":174},"      walker_position",[144,2544,2545],{"class":178},".x,\n",[144,2547,2548,2550],{"class":146,"line":708},[144,2549,2542],{"class":174},[144,2551,2552],{"class":178},".y\n",[144,2554,2555],{"class":146,"line":714},[144,2556,2557],{"class":178},"    );\n",[144,2559,2560],{"class":146,"line":720},[144,2561,802],{"class":178},[144,2563,2564],{"class":146,"line":743},[144,2565,475],{"class":178},[10,2567,2568,2569,2572,2573],{},"The implementation of ",[141,2570,2571],{},"pick_from"," (that picks a value from the given iterable at random) is left as an exercise for the reader.",[62,2574,2575],{},[45,2576,70],{"href":66,"ariaDescribedBy":2577,"dataFootnoteRef":17,"id":69},[68],[10,2579,2580],{},"To wire everything up, we must configure the systems to run at the appropriate times:",[135,2582,2584],{"className":137,"code":2583,"language":139,"meta":17,"style":17},"fn main() {\n  App::new()\n    // add minimal plugins & logging\n    .add_plugins((MinimalPlugins, LogPlugin::default()))\n    // schedule `setup_walker` to run once on startup\n    .add_systems(Startup, setup_walker)\n    // schedule `move_random_walkers` to run every update loop\n    .add_systems(Update, move_random_walkers)\n    .run();\n}\n",[141,2585,2586,2595,2607,2612,2638,2643,2662,2667,2685,2694],{"__ignoreMap":17},[144,2587,2588,2590,2593],{"class":146,"line":147},[144,2589,540],{"class":361},[144,2591,2592],{"class":182}," main",[144,2594,667],{"class":178},[144,2596,2597,2600,2602,2604],{"class":146,"line":18},[144,2598,2599],{"class":192},"  App",[144,2601,221],{"class":178},[144,2603,224],{"class":182},[144,2605,2606],{"class":178},"()\n",[144,2608,2609],{"class":146,"line":159},[144,2610,2611],{"class":150},"    // add minimal plugins & logging\n",[144,2613,2614,2617,2620,2622,2625,2627,2630,2632,2635],{"class":146,"line":165},[144,2615,2616],{"class":178},"    .",[144,2618,2619],{"class":182},"add_plugins",[144,2621,1526],{"class":178},[144,2623,2624],{"class":192},"MinimalPlugins",[144,2626,233],{"class":178},[144,2628,2629],{"class":192},"LogPlugin",[144,2631,221],{"class":178},[144,2633,2634],{"class":182},"default",[144,2636,2637],{"class":178},"()))\n",[144,2639,2640],{"class":146,"line":171},[144,2641,2642],{"class":150},"    // schedule `setup_walker` to run once on startup\n",[144,2644,2645,2647,2650,2652,2655,2657,2660],{"class":146,"line":189},[144,2646,2616],{"class":178},[144,2648,2649],{"class":182},"add_systems",[144,2651,205],{"class":178},[144,2653,2654],{"class":192},"Startup",[144,2656,233],{"class":178},[144,2658,2659],{"class":174},"setup_walker",[144,2661,527],{"class":178},[144,2663,2664],{"class":146,"line":199},[144,2665,2666],{"class":150},"    // schedule `move_random_walkers` to run every update loop\n",[144,2668,2669,2671,2673,2675,2678,2680,2683],{"class":146,"line":241},[144,2670,2616],{"class":178},[144,2672,2649],{"class":182},[144,2674,205],{"class":178},[144,2676,2677],{"class":192},"Update",[144,2679,233],{"class":178},[144,2681,2682],{"class":174},"move_random_walkers",[144,2684,527],{"class":178},[144,2686,2687,2689,2692],{"class":146,"line":250},[144,2688,2616],{"class":178},[144,2690,2691],{"class":182},"run",[144,2693,790],{"class":178},[144,2695,2696],{"class":146,"line":283},[144,2697,475],{"class":178},[10,2699,2700],{},"Running this Bevy app prints the following output:",[135,2702,2707],{"className":2703,"code":2705,"language":2706},[2704],"language-text","walker moved to (0 ,  0)\nwalker moved to (-1, -1)\nwalker moved to (-2, -1)\nwalker moved to (-2,  0)\nwalker moved to (-1, -1)\nwalker moved to (-2, -2)\n... and a whole bunch more\n","text",[141,2708,2705],{"__ignoreMap":17},[10,2710,2711],{},"As you can see, our setup system successfully initialised the walker, and the other function seems to properly mutate and log the walker's position every loop.",[115,2713,2714],{"id":2154},"Entities",[10,2716,2717],{},"In the text above, I've also introduced entities. Think of entities as things with their own identity—basically organized collections of components that work together.",[10,2719,2720,2721,2724],{},"It is important to note that you can't attach multiple components of the same type to a single entity—for example, you can't add two ",[141,2722,2723],{},"Position"," components to one entity.",[115,2726,2728],{"id":2727},"resources-and-events","Resources and events",[10,2730,2731,2732,1759,2735,179],{},"Bevy provides two additional core concepts beyond entities, components, and systems: ",[833,2733,2734],{},"resources",[833,2736,2737],{},"events",[10,2739,2740],{},"Resources function as \"singleton components\" not tied to any entity—you can think of them as global data where only one instance of each type can exist at a time.",[10,2742,2743],{},"Events are temporary pieces of data that aren't attached to entities.\nThey're ephemeral, lasting for just one update cycle before disappearing, making them perfect for communicating short-lived information across your game.",[84,2745,2747],{"id":2746},"networked-multiplayer-with-bevy-replicon","Networked multiplayer with Bevy Replicon",[10,2749,2750],{},"We're making good progress! We've learned how to model our game state using Bevy's ECS. But what happens when we need to share this state between players? That's where Bevy Replicon comes in...",[10,2752,2753,2754,2757,2758,2763],{},"Bevy Replicon allows you to ",[833,2755,2756],{},"replicate"," state between several Bevy apps over the network. ",[62,2759,2760],{},[45,2761,98],{"href":95,"ariaDescribedBy":2762,"dataFootnoteRef":17,"id":97},[68],"\nIn this setup, you'll run one Bevy app as the authoritative server and separate Bevy apps for each client.\nThe authoritative server can be a dedicated (headless) server, or it can be one of the clients.",[10,2765,2766],{},"Components are only replicated from the server to the clients, and never the other way around.",[10,2768,2769],{},"By default, nothing is replicated.\nFor a component to be replicated to clients, two things must be true:",[2055,2771,2772,2778],{},[984,2773,2774,2775,179],{},"The component type must be marked as replicated with ",[141,2776,2777],{},"app.replicate::\u003CMyComponent>()",[984,2779,2780,2781,2784],{},"The component must be attached to an entity that also has the ",[141,2782,2783],{},"Replicated"," component.",[10,2786,2787],{},"(I had to read that a few times before it clicked.)",[10,2789,2790,2791,2794],{},"Bevy Replicon also supports a ",[833,2792,2793],{},"single-player mode",", which doesn't do any networking, but emulates the architecture in a single Bevy app so you can easily play a game in offline mode as well.",[84,2796,2798],{"id":2797},"client-state-and-commands","Client state and commands",[10,2800,2801],{},"The client manages its own state and adds components like 3D meshes and materials to entities that it replicated from the server, giving them visual presence on screen.",[10,2803,2804],{},"When a player takes an action in the game (like clicking the mouse), a trigger is sent to the server so it can properly update the game state in a controlled way.",[10,2806,2807],{},"To be able to use a client trigger, both the server and the client must register it:",[135,2809,2811],{"className":137,"code":2810,"language":139,"meta":17,"style":17},"app\n  .add_client_trigger::\u003CMyClientTrigger>(ChannelKind::Ordered);\n",[141,2812,2813,2818],{"__ignoreMap":17},[144,2814,2815],{"class":146,"line":147},[144,2816,2817],{"class":174},"app\n",[144,2819,2820,2822,2825,2828,2831,2834,2837,2839,2842],{"class":146,"line":18},[144,2821,1037],{"class":178},[144,2823,2824],{"class":182},"add_client_trigger",[144,2826,2827],{"class":178},"::\u003C",[144,2829,2830],{"class":192},"MyClientTrigger",[144,2832,2833],{"class":178},">(",[144,2835,2836],{"class":192},"ChannelKind",[144,2838,221],{"class":178},[144,2840,2841],{"class":192},"Ordered",[144,2843,705],{"class":178},[10,2845,2846],{},"Then, it can be used from a client system this:",[135,2848,2850],{"className":137,"code":2849,"language":139,"meta":17,"style":17},"commands.client_trigger(MyClientTrigger { msg: \"Hi\" });\n",[141,2851,2852],{"__ignoreMap":17},[144,2853,2854,2856,2858,2861,2863,2865,2867,2870,2872,2875],{"class":146,"line":147},[144,2855,175],{"class":174},[144,2857,179],{"class":178},[144,2859,2860],{"class":182},"client_trigger",[144,2862,205],{"class":178},[144,2864,2830],{"class":192},[144,2866,2329],{"class":178},[144,2868,2869],{"class":174},"msg",[144,2871,452],{"class":178},[144,2873,2874],{"class":2534},"\"Hi\"",[144,2876,2877],{"class":178}," });\n",[10,2879,2880],{},"And picked from a server system like this:",[135,2882,2884],{"className":137,"code":2883,"language":139,"meta":17,"style":17},"fn process_trigger(\n  trigger: Trigger\u003CFromClient\u003CMyClientTrigger>>,\n) {\n  info!(\n    \"{} said '{}'\",\n    trigger.client_entity,\n    trigger.msg,\n  );\n}\n",[141,2885,2886,2895,2916,2920,2927,2934,2942,2949,2954],{"__ignoreMap":17},[144,2887,2888,2890,2893],{"class":146,"line":147},[144,2889,540],{"class":361},[144,2891,2892],{"class":182}," process_trigger",[144,2894,247],{"class":178},[144,2896,2897,2900,2902,2905,2907,2910,2912,2914],{"class":146,"line":18},[144,2898,2899],{"class":174},"  trigger",[144,2901,452],{"class":178},[144,2903,2904],{"class":192},"Trigger",[144,2906,593],{"class":178},[144,2908,2909],{"class":192},"FromClient",[144,2911,593],{"class":178},[144,2913,2830],{"class":192},[144,2915,598],{"class":178},[144,2917,2918],{"class":146,"line":159},[144,2919,621],{"class":178},[144,2921,2922,2925],{"class":146,"line":165},[144,2923,2924],{"class":182},"  info!",[144,2926,247],{"class":178},[144,2928,2929,2932],{"class":146,"line":171},[144,2930,2931],{"class":2534},"    \"{} said '{}'\"",[144,2933,196],{"class":178},[144,2935,2936,2939],{"class":146,"line":189},[144,2937,2938],{"class":174},"    trigger",[144,2940,2941],{"class":178},".client_entity,\n",[144,2943,2944,2946],{"class":146,"line":199},[144,2945,2938],{"class":174},[144,2947,2948],{"class":178},".msg,\n",[144,2950,2951],{"class":146,"line":241},[144,2952,2953],{"class":178},"  );\n",[144,2955,2956],{"class":146,"line":250},[144,2957,475],{"class":178},[10,2959,2960],{},"These triggers can also be set up the other way (from the server to the client).",[84,2962,2964],{"id":2963},"client-server-game-state-management","Client-server game state management",[10,2966,2967],{},"In broad strokes, the functionality that Bevy Replicon provides for me can be illustrated as follows:",[10,2969,2970],{},[2971,2972],"img",{":class":2973,"alt":2974,"src":2975},"diagram","a diagram that illustrates that entities are replicated from server to client and that triggers can go both ways","/images/replicon.svg",[10,2977,2978],{},"With this, I have all I need: I can have my dedicated (or emulated) server manage the shared game state, and have only the necessary data replicated to the client(s).",[10,2980,2981,2982,2985],{},"In my game, when a client connects, the server spawns a character entity and marks it as owned by that client with an ",[141,2983,2984],{},"OwnedBy(Entity)"," component that I created.\nThis way, the server can verify that commands for the character come from the right client.",[10,2987,2988,2989,2991,2992,2994],{},"Then—because I tagged the character with the ",[141,2990,2783],{}," marker and registered my ",[141,2993,2723],{}," component as a replicated component—the character automatically replicates to all active clients, where they also gain a visual presence on the screen.",[84,2996,2012],{"id":2011},[10,2998,2999],{},"Learning all this was quite a journey, but once everything clicked, I was amazed by how much heavy lifting Bevy Replicon handles behind the scenes.",[10,3001,3002],{},"The ECS pattern is simply so flexible and powerful that it allows for state synchronisation solutions like this.",[10,3004,3005],{},"I'm excited to watch Bevy's ecosystem continue to grow, and I'm eager to be part of this journey too.",[10,3007,3008,3009,233,3012,3015,3016,3020],{},"If you want to stay updated about my progress with this game, you can follow me on ",[45,3010,2034],{"href":2032,"rel":3011},[49],[45,3013,2040],{"href":2038,"rel":3014},[49]," (I share videos / screenshots there) or ",[45,3017,3019],{"href":3018},"/atom.xml","subscribe to my web feed"," (I might write more here).",[2047,3022,3024,3027],{"className":3023,"dataFootnotes":17},[2044],[84,3025,2045],{"className":3026,"id":68},[2053],[2055,3028,3029,3035],{},[984,3030,3031,3032],{"id":2059},"Ever since working through mathematics text books I've always wanted to write this. ",[45,3033,2073],{"href":2069,"ariaLabel":2070,"className":3034,"dataFootnoteBackref":17},[2072],[984,3036,3037,3038],{"id":2076},"Technically, it doesn't do any I/O without configuring a messaging backend & transport, but it provides the replication logic and a powerful API to be used from your Bevy app. ",[45,3039,2073],{"href":2084,"ariaLabel":2085,"className":3040,"dataFootnoteBackref":17},[2072],[2088,3042,3043],{},"html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sU0A5, html code.shiki .sU0A5{--shiki-default:#E5C07B}html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}",{"title":17,"searchDepth":18,"depth":18,"links":3045},[3046,3052,3053,3054,3055,3056],{"id":2136,"depth":18,"text":2137,"children":3047},[3048,3049,3050,3051],{"id":2158,"depth":159,"text":2165},{"id":2150,"depth":159,"text":2263},{"id":2154,"depth":159,"text":2714},{"id":2727,"depth":159,"text":2728},{"id":2746,"depth":18,"text":2747},{"id":2797,"depth":18,"text":2798},{"id":2963,"depth":18,"text":2964},{"id":2011,"depth":18,"text":2012},{"id":68,"depth":18,"text":2045},"2025-03-19","A crash course on Bevy ECS, and a practical guide to implementing multiplayer using Bevy Replicon.",{},"/posts/adding-networked-multiplayer-to-my-game-with-bevy-replicon",{"title":2113,"description":3058},"2.posts/20250319.Adding networked multiplayer to my game with Bevy Replicon","gsGxGNPoVseVK6NNAt8GvHnN_8rA1O36kGwff2VMjMY",{"id":3065,"title":3066,"body":3067,"created":3963,"description":3964,"extension":22,"meta":3965,"navigation":24,"path":3966,"seo":3967,"stem":3968,"updated":20,"__hash__":3969},"posts/2.posts/20250207.Getting Footnotes Right in the Feeds.md","Getting Footnotes Right in the Feeds",{"type":7,"value":3068,"toc":3955},[3069,3084,3103,3107,3122,3151,3155,3158,3161,3185,3188,3252,3264,3268,3277,3284,3287,3308,3315,3318,3584,3603,3614,3622,3625,3647,3656,3884,3890,3894,3909,3912,3930,3932,3935,3938,3952],[10,3070,3071,3072,3077,3078,3083],{},"While upgrading my blog from Nuxt Content 2 to ",[45,3073,3076],{"href":3074,"rel":3075},"https://content.nuxt.com/blog/v3",[49],"Nuxt Content 3",", I made some improvements to my web feed.\nPreviously, the footnotes",[62,3079,3080],{},[45,3081,70],{"href":66,"ariaDescribedBy":3082,"dataFootnoteRef":17,"id":69},[68]," that I added to my posts were not working properly in feed readers, and I wanted to fix that.",[10,3085,3086,3087,3092,3093,3096,3097,3102],{},"Specifically, I noticed how footnotes in John Siracusa's ",[45,3088,3091],{"href":3089,"rel":3090},"https://hypercritical.co/",[49],"Hypercritical"," feed ",[833,3094,3095],{},"did"," work nicely in my feed reader of choice (",[45,3098,3101],{"href":3099,"rel":3100},"https://netnewswire.com",[49],"NetNewsWire","), and it has been a great help in fixing my own feed.",[84,3104,3106],{"id":3105},"feeds-and-feed-readers","Feeds and feed readers",[10,3108,3109,3110,3115,3116,3121],{},"For the small number of people who read my blog, it's also possible to do that using a feed reader.\nI publish an ",[45,3111,3114],{"href":3112,"rel":3113},"https://en.wikipedia.org/wiki/Atom_(web_standard)",[49],"Atom feed",", which is an XML format for web feeds, similar to the ",[45,3117,3120],{"href":3118,"rel":3119},"https://en.wikipedia.org/wiki/RSS",[49],"RSS feed",".\nLike RSS, Atom is widely supported, but it is newer and has some additional features.",[10,3123,3124,3125,3128,3129,233,3134,233,3139,3144,3145,3150],{},"Personally, I use ",[45,3126,3101],{"href":3099,"rel":3127},[49]," as a feed reader.\nThere's also others such as ",[45,3130,3133],{"href":3131,"rel":3132},"https://reederapp.com",[49],"Reeder",[45,3135,3138],{"href":3136,"rel":3137},"https://readwise.io/read",[49],"Readwise Reader",[45,3140,3143],{"href":3141,"rel":3142},"https://feedly.com",[49],"Feedly",", or ",[45,3146,3149],{"href":3147,"rel":3148},"https://feedbin.com",[49],"Feedbin",", each with their own set of features (and not all of them support footnotes).",[84,3152,3154],{"id":3153},"footnotes-in-feed-readers","Footnotes in feed readers",[10,3156,3157],{},"So, what do I mean with footnotes working nicely in a feed reader?",[10,3159,3160],{},"There are two features that I require:",[981,3162,3163,3172],{},[984,3164,3165,3168,3169,179],{},[833,3166,3167],{},"Footnote pop-ups",": When tapping a footnote link, the footnote should appear as a small pop-up at the position of the footnote link in the text, and ",[833,3170,3171],{},"not scroll down to the footnote list",[984,3173,3174,3177,3178,3181,3182,179],{},[833,3175,3176],{},"Backlinks",": At the bottom of the article, all footnotes should be listed and they should link ",[833,3179,3180],{},"back"," to where the footnote links are in the text, and ",[833,3183,3184],{},"not open the link in a web view",[10,3186,3187],{},"This table gives an overview of the extent of support for these features in several feed readers:",[3189,3190,3191,3204],"table",{},[3192,3193,3194],"thead",{},[3195,3196,3197,3200,3202],"tr",{},[3198,3199],"th",{},[3198,3201,3167],{},[3198,3203,3176],{},[3205,3206,3207,3217,3226,3234,3244],"tbody",{},[3195,3208,3209,3212,3215],{},[3210,3211,3101],"td",{},[3210,3213,3214],{},"✅",[3210,3216,3214],{},[3195,3218,3219,3221,3224],{},[3210,3220,3133],{},[3210,3222,3223],{},"❌",[3210,3225,3223],{},[3195,3227,3228,3230,3232],{},[3210,3229,3149],{},[3210,3231,3214],{},[3210,3233,3223],{},[3195,3235,3236,3238,3241],{},[3210,3237,3138],{},[3210,3239,3240],{},"⏳",[3210,3242,3243],{},"⏳?",[3195,3245,3246,3248,3250],{},[3210,3247,3143],{},[3210,3249,3223],{},[3210,3251,3223],{},[10,3253,3254,3255,3258,3259,3261,3262],{},"✅: Supported",[3256,3257],"br",{},"\n❌: Not supported",[3256,3260],{},"\n⏳: On their backlog",[3256,3263],{},[84,3265,3267],{"id":3266},"generating-the-feed","Generating the feed",[10,3269,3270,3271,3276],{},"I write my articles in Markdown, and ",[45,3272,3275],{"href":3273,"rel":3274},"https://content.nuxt.com",[49],"Nuxt Content"," generates HTML for each of my articles.\nFor the website, that HTML is simply rendered in a web app.",[10,3278,3279,3280,3283],{},"However, the web feed requires ",[833,3281,3282],{},"all"," of the articles to be captured in a single XML file, along with some metadata.",[10,3285,3286],{},"So what's in the feed?",[981,3288,3289,3297],{},[984,3290,3291,3292],{},"Some metadata about the feed itself:\n",[981,3293,3294],{},[984,3295,3296],{},"Author, language, favicon, copyright, last updated date, etc.",[984,3298,3299,3300],{},"For each article (called \"entry\" in Atom):\n",[981,3301,3302,3305],{},[984,3303,3304],{},"Entry title, publish date, author, description, link to web page, etc.",[984,3306,3307],{},"And most importantly, the content of the entry: The 'raw' HTML that is presented in the feed reader app.",[10,3309,3310,3311,3314],{},"Unfortunately, it is currently not trivial to generate an Atom feed using Nuxt Content.\nNuxt Content ",[833,3312,3313],{},"does"," provide the HTML abstract syntax tree for the entries, which I can process to generate the HTML that goes in the entry's content.",[10,3316,3317],{},"However, the HTML that Nuxt Content provides does not make my footnotes work properly.\nThis is how the HTML is provided (I added some whitespace & comments to make it more readable):",[135,3319,3323],{"className":3320,"code":3321,"language":3322,"meta":17,"style":17},"language-html shiki shiki-themes one-dark-pro","\u003C!-- a footnote link that appears in\n     the article's main content -->\n\u003Csup>\n    \u003Ca\n        href=\"#user-content-fn-1\"\n        aria-describedby=\"footnote-label\"\n        data-footnote-ref\n        id=\"user-content-fnref-1\"\n    >1\u003C/a>\n\u003C/sup>\n\n\u003C!-- other content -->\n\n\u003Csection class=\"footnotes\" data-footnotes>\n    \u003Ch2 class=\"sr-only\" id=\"footnote-label\">\n        Footnotes\n    \u003C/h2>\n    \u003Col>\n        \u003Cli id=\"user-content-fn-1\">\n            This is the footnote.\n            \u003Ca\n                href=\"#user-content-fnref-1\"\n                aria-label=\"Back to reference 1\"\n                class=\"data-footnote-backref\"\n                data-footnote-backref\n            >↩\u003C/a>\n        \u003C/li>\n        \u003C!-- other footnotes -->\n    \u003C/ol>\n\u003C/section>\n","html",[141,3324,3325,3330,3335,3344,3352,3362,3372,3377,3387,3396,3405,3409,3414,3418,3437,3460,3465,3474,3482,3498,3503,3510,3520,3530,3540,3545,3554,3563,3568,3576],{"__ignoreMap":17},[144,3326,3327],{"class":146,"line":147},[144,3328,3329],{"class":150},"\u003C!-- a footnote link that appears in\n",[144,3331,3332],{"class":146,"line":18},[144,3333,3334],{"class":150},"     the article's main content -->\n",[144,3336,3337,3339,3341],{"class":146,"line":159},[144,3338,593],{"class":178},[144,3340,62],{"class":174},[144,3342,3343],{"class":178},">\n",[144,3345,3346,3349],{"class":146,"line":165},[144,3347,3348],{"class":178},"    \u003C",[144,3350,3351],{"class":174},"a\n",[144,3353,3354,3357,3359],{"class":146,"line":171},[144,3355,3356],{"class":229},"        href",[144,3358,1242],{"class":178},[144,3360,3361],{"class":2534},"\"#user-content-fn-1\"\n",[144,3363,3364,3367,3369],{"class":146,"line":189},[144,3365,3366],{"class":229},"        aria-describedby",[144,3368,1242],{"class":178},[144,3370,3371],{"class":2534},"\"footnote-label\"\n",[144,3373,3374],{"class":146,"line":199},[144,3375,3376],{"class":229},"        data-footnote-ref\n",[144,3378,3379,3382,3384],{"class":146,"line":241},[144,3380,3381],{"class":229},"        id",[144,3383,1242],{"class":178},[144,3385,3386],{"class":2534},"\"user-content-fnref-1\"\n",[144,3388,3389,3392,3394],{"class":146,"line":250},[144,3390,3391],{"class":178},"    >1\u003C/",[144,3393,45],{"class":174},[144,3395,3343],{"class":178},[144,3397,3398,3401,3403],{"class":146,"line":283},[144,3399,3400],{"class":178},"\u003C/",[144,3402,62],{"class":174},[144,3404,3343],{"class":178},[144,3406,3407],{"class":146,"line":289},[144,3408,711],{"emptyLinePlaceholder":24},[144,3410,3411],{"class":146,"line":675},[144,3412,3413],{"class":150},"\u003C!-- other content -->\n",[144,3415,3416],{"class":146,"line":681},[144,3417,711],{"emptyLinePlaceholder":24},[144,3419,3420,3422,3424,3427,3429,3432,3435],{"class":146,"line":708},[144,3421,593],{"class":178},[144,3423,2047],{"class":174},[144,3425,3426],{"class":229}," class",[144,3428,1242],{"class":178},[144,3430,3431],{"class":2534},"\"footnotes\"",[144,3433,3434],{"class":229}," data-footnotes",[144,3436,3343],{"class":178},[144,3438,3439,3441,3443,3445,3447,3450,3453,3455,3458],{"class":146,"line":714},[144,3440,3348],{"class":178},[144,3442,84],{"class":174},[144,3444,3426],{"class":229},[144,3446,1242],{"class":178},[144,3448,3449],{"class":2534},"\"sr-only\"",[144,3451,3452],{"class":229}," id",[144,3454,1242],{"class":178},[144,3456,3457],{"class":2534},"\"footnote-label\"",[144,3459,3343],{"class":178},[144,3461,3462],{"class":146,"line":720},[144,3463,3464],{"class":178},"        Footnotes\n",[144,3466,3467,3470,3472],{"class":146,"line":743},[144,3468,3469],{"class":178},"    \u003C/",[144,3471,84],{"class":174},[144,3473,3343],{"class":178},[144,3475,3476,3478,3480],{"class":146,"line":767},[144,3477,3348],{"class":178},[144,3479,2055],{"class":174},[144,3481,3343],{"class":178},[144,3483,3484,3487,3489,3491,3493,3496],{"class":146,"line":773},[144,3485,3486],{"class":178},"        \u003C",[144,3488,984],{"class":174},[144,3490,3452],{"class":229},[144,3492,1242],{"class":178},[144,3494,3495],{"class":2534},"\"user-content-fn-1\"",[144,3497,3343],{"class":178},[144,3499,3500],{"class":146,"line":779},[144,3501,3502],{"class":178},"            This is the footnote.\n",[144,3504,3505,3508],{"class":146,"line":793},[144,3506,3507],{"class":178},"            \u003C",[144,3509,3351],{"class":174},[144,3511,3512,3515,3517],{"class":146,"line":799},[144,3513,3514],{"class":229},"                href",[144,3516,1242],{"class":178},[144,3518,3519],{"class":2534},"\"#user-content-fnref-1\"\n",[144,3521,3522,3525,3527],{"class":146,"line":805},[144,3523,3524],{"class":229},"                aria-label",[144,3526,1242],{"class":178},[144,3528,3529],{"class":2534},"\"Back to reference 1\"\n",[144,3531,3532,3535,3537],{"class":146,"line":1329},[144,3533,3534],{"class":229},"                class",[144,3536,1242],{"class":178},[144,3538,3539],{"class":2534},"\"data-footnote-backref\"\n",[144,3541,3542],{"class":146,"line":1350},[144,3543,3544],{"class":229},"                data-footnote-backref\n",[144,3546,3547,3550,3552],{"class":146,"line":1370},[144,3548,3549],{"class":178},"            >↩\u003C/",[144,3551,45],{"class":174},[144,3553,3343],{"class":178},[144,3555,3556,3559,3561],{"class":146,"line":1376},[144,3557,3558],{"class":178},"        \u003C/",[144,3560,984],{"class":174},[144,3562,3343],{"class":178},[144,3564,3565],{"class":146,"line":1382},[144,3566,3567],{"class":150},"        \u003C!-- other footnotes -->\n",[144,3569,3570,3572,3574],{"class":146,"line":1388},[144,3571,3469],{"class":178},[144,3573,2055],{"class":174},[144,3575,3343],{"class":178},[144,3577,3578,3580,3582],{"class":146,"line":1411},[144,3579,3400],{"class":178},[144,3581,2047],{"class":174},[144,3583,3343],{"class":178},[10,3585,3586,3587,3590,3591,3594,3595,3598,3599,3602],{},"Now, observing the ",[141,3588,3589],{},"href","s, ",[141,3592,3593],{},"aria-*"," attributes, and ",[141,3596,3597],{},"data-*"," attributes, it is clear that ",[833,3600,3601],{},"some"," effort has been put into footnotes by Nuxt Content's Markdown processor.",[10,3604,3605,3606,3609,3610,3613],{},"However, when I load this into ",[45,3607,3101],{"href":3099,"rel":3608},[49],", clicking the link to the footnote ",[833,3611,3612],{},"takes me to a web browser"," where the web page is loaded!",[10,3615,3616,3617,3621],{},"And that made me wonder: How do I make it work like ",[45,3618,3091],{"href":3619,"rel":3620},"https://hypercritical.co",[49],"'s feed does?",[10,3623,3624],{},"It turned out that I needed to change 2 things:",[981,3626,3627,3638],{},[984,3628,3629,3630,3633,3634,3637],{},"Link to the footnotes with ",[141,3631,3632],{},"#fn:1"," (and ",[141,3635,3636],{},"#fn:2"," and so on).",[984,3639,3640,3641,3633,3644,3637],{},"Backlink from the footnotes with ",[141,3642,3643],{},"#fnref:1",[141,3645,3646],{},"#fnref:2",[10,3648,3649,3650,3655],{},"Doing this transformation while generating the feed (see ",[45,3651,3654],{"href":3652,"rel":3653},"https://github.com/HanKruiger/hankruiger.com/blob/main/server/routes/atom.xml.get.ts",[49],"my implementation",") results in the following HTML:",[135,3657,3659],{"className":3320,"code":3658,"language":3322,"meta":17,"style":17},"\u003C!-- a footnote link that appears in\n     the article's main content -->\n\u003Csup>\n    \u003Ca\n        href=\"#fn:1\"\n        aria-described-by=\"footnote-label\"\n        data-footnote-ref\n        id=\"fnref:1\"\n    >1\u003C/a>\n\u003C/sup>\n\n\u003C!-- other content -->\n\n\u003Csection class=\"footnotes\" data-footnotes>\n    \u003Ch2 class=\"sr-only\" id=\"footnote-label\">\n        Footnotes\n    \u003C/h2>\n    \u003Col>\n        \u003Cli id=\"fn:1\">\n            This is the footnote.\n            \u003Ca\n                href=\"#fnref:1\"\n                aria-label=\"Back to reference 1\"\n                class=\"data-footnote-backref\"\n                data-footnote-backref\n            >↩\u003C/a>\n        \u003C/li>\n    \u003C/ol>\n\u003C/section>\n",[141,3660,3661,3665,3669,3677,3683,3692,3701,3705,3714,3722,3730,3734,3738,3742,3758,3778,3782,3790,3798,3813,3817,3823,3832,3840,3848,3852,3860,3868,3876],{"__ignoreMap":17},[144,3662,3663],{"class":146,"line":147},[144,3664,3329],{"class":150},[144,3666,3667],{"class":146,"line":18},[144,3668,3334],{"class":150},[144,3670,3671,3673,3675],{"class":146,"line":159},[144,3672,593],{"class":178},[144,3674,62],{"class":174},[144,3676,3343],{"class":178},[144,3678,3679,3681],{"class":146,"line":165},[144,3680,3348],{"class":178},[144,3682,3351],{"class":174},[144,3684,3685,3687,3689],{"class":146,"line":171},[144,3686,3356],{"class":229},[144,3688,1242],{"class":178},[144,3690,3691],{"class":2534},"\"#fn:1\"\n",[144,3693,3694,3697,3699],{"class":146,"line":189},[144,3695,3696],{"class":229},"        aria-described-by",[144,3698,1242],{"class":178},[144,3700,3371],{"class":2534},[144,3702,3703],{"class":146,"line":199},[144,3704,3376],{"class":229},[144,3706,3707,3709,3711],{"class":146,"line":241},[144,3708,3381],{"class":229},[144,3710,1242],{"class":178},[144,3712,3713],{"class":2534},"\"fnref:1\"\n",[144,3715,3716,3718,3720],{"class":146,"line":250},[144,3717,3391],{"class":178},[144,3719,45],{"class":174},[144,3721,3343],{"class":178},[144,3723,3724,3726,3728],{"class":146,"line":283},[144,3725,3400],{"class":178},[144,3727,62],{"class":174},[144,3729,3343],{"class":178},[144,3731,3732],{"class":146,"line":289},[144,3733,711],{"emptyLinePlaceholder":24},[144,3735,3736],{"class":146,"line":675},[144,3737,3413],{"class":150},[144,3739,3740],{"class":146,"line":681},[144,3741,711],{"emptyLinePlaceholder":24},[144,3743,3744,3746,3748,3750,3752,3754,3756],{"class":146,"line":708},[144,3745,593],{"class":178},[144,3747,2047],{"class":174},[144,3749,3426],{"class":229},[144,3751,1242],{"class":178},[144,3753,3431],{"class":2534},[144,3755,3434],{"class":229},[144,3757,3343],{"class":178},[144,3759,3760,3762,3764,3766,3768,3770,3772,3774,3776],{"class":146,"line":714},[144,3761,3348],{"class":178},[144,3763,84],{"class":174},[144,3765,3426],{"class":229},[144,3767,1242],{"class":178},[144,3769,3449],{"class":2534},[144,3771,3452],{"class":229},[144,3773,1242],{"class":178},[144,3775,3457],{"class":2534},[144,3777,3343],{"class":178},[144,3779,3780],{"class":146,"line":720},[144,3781,3464],{"class":178},[144,3783,3784,3786,3788],{"class":146,"line":743},[144,3785,3469],{"class":178},[144,3787,84],{"class":174},[144,3789,3343],{"class":178},[144,3791,3792,3794,3796],{"class":146,"line":767},[144,3793,3348],{"class":178},[144,3795,2055],{"class":174},[144,3797,3343],{"class":178},[144,3799,3800,3802,3804,3806,3808,3811],{"class":146,"line":773},[144,3801,3486],{"class":178},[144,3803,984],{"class":174},[144,3805,3452],{"class":229},[144,3807,1242],{"class":178},[144,3809,3810],{"class":2534},"\"fn:1\"",[144,3812,3343],{"class":178},[144,3814,3815],{"class":146,"line":779},[144,3816,3502],{"class":178},[144,3818,3819,3821],{"class":146,"line":793},[144,3820,3507],{"class":178},[144,3822,3351],{"class":174},[144,3824,3825,3827,3829],{"class":146,"line":799},[144,3826,3514],{"class":229},[144,3828,1242],{"class":178},[144,3830,3831],{"class":2534},"\"#fnref:1\"\n",[144,3833,3834,3836,3838],{"class":146,"line":805},[144,3835,3524],{"class":229},[144,3837,1242],{"class":178},[144,3839,3529],{"class":2534},[144,3841,3842,3844,3846],{"class":146,"line":1329},[144,3843,3534],{"class":229},[144,3845,1242],{"class":178},[144,3847,3539],{"class":2534},[144,3849,3850],{"class":146,"line":1350},[144,3851,3544],{"class":229},[144,3853,3854,3856,3858],{"class":146,"line":1370},[144,3855,3549],{"class":178},[144,3857,45],{"class":174},[144,3859,3343],{"class":178},[144,3861,3862,3864,3866],{"class":146,"line":1376},[144,3863,3558],{"class":178},[144,3865,984],{"class":174},[144,3867,3343],{"class":178},[144,3869,3870,3872,3874],{"class":146,"line":1382},[144,3871,3469],{"class":178},[144,3873,2055],{"class":174},[144,3875,3343],{"class":178},[144,3877,3878,3880,3882],{"class":146,"line":1388},[144,3879,3400],{"class":178},[144,3881,2047],{"class":174},[144,3883,3343],{"class":178},[10,3885,3886,3887,179],{},"And, lo and behold, this makes the footnote behave perfectly in ",[45,3888,3101],{"href":3099,"rel":3889},[49],[84,3891,3893],{"id":3892},"how-do-feed-readers-process-this","How do feed readers process this?",[10,3895,3896,3897,3902,3903,3908],{},"Just to find out how my feed reader of choice actually processes this, I found the ",[45,3898,3901],{"href":3899,"rel":3900},"https://github.com/Ranchero-Software/NetNewsWire/blob/05c27b188c17194e92d412ebea9d49791e9ad49d/Shared/ArticleRendering/main.js#L147",[49],"footnote linking logic"," and the ",[45,3904,3907],{"href":3905,"rel":3906},"https://github.com/Ranchero-Software/NetNewsWire/blob/05c27b188c17194e92d412ebea9d49791e9ad49d/Shared/ArticleRendering/newsfoot.js#L160",[49],"backlink logic"," in their source code.",[10,3910,3911],{},"It appears like there's multiple conventions, so it's kind of a mess.\nIt would be nice if there'd by a standardised way of doing footnotes.",[10,3913,3914,3915,1759,3920,3925,3926,3929],{},"There are W3C-recommended ARIA roles called ",[45,3916,3919],{"href":3917,"rel":3918},"https://www.w3.org/TR/dpub-aria-1.0/#doc-footnote",[49],"doc-footnote",[45,3921,3924],{"href":3922,"rel":3923},"https://www.w3.org/TR/dpub-aria-1.0/#doc-backlink",[49],"doc-backlink"," that appear to be intended for this purpose, but they don't seem to be supported in ",[833,3927,3928],{},"any"," feed reader app to my knowledge.",[84,3931,2012],{"id":2011},[10,3933,3934],{},"This was a bit of a pain to properly set up, but now I'm glad that it works.",[10,3936,3937],{},"Let me know if you have any other insights into this, I would be happy to hear it.",[2047,3939,3941,3944],{"className":3940,"dataFootnotes":17},[2044],[84,3942,2045],{"className":3943,"id":68},[2053],[2055,3945,3946],{},[984,3947,3948,3949],{"id":2059},"This is an example of such a footnote. It allows the author to add additional information to their content, without cluttering the main text. Notice how the footnote (when viewing them at the bottom of the article) also links back to the position in the main text where it is linked. ",[45,3950,2073],{"href":2069,"ariaLabel":2070,"className":3951,"dataFootnoteBackref":17},[2072],[2088,3953,3954],{},"html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":17,"searchDepth":18,"depth":18,"links":3956},[3957,3958,3959,3960,3961,3962],{"id":3105,"depth":18,"text":3106},{"id":3153,"depth":18,"text":3154},{"id":3266,"depth":18,"text":3267},{"id":3892,"depth":18,"text":3893},{"id":2011,"depth":18,"text":2012},{"id":68,"depth":18,"text":2045},"2025-02-07","How to generate HTML to make footnotes compatible with RSS and Atom feed readers.",{},"/posts/getting-footnotes-right-in-the-feeds",{"title":3066,"description":3964},"2.posts/20250207.Getting Footnotes Right in the Feeds","pbpI6cUw9OHbCEHO3fxHv4ohHb5AmlAIMbyVuB40rXk",{"id":3971,"title":3972,"body":3973,"created":4350,"description":4351,"extension":22,"meta":4352,"navigation":24,"path":4353,"seo":4354,"stem":4355,"updated":20,"__hash__":4356},"posts/2.posts/20240306.Inserting into a Notion DB from Shortcuts.md","Inserting into a Notion Database from Shortcuts on iOS and iPadOS",{"type":7,"value":3974,"toc":4344},[3975,3978,3981,3988,3995,3998,4002,4014,4021,4028,4031,4050,4133,4136,4175,4181,4185,4188,4199,4210,4214,4217,4223,4270,4277,4283,4294,4308,4317,4328,4332,4335,4338,4341],[10,3976,3977],{},"For about 5 years now, I keep a log of movies I watch in a Notion database, and I wanted to make it a little easier to add an entry.",[10,3979,3980],{},"Initially, I would open Notion, go to the database, and add the entry.",[10,3982,3983,3984,3987],{},"A while ago, I made a simple link opener in Shortcuts that took me to the database directly.\nThis shortcut only had a single ",[833,3985,3986],{},"Open URL"," action that I configured to open my database's page in Notion.",[10,3989,3990,3991,3994],{},"However, after running this shortcut, I still had to tap that ",[833,3992,3993],{},"New"," button to add my entry, and I had to fill in today's date, in addition to other details such as the name of the movie.",[10,3996,3997],{},"Today I made this process a little easier by using the Notion API from Shortcuts on iOS and iPadOS.",[84,3999,4001],{"id":4000},"steps","Steps",[10,4003,4004,4005,4010,4011,179],{},"First off, I had to create the Notion integration on ",[45,4006,4009],{"href":4007,"rel":4008},"https://www.notion.so/my-integrations",[49],"the Notion web page for it",".\nHere, I could create a secret key, and give the integration the appropriate capability: ",[833,4012,4013],{},"Insert content",[10,4015,4016,4017,4020],{},"Then, I had to enable the integration for the specific database that I wanted it to integrate with.\nThis was easily done by going to the Notion database, tapping the three dots, and adding the integration in the ",[833,4018,4019],{},"Connections"," section.",[10,4022,4023,4024,4027],{},"After setting this up, I needed one more thing before creating the shortcut: The database ID.\nYou can find this in the URL that you get when tapping ",[833,4025,4026],{},"Copy link"," on the database.\nFrom that URL, you need the final section of the path (before the query parameters).\nThis is known as the 'page ID' of the Notion page (which in this case is a database).",[10,4029,4030],{},"Finally, I could start building the shortcut.\nIt contains two important actions: The HTTP request that inserts the new entry into the database, and a step to open the newly created page.",[10,4032,4033,4034,4037,4038,4041,4042,4045,4046,4049],{},"To do the HTTP request, I could simply use a ",[833,4035,4036],{},"Get contents of URL"," action.\nI could configure this action to do a HTTP POST request to ",[141,4039,4040],{},"https://api.notion.com/v1/pages",", with the appropriate headers (",[141,4043,4044],{},"Authorization"," with the secret key and ",[141,4047,4048],{},"Notion-Version"," for the API version), and with the following JSON body:",[135,4051,4055],{"className":4052,"code":4053,"language":4054,"meta":17,"style":17},"language-json shiki shiki-themes one-dark-pro","{\n  \"parent\": {\n    \"database_id\": \"{the database id}\"\n  },\n  \"properties\": {\n    \"When\": {\n      \"date\": {\n        \"start\": \"{today's date}\"\n      }\n    }\n  }\n}\n","json",[141,4056,4057,4062,4070,4080,4085,4092,4099,4106,4116,4121,4125,4129],{"__ignoreMap":17},[144,4058,4059],{"class":146,"line":147},[144,4060,4061],{"class":178},"{\n",[144,4063,4064,4067],{"class":146,"line":18},[144,4065,4066],{"class":174},"  \"parent\"",[144,4068,4069],{"class":178},": {\n",[144,4071,4072,4075,4077],{"class":146,"line":159},[144,4073,4074],{"class":174},"    \"database_id\"",[144,4076,452],{"class":178},[144,4078,4079],{"class":2534},"\"{the database id}\"\n",[144,4081,4082],{"class":146,"line":165},[144,4083,4084],{"class":178},"  },\n",[144,4086,4087,4090],{"class":146,"line":171},[144,4088,4089],{"class":174},"  \"properties\"",[144,4091,4069],{"class":178},[144,4093,4094,4097],{"class":146,"line":189},[144,4095,4096],{"class":174},"    \"When\"",[144,4098,4069],{"class":178},[144,4100,4101,4104],{"class":146,"line":199},[144,4102,4103],{"class":174},"      \"date\"",[144,4105,4069],{"class":178},[144,4107,4108,4111,4113],{"class":146,"line":241},[144,4109,4110],{"class":174},"        \"start\"",[144,4112,452],{"class":178},[144,4114,4115],{"class":2534},"\"{today's date}\"\n",[144,4117,4118],{"class":146,"line":250},[144,4119,4120],{"class":178},"      }\n",[144,4122,4123],{"class":146,"line":283},[144,4124,796],{"class":178},[144,4126,4127],{"class":146,"line":289},[144,4128,802],{"class":178},[144,4130,4131],{"class":146,"line":675},[144,4132,475],{"class":178},[10,4134,4135],{},"A few clarifications:",[981,4137,4138,4145,4151,4161,4164],{},[984,4139,4140,4141,4144],{},"The ",[141,4142,4143],{},"\"parent\""," property denotes where this action will try to insert the new page in.",[984,4146,4140,4147,4150],{},[141,4148,4149],{},"\"properties\""," property denotes the properties that the new page will have in the database. This is exactly like the column values for a row in a table.",[984,4152,4153,4156,4157,4160],{},[141,4154,4155],{},"\"When\""," is the name of the property in my database that I use for tracking when I watched the movie. I don't use Notion's built-in ",[141,4158,4159],{},"\"Created time\""," property because I sometimes add entries days after I have watched a movie.",[984,4162,4163],{},"The date itself is set to today's date in the shortcut, so that I don't have to enter it manually in Notion. More on this below.",[984,4165,4166,4167,4170,4171,4174],{},"In Notion, dates can optionally have an ",[141,4168,4169],{},"\"end\""," value, which would turn them into date ranges. To denote a single date, one has to only use the ",[141,4172,4173],{},"\"start\""," property.",[10,4176,4177,4178,4180],{},"After all this, to open the newly created page I would only need to find out the URL of the page and open it using a ",[833,4179,3986],{}," action. (More on that below.)",[84,4182,4184],{"id":4183},"strange-encounter-1-shortcuts-bug","Strange encounter #1 (Shortcuts bug)",[10,4186,4187],{},"The first strange encounter I had was with the Shortcuts app and selecting the date format in the nested JSON object I was building.",[10,4189,4190,4191,4194,4195,4198],{},"Remember that I wanted to pre-fill the date of my entry with today's date.\nI was able to use Shortcut's ",[833,4192,4193],{},"Current Date"," variable in the JSON object, but when trying to edit the date format (it needs to be ",[141,4196,4197],{},"YYYY-MM-DD",") the dialog would bug out and take me back to some other level of the JSON object. Very annoying. (Yes, this also consistently happened after turning Shortcuts off and on again.)",[10,4200,4201,4202,4205,4206,4209],{},"To work around this, I added a ",[833,4203,4204],{},"Set Variable"," action before the HTTP request that set today's date into a variable.\nI ",[833,4207,4208],{},"was"," able to select the right format here.\nThen, I could use that variable in the JSON object. 🤷‍♂️",[84,4211,4213],{"id":4212},"strange-encounter-2-notion-page-id-hyphen-behaviour","Strange encounter #2 (Notion page ID hyphen behaviour)",[10,4215,4216],{},"After adding the (partially filled) entry to the Notion database, I wanted the shortcut to take me to the page in Notion so that I could fill out the rest of the entry.",[10,4218,4219,4220,4222],{},"From the ",[833,4221,4036],{}," action of the shortcut, I got the following JSON response from the POST request:",[135,4224,4226],{"className":4052,"code":4225,"language":4054,"meta":17,"style":17},"{\n  \"id\": \"59833787-2cf9-4fdf-8782-e53db20768a5\",\n  \"object\": \"page\",\n  \"request_id\": \"{some other id}\"\n}\n",[141,4227,4228,4232,4244,4256,4266],{"__ignoreMap":17},[144,4229,4230],{"class":146,"line":147},[144,4231,4061],{"class":178},[144,4233,4234,4237,4239,4242],{"class":146,"line":18},[144,4235,4236],{"class":174},"  \"id\"",[144,4238,452],{"class":178},[144,4240,4241],{"class":2534},"\"59833787-2cf9-4fdf-8782-e53db20768a5\"",[144,4243,196],{"class":178},[144,4245,4246,4249,4251,4254],{"class":146,"line":159},[144,4247,4248],{"class":174},"  \"object\"",[144,4250,452],{"class":178},[144,4252,4253],{"class":2534},"\"page\"",[144,4255,196],{"class":178},[144,4257,4258,4261,4263],{"class":146,"line":165},[144,4259,4260],{"class":174},"  \"request_id\"",[144,4262,452],{"class":178},[144,4264,4265],{"class":2534},"\"{some other id}\"\n",[144,4267,4268],{"class":146,"line":171},[144,4269,475],{"class":178},[10,4271,4272,4273,4276],{},"So I figured that I could just use the ",[141,4274,4275],{},"\"id\""," property and fill it into this template and open that link:",[135,4278,4281],{"className":4279,"code":4280,"language":2706},[2704],"https://www.notion.so/{my-workspace-id}/59833787-2cf9-4fdf-8782-e53db20768a5\n",[141,4282,4280],{"__ignoreMap":17},[10,4284,4285,4289,4290,4293],{},[4286,4287,4288],"strong",{},"But",", doing it this way showed an error in Notion, and resulted in ",[833,4291,4292],{},"completely breaking my Notion installation, requiring me to reinstall Notion on my device",".\nThis is not great, and is clearly a bug in Notion.",[10,4295,4296,4297,4300,4301,4304,4305,179],{},"Granted, I made a mistake, because I first had to remove the hyphens from the ID.\nThis was an easy fix, requiring the use of a ",[833,4298,4299],{},"Replace Text"," action in the shortcut that replaces all occurrences of ",[141,4302,4303],{},"\"-\""," with ",[141,4306,4307],{},"\"\"",[10,4309,4310,4311,4316],{},"The behaviour regarding hyphens ",[45,4312,4315],{"href":4313,"rel":4314},"https://developers.notion.com/docs/working-with-page-content#creating-a-page-with-content",[49],"is documented somewhat",", but I don't think it is very convenient.",[10,4318,4319,4320,4323,4324,4327],{},"Later, I learned that this strange encounter (and my workaround) could have been prevented if I granted the ",[833,4321,4322],{},"Read content"," capability to my Notion integration.\nWith that capability, the response from the HTTP request includes a ",[141,4325,4326],{},"\"url\""," property which is exactly the URL that I want to open in the final action of the shortcut.",[84,4329,4331],{"id":4330},"conclusions","Conclusions",[10,4333,4334],{},"So there you have it! I now have this shortcut on my home screen, and after tapping it I only need to enter the movie's name (and some other things that I track).",[10,4336,4337],{},"My new shortcut saves me about 1 second for each entry I add.\nBuilding the shortcut took me about 2 hours, so I will need to watch another 7200 movies to make this adventure worth it (if time is all that matters).",[10,4339,4340],{},"I think it's fun that I am able to automate this stuff on an iPad without writing any normal code.\nBut, being used to writing code, doing it in Shortcuts is also frustrating, given all its quirks.",[2088,4342,4343],{},"html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":17,"searchDepth":18,"depth":18,"links":4345},[4346,4347,4348,4349],{"id":4000,"depth":18,"text":4001},{"id":4183,"depth":18,"text":4184},{"id":4212,"depth":18,"text":4213},{"id":4330,"depth":18,"text":4331},"2024-03-06","A short adventure into integrating with Notion's API from Shortcuts on iOS and iPadOS.",{},"/posts/inserting-into-a-notion-db-from-shortcuts",{"title":3972,"description":4351},"2.posts/20240306.Inserting into a Notion DB from Shortcuts","tQde2BDjqSeWdpK64GEK7XOBMikIxsGsvZEI00orcr0",{"id":4358,"title":4359,"body":4360,"created":4529,"description":4530,"extension":22,"meta":4531,"navigation":24,"path":4532,"seo":4533,"stem":4534,"updated":20,"__hash__":4535},"posts/2.posts/20230807.Bypassing Server Cache.md","Bypassing server cache when digests don't match",{"type":7,"value":4361,"toc":4522},[4362,4365,4385,4388,4398,4402,4405,4408,4419,4423,4432,4436,4448,4451,4455,4464,4470,4473,4479,4486,4497,4509,4513,4516,4519],[10,4363,4364],{},"I'm building an application that keeps track of a file that is hosted somewhere on a public web server (which is out of my control).",[10,4366,4367,4368,4371,4372,4377,4378,4381,4382,4384],{},"The web server serves the file that I'm interested in, which we'll call ",[141,4369,4370],{},"cool-file.txt",", along with an ",[45,4373,4376],{"href":4374,"rel":4375},"https://en.wikipedia.org/wiki/MD5",[49],"MD5 digest"," of the file called ",[141,4379,4380],{},"cool-file.txt.md5",".\nThis MD5 digest is like a small 'fingerprint' of the actual file.\n",[141,4383,4370],{}," could be many megabytes in size, but the fingerprint is only a few bytes.",[10,4386,4387],{},"MD5 digests like this are usually used for verifying the integrity of downloaded files: After downloading the file, you can compute the digest yourself, and check if it matches the digest from the server.",[10,4389,4390,4391,4393,4394,4397],{},"Another reason for using the digest is to save on resources.\nIf you want to know if ",[141,4392,4370],{}," has changed, you can simply retrieve the (small) digest, and see if ",[833,4395,4396],{},"that"," changed. This way you don't have to redownload the (potentially huge) file every time you want to check if it changed.",[84,4399,4401],{"id":4400},"the-problem","The problem",[10,4403,4404],{},"However, I noticed that when retrieving the file and computing the MD5 digest myself, it did not match the digest from the server!",[10,4406,4407],{},"This is problematic because of two reasons:",[2055,4409,4410,4413],{},[984,4411,4412],{},"My application's integrity validation fails because my digest does not match the server's digest.",[984,4414,4415,4416,4418],{},"When ",[141,4417,4370],{}," changes, my application wouldn't notice it because it will keep receiving the old digest.",[84,4420,4422],{"id":4421},"the-cause","The cause",[10,4424,4425,4426,4431],{},"It turned out that the file was recently updated, but I did not yet receive the new MD5 digest because the old digest was cached at the web server's end.\nThat is to say: the web server receives a request for the digest, and as an optimization, it doesn't read the updated digest from its file system (",[45,4427,4430],{"href":4428,"rel":4429},"https://atp.fm/",[49],"🛎️","), but it returns the old digest that it still memorized!",[84,4433,4435],{"id":4434},"fix-attempt-1","Fix attempt #1 ❌",[10,4437,4438,4439,4447],{},"Unfortunately, trying to bypass the cache with a ",[45,4440,4443,4446],{"href":4441,"rel":4442},"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control",[49],[141,4444,4445],{},"\"Cache-Control: no-cache\""," header"," did not work, because the server did not respect that header.",[10,4449,4450],{},"Ideally, when a server receives a request with that header included, it should disable the cache and give the latest result. But a server can choose to ignore it.",[84,4452,4454],{"id":4453},"fix-attempt-2","Fix attempt #2 ✅",[10,4456,4457,4458,4463],{},"I tried adding a ",[45,4459,4462],{"href":4460,"rel":4461},"https://www.rfc-editor.org/rfc/rfc3986.html#section-3.4",[49],"query component"," to the URL.\nThis component is ignored by most (if not all) web servers when serving static files.\nSo, instead of",[135,4465,4468],{"className":4466,"code":4467,"language":2706},[2704],"https://example.org/cool-file.txt.md5\n",[141,4469,4467],{"__ignoreMap":17},[10,4471,4472],{},"I used",[135,4474,4477],{"className":4475,"code":4476,"language":2706},[2704],"https://example.org/cool-file.txt.md5?hello\n",[141,4478,4476],{"__ignoreMap":17},[10,4480,4481,4482,4485],{},"and it worked!\nApparently, the cache in the web server works with some sort of lookup table that allows it to look up the memorized responses for certain URLs (",[833,4483,4484],{},"including"," the query component).",[10,4487,4488,4489,4492,4493,4496],{},"Of course, for this fix to work consistently, the part after the ",[141,4490,4491],{},"?"," should be different every time, because otherwise the web server can just return another outdated digest that it memorized for the ",[141,4494,4495],{},"https://example.org/cool-file.txt.md5?hello"," request.",[10,4498,4499,4500,4502,4503,4508],{},"For this reason, my application now just appends ",[141,4501,4491],{}," and a ",[45,4504,4507],{"href":4505,"rel":4506},"https://developer.mozilla.org/en-US/docs/Glossary/UUID",[49],"UUID"," to the URL of the digest (and the file itself as well!).",[84,4510,4512],{"id":4511},"discussion","Discussion",[10,4514,4515],{},"This doesn't feel like a great fix.\nWith every request I do now, this will probably create an entry in the cache in the web server which consumes memory on their end.",[10,4517,4518],{},"But hey, the cache at the web server is out of my control, so I had to make do with it somehow!",[10,4520,4521],{},"What do you think? Should I contact the administrator of the web server to better invalidate their caches?",{"title":17,"searchDepth":18,"depth":18,"links":4523},[4524,4525,4526,4527,4528],{"id":4400,"depth":18,"text":4401},{"id":4421,"depth":18,"text":4422},{"id":4434,"depth":18,"text":4435},{"id":4453,"depth":18,"text":4454},{"id":4511,"depth":18,"text":4512},"2023-08-07","How to bypass server cache with a URL query component.",{},"/posts/bypassing-server-cache",{"title":4359,"description":4530},"2.posts/20230807.Bypassing Server Cache","d6NiEFAtm26tdS_C7-uDi9DvDCSn_U-lHUim8Qm8_MU",{"id":4537,"title":4538,"body":4539,"created":5256,"description":5257,"extension":22,"meta":5258,"navigation":24,"path":5259,"seo":5260,"stem":5261,"updated":20,"__hash__":5262},"posts/2.posts/20230517.Combining DISTINCT and group-concat with custom delimiters in SQLite3.md","Combining DISTINCT and group_concat() with custom delimiters in SQLite3",{"type":7,"value":4540,"toc":5248},[4541,4551,4558,4561,4703,4706,4724,4777,4780,4784,4794,4797,4813,4819,4826,4860,4866,4869,4873,4879,4882,4898,4928,4934,4950,4976,4979,4983,4991,5022,5028,5031,5067,5073,5086,5094,5129,5135,5152,5158,5162,5171,5223,5229,5232,5234,5242,5245],[10,4542,4543,4544,1759,4547,4550],{},"In this blog post, I'll introduce two useful tools in SQLite3: ",[141,4545,4546],{},"group_concat()",[141,4548,4549],{},"DISTINCT",".\nI show how they can be used individually, and that you need to watch out when combining them with custom delimiters.",[10,4552,4553,4554,4557],{},"I assume you have a basic understanding of SQL, but mostly you will need to know about the concepts of a table with data and simple ",[141,4555,4556],{},"SELECT"," queries.",[10,4559,4560],{},"But first, we need a table to work with.\nFor that, we'll create a table of hobbit names, and fill it with a few hobbits from Tolkien's fictional universe:",[135,4562,4566],{"className":4563,"code":4564,"language":4565,"meta":17,"style":17},"language-sql shiki shiki-themes one-dark-pro","CREATE TABLE hobbits (\n  first_name VARCHAR(64),\n  last_name VARCHAR(64)\n);\n\nINSERT INTO hobbits VALUES\n  (\"Frodo\", \"Baggins\"),\n  (\"Bilbo\", \"Baggins\"),\n  (\"Sam\", \"Gamgee\"),\n  (\"Pippin\", \"Took\"),\n  (\"Merry\", \"Brandybuck\")\n;\n","sql",[141,4567,4568,4582,4597,4610,4614,4618,4629,4644,4657,4671,4685,4699],{"__ignoreMap":17},[144,4569,4570,4573,4576,4579],{"class":146,"line":147},[144,4571,4572],{"class":361},"CREATE",[144,4574,4575],{"class":361}," TABLE",[144,4577,4578],{"class":182}," hobbits",[144,4580,4581],{"class":178}," (\n",[144,4583,4584,4587,4590,4592,4595],{"class":146,"line":18},[144,4585,4586],{"class":178},"  first_name ",[144,4588,4589],{"class":361},"VARCHAR",[144,4591,205],{"class":178},[144,4593,4594],{"class":229},"64",[144,4596,922],{"class":178},[144,4598,4599,4602,4604,4606,4608],{"class":146,"line":159},[144,4600,4601],{"class":178},"  last_name ",[144,4603,4589],{"class":361},[144,4605,205],{"class":178},[144,4607,4594],{"class":229},[144,4609,527],{"class":178},[144,4611,4612],{"class":146,"line":165},[144,4613,705],{"class":178},[144,4615,4616],{"class":146,"line":171},[144,4617,711],{"emptyLinePlaceholder":24},[144,4619,4620,4623,4626],{"class":146,"line":189},[144,4621,4622],{"class":361},"INSERT INTO",[144,4624,4625],{"class":178}," hobbits ",[144,4627,4628],{"class":361},"VALUES\n",[144,4630,4631,4634,4637,4639,4642],{"class":146,"line":199},[144,4632,4633],{"class":178},"  (",[144,4635,4636],{"class":2534},"\"Frodo\"",[144,4638,233],{"class":178},[144,4640,4641],{"class":2534},"\"Baggins\"",[144,4643,922],{"class":178},[144,4645,4646,4648,4651,4653,4655],{"class":146,"line":241},[144,4647,4633],{"class":178},[144,4649,4650],{"class":2534},"\"Bilbo\"",[144,4652,233],{"class":178},[144,4654,4641],{"class":2534},[144,4656,922],{"class":178},[144,4658,4659,4661,4664,4666,4669],{"class":146,"line":250},[144,4660,4633],{"class":178},[144,4662,4663],{"class":2534},"\"Sam\"",[144,4665,233],{"class":178},[144,4667,4668],{"class":2534},"\"Gamgee\"",[144,4670,922],{"class":178},[144,4672,4673,4675,4678,4680,4683],{"class":146,"line":283},[144,4674,4633],{"class":178},[144,4676,4677],{"class":2534},"\"Pippin\"",[144,4679,233],{"class":178},[144,4681,4682],{"class":2534},"\"Took\"",[144,4684,922],{"class":178},[144,4686,4687,4689,4692,4694,4697],{"class":146,"line":289},[144,4688,4633],{"class":178},[144,4690,4691],{"class":2534},"\"Merry\"",[144,4693,233],{"class":178},[144,4695,4696],{"class":2534},"\"Brandybuck\"",[144,4698,527],{"class":178},[144,4700,4701],{"class":146,"line":675},[144,4702,371],{"class":178},[10,4704,4705],{},"To simply select the first and last names of each of the hobbits, the following query can be used:",[135,4707,4709],{"className":4563,"code":4708,"language":4565,"meta":17,"style":17},"SELECT first_name, last_name FROM hobbits;\n",[141,4710,4711],{"__ignoreMap":17},[144,4712,4713,4715,4718,4721],{"class":146,"line":147},[144,4714,4556],{"class":361},[144,4716,4717],{"class":178}," first_name, last_name ",[144,4719,4720],{"class":361},"FROM",[144,4722,4723],{"class":178}," hobbits;\n",[3189,4725,4726,4736],{},[3192,4727,4728],{},[3195,4729,4730,4733],{},[3198,4731,4732],{},"first_name",[3198,4734,4735],{},"last_name",[3205,4737,4738,4746,4753,4761,4769],{},[3195,4739,4740,4743],{},[3210,4741,4742],{},"Frodo",[3210,4744,4745],{},"Baggins",[3195,4747,4748,4751],{},[3210,4749,4750],{},"Bilbo",[3210,4752,4745],{},[3195,4754,4755,4758],{},[3210,4756,4757],{},"Sam",[3210,4759,4760],{},"Gamgee",[3195,4762,4763,4766],{},[3210,4764,4765],{},"Pippin",[3210,4767,4768],{},"Took",[3195,4770,4771,4774],{},[3210,4772,4773],{},"Merry",[3210,4775,4776],{},"Brandybuck",[10,4778,4779],{},"Great! But we can do more interesting things, as demonstrated next.",[84,4781,4783],{"id":4782},"concatenating","Concatenating",[10,4785,4786,4787,4793],{},"One of the tools in SQL is the ",[45,4788,4791],{"href":4789,"rel":4790},"https://www.sqlite.org/lang_aggfunc.html#group_concat",[49],[141,4792,4546],{}," function, which returns a string that is the concatenation of all non-null values of the given result set.",[10,4795,4796],{},"For example, this query concatenates all of the hobbits' first names:",[135,4798,4800],{"className":4563,"code":4799,"language":4565,"meta":17,"style":17},"SELECT group_concat(first_name) FROM hobbits;\n",[141,4801,4802],{"__ignoreMap":17},[144,4803,4804,4806,4809,4811],{"class":146,"line":147},[144,4805,4556],{"class":361},[144,4807,4808],{"class":178}," group_concat(first_name) ",[144,4810,4720],{"class":361},[144,4812,4723],{"class":178},[135,4814,4817],{"className":4815,"code":4816,"language":2706},[2704],"Frodo,Bilbo,Sam,Pippin,Merry\n",[141,4818,4816],{"__ignoreMap":17},[10,4820,4821,4822,4825],{},"You can see that, by default, the values are delimited by commas (",[141,4823,4824],{},",",").\nTo customise this, it is possible to provide an additional argument that sets the delimiter:",[135,4827,4829],{"className":4563,"code":4828,"language":4565,"meta":17,"style":17},"SELECT\n  group_concat(first_name, \" and \")\nFROM\n  hobbits\n;\n",[141,4830,4831,4836,4846,4851,4856],{"__ignoreMap":17},[144,4832,4833],{"class":146,"line":147},[144,4834,4835],{"class":361},"SELECT\n",[144,4837,4838,4841,4844],{"class":146,"line":18},[144,4839,4840],{"class":178},"  group_concat(first_name, ",[144,4842,4843],{"class":2534},"\" and \"",[144,4845,527],{"class":178},[144,4847,4848],{"class":146,"line":159},[144,4849,4850],{"class":361},"FROM\n",[144,4852,4853],{"class":146,"line":165},[144,4854,4855],{"class":178},"  hobbits\n",[144,4857,4858],{"class":146,"line":171},[144,4859,371],{"class":178},[135,4861,4864],{"className":4862,"code":4863,"language":2706},[2704],"Frodo and Bilbo and Sam and Pippin and Merry\n",[141,4865,4863],{"__ignoreMap":17},[10,4867,4868],{},"That's a little more readable.",[84,4870,4872],{"id":4871},"distinct-values","Distinct values",[10,4874,4875,4876,4878],{},"Another tool in SQL is the ",[141,4877,4549],{}," keyword, which can remove duplicate entries from result sets.",[10,4880,4881],{},"The keen-eyed (or well-read) among you may have observed (or remembered) that Frodo and Bilbo share their last names!\nThis means that when selecting the last names, we would see a duplicate entry.\nAnd indeed:",[135,4883,4885],{"className":4563,"code":4884,"language":4565,"meta":17,"style":17},"SELECT last_name FROM hobbits;\n",[141,4886,4887],{"__ignoreMap":17},[144,4888,4889,4891,4894,4896],{"class":146,"line":147},[144,4890,4556],{"class":361},[144,4892,4893],{"class":178}," last_name ",[144,4895,4720],{"class":361},[144,4897,4723],{"class":178},[3189,4899,4900,4906],{},[3192,4901,4902],{},[3195,4903,4904],{},[3198,4905,4735],{},[3205,4907,4908,4912,4916,4920,4924],{},[3195,4909,4910],{},[3210,4911,4745],{},[3195,4913,4914],{},[3210,4915,4745],{},[3195,4917,4918],{},[3210,4919,4760],{},[3195,4921,4922],{},[3210,4923,4768],{},[3195,4925,4926],{},[3210,4927,4776],{},[10,4929,4930,4931,4933],{},"To remove the duplicate Bagginses from the result set, you can specify that the result set should be made distinct with the ",[141,4932,4549],{}," keyword:",[135,4935,4937],{"className":4563,"code":4936,"language":4565,"meta":17,"style":17},"SELECT DISTINCT last_name FROM hobbits;\n",[141,4938,4939],{"__ignoreMap":17},[144,4940,4941,4944,4946,4948],{"class":146,"line":147},[144,4942,4943],{"class":361},"SELECT DISTINCT",[144,4945,4893],{"class":178},[144,4947,4720],{"class":361},[144,4949,4723],{"class":178},[3189,4951,4952,4958],{},[3192,4953,4954],{},[3195,4955,4956],{},[3198,4957,4735],{},[3205,4959,4960,4964,4968,4972],{},[3195,4961,4962],{},[3210,4963,4745],{},[3195,4965,4966],{},[3210,4967,4760],{},[3195,4969,4970],{},[3210,4971,4768],{},[3195,4973,4974],{},[3210,4975,4776],{},[10,4977,4978],{},"As expected, only a single Baggins remains in the result.",[84,4980,4982],{"id":4981},"concatenating-distinct-values","Concatenating distinct values",[10,4984,4985,4986,1759,4988,4990],{},"So, what if we want to combine ",[141,4987,4546],{},[141,4989,4549],{},"?\nWe can!\nThis will concatenate all the distinct last names:",[135,4992,4994],{"className":4563,"code":4993,"language":4565,"meta":17,"style":17},"SELECT\n  group_concat(DISTINCT last_name)\nFROM\n  hobbits\n;\n",[141,4995,4996,5000,5010,5014,5018],{"__ignoreMap":17},[144,4997,4998],{"class":146,"line":147},[144,4999,4835],{"class":361},[144,5001,5002,5005,5007],{"class":146,"line":18},[144,5003,5004],{"class":178},"  group_concat(",[144,5006,4549],{"class":361},[144,5008,5009],{"class":178}," last_name)\n",[144,5011,5012],{"class":146,"line":159},[144,5013,4850],{"class":361},[144,5015,5016],{"class":146,"line":165},[144,5017,4855],{"class":178},[144,5019,5020],{"class":146,"line":171},[144,5021,371],{"class":178},[135,5023,5026],{"className":5024,"code":5025,"language":2706},[2704],"Baggins,Gamgee,Took,Brandybuck\n",[141,5027,5025],{"__ignoreMap":17},[10,5029,5030],{},"Fabulous!\nWe're wielding these tools like a pro!\nFeeling overly confident, we may even try to customise the delimiter for extra points:",[135,5032,5034],{"className":4563,"code":5033,"language":4565,"meta":17,"style":17},"SELECT\n    group_concat(DISTINCT last_name, \" and \")\nFROM\n    hobbits\n;\n",[141,5035,5036,5040,5054,5058,5063],{"__ignoreMap":17},[144,5037,5038],{"class":146,"line":147},[144,5039,4835],{"class":361},[144,5041,5042,5045,5047,5050,5052],{"class":146,"line":18},[144,5043,5044],{"class":178},"    group_concat(",[144,5046,4549],{"class":361},[144,5048,5049],{"class":178}," last_name, ",[144,5051,4843],{"class":2534},[144,5053,527],{"class":178},[144,5055,5056],{"class":146,"line":159},[144,5057,4850],{"class":361},[144,5059,5060],{"class":146,"line":165},[144,5061,5062],{"class":178},"    hobbits\n",[144,5064,5065],{"class":146,"line":171},[144,5066,371],{"class":178},[135,5068,5071],{"className":5069,"code":5070,"language":2706},[2704],"Parse error near line 2: DISTINCT aggregates must\n  have exactly one argument\n",[141,5072,5070],{"__ignoreMap":17},[10,5074,5075,5076,5078,5079,5082,5083,5085],{},"Uh oh.\nIt looks like the way we use the ",[141,5077,4549],{}," keyword confuses the query parser.\nIt seems like the ",[141,5080,5081],{},"last_name, \" and \""," part is interpreted as two arguments for the ",[141,5084,4549],{}," keyword?",[10,5087,5088,5089,5091,5092,836],{},"Let's try to fix that with parentheses so that the ",[141,5090,4549],{}," keyword only applies to ",[141,5093,4735],{},[135,5095,5097],{"className":4563,"code":5096,"language":4565,"meta":17,"style":17},"SELECT\n    group_concat((DISTINCT last_name), \" and \")\nFROM\n    hobbits\n;\n",[141,5098,5099,5103,5117,5121,5125],{"__ignoreMap":17},[144,5100,5101],{"class":146,"line":147},[144,5102,4835],{"class":361},[144,5104,5105,5108,5110,5113,5115],{"class":146,"line":18},[144,5106,5107],{"class":178},"    group_concat((",[144,5109,4549],{"class":361},[144,5111,5112],{"class":178}," last_name), ",[144,5114,4843],{"class":2534},[144,5116,527],{"class":178},[144,5118,5119],{"class":146,"line":159},[144,5120,4850],{"class":361},[144,5122,5123],{"class":146,"line":165},[144,5124,5062],{"class":178},[144,5126,5127],{"class":146,"line":171},[144,5128,371],{"class":178},[135,5130,5133],{"className":5131,"code":5132,"language":2706},[2704],"Parse error near line 39: near \"DISTINCT\": syntax\n  error\n  SELECT     group_concat((DISTINCT last_name),\n    \" and \") FROM     hobbits ;\n             error here ---^\n",[141,5134,5132],{"__ignoreMap":17},[10,5136,5137,5138,5145,5146,5148,5149,5151],{},"Nope! It seems like this is not allowed.\nConsulting the ",[45,5139,5142,5144],{"href":5140,"rel":5141},"https://www.sqlite.org/syntax/select-stmt.html",[49],[141,5143,4556],{}," grammar",", it is indeed the case that it is required for the ",[141,5147,4549],{}," keyword to directly follow the opening parenthesis of the ",[141,5150,4546],{}," (or other) function.",[10,5153,5154,5155,5157],{},"It looks like the ",[141,5156,4549],{}," keyword is only allowed in certain contexts, and a special case has been made for adding it in function invocations.\nThis special case prohibits extra arguments in the function invocation.",[115,5159,5161],{"id":5160},"the-solution","The Solution",[10,5163,5164,5165,5170],{},"So, how do we solve this?\nWell, ",[45,5166,5169],{"href":5167,"rel":5168},"https://sqlite.org/forum/info/221c2926f5e6f155",[49],"an answer on the SQLite Forum"," provided a solution!\nIt uses a subquery as a workaround:",[135,5172,5174],{"className":4563,"code":5173,"language":4565,"meta":17,"style":17},"SELECT\n  group_concat(distinct_last_name, \" and \")\nFROM (\n  SELECT DISTINCT\n    last_name AS distinct_last_name\n  FROM hobbits\n);\n",[141,5175,5176,5180,5189,5195,5200,5211,5219],{"__ignoreMap":17},[144,5177,5178],{"class":146,"line":147},[144,5179,4835],{"class":361},[144,5181,5182,5185,5187],{"class":146,"line":18},[144,5183,5184],{"class":178},"  group_concat(distinct_last_name, ",[144,5186,4843],{"class":2534},[144,5188,527],{"class":178},[144,5190,5191,5193],{"class":146,"line":159},[144,5192,4720],{"class":361},[144,5194,4581],{"class":178},[144,5196,5197],{"class":146,"line":165},[144,5198,5199],{"class":361},"  SELECT DISTINCT\n",[144,5201,5202,5205,5208],{"class":146,"line":171},[144,5203,5204],{"class":178},"    last_name ",[144,5206,5207],{"class":361},"AS",[144,5209,5210],{"class":178}," distinct_last_name\n",[144,5212,5213,5216],{"class":146,"line":189},[144,5214,5215],{"class":361},"  FROM",[144,5217,5218],{"class":178}," hobbits\n",[144,5220,5221],{"class":146,"line":199},[144,5222,705],{"class":178},[135,5224,5227],{"className":5225,"code":5226,"language":2706},[2704],"Baggins and Gamgee and Took and Brandybuck\n",[141,5228,5226],{"__ignoreMap":17},[10,5230,5231],{},"The inner query creates an intermediate result set that we can use in the outer query.\nThis workaround definitely feels like... well, a workaround.\nIf you find a more elegant solution to this, please let me know!",[84,5233,2012],{"id":2011},[10,5235,5236,5237,1759,5239,5241],{},"Apparently, the ",[141,5238,4546],{},[141,5240,4549],{}," tools do not work together as I expected when using a custom delimiter.",[10,5243,5244],{},"As Bilbo Baggins would put it: I don't know half of SQL as well as I should like; and I like less than half of it half as well as it deserves.",[2088,5246,5247],{},"html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":17,"searchDepth":18,"depth":18,"links":5249},[5250,5251,5252,5255],{"id":4782,"depth":18,"text":4783},{"id":4871,"depth":18,"text":4872},{"id":4981,"depth":18,"text":4982,"children":5253},[5254],{"id":5160,"depth":159,"text":5161},{"id":2011,"depth":18,"text":2012},"2023-05-17","How to use DISTINCT and group_concat() with custom delimiters in SQLite3.",{},"/posts/combining-distinct-and-group-concat-with-custom-delimiters-in-sqlite3",{"title":4538,"description":5257},"2.posts/20230517.Combining DISTINCT and group-concat with custom delimiters in SQLite3","6mL4W5qn2rBDyftraL23X546533kQlnUKg-0wE5tERY",{"id":5264,"title":5265,"body":5266,"created":5801,"description":5802,"extension":22,"meta":5803,"navigation":24,"path":5804,"seo":5805,"stem":5806,"updated":20,"__hash__":5807},"posts/2.posts/20221006.An Unexpected Encounter with Python Class Attributes.md","An Unexpected Encounter with Python Class Attributes",{"type":7,"value":5267,"toc":5793},[5268,5271,5279,5282,5286,5293,5362,5369,5429,5432,5440,5443,5450,5453,5456,5460,5463,5517,5520,5526,5533,5539,5542,5546,5552,5565,5584,5602,5606,5626,5635,5638,5654,5660,5674,5684,5687,5691,5696,5763,5773,5776,5790],[10,5269,5270],{},"Recently, I encountered a mysterious bug in my Python application.\nThe cause of the bug was my poor understanding of:",[981,5272,5273,5276],{},[984,5274,5275],{},"how class instances in Python initialise their attributes, and",[984,5277,5278],{},"how instance attributes and class attributes are resolved.",[10,5280,5281],{},"In this post I will explain my incorrect assumptions about this part of Python.",[84,5283,5285],{"id":5284},"people-with-hobbies","People with Hobbies",[10,5287,5288,5289,5292],{},"The following Python snippet defines the ",[141,5290,5291],{},"Person"," class, with a method for adding a hobby to the person's list of hobbies:",[135,5294,5298],{"className":5295,"code":5296,"language":5297,"meta":17,"style":17},"language-python shiki shiki-themes one-dark-pro","class Person:\n  hobbies = []\n\n  def add_hobby(self, hobby):\n    self.hobbies.append(hobby)\n","python",[141,5299,5300,5311,5321,5325,5348],{"__ignoreMap":17},[144,5301,5302,5305,5308],{"class":146,"line":147},[144,5303,5304],{"class":361},"class",[144,5306,5307],{"class":192}," Person",[144,5309,5310],{"class":178},":\n",[144,5312,5313,5316,5318],{"class":146,"line":18},[144,5314,5315],{"class":178},"  hobbies ",[144,5317,1242],{"class":340},[144,5319,5320],{"class":178}," []\n",[144,5322,5323],{"class":146,"line":159},[144,5324,711],{"emptyLinePlaceholder":24},[144,5326,5327,5330,5333,5335,5339,5341,5345],{"class":146,"line":165},[144,5328,5329],{"class":361},"  def",[144,5331,5332],{"class":182}," add_hobby",[144,5334,205],{"class":178},[144,5336,5338],{"class":5337},"sKU4T","self",[144,5340,233],{"class":178},[144,5342,5344],{"class":5343},"sb9H8","hobby",[144,5346,5347],{"class":178},"):\n",[144,5349,5350,5353,5356,5359],{"class":146,"line":171},[144,5351,5352],{"class":192},"    self",[144,5354,5355],{"class":178},".hobbies.",[144,5357,5358],{"class":182},"append",[144,5360,5361],{"class":178},"(hobby)\n",[10,5363,5364,5365,5368],{},"That's marvellous. With this class, we can instantiate some persons (",[833,5366,5367],{},"people"," in human-speak) and give them hobbies:",[135,5370,5372],{"className":5295,"code":5371,"language":5297,"meta":17,"style":17},"tolkien = Person()\ntolkien.add_hobby(\"writing\")\n\nelvis = Person()\nelvis.add_hobby(\"music\")\n",[141,5373,5374,5385,5400,5404,5415],{"__ignoreMap":17},[144,5375,5376,5379,5381,5383],{"class":146,"line":147},[144,5377,5378],{"class":178},"tolkien ",[144,5380,1242],{"class":340},[144,5382,5307],{"class":182},[144,5384,2606],{"class":178},[144,5386,5387,5390,5393,5395,5398],{"class":146,"line":18},[144,5388,5389],{"class":178},"tolkien.",[144,5391,5392],{"class":182},"add_hobby",[144,5394,205],{"class":178},[144,5396,5397],{"class":2534},"\"writing\"",[144,5399,527],{"class":178},[144,5401,5402],{"class":146,"line":159},[144,5403,711],{"emptyLinePlaceholder":24},[144,5405,5406,5409,5411,5413],{"class":146,"line":165},[144,5407,5408],{"class":178},"elvis ",[144,5410,1242],{"class":340},[144,5412,5307],{"class":182},[144,5414,2606],{"class":178},[144,5416,5417,5420,5422,5424,5427],{"class":146,"line":171},[144,5418,5419],{"class":178},"elvis.",[144,5421,5392],{"class":182},[144,5423,205],{"class":178},[144,5425,5426],{"class":2534},"\"music\"",[144,5428,527],{"class":178},[10,5430,5431],{},"Now, pause for a moment.",[5433,5434,5435],"blockquote",{},[10,5436,5437],{},[833,5438,5439],{},"elevator music starts playing",[10,5441,5442],{},"Ponder for a while.",[5433,5444,5445],{},[10,5446,5447],{},[833,5448,5449],{},"elevator music suddenly stops",[10,5451,5452],{},"Did you spot the mistake?",[10,5454,5455],{},"I had been programming in Python for more than five years, and I didn't!",[84,5457,5459],{"id":5458},"unforeseen-consequences","Unforeseen Consequences",[10,5461,5462],{},"What do you think the following two lines would print?",[135,5464,5466],{"className":5295,"code":5465,"language":5297,"meta":17,"style":17},"print(f\"Tolkien's hobbies: {tolkien.hobbies}\")\nprint(f\"Elvis's hobbies: {elvis.hobbies}\")\n",[141,5467,5468,5495],{"__ignoreMap":17},[144,5469,5470,5473,5475,5478,5481,5484,5487,5490,5493],{"class":146,"line":147},[144,5471,5472],{"class":340},"print",[144,5474,205],{"class":178},[144,5476,5477],{"class":361},"f",[144,5479,5480],{"class":2534},"\"Tolkien's hobbies: ",[144,5482,5483],{"class":229},"{",[144,5485,5486],{"class":178},"tolkien.hobbies",[144,5488,5489],{"class":229},"}",[144,5491,5492],{"class":2534},"\"",[144,5494,527],{"class":178},[144,5496,5497,5499,5501,5503,5506,5508,5511,5513,5515],{"class":146,"line":18},[144,5498,5472],{"class":340},[144,5500,205],{"class":178},[144,5502,5477],{"class":361},[144,5504,5505],{"class":2534},"\"Elvis's hobbies: ",[144,5507,5483],{"class":229},[144,5509,5510],{"class":178},"elvis.hobbies",[144,5512,5489],{"class":229},[144,5514,5492],{"class":2534},[144,5516,527],{"class":178},[10,5518,5519],{},"This is what I expected it to print:",[135,5521,5524],{"className":5522,"code":5523,"language":2706},[2704],"Tolkien's hobbies: ['writing']\nElvis's hobbies: ['music']\n",[141,5525,5523],{"__ignoreMap":17},[10,5527,5528,5529,5532],{},"Here's what it ",[833,5530,5531],{},"actually"," prints:",[135,5534,5537],{"className":5535,"code":5536,"language":2706},[2704],"Tolkien's hobbies: ['writing', 'music']\nElvis's hobbies: ['writing', 'music']\n",[141,5538,5536],{"__ignoreMap":17},[10,5540,5541],{},"What?! Why are both hobbies listed for both persons?",[84,5543,5545],{"id":5544},"the-incident","The Incident",[10,5547,5548,5549,5551],{},"My bug was rooted in this statement in the ",[141,5550,5291],{}," class:",[135,5553,5555],{"className":5295,"code":5554,"language":5297,"meta":17,"style":17},"  hobbies = []\n",[141,5556,5557],{"__ignoreMap":17},[144,5558,5559,5561,5563],{"class":146,"line":147},[144,5560,5315],{"class":178},[144,5562,1242],{"class":340},[144,5564,5320],{"class":178},[10,5566,5567,5568,5571,5572,5574,5575,5578,5579],{},"I ",[833,5569,5570],{},"thought"," this statement made sure that every new ",[141,5573,5291],{},"'s ",[141,5576,5577],{},"hobbies"," attribute was set to a new empty list.",[62,5580,5581],{},[45,5582,70],{"href":66,"ariaDescribedBy":5583,"dataFootnoteRef":17,"id":69},[68],[10,5585,5586,5587,5590,5591,5594,5595,5598,5599,5601],{},"However, it actually sets the ",[833,5588,5589],{},"class attribute"," ",[141,5592,5593],{},"Person.hobbies"," to an empty list.\nThe statement is only evaluated ",[833,5596,5597],{},"once",", namely when the class is first evaluated, and no new hobby lists are made when new ",[141,5600,5291],{}," instances are made.",[84,5603,5605],{"id":5604},"resolving-hobbies","Resolving Hobbies",[10,5607,5608,5609,5611,5612,5615,5616,5618,5619,5622,5623,5625],{},"When appending to the person's hobby list in ",[141,5610,5392],{},", the attribute ",[141,5613,5614],{},"self.hobbies"," is used.\nWhen looking up that attribute, Python first looks for a variable named ",[141,5617,5577],{}," in the instance's namespace.\nIf that fails, ",[4286,5620,5621],{},"it tries to find the variable in the namespace of the instance's class!","\nSo, it finds the ",[141,5624,5593],{}," attribute in the class's namespace.",[10,5627,5628,5629,5590,5631,5634],{},"The calls to ",[141,5630,5392],{},[833,5632,5633],{},"mutated"," that single list in the class attribute, resulting in all hobbies from different person instances being added to the same list.",[10,5636,5637],{},"We can verify that they resolve to the same list by evaluating the expression:",[135,5639,5641],{"className":5295,"code":5640,"language":5297,"meta":17,"style":17},"tolkien.hobbies is elvis.hobbies\n",[141,5642,5643],{"__ignoreMap":17},[144,5644,5645,5648,5651],{"class":146,"line":147},[144,5646,5647],{"class":178},"tolkien.hobbies ",[144,5649,5650],{"class":361},"is",[144,5652,5653],{"class":178}," elvis.hobbies\n",[10,5655,5656,5657,179],{},"which results in ",[141,5658,5659],{},"True",[10,5661,5662,5663,5668,5669,179],{},"This behaviour is well documented in the ",[45,5664,5667],{"href":5665,"rel":5666},"https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables",[49],"Python tutorial"," which goes to show that I should have ",[45,5670,5673],{"href":5671,"rel":5672},"https://en.wikipedia.org/wiki/RTFM",[49],"RTFM",[10,5675,5676,5679,5680,5683],{},[833,5677,5678],{},"Why"," it behaves like this is unclear to me (perhaps I should read some more).\nTo me, it would make sense for Python to raise an ",[141,5681,5682],{},"AttributeError"," when trying to access an instance attribute that doesn't exist on the instance.",[10,5685,5686],{},"Feel free to reach out if you can enlighten me.",[84,5688,5690],{"id":5689},"the-fix","The Fix",[10,5692,5693,5694,5551],{},"Here's a corrected version of the ",[141,5695,5291],{},[135,5697,5699],{"className":5295,"code":5698,"language":5297,"meta":17,"style":17},"class Person:\n  def __init__(self):\n    self.hobbies = []\n\n  def add_hobby(self, hobby):\n    self.hobbies.append(hobby)\n",[141,5700,5701,5709,5722,5733,5737,5753],{"__ignoreMap":17},[144,5702,5703,5705,5707],{"class":146,"line":147},[144,5704,5304],{"class":361},[144,5706,5307],{"class":192},[144,5708,5310],{"class":178},[144,5710,5711,5713,5716,5718,5720],{"class":146,"line":18},[144,5712,5329],{"class":361},[144,5714,5715],{"class":340}," __init__",[144,5717,205],{"class":178},[144,5719,5338],{"class":5337},[144,5721,5347],{"class":178},[144,5723,5724,5726,5729,5731],{"class":146,"line":159},[144,5725,5352],{"class":192},[144,5727,5728],{"class":178},".hobbies ",[144,5730,1242],{"class":340},[144,5732,5320],{"class":178},[144,5734,5735],{"class":146,"line":165},[144,5736,711],{"emptyLinePlaceholder":24},[144,5738,5739,5741,5743,5745,5747,5749,5751],{"class":146,"line":171},[144,5740,5329],{"class":361},[144,5742,5332],{"class":182},[144,5744,205],{"class":178},[144,5746,5338],{"class":5337},[144,5748,233],{"class":178},[144,5750,5344],{"class":5343},[144,5752,5347],{"class":178},[144,5754,5755,5757,5759,5761],{"class":146,"line":189},[144,5756,5352],{"class":192},[144,5758,5355],{"class":178},[144,5760,5358],{"class":182},[144,5762,5361],{"class":178},[10,5764,5765,5766,5768,5769,5772],{},"In this version, the ",[141,5767,5577],{}," attribute is assigned to the instance's namespace in the ",[141,5770,5771],{},"__init__"," method, which is executed when an instance is initialised.",[10,5774,5775],{},"This way, every person instance gets its own list of hobbies in its instance attributes.",[2047,5777,5779,5782],{"className":5778,"dataFootnotes":17},[2044],[84,5780,2045],{"className":5781,"id":68},[2053],[2055,5783,5784],{},[984,5785,5786,5787],{"id":2059},"This is vaguely how it works in some other programming languages like Java and PHP. ",[45,5788,2073],{"href":2069,"ariaLabel":2070,"className":5789,"dataFootnoteBackref":17},[2072],[2088,5791,5792],{},"html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}html pre.shiki code .sU0A5, html code.shiki .sU0A5{--shiki-default:#E5C07B}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .sKU4T, html code.shiki .sKU4T{--shiki-default:#E5C07B;--shiki-default-font-style:italic}html pre.shiki code .sb9H8, html code.shiki .sb9H8{--shiki-default:#D19A66;--shiki-default-font-style:italic}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}",{"title":17,"searchDepth":18,"depth":18,"links":5794},[5795,5796,5797,5798,5799,5800],{"id":5284,"depth":18,"text":5285},{"id":5458,"depth":18,"text":5459},{"id":5544,"depth":18,"text":5545},{"id":5604,"depth":18,"text":5605},{"id":5689,"depth":18,"text":5690},{"id":68,"depth":18,"text":2045},"2022-10-06","Strange encounters with Python classes.",{},"/posts/an-unexpected-encounter-with-python-class-attributes",{"title":5265,"description":5802},"2.posts/20221006.An Unexpected Encounter with Python Class Attributes","aZjMXSm3aCS5Yeh4KW5_wWTonU1rjPDFMO8zmms4Tws",{"id":5809,"title":5810,"body":5811,"created":5888,"description":5889,"extension":22,"meta":5890,"navigation":24,"path":5891,"seo":5892,"stem":5893,"updated":20,"__hash__":5894},"posts/2.posts/20190817.Dumbing Down my Website.md","Dumbing Down my Website",{"type":7,"value":5812,"toc":5885},[5813,5816,5819,5822,5848,5851,5859],[10,5814,5815],{},"When you're reading this, my website has been updated to be more minimal, simple, and fast.",[10,5817,5818],{},"These days, many websites come with bandwidth-heavy stuff like custom fonts, bundled JavaScript, front-end UI frameworks, and more.",[10,5820,5821],{},"That's cool. But most web pages don't need that.",[10,5823,5824,5825,5830,5831,5836,5837,5842,5843,179],{},"I have rebuilt my website from skratch using ",[45,5826,5829],{"href":5827,"rel":5828},"https://www.getzola.org",[49],"Zola",", a static site generator made with the ",[45,5832,5835],{"href":5833,"rel":5834},"https://www.rust-lang.org",[49],"Rust programming language",".\nIt allows me to have my content in ",[45,5838,5841],{"href":5839,"rel":5840},"https://daringfireball.net/projects/markdown/syntax",[49],"Markdown"," format in an easy to manage folder structure.\nThe Markdown (and some templates) are processed by Zola to build the content that is served over the World Wide Web",[62,5844,5845],{},[45,5846,70],{"href":66,"ariaDescribedBy":5847,"dataFootnoteRef":17,"id":69},[68],[10,5849,5850],{},"I decided not to use any of the themes that come with Zola, and instead use my own simple themes and templates, to keep it even more minimalistic.",[10,5852,5853,5854,179],{},"If you're interested, the source code of my website is public, and can be seen in ",[45,5855,5858],{"href":5856,"rel":5857},"https://github.com/HanKruiger/hankruiger.com",[49],"this repository",[2047,5860,5862,5865],{"className":5861,"dataFootnotes":17},[2044],[84,5863,2045],{"className":5864,"id":68},[2053],[2055,5866,5867],{},[984,5868,5869,5870,5875,5876,5881,5882],{"id":2059},"I use ",[45,5871,5874],{"href":5872,"rel":5873},"https://netlify.com/",[49],"Netlify"," for this. It's free. For my domain name, I used ",[45,5877,5880],{"href":5878,"rel":5879},"https://hover.com/",[49],"Hover",", which is not free, but really easy to use. ",[45,5883,2073],{"href":2069,"ariaLabel":2070,"className":5884,"dataFootnoteBackref":17},[2072],{"title":17,"searchDepth":18,"depth":18,"links":5886},[5887],{"id":68,"depth":18,"text":2045},"2019-08-17","Blogging about blogging.",{},"/posts/dumbing-down-my-website",{"title":5810,"description":5889},"2.posts/20190817.Dumbing Down my Website","i-pat84g0fn4wPOqPFH19f_SzyKtUqT_eGjq25dT2Rw",{"id":5896,"title":5897,"body":5898,"created":5953,"description":5954,"extension":22,"meta":5955,"navigation":24,"path":5956,"seo":5957,"stem":5958,"updated":20,"__hash__":5959},"posts/2.posts/20180310.Conway Checkers.md","Conway Checkers",{"type":7,"value":5899,"toc":5951},[5900,5909,5917,5920,5935,5938],[10,5901,5902,5903,5908],{},"Conway Checkers (or: ",[45,5904,5907],{"href":5905,"rel":5906},"https://en.wikipedia.org/wiki/Conway%27s_Soldiers",[49],"Conway's Soldiers",") is a single player game on a checkerboard with an interesting twist: the board is infinite.",[10,5910,5911,5912,179],{},"Infinite objects aren't really compatible with physical space, so I made ",[45,5913,5916],{"href":5914,"rel":5915},"https://demos.hankruiger.com/conway-checkers/",[49],"a thing where you can play it",[10,5918,5919],{},"The rules are simple:",[2055,5921,5922,5925,5932],{},[984,5923,5924],{},"You set up the game by placing your pieces below a horizontal line. You can use any number of pieces you like.",[984,5926,5927,5928,5931],{},"The goal is to move any of your pieces ",[833,5929,5930],{},"above"," the line as far as possible.",[984,5933,5934],{},"The only legal move is to—vertically or horizontally—jump over an adjacent piece to an empty spot. The adjacent piece dies during this process.",[10,5936,5937],{},"I challenge you to beat my highscore: 4.",[10,5939,5940,5941,3144,5946,179],{},"If you want to learn more, check out ",[45,5942,5945],{"href":5943,"rel":5944},"https://youtu.be/FtNWzlfEQgY",[49],"the video that introduced me to it",[45,5947,5950],{"href":5948,"rel":5949},"https://youtu.be/Or0uWM9bT5w",[49],"the video that proves you can never beat me",{"title":17,"searchDepth":18,"depth":18,"links":5952},[],"2018-03-10","A checkers-like game on an infinite board.",{},"/posts/conway-checkers",{"title":5897,"description":5954},"2.posts/20180310.Conway Checkers","qNE7two4oKhFOIL-tEqisSJ5N5WUxyDyl_VomWsQz6A",{"id":5961,"title":5962,"body":5963,"created":5988,"description":5989,"extension":22,"meta":5990,"navigation":24,"path":5991,"seo":5992,"stem":5993,"updated":20,"__hash__":5994},"posts/2.posts/20160316.Graph Editor.md","Graph Editor",{"type":7,"value":5964,"toc":5986},[5965,5968,5977],[10,5966,5967],{},"Large networks (loosely known as graphs in mathematics) can be hard to visualise.",[10,5969,5970,5971,5976],{},"An intuitive method is to draw them as node-link diagrams.\nIn a node-link diagram, the network entities are drawn as nodes, and the connections are drawn as lines.\nTo draw these things, we need to find proper positions for the nodes.\nThis is where ",[45,5972,5975],{"href":5973,"rel":5974},"https://en.wikipedia.org/wiki/Graph_drawing#Layout_methods",[49],"graph layouts"," come into play.",[10,5978,5979,5980,5985],{},"A common way to lay out the nodes is to use a force-based simulation, and treat the nodes and links as a spring-mass system.\nWhile learning about this, I made ",[45,5981,5984],{"href":5982,"rel":5983},"https://demos.hankruiger.com/graph-editor/",[49],"a thing"," that demonstrates it (but is otherwise completely useless).",{"title":17,"searchDepth":18,"depth":18,"links":5987},[],"2016-03-16","Visualising and editing graphs using a web application.",{},"/posts/graph-editor",{"title":5962,"description":5989},"2.posts/20160316.Graph Editor","PxcskW40c0PN66Wt353s8oT3fXB6ymimAiaFylvgjpY",{"id":5996,"title":5997,"body":5998,"created":6020,"description":6021,"extension":22,"meta":6022,"navigation":24,"path":6023,"seo":6024,"stem":6025,"updated":20,"__hash__":6026},"posts/2.posts/20160119.Kapitzas Pendulum.md","Kapitza's Pendulum",{"type":7,"value":5999,"toc":6018},[6000,6003],[10,6001,6002],{},"When you hold a pendulum upright—with its mass above the pivot point—it will tumble down, right?",[10,6004,6005,6006,6011,6012,6017],{},"Well, if you vertically shake it at ",[45,6007,6010],{"href":6008,"rel":6009},"https://en.wikipedia.org/wiki/Kapitza%27s_pendulum#Equilibrium_positions",[49],"just the right frequency"," it won't, and ",[45,6013,6016],{"href":6014,"rel":6015},"https://demos.hankruiger.com/inverted-pendulum",[49],"this simulation"," demonstrates that!",{"title":17,"searchDepth":18,"depth":18,"links":6019},[],"2016-01-19","Stabilising a pendulum by shaking it vertically.",{},"/posts/kapitzas-pendulum",{"title":5997,"description":6021},"2.posts/20160119.Kapitzas Pendulum","HIpAiGurMXq4eXvEDpL-g70mdP-66H8CXPxXlVc-HhE",1775234851758]